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 { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { ipcChannels, type SessionState } from '../shared/ipc/contracts'; import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import type { Project } from '../shared/types';
import { import {
PROJECT_ZIP_OPEN_DIALOG_FILTER, PROJECT_ZIP_OPEN_DIALOG_FILTER,
PROJECT_ZIP_SAVE_DIALOG_FILTER, PROJECT_ZIP_SAVE_DIALOG_FILTER,
@@ -10,6 +9,7 @@ import {
projectZipFileNameFromBase, projectZipFileNameFromBase,
stripProjectZipExtension, stripProjectZipExtension,
} from '../shared/project/projectZipExtension'; } from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types';
import { EffectsStore } from './effects/effectsStore'; import { EffectsStore } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore'; 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). По умолчанию не трогаем. * Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`. * При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
@@ -374,7 +380,7 @@ async function main() {
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => { registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
const next = await projectStore.updateScene(sceneId, patch); const next = await projectStore.updateScene(sceneId, patch);
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project && project.currentSceneId === sceneId && patch.darkenScene !== undefined) { if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
emitSceneDarknessState(); emitSceneDarknessState();
} }
@@ -442,11 +448,39 @@ async function main() {
if (canceled || !filePaths[0]) { if (canceled || !filePaths[0]) {
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project'); 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(); 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 }) => { registerHandler(ipcChannels.project.clearScenePreview, async ({ sceneId }) => {
const project = await projectStore.clearScenePreview(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'); 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. // When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
assert.match(src, /async openProjectById/); 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, /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 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', () => { void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
+191 -103
View File
@@ -59,22 +59,44 @@ export class ZipProjectStore {
private projectSession = 0; private projectSession = 0;
/** Serializes project.json writes — parallel renames caused ENOENT on Windows. */ /** Serializes project.json writes — parallel renames caused ENOENT on Windows. */
private projectWriteChain: Promise<void> = Promise.resolve(); private projectWriteChain: Promise<void> = Promise.resolve();
/** Пока идёт сборка zip, в кэш не пишем — иначе yauzl/yazl: «unexpected number of bytes». */ /** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
private isPacking = false; 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> { private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
while (this.isPacking) { const next = this.packChain.then(async () => {
await new Promise((r) => setTimeout(r, 15)); await this.packZipFromCache(cacheDir, zipPath);
} });
this.packChain = next.catch(() => undefined);
return next;
} }
private async packZipExclusive(cacheDir: string, zipPath: string): Promise<void> { private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> {
this.isPacking = true; const task = this.openChain.then(() => this.openProjectByIdInner(projectId, onUnzipPercent));
try { this.openChain = task.then(
await this.packZipFromCache(cacheDir, zipPath); () => undefined,
} finally { () => undefined,
this.isPacking = false; );
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> { async ensureRoots(): Promise<void> {
@@ -190,17 +212,28 @@ export class ZipProjectStore {
const projectPath = path.join(cacheDir, 'project.json'); const projectPath = path.join(cacheDir, 'project.json');
this.openProject = { id, zipPath, cacheDir, projectPath, project }; this.openProject = { id, zipPath, cacheDir, projectPath, project };
await this.writeCacheProject(cacheDir, project); await this.writeCacheProject(cacheDir, project);
await this.packZipExclusive(cacheDir, zipPath); await this.enqueuePack(cacheDir, zipPath);
return this.openProject.project; return this.openProject.project;
} }
async openProjectById(projectId: ProjectId): Promise<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(); await this.ensureRoots();
if (this.openProject?.id === projectId) {
return this.openProject.project;
}
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave). // 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. // When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) { if (this.openProject) {
await this.saveNow(); await this.saveNow();
} }
await this.drainSavePipeline();
this.projectSession += 1; this.projectSession += 1;
const list = await this.listProjects(); const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId); 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.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(cacheDir, { recursive: 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 projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8'); const projectRaw = await fs.readFile(projectPath, 'utf8');
@@ -230,38 +272,7 @@ export class ZipProjectStore {
projectId: ProjectId, projectId: ProjectId,
onUnzipPercent: (pct: number) => void, onUnzipPercent: (pct: number) => void,
): Promise<Project> { ): Promise<Project> {
await this.ensureRoots(); return this.enqueueOpenProject(projectId, onUnzipPercent);
// 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;
} }
getOpenProject(): Project | null { getOpenProject(): Project | null {
@@ -290,7 +301,10 @@ export class ZipProjectStore {
return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime }; 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; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
const sc = open.project.scenes[sceneId]; const sc = open.project.scenes[sceneId];
@@ -300,54 +314,17 @@ export class ZipProjectStore {
if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) { if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) {
throw new Error('Файл превью должен быть изображением или видео'); throw new Error('Файл превью должен быть изображением или видео');
} }
let kind: MediaKind = kind0;
const buf = await fs.readFile(filePath); const buf = await fs.readFile(filePath);
const id = asAssetId(this.randomId()); const id = asAssetId(this.randomId());
const orig = path.basename(filePath); const orig = path.basename(filePath);
let safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
let relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
let abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
let writeBuf = buf; const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
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');
await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, writeBuf); await fs.writeFile(abs, buf);
const asset = buildMediaAsset(id, kind, storedOrig, relPath, sha256, writeBuf.length); const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.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,
);
}
const oldPreviewId = sc.previewAssetId; const oldPreviewId = sc.previewAssetId;
const oldThumbId = sc.previewThumbAssetId ?? null; const oldThumbId = sc.previewThumbAssetId ?? null;
@@ -365,6 +342,101 @@ export class ZipProjectStore {
) as Record<AssetId, MediaAsset>; ) as Record<AssetId, MediaAsset>;
} }
assets[id] = asset; 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) { if (thumbAsset !== null && thumbId !== null) {
assets[thumbId] = thumbAsset; assets[thumbId] = thumbAsset;
} }
@@ -375,10 +447,10 @@ export class ZipProjectStore {
...p.scenes, ...p.scenes,
[sceneId]: { [sceneId]: {
...scene, ...scene,
previewAssetId: id, previewAssetId: finalAssetId,
previewAssetType: kind.type, previewAssetType: finalAsset.type,
previewThumbAssetId: thumbId, 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(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); 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> { async clearScenePreview(sceneId: SceneId): Promise<Project> {
@@ -759,14 +843,13 @@ export class ZipProjectStore {
const open = this.openProject; const open = this.openProject;
if (!open) return; if (!open) return;
await this.projectWriteChain; await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath); await this.enqueuePack(open.cacheDir, open.zipPath);
} }
async closeOpenProject(): Promise<void> { async closeOpenProject(): Promise<void> {
if (!this.openProject) return; if (!this.openProject) return;
await this.saveNow(); await this.saveNow();
await this.waitWhilePacking(); await this.drainSavePipeline();
await this.projectWriteChain;
this.saveQueued = false; this.saveQueued = false;
this.openProject = null; this.openProject = null;
this.projectSession += 1; this.projectSession += 1;
@@ -821,7 +904,7 @@ export class ZipProjectStore {
if (nextBase !== oldBase) { if (nextBase !== oldBase) {
const nextZipPath = path.join(root, nextFileName); const nextZipPath = path.join(root, nextFileName);
await this.projectWriteChain; await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath); await this.enqueuePack(open.cacheDir, open.zipPath);
await replaceFileAtomic(open.zipPath, nextZipPath); await replaceFileAtomic(open.zipPath, nextZipPath);
open.zipPath = nextZipPath; open.zipPath = nextZipPath;
} }
@@ -834,7 +917,13 @@ export class ZipProjectStore {
private queueSave() { private queueSave() {
if (this.saveQueued) return; if (this.saveQueued) return;
this.saveQueued = true; 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() { private async flushSave() {
@@ -846,7 +935,7 @@ export class ZipProjectStore {
this.saving = true; this.saving = true;
try { try {
await this.projectWriteChain; await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath); await this.enqueuePack(open.cacheDir, open.zipPath);
} finally { } finally {
this.saving = false; this.saving = false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may change during async save // 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> { private async writeCacheProject(cacheDir: string, project: Project): Promise<void> {
const sessionAtStart = this.projectSession; const sessionAtStart = this.projectSession;
const run = async (): Promise<void> => { const run = async (): Promise<void> => {
await this.waitWhilePacking(); await this.packChain;
if (sessionAtStart !== this.projectSession) { if (sessionAtStart !== this.projectSession) {
return; return;
} }
@@ -1005,8 +1094,7 @@ export class ZipProjectStore {
const cacheDir = path.join(getProjectsCacheRootDir(), projectId); const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
if (this.openProject?.id === projectId) { if (this.openProject?.id === projectId) {
await this.waitWhilePacking(); await this.drainSavePipeline();
await this.projectWriteChain;
this.saveQueued = false; this.saveQueued = false;
this.openProject = null; this.openProject = null;
this.projectSession += 1; this.projectSession += 1;
+10
View File
@@ -581,6 +581,16 @@
border-radius: 10px; border-radius: 10px;
} }
.projectCardBodyOpening {
cursor: wait;
opacity: 0.72;
}
.projectCardBodyDisabled {
cursor: default;
opacity: 0.55;
}
.projectCardMenuBtn { .projectCardMenuBtn {
flex-shrink: 0; flex-shrink: 0;
margin: -4px -4px 0 0; margin: -4px -4px 0 0;
+115 -60
View File
@@ -9,7 +9,15 @@ import {
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'; 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 { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
@@ -17,6 +25,7 @@ import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell'; import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css'; import styles from './EditorApp.module.css';
import { buildNextSceneCardById } from './graph/sceneCardById'; import { buildNextSceneCardById } from './graph/sceneCardById';
import { import {
@@ -27,7 +36,6 @@ import {
} from './graph/SceneGraph'; } from './graph/SceneGraph';
import { useEditorI18n } from './i18n/EditorI18nContext'; import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import type { ProjectNoticeCode } from './state/projectState'; import type { ProjectNoticeCode } from './state/projectState';
import { useProjectState } from './state/projectState'; import { useProjectState } from './state/projectState';
@@ -82,7 +90,7 @@ export function EditorApp() {
const [instructionsOpen, setInstructionsOpen] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = 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 [presentationOpen, setPresentationOpen] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null); const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false); const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false);
@@ -528,7 +536,11 @@ export function EditorApp() {
<> <>
<div className={styles.gridTools}> <div className={styles.gridTools}>
<Input value={query} onChange={setQuery} placeholder={t('scenes.search')} /> <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')} {t('scenes.new')}
</Button> </Button>
</div> </div>
@@ -550,6 +562,7 @@ export function EditorApp() {
<ProjectPicker <ProjectPicker
projects={state.projects} projects={state.projects}
licenseActive={licenseActive} licenseActive={licenseActive}
openingProjectId={state.openingProjectId}
onCreate={actions.createProject} onCreate={actions.createProject}
onOpen={actions.openProject} onOpen={actions.openProject}
onDelete={actions.deleteProject} onDelete={actions.deleteProject}
@@ -615,6 +628,18 @@ export function EditorApp() {
const proj = state.project; const proj = state.project;
const sid = state.selectedSceneId; const sid = state.selectedSceneId;
const sc = proj.scenes[sid]; 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 ( return (
<SceneInspector <SceneInspector
title={sc?.title ?? ''} title={sc?.title ?? ''}
@@ -625,6 +650,7 @@ export function EditorApp() {
previewRotationDeg={sc?.previewRotationDeg ?? 0} previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false} darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy} previewBusy={previewBusy}
previewBusyText={previewBusyText}
mediaAssets={sceneMediaAssets} mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs} audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) => onAudioRefsChange={(next) =>
@@ -633,15 +659,14 @@ export function EditorApp() {
onPreviewVideoAutostartChange={(next) => onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next }) void actions.updateScene(sid, { previewVideoAutostart: next })
} }
onDarkenSceneChange={(next) => onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
void actions.updateScene(sid, { darkenScene: next })
}
onTitleChange={(title) => void actions.updateScene(sid, { title })} onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) => onDescriptionChange={(description) =>
void actions.updateScene(sid, { description }) void actions.updateScene(sid, { description })
} }
onImportPreview={() => { onImportPreview={() => {
setPreviewBusy(true); if (previewBusy) return;
setPreviewDialogSceneId(sid);
void (async () => { void (async () => {
try { try {
await actions.importScenePreview(sid); await actions.importScenePreview(sid);
@@ -651,7 +676,7 @@ export function EditorApp() {
message: e instanceof Error ? e.message : String(e), message: e instanceof Error ? e.message : String(e),
}); });
} finally { } finally {
setPreviewBusy(false); setPreviewDialogSceneId((cur) => (cur === sid ? null : cur));
} }
})(); })();
}} }}
@@ -805,11 +830,7 @@ export function EditorApp() {
onClose={() => setAboutLicenseOpen(false)} onClose={() => setAboutLicenseOpen(false)}
snapshot={licenseSnap} snapshot={licenseSnap}
/> />
<AppAboutModal <AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
open={appAboutOpen}
onClose={() => setAppAboutOpen(false)}
appVersion={appVersionText}
/>
<InstructionsModal open={instructionsOpen} onClose={() => setInstructionsOpen(false)} /> <InstructionsModal open={instructionsOpen} onClose={() => setInstructionsOpen(false)} />
{aboutMenuOpen && aboutMenuPos {aboutMenuOpen && aboutMenuPos
? createPortal( ? createPortal(
@@ -1203,7 +1224,6 @@ function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
variant="primary" variant="primary"
disabled={downloadBusy} disabled={downloadBusy}
onClick={() => { onClick={() => {
if (res?.outcome !== 'available') return;
setDownloadBusy(true); setDownloadBusy(true);
setProgress({ phase: 'downloading', version: res.version, percent: 0 }); setProgress({ phase: 'downloading', version: res.version, percent: 0 });
void getDndApi() void getDndApi()
@@ -1515,12 +1535,20 @@ function RenameProjectModal({
type ProjectPickerProps = { type ProjectPickerProps = {
projects: { id: ProjectId; name: string; updatedAt: string }[]; projects: { id: ProjectId; name: string; updatedAt: string }[];
licenseActive: boolean; licenseActive: boolean;
openingProjectId: ProjectId | null;
onCreate: (name: string) => Promise<void>; onCreate: (name: string) => Promise<void>;
onOpen: (id: ProjectId) => Promise<void>; onOpen: (id: ProjectId) => Promise<void>;
onDelete: (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 { t, locale } = useEditorI18n();
const [name, setName] = useState(() => t('picker.defaultName')); const [name, setName] = useState(() => t('picker.defaultName'));
const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null); const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
@@ -1569,52 +1597,77 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }:
) : null} ) : null}
<div className={styles.projectListScroll}> <div className={styles.projectListScroll}>
<div className={styles.projectList}> <div className={styles.projectList}>
{projects.map((p) => ( {projects.map((p) => {
<div key={p.id} className={styles.projectCard}> const isOpening = openingProjectId === p.id;
<div const openDisabled = !licenseActive || openingProjectId !== null;
className={styles.projectCardBody} return (
onClick={() => { <div key={p.id} className={styles.projectCard}>
if (!licenseActive) return; <div
void onOpen(p.id); className={[
}} styles.projectCardBody,
role="button" isOpening ? styles.projectCardBodyOpening : null,
tabIndex={0} openDisabled && !isOpening ? styles.projectCardBodyDisabled : null,
title={!licenseActive ? t('picker.openDisabled') : undefined} ]
onKeyDown={(e) => { .filter((className): className is string => Boolean(className))
if (e.key === 'Enter' || e.key === ' ') { .join(' ')}
if (!licenseActive) return; onClick={() => {
if (openDisabled) return;
void onOpen(p.id); 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
} }
}} onKeyDown={(e) => {
> if (e.key === 'Enter' || e.key === ' ') {
<div className={styles.projectCardName}>{p.name}</div> if (openDisabled) return;
<div className={styles.projectCardMeta}> void onOpen(p.id);
{new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} }
}}
>
<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> </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> </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} {projects.length === 0 ? <div className={styles.muted}>{t('picker.empty')}</div> : null}
</div> </div>
</div> </div>
@@ -1687,6 +1740,7 @@ type SceneInspectorProps = {
previewRotationDeg: 0 | 90 | 180 | 270; previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean; darkenScene: boolean;
previewBusy: boolean; previewBusy: boolean;
previewBusyText: string;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[]; audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void; onAudioRefsChange: (next: SceneAudioRef[]) => void;
@@ -1796,6 +1850,7 @@ function SceneInspector({
previewRotationDeg, previewRotationDeg,
darkenScene, darkenScene,
previewBusy, previewBusy,
previewBusyText,
mediaAssets, mediaAssets,
audioRefs, audioRefs,
onAudioRefsChange, onAudioRefsChange,
@@ -1854,13 +1909,13 @@ function SceneInspector({
<div className={styles.previewBusyOverlay} aria-live="polite"> <div className={styles.previewBusyOverlay} aria-live="polite">
<div className={styles.previewBusyModal}> <div className={styles.previewBusyModal}>
<div className={styles.previewSpinner} aria-hidden /> <div className={styles.previewSpinner} aria-hidden />
<div className={styles.previewBusyText}>{t('scene.previewBusy')}</div> <div className={styles.previewBusyText}>{previewBusyText}</div>
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
<div className={styles.actionsRow}> <div className={styles.actionsRow}>
<Button variant="primary" onClick={onImportPreview}> <Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
{previewAssetId ? t('scene.change') : t('campaign.upload')} {previewAssetId ? t('scene.change') : t('campaign.upload')}
</Button> </Button>
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null} {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.empty': 'Пока нет проектов.',
'picker.projectMenu': 'Меню проекта', 'picker.projectMenu': 'Меню проекта',
'picker.openDisabled': 'Открытие проекта — после активации лицензии', 'picker.openDisabled': 'Открытие проекта — после активации лицензии',
'picker.opening': 'Открытие…',
'picker.defaultName': 'Моя кампания', 'picker.defaultName': 'Моя кампания',
'campaign.label': 'АУДИО ИГРЫ', 'campaign.label': 'АУДИО ИГРЫ',
@@ -278,6 +279,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.previewHint': 'Файл изображения (PNG, JPG, WebP, GIF и т.д.).', 'scene.previewHint': 'Файл изображения (PNG, JPG, WebP, GIF и т.д.).',
'scene.previewEmpty': 'Превью не задано', 'scene.previewEmpty': 'Превью не задано',
'scene.previewBusy': 'Загрузка и оптимизация изображения…', 'scene.previewBusy': 'Загрузка и оптимизация изображения…',
'scene.previewBusySelecting': 'Выберите файл…',
'scene.previewOptimizing': 'Превью уже доступно. Оптимизируем в фоне…',
'scene.previewReady': 'Превью готово',
'scene.previewFailed': 'Превью добавлено, но оптимизация не удалась',
'scene.change': 'Изменить', 'scene.change': 'Изменить',
'scene.clear': 'Очистить', 'scene.clear': 'Очистить',
'scene.autostart': 'Автостарт', 'scene.autostart': 'Автостарт',
@@ -586,6 +591,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'picker.empty': 'No projects yet.', 'picker.empty': 'No projects yet.',
'picker.projectMenu': 'Project menu', 'picker.projectMenu': 'Project menu',
'picker.openDisabled': 'Open project — after license activation', 'picker.openDisabled': 'Open project — after license activation',
'picker.opening': 'Opening…',
'picker.defaultName': 'My campaign', 'picker.defaultName': 'My campaign',
'campaign.label': 'GAME AUDIO', '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.previewHint': 'Image file (PNG, JPG, WebP, GIF, etc.).',
'scene.previewEmpty': 'No preview', 'scene.previewEmpty': 'No preview',
'scene.previewBusy': 'Loading and optimizing image…', '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.change': 'Change',
'scene.clear': 'Clear', 'scene.clear': 'Clear',
'scene.autostart': 'Autostart', 'scene.autostart': 'Autostart',
@@ -17,7 +17,7 @@ void test('projectState: list/get after delete invalidates in-flight initial loa
); );
assert.match( assert.match(
src, 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/); 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 { 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 type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi'; import { getDndApi } from '../../shared/dndApi';
@@ -10,7 +10,13 @@ type State = {
projects: ProjectSummary[]; projects: ProjectSummary[];
project: Project | null; project: Project | null;
selectedSceneId: SceneId | null; selectedSceneId: SceneId | null;
openingProjectId: ProjectId | null;
creatingScene: boolean;
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null; zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
scenePreviewImports: Record<
SceneId,
{ assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
>;
}; };
type Actions = { type Actions = {
@@ -40,7 +46,7 @@ type Actions = {
) => Promise<void>; ) => Promise<void>;
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>; updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
importMediaToScene: (sceneId: 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>; clearScenePreview: (sceneId: SceneId) => Promise<void>;
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>; updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
addSceneGraphNode: (sceneId: SceneId, 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: [], projects: [],
project: null, project: null,
selectedSceneId: null, selectedSceneId: null,
openingProjectId: null,
creatingScene: false,
zipProgress: null, zipProgress: null,
scenePreviewImports: {} as Record<
SceneId,
{ assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
>,
}); });
const projectRef = useRef<Project | null>(null); 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). */ /** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */
const projectDataEpochRef = useRef(0); const projectDataEpochRef = useRef(0);
useEffect(() => { useEffect(() => {
@@ -115,9 +129,40 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450); 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 () => { return () => {
offImport(); offImport();
offExport(); offExport();
offPreview();
}; };
}, [api]); }, [api]);
@@ -135,9 +180,26 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
}; };
const openProject = async (id: ProjectId) => { const openProject = async (id: ProjectId) => {
projectDataEpochRef.current += 1; if (openInFlightRef.current) return openInFlightRef.current;
const res = await api.invoke(ipcChannels.project.open, { projectId: id }); const job = (async () => {
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId })); 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 () => { const closeProject = async () => {
@@ -147,41 +209,51 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
}; };
const createScene = async () => { const createScene = async () => {
if (createSceneInFlightRef.current) return createSceneInFlightRef.current;
const p = projectRef.current; const p = projectRef.current;
if (!p) return; if (!p) return;
const sceneId = randomId('scene') as SceneId; const job = (async () => {
const scene: Scene = { setState((s) => ({ ...s, creatingScene: true }));
id: sceneId, const sceneId = randomId('scene') as SceneId;
title: `Новая сцена`, const scene: Scene = {
description: '', id: sceneId,
previewAssetId: null, title: `Новая сцена`,
previewThumbAssetId: null, description: '',
previewAssetType: null, previewAssetId: null,
previewVideoAutostart: false, previewThumbAssetId: null,
previewRotationDeg: 0, previewAssetType: null,
darkenScene: false, previewVideoAutostart: false,
media: { videos: [], audios: [] }, previewRotationDeg: 0,
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, darkenScene: false,
connections: [], media: { videos: [], audios: [] },
layout: { x: 0, y: 0 }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
}; connections: [],
layout: { x: 0, y: 0 },
await api.invoke(ipcChannels.project.updateScene, { };
sceneId, await api.invoke(ipcChannels.project.updateScene, {
patch: { sceneId,
title: scene.title, patch: {
description: scene.description, title: scene.title,
media: scene.media, description: scene.description,
settings: scene.settings, media: scene.media,
layout: scene.layout, settings: scene.settings,
previewAssetId: scene.previewAssetId, layout: scene.layout,
previewAssetType: scene.previewAssetType, previewAssetId: scene.previewAssetId,
previewVideoAutostart: scene.previewVideoAutostart, 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 }); return createSceneInFlightRef.current;
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
}; };
const selectScene = async (id: SceneId) => { const selectScene = async (id: SceneId) => {
@@ -276,7 +348,17 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
const importScenePreview = async (sceneId: SceneId) => { const importScenePreview = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId }); const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
setState((s) => ({ ...s, project: res.project })); 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(); await refreshProjects();
return { assetId: res.assetId, background: res.background };
}; };
const clearScenePreview = async (sceneId: SceneId) => { const clearScenePreview = async (sceneId: SceneId) => {
+11 -1
View File
@@ -55,6 +55,7 @@ export const ipcChannels = {
deleteProject: 'project.deleteProject', deleteProject: 'project.deleteProject',
importZipProgress: 'project.importZipProgress', importZipProgress: 'project.importZipProgress',
exportZipProgress: 'project.exportZipProgress', exportZipProgress: 'project.exportZipProgress',
scenePreviewImportProgress: 'project.scenePreviewImportProgress',
}, },
windows: { windows: {
openMultiWindow: 'windows.openMultiWindow', openMultiWindow: 'windows.openMultiWindow',
@@ -97,6 +98,14 @@ export type ZipProgressEvent = {
detail?: string; detail?: string;
}; };
export type ScenePreviewImportEvent = {
sceneId: SceneId;
assetId: AssetId;
phase: 'queued' | 'optimizing' | 'thumbnail' | 'done' | 'error';
project?: Project;
message?: string;
};
export type UpdaterCheckResponse = export type UpdaterCheckResponse =
| { outcome: 'not_packaged' } | { outcome: 'not_packaged' }
| { outcome: 'no_license' } | { outcome: 'no_license' }
@@ -131,6 +140,7 @@ export type IpcEventMap = {
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
[ipcChannels.project.importZipProgress]: ZipProgressEvent; [ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent; [ipcChannels.project.exportZipProgress]: ZipProgressEvent;
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
[ipcChannels.updater.progress]: UpdaterProgressEvent; [ipcChannels.updater.progress]: UpdaterProgressEvent;
}; };
@@ -205,7 +215,7 @@ export type IpcInvokeMap = {
}; };
[ipcChannels.project.importScenePreview]: { [ipcChannels.project.importScenePreview]: {
req: { sceneId: SceneId }; req: { sceneId: SceneId };
res: { project: Project }; res: { project: Project; assetId: AssetId | null; background: boolean };
}; };
[ipcChannels.project.clearScenePreview]: { [ipcChannels.project.clearScenePreview]: {
req: { sceneId: SceneId }; req: { sceneId: SceneId };