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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user