feat(scenes): add scene darkness reveal and fix project reopen crash

Add darkenScene with Opening brush in presentation, persist reveal strokes per scene during a session, and close projects properly on return to home. Harden dnd asset streaming to avoid Windows main-process crashes when reopening projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-02 19:41:26 +08:00
parent d54e9ed02d
commit 4631a1bece
19 changed files with 526 additions and 11 deletions
+72 -1
View File
@@ -7,7 +7,9 @@ import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
@@ -49,6 +51,7 @@ export function ControlApp() {
const tRef = useRef(t);
tRef.current = t;
const [fxState, fx] = useEffectsState();
const [sdState, sd] = useSceneDarknessState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
@@ -69,7 +72,18 @@ export function ControlApp() {
const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const brushRef = useRef<{
tool: 'fog' | 'fire' | 'rain' | 'water' | 'darkness' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser';
tool:
| 'fog'
| 'fire'
| 'rain'
| 'water'
| 'darkness'
| 'lightning'
| 'sunbeam'
| 'poisonCloud'
| 'freeze'
| 'exploreBrush'
| 'eraser';
startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[];
} | null>(null);
@@ -145,6 +159,7 @@ export function ControlApp() {
const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene;
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
// Keep this memo as narrow as possible: project changes on scene switch,
// but campaign audio list/config often does not.
@@ -609,6 +624,13 @@ export function ControlApp() {
draftPaintRafRef.current = requestAnimationFrame(() => {
draftPaintRafRef.current = 0;
setDraftFxTick((x) => x + 1);
const b = brushRef.current;
if (b?.tool === 'exploreBrush' && b.points) {
void sd.dispatch({
kind: 'draft.set',
draft: { points: b.points, radiusN: toolRef.current.radiusN },
});
}
});
}
@@ -711,6 +733,18 @@ export function ControlApp() {
},
});
}
if (b.tool === 'exploreBrush' && b.points && b.points.length > 0) {
await sd.dispatch({
kind: 'stroke.add',
stroke: {
id: `sd_${String(createdAtMs)}_${String(seed)}`,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
},
});
}
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
@@ -1137,6 +1171,24 @@ export function ControlApp() {
</Button>
</div>
</div>
{isDarkenScene ? (
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>{t('control.darknessControl')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'exploreBrush' ? 'primary' : 'ghost'}
iconOnly
title={t('control.explorerBrush')}
ariaLabel={t('control.explorerBrush')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'exploreBrush' } })
}
>
<span className={styles.iconGlyph}>🔦</span>
</Button>
</div>
</div>
) : null}
<div className={styles.radiusRow}>
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
<input
@@ -1235,6 +1287,13 @@ export function ControlApp() {
: undefined
}
/>
{previewContentRect ? (
<SceneDarknessOverlay
state={sdState}
overlayAlpha={0.5}
viewport={previewContentRect}
/>
) : null}
<div
ref={brushCursorElRef}
className={styles.brushCursor}
@@ -1269,6 +1328,15 @@ export function ControlApp() {
startN: p,
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
};
if (tool.tool === 'exploreBrush') {
void sd.dispatch({
kind: 'draft.set',
draft: {
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
radiusN: tool.radiusN,
},
});
}
setDraftFxTick((x) => x + 1);
}}
onPointerMove={(e) => {
@@ -1296,6 +1364,9 @@ export function ControlApp() {
void commitStroke();
}}
onPointerCancel={() => {
if (brushRef.current?.tool === 'exploreBrush') {
void sd.dispatch({ kind: 'draft.set', draft: null });
}
brushRef.current = null;
setDraftFxTick((x) => x + 1);
}}
@@ -51,6 +51,10 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
assert.ok(src.includes("t('control.tools')"));
assert.ok(src.includes("t('control.fieldEffects')"));
assert.ok(src.includes("t('control.actionEffects')"));
assert.ok(src.includes("t('control.darknessControl')"));
assert.ok(src.includes("t('control.explorerBrush')"));
assert.ok(src.includes('SceneDarknessOverlay'));
assert.ok(src.includes('useSceneDarknessState'));
assert.ok(src.includes("t('control.sunbeam')"));
assert.ok(src.includes("title={t('control.water')}"));
assert.ok(src.includes("title={t('control.darkness')}"));
+21
View File
@@ -623,6 +623,7 @@ export function EditorApp() {
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
@@ -632,6 +633,9 @@ export function EditorApp() {
onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next })
}
onDarkenSceneChange={(next) =>
void actions.updateScene(sid, { darkenScene: next })
}
onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) =>
void actions.updateScene(sid, { description })
@@ -1681,11 +1685,13 @@ type SceneInspectorProps = {
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean;
previewBusy: boolean;
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void;
onDarkenSceneChange: (next: boolean) => void;
onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
onImportPreview: () => void;
@@ -1788,11 +1794,13 @@ function SceneInspector({
previewAssetType,
previewVideoAutostart,
previewRotationDeg,
darkenScene,
previewBusy,
mediaAssets,
audioRefs,
onAudioRefsChange,
onPreviewVideoAutostartChange,
onDarkenSceneChange,
onTitleChange,
onDescriptionChange,
onImportPreview,
@@ -1877,6 +1885,19 @@ function SceneInspector({
</Button>
) : null}
</div>
{previewAssetId && previewAssetType === 'image' ? (
<>
<div className={styles.spacer6} />
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={darkenScene}
onChange={(e) => onDarkenSceneChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
</label>
</>
) : null}
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.audio')}</div>
<div className={styles.audioDrop}>
@@ -281,6 +281,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.change': 'Изменить',
'scene.clear': 'Очистить',
'scene.autostart': 'Автостарт',
'scene.darkenScene': 'Затемнить сцену',
'scene.rotate': 'Повернуть',
'scene.audio': 'АУДИО СЦЕНЫ',
'scene.removeTitle': 'Убрать из сцены',
@@ -319,6 +320,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.fire': 'Огонь',
'control.water': 'Вода',
'control.darkness': 'Тьма',
'control.darknessControl': 'Управление затемнением',
'control.explorerBrush': 'Кисть Открытия',
'control.lightning': 'Молния',
'control.sunbeam': 'Луч света',
'control.freeze': 'Заморозка',
@@ -601,6 +604,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.change': 'Change',
'scene.clear': 'Clear',
'scene.autostart': 'Autostart',
'scene.darkenScene': 'Darken scene',
'scene.rotate': 'Rotate',
'scene.audio': 'SCENE AUDIO',
'scene.removeTitle': 'Remove from scene',
@@ -639,6 +643,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.fire': 'Fire',
'control.water': 'Water',
'control.darkness': 'Darkness',
'control.darknessControl': 'Darkness control',
'control.explorerBrush': 'Opening brush',
'control.lightning': 'Lightning',
'control.sunbeam': 'Sunbeam',
'control.freeze': 'Freeze',
@@ -32,6 +32,7 @@ type Actions = {
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
@@ -140,6 +141,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
};
const closeProject = async () => {
await api.invoke(ipcChannels.project.close, {});
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
await refreshProjects();
};
@@ -157,6 +159,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
connections: [],
@@ -212,6 +215,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
@@ -237,6 +241,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
...(patch.previewRotationDeg !== undefined
? { previewRotationDeg: patch.previewRotationDeg }
: null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
+6
View File
@@ -4,7 +4,9 @@ import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './effects/useSceneDarknessState';
import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
@@ -25,6 +27,7 @@ export function PresentationView({
showEffects = true,
}: PresentationViewProps) {
const [fxState] = useEffectsState();
const [sdState] = useSceneDarknessState();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -132,6 +135,9 @@ export function PresentationView({
}
/>
) : null}
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
) : null}
{showTitle ? (
<div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}>
@@ -0,0 +1,96 @@
import React, { useEffect, useRef } from 'react';
import type { SceneDarknessRevealStroke, SceneDarknessState } from '../../../shared/types';
export type SceneDarknessOverlayProps = {
state: SceneDarknessState | null;
viewport?: { x: number; y: number; w: number; h: number };
/** 1.0 — полностью чёрный (Presentation); 0.5 — полупрозрачный (Control). */
overlayAlpha: number;
style?: React.CSSProperties;
};
function drawRevealStroke(
ctx: CanvasRenderingContext2D,
stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number },
w: number,
h: number,
): void {
const pts = stroke.points;
if (pts.length === 0) return;
const r = stroke.radiusN * Math.min(w, h);
if (pts.length === 1) {
const p = pts[0];
if (!p) return;
ctx.beginPath();
ctx.arc(p.x * w, p.y * h, r, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = r * 2;
ctx.strokeStyle = 'rgba(0,0,0,1)';
ctx.beginPath();
const first = pts[0];
if (!first) return;
ctx.moveTo(first.x * w, first.y * h);
for (let i = 1; i < pts.length; i++) {
const p = pts[i];
if (!p) continue;
ctx.lineTo(p.x * w, p.y * h);
}
ctx.stroke();
}
export function SceneDarknessOverlay({
state,
viewport,
overlayAlpha,
style,
}: SceneDarknessOverlayProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !state?.enabled || !viewport) return;
const w = Math.max(1, Math.round(viewport.w));
const h = Math.max(1, Math.round(viewport.h));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'destination-out';
for (const stroke of state.strokes) {
drawRevealStroke(ctx, stroke, w, h);
}
if (state.draft && state.draft.points.length > 0) {
drawRevealStroke(ctx, state.draft, w, h);
}
}, [state, viewport]);
if (!state?.enabled || !viewport) return null;
return (
<canvas
ref={canvasRef}
aria-hidden
style={{
position: 'absolute',
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
opacity: overlayAlpha,
pointerEvents: 'none',
zIndex: 2,
...style,
}}
/>
);
}
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { SceneDarknessEvent, SceneDarknessState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useSceneDarknessState(): readonly [
SceneDarknessState | null,
{ dispatch: (event: SceneDarknessEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<SceneDarknessState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.sceneDarkness.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.sceneDarkness.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.sceneDarkness.dispatch, { event });
},
},
] as const;
}