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:
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user