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(null); const historyRef = useRef([]); const suppressNextHistoryPushRef = useRef(false); const [history, setHistory] = useState([]); const audioElsRef = useRef>(new Map()); const audioMetaRef = useRef>(new Map()); const [audioStateTick, setAudioStateTick] = useState(0); const audioLoadRunRef = useRef(0); const previewHostRef = useRef(null); const previewVideoRef = useRef(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['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 { 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 (
ПУЛЬТ УПРАВЛЕНИЯ
{!isVideoPreviewScene ? ( <>
ЭФФЕКТЫ
Радиус кисти
{ 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="Радиус кисти" />
{Math.round(tool.radiusN * 100)}
) : null}
СЮЖЕТНАЯ ЛИНИЯ
{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 ( ); })} {history.length === 0 ?
Нет активной сцены.
: null}
Предпросмотр экрана
{isVideoPreviewScene ? (
Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).
) : null}
{!isVideoPreviewScene ? ( <> {cursorN ? (
) : null}
{ 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}
Варианты ветвления
{nextScenes.map((o, i) => (
ОПЦИЯ {String(i + 1)}
{o.scene.title || 'Без названия'}
))} {nextScenes.length === 0 ? (
Нет вариантов перехода.
) : null}
Музыка
{sceneAudios.length === 0 ? (
В текущей сцене нет аудио.
) : (
{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 (
{asset.originalName}
{ref.autoplay ? 'Авто' : 'Ручн.'}
{ref.loop ? 'Цикл' : 'Один раз'}
{st.label}
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 ? 'Клик — перемотка' : 'Длительность неизвестна'} >
{formatTime(cur)}
{dur ? formatTime(dur) : '—:—'}
); })}
)}
); }