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
+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}