diff --git a/app/main/index.ts b/app/main/index.ts index 948c3d9..41823e7 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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); diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index 1bea2f8..8a16e2a 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -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/); + assert.match(src, /private openChain: Promise/); + assert.match(src, /enqueuePack/); + assert.match(src, /enqueueOpenProject/); }); void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => { diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 8d6b3d8..951d6db 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -59,22 +59,44 @@ export class ZipProjectStore { private projectSession = 0; /** Serializes project.json writes — parallel renames caused ENOENT on Windows. */ private projectWriteChain: Promise = 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 = Promise.resolve(); + /** Serializes open/unzip — double-click fired two concurrent opens and corrupted reads. */ + private openChain: Promise = Promise.resolve(); + private saveDebounceTimer: ReturnType | null = null; - private async waitWhilePacking(): Promise { - while (this.isPacking) { - await new Promise((r) => setTimeout(r, 15)); - } + private enqueuePack(cacheDir: string, zipPath: string): Promise { + const next = this.packChain.then(async () => { + await this.packZipFromCache(cacheDir, zipPath); + }); + this.packChain = next.catch(() => undefined); + return next; } - private async packZipExclusive(cacheDir: string, zipPath: string): Promise { - this.isPacking = true; - try { - await this.packZipFromCache(cacheDir, zipPath); - } finally { - this.isPacking = false; + private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise { + 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 { + 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 { @@ -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 { + return this.enqueueOpenProject(projectId); + } + + private async openProjectByIdInner( + projectId: ProjectId, + onUnzipPercent?: (pct: number) => void, + ): Promise { 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 { - 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 { + 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; } 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 = { ...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 { @@ -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 { 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 { const sessionAtStart = this.projectSession; const run = async (): Promise => { - 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; diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index 671f854..ffb162f 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -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; diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index f6a7fea..a91ed05 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -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(null); const [presentationOpen, setPresentationOpen] = useState(false); const [licenseSnap, setLicenseSnap] = useState(null); const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false); @@ -528,7 +536,11 @@ export function EditorApp() { <>
-
@@ -550,6 +562,7 @@ export function EditorApp() { @@ -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} /> - setAppAboutOpen(false)} - appVersion={appVersionText} - /> + setAppAboutOpen(false)} appVersion={appVersionText} /> 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; onOpen: (id: ProjectId) => Promise; onDelete: (id: ProjectId) => Promise; }; -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(null); @@ -1569,52 +1597,77 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: ) : null}
- {projects.map((p) => ( -
-
{ - if (!licenseActive) return; - void onOpen(p.id); - }} - role="button" - tabIndex={0} - title={!licenseActive ? t('picker.openDisabled') : undefined} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - if (!licenseActive) return; + {projects.map((p) => { + const isOpening = openingProjectId === p.id; + const openDisabled = !licenseActive || openingProjectId !== null; + return ( +
+
Boolean(className)) + .join(' ')} + onClick={() => { + if (openDisabled) return; void onOpen(p.id); + }} + onDoubleClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + role="button" + tabIndex={openDisabled ? -1 : 0} + aria-busy={isOpening} + title={ + !licenseActive ? t('picker.openDisabled') : isOpening ? t('picker.opening') : undefined } - }} - > -
{p.name}
-
- {new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (openDisabled) return; + void onOpen(p.id); + } + }} + > +
{p.name}
+
+ {isOpening + ? t('picker.opening') + : new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} +
+
- -
- ))} + ); + })} {projects.length === 0 ?
{t('picker.empty')}
: null}
@@ -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({
-
{t('scene.previewBusy')}
+
{previewBusyText}
) : null}
- {previewAssetId ? : null} diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index ee58427..f699dd8 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -263,6 +263,7 @@ export const EDITOR_MESSAGES: Record> = { 'picker.empty': 'Пока нет проектов.', 'picker.projectMenu': 'Меню проекта', 'picker.openDisabled': 'Открытие проекта — после активации лицензии', + 'picker.opening': 'Открытие…', 'picker.defaultName': 'Моя кампания', 'campaign.label': 'АУДИО ИГРЫ', @@ -278,6 +279,10 @@ export const EDITOR_MESSAGES: Record> = { '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> = { '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> = { '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', diff --git a/app/renderer/editor/state/projectState.race.test.ts b/app/renderer/editor/state/projectState.race.test.ts index adfa7c0..eb18395 100644 --- a/app/renderer/editor/state/projectState.race.test.ts +++ b/app/renderer/editor/state/projectState.race.test.ts @@ -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/); }); diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index 0ceabe8..0d08196 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -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; updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise; importMediaToScene: (sceneId: SceneId) => Promise; - importScenePreview: (sceneId: SceneId) => Promise; + importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>; clearScenePreview: (sceneId: SceneId) => Promise; updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise; addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise; @@ -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(null); + const openInFlightRef = useRef | null>(null); + const createSceneInFlightRef = useRef | null>(null); /** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */ const projectDataEpochRef = useRef(0); useEffect(() => { @@ -115,9 +129,40 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450); } }); + const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => { + setState((s) => { + const nextImports = { ...s.scenePreviewImports }; + nextImports[evt.sceneId] = { + assetId: evt.assetId, + phase: evt.phase, + ...(evt.message ? { message: evt.message } : null), + }; + return { + ...s, + ...(evt.project ? { project: evt.project } : null), + scenePreviewImports: nextImports, + }; + }); + if (evt.phase === 'done' || evt.phase === 'error') { + setTimeout( + () => { + setState((s) => { + const cur = s.scenePreviewImports[evt.sceneId]; + if (cur?.assetId !== evt.assetId || cur.phase !== evt.phase) return s; + const nextImports = Object.fromEntries( + Object.entries(s.scenePreviewImports).filter(([sceneId]) => sceneId !== evt.sceneId), + ) as State['scenePreviewImports']; + return { ...s, scenePreviewImports: nextImports }; + }); + }, + evt.phase === 'done' ? 1000 : 4000, + ); + } + }); return () => { offImport(); offExport(); + offPreview(); }; }, [api]); @@ -135,9 +180,26 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) }; const openProject = async (id: ProjectId) => { - projectDataEpochRef.current += 1; - const res = await api.invoke(ipcChannels.project.open, { projectId: id }); - setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId })); + if (openInFlightRef.current) return openInFlightRef.current; + const job = (async () => { + setState((s) => ({ ...s, openingProjectId: id })); + try { + projectDataEpochRef.current += 1; + const res = await api.invoke(ipcChannels.project.open, { projectId: id }); + setState((s) => ({ + ...s, + project: res.project, + selectedSceneId: res.project.currentSceneId, + openingProjectId: null, + })); + } catch { + setState((s) => ({ ...s, openingProjectId: null })); + } + })(); + openInFlightRef.current = job.finally(() => { + if (openInFlightRef.current === job) openInFlightRef.current = null; + }); + return openInFlightRef.current; }; const closeProject = async () => { @@ -147,41 +209,51 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) }; const createScene = async () => { + if (createSceneInFlightRef.current) return createSceneInFlightRef.current; const p = projectRef.current; if (!p) return; - const sceneId = randomId('scene') as SceneId; - const scene: Scene = { - id: sceneId, - title: `Новая сцена`, - description: '', - previewAssetId: null, - previewThumbAssetId: null, - previewAssetType: null, - previewVideoAutostart: false, - previewRotationDeg: 0, - darkenScene: false, - media: { videos: [], audios: [] }, - settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, - connections: [], - layout: { x: 0, y: 0 }, - }; - - await api.invoke(ipcChannels.project.updateScene, { - sceneId, - patch: { - title: scene.title, - description: scene.description, - media: scene.media, - settings: scene.settings, - layout: scene.layout, - previewAssetId: scene.previewAssetId, - previewAssetType: scene.previewAssetType, - previewVideoAutostart: scene.previewVideoAutostart, - }, + const job = (async () => { + setState((s) => ({ ...s, creatingScene: true })); + const sceneId = randomId('scene') as SceneId; + const scene: Scene = { + id: sceneId, + title: `Новая сцена`, + description: '', + previewAssetId: null, + previewThumbAssetId: null, + previewAssetType: null, + previewVideoAutostart: false, + previewRotationDeg: 0, + darkenScene: false, + media: { videos: [], audios: [] }, + settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, + connections: [], + layout: { x: 0, y: 0 }, + }; + await api.invoke(ipcChannels.project.updateScene, { + sceneId, + patch: { + title: scene.title, + description: scene.description, + media: scene.media, + settings: scene.settings, + layout: scene.layout, + previewAssetId: scene.previewAssetId, + previewAssetType: scene.previewAssetType, + previewVideoAutostart: scene.previewVideoAutostart, + }, + }); + await api.invoke(ipcChannels.project.setCurrentScene, { sceneId }); + const res = await api.invoke(ipcChannels.project.get, {}); + setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId })); + })(); + createSceneInFlightRef.current = job.finally(() => { + if (createSceneInFlightRef.current === job) { + createSceneInFlightRef.current = null; + setState((s) => ({ ...s, creatingScene: false })); + } }); - await api.invoke(ipcChannels.project.setCurrentScene, { sceneId }); - const res = await api.invoke(ipcChannels.project.get, {}); - setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId })); + return createSceneInFlightRef.current; }; const selectScene = async (id: SceneId) => { @@ -276,7 +348,17 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) const importScenePreview = async (sceneId: SceneId) => { const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId }); setState((s) => ({ ...s, project: res.project })); + if (res.assetId !== null && res.background) { + setState((s) => ({ + ...s, + scenePreviewImports: { + ...s.scenePreviewImports, + [sceneId]: { assetId: res.assetId, phase: 'queued' }, + }, + })); + } await refreshProjects(); + return { assetId: res.assetId, background: res.background }; }; const clearScenePreview = async (sceneId: SceneId) => { diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 8f09e5e..5455c29 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -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 };