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
+10
View File
@@ -581,6 +581,16 @@
border-radius: 10px;
}
.projectCardBodyOpening {
cursor: wait;
opacity: 0.72;
}
.projectCardBodyDisabled {
cursor: default;
opacity: 0.55;
}
.projectCardMenuBtn {
flex-shrink: 0;
margin: -4px -4px 0 0;
+115 -60
View File
@@ -9,7 +9,15 @@ import {
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
import type { AssetId, GraphNodeId, MediaAsset, Project, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
import type {
AssetId,
GraphNodeId,
MediaAsset,
Project,
ProjectId,
SceneAudioRef,
SceneId,
} from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
@@ -17,6 +25,7 @@ import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css';
import { buildNextSceneCardById } from './graph/sceneCardById';
import {
@@ -27,7 +36,6 @@ import {
} from './graph/SceneGraph';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import type { ProjectNoticeCode } from './state/projectState';
import { useProjectState } from './state/projectState';
@@ -82,7 +90,7 @@ export function EditorApp() {
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [previewBusy, setPreviewBusy] = useState(false);
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
const [presentationOpen, setPresentationOpen] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false);
@@ -528,7 +536,11 @@ export function EditorApp() {
<>
<div className={styles.gridTools}>
<Input value={query} onChange={setQuery} placeholder={t('scenes.search')} />
<Button variant="primary" onClick={() => void actions.createScene()}>
<Button
variant="primary"
disabled={state.creatingScene}
onClick={() => void actions.createScene()}
>
{t('scenes.new')}
</Button>
</div>
@@ -550,6 +562,7 @@ export function EditorApp() {
<ProjectPicker
projects={state.projects}
licenseActive={licenseActive}
openingProjectId={state.openingProjectId}
onCreate={actions.createProject}
onOpen={actions.openProject}
onDelete={actions.deleteProject}
@@ -615,6 +628,18 @@ export function EditorApp() {
const proj = state.project;
const sid = state.selectedSceneId;
const sc = proj.scenes[sid];
const previewImport = state.scenePreviewImports[sid] ?? null;
const previewBusy = previewDialogSceneId === sid || previewImport !== null;
const previewBusyText =
previewDialogSceneId === sid
? t('scene.previewBusySelecting')
: previewImport?.phase === 'done'
? t('scene.previewReady')
: previewImport?.phase === 'error'
? t('scene.previewFailed')
: previewImport !== null
? t('scene.previewOptimizing')
: t('scene.previewBusy');
return (
<SceneInspector
title={sc?.title ?? ''}
@@ -625,6 +650,7 @@ export function EditorApp() {
previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy}
previewBusyText={previewBusyText}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) =>
@@ -633,15 +659,14 @@ export function EditorApp() {
onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next })
}
onDarkenSceneChange={(next) =>
void actions.updateScene(sid, { darkenScene: next })
}
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) =>
void actions.updateScene(sid, { description })
}
onImportPreview={() => {
setPreviewBusy(true);
if (previewBusy) return;
setPreviewDialogSceneId(sid);
void (async () => {
try {
await actions.importScenePreview(sid);
@@ -651,7 +676,7 @@ export function EditorApp() {
message: e instanceof Error ? e.message : String(e),
});
} finally {
setPreviewBusy(false);
setPreviewDialogSceneId((cur) => (cur === sid ? null : cur));
}
})();
}}
@@ -805,11 +830,7 @@ export function EditorApp() {
onClose={() => setAboutLicenseOpen(false)}
snapshot={licenseSnap}
/>
<AppAboutModal
open={appAboutOpen}
onClose={() => setAppAboutOpen(false)}
appVersion={appVersionText}
/>
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
<InstructionsModal open={instructionsOpen} onClose={() => setInstructionsOpen(false)} />
{aboutMenuOpen && aboutMenuPos
? createPortal(
@@ -1203,7 +1224,6 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
variant="primary"
disabled={downloadBusy}
onClick={() => {
if (res?.outcome !== 'available') return;
setDownloadBusy(true);
setProgress({ phase: 'downloading', version: res.version, percent: 0 });
void getDndApi()
@@ -1515,12 +1535,20 @@ function RenameProjectModal({
type ProjectPickerProps = {
projects: { id: ProjectId; name: string; updatedAt: string }[];
licenseActive: boolean;
openingProjectId: ProjectId | null;
onCreate: (name: string) => Promise<void>;
onOpen: (id: ProjectId) => Promise<void>;
onDelete: (id: ProjectId) => Promise<void>;
};
function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: ProjectPickerProps) {
function ProjectPicker({
projects,
licenseActive,
openingProjectId,
onCreate,
onOpen,
onDelete,
}: ProjectPickerProps) {
const { t, locale } = useEditorI18n();
const [name, setName] = useState(() => t('picker.defaultName'));
const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
@@ -1569,52 +1597,77 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }:
) : null}
<div className={styles.projectListScroll}>
<div className={styles.projectList}>
{projects.map((p) => (
<div key={p.id} className={styles.projectCard}>
<div
className={styles.projectCardBody}
onClick={() => {
if (!licenseActive) return;
void onOpen(p.id);
}}
role="button"
tabIndex={0}
title={!licenseActive ? t('picker.openDisabled') : undefined}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!licenseActive) return;
{projects.map((p) => {
const isOpening = openingProjectId === p.id;
const openDisabled = !licenseActive || openingProjectId !== null;
return (
<div key={p.id} className={styles.projectCard}>
<div
className={[
styles.projectCardBody,
isOpening ? styles.projectCardBodyOpening : null,
openDisabled && !isOpening ? styles.projectCardBodyDisabled : null,
]
.filter((className): className is string => Boolean(className))
.join(' ')}
onClick={() => {
if (openDisabled) return;
void onOpen(p.id);
}}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
role="button"
tabIndex={openDisabled ? -1 : 0}
aria-busy={isOpening}
title={
!licenseActive ? t('picker.openDisabled') : isOpening ? t('picker.opening') : undefined
}
}}
>
<div className={styles.projectCardName}>{p.name}</div>
<div className={styles.projectCardMeta}>
{new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (openDisabled) return;
void onOpen(p.id);
}
}}
>
<div className={styles.projectCardName}>{p.name}</div>
<div className={styles.projectCardMeta}>
{isOpening
? t('picker.opening')
: new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
</div>
</div>
<button
type="button"
className={styles.projectCardMenuBtn}
data-project-row-menu-root="1"
aria-label={t('picker.projectMenu')}
aria-haspopup="menu"
aria-expanded={rowMenuFor === p.id}
disabled={!licenseActive || openingProjectId !== null}
title={
!licenseActive
? t('top.afterLicense')
: openingProjectId !== null
? t('picker.opening')
: undefined
}
onClick={(e) => {
e.stopPropagation();
if (!licenseActive || openingProjectId !== null) return;
const r = e.currentTarget.getBoundingClientRect();
const menuW = 220;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
setRowMenuPos({ left, top: r.bottom + 8 });
setRowMenuFor((cur) => (cur === p.id ? null : p.id));
}}
>
</button>
</div>
<button
type="button"
className={styles.projectCardMenuBtn}
data-project-row-menu-root="1"
aria-label={t('picker.projectMenu')}
aria-haspopup="menu"
aria-expanded={rowMenuFor === p.id}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={(e) => {
e.stopPropagation();
if (!licenseActive) return;
const r = e.currentTarget.getBoundingClientRect();
const menuW = 220;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
setRowMenuPos({ left, top: r.bottom + 8 });
setRowMenuFor((cur) => (cur === p.id ? null : p.id));
}}
>
</button>
</div>
))}
);
})}
{projects.length === 0 ? <div className={styles.muted}>{t('picker.empty')}</div> : null}
</div>
</div>
@@ -1687,6 +1740,7 @@ type SceneInspectorProps = {
previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean;
previewBusy: boolean;
previewBusyText: string;
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
@@ -1796,6 +1850,7 @@ function SceneInspector({
previewRotationDeg,
darkenScene,
previewBusy,
previewBusyText,
mediaAssets,
audioRefs,
onAudioRefsChange,
@@ -1854,13 +1909,13 @@ function SceneInspector({
<div className={styles.previewBusyOverlay} aria-live="polite">
<div className={styles.previewBusyModal}>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.previewBusyText}>{t('scene.previewBusy')}</div>
<div className={styles.previewBusyText}>{previewBusyText}</div>
</div>
</div>
) : null}
</div>
<div className={styles.actionsRow}>
<Button variant="primary" onClick={onImportPreview}>
<Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
{previewAssetId ? t('scene.change') : t('campaign.upload')}
</Button>
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null}
@@ -263,6 +263,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'picker.empty': 'Пока нет проектов.',
'picker.projectMenu': 'Меню проекта',
'picker.openDisabled': 'Открытие проекта — после активации лицензии',
'picker.opening': 'Открытие…',
'picker.defaultName': 'Моя кампания',
'campaign.label': 'АУДИО ИГРЫ',
@@ -278,6 +279,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.previewHint': 'Файл изображения (PNG, JPG, WebP, GIF и т.д.).',
'scene.previewEmpty': 'Превью не задано',
'scene.previewBusy': 'Загрузка и оптимизация изображения…',
'scene.previewBusySelecting': 'Выберите файл…',
'scene.previewOptimizing': 'Превью уже доступно. Оптимизируем в фоне…',
'scene.previewReady': 'Превью готово',
'scene.previewFailed': 'Превью добавлено, но оптимизация не удалась',
'scene.change': 'Изменить',
'scene.clear': 'Очистить',
'scene.autostart': 'Автостарт',
@@ -586,6 +591,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'picker.empty': 'No projects yet.',
'picker.projectMenu': 'Project menu',
'picker.openDisabled': 'Open project — after license activation',
'picker.opening': 'Opening…',
'picker.defaultName': 'My campaign',
'campaign.label': 'GAME AUDIO',
@@ -601,6 +607,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.previewHint': 'Image file (PNG, JPG, WebP, GIF, etc.).',
'scene.previewEmpty': 'No preview',
'scene.previewBusy': 'Loading and optimizing image…',
'scene.previewBusySelecting': 'Choose a file…',
'scene.previewOptimizing': 'Preview is ready. Optimizing in the background…',
'scene.previewReady': 'Preview is ready',
'scene.previewFailed': 'Preview was added, but optimization failed',
'scene.change': 'Change',
'scene.clear': 'Clear',
'scene.autostart': 'Autostart',
@@ -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) => {