DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.remote {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.remoteTitle {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.spacer12 {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.spacer8 {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.spacer10 {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.effectsStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.iconRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconGlyph {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clearIcon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radiusRow {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.range {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.radiusValue {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.storyWrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.storyScroll {
|
||||
flex: 0 0 70%;
|
||||
min-height: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-2);
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.historyBtn {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-radius: var(--scene-tile-radius);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.historyBtn:not(:disabled):not(.historyBtnCurrent):hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
}
|
||||
|
||||
.historyBtn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.historyBtnCurrent {
|
||||
border: 1px solid var(--scene-list-selected-border);
|
||||
background: var(--scene-list-selected-bg);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.historyBadge {
|
||||
color: var(--accent2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.historyMuted {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.historyTitle {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.emptyStory {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.rightStack {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.surfacePad {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.videoHint {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.previewFrame {
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--stroke);
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background: var(--color-overlay-dark-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.previewHost {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.brushCursor {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.3),
|
||||
0 0 18px rgba(255, 140, 40, 0.12),
|
||||
inset 0 0 18px rgba(255, 140, 40, 0.1);
|
||||
background: rgba(255, 160, 60, 0.03);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brushLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.branchTitle {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 900;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.branchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.branchCard {
|
||||
border-radius: var(--scene-tile-radius);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-2);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.branchCardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.branchOption {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.branchName {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.musicHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.musicEmpty {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.audioMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audioBadges {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.audioName {
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audioTransport {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scrubFill {
|
||||
height: 100%;
|
||||
background: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.branchEmpty {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.audioList {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 210px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.audioCard {
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-2);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.audioScrub {
|
||||
margin-top: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--stroke);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.audioScrubPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audioScrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
|
||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { Surface } from '../shared/ui/Surface';
|
||||
|
||||
import styles from './ControlApp.module.css';
|
||||
import { ControlScenePreview } from './ControlScenePreview';
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ControlApp() {
|
||||
const api = getDndApi();
|
||||
const [fxState, fx] = useEffectsState();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const historyRef = useRef<GraphNodeId[]>([]);
|
||||
const suppressNextHistoryPushRef = useRef(false);
|
||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||
const audioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||||
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||||
const [audioStateTick, setAudioStateTick] = useState(0);
|
||||
const audioLoadRunRef = useRef(0);
|
||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const brushRef = useRef<{
|
||||
tool: 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser';
|
||||
startN?: { x: number; y: number };
|
||||
points?: { x: number; y: number; tMs: number }[];
|
||||
} | null>(null);
|
||||
const [draftFxTick, setDraftFxTick] = useState(0);
|
||||
const [cursorN, setCursorN] = useState<{ x: number; y: number } | null>(null);
|
||||
const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
|
||||
const [previewContentRect, setPreviewContentRect] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then((res) => {
|
||||
const next: SessionState = {
|
||||
project: res.project,
|
||||
currentSceneId: res.project?.currentSceneId ?? null,
|
||||
};
|
||||
setSession(next);
|
||||
historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : [];
|
||||
setHistory(historyRef.current);
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
const cur = state.project?.currentGraphNodeId ?? null;
|
||||
if (!cur) return;
|
||||
const arr = historyRef.current;
|
||||
if (suppressNextHistoryPushRef.current) {
|
||||
suppressNextHistoryPushRef.current = false;
|
||||
setHistory(arr);
|
||||
return;
|
||||
}
|
||||
// Если мы перемотались на уже существующий шаг, не дублируем его в истории.
|
||||
if (arr.includes(cur)) {
|
||||
setHistory(arr);
|
||||
return;
|
||||
}
|
||||
if (arr[arr.length - 1] !== cur) {
|
||||
historyRef.current = [...arr, cur];
|
||||
setHistory(historyRef.current);
|
||||
}
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
||||
const currentScene =
|
||||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||||
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
||||
|
||||
const sceneAudios = useMemo(() => {
|
||||
if (!project) return [];
|
||||
return sceneAudioRefs
|
||||
.map((r) => {
|
||||
const a = project.assets[r.assetId];
|
||||
return a?.type === 'audio' ? { ref: r, asset: a } : null;
|
||||
})
|
||||
.filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
|
||||
Boolean(x),
|
||||
);
|
||||
}, [project, sceneAudioRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
audioLoadRunRef.current += 1;
|
||||
const runId = audioLoadRunRef.current;
|
||||
// Cleanup old audios on scene change.
|
||||
const els = audioElsRef.current;
|
||||
for (const el of els.values()) {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
els.clear();
|
||||
audioMetaRef.current.clear();
|
||||
setAudioStateTick((x) => x + 1);
|
||||
|
||||
if (!project || !currentScene) return;
|
||||
void (async () => {
|
||||
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||||
for (const item of sceneAudioRefs) {
|
||||
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||||
if (audioLoadRunRef.current !== runId) return;
|
||||
if (!r.url) continue;
|
||||
const el = new Audio(r.url);
|
||||
el.loop = item.loop;
|
||||
el.preload = 'auto';
|
||||
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('ended', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('canplay', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('error', () => setAudioStateTick((x) => x + 1));
|
||||
loaded.push({ ref: item, el });
|
||||
audioElsRef.current.set(item.assetId, el);
|
||||
}
|
||||
setAudioStateTick((x) => x + 1);
|
||||
for (const { ref, el } of loaded) {
|
||||
if (audioLoadRunRef.current !== runId) {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!ref.autoplay) continue;
|
||||
try {
|
||||
await el.play();
|
||||
} catch {
|
||||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, {
|
||||
...m,
|
||||
lastPlayError:
|
||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||
});
|
||||
setAudioStateTick((x) => x + 1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [api, currentScene, project, sceneAudioRefs]);
|
||||
|
||||
const anyPlaying = useMemo(() => {
|
||||
for (const el of audioElsRef.current.values()) {
|
||||
if (!el.paused) return true;
|
||||
}
|
||||
return false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [audioStateTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyPlaying) return;
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
setAudioStateTick((x) => x + 1);
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
};
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [anyPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = previewHostRef.current;
|
||||
if (!host) return;
|
||||
const update = () => {
|
||||
const r = host.getBoundingClientRect();
|
||||
setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) });
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
function audioStatus(assetId: string): { label: string; detail?: string } {
|
||||
const el = audioElsRef.current.get(assetId) ?? null;
|
||||
if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
|
||||
const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null };
|
||||
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
|
||||
if (el.error)
|
||||
return {
|
||||
label: 'Ошибка',
|
||||
detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`,
|
||||
};
|
||||
if (el.readyState < 2) return { label: 'Загрузка…' };
|
||||
if (!el.paused) return { label: 'Играет' };
|
||||
if (el.currentTime > 0) return { label: 'Пауза' };
|
||||
return { label: 'Остановлено' };
|
||||
}
|
||||
const nextScenes = useMemo(() => {
|
||||
if (!project) return [];
|
||||
if (!currentGraphNodeId) return [];
|
||||
const outgoing = project.sceneGraphEdges
|
||||
.filter((e) => e.sourceGraphNodeId === currentGraphNodeId)
|
||||
.map((e) => {
|
||||
const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId);
|
||||
return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null;
|
||||
})
|
||||
.filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x));
|
||||
return outgoing
|
||||
.map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] }))
|
||||
.filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
|
||||
}, [currentGraphNodeId, project]);
|
||||
|
||||
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
||||
|
||||
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
||||
const host = previewHostRef.current;
|
||||
if (!host) return null;
|
||||
const r = host.getBoundingClientRect();
|
||||
const cr = previewContentRect;
|
||||
const ox = cr ? cr.x : 0;
|
||||
const oy = cr ? cr.y : 0;
|
||||
const cw = cr ? cr.w : r.width;
|
||||
const ch = cr ? cr.h : r.height;
|
||||
const x = (e.clientX - (r.left + ox)) / Math.max(1, cw);
|
||||
const y = (e.clientY - (r.top + oy)) / Math.max(1, ch);
|
||||
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
||||
}
|
||||
|
||||
async function commitStroke(): Promise<void> {
|
||||
if (isVideoPreviewScene) {
|
||||
brushRef.current = null;
|
||||
setDraftFxTick((x) => x + 1);
|
||||
return;
|
||||
}
|
||||
if (!fxState) return;
|
||||
const b = brushRef.current;
|
||||
if (!b) return;
|
||||
const createdAtMs = Date.now();
|
||||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||||
|
||||
if (b.tool === 'fog' && b.points && b.points.length > 0) {
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `fog_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'fog',
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.05, Math.min(0.9, tool.intensity)),
|
||||
lifetimeMs: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'fire' && b.points && b.points.length > 0) {
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `fire_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'fire',
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
// Огонь визуально ярче, но всё равно ограничиваемся безопасными пределами.
|
||||
opacity: Math.max(0.12, Math.min(0.95, tool.intensity)),
|
||||
lifetimeMs: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `rain_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'rain',
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.08, Math.min(0.9, tool.intensity)),
|
||||
lifetimeMs: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return;
|
||||
const end = { x: last.x, y: last.y };
|
||||
const start = { x: end.x, y: 0 };
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `lt_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'lightning',
|
||||
seed,
|
||||
createdAtMs,
|
||||
start,
|
||||
end,
|
||||
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
||||
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
|
||||
lifetimeMs: 180,
|
||||
},
|
||||
});
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `sc_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'scorch',
|
||||
seed: seed ^ 0x5a5a5a,
|
||||
createdAtMs,
|
||||
at: end,
|
||||
radiusN: Math.max(0.03, tool.radiusN * 0.625),
|
||||
opacity: 0.92,
|
||||
lifetimeMs: 60_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return;
|
||||
const at = { x: last.x, y: last.y };
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `fr_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'freeze',
|
||||
seed,
|
||||
createdAtMs,
|
||||
at,
|
||||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||||
// Быстро появиться → чуть задержаться → плавно исчезнуть.
|
||||
lifetimeMs: 820,
|
||||
},
|
||||
});
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `ice_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'ice',
|
||||
seed: seed ^ 0x33cc99,
|
||||
createdAtMs,
|
||||
at,
|
||||
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||||
opacity: 0.85,
|
||||
lifetimeMs: 60_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
brushRef.current = null;
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}
|
||||
const draftInstance = useMemo(() => {
|
||||
const b = brushRef.current;
|
||||
if (!b) return null;
|
||||
const seed = 12345;
|
||||
const createdAtMs = Date.now();
|
||||
if (b.tool === 'fog' && b.points && b.points.length > 0) {
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'fog' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)),
|
||||
lifetimeMs: null,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'fire' && b.points && b.points.length > 0) {
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'fire' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.12, Math.min(0.75, tool.intensity * 0.85)),
|
||||
lifetimeMs: null,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'rain' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)),
|
||||
lifetimeMs: null,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return null;
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'lightning' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
start: { x: last.x, y: 0 },
|
||||
end: { x: last.x, y: last.y },
|
||||
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
||||
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
|
||||
lifetimeMs: 180,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return null;
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'freeze' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
at: { x: last.x, y: last.y },
|
||||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||||
lifetimeMs: 240,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftFxTick, tool.intensity, tool.radiusN, tool.tool]);
|
||||
|
||||
const fxMergedState = useMemo(() => {
|
||||
if (!fxState) return null;
|
||||
if (!draftInstance) return fxState;
|
||||
return { ...fxState, instances: [...fxState.instances, draftInstance] };
|
||||
}, [draftInstance, fxState]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Surface className={styles.remote}>
|
||||
<div className={styles.remoteTitle}>ПУЛЬТ УПРАВЛЕНИЯ</div>
|
||||
<div className={styles.spacer12} />
|
||||
{!isVideoPreviewScene ? (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
|
||||
<div className={styles.spacer8} />
|
||||
<div className={styles.effectsStack}>
|
||||
<div className={styles.iconRow}>
|
||||
<Button
|
||||
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Туман"
|
||||
ariaLabel="Туман"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🌫️</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Огонь"
|
||||
ariaLabel="Огонь"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🔥</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Дождь"
|
||||
ariaLabel="Дождь"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🌧️</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Молния"
|
||||
ariaLabel="Молния"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>⚡</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Заморозка"
|
||||
ariaLabel="Заморозка"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>❄️</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Ластик"
|
||||
ariaLabel="Ластик"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🧹</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
title="Очистить эффекты"
|
||||
ariaLabel="Очистить эффекты"
|
||||
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
|
||||
>
|
||||
<span className={styles.clearIcon}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="12" cy="12" r="8" fill="none" stroke="#e5484d" strokeWidth="2" />
|
||||
<line
|
||||
x1="7"
|
||||
y1="17"
|
||||
x2="17"
|
||||
y2="7"
|
||||
stroke="#e5484d"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.radiusRow}>
|
||||
<div className={styles.radiusLabel}>Радиус кисти</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.015}
|
||||
max={0.18}
|
||||
step={0.001}
|
||||
value={tool.radiusN}
|
||||
onChange={(e) => {
|
||||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||||
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
|
||||
}}
|
||||
className={styles.range}
|
||||
aria-label="Радиус кисти"
|
||||
/>
|
||||
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.spacer12} />
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.storyWrap}>
|
||||
<div className={styles.sectionLabel}>СЮЖЕТНАЯ ЛИНИЯ</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.storyScroll}>
|
||||
{history.map((gnId, idx) => {
|
||||
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
||||
const s = gn ? project?.scenes[gn.sceneId] : undefined;
|
||||
const isCurrent = gnId === project?.currentGraphNodeId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${gnId}_${String(idx)}`}
|
||||
disabled={!project || isCurrent}
|
||||
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
title={project && !isCurrent ? 'Перейти к этой сцене' : undefined}
|
||||
onClick={() => {
|
||||
if (!project) return;
|
||||
if (isCurrent) return;
|
||||
// Перемотка: переходим на выбранный шаг без добавления нового пункта в историю.
|
||||
suppressNextHistoryPushRef.current = true;
|
||||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
|
||||
}}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<div className={styles.historyBadge}>ТЕКУЩАЯ СЦЕНА</div>
|
||||
) : (
|
||||
<div className={styles.historyMuted}>Пройдено</div>
|
||||
)}
|
||||
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{history.length === 0 ? <div className={styles.emptyStory}>Нет активной сцены.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<div className={styles.rightStack}>
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.previewHeader}>
|
||||
<div className={styles.previewTitle}>Предпросмотр экрана</div>
|
||||
<div className={styles.previewActions}>
|
||||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||||
Выключить демонстрацию
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
{isVideoPreviewScene ? (
|
||||
<div className={styles.videoHint}>
|
||||
Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для
|
||||
изображения).
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.previewFrame}>
|
||||
<div ref={previewHostRef} className={styles.previewHost}>
|
||||
<ControlScenePreview
|
||||
session={session}
|
||||
videoRef={previewVideoRef}
|
||||
onContentRectChange={setPreviewContentRect}
|
||||
/>
|
||||
</div>
|
||||
{!isVideoPreviewScene ? (
|
||||
<>
|
||||
<PixiEffectsOverlay
|
||||
state={fxMergedState}
|
||||
style={{ zIndex: 1 }}
|
||||
viewport={
|
||||
previewContentRect
|
||||
? {
|
||||
x: previewContentRect.x,
|
||||
y: previewContentRect.y,
|
||||
w: previewContentRect.w,
|
||||
h: previewContentRect.h,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{cursorN ? (
|
||||
<div
|
||||
className={styles.brushCursor}
|
||||
style={{
|
||||
left:
|
||||
(previewContentRect ? previewContentRect.x : 0) +
|
||||
cursorN.x * (previewContentRect ? previewContentRect.w : previewSize.w),
|
||||
top:
|
||||
(previewContentRect ? previewContentRect.y : 0) +
|
||||
cursorN.y * (previewContentRect ? previewContentRect.h : previewSize.h),
|
||||
width:
|
||||
tool.radiusN *
|
||||
Math.min(
|
||||
previewContentRect ? previewContentRect.w : previewSize.w,
|
||||
previewContentRect ? previewContentRect.h : previewSize.h,
|
||||
) *
|
||||
2,
|
||||
height:
|
||||
tool.radiusN *
|
||||
Math.min(
|
||||
previewContentRect ? previewContentRect.w : previewSize.w,
|
||||
previewContentRect ? previewContentRect.h : previewSize.h,
|
||||
) *
|
||||
2,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={styles.brushLayer}
|
||||
onPointerEnter={(e) => {
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
setCursorN(p);
|
||||
}}
|
||||
onPointerLeave={() => setCursorN(null)}
|
||||
onPointerDown={(e) => {
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
setCursorN(p);
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
if (tool.tool === 'eraser') {
|
||||
const rN = tool.radiusN;
|
||||
const nearest = (fxState?.instances ?? [])
|
||||
.map((inst) => {
|
||||
if (inst.type === 'fog') {
|
||||
const d = inst.points.reduce((best, q) => {
|
||||
const dx = q.x - p.x;
|
||||
const dy = q.y - p.y;
|
||||
const dd = dx * dx + dy * dy;
|
||||
return Math.min(best, dd);
|
||||
}, Number.POSITIVE_INFINITY);
|
||||
return { id: inst.id, dd: d };
|
||||
}
|
||||
if (inst.type === 'lightning') {
|
||||
const dx = inst.end.x - p.x;
|
||||
const dy = inst.end.y - p.y;
|
||||
return { id: inst.id, dd: dx * dx + dy * dy };
|
||||
}
|
||||
if (inst.type === 'freeze') {
|
||||
const dx = inst.at.x - p.x;
|
||||
const dy = inst.at.y - p.y;
|
||||
return { id: inst.id, dd: dx * dx + dy * dy };
|
||||
}
|
||||
return { id: inst.id, dd: Number.POSITIVE_INFINITY };
|
||||
})
|
||||
.sort((a, b) => a.dd - b.dd)[0];
|
||||
if (nearest && nearest.dd <= rN * rN) {
|
||||
void fx.dispatch({ kind: 'instance.remove', id: nearest.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
brushRef.current = {
|
||||
tool: tool.tool,
|
||||
startN: p,
|
||||
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
||||
};
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const b = brushRef.current;
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
setCursorN(p);
|
||||
if (!b?.points) return;
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (!last) return;
|
||||
const dx = p.x - last.x;
|
||||
const dy = p.y - last.y;
|
||||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||||
if (dx * dx + dy * dy < minStep * minStep) return;
|
||||
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
void commitStroke();
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
brushRef.current = null;
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.branchTitle}>Варианты ветвления</div>
|
||||
<div className={styles.branchGrid}>
|
||||
{nextScenes.map((o, i) => (
|
||||
<div key={o.graphNodeId} className={styles.branchCard}>
|
||||
<div className={styles.branchCardHeader}>
|
||||
<div className={styles.branchOption}>ОПЦИЯ {String(i + 1)}</div>
|
||||
</div>
|
||||
<div className={styles.branchName}>{o.scene.title || 'Без названия'}</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
|
||||
}
|
||||
>
|
||||
Переключить
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{nextScenes.length === 0 ? (
|
||||
<div className={styles.branchEmpty}>
|
||||
<div>Нет вариантов перехода.</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!session?.project?.currentGraphNodeId}
|
||||
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
|
||||
>
|
||||
Завершить показ
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.musicHeader}>
|
||||
<div className={styles.previewTitle}>Музыка</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
{sceneAudios.length === 0 ? (
|
||||
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
|
||||
) : (
|
||||
<div className={styles.audioList}>
|
||||
{sceneAudios.map(({ ref, asset }) => {
|
||||
const el = audioElsRef.current.get(ref.assetId) ?? null;
|
||||
const st = audioStatus(ref.assetId);
|
||||
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
|
||||
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
return (
|
||||
<div key={ref.assetId} className={styles.audioCard}>
|
||||
<div className={styles.audioMeta}>
|
||||
<div className={styles.audioName}>{asset.originalName}</div>
|
||||
<div className={styles.audioBadges}>
|
||||
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
|
||||
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
|
||||
<div title={st.detail}>{st.label}</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div
|
||||
role="slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
||||
aria-valuenow={Math.round(cur)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (!el) return;
|
||||
if (!dur) return;
|
||||
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
|
||||
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
|
||||
setAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (!el) return;
|
||||
if (!dur) return;
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const next = (e.clientX - rect.left) / rect.width;
|
||||
el.currentTime = Math.max(0, Math.min(dur, next * dur));
|
||||
setAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
className={[
|
||||
styles.audioScrub,
|
||||
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
|
||||
].join(' ')}
|
||||
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
|
||||
>
|
||||
<div
|
||||
className={styles.scrubFill}
|
||||
style={{ width: `${String(Math.round(pct * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.timeRow}>
|
||||
<div>{formatTime(cur)}</div>
|
||||
<div>{dur ? formatTime(dur) : '—:—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.audioTransport}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (!el) return;
|
||||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||||
void el.play().catch(() => {
|
||||
const mm = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, {
|
||||
...mm,
|
||||
lastPlayError: 'Не удалось запустить.',
|
||||
});
|
||||
setAudioStateTick((x) => x + 1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!el) return;
|
||||
el.pause();
|
||||
}}
|
||||
>
|
||||
❚❚
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!el) return;
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
setAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
>
|
||||
■
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Surface>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-overlay-dark-6);
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scrub {
|
||||
height: 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrubPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scrubFill {
|
||||
height: 100%;
|
||||
background: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.transport {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.transportBtn {
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke-light);
|
||||
background: var(--color-overlay-dark-3);
|
||||
color: var(--text-muted-on-dark-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
|
||||
|
||||
import styles from './ControlScenePreview.module.css';
|
||||
|
||||
type Props = {
|
||||
session: SessionState | null;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
|
||||
};
|
||||
|
||||
function fmt(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
|
||||
const [vp, video] = useVideoPlaybackState();
|
||||
const scene =
|
||||
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const dur = useMemo(
|
||||
() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return 0;
|
||||
return Number.isFinite(v.duration) ? v.duration : 0;
|
||||
},
|
||||
// tick: перечитываем duration из video ref на каждом кадре RAF
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
||||
[tick, videoRef],
|
||||
);
|
||||
const cur = useMemo(
|
||||
() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return 0;
|
||||
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
||||
[tick, videoRef],
|
||||
);
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
let raf = 0;
|
||||
const loop = () => {
|
||||
setTick((x) => x + 1);
|
||||
raf = window.requestAnimationFrame(loop);
|
||||
};
|
||||
raf = window.requestAnimationFrame(loop);
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
void video.dispatch({
|
||||
kind: 'target.set',
|
||||
assetId,
|
||||
autostart: scene.previewVideoAutostart,
|
||||
});
|
||||
}, [assetId, isVideo, scene, video]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (!vp) return;
|
||||
if (vp.targetAssetId !== assetId) return;
|
||||
v.playbackRate = vp.playbackRate;
|
||||
const desired = computeTimeSec(vp, vp.serverNowMs);
|
||||
if (Number.isFinite(desired) && Math.abs(v.currentTime - desired) > 0.25) {
|
||||
v.currentTime = Math.max(0, desired);
|
||||
}
|
||||
if (vp.playing) {
|
||||
void v.play().catch(() => undefined);
|
||||
} else {
|
||||
v.pause();
|
||||
}
|
||||
}, [assetId, vp, videoRef]);
|
||||
|
||||
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{url && scene?.previewAssetType === 'image' ? (
|
||||
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
|
||||
) : url && isVideo ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
|
||||
}}
|
||||
className={styles.video}
|
||||
src={url}
|
||||
playsInline
|
||||
preload="auto"
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
|
||||
</video>
|
||||
) : (
|
||||
<div className={styles.placeholder} />
|
||||
)}
|
||||
|
||||
{isVideo ? (
|
||||
<div className={styles.controls}>
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
||||
aria-valuenow={Math.round(cur)}
|
||||
className={scrubClass}
|
||||
onClick={(e) => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !dur) return;
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const next = (e.clientX - rect.left) / rect.width;
|
||||
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
|
||||
setTick((x) => x + 1);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!dur) return;
|
||||
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
|
||||
if (e.key === 'ArrowRight')
|
||||
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
|
||||
}}
|
||||
title="Клик — перемотка"
|
||||
>
|
||||
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.transport}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={() => void video.dispatch({ kind: 'play' })}
|
||||
title="Play"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={() => void video.dispatch({ kind: 'pause' })}
|
||||
title="Pause"
|
||||
>
|
||||
❚❚
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={() => {
|
||||
void video.dispatch({ kind: 'stop' });
|
||||
setTick((x) => x + 1);
|
||||
}}
|
||||
title="Stop"
|
||||
>
|
||||
■
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function readControlApp(): string {
|
||||
return fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
}
|
||||
|
||||
function readControlAppCss(): string {
|
||||
return fs.readFileSync(path.join(here, 'ControlApp.module.css'), 'utf8');
|
||||
}
|
||||
|
||||
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('ЭФФЕКТЫ'));
|
||||
assert.ok(src.includes('title="Туман"'));
|
||||
assert.ok(src.includes('ariaLabel="Туман"'));
|
||||
assert.ok(src.includes('iconOnly'));
|
||||
assert.ok(src.includes('title="Очистить эффекты"'));
|
||||
assert.ok(src.includes('ariaLabel="Очистить эффекты"'));
|
||||
assert.ok(src.includes('#e5484d'));
|
||||
const fx = src.indexOf('ЭФФЕКТЫ');
|
||||
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
|
||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
|
||||
assert.ok(story !== -1);
|
||||
assert.ok(src.includes('className={styles.storyScroll}'));
|
||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
});
|
||||
|
||||
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
assert.ok(!src.includes("tool.tool === 'eraser' ? 'not-allowed'"));
|
||||
assert.ok(src.includes('className={styles.brushLayer}'));
|
||||
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
|
||||
});
|
||||
|
||||
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
|
||||
const src = readControlApp();
|
||||
const previewLabel = src.indexOf('Предпросмотр экрана');
|
||||
const radius = src.indexOf('Радиус кисти');
|
||||
assert.ok(previewLabel !== -1 && radius !== -1);
|
||||
assert.ok(
|
||||
radius < previewLabel,
|
||||
'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { ControlApp } from './ControlApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<ControlApp />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user