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:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() };
|
||||
});
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user