diff --git a/app/main/index.ts b/app/main/index.ts index 2703bb2..d61abf4 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -172,6 +172,27 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise< async function main() { await app.whenReady(); + /** + * `ZipProjectStore` пишет `project.json` в кэш синхронно с мутациями, а упаковку `.dnd.zip` + * откладывает (`queueSave`, ~250 мс). Без финального `saveNow()` при выходе на диске + * остаётся старый zip — при следующем открытии кэш пересоздаётся из него и теряются + * недавние поля (в т.ч. `campaignAudios`). + */ + let appQuittingAfterPendingSave = false; + app.on('before-quit', (e) => { + if (appQuittingAfterPendingSave) return; + e.preventDefault(); + appQuittingAfterPendingSave = true; + void projectStore + .saveNow() + .catch((err: unknown) => { + console.error('[before-quit] saveNow failed', err); + }) + .finally(() => { + app.quit(); + }); + }); + const licenseService = new LicenseService(app.getPath('userData')); setLicenseAssert(() => { licenseService.assertForIpc(); @@ -286,6 +307,30 @@ async function main() { emitSessionState(); return result; }); + registerHandler(ipcChannels.project.importCampaignAudio, async () => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile', 'multiSelections'], + filters: [ + { + name: 'Аудио', + extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'], + }, + ], + }); + if (canceled || filePaths.length === 0) { + const project = projectStore.getOpenProject(); + if (!project) throw new Error('No open project'); + return { canceled: true as const, project, imported: [] }; + } + const result = await projectStore.importCampaignAudioFiles(filePaths); + emitSessionState(); + return { canceled: false as const, ...result }; + }); + registerHandler(ipcChannels.project.updateCampaignAudios, async ({ audios }) => { + const project = await projectStore.setCampaignAudios(audios); + emitSessionState(); + return { project }; + }); registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => { const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], diff --git a/app/main/project/assetPrune.test.ts b/app/main/project/assetPrune.test.ts index d14e459..cdaad8a 100644 --- a/app/main/project/assetPrune.test.ts +++ b/app/main/project/assetPrune.test.ts @@ -20,9 +20,10 @@ void test('collectReferencedAssetIds: превью, видео и аудио', ( }, }, }, + campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }], } as unknown as Project; const s = collectReferencedAssetIds(p); - assert.deepEqual([...s].sort(), ['a1', 'pr', 'v1'].sort()); + assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'v1'].sort()); }); void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => { @@ -62,11 +63,13 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и const prev: Project = { ...base, scenes: {}, + campaignAudios: [], assets: { orphan: asset } as Project['assets'], }; const next: Project = { ...base, scenes: {}, + campaignAudios: [], assets: { orphan: asset } as Project['assets'], }; @@ -111,7 +114,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен } as unknown as Project; const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] }; - const next: Project = { ...base, assets: {} as Project['assets'] }; + const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] }; const out = await reconcileAssetFiles(prev, next, tmp); assert.deepEqual(out.assets, {}); diff --git a/app/main/project/assetPrune.ts b/app/main/project/assetPrune.ts index 87c1330..b35b1ff 100644 --- a/app/main/project/assetPrune.ts +++ b/app/main/project/assetPrune.ts @@ -12,6 +12,7 @@ export function collectReferencedAssetIds(p: Project): Set { for (const vid of sc.media.videos) refs.add(vid); for (const au of sc.media.audios) refs.add(au.assetId); } + for (const au of p.campaignAudios) refs.add(au.assetId); return refs; } diff --git a/app/main/project/zipRead.test.ts b/app/main/project/zipRead.test.ts index c118501..0f5a029 100644 --- a/app/main/project/zipRead.test.ts +++ b/app/main/project/zipRead.test.ts @@ -51,3 +51,48 @@ void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', as await fs.rm(tmp, { recursive: true, force: true }); }); + +void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-')); + const zipPath = path.join(tmp, 'test.dnd.zip'); + const assetId = 'audio_asset_1'; + const minimal = { + id: 'p1', + meta: { + name: 'n', + fileBaseName: 'f', + createdAt: '2020-01-01', + updatedAt: '2020-01-01', + createdWithAppVersion: '1', + appVersion: '1', + schemaVersion: PROJECT_SCHEMA_VERSION, + }, + scenes: {}, + assets: {}, + campaignAudios: [{ assetId, autoplay: true, loop: false }], + currentSceneId: null, + currentGraphNodeId: null, + sceneGraphNodes: [], + sceneGraphEdges: [], + }; + + const zipfile = new ZipFile(); + zipfile.addBuffer(Buffer.from(JSON.stringify(minimal), 'utf8'), 'project.json', { compressionLevel: 9 }); + const out = fssync.createWriteStream(zipPath); + const done = new Promise((resolve, reject) => { + out.on('close', resolve); + out.on('error', reject); + }); + zipfile.outputStream.pipe(out); + zipfile.end(); + await done; + + const p = await readProjectJsonFromZip(zipPath); + assert.ok(Array.isArray((p as { campaignAudios?: unknown }).campaignAudios)); + assert.deepEqual( + (p as { campaignAudios: typeof minimal.campaignAudios }).campaignAudios, + minimal.campaignAudios, + ); + + await fs.rm(tmp, { recursive: true, force: true }); +}); diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index faa3e85..bd43ae7 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -23,3 +23,19 @@ void test('zipStore: legacy migration moves or copy\\+rm so deleted projects are assert.match(src, /rmWithRetries\(fs\.rm, from, \{ force: true \}\)/); assert.match(src, /не «возрождались»/); }); + +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, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/); + assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/); + assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/); +}); + +void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => { + const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); + assert.match(src, /async exportProjectZipToPath/); + assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*await this\.saveNow\(\);\s*\}/); + assert.match(src, /await fs\.copyFile\(src, dest\)/); +}); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index e2e4d31..ba2698d 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -165,6 +165,7 @@ export class ZipProjectStore { }, scenes: {}, assets: {}, + campaignAudios: [], currentSceneId: null, currentGraphNodeId: null, sceneGraphNodes: [], @@ -182,6 +183,11 @@ export class ZipProjectStore { async openProjectById(projectId: ProjectId): 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); @@ -589,6 +595,61 @@ export class ZipProjectStore { return { project: latest, imported: staged }; } + /** + * Copies audio files into cache `assets/` and registers them on the project as campaign audio. + */ + async importCampaignAudioFiles(filePaths: string[]): Promise<{ project: Project; imported: MediaAsset[] }> { + const open = this.openProject; + if (!open) throw new Error('No open project'); + if (filePaths.length === 0) { + return { project: open.project, imported: [] }; + } + + const staged: MediaAsset[] = []; + for (const filePath of filePaths) { + const kind = classifyMediaPath(filePath); + if (kind?.type !== 'audio') continue; + const buf = await fs.readFile(filePath); + const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); + const id = asAssetId(this.randomId()); + const orig = path.basename(filePath); + const safeOrig = sanitizeFileName(orig); + const relPath = `assets/${id}_${safeOrig}`; + const abs = path.join(open.cacheDir, relPath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.copyFile(filePath, abs); + staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); + } + + if (staged.length === 0) { + return { project: open.project, imported: [] }; + } + + await this.updateProject((p) => { + const assets = { ...p.assets }; + const campaignAudios = [...p.campaignAudios]; + for (const asset of staged) { + assets[asset.id] = asset; + if (asset.type !== 'audio') continue; + campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true }); + } + return { ...p, assets, campaignAudios }; + }); + + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return { project: latest, imported: staged }; + } + + async setCampaignAudios(audios: Project['campaignAudios']): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] })); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + async saveNow(): Promise { const open = this.openProject; if (!open) return; @@ -777,6 +838,10 @@ export class ZipProjectStore { /** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */ async exportProjectZipToPath(projectId: ProjectId, destinationPath: string): Promise { await this.ensureRoots(); + // If exporting the currently open project, make sure pending debounced pack is flushed. + if (this.openProject?.id === projectId) { + await this.saveNow(); + } const list = await this.listProjects(); const entry = list.find((p) => p.id === projectId); if (!entry) { @@ -982,6 +1047,18 @@ function normalizeProject(p: Project): Project { } sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes); const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null; + const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios; + const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : []) + .map((a) => { + if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false }; + if (a && typeof a === 'object') { + const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean }; + if (!obj.assetId) return null; + return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) }; + } + return null; + }) + .filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x)); const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string }; const createdWithAppVersion = (() => { const c = metaRaw.createdWithAppVersion?.trim(); @@ -1001,6 +1078,7 @@ function normalizeProject(p: Project): Project { schemaVersion: PROJECT_SCHEMA_VERSION, }, scenes, + campaignAudios, sceneGraphNodes, sceneGraphEdges, currentGraphNodeId, diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index b472309..e5e1aaf 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -49,10 +49,18 @@ export function ControlApp() { const historyRef = useRef([]); const suppressNextHistoryPushRef = useRef(false); const [history, setHistory] = useState([]); - const audioElsRef = useRef>(new Map()); - const audioMetaRef = useRef>(new Map()); - const [audioStateTick, setAudioStateTick] = useState(0); - const audioLoadRunRef = useRef(0); + const sceneAudioElsRef = useRef>(new Map()); + const sceneAudioMetaRef = useRef>(new Map()); + const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0); + const sceneAudioLoadRunRef = useRef(0); + + const campaignAudioElsRef = useRef>(new Map()); + const campaignAudioMetaRef = useRef>(new Map()); + const [campaignAudioStateTick, setCampaignAudioStateTick] = useState(0); + const campaignAudioLoadRunRef = useRef(0); + /** Snapshot of `!el.paused` per assetId when scene music takes over; used to resume when `allowCampaignAudio` is true again. */ + const campaignResumeAfterSceneRef = useRef | null>(null); + const allowCampaignAudioRef = useRef(true); const audioUnmountRef = useRef(false); const previewHostRef = useRef(null); const previewVideoRef = useRef(null); @@ -137,6 +145,17 @@ export function ControlApp() { project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined; const isVideoPreviewScene = currentScene?.previewAssetType === 'video'; const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]); + // Keep this memo as narrow as possible: project changes on scene switch, + // but campaign audio list/config often does not. + const campaignAudioRefs = useMemo(() => project?.campaignAudios ?? [], [project?.campaignAudios]); + const allowCampaignAudio = !sceneAudioRefs.some((a) => a.autoplay); + allowCampaignAudioRef.current = allowCampaignAudio; + + const campaignAudioSpecKey = useMemo( + () => + campaignAudioRefs.map((r) => `${r.assetId}:${r.loop ? '1' : '0'}:${r.autoplay ? '1' : '0'}`).join('|'), + [campaignAudioRefs], + ); const sceneAudios = useMemo(() => { if (!project) return []; @@ -150,14 +169,26 @@ export function ControlApp() { ); }, [project, sceneAudioRefs]); - useEffect(() => { - audioLoadRunRef.current += 1; - const runId = audioLoadRunRef.current; + const campaignAudios = useMemo(() => { + if (!project) return []; + return campaignAudioRefs + .map((r) => { + const a = project.assets[r.assetId]; + return a?.type === 'audio' ? { ref: r, asset: a } : null; + }) + .filter((x): x is { ref: (typeof campaignAudioRefs)[number]; asset: NonNullable['asset'] } => + Boolean(x), + ); + }, [campaignAudioRefs, project]); - const oldEls = new Map(audioElsRef.current); - audioElsRef.current = new Map(); - audioMetaRef.current.clear(); - setAudioStateTick((x) => x + 1); + useEffect(() => { + sceneAudioLoadRunRef.current += 1; + const runId = sceneAudioLoadRunRef.current; + + const oldEls = new Map(sceneAudioElsRef.current); + sceneAudioElsRef.current = new Map(); + sceneAudioMetaRef.current.clear(); + setSceneAudioStateTick((x) => x + 1); const FADE_OUT_MS = 450; const fadeOutCtl = { raf: 0, cancelled: false }; @@ -213,24 +244,24 @@ export function ControlApp() { const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = []; for (const item of sceneAudioRefs) { const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId }); - if (audioLoadRunRef.current !== runId) return; + if (sceneAudioLoadRunRef.current !== runId) return; if (!r.url) continue; const el = new Audio(r.url); el.loop = item.loop; el.preload = 'auto'; el.volume = item.autoplay ? 0 : 1; - audioMetaRef.current.set(item.assetId, { lastPlayError: null }); - el.addEventListener('play', () => setAudioStateTick((x) => x + 1)); - el.addEventListener('pause', () => setAudioStateTick((x) => x + 1)); - el.addEventListener('ended', () => setAudioStateTick((x) => x + 1)); - el.addEventListener('canplay', () => setAudioStateTick((x) => x + 1)); - el.addEventListener('error', () => setAudioStateTick((x) => x + 1)); + sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null }); + el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1)); + el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1)); + el.addEventListener('ended', () => setSceneAudioStateTick((x) => x + 1)); + el.addEventListener('canplay', () => setSceneAudioStateTick((x) => x + 1)); + el.addEventListener('error', () => setSceneAudioStateTick((x) => x + 1)); loaded.push({ ref: item, el }); - audioElsRef.current.set(item.assetId, el); + sceneAudioElsRef.current.set(item.assetId, el); } - setAudioStateTick((x) => x + 1); + setSceneAudioStateTick((x) => x + 1); for (const { ref, el } of loaded) { - if (audioLoadRunRef.current !== runId) { + if (sceneAudioLoadRunRef.current !== runId) { try { el.pause(); el.currentTime = 0; @@ -244,13 +275,13 @@ export function ControlApp() { try { await el.play(); } catch { - const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; - audioMetaRef.current.set(ref.assetId, { + const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; + sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: 'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.', }); - setAudioStateTick((x) => x + 1); + setSceneAudioStateTick((x) => x + 1); try { el.volume = 1; } catch { @@ -258,7 +289,7 @@ export function ControlApp() { } continue; } - if (audioLoadRunRef.current !== runId || audioUnmountRef.current) { + if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) { try { el.volume = 1; } catch { @@ -268,7 +299,7 @@ export function ControlApp() { } const tIn0 = performance.now(); const tickIn = (now: number): void => { - if (audioLoadRunRef.current !== runId || audioUnmountRef.current) { + if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) { try { el.volume = 1; } catch { @@ -294,19 +325,203 @@ export function ControlApp() { }; }, [api, currentScene, project, sceneAudioRefs]); + // Campaign elements: lifecycle depends only on campaign track list/config, not scene or allowCampaignAudio + // (scene music uses allowCampaignAudioRef + separate pause/resume effect). + // Spec is encoded in campaignAudioSpecKey; campaignAudioRefs is intentionally omitted to avoid scene-switch churn. + useEffect(() => { + campaignAudioLoadRunRef.current += 1; + const runId = campaignAudioLoadRunRef.current; + + const oldEls = new Map(campaignAudioElsRef.current); + campaignAudioElsRef.current = new Map(); + campaignAudioMetaRef.current.clear(); + setCampaignAudioStateTick((x) => x + 1); + + for (const el of oldEls.values()) { + try { + el.pause(); + el.volume = 1; + } catch { + // ignore + } + } + + if (campaignAudioSpecKey === '') { + return; + } + + void (async () => { + const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = []; + for (const item of campaignAudioRefs) { + const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId }); + if (campaignAudioLoadRunRef.current !== runId) return; + if (!r.url) continue; + const el = new Audio(r.url); + el.loop = item.loop; + el.preload = 'auto'; + el.volume = item.autoplay ? 0 : 1; + campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null }); + el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1)); + el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1)); + el.addEventListener('ended', () => setCampaignAudioStateTick((x) => x + 1)); + el.addEventListener('canplay', () => setCampaignAudioStateTick((x) => x + 1)); + el.addEventListener('error', () => setCampaignAudioStateTick((x) => x + 1)); + loaded.push({ ref: item, el }); + campaignAudioElsRef.current.set(item.assetId, el); + } + setCampaignAudioStateTick((x) => x + 1); + + if (!allowCampaignAudioRef.current) return; + + for (const { ref, el } of loaded) { + if (campaignAudioLoadRunRef.current !== runId) return; + if (!ref.autoplay) continue; + try { + await el.play(); + } catch { + const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; + campaignAudioMetaRef.current.set(ref.assetId, { + ...m, + lastPlayError: + 'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.', + }); + setCampaignAudioStateTick((x) => x + 1); + try { + el.volume = 1; + } catch { + // ignore + } + continue; + } + if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) { + try { + el.volume = 1; + } catch { + // ignore + } + continue; + } + const tIn0 = performance.now(); + const tickIn = (now: number): void => { + if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) { + try { + el.volume = 1; + } catch { + // ignore + } + return; + } + const u = Math.min(1, (now - tIn0) / 550); + try { + el.volume = u; + } catch { + // ignore + } + if (u < 1) window.requestAnimationFrame(tickIn); + }; + window.requestAnimationFrame(tickIn); + } + })(); + // Deps: api + campaignAudioSpecKey only; list iteration uses current campaignAudioRefs (stable while spec is stable). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api, campaignAudioSpecKey]); + + useEffect(() => { + if (allowCampaignAudio) { + const snap = campaignResumeAfterSceneRef.current; + campaignResumeAfterSceneRef.current = null; + if (snap && snap.size > 0) { + void (async () => { + for (const [assetId, wasPlaying] of snap) { + if (!wasPlaying) continue; + const el = campaignAudioElsRef.current.get(assetId) ?? null; + if (!el) continue; + try { + // If a track was created with autoplay volume ramp but never started yet, + // ensure it is audible on resume. + if (el.volume === 0) el.volume = 1; + await el.play(); + } catch { + // ignore; user can press play + } + } + setCampaignAudioStateTick((x) => x + 1); + })(); + } + // If we entered a scene that allows campaign audio and there was no "resume snapshot" + // (e.g. first scene had autoplay scene music so campaign autoplay was blocked), + // start campaign tracks that have autoplay enabled. + if (!snap || snap.size === 0) { + void (async () => { + for (const ref of campaignAudioRefs) { + if (!ref.autoplay) continue; + const el = campaignAudioElsRef.current.get(ref.assetId) ?? null; + if (!el) continue; + if (!el.paused) continue; + try { + el.volume = 0; + } catch { + // ignore + } + try { + await el.play(); + } catch { + // ignore; user can press play + continue; + } + const tIn0 = performance.now(); + const tickIn = (now: number): void => { + if (!allowCampaignAudioRef.current || audioUnmountRef.current) return; + const u = Math.min(1, (now - tIn0) / 550); + try { + el.volume = u; + } catch { + // ignore + } + if (u < 1) window.requestAnimationFrame(tickIn); + }; + window.requestAnimationFrame(tickIn); + } + setCampaignAudioStateTick((x) => x + 1); + })(); + } + return; + } + // Scene has its own audio: remember what was playing, then pause campaign. (keep currentTime) + const snap = new Map(); + for (const [assetId, el] of campaignAudioElsRef.current) { + snap.set(assetId, !el.paused); + } + campaignResumeAfterSceneRef.current = snap; + for (const el of campaignAudioElsRef.current.values()) { + try { + el.pause(); + } catch { + // ignore + } + } + setCampaignAudioStateTick((x) => x + 1); + // Intentionally not depending on campaignAudioRefs: this effect is about scene-driven pausing/resuming. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allowCampaignAudio]); + const anyPlaying = useMemo(() => { - for (const el of audioElsRef.current.values()) { + for (const el of sceneAudioElsRef.current.values()) { + if (!el.paused) return true; + } + for (const el of campaignAudioElsRef.current.values()) { if (!el.paused) return true; } return false; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [audioStateTick]); + }, [campaignAudioStateTick, sceneAudioStateTick]); useEffect(() => { if (!anyPlaying) return; let raf = 0; const tick = () => { - setAudioStateTick((x) => x + 1); + setSceneAudioStateTick((x) => x + 1); + setCampaignAudioStateTick((x) => x + 1); raf = window.requestAnimationFrame(tick); }; raf = window.requestAnimationFrame(tick); @@ -326,10 +541,16 @@ export function ControlApp() { return () => ro.disconnect(); }, []); - function audioStatus(assetId: string): { label: string; detail?: string } { - const el = audioElsRef.current.get(assetId) ?? null; + function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } { + const el = + group === 'scene' + ? (sceneAudioElsRef.current.get(assetId) ?? null) + : (campaignAudioElsRef.current.get(assetId) ?? null); if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' }; - const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null }; + const meta = + group === 'scene' + ? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null }) + : (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null }); if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError }; if (el.error) return { @@ -1075,13 +1296,16 @@ export function ControlApp() {
Музыка
+
МУЗЫКА СЦЕНЫ
+
{sceneAudios.length === 0 ? (
В текущей сцене нет аудио.
- ) : ( + ) : null} + {sceneAudios.length > 0 ? (
{sceneAudios.map(({ ref, asset }) => { - const el = audioElsRef.current.get(ref.assetId) ?? null; - const st = audioStatus(ref.assetId); + const el = sceneAudioElsRef.current.get(ref.assetId) ?? null; + const st = audioStatus('scene', ref.assetId); const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0; const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0; const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; @@ -1106,7 +1330,7 @@ export function ControlApp() { if (!dur) return; if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5); if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5); - setAudioStateTick((x) => x + 1); + setSceneAudioStateTick((x) => x + 1); }} onClick={(e) => { if (!el) return; @@ -1114,7 +1338,7 @@ export function ControlApp() { const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); const next = (e.clientX - rect.left) / rect.width; el.currentTime = Math.max(0, Math.min(dur, next * dur)); - setAudioStateTick((x) => x + 1); + setSceneAudioStateTick((x) => x + 1); }} className={[ styles.audioScrub, @@ -1137,15 +1361,17 @@ export function ControlApp() { variant="primary" onClick={() => { if (!el) return; - const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; - audioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null }); + const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; + sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null }); void el.play().catch(() => { - const mm = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; - audioMetaRef.current.set(ref.assetId, { + const mm = + sceneAudioMetaRef.current.get(ref.assetId) ?? + ({ lastPlayError: null } as const); + sceneAudioMetaRef.current.set(ref.assetId, { ...mm, lastPlayError: 'Не удалось запустить.', }); - setAudioStateTick((x) => x + 1); + setSceneAudioStateTick((x) => x + 1); }); }} > @@ -1164,7 +1390,122 @@ export function ControlApp() { if (!el) return; el.pause(); el.currentTime = 0; - setAudioStateTick((x) => x + 1); + setSceneAudioStateTick((x) => x + 1); + }} + > + ■ + +
+
+ ); + })} +
+ ) : null} + +
+
МУЗЫКА ИГРЫ
+
+ {campaignAudios.length === 0 ? ( +
В игре нет аудио.
+ ) : ( +
+ {campaignAudios.map(({ ref, asset }) => { + const el = campaignAudioElsRef.current.get(ref.assetId) ?? null; + const st = audioStatus('campaign', ref.assetId); + const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0; + const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0; + const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; + return ( +
+
+
{asset.originalName}
+
+
{ref.autoplay ? 'Авто' : 'Ручн.'}
+
{ref.loop ? 'Цикл' : 'Один раз'}
+
{st.label}
+ {!allowCampaignAudio ?
Пауза (сцена)
: null} +
+
+
0 ? Math.round(dur) : 0} + aria-valuenow={Math.round(cur)} + tabIndex={0} + onKeyDown={(e) => { + if (!el) return; + if (!dur) return; + if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5); + if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5); + setCampaignAudioStateTick((x) => x + 1); + }} + onClick={(e) => { + if (!el) return; + if (!dur) return; + const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); + const next = (e.clientX - rect.left) / rect.width; + el.currentTime = Math.max(0, Math.min(dur, next * dur)); + setCampaignAudioStateTick((x) => x + 1); + }} + className={[ + styles.audioScrub, + dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault, + ].join(' ')} + title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'} + > +
+
+
+
{formatTime(cur)}
+
{dur ? formatTime(dur) : '—:—'}
+
+
+
+ + + + +
+ ))} +
+ )} + +
+
+ ); +} + function SceneInspector({ title, description, @@ -988,7 +1147,7 @@ function SceneInspector({ />
ПРЕВЬЮ СЦЕНЫ
-
Отдельный файл изображения (PNG, JPG, WebP, GIF и т.д.).
+
Файл изображения (PNG, JPG, WebP, GIF и т.д.).
{previewUrl && previewAssetType === 'image' ? ( @@ -1159,7 +1318,13 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
{url && scene.previewAssetType === 'image' ? (
- +
) : url && scene.previewAssetType === 'video' ? (
diff --git a/app/renderer/editor/graph/SceneGraph.tsx b/app/renderer/editor/graph/SceneGraph.tsx index b9cd791..752f42c 100644 --- a/app/renderer/editor/graph/SceneGraph.tsx +++ b/app/renderer/editor/graph/SceneGraph.tsx @@ -19,19 +19,29 @@ import ReactFlow, { import 'reactflow/dist/style.css'; import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules'; -import type { - AssetId, - GraphNodeId, - Scene, - SceneGraphEdge, - SceneGraphNode, - SceneId, -} from '../../../shared/types'; +import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types'; import { RotatedImage } from '../../shared/RotatedImage'; import { useAssetUrl } from '../../shared/useAssetImageUrl'; import styles from './SceneGraph.module.css'; +/** Поля сцены, нужные только для карточки узла графа (без описания и прочего). */ +export type SceneGraphSceneAudioSummary = { + assetId: AssetId; + loop: boolean; + autoplay: boolean; +}; + +export type SceneGraphSceneCard = { + title: string; + previewAssetId: AssetId | null; + previewAssetType: 'image' | 'video' | null; + previewVideoAutostart: boolean; + previewRotationDeg: 0 | 90 | 180 | 270; + loopVideo: boolean; + audios: readonly SceneGraphSceneAudioSummary[]; +}; + /** MIME для перетаскивания сцены из списка на граф (см. EditorApp). */ export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id'; @@ -42,7 +52,7 @@ const SCENE_CARD_H = 248; export type SceneGraphProps = { sceneGraphNodes: SceneGraphNode[]; sceneGraphEdges: SceneGraphEdge[]; - sceneById: Record; + sceneCardById: Record; currentSceneId: SceneId | null; onCurrentSceneChange: (id: SceneId) => void; onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void; @@ -132,12 +142,21 @@ function SceneCardNode({ data }: NodeProps) { {url && data.previewAssetType === 'image' ? (
{data.previewRotationDeg === 0 ? ( - + ) : ( )} @@ -261,7 +280,7 @@ function GraphZoomToolbar() { function SceneGraphCanvas({ sceneGraphNodes, sceneGraphEdges, - sceneById, + sceneCardById, currentSceneId, onCurrentSceneChange, onConnect, @@ -291,33 +310,33 @@ function SceneGraphCanvas({ const desiredNodes = useMemo[]>(() => { return sceneGraphNodes.map((gn) => { - const s = sceneById[gn.sceneId]; + const c = sceneCardById[gn.sceneId]; const active = gn.sceneId === currentSceneId; - const audios = s?.media.audios ?? []; + const audios = c?.audios ?? []; return { id: gn.id, type: 'sceneCard', position: { x: gn.x, y: gn.y }, data: { sceneId: gn.sceneId, - title: s?.title ?? '', + title: c?.title ?? '', active, - previewAssetId: s?.previewAssetId ?? null, - previewAssetType: s?.previewAssetType ?? null, - previewVideoAutostart: s?.previewVideoAutostart ?? false, - previewRotationDeg: s?.previewRotationDeg ?? 0, + previewAssetId: c?.previewAssetId ?? null, + previewAssetType: c?.previewAssetType ?? null, + previewVideoAutostart: c?.previewVideoAutostart ?? false, + previewRotationDeg: c?.previewRotationDeg ?? 0, isStartScene: gn.isStartScene, hasSceneAudio: audios.length >= 1, - previewIsVideo: s?.previewAssetType === 'video', + previewIsVideo: c?.previewAssetType === 'video', hasAnyAudioLoop: audios.some((a) => a.loop), hasAnyAudioAutoplay: audios.some((a) => a.autoplay), - showPreviewVideoAutostart: s?.previewAssetType === 'video' ? s.previewVideoAutostart : false, - showPreviewVideoLoop: s?.previewAssetType === 'video' ? s.settings.loopVideo : false, + showPreviewVideoAutostart: c?.previewAssetType === 'video' ? c.previewVideoAutostart : false, + showPreviewVideoLoop: c?.previewAssetType === 'video' ? c.loopVideo : false, }, style: { padding: 0, background: 'transparent', border: 'none' }, }; }); - }, [currentSceneId, sceneById, sceneGraphNodes]); + }, [currentSceneId, sceneCardById, sceneGraphNodes]); const desiredEdges = useMemo(() => { return sceneGraphEdges.map((e) => ({ diff --git a/app/renderer/editor/graph/sceneCardById.test.ts b/app/renderer/editor/graph/sceneCardById.test.ts new file mode 100644 index 0000000..94eeaba --- /dev/null +++ b/app/renderer/editor/graph/sceneCardById.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { Project } from '../../../shared/types'; +import type { AssetId, SceneId } from '../../../shared/types/ids'; + +import { buildNextSceneCardById } from './sceneCardById'; + +function minimalProject(overrides: Partial): Project { + return { + id: 'p1' as unknown as Project['id'], + meta: { + name: 'n', + fileBaseName: 'f', + createdAt: '', + updatedAt: '', + createdWithAppVersion: '1', + appVersion: '1', + schemaVersion: 1 as unknown as Project['meta']['schemaVersion'], + }, + scenes: {}, + assets: {}, + campaignAudios: [], + currentSceneId: null, + currentGraphNodeId: null, + sceneGraphNodes: [], + sceneGraphEdges: [], + ...overrides, + } as unknown as Project; +} + +void test('buildNextSceneCardById: does not change refs when irrelevant fields change', () => { + const sid = 's1' as SceneId; + const base = minimalProject({ + scenes: { + [sid]: { + id: sid, + title: 'T', + description: 'A', + media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true }] }, + settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, + connections: [], + layout: { x: 0, y: 0 }, + previewAssetId: null, + previewAssetType: null, + previewVideoAutostart: false, + previewRotationDeg: 0, + }, + } as unknown as Project['scenes'], + }); + + const first = buildNextSceneCardById({}, base); + const card1 = first[sid]; + assert.ok(card1); + + const changedOnlyDescription = minimalProject({ + ...base, + scenes: { + ...base.scenes, + [sid]: { ...base.scenes[sid], description: 'B' }, + }, + }); + const second = buildNextSceneCardById(first, changedOnlyDescription); + + assert.equal(second, first, 'record identity should be reused'); + assert.equal(second[sid], card1, 'card identity should be reused'); +}); + +void test('buildNextSceneCardById: changes card when title changes', () => { + const sid = 's1' as SceneId; + const base = minimalProject({ + scenes: { + [sid]: { + id: sid, + title: 'T', + description: '', + media: { videos: [], audios: [] }, + settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, + connections: [], + layout: { x: 0, y: 0 }, + previewAssetId: null, + previewAssetType: null, + previewVideoAutostart: false, + previewRotationDeg: 0, + }, + } as unknown as Project['scenes'], + }); + const first = buildNextSceneCardById({}, base); + const card1 = first[sid]; + + const changedTitle = minimalProject({ + ...base, + scenes: { + ...base.scenes, + [sid]: { ...base.scenes[sid], title: 'T2' }, + }, + }); + const second = buildNextSceneCardById(first, changedTitle); + + assert.notEqual(second[sid], card1); +}); diff --git a/app/renderer/editor/graph/sceneCardById.ts b/app/renderer/editor/graph/sceneCardById.ts new file mode 100644 index 0000000..8214941 --- /dev/null +++ b/app/renderer/editor/graph/sceneCardById.ts @@ -0,0 +1,68 @@ +import type { Project } from '../../../shared/types'; +import type { SceneId } from '../../../shared/types/ids'; + +import type { SceneGraphSceneAudioSummary, SceneGraphSceneCard } from './SceneGraph'; + +export function stableSceneGraphAudios( + prevCard: SceneGraphSceneCard | undefined, + nextRaw: SceneGraphSceneAudioSummary[], +): readonly SceneGraphSceneAudioSummary[] { + if (!prevCard) return nextRaw; + const pa = prevCard.audios; + if (pa.length !== nextRaw.length) return nextRaw; + for (let i = 0; i < nextRaw.length; i++) { + const p = pa[i]; + const n = nextRaw[i]; + if (p?.assetId !== n?.assetId || p?.loop !== n?.loop || p?.autoplay !== n?.autoplay) return nextRaw; + } + return pa; +} + +export function buildNextSceneCardById( + prevRecord: Record, + project: Project, +): Record { + const nextMap: Record = {}; + + for (const id of Object.keys(project.scenes) as SceneId[]) { + const s = project.scenes[id]; + if (!s) continue; + const prevCard = prevRecord[id]; + const nextAudiosRaw: SceneGraphSceneAudioSummary[] = s.media.audios.map((a) => ({ + assetId: a.assetId, + loop: a.loop, + autoplay: a.autoplay, + })); + const audios = stableSceneGraphAudios(prevCard, nextAudiosRaw); + const loopVideo = s.settings.loopVideo; + if ( + prevCard?.title === s.title && + prevCard.previewAssetId === s.previewAssetId && + prevCard.previewAssetType === s.previewAssetType && + prevCard.previewVideoAutostart === s.previewVideoAutostart && + prevCard.previewRotationDeg === s.previewRotationDeg && + prevCard.loopVideo === loopVideo && + prevCard.audios === audios + ) { + nextMap[id] = prevCard; + } else { + nextMap[id] = { + title: s.title, + previewAssetId: s.previewAssetId, + previewAssetType: s.previewAssetType, + previewVideoAutostart: s.previewVideoAutostart, + previewRotationDeg: s.previewRotationDeg, + loopVideo, + audios, + }; + } + } + + const prevKeys = Object.keys(prevRecord); + const nextKeys = Object.keys(nextMap); + const reuseRecord = + prevKeys.length === nextKeys.length && + nextKeys.every((k) => prevRecord[k as SceneId] === nextMap[k as SceneId]); + + return reuseRecord ? prevRecord : nextMap; +} diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index ff4e87f..a9dbac3 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -19,6 +19,8 @@ type Actions = { closeProject: () => Promise; createScene: () => Promise; selectScene: (id: SceneId) => Promise; + importCampaignAudio: () => Promise; + updateCampaignAudios: (next: Project['campaignAudios']) => Promise; updateScene: ( sceneId: SceneId, patch: { @@ -129,6 +131,22 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: id }); }; + const importCampaignAudio = async () => { + const res = await api.invoke(ipcChannels.project.importCampaignAudio, {}); + if (res.canceled) return; + if (res.imported.length === 0) { + window.alert('Аудио не добавлено. Проверьте формат файла.'); + } + setState((s) => ({ ...s, project: res.project })); + await refreshProjects(); + }; + + const updateCampaignAudios = async (next: Project['campaignAudios']) => { + const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next }); + setState((s) => ({ ...s, project: res.project })); + await refreshProjects(); + }; + const updateScene = async ( sceneId: SceneId, patch: { @@ -299,6 +317,8 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action closeProject, createScene, selectScene, + importCampaignAudio, + updateCampaignAudios, updateScene, updateConnections, importMediaToScene, diff --git a/app/renderer/shared/RotatedImage.tsx b/app/renderer/shared/RotatedImage.tsx index 6fa19f3..a59a3b3 100644 --- a/app/renderer/shared/RotatedImage.tsx +++ b/app/renderer/shared/RotatedImage.tsx @@ -9,6 +9,8 @@ type RotatedImageProps = { rotationDeg: 0 | 90 | 180 | 270; mode: Mode; alt?: string; + loading?: React.ImgHTMLAttributes['loading']; + decoding?: React.ImgHTMLAttributes['decoding']; /** Высота/ширина полностью контролируются родителем. */ style?: React.CSSProperties; /** Прямоугольник видимого контента (contain/cover) внутри контейнера. */ @@ -40,6 +42,8 @@ export function RotatedImage({ rotationDeg, mode, alt = '', + loading, + decoding, style, onContentRectChange, }: RotatedImageProps) { @@ -91,6 +95,8 @@ export function RotatedImage({ {alt}; + res: { canceled: boolean; project: Project; imported: MediaAsset[] }; + }; + [ipcChannels.project.updateCampaignAudios]: { + req: { audios: Project['campaignAudios'] }; + res: { project: Project }; + }; [ipcChannels.project.importScenePreview]: { req: { sceneId: SceneId }; res: { project: Project }; diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index 8fa433c..4292e96 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -112,6 +112,8 @@ export type Project = { meta: ProjectMeta; scenes: Record; assets: Record; + /** Аудио кампании: играет в пульте на протяжении всей презентации, если в сцене нет своей музыки. */ + campaignAudios: SceneAudioRef[]; currentSceneId: SceneId | null; /** Текущая нода графа (важно, когда одна сцена имеет несколько нод). */ currentGraphNodeId: GraphNodeId | null; diff --git a/docs/editor-performance-plan.md b/docs/editor-performance-plan.md new file mode 100644 index 0000000..80c8a60 --- /dev/null +++ b/docs/editor-performance-plan.md @@ -0,0 +1,90 @@ +## План оптимизации редактора (агент) + +Цель: убрать лаги визуального редактора на проектах 20+ сцен (пан/зум графа, drag, создание связей) и снизить влияние тяжёлых ассетов (изображения/видео) на редактор. + +### Этап 0 — Замер и воспроизведение + +- **Сценарии**: пан/зум графа, drag узлов, создание/удаление связей, работа со списком сцен, редактирование свойств сцены. +- **Сбор данных**: DevTools Performance (CPU/Rendering), наблюдение нагрузки (GPU/Video Decode), фиксация “где именно тормозит”. + +**Результат**: подтверждены основные “горячие места” (граф/рендер/медиа/IPC). + +--- + +### Этап 1 — Быстрый выигрыш без изменения формата проекта (минимально и безопасно) + +#### 1.1 Lazy/async для изображений в карточках + +- Для `` в карточках графа и списка сцен применить: + - `loading="lazy"` + - `decoding="async"` +- Убедиться, что контейнеры превью не провоцируют лишние перерасчёты размеров/перерисовки. + +#### 1.2 Снижение перерисовок графа (минимально и безопасно) + +- **Стабилизировать входные данные** графа: + - вместо передачи “всего `project.scenes`” подготовить “лёгкое” представление сцен, содержащее только поля, нужные для карточек (title, preview refs, флаги). +- Цель: изменения в инспекторе (описание/аудио/и т.п.) не должны заставлять ReactFlow пересобирать все `nodes/edges`. + +#### 1.3 Ограничить частоту тяжёлых обновлений при действиях в графе + +- Проверить, чтобы обновления позиций/связей не вызывали лишних “полных” пересборок и не инициировали дорогие операции чаще, чем нужно. + +**Критерий готовности**: на проекте с 22 сценами взаимодействие с графом заметно плавнее, задержка при создании связей снижается. + +--- + +### Этап 2 — Самое эффективное: thumbnails вместо оригиналов + +#### 2.1 Модель данных для миниатюр + +- Для превью/медиа хранить: + - **original asset** (как сейчас) — для просмотра/презентации + - **thumbnail asset** — для графа и списков +- Миниатюра — отдельный asset в `project.assets`, связанный с оригиналом (через поле в сцене или метаданные asset). + +#### 2.2 Генерация thumbnail при импорте изображений + +- При импорте превью/изображений: + - ресайз до ~**320px по длинной стороне** + - кодек: **WebP** (или JPEG) +- Сохранять thumbnail как отдельный файл/asset. + +#### 2.3 Генерация thumbnail для видео по первому кадру + +- При импорте видео: + - извлечь кадр (0.5–1s если на 0s чёрный) + - сохранить как **image/webp/jpeg** thumbnail +- В UI использовать thumbnail как постер для карточек. + +#### 2.4 Использование thumbnails в UI + +- **Граф сцен**: показывает только thumbnail. +- **Список сцен**: показывает только thumbnail. +- **Инспектор**: по желанию — thumbnail или оригинал (можно оставить оригинал только тут). +- **Презентация/просмотр**: оригинал. + +#### 2.5 Обратная совместимость + +- Старые проекты без thumbnails: + - “ленивая” догенерация в фоне при первом отображении, + - или отдельная команда “Оптимизировать проект (создать миниатюры)”. + +**Критерий готовности**: тяжёлые исходники почти не влияют на редактор; масштабируемость по сценам растёт. + +--- + +### Этап 3 — Дополнительные ускорения (по ситуации) + +- **3.1 Виртуализация списка сцен** (если список остаётся тяжёлым при 50+ сценах). +- **3.2 Дифф‑обновление `nodes/edges`** в ReactFlow вместо “пересборки целиком” (для 100+ сцен). +- **3.3 LOD‑поведение**: при сильном зуме карточки упрощаются (только заголовок/иконки), thumbnails отключаются. + +--- + +### Приоритизация + +- **Сразу**: Этап 1. +- **Максимальный эффект**: Этап 2. +- **Для больших проектов**: Этап 3. + diff --git a/package.json b/package.json index d7a324c..f99ad27 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "release:info": "node scripts/print-release-info.mjs",