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:
@@ -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')}"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user