DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 14:16:54 +08:00
commit a6cbcc273e
82 changed files with 22195 additions and 0 deletions
+359
View File
@@ -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;
}
+887
View File
@@ -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,
'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)',
);
});
+16
View File
@@ -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>,
);