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
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneDarknessStore } from './sceneDarknessStore';
void test('SceneDarknessStore: сохраняет штрихи при переключении сцен', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
},
});
store.switchScene('scene_b', true);
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_a', true);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 's1');
});
void test('SceneDarknessStore: resetSession очищает кэш', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
radiusN: 0.08,
},
});
store.resetSession();
store.switchScene('scene_a', true);
assert.equal(store.getState().strokes.length, 0);
});
void test('SceneDarknessStore: draft синхронизируется и сбрасывается при commit', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'draft.set',
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05 },
});
assert.ok(store.getState().draft);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.1, y: 0.1, tMs: 1 }],
radiusN: 0.05,
},
});
assert.equal(store.getState().draft, null);
assert.equal(store.getState().strokes.length, 1);
});
+80
View File
@@ -0,0 +1,80 @@
import type { SceneDarknessEvent, SceneDarknessRevealStroke, SceneDarknessState } from '../../shared/types';
function emptyState(): SceneDarknessState {
return {
revision: 1,
enabled: false,
cacheKey: null,
strokes: [],
draft: null,
};
}
export class SceneDarknessStore {
private state: SceneDarknessState = emptyState();
/** Кэш раскрытых областей по ключу (graphNodeId или sceneId) на время сессии показа. */
private cache = new Map<string, SceneDarknessRevealStroke[]>();
private currentKey: string | null = null;
getState(): SceneDarknessState {
return { ...this.state, strokes: [...this.state.strokes] };
}
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/**
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
*/
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]);
}
this.currentKey = cacheKey;
const strokes =
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
revision: this.state.revision + 1,
enabled,
cacheKey,
strokes,
draft: null,
};
return this.getState();
}
dispatch(event: SceneDarknessEvent): SceneDarknessState {
if (!this.state.enabled) return this.getState();
switch (event.kind) {
case 'draft.set':
this.state = {
...this.state,
revision: this.state.revision + 1,
draft: event.draft,
};
return this.getState();
case 'stroke.add': {
const strokes = [...this.state.strokes, event.stroke];
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return this.getState();
}
}
}
}
+50
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
import type { Project } from '../shared/types';
import {
PROJECT_ZIP_OPEN_DIALOG_FILTER,
PROJECT_ZIP_SAVE_DIALOG_FILTER,
@@ -11,6 +12,7 @@ import {
} from '../shared/project/projectZipExtension';
import { EffectsStore } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { ZipProjectStore } from './project/zipStore';
@@ -117,6 +119,7 @@ function installAppMenuForSession(): void {
}
const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore();
const videoStore = new VideoPlaybackStore();
function emitEffectsState(): void {
@@ -126,6 +129,20 @@ function emitEffectsState(): void {
}
}
function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneDarkness.stateChanged, { state });
}
}
function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
const enabled = Boolean(scene?.darkenScene) && scene?.previewAssetType === 'image';
sceneDarknessStore.switchScene(cacheKey, enabled);
}
function emitVideoState(): void {
const state = videoStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -267,7 +284,11 @@ async function main() {
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, () => {
sceneDarknessStore.resetSession();
openMultiWindow();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitSceneDarknessState();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
@@ -303,6 +324,15 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.close, async () => {
await projectStore.closeOpenProject();
effectsStore.clear();
sceneDarknessStore.resetSession();
emitEffectsState();
emitSceneDarknessState();
emitSessionState();
return { ok: true };
});
registerHandler(ipcChannels.project.get, () => {
return { project: projectStore.getOpenProject() };
});
@@ -313,7 +343,10 @@ async function main() {
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitSceneDarknessState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
});
@@ -327,7 +360,10 @@ async function main() {
currentSceneId: gn ? gn.sceneId : null,
}));
effectsStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitSceneDarknessState();
emitSessionState();
const p = projectStore.getOpenProject();
return {
@@ -337,6 +373,11 @@ async function main() {
});
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
const next = await projectStore.updateScene(sceneId, patch);
const project = projectStore.getOpenProject();
if (project && project.currentSceneId === sceneId && patch.darkenScene !== undefined) {
syncSceneDarknessForProject(project);
emitSceneDarknessState();
}
emitSessionState();
return { scene: next };
});
@@ -524,6 +565,15 @@ async function main() {
return { ok: true };
});
registerHandler(ipcChannels.sceneDarkness.getState, () => {
return { state: sceneDarknessStore.getState() };
});
registerHandler(ipcChannels.sceneDarkness.dispatch, ({ event }) => {
sceneDarknessStore.dispatch(event);
emitSceneDarknessState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() };
});
+14
View File
@@ -457,6 +457,7 @@ export class ZipProjectStore {
previewThumbAssetId: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
} satisfies Scene);
const next: Scene = {
@@ -472,6 +473,7 @@ export class ZipProjectStore {
? { previewVideoAutostart: patch.previewVideoAutostart }
: null),
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
@@ -760,6 +762,16 @@ export class ZipProjectStore {
await this.packZipExclusive(open.cacheDir, open.zipPath);
}
async closeOpenProject(): Promise<void> {
if (!this.openProject) return;
await this.saveNow();
await this.waitWhilePacking();
await this.projectWriteChain;
this.saveQueued = false;
this.openProject = null;
this.projectSession += 1;
}
async renameOpenProject(name: string, fileBaseName: string): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
@@ -1123,6 +1135,7 @@ function normalizeScene(s: Scene): Scene {
);
const previewThumbAssetId =
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
const audios = rawAudios
@@ -1150,6 +1163,7 @@ function normalizeScene(s: Scene): Scene {
previewThumbAssetId,
previewVideoAutostart,
previewRotationDeg,
darkenScene,
layout: layoutIn ?? { x: 0, y: 0 },
media: {
videos: raw.videos ?? [],
+25 -9
View File
@@ -33,22 +33,39 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
const start = Number(m[1]);
const endRaw = m[2] ? Number(m[2]) : total - 1;
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
return new Response(null, { status: 416 });
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const len = end - start + 1;
const fh = await fs.open(info.absPath, 'r');
try {
const buf = Buffer.alloc(len);
await fh.read(buf, 0, len, start);
return new Response(buf, {
const { bytesRead } = await fh.read(buf, 0, len, start);
if (bytesRead <= 0) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
const actualEnd = start + bytesRead - 1;
return new Response(body, {
status: 206,
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
'Content-Length': String(len),
'Cache-Control': 'public, max-age=300',
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
'Content-Length': String(body.length),
'Cache-Control': 'no-store',
},
});
} finally {
@@ -62,8 +79,7 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Length': String(buf.length),
'Cache-Control': 'public, max-age=300',
'Cache-Control': 'no-store',
},
});
} catch {