feat(editor): show scene preview immediately and optimize in background

Return control after copying the original asset, then run image optimization
and thumbnail generation in the background with IPC progress updates.
Block duplicate preview uploads and scene creation while requests are in flight.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-03 22:38:51 +08:00
parent 83a326b0b9
commit 10bb7013e6
9 changed files with 514 additions and 209 deletions
@@ -17,7 +17,7 @@ void test('projectState: list/get after delete invalidates in-flight initial loa
);
assert.match(
src,
/const openProject = async[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?await api\.invoke/,
/const openProject = async[\s\S]+?openInFlightRef\.current[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?await api\.invoke/,
);
assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/);
});
+119 -37
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi';
@@ -10,7 +10,13 @@ type State = {
projects: ProjectSummary[];
project: Project | null;
selectedSceneId: SceneId | null;
openingProjectId: ProjectId | null;
creatingScene: boolean;
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
scenePreviewImports: Record<
SceneId,
{ assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
>;
};
type Actions = {
@@ -40,7 +46,7 @@ type Actions = {
) => Promise<void>;
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
importMediaToScene: (sceneId: SceneId) => Promise<void>;
importScenePreview: (sceneId: SceneId) => Promise<void>;
importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>;
clearScenePreview: (sceneId: SceneId) => Promise<void>;
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise<void>;
@@ -75,9 +81,17 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
projects: [],
project: null,
selectedSceneId: null,
openingProjectId: null,
creatingScene: false,
zipProgress: null,
scenePreviewImports: {} as Record<
SceneId,
{ assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
>,
});
const projectRef = useRef<Project | null>(null);
const openInFlightRef = useRef<Promise<void> | null>(null);
const createSceneInFlightRef = useRef<Promise<void> | null>(null);
/** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */
const projectDataEpochRef = useRef(0);
useEffect(() => {
@@ -115,9 +129,40 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
}
});
const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => {
setState((s) => {
const nextImports = { ...s.scenePreviewImports };
nextImports[evt.sceneId] = {
assetId: evt.assetId,
phase: evt.phase,
...(evt.message ? { message: evt.message } : null),
};
return {
...s,
...(evt.project ? { project: evt.project } : null),
scenePreviewImports: nextImports,
};
});
if (evt.phase === 'done' || evt.phase === 'error') {
setTimeout(
() => {
setState((s) => {
const cur = s.scenePreviewImports[evt.sceneId];
if (cur?.assetId !== evt.assetId || cur.phase !== evt.phase) return s;
const nextImports = Object.fromEntries(
Object.entries(s.scenePreviewImports).filter(([sceneId]) => sceneId !== evt.sceneId),
) as State['scenePreviewImports'];
return { ...s, scenePreviewImports: nextImports };
});
},
evt.phase === 'done' ? 1000 : 4000,
);
}
});
return () => {
offImport();
offExport();
offPreview();
};
}, [api]);
@@ -135,9 +180,26 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
};
const openProject = async (id: ProjectId) => {
projectDataEpochRef.current += 1;
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
if (openInFlightRef.current) return openInFlightRef.current;
const job = (async () => {
setState((s) => ({ ...s, openingProjectId: id }));
try {
projectDataEpochRef.current += 1;
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
openingProjectId: null,
}));
} catch {
setState((s) => ({ ...s, openingProjectId: null }));
}
})();
openInFlightRef.current = job.finally(() => {
if (openInFlightRef.current === job) openInFlightRef.current = null;
});
return openInFlightRef.current;
};
const closeProject = async () => {
@@ -147,41 +209,51 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
};
const createScene = async () => {
if (createSceneInFlightRef.current) return createSceneInFlightRef.current;
const p = projectRef.current;
if (!p) return;
const sceneId = randomId('scene') as SceneId;
const scene: Scene = {
id: sceneId,
title: `Новая сцена`,
description: '',
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
connections: [],
layout: { x: 0, y: 0 },
};
await api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: {
title: scene.title,
description: scene.description,
media: scene.media,
settings: scene.settings,
layout: scene.layout,
previewAssetId: scene.previewAssetId,
previewAssetType: scene.previewAssetType,
previewVideoAutostart: scene.previewVideoAutostart,
},
const job = (async () => {
setState((s) => ({ ...s, creatingScene: true }));
const sceneId = randomId('scene') as SceneId;
const scene: Scene = {
id: sceneId,
title: `Новая сцена`,
description: '',
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
connections: [],
layout: { x: 0, y: 0 },
};
await api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: {
title: scene.title,
description: scene.description,
media: scene.media,
settings: scene.settings,
layout: scene.layout,
previewAssetId: scene.previewAssetId,
previewAssetType: scene.previewAssetType,
previewVideoAutostart: scene.previewVideoAutostart,
},
});
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
})();
createSceneInFlightRef.current = job.finally(() => {
if (createSceneInFlightRef.current === job) {
createSceneInFlightRef.current = null;
setState((s) => ({ ...s, creatingScene: false }));
}
});
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
return createSceneInFlightRef.current;
};
const selectScene = async (id: SceneId) => {
@@ -276,7 +348,17 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
const importScenePreview = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
setState((s) => ({ ...s, project: res.project }));
if (res.assetId !== null && res.background) {
setState((s) => ({
...s,
scenePreviewImports: {
...s.scenePreviewImports,
[sceneId]: { assetId: res.assetId, phase: 'queued' },
},
}));
}
await refreshProjects();
return { assetId: res.assetId, background: res.background };
};
const clearScenePreview = async (sceneId: SceneId) => {