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
+40 -6
View File
@@ -1,7 +1,6 @@
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
import type { Project } from '../shared/types';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
PROJECT_ZIP_OPEN_DIALOG_FILTER,
PROJECT_ZIP_SAVE_DIALOG_FILTER,
@@ -10,6 +9,7 @@ import {
projectZipFileNameFromBase,
stripProjectZipExtension,
} from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types';
import { EffectsStore } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
@@ -53,6 +53,12 @@ function emitZipProgress(evt: {
}
}
function emitScenePreviewImportProgress(evt: ScenePreviewImportEvent): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.scenePreviewImportProgress, evt);
}
}
/**
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
@@ -374,7 +380,7 @@ async function main() {
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
const next = await projectStore.updateScene(sceneId, patch);
const project = projectStore.getOpenProject();
if (project && project.currentSceneId === sceneId && patch.darkenScene !== undefined) {
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
syncSceneDarknessForProject(project);
emitSceneDarknessState();
}
@@ -442,11 +448,39 @@ async function main() {
if (canceled || !filePaths[0]) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project };
return { project, assetId: null, background: false };
}
const project = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]);
const result = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]);
emitSessionState();
return { project };
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'queued',
project: result.project,
});
void (async () => {
try {
emitScenePreviewImportProgress({ sceneId, assetId: result.assetId, phase: 'optimizing' });
const finalized = await projectStore.finalizeScenePreviewImport(sceneId, result.assetId);
if (finalized.changed) {
emitSessionState();
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'done',
project: finalized.project,
});
}
} catch (e) {
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'error',
message: e instanceof Error ? e.message : String(e),
});
}
})();
return result;
});
registerHandler(ipcChannels.project.clearScenePreview, async ({ sceneId }) => {
const project = await projectStore.clearScenePreview(sceneId);
@@ -28,9 +28,25 @@ void test('zipStore: openProjectById flushes pending saveNow before cache reset'
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
assert.match(src, /async openProjectById/);
assert.match(src, /enqueueOpenProject/);
assert.match(src, /openProjectByIdInner/);
assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
assert.match(src, /await this\.drainSavePipeline\(\)/);
assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir/);
});
void test('zipStore: openProjectById skips re-unzip when project is already open', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*return this\.openProject\.project;\s*\}/);
});
void test('zipStore: pack and open operations are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private packChain: Promise<void>/);
assert.match(src, /private openChain: Promise<void>/);
assert.match(src, /enqueuePack/);
assert.match(src, /enqueueOpenProject/);
});
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
+192 -104
View File
@@ -59,22 +59,44 @@ export class ZipProjectStore {
private projectSession = 0;
/** Serializes project.json writes — parallel renames caused ENOENT on Windows. */
private projectWriteChain: Promise<void> = Promise.resolve();
/** Пока идёт сборка zip, в кэш не пишем — иначе yauzl/yazl: «unexpected number of bytes». */
private isPacking = false;
/** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
private packChain: Promise<void> = Promise.resolve();
/** Serializes open/unzip — double-click fired two concurrent opens and corrupted reads. */
private openChain: Promise<void> = Promise.resolve();
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private async waitWhilePacking(): Promise<void> {
while (this.isPacking) {
await new Promise((r) => setTimeout(r, 15));
}
}
private async packZipExclusive(cacheDir: string, zipPath: string): Promise<void> {
this.isPacking = true;
try {
private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
const next = this.packChain.then(async () => {
await this.packZipFromCache(cacheDir, zipPath);
} finally {
this.isPacking = false;
});
this.packChain = next.catch(() => undefined);
return next;
}
private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> {
const task = this.openChain.then(() => this.openProjectByIdInner(projectId, onUnzipPercent));
this.openChain = task.then(
() => undefined,
() => undefined,
);
return task;
}
/** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */
private async drainSavePipeline(): Promise<void> {
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
this.saveDebounceTimer = null;
}
if (this.saveQueued) {
this.saveQueued = false;
await this.flushSave();
}
while (this.saving) {
await new Promise((r) => setTimeout(r, 10));
}
await this.packChain;
await this.projectWriteChain;
}
async ensureRoots(): Promise<void> {
@@ -190,17 +212,28 @@ export class ZipProjectStore {
const projectPath = path.join(cacheDir, 'project.json');
this.openProject = { id, zipPath, cacheDir, projectPath, project };
await this.writeCacheProject(cacheDir, project);
await this.packZipExclusive(cacheDir, zipPath);
await this.enqueuePack(cacheDir, zipPath);
return this.openProject.project;
}
async openProjectById(projectId: ProjectId): Promise<Project> {
return this.enqueueOpenProject(projectId);
}
private async openProjectByIdInner(
projectId: ProjectId,
onUnzipPercent?: (pct: number) => void,
): Promise<Project> {
await this.ensureRoots();
if (this.openProject?.id === projectId) {
return this.openProject.project;
}
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
await this.drainSavePipeline();
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
@@ -212,7 +245,16 @@ export class ZipProjectStore {
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(cacheDir, { recursive: true });
await unzipToDir(zipPath, cacheDir);
try {
await unzipToDir(zipPath, cacheDir, (done, total) => {
if (!onUnzipPercent) return;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
onUnzipPercent(Math.max(0, Math.min(100, pct)));
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Не удалось открыть проект: архив повреждён или занят (${detail})`);
}
const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8');
@@ -230,38 +272,7 @@ export class ZipProjectStore {
projectId: ProjectId,
onUnzipPercent: (pct: number) => void,
): Promise<Project> {
await this.ensureRoots();
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Project not found');
}
const zipPath = path.join(getProjectsRootDir(), entry.fileName);
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(cacheDir, { recursive: true });
await unzipToDir(zipPath, cacheDir, (done, total) => {
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
onUnzipPercent(Math.max(0, Math.min(100, pct)));
});
const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8');
const parsed = JSON.parse(projectRaw) as unknown as Project;
const project = normalizeProject(parsed);
const fileBaseName = entry.fileName.replace(/\.dnd\.zip$/iu, '');
project.meta.fileBaseName = project.meta.fileBaseName.trim().length
? project.meta.fileBaseName
: fileBaseName;
this.openProject = { id: projectId, zipPath, cacheDir, projectPath, project };
return project;
return this.enqueueOpenProject(projectId, onUnzipPercent);
}
getOpenProject(): Project | null {
@@ -290,7 +301,10 @@ export class ZipProjectStore {
return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime };
}
async importScenePreviewMedia(sceneId: SceneId, filePath: string): Promise<Project> {
async importScenePreviewMedia(
sceneId: SceneId,
filePath: string,
): Promise<{ project: Project; assetId: AssetId; background: boolean }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const sc = open.project.scenes[sceneId];
@@ -300,54 +314,17 @@ export class ZipProjectStore {
if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) {
throw new Error('Файл превью должен быть изображением или видео');
}
let kind: MediaKind = kind0;
const buf = await fs.readFile(filePath);
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
let safeOrig = sanitizeFileName(orig);
let relPath = `assets/${id}_${safeOrig}`;
let abs = path.join(open.cacheDir, relPath);
let writeBuf = buf;
let storedOrig = orig;
if (kind.type === 'image') {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (!opt.passthrough) {
writeBuf = Buffer.from(opt.buffer);
kind = { type: 'image', mime: opt.mime };
safeOrig = sanitizeFileName(`${path.parse(orig).name}.${opt.ext}`);
relPath = `assets/${id}_${safeOrig}`;
abs = path.join(open.cacheDir, relPath);
storedOrig = `${path.parse(orig).name}.${opt.ext}`;
}
}
const sha256 = crypto.createHash('sha256').update(writeBuf).digest('hex');
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, writeBuf);
const asset = buildMediaAsset(id, kind, storedOrig, relPath, sha256, writeBuf.length);
const thumbKind = kind.type === 'image' ? 'image' : 'video';
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, thumbKind);
let thumbAsset: MediaAsset | null = null;
let thumbId: AssetId | null = null;
if (thumbBytes !== null && thumbBytes.length > 0) {
thumbId = asAssetId(this.randomId());
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
const thumbAbs = path.join(open.cacheDir, thumbRelPath);
await fs.writeFile(thumbAbs, thumbBytes);
const thumbSha = crypto.createHash('sha256').update(thumbBytes).digest('hex');
const thumbOrigName = `${path.parse(safeOrig).name}_preview_thumb.webp`;
thumbAsset = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
thumbOrigName,
thumbRelPath,
thumbSha,
thumbBytes.length,
);
}
await fs.writeFile(abs, buf);
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
const oldPreviewId = sc.previewAssetId;
const oldThumbId = sc.previewThumbAssetId ?? null;
@@ -365,6 +342,101 @@ export class ZipProjectStore {
) as Record<AssetId, MediaAsset>;
}
assets[id] = asset;
return {
...p,
assets,
scenes: {
...p.scenes,
[sceneId]: {
...scene,
previewAssetId: id,
previewAssetType: kind0.type,
previewThumbAssetId: null,
previewVideoAutostart: kind0.type === 'video' ? scene.previewVideoAutostart : false,
},
},
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return { project: latest, assetId: id, background: true };
}
async finalizeScenePreviewImport(
sceneId: SceneId,
assetId: AssetId,
): Promise<{ project: Project; changed: boolean }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const sceneAtStart = open.project.scenes[sceneId];
if (sceneAtStart?.previewAssetId !== assetId) {
return { project: open.project, changed: false };
}
const sourceAsset = open.project.assets[assetId];
if (!sourceAsset || (sourceAsset.type !== 'image' && sourceAsset.type !== 'video')) {
return { project: open.project, changed: false };
}
const generatedRelPaths: string[] = [];
let finalAsset = sourceAsset;
let finalAssetId = assetId;
let finalAbs = path.join(open.cacheDir, sourceAsset.relPath);
if (sourceAsset.type === 'image') {
const input = await fs.readFile(finalAbs);
const opt = await optimizeImageBufferVisuallyLossless(input);
if (!opt.passthrough) {
finalAssetId = asAssetId(this.randomId());
const optimizedName = `${path.parse(sourceAsset.originalName).name}.${opt.ext}`;
const safeOptimizedName = sanitizeFileName(optimizedName);
const optimizedRelPath = `assets/${finalAssetId}_${safeOptimizedName}`;
finalAbs = path.join(open.cacheDir, optimizedRelPath);
const optimizedBuffer = Buffer.from(opt.buffer);
await fs.writeFile(finalAbs, optimizedBuffer);
generatedRelPaths.push(optimizedRelPath);
const optimizedAsset = buildMediaAsset(
finalAssetId,
{ type: 'image', mime: opt.mime },
optimizedName,
optimizedRelPath,
crypto.createHash('sha256').update(optimizedBuffer).digest('hex'),
optimizedBuffer.length,
);
if (optimizedAsset.type !== 'image') {
throw new Error('Optimized preview asset must be an image');
}
finalAsset = optimizedAsset;
}
}
const thumbKind = finalAsset.type === 'image' ? 'image' : 'video';
const thumbBytes = await generateScenePreviewThumbnailBytes(finalAbs, thumbKind);
let thumbAsset: MediaAsset | null = null;
let thumbId: AssetId | null = null;
if (thumbBytes !== null && thumbBytes.length > 0) {
thumbId = asAssetId(this.randomId());
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
const thumbAbs = path.join(open.cacheDir, thumbRelPath);
await fs.writeFile(thumbAbs, thumbBytes);
generatedRelPaths.push(thumbRelPath);
const thumbOrigName = `${path.parse(finalAsset.originalName).name}_preview_thumb.webp`;
thumbAsset = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
thumbOrigName,
thumbRelPath,
crypto.createHash('sha256').update(thumbBytes).digest('hex'),
thumbBytes.length,
);
}
await this.updateProject((p) => {
const scene = p.scenes[sceneId];
if (scene?.previewAssetId !== assetId) {
return p;
}
const assets: Record<AssetId, MediaAsset> = { ...p.assets, [finalAssetId]: finalAsset };
if (thumbAsset !== null && thumbId !== null) {
assets[thumbId] = thumbAsset;
}
@@ -375,10 +447,10 @@ export class ZipProjectStore {
...p.scenes,
[sceneId]: {
...scene,
previewAssetId: id,
previewAssetType: kind.type,
previewAssetId: finalAssetId,
previewAssetType: finalAsset.type,
previewThumbAssetId: thumbId,
previewVideoAutostart: kind.type === 'video' ? scene.previewVideoAutostart : false,
previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false,
},
},
};
@@ -386,7 +458,19 @@ export class ZipProjectStore {
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
const latestScene = latest.scenes[sceneId];
const applied =
latestScene?.previewAssetId === finalAssetId &&
(thumbId === null || latestScene.previewThumbAssetId === thumbId);
if (!applied) {
await Promise.all(
generatedRelPaths.map((relPath) =>
fs.unlink(path.join(open.cacheDir, relPath)).catch(() => undefined),
),
);
}
return { project: latest, changed: applied };
}
async clearScenePreview(sceneId: SceneId): Promise<Project> {
@@ -759,14 +843,13 @@ export class ZipProjectStore {
const open = this.openProject;
if (!open) return;
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
await this.enqueuePack(open.cacheDir, open.zipPath);
}
async closeOpenProject(): Promise<void> {
if (!this.openProject) return;
await this.saveNow();
await this.waitWhilePacking();
await this.projectWriteChain;
await this.drainSavePipeline();
this.saveQueued = false;
this.openProject = null;
this.projectSession += 1;
@@ -821,7 +904,7 @@ export class ZipProjectStore {
if (nextBase !== oldBase) {
const nextZipPath = path.join(root, nextFileName);
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
await this.enqueuePack(open.cacheDir, open.zipPath);
await replaceFileAtomic(open.zipPath, nextZipPath);
open.zipPath = nextZipPath;
}
@@ -834,7 +917,13 @@ export class ZipProjectStore {
private queueSave() {
if (this.saveQueued) return;
this.saveQueued = true;
setTimeout(() => void this.flushSave(), 250);
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
}
this.saveDebounceTimer = setTimeout(() => {
this.saveDebounceTimer = null;
void this.flushSave();
}, 250);
}
private async flushSave() {
@@ -846,7 +935,7 @@ export class ZipProjectStore {
this.saving = true;
try {
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
await this.enqueuePack(open.cacheDir, open.zipPath);
} finally {
this.saving = false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may change during async save
@@ -859,7 +948,7 @@ export class ZipProjectStore {
private async writeCacheProject(cacheDir: string, project: Project): Promise<void> {
const sessionAtStart = this.projectSession;
const run = async (): Promise<void> => {
await this.waitWhilePacking();
await this.packChain;
if (sessionAtStart !== this.projectSession) {
return;
}
@@ -1005,8 +1094,7 @@ export class ZipProjectStore {
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
if (this.openProject?.id === projectId) {
await this.waitWhilePacking();
await this.projectWriteChain;
await this.drainSavePipeline();
this.saveQueued = false;
this.openProject = null;
this.projectSession += 1;
+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;
+84 -29
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,27 +1597,45 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }:
) : null}
<div className={styles.projectListScroll}>
<div className={styles.projectList}>
{projects.map((p) => (
{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}
className={[
styles.projectCardBody,
isOpening ? styles.projectCardBodyOpening : null,
openDisabled && !isOpening ? styles.projectCardBodyDisabled : null,
]
.filter((className): className is string => Boolean(className))
.join(' ')}
onClick={() => {
if (!licenseActive) return;
if (openDisabled) return;
void onOpen(p.id);
}}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
role="button"
tabIndex={0}
title={!licenseActive ? t('picker.openDisabled') : undefined}
tabIndex={openDisabled ? -1 : 0}
aria-busy={isOpening}
title={
!licenseActive ? t('picker.openDisabled') : isOpening ? t('picker.opening') : undefined
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!licenseActive) return;
if (openDisabled) return;
void onOpen(p.id);
}
}}
>
<div className={styles.projectCardName}>{p.name}</div>
<div className={styles.projectCardMeta}>
{new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
{isOpening
? t('picker.opening')
: new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
</div>
</div>
<button
@@ -1599,11 +1645,17 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }:
aria-label={t('picker.projectMenu')}
aria-haspopup="menu"
aria-expanded={rowMenuFor === p.id}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
disabled={!licenseActive || openingProjectId !== null}
title={
!licenseActive
? t('top.afterLicense')
: openingProjectId !== null
? t('picker.opening')
: undefined
}
onClick={(e) => {
e.stopPropagation();
if (!licenseActive) return;
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));
@@ -1614,7 +1666,8 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }:
</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/);
});
+86 -4
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) => {
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 }));
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,8 +209,11 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
};
const createScene = async () => {
if (createSceneInFlightRef.current) return createSceneInFlightRef.current;
const p = projectRef.current;
if (!p) return;
const job = (async () => {
setState((s) => ({ ...s, creatingScene: true }));
const sceneId = randomId('scene') as SceneId;
const scene: Scene = {
id: sceneId,
@@ -165,7 +230,6 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
connections: [],
layout: { x: 0, y: 0 },
};
await api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: {
@@ -182,6 +246,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
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 }));
}
});
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) => {
+11 -1
View File
@@ -55,6 +55,7 @@ export const ipcChannels = {
deleteProject: 'project.deleteProject',
importZipProgress: 'project.importZipProgress',
exportZipProgress: 'project.exportZipProgress',
scenePreviewImportProgress: 'project.scenePreviewImportProgress',
},
windows: {
openMultiWindow: 'windows.openMultiWindow',
@@ -97,6 +98,14 @@ export type ZipProgressEvent = {
detail?: string;
};
export type ScenePreviewImportEvent = {
sceneId: SceneId;
assetId: AssetId;
phase: 'queued' | 'optimizing' | 'thumbnail' | 'done' | 'error';
project?: Project;
message?: string;
};
export type UpdaterCheckResponse =
| { outcome: 'not_packaged' }
| { outcome: 'no_license' }
@@ -131,6 +140,7 @@ export type IpcEventMap = {
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
[ipcChannels.updater.progress]: UpdaterProgressEvent;
};
@@ -205,7 +215,7 @@ export type IpcInvokeMap = {
};
[ipcChannels.project.importScenePreview]: {
req: { sceneId: SceneId };
res: { project: Project };
res: { project: Project; assetId: AssetId | null; background: boolean };
};
[ipcChannels.project.clearScenePreview]: {
req: { sceneId: SceneId };