From a6cbcc273e6d135388e1b7ab806a67f0427453e7 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Sun, 19 Apr 2026 14:16:54 +0800 Subject: [PATCH] =?UTF-8?q?DNDGamePlayer:=20Electron=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=20=D1=81=D1=86=D0=B5=D0=BD,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B7=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F,=20=D1=83=D0=BF=D0=B0=D0=BA=D0=BE=D0=B2=D0=BA=D0=B0=20el?= =?UTF-8?q?ectron-builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .gitignore | 17 + .prettierignore | 4 + .prettierrc.json | 7 + app/main/effects/effectsStore.ts | 97 + app/main/index.ts | 344 + app/main/ipc/router.ts | 19 + app/main/project/assetPrune.test.ts | 119 + app/main/project/assetPrune.ts | 51 + app/main/project/paths.ts | 27 + app/main/project/zipStore.ts | 1235 ++ app/main/protocol/dndAssetProtocol.ts | 73 + app/main/tsconfig.json | 14 + app/main/versionInfo.ts | 24 + app/main/video/videoPlaybackStore.ts | 108 + .../windows/createWindows.editorClose.test.ts | 28 + app/main/windows/createWindows.ts | 159 + app/preload/index.ts | 24 + app/preload/ipcClient.ts | 23 + app/preload/tsconfig.json | 14 + app/renderer/ambient-modules.d.ts | 1 + app/renderer/control.html | 13 + app/renderer/control/ControlApp.module.css | 359 + app/renderer/control/ControlApp.tsx | 887 ++ .../control/ControlScenePreview.module.css | 72 + app/renderer/control/ControlScenePreview.tsx | 181 + .../control/controlApp.effectsPanel.test.ts | 59 + app/renderer/control/main.tsx | 16 + app/renderer/editor.html | 13 + app/renderer/editor/EditorApp.module.css | 619 + app/renderer/editor/EditorApp.tsx | 1066 ++ .../editor/graph/SceneGraph.module.css | 276 + app/renderer/editor/graph/SceneGraph.tsx | 496 + app/renderer/editor/main.tsx | 16 + app/renderer/editor/state/projectState.ts | 322 + app/renderer/presentation.html | 13 + .../presentation/PresentationApp.module.css | 4 + app/renderer/presentation/PresentationApp.tsx | 41 + app/renderer/presentation/main.tsx | 16 + app/renderer/public/app-logo.svg | 10 + app/renderer/public/app-window-icon.png | Bin 0 -> 5747 bytes .../shared/PresentationView.module.css | 63 + app/renderer/shared/PresentationView.tsx | 105 + app/renderer/shared/RotatedImage.module.css | 15 + app/renderer/shared/RotatedImage.tsx | 104 + app/renderer/shared/branding/AppLogo.tsx | 33 + app/renderer/shared/dndApi.ts | 5 + .../effects/PxiEffectsOverlay.module.css | 12 + .../effects/PxiEffectsOverlay.pointer.test.ts | 12 + .../shared/effects/PxiEffectsOverlay.tsx | 1207 ++ .../shared/effects/useEffectsState.ts | 31 + app/renderer/shared/styles/variables.css | 114 + app/renderer/shared/ui/Controls.module.css | 50 + app/renderer/shared/ui/LayoutShell.module.css | 27 + app/renderer/shared/ui/LayoutShell.tsx | 23 + app/renderer/shared/ui/Surface.module.css | 8 + app/renderer/shared/ui/Surface.tsx | 18 + .../shared/ui/controls.tooltip.test.ts | 15 + app/renderer/shared/ui/controls.tsx | 96 + app/renderer/shared/ui/globals.css | 54 + app/renderer/shared/useAssetImageUrl.ts | 42 + .../shared/video/useVideoPlaybackState.ts | 43 + app/renderer/tsconfig.json | 10 + app/shared/graph/sceneGraphEdgeRules.ts | 26 + app/shared/ipc/contracts.mediaRemoval.test.ts | 28 + app/shared/ipc/contracts.ts | 222 + app/shared/package.build.test.ts | 17 + app/shared/types/domain.ts | 121 + app/shared/types/effects.ts | 102 + app/shared/types/ids.ts | 22 + app/shared/types/index.ts | 4 + app/shared/types/videoPlayback.ts | 21 + eslint.config.js | 97 + package-lock.json | 12200 ++++++++++++++++ package.json | 106 + scripts/build.mjs | 47 + scripts/dev.mjs | 108 + scripts/gen-window-icon.mjs | 32 + scripts/print-release-info.mjs | 13 + tsconfig.base.json | 25 + tsconfig.eslint.json | 10 + tsconfig.json | 9 + vite.config.ts | 31 + 82 files changed, 22195 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 app/main/effects/effectsStore.ts create mode 100644 app/main/index.ts create mode 100644 app/main/ipc/router.ts create mode 100644 app/main/project/assetPrune.test.ts create mode 100644 app/main/project/assetPrune.ts create mode 100644 app/main/project/paths.ts create mode 100644 app/main/project/zipStore.ts create mode 100644 app/main/protocol/dndAssetProtocol.ts create mode 100644 app/main/tsconfig.json create mode 100644 app/main/versionInfo.ts create mode 100644 app/main/video/videoPlaybackStore.ts create mode 100644 app/main/windows/createWindows.editorClose.test.ts create mode 100644 app/main/windows/createWindows.ts create mode 100644 app/preload/index.ts create mode 100644 app/preload/ipcClient.ts create mode 100644 app/preload/tsconfig.json create mode 100644 app/renderer/ambient-modules.d.ts create mode 100644 app/renderer/control.html create mode 100644 app/renderer/control/ControlApp.module.css create mode 100644 app/renderer/control/ControlApp.tsx create mode 100644 app/renderer/control/ControlScenePreview.module.css create mode 100644 app/renderer/control/ControlScenePreview.tsx create mode 100644 app/renderer/control/controlApp.effectsPanel.test.ts create mode 100644 app/renderer/control/main.tsx create mode 100644 app/renderer/editor.html create mode 100644 app/renderer/editor/EditorApp.module.css create mode 100644 app/renderer/editor/EditorApp.tsx create mode 100644 app/renderer/editor/graph/SceneGraph.module.css create mode 100644 app/renderer/editor/graph/SceneGraph.tsx create mode 100644 app/renderer/editor/main.tsx create mode 100644 app/renderer/editor/state/projectState.ts create mode 100644 app/renderer/presentation.html create mode 100644 app/renderer/presentation/PresentationApp.module.css create mode 100644 app/renderer/presentation/PresentationApp.tsx create mode 100644 app/renderer/presentation/main.tsx create mode 100644 app/renderer/public/app-logo.svg create mode 100644 app/renderer/public/app-window-icon.png create mode 100644 app/renderer/shared/PresentationView.module.css create mode 100644 app/renderer/shared/PresentationView.tsx create mode 100644 app/renderer/shared/RotatedImage.module.css create mode 100644 app/renderer/shared/RotatedImage.tsx create mode 100644 app/renderer/shared/branding/AppLogo.tsx create mode 100644 app/renderer/shared/dndApi.ts create mode 100644 app/renderer/shared/effects/PxiEffectsOverlay.module.css create mode 100644 app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts create mode 100644 app/renderer/shared/effects/PxiEffectsOverlay.tsx create mode 100644 app/renderer/shared/effects/useEffectsState.ts create mode 100644 app/renderer/shared/styles/variables.css create mode 100644 app/renderer/shared/ui/Controls.module.css create mode 100644 app/renderer/shared/ui/LayoutShell.module.css create mode 100644 app/renderer/shared/ui/LayoutShell.tsx create mode 100644 app/renderer/shared/ui/Surface.module.css create mode 100644 app/renderer/shared/ui/Surface.tsx create mode 100644 app/renderer/shared/ui/controls.tooltip.test.ts create mode 100644 app/renderer/shared/ui/controls.tsx create mode 100644 app/renderer/shared/ui/globals.css create mode 100644 app/renderer/shared/useAssetImageUrl.ts create mode 100644 app/renderer/shared/video/useVideoPlaybackState.ts create mode 100644 app/renderer/tsconfig.json create mode 100644 app/shared/graph/sceneGraphEdgeRules.ts create mode 100644 app/shared/ipc/contracts.mediaRemoval.test.ts create mode 100644 app/shared/ipc/contracts.ts create mode 100644 app/shared/package.build.test.ts create mode 100644 app/shared/types/domain.ts create mode 100644 app/shared/types/effects.ts create mode 100644 app/shared/types/ids.ts create mode 100644 app/shared/types/index.ts create mode 100644 app/shared/types/videoPlayback.ts create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build.mjs create mode 100644 scripts/dev.mjs create mode 100644 scripts/gen-window-icon.mjs create mode 100644 scripts/print-release-info.mjs create mode 100644 tsconfig.base.json create mode 100644 tsconfig.eslint.json create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..724a39a --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +release/ +build/ +.cursor/ +mcps/ +node_modules/ +dist/ +*.log +.DS_Store +Thumbs.db +.env +.env.* +!.env.example +*.local +.idea/ +.vscode/* +!.vscode/extensions.json +*.tsbuildinfo diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3358e37 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +.cursor +*.zip diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..f11cc08 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "singleQuote": true, + "semi": true, + "printWidth": 110, + "trailingComma": "all" +} diff --git a/app/main/effects/effectsStore.ts b/app/main/effects/effectsStore.ts new file mode 100644 index 0000000..98ae763 --- /dev/null +++ b/app/main/effects/effectsStore.ts @@ -0,0 +1,97 @@ +import crypto from 'node:crypto'; + +import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types'; + +function nowMs(): number { + return Date.now(); +} + +function defaultTool(): EffectToolState { + return { tool: 'fog', radiusN: 0.08, intensity: 0.6 }; +} + +export class EffectsStore { + private state: EffectsState = { + revision: 1, + serverNowMs: nowMs(), + tool: defaultTool(), + instances: [], + }; + + getState(): EffectsState { + // Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров. + return { ...this.state, serverNowMs: nowMs() }; + } + + clear(): EffectsState { + this.state = { + ...this.state, + revision: this.state.revision + 1, + serverNowMs: nowMs(), + instances: [], + }; + return this.state; + } + + dispatch(event: EffectsEvent): EffectsState { + const s = this.state; + const next: EffectsState = applyEvent(s, event); + this.state = next; + return next; + } + + /** Удаляет истёкшие (по lifetime) эффекты, чтобы state не разрастался бесконечно. */ + pruneExpired(): boolean { + const now = nowMs(); + const before = this.state.instances.length; + const kept = this.state.instances.filter((i) => { + if (i.type === 'lightning') { + return now - i.createdAtMs < i.lifetimeMs; + } + if (i.type === 'scorch') { + return now - i.createdAtMs < i.lifetimeMs; + } + if (i.type === 'fog') { + if (i.lifetimeMs === null) return true; + return now - i.createdAtMs < i.lifetimeMs; + } + return true; + }); + if (kept.length === before) return false; + this.state = { + ...this.state, + revision: this.state.revision + 1, + serverNowMs: now, + instances: kept, + }; + return true; + } + + makeId(prefix: string): string { + return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`; + } +} + +function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState { + const bump = (patch: Omit): EffectsState => ({ + ...patch, + revision: state.revision + 1, + serverNowMs: nowMs(), + }); + switch (event.kind) { + case 'tool.set': + return bump({ ...state, tool: event.tool }); + case 'instances.clear': + return bump({ ...state, instances: [] }); + case 'instance.add': + return bump({ ...state, instances: [...state.instances, event.instance] }); + case 'instance.remove': + return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) }); + default: { + // Exhaustiveness + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _x: never = event; + return state; + } + } +} diff --git a/app/main/index.ts b/app/main/index.ts new file mode 100644 index 0000000..08f5649 --- /dev/null +++ b/app/main/index.ts @@ -0,0 +1,344 @@ +import { app, BrowserWindow, dialog, Menu, protocol } from 'electron'; + +import { ipcChannels, type SessionState } from '../shared/ipc/contracts'; + +import { EffectsStore } from './effects/effectsStore'; +import { installIpcRouter, registerHandler } from './ipc/router'; +import { ZipProjectStore } from './project/zipStore'; +import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; +import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo'; +import { VideoPlaybackStore } from './video/videoPlaybackStore'; +import { + closeMultiWindow, + createWindows, + focusEditorWindow, + markAppQuitting, + openMultiWindow, + togglePresentationFullscreen, +} from './windows/createWindows'; + +if (process.platform === 'win32') { + app.setAppUserModelId('com.dndplayer.app'); +} +// Не вызывать app.setName() с другим именем: на Windows/macOS меняется каталог userData, +// и проекты в …/userData/projects «пропадают» из списка (остаются в старой папке). + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'dnd', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, +]); + +const gotTheLock = app.requestSingleInstanceLock(); +if (!gotTheLock) { + app.quit(); +} else { + app.on('second-instance', () => { + focusEditorWindow(); + }); +} + +const projectStore = new ZipProjectStore(); +const effectsStore = new EffectsStore(); +const videoStore = new VideoPlaybackStore(); + +function emitEffectsState(): void { + const state = effectsStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.effects.stateChanged, { state }); + } +} + +function emitVideoState(): void { + const state = videoStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.video.stateChanged, { state }); + } +} + +// Периодически чистим истёкшие эффекты (в основном молнии). +setInterval(() => { + if (effectsStore.pruneExpired()) { + emitEffectsState(); + } +}, 500); + +// Пока видео "играет" — периодически рассылаем state (нужен для новых окон и коррекции). +setInterval(() => { + if (videoStore.getState().playing) { + emitVideoState(); + } +}, 500); + +function emitSessionState(): void { + const project = projectStore.getOpenProject(); + const state: SessionState = { + project, + currentSceneId: project?.currentSceneId ?? null, + }; + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.session.stateChanged, { state }); + } +} + +async function main() { + await app.whenReady(); + Menu.setApplicationMenu(null); + registerDndAssetProtocol(projectStore); + registerHandler(ipcChannels.app.quit, () => { + markAppQuitting(); + app.quit(); + return { ok: true }; + }); + registerHandler(ipcChannels.app.getVersion, () => ({ + version: getAppSemanticVersion(), + buildNumber: getOptionalBuildNumber(), + })); + registerHandler(ipcChannels.windows.openMultiWindow, () => { + openMultiWindow(); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.closeMultiWindow, () => { + closeMultiWindow(); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => { + const isFullScreen = togglePresentationFullscreen(); + return { ok: true, isFullScreen }; + }); + + registerHandler(ipcChannels.project.list, async () => { + const projects = await projectStore.listProjects(); + return { + projects: projects.map((p) => ({ + id: p.id, + name: p.name, + updatedAt: p.updatedAt, + fileName: p.fileName, + })), + }; + }); + registerHandler(ipcChannels.project.create, async ({ name }) => { + const project = await projectStore.createProject(name); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.open, async ({ projectId }) => { + const project = await projectStore.openProjectById(projectId); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.get, () => { + return { project: projectStore.getOpenProject() }; + }); + registerHandler(ipcChannels.project.saveNow, async () => { + await projectStore.saveNow(); + return { ok: true }; + }); + registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => { + await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null })); + effectsStore.clear(); + emitEffectsState(); + emitSessionState(); + return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; + }); + registerHandler(ipcChannels.project.setCurrentGraphNode, async ({ graphNodeId }) => { + const open = projectStore.getOpenProject(); + if (!open) throw new Error('No open project'); + const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null; + await projectStore.updateProject((p) => ({ + ...p, + currentGraphNodeId: graphNodeId, + currentSceneId: gn ? gn.sceneId : null, + })); + effectsStore.clear(); + emitEffectsState(); + emitSessionState(); + const p = projectStore.getOpenProject(); + return { + currentGraphNodeId: p?.currentGraphNodeId ?? null, + currentSceneId: p?.currentSceneId ?? null, + }; + }); + registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => { + const next = await projectStore.updateScene(sceneId, patch); + emitSessionState(); + return { scene: next }; + }); + registerHandler(ipcChannels.project.updateConnections, async ({ sceneId, connections }) => { + const next = await projectStore.updateConnections(sceneId, connections); + emitSessionState(); + return { scene: next }; + }); + registerHandler(ipcChannels.project.importMedia, async ({ sceneId }) => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile', 'multiSelections'], + filters: [ + { + name: 'Видео и аудио', + extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'], + }, + ], + }); + if (canceled || filePaths.length === 0) { + const project = projectStore.getOpenProject(); + if (!project) throw new Error('No open project'); + return { project, imported: [] }; + } + const result = await projectStore.importMediaFiles(sceneId, filePaths); + emitSessionState(); + return result; + }); + registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [ + { + name: 'Изображения и видео', + extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'], + }, + ], + }); + if (canceled || !filePaths[0]) { + const project = projectStore.getOpenProject(); + if (!project) throw new Error('No open project'); + return { project }; + } + const project = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.clearScenePreview, async ({ sceneId }) => { + const project = await projectStore.clearScenePreview(sceneId); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.updateSceneGraphNodePosition, async ({ nodeId, x, y }) => { + const project = await projectStore.updateSceneGraphNodePosition(nodeId, x, y); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.addSceneGraphNode, async ({ sceneId, x, y }) => { + const project = await projectStore.addSceneGraphNode(sceneId, x, y); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.removeSceneGraphNode, async ({ nodeId }) => { + const project = await projectStore.removeSceneGraphNode(nodeId); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.addSceneGraphEdge, async ({ sourceGraphNodeId, targetGraphNodeId }) => { + const project = await projectStore.addSceneGraphEdge(sourceGraphNodeId, targetGraphNodeId); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.removeSceneGraphEdge, async ({ edgeId }) => { + const project = await projectStore.removeSceneGraphEdge(edgeId); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.setSceneGraphNodeStart, async ({ graphNodeId }) => { + const project = await projectStore.setSceneGraphNodeStart(graphNodeId); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => { + const project = await projectStore.deleteScene(sceneId); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => { + const project = await projectStore.renameOpenProject(name, fileBaseName); + emitSessionState(); + return { project }; + }); + registerHandler(ipcChannels.project.importZip, async () => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }], + }); + if (canceled || !filePaths[0]) { + return { canceled: true as const }; + } + const project = await projectStore.importProjectFromExternalZip(filePaths[0]); + emitSessionState(); + return { canceled: false as const, project }; + }); + registerHandler(ipcChannels.project.exportZip, async ({ projectId }) => { + const list = await projectStore.listProjects(); + const entry = list.find((p) => p.id === projectId); + if (!entry) { + throw new Error('Проект не найден'); + } + const defaultName = entry.fileName.toLowerCase().endsWith('.dnd.zip') + ? entry.fileName + : `${entry.fileName}.dnd.zip`; + const { canceled, filePath } = await dialog.showSaveDialog({ + defaultPath: defaultName, + filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }], + }); + if (canceled || !filePath) { + return { canceled: true as const }; + } + let dest = filePath; + const lower = dest.toLowerCase(); + if (!lower.endsWith('.dnd.zip')) { + dest = lower.endsWith('.zip') ? dest.replace(/\.zip$/iu, '.dnd.zip') : `${dest}.dnd.zip`; + } + await projectStore.exportProjectZipToPath(projectId, dest); + return { canceled: false as const }; + }); + registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => { + await projectStore.deleteProjectById(projectId); + emitSessionState(); + return { ok: true }; + }); + registerHandler(ipcChannels.project.assetFileUrl, ({ assetId }) => ({ + url: projectStore.getAssetFileUrl(assetId), + })); + + registerHandler(ipcChannels.effects.getState, () => { + return { state: effectsStore.getState() }; + }); + registerHandler(ipcChannels.effects.dispatch, ({ event }) => { + effectsStore.dispatch(event); + emitEffectsState(); + return { ok: true }; + }); + + registerHandler(ipcChannels.video.getState, () => { + return { state: videoStore.getState() }; + }); + registerHandler(ipcChannels.video.dispatch, ({ event }) => { + videoStore.dispatch(event); + emitVideoState(); + return { ok: true }; + }); + + installIpcRouter(); + createWindows(); + emitSessionState(); + emitEffectsState(); + emitVideoState(); + + app.on('activate', () => { + focusEditorWindow(); + }); +} + +if (gotTheLock) { + void main(); +} + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); diff --git a/app/main/ipc/router.ts b/app/main/ipc/router.ts new file mode 100644 index 0000000..dc89cfe --- /dev/null +++ b/app/main/ipc/router.ts @@ -0,0 +1,19 @@ +import { ipcMain } from 'electron'; + +import type { IpcInvokeMap } from '../../shared/ipc/contracts'; + +type Handler = ( + payload: IpcInvokeMap[K]['req'], +) => Promise | IpcInvokeMap[K]['res']; + +const handlers = new Map Promise>(); + +export function registerHandler(channel: K, handler: Handler) { + handlers.set(channel as string, async (payload: unknown) => handler(payload as IpcInvokeMap[K]['req'])); +} + +export function installIpcRouter() { + for (const [channel, handler] of handlers.entries()) { + ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload)); + } +} diff --git a/app/main/project/assetPrune.test.ts b/app/main/project/assetPrune.test.ts new file mode 100644 index 0000000..d14e459 --- /dev/null +++ b/app/main/project/assetPrune.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { PROJECT_SCHEMA_VERSION, type Project } from '../../shared/types'; +import type { AssetId } from '../../shared/types/ids'; + +import { collectReferencedAssetIds, reconcileAssetFiles } from './assetPrune'; + +void test('collectReferencedAssetIds: превью, видео и аудио', () => { + const p = { + scenes: { + s1: { + previewAssetId: 'pr' as AssetId, + media: { + videos: ['v1' as AssetId], + audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }], + }, + }, + }, + } as unknown as Project; + const s = collectReferencedAssetIds(p); + assert.deepEqual([...s].sort(), ['a1', 'pr', 'v1'].sort()); +}); + +void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-asset-prune-')); + const relPath = 'assets/orphan.bin'; + await fs.mkdir(path.join(tmp, 'assets'), { recursive: true }); + await fs.writeFile(path.join(tmp, relPath), Buffer.from([1, 2, 3])); + + const asset = { + id: 'orph' as AssetId, + type: 'audio' as const, + mime: 'audio/wav', + originalName: 'x.wav', + relPath, + sha256: 'a', + sizeBytes: 3, + createdAt: new Date().toISOString(), + }; + + const base = { + id: 'p1', + meta: { + name: 't', + fileBaseName: 't', + createdAt: '', + updatedAt: '', + createdWithAppVersion: '1', + appVersion: '1', + schemaVersion: PROJECT_SCHEMA_VERSION, + }, + currentSceneId: null, + currentGraphNodeId: null, + sceneGraphNodes: [], + sceneGraphEdges: [], + } as unknown as Project; + + const prev: Project = { + ...base, + scenes: {}, + assets: { orphan: asset } as Project['assets'], + }; + const next: Project = { + ...base, + scenes: {}, + assets: { orphan: asset } as Project['assets'], + }; + + const out = await reconcileAssetFiles(prev, next, tmp); + assert.ok(!('orphan' in out.assets)); + await assert.rejects(() => fs.stat(path.join(tmp, relPath))); +}); + +void test('reconcileAssetFiles: удаляет файл при исключении id из assets', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-asset-prune-')); + const relPath = 'assets/gone.bin'; + await fs.mkdir(path.join(tmp, 'assets'), { recursive: true }); + await fs.writeFile(path.join(tmp, relPath), Buffer.from([9])); + + const asset = { + id: 'gone' as AssetId, + type: 'audio' as const, + mime: 'audio/wav', + originalName: 'x.wav', + relPath, + sha256: 'b', + sizeBytes: 1, + createdAt: new Date().toISOString(), + }; + + const base = { + id: 'p1', + meta: { + name: 't', + fileBaseName: 't', + createdAt: '', + updatedAt: '', + createdWithAppVersion: '1', + appVersion: '1', + schemaVersion: PROJECT_SCHEMA_VERSION, + }, + scenes: {}, + currentSceneId: null, + currentGraphNodeId: null, + sceneGraphNodes: [], + sceneGraphEdges: [], + } as unknown as Project; + + const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] }; + const next: Project = { ...base, assets: {} as Project['assets'] }; + + const out = await reconcileAssetFiles(prev, next, tmp); + assert.deepEqual(out.assets, {}); + await assert.rejects(() => fs.stat(path.join(tmp, relPath))); +}); diff --git a/app/main/project/assetPrune.ts b/app/main/project/assetPrune.ts new file mode 100644 index 0000000..87c1330 --- /dev/null +++ b/app/main/project/assetPrune.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { MediaAsset, Project } from '../../shared/types'; +import type { AssetId } from '../../shared/types/ids'; + +/** Все asset id, на которые есть ссылки из сцен (превью, видео, аудио). */ +export function collectReferencedAssetIds(p: Project): Set { + const refs = new Set(); + for (const sc of Object.values(p.scenes)) { + if (sc.previewAssetId) refs.add(sc.previewAssetId); + for (const vid of sc.media.videos) refs.add(vid); + for (const au of sc.media.audios) refs.add(au.assetId); + } + return refs; +} + +/** + * Удаляет с диска файлы снятых материалов и записи в `assets`, на которые больше нет ссылок. + */ +export async function reconcileAssetFiles(prev: Project, next: Project, cacheDir: string): Promise { + const prevIds = new Set(Object.keys(prev.assets) as AssetId[]); + const nextIds = new Set(Object.keys(next.assets) as AssetId[]); + + for (const id of prevIds) { + if (nextIds.has(id)) continue; + const a = prev.assets[id]; + if (a) { + const abs = path.join(cacheDir, a.relPath); + await fs.unlink(abs).catch(() => undefined); + } + } + + const refs = collectReferencedAssetIds(next); + const assets = next.assets; + const kept: Record = {} as Record; + let droppedOrphans = false; + for (const id of Object.keys(assets) as AssetId[]) { + const a = assets[id]; + if (!a) continue; + if (refs.has(id)) { + kept[id] = a; + } else { + droppedOrphans = true; + const abs = path.join(cacheDir, a.relPath); + await fs.unlink(abs).catch(() => undefined); + } + } + + return droppedOrphans ? { ...next, assets: kept } : next; +} diff --git a/app/main/project/paths.ts b/app/main/project/paths.ts new file mode 100644 index 0000000..88cfc93 --- /dev/null +++ b/app/main/project/paths.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; + +import { app } from 'electron'; + +export function getProjectsRootDir(): string { + return path.join(app.getPath('userData'), 'projects'); +} + +export function getProjectsCacheRootDir(): string { + return path.join(app.getPath('userData'), 'projects-cache'); +} + +/** + * Каталоги `…/projects` из других имён приложения в `%AppData%` (родитель `userData`). + * Если когда‑то меняли `app.setName`, проекты могли остаться в соседней папке — их подхватываем при старте. + */ +export function getLegacyProjectsRootDirs(): string[] { + const cur = getProjectsRootDir(); + const parent = path.dirname(app.getPath('userData')); + const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player']; + const out: string[] = []; + for (const n of siblingNames) { + const p = path.join(parent, n, 'projects'); + if (p !== cur) out.push(p); + } + return out; +} diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts new file mode 100644 index 0000000..9ad5dc2 --- /dev/null +++ b/app/main/project/zipStore.ts @@ -0,0 +1,1235 @@ +import crypto from 'node:crypto'; +import fssync from 'node:fs'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import yauzl from 'yauzl'; +import { ZipFile } from 'yazl'; + +import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules'; +import type { ScenePatch } from '../../shared/ipc/contracts'; +import type { + MediaAsset, + MediaAssetType, + Project, + ProjectId, + Scene, + SceneGraphEdge, + SceneGraphNode, + SceneId, +} from '../../shared/types'; +import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; +import type { AssetId, GraphNodeId } from '../../shared/types/ids'; +import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids'; +import { getAppSemanticVersion } from '../versionInfo'; + +import { reconcileAssetFiles } from './assetPrune'; +import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths'; + +type ProjectIndexEntry = { + id: ProjectId; + name: string; + updatedAt: string; + fileName: string; +}; + +type OpenProject = { + id: ProjectId; + zipPath: string; + cacheDir: string; + projectPath: string; + project: Project; +}; + +export class ZipProjectStore { + private openProject: OpenProject | null = null; + private saveQueued = false; + private saving = false; + /** Bumps on create/open so in-flight disk writes cannot commit after project switch. */ + 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; + + private async waitWhilePacking(): Promise { + while (this.isPacking) { + await new Promise((r) => setTimeout(r, 15)); + } + } + + private async packZipExclusive(cacheDir: string, zipPath: string): Promise { + this.isPacking = true; + try { + await this.packZipFromCache(cacheDir, zipPath); + } finally { + this.isPacking = false; + } + } + + async ensureRoots(): Promise { + await fs.mkdir(getProjectsRootDir(), { recursive: true }); + await fs.mkdir(getProjectsCacheRootDir(), { recursive: true }); + await this.migrateLegacyProjectZipsIfNeeded(); + } + + /** Копирует .dnd.zip из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */ + private async migrateLegacyProjectZipsIfNeeded(): Promise { + const dest = getProjectsRootDir(); + let destNames: string[]; + try { + destNames = await fs.readdir(dest); + } catch { + return; + } + const destZips = new Set(destNames.filter((n) => n.endsWith('.dnd.zip'))); + for (const legacyRoot of getLegacyProjectsRootDirs()) { + let legacyNames: string[]; + try { + legacyNames = await fs.readdir(legacyRoot); + } catch { + continue; + } + for (const name of legacyNames) { + if (!name.endsWith('.dnd.zip')) continue; + if (destZips.has(name)) continue; + const from = path.join(legacyRoot, name); + const to = path.join(dest, name); + try { + const st = await fs.stat(from); + if (!st.isFile()) continue; + await fs.copyFile(from, to); + destZips.add(name); + } catch { + /* ignore */ + } + } + } + } + + async listProjects(): Promise { + await this.ensureRoots(); + const root = getProjectsRootDir(); + const entries = await fs.readdir(root, { withFileTypes: true }); + const files = entries + .filter((e) => e.isFile() && e.name.endsWith('.dnd.zip')) + .map((e) => path.join(root, e.name)); + + const out: ProjectIndexEntry[] = []; + for (const filePath of files) { + const project = await readProjectJsonFromZip(filePath); + out.push({ + id: project.id, + name: project.meta.name, + updatedAt: project.meta.updatedAt, + fileName: path.basename(filePath), + }); + } + out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return out; + } + + async createProject(name: string): Promise { + await this.ensureRoots(); + this.projectSession += 1; + const id = asProjectId(this.randomId()); + const now = new Date().toISOString(); + const fileBaseName = `${sanitizeFileName(name)}_${id}`; + const appVer = getAppSemanticVersion(); + const project: Project = { + id, + meta: { + name, + fileBaseName, + createdAt: now, + updatedAt: now, + createdWithAppVersion: appVer, + appVersion: appVer, + schemaVersion: PROJECT_SCHEMA_VERSION, + }, + scenes: {}, + assets: {}, + currentSceneId: null, + currentGraphNodeId: null, + sceneGraphNodes: [], + sceneGraphEdges: [], + }; + + const zipPath = path.join(getProjectsRootDir(), `${fileBaseName}.dnd.zip`); + const cacheDir = path.join(getProjectsCacheRootDir(), id); + const projectPath = path.join(cacheDir, 'project.json'); + this.openProject = { id, zipPath, cacheDir, projectPath, project }; + await this.writeCacheProject(cacheDir, project); + await this.packZipExclusive(cacheDir, zipPath); + return this.openProject.project; + } + + async openProjectById(projectId: ProjectId): Promise { + await this.ensureRoots(); + 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); + + 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 { + return this.openProject?.project ?? null; + } + + /** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */ + getAssetFileUrl(assetId: AssetId): string | null { + if (!this.getAssetReadInfo(assetId)) return null; + return `dnd://asset?id=${encodeURIComponent(assetId)}`; + } + + getAssetReadInfo(assetId: AssetId): { absPath: string; mime: string } | null { + const open = this.openProject; + if (!open) return null; + const asset = open.project.assets[assetId]; + if (!asset) return null; + return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime }; + } + + getImageAssetReadInfo(assetId: AssetId): { absPath: string; mime: string } | null { + const open = this.openProject; + if (!open) return null; + const asset = open.project.assets[assetId]; + if (asset?.type !== 'image') return null; + return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime }; + } + + async importScenePreviewMedia(sceneId: SceneId, filePath: string): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const sc = open.project.scenes[sceneId]; + if (!sc) throw new Error('Scene not found'); + + const kind = classifyMediaPath(filePath); + if (kind?.type !== 'image' && kind?.type !== 'video') { + throw new Error('Файл превью должен быть изображением или видео'); + } + + 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); + const asset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); + + const oldPreviewId = sc.previewAssetId; + + await this.updateProject((p) => { + const scene = p.scenes[sceneId]; + if (!scene) throw new Error('Scene not found'); + let assets: Record = { ...p.assets }; + if (oldPreviewId) { + assets = Object.fromEntries(Object.entries(assets).filter(([k]) => k !== oldPreviewId)) as Record< + AssetId, + MediaAsset + >; + } + assets[id] = asset; + return { + ...p, + assets, + scenes: { + ...p.scenes, + [sceneId]: { + ...scene, + previewAssetId: id, + previewAssetType: kind.type, + previewVideoAutostart: kind.type === 'video' ? scene.previewVideoAutostart : false, + }, + }, + }; + }); + + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async clearScenePreview(sceneId: SceneId): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const sc = open.project.scenes[sceneId]; + if (!sc) throw new Error('Scene not found'); + const oldId = sc.previewAssetId; + if (!oldId) { + return open.project; + } + await this.updateProject((p) => { + const assets = Object.fromEntries(Object.entries(p.assets).filter(([k]) => k !== oldId)) as Record< + AssetId, + MediaAsset + >; + return { + ...p, + assets, + scenes: { + ...p.scenes, + [sceneId]: { + ...p.scenes[sceneId], + previewAssetId: null, + previewAssetType: null, + previewVideoAutostart: false, + }, + }, + }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async updateProject(mutator: (draft: Project) => Project): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const prev = open.project; + let next = mutator(prev); + next = await reconcileAssetFiles(prev, next, open.cacheDir); + open.project = next; + await this.writeCacheProject(open.cacheDir, next); + this.queueSave(); + return next; + } + + async updateScene(sceneId: SceneId, patch: ScenePatch): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const existing = open.project.scenes[sceneId]; + const base: Scene = + existing ?? + ({ + id: sceneId, + title: '', + 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, + } satisfies Scene); + + const next: Scene = { + ...base, + ...(patch.title !== undefined ? { title: patch.title } : null), + ...(patch.description !== undefined ? { description: patch.description } : null), + ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null), + ...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null), + ...(patch.previewVideoAutostart !== undefined + ? { previewVideoAutostart: patch.previewVideoAutostart } + : null), + ...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null), + ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), + ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), + ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), + }; + + await this.updateProject((p) => ({ ...p, scenes: { ...p.scenes, [sceneId]: next } })); + return next; + } + + async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const existing = open.project.scenes[sceneId]; + if (!existing) throw new Error('Scene not found'); + const next: Scene = { ...existing, connections: [...connections] }; + await this.updateProject((p) => ({ ...p, scenes: { ...p.scenes, [sceneId]: next } })); + return next; + } + + async deleteScene(sceneId: SceneId): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + if (!open.project.scenes[sceneId]) throw new Error('Scene not found'); + + await this.updateProject((p) => { + const sceneGraphNodes = p.sceneGraphNodes.filter((n) => n.sceneId !== sceneId); + const nodeIds = new Set(sceneGraphNodes.map((n) => n.id)); + const sceneGraphEdges = p.sceneGraphEdges.filter( + (e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId), + ); + + const scenes = Object.fromEntries( + (Object.entries(p.scenes) as [SceneId, Scene][]).filter(([id]) => id !== sceneId), + ) as Record; + const withGraph: Project = { + ...p, + scenes, + sceneGraphNodes, + sceneGraphEdges, + }; + const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); + const nextScenes = applyConnectionSets(withGraph.scenes, outgoing); + + let currentSceneId = p.currentSceneId; + if (currentSceneId === sceneId) { + const ids = Object.keys(nextScenes) as SceneId[]; + currentSceneId = ids[0] ?? null; + } + + return { + ...withGraph, + scenes: nextScenes, + currentSceneId, + }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async updateSceneGraphNodePosition(nodeId: GraphNodeId, x: number, y: number): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + await this.updateProject((p) => ({ + ...p, + sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)), + })); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async addSceneGraphNode(sceneId: SceneId, x: number, y: number): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + if (!open.project.scenes[sceneId]) throw new Error('Scene not found'); + const node: SceneGraphNode = { + id: asGraphNodeId(`gn_${this.randomId()}`), + sceneId, + x, + y, + isStartScene: false, + }; + await this.updateProject((p) => ({ ...p, sceneGraphNodes: [...p.sceneGraphNodes, node] })); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async setSceneGraphNodeStart(graphNodeId: GraphNodeId | null): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) { + throw new Error('Graph node not found'); + } + await this.updateProject((p) => ({ + ...p, + sceneGraphNodes: p.sceneGraphNodes.map((n) => ({ + ...n, + isStartScene: graphNodeId !== null && n.id === graphNodeId, + })), + })); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async removeSceneGraphNode(nodeId: GraphNodeId): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const nextNodes = open.project.sceneGraphNodes.filter((gn) => gn.id !== nodeId); + const nextEdges = open.project.sceneGraphEdges.filter( + (e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId, + ); + await this.updateProject((p) => { + const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges }; + const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); + return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async addSceneGraphEdge(sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const { sceneGraphNodes, sceneGraphEdges } = open.project; + if (isSceneGraphEdgeRejected(sceneGraphNodes, sceneGraphEdges, sourceGraphNodeId, targetGraphNodeId)) { + return open.project; + } + const edge: SceneGraphEdge = { + id: `e_${this.randomId()}`, + sourceGraphNodeId, + targetGraphNodeId, + }; + await this.updateProject((p) => { + const nextEdges = [...p.sceneGraphEdges, edge]; + const withGraph = { ...p, sceneGraphEdges: nextEdges }; + const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); + return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + async removeSceneGraphEdge(edgeId: string): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + await this.updateProject((p) => { + const nextEdges = p.sceneGraphEdges.filter((e) => e.id !== edgeId); + const withGraph = { ...p, sceneGraphEdges: nextEdges }; + const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); + return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) }; + }); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + /** + * Copies files into cache `assets/` and registers them on the scene + project.assets. + */ + async importMediaFiles( + sceneId: SceneId, + filePaths: string[], + ): Promise<{ project: Project; imported: MediaAsset[] }> { + const open = this.openProject; + if (!open) throw new Error('No open project'); + const existingScene = open.project.scenes[sceneId]; + if (!existingScene) throw new Error('Scene not found'); + 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 sc = p.scenes[sceneId]; + if (!sc) throw new Error('Scene not found'); + const assets = { ...p.assets }; + const media = { + videos: [...sc.media.videos], + audios: [...sc.media.audios], + }; + for (const asset of staged) { + assets[asset.id] = asset; + if (asset.type === 'video') media.videos.push(asset.id); + else + media.audios.push({ + assetId: asset.id, + autoplay: sc.settings.autoplayAudio, + loop: sc.settings.loopAudio, + }); + } + return { + ...p, + assets, + scenes: { ...p.scenes, [sceneId]: { ...sc, media } }, + }; + }); + + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return { project: latest, imported: staged }; + } + + async saveNow(): Promise { + const open = this.openProject; + if (!open) return; + await this.projectWriteChain; + await this.packZipExclusive(open.cacheDir, open.zipPath); + } + + async renameOpenProject(name: string, fileBaseName: string): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + + const nextName = name.trim(); + const nextBase = fileBaseName.trim(); + if (nextName.length < 3) { + throw new Error('Название проекта должно быть не короче 3 символов'); + } + if (nextBase.length < 3) { + throw new Error('Название файла проекта должно быть не короче 3 символов'); + } + + const sanitizedBase = sanitizeFileName(nextBase); + if (sanitizedBase !== nextBase) { + throw new Error('Название файла содержит недопустимые символы'); + } + + await this.ensureRoots(); + const root = getProjectsRootDir(); + const list = await this.listProjects(); + + const oldBase = open.project.meta.fileBaseName; + const nextFileName = `${sanitizedBase}.dnd.zip`; + + const nameClash = list.some( + (p) => p.id !== open.id && p.name.trim().toLowerCase() === nextName.toLowerCase(), + ); + if (nameClash) { + throw new Error('Проект с таким названием уже существует'); + } + + const fileClash = list.some( + (p) => p.id !== open.id && p.fileName.trim().toLowerCase() === nextFileName.toLowerCase(), + ); + if (fileClash) { + throw new Error('Файл проекта с таким названием уже существует'); + } + + // Update project meta first (will auto-save). + await this.updateProject((p) => ({ + ...p, + meta: { ...p.meta, name: nextName, fileBaseName: sanitizedBase }, + })); + + // Rename zip on disk (cache stays the same, just points to new zip path). + if (nextBase !== oldBase) { + const nextZipPath = path.join(root, nextFileName); + await this.projectWriteChain; + await this.packZipExclusive(open.cacheDir, open.zipPath); + await replaceFileAtomic(open.zipPath, nextZipPath); + open.zipPath = nextZipPath; + } + + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + + private queueSave() { + if (this.saveQueued) return; + this.saveQueued = true; + setTimeout(() => void this.flushSave(), 250); + } + + private async flushSave() { + if (this.saving) return; + const open = this.openProject; + if (!open) return; + + this.saveQueued = false; + this.saving = true; + try { + await this.projectWriteChain; + await this.packZipExclusive(open.cacheDir, open.zipPath); + } finally { + this.saving = false; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may change during async save + if (this.saveQueued) { + setTimeout(() => void this.flushSave(), 50); + } + } + } + + private async writeCacheProject(cacheDir: string, project: Project): Promise { + const sessionAtStart = this.projectSession; + const run = async (): Promise => { + await this.waitWhilePacking(); + if (sessionAtStart !== this.projectSession) { + return; + } + await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true }); + const now = new Date().toISOString(); + const withUpdated: Project = { + ...project, + meta: { + ...project.meta, + updatedAt: now, + appVersion: getAppSemanticVersion(), + }, + }; + const targetPath = path.join(cacheDir, 'project.json'); + await atomicWriteFile(targetPath, JSON.stringify(withUpdated, null, 2)); + if (sessionAtStart !== this.projectSession) { + return; + } + if (this.openProject?.cacheDir === cacheDir) { + this.openProject.project = withUpdated; + } + }; + const chained = this.projectWriteChain.then(run); + this.projectWriteChain = chained.catch(() => undefined); + await chained; + } + + private async packZipFromCache(cacheDir: string, zipPath: string): Promise { + const tmpPath = `${zipPath}.tmp`; + await fs.mkdir(path.dirname(zipPath), { recursive: true }); + await zipDir(cacheDir, tmpPath); + await replaceFileAtomic(tmpPath, zipPath); + } + + /** + * Копирует внешний `.dnd.zip` в каталог проектов и открывает его. + * Если архив уже лежит в `projects`, только открывает. + * При конфликте `id` с другим файлом перезаписывает `project.json` в копии с новым id. + */ + async importProjectFromExternalZip(sourcePath: string): Promise { + await this.ensureRoots(); + const resolved = path.resolve(sourcePath); + const st = await fs.stat(resolved).catch(() => null); + if (!st?.isFile()) { + throw new Error('Файл проекта не найден'); + } + if (!resolved.toLowerCase().endsWith('.dnd.zip')) { + throw new Error('Ожидается файл с расширением .dnd.zip'); + } + + const root = getProjectsRootDir(); + const rootNorm = path.normalize(root).toLowerCase(); + const dirNorm = path.normalize(path.dirname(resolved)).toLowerCase(); + const baseName = path.basename(resolved); + + let destPath: string; + let destFileName: string; + + if (dirNorm === rootNorm && baseName.toLowerCase().endsWith('.dnd.zip')) { + destPath = resolved; + destFileName = baseName; + } else { + destFileName = await uniqueDndZipFileName(root, baseName); + destPath = path.join(root, destFileName); + await fs.copyFile(resolved, destPath); + } + + let project = await readProjectJsonFromZip(destPath); + project = normalizeProject(project); + + const entries = await this.listProjects(); + const othersWithSameId = entries.filter((e) => e.id === project.id && e.fileName !== destFileName); + if (othersWithSameId.length > 0) { + const newId = asProjectId(this.randomId()); + const stem = destFileName.replace(/\.dnd\.zip$/iu, ''); + project = { + ...project, + id: newId, + meta: { + ...project.meta, + fileBaseName: project.meta.fileBaseName.trim().length ? project.meta.fileBaseName : stem, + }, + }; + await rewriteProjectJsonInZip(destPath, project); + } + + this.projectSession += 1; + return this.openProjectById(project.id); + } + + /** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */ + async exportProjectZipToPath(projectId: ProjectId, destinationPath: string): Promise { + await this.ensureRoots(); + const list = await this.listProjects(); + const entry = list.find((p) => p.id === projectId); + if (!entry) { + throw new Error('Проект не найден'); + } + const src = path.join(getProjectsRootDir(), entry.fileName); + const dest = path.resolve(destinationPath); + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.copyFile(src, dest); + } + + /** Удаляет архив проекта и кэш распаковки с диска. Если проект открыт — сбрасывает сессию. */ + async deleteProjectById(projectId: ProjectId): Promise { + await this.ensureRoots(); + const list = await this.listProjects(); + const entry = list.find((p) => p.id === projectId); + if (!entry) { + throw new Error('Проект не найден'); + } + const zipPath = path.join(getProjectsRootDir(), entry.fileName); + const cacheDir = path.join(getProjectsCacheRootDir(), projectId); + + if (this.openProject?.id === projectId) { + await this.waitWhilePacking(); + await this.projectWriteChain; + this.saveQueued = false; + this.openProject = null; + this.projectSession += 1; + } + + await fs.rm(zipPath, { force: true }).catch(() => undefined); + await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => undefined); + } + + private randomId(): string { + return crypto.randomBytes(16).toString('hex'); + } + + // id is stored in project.json inside the zip +} + +function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map> { + const gnMap = new Map(nodes.map((n) => [n.id, n])); + const outgoing = new Map>(); + for (const e of edges) { + const a = gnMap.get(e.sourceGraphNodeId); + const b = gnMap.get(e.targetGraphNodeId); + if (!a || !b) continue; + if (a.sceneId === b.sceneId) continue; + let set = outgoing.get(a.sceneId); + if (!set) { + set = new Set(); + outgoing.set(a.sceneId, set); + } + set.add(b.sceneId); + } + return outgoing; +} + +function applyConnectionSets( + scenes: Record, + outgoing: Map>, +): Record { + const next: Record = { ...scenes }; + for (const sid of Object.keys(next) as SceneId[]) { + const prev = next[sid]; + if (prev === undefined) continue; + const c = outgoing.get(sid); + next[sid] = { ...prev, connections: c ? [...c] : [] }; + } + return next; +} + +function migrateSceneGraphFromLegacy(scenes: Record): { + sceneGraphNodes: SceneGraphNode[]; + sceneGraphEdges: SceneGraphEdge[]; +} { + const sceneList = Object.values(scenes); + const sceneGraphNodes: SceneGraphNode[] = sceneList.map((s) => ({ + id: asGraphNodeId(`gn_legacy_${s.id}`), + sceneId: s.id, + x: s.layout.x, + y: s.layout.y, + isStartScene: false, + })); + const byScene = new Map(sceneGraphNodes.map((n) => [n.sceneId, n])); + const sceneGraphEdges: SceneGraphEdge[] = []; + for (const s of sceneList) { + const srcGn = byScene.get(s.id); + if (!srcGn) continue; + for (const to of s.connections) { + const tgtGn = byScene.get(to); + if (!tgtGn) continue; + if (srcGn.sceneId === tgtGn.sceneId) continue; + sceneGraphEdges.push({ + id: `e_legacy_${String(srcGn.id)}_${String(tgtGn.id)}`, + sourceGraphNodeId: srcGn.id, + targetGraphNodeId: tgtGn.id, + }); + } + } + return { sceneGraphNodes, sceneGraphEdges }; +} + +/** Один флаг `isStartScene` на весь проект; лишние true сбрасываются. */ +function normalizeSceneGraphNodeFlags(nodes: SceneGraphNode[]): SceneGraphNode[] { + const withDefaults = nodes.map((n) => { + const raw = n as unknown as { isStartScene?: boolean }; + return { + ...n, + isStartScene: raw.isStartScene === true, + }; + }); + const starters = withDefaults.filter((n) => n.isStartScene); + if (starters.length <= 1) { + return withDefaults; + } + const keep = starters[0]; + if (!keep) return withDefaults; + const keepId = keep.id; + return withDefaults.map((n) => ({ ...n, isStartScene: n.id === keepId })); +} + +function normalizeScene(s: Scene): Scene { + const raw = s.media as unknown as { videos?: AssetId[]; audios?: unknown[] }; + const layoutIn = (s as { layout?: Scene['layout'] }).layout; + const rot = (s as unknown as { previewRotationDeg?: number }).previewRotationDeg; + const previewRotationDeg: Scene['previewRotationDeg'] = rot === 90 || rot === 180 || rot === 270 ? rot : 0; + const legacyPreviewId = + (s as unknown as { previewImageAssetId?: AssetId | null }).previewImageAssetId ?? null; + const previewAssetId = + (s as unknown as { previewAssetId?: AssetId | null }).previewAssetId ?? legacyPreviewId; + const previewAssetType = + (s as unknown as { previewAssetType?: 'image' | 'video' | null }).previewAssetType ?? + (legacyPreviewId ? 'image' : null); + const previewVideoAutostart = Boolean( + (s as unknown as { previewVideoAutostostart?: boolean; previewVideoAutostart?: boolean }) + .previewVideoAutostart, + ); + + const rawAudios = Array.isArray(raw.audios) ? raw.audios : []; + const audios = rawAudios + .map((a) => { + if (typeof a === 'string') { + return { + assetId: a as AssetId, + autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio), + loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio), + }; + } + 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)); + + return { + ...s, + previewAssetId: previewAssetId ?? null, + previewAssetType, + previewVideoAutostart, + previewRotationDeg, + layout: layoutIn ?? { x: 0, y: 0 }, + media: { + videos: raw.videos ?? [], + audios, + }, + }; +} + +function normalizeProject(p: Project): Project { + const scenes: Record = {}; + for (const sid of Object.keys(p.scenes) as SceneId[]) { + const rawScene = p.scenes[sid]; + if (!rawScene) continue; + scenes[sid] = normalizeScene(rawScene); + } + const schemaVersion = (p.meta as { schemaVersion?: number }).schemaVersion ?? 1; + const rawNodes = (p as { sceneGraphNodes?: SceneGraphNode[] }).sceneGraphNodes; + const rawEdges = (p as { sceneGraphEdges?: SceneGraphEdge[] }).sceneGraphEdges; + let sceneGraphNodes: SceneGraphNode[] = Array.isArray(rawNodes) ? rawNodes : []; + let sceneGraphEdges: SceneGraphEdge[] = Array.isArray(rawEdges) ? rawEdges : []; + const needsLegacyGraphMigration = schemaVersion < PROJECT_SCHEMA_VERSION && sceneGraphNodes.length === 0; + if (needsLegacyGraphMigration) { + const migrated = migrateSceneGraphFromLegacy(scenes); + sceneGraphNodes = migrated.sceneGraphNodes; + sceneGraphEdges = migrated.sceneGraphEdges; + } + sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes); + const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null; + const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string }; + const createdWithAppVersion = (() => { + const c = metaRaw.createdWithAppVersion?.trim(); + if (c && c.length > 0) return c; + const a = metaRaw.appVersion?.trim(); + if (a && a.length > 0) return a; + return '0.0.0'; + })(); + return { + ...p, + meta: { + ...p.meta, + fileBaseName: (p.meta as unknown as { fileBaseName?: string }).fileBaseName?.trim().length + ? (p.meta as unknown as { fileBaseName: string }).fileBaseName + : '', + createdWithAppVersion, + schemaVersion: PROJECT_SCHEMA_VERSION, + }, + scenes, + sceneGraphNodes, + sceneGraphEdges, + currentGraphNodeId, + }; +} + +function sanitizeFileName(name: string): string { + const trimmed = name.trim(); + const safe = trimmed.replace(/[<>:"/\\|?*]/gu, '_'); + return safe.length > 0 ? safe : 'Untitled'; +} + +async function uniqueDndZipFileName(root: string, preferredBaseFileName: string): Promise { + let base = preferredBaseFileName; + if (!base.toLowerCase().endsWith('.dnd.zip')) { + const stem = sanitizeFileName(path.basename(base, path.extname(base)) || 'project'); + base = `${stem}.dnd.zip`; + } + const stem = base.replace(/\.dnd\.zip$/iu, ''); + let candidate = base; + let n = 0; + for (;;) { + try { + await fs.access(path.join(root, candidate)); + n += 1; + candidate = `${stem}_${String(n)}.dnd.zip`; + } catch { + return candidate; + } + } +} + +async function replaceFileAtomic(srcPath: string, destPath: string): Promise { + try { + await fs.rename(srcPath, destPath); + } catch { + try { + await fs.rm(destPath, { force: true }); + await fs.rename(srcPath, destPath); + } catch (second: unknown) { + await fs.unlink(srcPath).catch(() => undefined); + throw second instanceof Error ? second : new Error(String(second)); + } + } +} + +type MediaKind = { type: MediaAssetType; mime: string }; + +function classifyMediaPath(filePath: string): MediaKind | null { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.png': + return { type: 'image', mime: 'image/png' }; + case '.jpg': + case '.jpeg': + return { type: 'image', mime: 'image/jpeg' }; + case '.webp': + return { type: 'image', mime: 'image/webp' }; + case '.gif': + return { type: 'image', mime: 'image/gif' }; + case '.mp4': + return { type: 'video', mime: 'video/mp4' }; + case '.webm': + return { type: 'video', mime: 'video/webm' }; + case '.mov': + return { type: 'video', mime: 'video/quicktime' }; + case '.mp3': + return { type: 'audio', mime: 'audio/mpeg' }; + case '.wav': + return { type: 'audio', mime: 'audio/wav' }; + case '.ogg': + return { type: 'audio', mime: 'audio/ogg' }; + case '.m4a': + return { type: 'audio', mime: 'audio/mp4' }; + case '.aac': + return { type: 'audio', mime: 'audio/aac' }; + default: + return null; + } +} + +function buildMediaAsset( + id: AssetId, + kind: MediaKind, + originalName: string, + relPath: string, + sha256: string, + sizeBytes: number, +): MediaAsset { + const createdAt = new Date().toISOString(); + const base = { + id, + mime: kind.mime, + originalName, + relPath, + sha256, + sizeBytes, + createdAt, + }; + if (kind.type === 'image') return { ...base, type: 'image' }; + if (kind.type === 'video') return { ...base, type: 'video' }; + return { ...base, type: 'audio' }; +} + +async function atomicWriteFile(filePath: string, contents: string): Promise { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + const tmp = path.join(dir, `.tmp_${path.basename(filePath)}_${crypto.randomBytes(8).toString('hex')}`); + await fs.writeFile(tmp, contents, 'utf8'); + try { + await fs.rename(tmp, filePath); + } catch { + try { + await fs.rm(filePath, { force: true }); + await fs.rename(tmp, filePath); + } catch (second: unknown) { + await fs.unlink(tmp).catch(() => undefined); + throw second instanceof Error ? second : new Error(String(second)); + } + } +} + +function unzipToDir(zipPath: string, outDir: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => { + if (err) return reject(err); + const zipFile = zip; + zipFile.readEntry(); + zipFile.on('entry', (entry: yauzl.Entry) => { + const filePath = path.join(outDir, entry.fileName); + if (entry.fileName.endsWith('/')) { + fssync.mkdirSync(filePath, { recursive: true }); + zipFile.readEntry(); + return; + } + fssync.mkdirSync(path.dirname(filePath), { recursive: true }); + zipFile.openReadStream(entry, (streamErr, readStream) => { + if (streamErr) return reject(streamErr); + readStream.on('error', reject); + const writeStream = fssync.createWriteStream(filePath); + writeStream.on('error', reject); + readStream.pipe(writeStream); + writeStream.on('close', () => zipFile.readEntry()); + }); + }); + zipFile.on('end', resolve); + zipFile.on('error', reject); + }); + }); +} + +async function readProjectJsonFromZip(zipPath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => { + if (err) return reject(err); + const zipFile = zip; + zipFile.readEntry(); + zipFile.on('entry', (entry: yauzl.Entry) => { + if (entry.fileName !== 'project.json') { + zipFile.readEntry(); + return; + } + zipFile.openReadStream(entry, (streamErr, readStream) => { + if (streamErr) return reject(streamErr); + const chunks: Buffer[] = []; + readStream.on('data', (c: Buffer) => chunks.push(c)); + readStream.on('error', reject); + readStream.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8'); + const parsed = JSON.parse(raw) as unknown as Project; + resolve(parsed); + } catch (e) { + reject(e instanceof Error ? e : new Error('Failed to parse project.json')); + } + }); + }); + }); + zipFile.on('error', reject); + zipFile.on('end', () => reject(new Error('project.json not found in zip'))); + }); + }); +} + +/** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */ +function zipOptionsForRelativeEntry(rel: string): { compressionLevel: number } { + const norm = rel.replace(/\\/gu, '/').toLowerCase(); + if (norm === 'project.json') { + return { compressionLevel: 9 }; + } + if (norm.startsWith('assets/')) { + const ext = path.extname(norm).toLowerCase(); + if ( + ext === '.jpg' || + ext === '.jpeg' || + ext === '.mp4' || + ext === '.webm' || + ext === '.mov' || + ext === '.mp3' || + ext === '.m4a' || + ext === '.aac' || + ext === '.ogg' || + ext === '.webp' || + ext === '.gif' + ) { + return { compressionLevel: 0 }; + } + } + return { compressionLevel: 9 }; +} + +async function zipDir(srcDir: string, outZipPath: string): Promise { + const zipfile = new ZipFile(); + const all = await listFilesRecursive(srcDir); + for (const abs of all) { + const rel = path.relative(srcDir, abs).replace(/\\/gu, '/'); + zipfile.addFile(abs, rel, zipOptionsForRelativeEntry(rel)); + } + await fs.mkdir(path.dirname(outZipPath), { recursive: true }); + const out = fssync.createWriteStream(outZipPath); + const done = new Promise((resolve, reject) => { + out.on('close', resolve); + out.on('error', reject); + }); + zipfile.outputStream.pipe(out); + zipfile.end(); + await done; +} + +async function rewriteProjectJsonInZip(zipPath: string, project: Project): Promise { + const work = path.join(tmpdir(), `dnd_rewrite_${crypto.randomBytes(8).toString('hex')}`); + await fs.mkdir(work, { recursive: true }); + try { + await unzipToDir(zipPath, work); + await atomicWriteFile(path.join(work, 'project.json'), JSON.stringify(project, null, 2)); + const outTmp = `${zipPath}.rewrite.tmp`; + await zipDir(work, outTmp); + await replaceFileAtomic(outTmp, zipPath); + } finally { + await fs.rm(work, { recursive: true, force: true }); + } +} + +async function listFilesRecursive(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out: string[] = []; + for (const e of entries) { + if (e.name.startsWith('.tmp_')) { + continue; + } + const abs = path.join(dir, e.name); + if (e.isDirectory()) { + out.push(...(await listFilesRecursive(abs))); + } else if (e.isFile()) { + out.push(abs); + } + } + return out; +} diff --git a/app/main/protocol/dndAssetProtocol.ts b/app/main/protocol/dndAssetProtocol.ts new file mode 100644 index 0000000..8d09b8b --- /dev/null +++ b/app/main/protocol/dndAssetProtocol.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs/promises'; + +import { session } from 'electron'; + +import { asAssetId } from '../../shared/types/ids'; +import type { ZipProjectStore } from '../project/zipStore'; + +/** + * Обслуживает `dnd://asset?...` — без этого `` в рендерере часто ломается. + */ +export function registerDndAssetProtocol(projectStore: ZipProjectStore): void { + session.defaultSession.protocol.handle('dnd', async (request) => { + const url = new URL(request.url); + if (url.hostname !== 'asset') { + return new Response(null, { status: 404 }); + } + const id = url.searchParams.get('id'); + if (!id) { + return new Response(null, { status: 404 }); + } + const info = projectStore.getAssetReadInfo(asAssetId(id)); + if (!info) { + return new Response(null, { status: 404 }); + } + try { + const stat = await fs.stat(info.absPath); + const total = stat.size; + const range = request.headers.get('range') ?? request.headers.get('Range'); + + if (range) { + const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim()); + if (m) { + const start = Number(m[1]); + const endRaw = m[2] ? Number(m[2]) : total - 1; + const end = Math.min(endRaw, total - 1); + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) { + return new Response(null, { status: 416 }); + } + const len = end - start + 1; + const fh = await fs.open(info.absPath, 'r'); + try { + const buf = Buffer.alloc(len); + await fh.read(buf, 0, len, start); + return new Response(buf, { + status: 206, + headers: { + 'Content-Type': info.mime, + 'Accept-Ranges': 'bytes', + 'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`, + 'Content-Length': String(len), + 'Cache-Control': 'public, max-age=300', + }, + }); + } finally { + await fh.close(); + } + } + } + + const buf = await fs.readFile(info.absPath); + return new Response(buf, { + headers: { + 'Content-Type': info.mime, + 'Accept-Ranges': 'bytes', + 'Content-Length': String(buf.length), + 'Cache-Control': 'public, max-age=300', + }, + }); + } catch { + return new Response(null, { status: 404 }); + } + }); +} diff --git a/app/main/tsconfig.json b/app/main/tsconfig.json new file mode 100644 index 0000000..6ca5298 --- /dev/null +++ b/app/main/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "outDir": "../../dist/main", + "moduleResolution": "NodeNext", + "module": "NodeNext", + "types": ["node", "electron"], + "lib": ["ES2022"] + }, + "include": ["./**/*.ts"], + "exclude": ["../../dist", "../../node_modules"] +} diff --git a/app/main/versionInfo.ts b/app/main/versionInfo.ts new file mode 100644 index 0000000..01d0d86 --- /dev/null +++ b/app/main/versionInfo.ts @@ -0,0 +1,24 @@ +import { app } from 'electron'; + +/** + * Семантическая версия приложения: из упакованного приложения — `app.getVersion()` (= `version` из package.json), + * в dev при запуске через npm — обычно то же; иначе fallback на `npm_package_version` / `0.0.0`. + */ +export function getAppSemanticVersion(): string { + try { + const v = app.getVersion(); + if (typeof v === 'string' && v.trim().length > 0) { + return v.trim(); + } + } catch { + /* вне процесса Electron */ + } + const fromEnv = process.env.npm_package_version?.trim(); + return fromEnv !== undefined && fromEnv.length > 0 ? fromEnv : '0.0.0'; +} + +/** Необязательный номер сборки из CI (`DND_BUILD_NUMBER`). */ +export function getOptionalBuildNumber(): string | null { + const b = process.env.DND_BUILD_NUMBER?.trim(); + return b && b.length > 0 ? b : null; +} diff --git a/app/main/video/videoPlaybackStore.ts b/app/main/video/videoPlaybackStore.ts new file mode 100644 index 0000000..6eaa57b --- /dev/null +++ b/app/main/video/videoPlaybackStore.ts @@ -0,0 +1,108 @@ +import type { VideoPlaybackEvent, VideoPlaybackState } from '../../shared/types'; + +function nowMs(): number { + return Date.now(); +} + +function clamp(v: number, min: number, max: number): number { + return Math.max(min, Math.min(max, v)); +} + +export class VideoPlaybackStore { + private state: VideoPlaybackState = { + revision: 1, + serverNowMs: nowMs(), + targetAssetId: null, + playing: false, + playbackRate: 1, + anchorServerMs: nowMs(), + anchorVideoTimeSec: 0, + }; + + getState(): VideoPlaybackState { + return { ...this.state, serverNowMs: nowMs() }; + } + + dispatch(event: VideoPlaybackEvent): VideoPlaybackState { + const s = this.getState(); + const curTime = computeTimeSec(s, s.serverNowMs); + const bump = (patch: Omit): VideoPlaybackState => ({ + ...patch, + revision: s.revision + 1, + serverNowMs: nowMs(), + }); + + switch (event.kind) { + case 'target.set': { + const nextTarget = event.assetId ?? null; + const next: Omit = { + ...s, + targetAssetId: nextTarget, + playing: Boolean(event.autostart) && nextTarget !== null, + playbackRate: s.playbackRate, + anchorServerMs: s.serverNowMs, + anchorVideoTimeSec: 0, + }; + this.state = bump(next); + return this.state; + } + case 'play': { + this.state = bump({ + ...s, + playing: true, + anchorServerMs: s.serverNowMs, + anchorVideoTimeSec: curTime, + }); + return this.state; + } + case 'pause': { + this.state = bump({ + ...s, + playing: false, + anchorServerMs: s.serverNowMs, + anchorVideoTimeSec: curTime, + }); + return this.state; + } + case 'stop': { + this.state = bump({ + ...s, + playing: false, + anchorServerMs: s.serverNowMs, + anchorVideoTimeSec: 0, + }); + return this.state; + } + case 'seek': { + const t = clamp(event.timeSec, 0, 1_000_000); + this.state = bump({ + ...s, + anchorServerMs: s.serverNowMs, + anchorVideoTimeSec: t, + }); + return this.state; + } + case 'rate.set': { + const rate = clamp(event.rate, 0.25, 3); + this.state = bump({ + ...s, + playbackRate: rate, + anchorServerMs: s.serverNowMs, + anchorVideoTimeSec: curTime, + }); + return this.state; + } + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _x: never = event; + return this.state; + } + } + } +} + +export function computeTimeSec(state: VideoPlaybackState, atServerNowMs: number): number { + if (!state.playing) return state.anchorVideoTimeSec; + const dt = Math.max(0, atServerNowMs - state.anchorServerMs); + return state.anchorVideoTimeSec + (dt / 1000) * state.playbackRate; +} diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts new file mode 100644 index 0000000..60891ad --- /dev/null +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +function readCreateWindows(): string { + return fs.readFileSync(path.join(here, 'createWindows.ts'), 'utf8'); +} + +void test('createWindows: закрытие редактора завершает приложение', () => { + const src = readCreateWindows(); + assert.match(src, /kind === 'editor'/); + assert.match(src, /win\.on\(\s*['"]close['"]/); + assert.ok(src.includes('appQuitting')); + assert.ok(src.includes('e.preventDefault()')); + assert.ok(src.includes('quitAppFromEditorClose') || src.includes('app.quit()')); + assert.ok(src.includes('markAppQuitting')); +}); + +void test('createWindows: иконка окна (PNG приоритетно, затем SVG)', () => { + const src = readCreateWindows(); + assert.ok(src.includes('resolveWindowIconPath')); + assert.ok(src.includes('app-window-icon.png')); + assert.ok(src.includes('app-logo.svg')); +}); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts new file mode 100644 index 0000000..c874bc4 --- /dev/null +++ b/app/main/windows/createWindows.ts @@ -0,0 +1,159 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { app, BrowserWindow, nativeImage, screen } from 'electron'; + +type WindowKind = 'editor' | 'presentation' | 'control'; + +const windows = new Map(); + +let appQuitting = false; + +/** Разрешает реальное закрытие окна редактора (выход из приложения). */ +export function markAppQuitting(): void { + appQuitting = true; +} + +function quitAppFromEditorClose(): void { + markAppQuitting(); + app.quit(); +} + +function isDev() { + return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined; +} + +function getRendererUrl(kind: WindowKind): string { + const dev = process.env.VITE_DEV_SERVER_URL; + if (dev) { + const page = + kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html'; + return new URL(page, dev).toString(); + } + const filePath = path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`); + return pathToFileURL(filePath).toString(); +} + +function getPreloadPath(): string { + return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs'); +} + +/** + * Иконка окна. На Windows `nativeImage` из SVG часто пустой — сначала ищем PNG + * (`app-window-icon.png`), затем SVG из public / dist. + */ +function resolveWindowIconPath(): string | undefined { + const root = app.getAppPath(); + const candidates = [ + path.join(root, 'dist', 'renderer', 'app-window-icon.png'), + path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'), + path.join(root, 'dist', 'renderer', 'app-logo.svg'), + path.join(root, 'app', 'renderer', 'public', 'app-logo.svg'), + ]; + for (const p of candidates) { + try { + if (fs.existsSync(p)) return p; + } catch { + /* ignore */ + } + } + return undefined; +} + +function resolveWindowIcon(): Electron.NativeImage | undefined { + const p = resolveWindowIconPath(); + if (!p) return undefined; + try { + const img = nativeImage.createFromPath(p); + if (!img.isEmpty()) return img; + } catch { + /* ignore */ + } + return undefined; +} + +function createWindow(kind: WindowKind): BrowserWindow { + const icon = resolveWindowIcon(); + const win = new BrowserWindow({ + width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280, + height: 800, + show: false, + backgroundColor: '#09090B', + ...(icon ? { icon } : {}), + webPreferences: { + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: isDev(), + preload: getPreloadPath(), + autoplayPolicy: 'no-user-gesture-required', + }, + }); + + win.webContents.on('preload-error', (_event, preloadPath, error) => { + console.error(`[preload-error] ${preloadPath}:`, error); + }); + win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { + console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`); + }); + + win.once('ready-to-show', () => win.show()); + void win.loadURL(getRendererUrl(kind)); + if (kind === 'editor') { + win.on('close', (e) => { + if (appQuitting) return; + e.preventDefault(); + quitAppFromEditorClose(); + }); + } + win.on('closed', () => windows.delete(kind)); + windows.set(kind, win); + return win; +} + +export function createWindows() { + if (!windows.has('editor')) { + createWindow('editor'); + } +} + +export function focusEditorWindow(): void { + const win = windows.get('editor'); + if (win) { + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + } else { + createWindows(); + } +} + +export function openMultiWindow() { + if (!windows.has('presentation')) { + const display = screen.getPrimaryDisplay(); + const { x, y, width, height } = display.bounds; + const win = createWindow('presentation'); + win.setBounds({ x, y, width, height }); + win.setMenuBarVisibility(false); + win.maximize(); + } + if (!windows.has('control')) { + createWindow('control'); + } +} + +export function closeMultiWindow(): void { + const pres = windows.get('presentation'); + const ctrl = windows.get('control'); + if (pres) pres.close(); + if (ctrl) ctrl.close(); +} + +export function togglePresentationFullscreen(): boolean { + const pres = windows.get('presentation'); + if (!pres) return false; + const next = !pres.isFullScreen(); + pres.setFullScreen(next); + return pres.isFullScreen(); +} diff --git a/app/preload/index.ts b/app/preload/index.ts new file mode 100644 index 0000000..939c69b --- /dev/null +++ b/app/preload/index.ts @@ -0,0 +1,24 @@ +import { contextBridge } from 'electron'; + +import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts'; + +import { invoke, on } from './ipcClient'; + +export type DndApi = { + invoke: ( + channel: K, + payload: IpcInvokeMap[K]['req'], + ) => Promise; + on: (channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void; +}; + +const api: DndApi = { invoke, on }; + +contextBridge.exposeInMainWorld('dnd', api); + +declare global { + var dnd: DndApi | undefined; + interface Window { + dnd: DndApi; + } +} diff --git a/app/preload/ipcClient.ts b/app/preload/ipcClient.ts new file mode 100644 index 0000000..b8821a9 --- /dev/null +++ b/app/preload/ipcClient.ts @@ -0,0 +1,23 @@ +import { ipcRenderer } from 'electron'; + +import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts'; + +export async function invoke( + channel: K, + payload: IpcInvokeMap[K]['req'], +): Promise { + return (await ipcRenderer.invoke(channel as string, payload)) as IpcInvokeMap[K]['res']; +} + +export function on( + channel: K, + listener: (payload: IpcEventMap[K]) => void, +): () => void { + const wrapped = (_: Electron.IpcRendererEvent, payload: unknown) => { + listener(payload as IpcEventMap[K]); + }; + ipcRenderer.on(channel as string, wrapped); + return () => { + ipcRenderer.off(channel as string, wrapped); + }; +} diff --git a/app/preload/tsconfig.json b/app/preload/tsconfig.json new file mode 100644 index 0000000..c885fb5 --- /dev/null +++ b/app/preload/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "outDir": "../../dist/preload", + "moduleResolution": "NodeNext", + "module": "NodeNext", + "types": ["node", "electron"], + "lib": ["ES2022", "DOM"] + }, + "include": ["./**/*.ts"], + "exclude": ["../../dist", "../../node_modules"] +} diff --git a/app/renderer/ambient-modules.d.ts b/app/renderer/ambient-modules.d.ts new file mode 100644 index 0000000..8eb6ea9 --- /dev/null +++ b/app/renderer/ambient-modules.d.ts @@ -0,0 +1 @@ +declare module 'reactflow/dist/style.css'; diff --git a/app/renderer/control.html b/app/renderer/control.html new file mode 100644 index 0000000..d41d20f --- /dev/null +++ b/app/renderer/control.html @@ -0,0 +1,13 @@ + + + + + + + DnD Player — Control + + +
+ + + diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css new file mode 100644 index 0000000..8a8245e --- /dev/null +++ b/app/renderer/control/ControlApp.module.css @@ -0,0 +1,359 @@ +.page { + height: 100vh; + padding: 16px; + display: grid; + grid-template-columns: 280px 1fr; + gap: 16px; +} + +.remote { + padding: 12px; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.remoteTitle { + font-size: var(--text-xs); + font-weight: 900; + letter-spacing: 0.8px; + color: var(--text1); +} + +.spacer12 { + height: 12px; +} + +.spacer8 { + height: 8px; +} + +.spacer10 { + height: 10px; +} + +.sectionLabel { + font-size: var(--text-xs); + font-weight: 800; + color: var(--text2); +} + +.effectsStack { + display: flex; + flex-direction: column; + gap: 10px; +} + +.iconRow { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.iconGlyph { + font-size: 18px; + line-height: 1; + display: block; +} + +.clearIcon { + display: flex; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; +} + +.radiusRow { + display: grid; + grid-template-columns: 100px 1fr 44px; + align-items: center; + gap: 8px; +} + +.radiusLabel { + color: var(--text2); + font-size: var(--text-xs); + font-weight: 800; +} + +.range { + width: 100%; +} + +.radiusValue { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text2); + font-size: var(--text-xs); +} + +.storyWrap { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.storyScroll { + flex: 0 0 70%; + min-height: 0; + border-radius: var(--radius-lg); + border: 1px solid var(--stroke); + background: var(--color-overlay-dark-2); + overflow: auto; + padding: 10px; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 10px; +} + +.historyBtn { + text-align: left; + padding: 10px; + border-radius: var(--scene-tile-radius); + border: none; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0.9; +} + +.historyBtn:not(:disabled):not(.historyBtnCurrent):hover { + background: var(--scene-list-hover-bg); +} + +.historyBtn:disabled { + cursor: default; + opacity: 0.9; +} + +.historyBtnCurrent { + border: 1px solid var(--scene-list-selected-border); + background: var(--scene-list-selected-bg); + cursor: default; +} + +.historyBadge { + color: var(--accent2); + font-size: var(--text-xs); + font-weight: 900; +} + +.historyMuted { + color: var(--text2); + font-size: var(--text-xs); +} + +.historyTitle { + font-weight: 800; +} + +.emptyStory { + color: var(--text2); + font-size: var(--text-xs); +} + +.rightStack { + min-width: 0; + display: grid; + grid-template-rows: auto auto auto; + gap: 16px; +} + +.surfacePad { + padding: 12px; +} + +.previewHeader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.previewTitle { + font-size: var(--text-md); + font-weight: 900; +} + +.previewActions { + display: flex; + gap: 10px; +} + +.videoHint { + color: var(--text2); + font-size: var(--text-xs); + line-height: 1.45; + margin-bottom: 8px; +} + +.previewFrame { + border-radius: 18px; + border: 1px solid var(--stroke); + height: 360px; + overflow: hidden; + background: var(--color-overlay-dark-2); + position: relative; +} + +.previewHost { + position: absolute; + inset: 0; +} + +.brushCursor { + position: absolute; + z-index: 2; + transform: translate(-50%, -50%); + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.55); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.3), + 0 0 18px rgba(255, 140, 40, 0.12), + inset 0 0 18px rgba(255, 140, 40, 0.1); + background: rgba(255, 160, 60, 0.03); + pointer-events: none; +} + +.brushLayer { + position: absolute; + inset: 0; + z-index: 3; + cursor: crosshair; +} + +.branchTitle { + font-size: var(--text-md); + font-weight: 900; + margin-bottom: 10px; +} + +.branchGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.branchCard { + border-radius: var(--scene-tile-radius); + border: 1px solid var(--stroke); + background: var(--color-overlay-dark-2); + padding: 12px; + display: grid; + gap: 10px; +} + +.branchCardHeader { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--text2); +} + +.branchOption { + font-size: 11px; + font-weight: 900; +} + +.branchName { + font-weight: 900; +} + +.musicHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.musicEmpty { + color: var(--text2); + font-size: var(--text-xs); +} + +.audioMeta { + min-width: 0; +} + +.audioBadges { + display: flex; + gap: 10px; + color: var(--text2); + font-size: var(--text-xs); +} + +.audioName { + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.audioTransport { + display: flex; + gap: 10px; + flex-shrink: 0; +} + +.scrubFill { + height: 100%; + background: var(--accent-fill-solid); +} + +.timeRow { + margin-top: 6px; + display: flex; + justify-content: space-between; + color: var(--text2); + font-size: 11px; +} + +.branchEmpty { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--text2); + font-size: var(--text-xs); + padding: 6px 2px; +} + +.audioList { + display: grid; + gap: 8px; + max-height: 210px; + overflow: auto; +} + +.audioCard { + padding: 10px; + border-radius: 14px; + border: 1px solid var(--stroke); + background: var(--color-overlay-dark-2); + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; +} + +.audioScrub { + margin-top: 10px; + height: 10px; + border-radius: var(--radius-pill); + border: 1px solid var(--stroke); + background: rgba(0, 0, 0, 0.22); + overflow: hidden; +} + +.audioScrubPointer { + cursor: pointer; +} + +.audioScrubDefault { + cursor: default; +} diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx new file mode 100644 index 0000000..6bcc6c1 --- /dev/null +++ b/app/renderer/control/ControlApp.tsx @@ -0,0 +1,887 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { ipcChannels } from '../../shared/ipc/contracts'; +import type { SessionState } from '../../shared/ipc/contracts'; +import type { GraphNodeId, Scene, SceneId } from '../../shared/types'; +import { getDndApi } from '../shared/dndApi'; +import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay'; +import { useEffectsState } from '../shared/effects/useEffectsState'; +import { Button } from '../shared/ui/controls'; +import { Surface } from '../shared/ui/Surface'; + +import styles from './ControlApp.module.css'; +import { ControlScenePreview } from './ControlScenePreview'; + +function formatTime(sec: number): string { + if (!Number.isFinite(sec) || sec < 0) return '0:00'; + const s = Math.floor(sec); + const m = Math.floor(s / 60); + const r = s % 60; + return `${String(m)}:${String(r).padStart(2, '0')}`; +} + +export function ControlApp() { + const api = getDndApi(); + const [fxState, fx] = useEffectsState(); + const [session, setSession] = useState(null); + 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 previewHostRef = useRef(null); + const previewVideoRef = useRef(null); + const brushRef = useRef<{ + tool: 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser'; + startN?: { x: number; y: number }; + points?: { x: number; y: number; tMs: number }[]; + } | null>(null); + const [draftFxTick, setDraftFxTick] = useState(0); + const [cursorN, setCursorN] = useState<{ x: number; y: number } | null>(null); + const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 }); + const [previewContentRect, setPreviewContentRect] = useState<{ + x: number; + y: number; + w: number; + h: number; + } | null>(null); + + useEffect(() => { + void api.invoke(ipcChannels.project.get, {}).then((res) => { + const next: SessionState = { + project: res.project, + currentSceneId: res.project?.currentSceneId ?? null, + }; + setSession(next); + historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : []; + setHistory(historyRef.current); + }); + return api.on(ipcChannels.session.stateChanged, ({ state }) => { + setSession(state); + const cur = state.project?.currentGraphNodeId ?? null; + if (!cur) return; + const arr = historyRef.current; + if (suppressNextHistoryPushRef.current) { + suppressNextHistoryPushRef.current = false; + setHistory(arr); + return; + } + // Если мы перемотались на уже существующий шаг, не дублируем его в истории. + if (arr.includes(cur)) { + setHistory(arr); + return; + } + if (arr[arr.length - 1] !== cur) { + historyRef.current = [...arr, cur]; + setHistory(historyRef.current); + } + }); + }, [api]); + + const project = session?.project ?? null; + const currentGraphNodeId = project?.currentGraphNodeId ?? null; + const currentScene = + project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined; + const isVideoPreviewScene = currentScene?.previewAssetType === 'video'; + const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]); + + const sceneAudios = useMemo(() => { + if (!project) return []; + return sceneAudioRefs + .map((r) => { + const a = project.assets[r.assetId]; + return a?.type === 'audio' ? { ref: r, asset: a } : null; + }) + .filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable['asset'] } => + Boolean(x), + ); + }, [project, sceneAudioRefs]); + + useEffect(() => { + audioLoadRunRef.current += 1; + const runId = audioLoadRunRef.current; + // Cleanup old audios on scene change. + const els = audioElsRef.current; + for (const el of els.values()) { + try { + el.pause(); + el.currentTime = 0; + } catch { + // ignore + } + } + els.clear(); + audioMetaRef.current.clear(); + setAudioStateTick((x) => x + 1); + + if (!project || !currentScene) return; + void (async () => { + 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 (!r.url) continue; + const el = new Audio(r.url); + el.loop = item.loop; + el.preload = 'auto'; + 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)); + loaded.push({ ref: item, el }); + audioElsRef.current.set(item.assetId, el); + } + setAudioStateTick((x) => x + 1); + for (const { ref, el } of loaded) { + if (audioLoadRunRef.current !== runId) { + try { + el.pause(); + el.currentTime = 0; + } catch { + // ignore + } + continue; + } + if (!ref.autoplay) continue; + try { + await el.play(); + } catch { + const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; + audioMetaRef.current.set(ref.assetId, { + ...m, + lastPlayError: + 'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.', + }); + setAudioStateTick((x) => x + 1); + } + } + })(); + }, [api, currentScene, project, sceneAudioRefs]); + + const anyPlaying = useMemo(() => { + for (const el of audioElsRef.current.values()) { + if (!el.paused) return true; + } + return false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [audioStateTick]); + + useEffect(() => { + if (!anyPlaying) return; + let raf = 0; + const tick = () => { + setAudioStateTick((x) => x + 1); + raf = window.requestAnimationFrame(tick); + }; + raf = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(raf); + }, [anyPlaying]); + + useEffect(() => { + const host = previewHostRef.current; + if (!host) return; + const update = () => { + const r = host.getBoundingClientRect(); + setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(host); + return () => ro.disconnect(); + }, []); + + function audioStatus(assetId: string): { label: string; detail?: string } { + const el = audioElsRef.current.get(assetId) ?? null; + if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' }; + const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null }; + if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError }; + if (el.error) + return { + label: 'Ошибка', + detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`, + }; + if (el.readyState < 2) return { label: 'Загрузка…' }; + if (!el.paused) return { label: 'Играет' }; + if (el.currentTime > 0) return { label: 'Пауза' }; + return { label: 'Остановлено' }; + } + const nextScenes = useMemo(() => { + if (!project) return []; + if (!currentGraphNodeId) return []; + const outgoing = project.sceneGraphEdges + .filter((e) => e.sourceGraphNodeId === currentGraphNodeId) + .map((e) => { + const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId); + return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null; + }) + .filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x)); + return outgoing + .map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] })) + .filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined); + }, [currentGraphNodeId, project]); + + const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 }; + + function toNPoint(e: React.PointerEvent): { x: number; y: number } | null { + const host = previewHostRef.current; + if (!host) return null; + const r = host.getBoundingClientRect(); + const cr = previewContentRect; + const ox = cr ? cr.x : 0; + const oy = cr ? cr.y : 0; + const cw = cr ? cr.w : r.width; + const ch = cr ? cr.h : r.height; + const x = (e.clientX - (r.left + ox)) / Math.max(1, cw); + const y = (e.clientY - (r.top + oy)) / Math.max(1, ch); + return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }; + } + + async function commitStroke(): Promise { + if (isVideoPreviewScene) { + brushRef.current = null; + setDraftFxTick((x) => x + 1); + return; + } + if (!fxState) return; + const b = brushRef.current; + if (!b) return; + const createdAtMs = Date.now(); + const seed = Math.floor(Math.random() * 1_000_000_000); + + if (b.tool === 'fog' && b.points && b.points.length > 0) { + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `fog_${String(createdAtMs)}_${String(seed)}`, + type: 'fog', + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + opacity: Math.max(0.05, Math.min(0.9, tool.intensity)), + lifetimeMs: null, + }, + }); + } + if (b.tool === 'fire' && b.points && b.points.length > 0) { + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `fire_${String(createdAtMs)}_${String(seed)}`, + type: 'fire', + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + // Огонь визуально ярче, но всё равно ограничиваемся безопасными пределами. + opacity: Math.max(0.12, Math.min(0.95, tool.intensity)), + lifetimeMs: null, + }, + }); + } + if (b.tool === 'rain' && b.points && b.points.length > 0) { + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `rain_${String(createdAtMs)}_${String(seed)}`, + type: 'rain', + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + opacity: Math.max(0.08, Math.min(0.9, tool.intensity)), + lifetimeMs: null, + }, + }); + } + if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return; + const end = { x: last.x, y: last.y }; + const start = { x: end.x, y: 0 }; + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `lt_${String(createdAtMs)}_${String(seed)}`, + type: 'lightning', + seed, + createdAtMs, + start, + end, + widthN: Math.max(0.01, tool.radiusN * 0.9), + intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)), + lifetimeMs: 180, + }, + }); + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `sc_${String(createdAtMs)}_${String(seed)}`, + type: 'scorch', + seed: seed ^ 0x5a5a5a, + createdAtMs, + at: end, + radiusN: Math.max(0.03, tool.radiusN * 0.625), + opacity: 0.92, + lifetimeMs: 60_000, + }, + }); + } + if (b.tool === 'freeze' && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return; + const at = { x: last.x, y: last.y }; + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `fr_${String(createdAtMs)}_${String(seed)}`, + type: 'freeze', + seed, + createdAtMs, + at, + intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), + // Быстро появиться → чуть задержаться → плавно исчезнуть. + lifetimeMs: 820, + }, + }); + await fx.dispatch({ + kind: 'instance.add', + instance: { + id: `ice_${String(createdAtMs)}_${String(seed)}`, + type: 'ice', + seed: seed ^ 0x33cc99, + createdAtMs, + at, + radiusN: Math.max(0.03, tool.radiusN * 0.9), + opacity: 0.85, + lifetimeMs: 60_000, + }, + }); + } + brushRef.current = null; + setDraftFxTick((x) => x + 1); + } + const draftInstance = useMemo(() => { + const b = brushRef.current; + if (!b) return null; + const seed = 12345; + const createdAtMs = Date.now(); + if (b.tool === 'fog' && b.points && b.points.length > 0) { + return { + id: '__draft__', + type: 'fog' as const, + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)), + lifetimeMs: null, + }; + } + if (b.tool === 'fire' && b.points && b.points.length > 0) { + return { + id: '__draft__', + type: 'fire' as const, + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + opacity: Math.max(0.12, Math.min(0.75, tool.intensity * 0.85)), + lifetimeMs: null, + }; + } + if (b.tool === 'rain' && b.points && b.points.length > 0) { + return { + id: '__draft__', + type: 'rain' as const, + seed, + createdAtMs, + points: b.points, + radiusN: tool.radiusN, + opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)), + lifetimeMs: null, + }; + } + if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'lightning' as const, + seed, + createdAtMs, + start: { x: last.x, y: 0 }, + end: { x: last.x, y: last.y }, + widthN: Math.max(0.01, tool.radiusN * 0.9), + intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)), + lifetimeMs: 180, + }; + } + if (b.tool === 'freeze' && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'freeze' as const, + seed, + createdAtMs, + at: { x: last.x, y: last.y }, + intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), + lifetimeMs: 240, + }; + } + return null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draftFxTick, tool.intensity, tool.radiusN, tool.tool]); + + const fxMergedState = useMemo(() => { + if (!fxState) return null; + if (!draftInstance) return fxState; + return { ...fxState, instances: [...fxState.instances, draftInstance] }; + }, [draftInstance, fxState]); + + return ( +
+ +
ПУЛЬТ УПРАВЛЕНИЯ
+
+ {!isVideoPreviewScene ? ( + <> +
ЭФФЕКТЫ
+
+
+
+ + + + + + + +
+
+
Радиус кисти
+ { + const v = Number((e.currentTarget as HTMLInputElement).value); + const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN)); + void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } }); + }} + className={styles.range} + aria-label="Радиус кисти" + /> +
{Math.round(tool.radiusN * 100)}
+
+
+
+ + ) : null} +
+
СЮЖЕТНАЯ ЛИНИЯ
+
+
+ {history.map((gnId, idx) => { + const gn = project?.sceneGraphNodes.find((n) => n.id === gnId); + const s = gn ? project?.scenes[gn.sceneId] : undefined; + const isCurrent = gnId === project?.currentGraphNodeId; + return ( + + ); + })} + {history.length === 0 ?
Нет активной сцены.
: null} +
+
+ + +
+ +
+
Предпросмотр экрана
+
+ +
+
+
+ {isVideoPreviewScene ? ( +
+ Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для + изображения). +
+ ) : null} +
+
+
+ +
+ {!isVideoPreviewScene ? ( + <> + + {cursorN ? ( +
+ ) : null} +
{ + const p = toNPoint(e); + if (!p) return; + setCursorN(p); + }} + onPointerLeave={() => setCursorN(null)} + onPointerDown={(e) => { + const p = toNPoint(e); + if (!p) return; + setCursorN(p); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + if (tool.tool === 'eraser') { + const rN = tool.radiusN; + const nearest = (fxState?.instances ?? []) + .map((inst) => { + if (inst.type === 'fog') { + const d = inst.points.reduce((best, q) => { + const dx = q.x - p.x; + const dy = q.y - p.y; + const dd = dx * dx + dy * dy; + return Math.min(best, dd); + }, Number.POSITIVE_INFINITY); + return { id: inst.id, dd: d }; + } + if (inst.type === 'lightning') { + const dx = inst.end.x - p.x; + const dy = inst.end.y - p.y; + return { id: inst.id, dd: dx * dx + dy * dy }; + } + if (inst.type === 'freeze') { + const dx = inst.at.x - p.x; + const dy = inst.at.y - p.y; + return { id: inst.id, dd: dx * dx + dy * dy }; + } + return { id: inst.id, dd: Number.POSITIVE_INFINITY }; + }) + .sort((a, b) => a.dd - b.dd)[0]; + if (nearest && nearest.dd <= rN * rN) { + void fx.dispatch({ kind: 'instance.remove', id: nearest.id }); + } + return; + } + brushRef.current = { + tool: tool.tool, + startN: p, + points: [{ x: p.x, y: p.y, tMs: Date.now() }], + }; + setDraftFxTick((x) => x + 1); + }} + onPointerMove={(e) => { + const b = brushRef.current; + const p = toNPoint(e); + if (!p) return; + setCursorN(p); + if (!b?.points) return; + const last = b.points[b.points.length - 1]; + if (!last) return; + const dx = p.x - last.x; + const dy = p.y - last.y; + const minStep = Math.max(0.004, tool.radiusN * 0.25); + if (dx * dx + dy * dy < minStep * minStep) return; + b.points.push({ x: p.x, y: p.y, tMs: Date.now() }); + setDraftFxTick((x) => x + 1); + }} + onPointerUp={() => { + void commitStroke(); + }} + onPointerCancel={() => { + brushRef.current = null; + setDraftFxTick((x) => x + 1); + }} + /> + + ) : null} +
+ + + +
Варианты ветвления
+
+ {nextScenes.map((o, i) => ( +
+
+
ОПЦИЯ {String(i + 1)}
+
+
{o.scene.title || 'Без названия'}
+ +
+ ))} + {nextScenes.length === 0 ? ( +
+
Нет вариантов перехода.
+ +
+ ) : null} +
+
+ + +
+
Музыка
+
+
+ {sceneAudios.length === 0 ? ( +
В текущей сцене нет аудио.
+ ) : ( +
+ {sceneAudios.map(({ ref, asset }) => { + const el = audioElsRef.current.get(ref.assetId) ?? null; + const st = audioStatus(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}
+
+
+
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); + setAudioStateTick((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)); + setAudioStateTick((x) => x + 1); + }} + className={[ + styles.audioScrub, + dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault, + ].join(' ')} + title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'} + > +
+
+
+
{formatTime(cur)}
+
{dur ? formatTime(dur) : '—:—'}
+
+
+
+ + + +
+
+ ); + })} +
+ )} + +
+
+ ); +} diff --git a/app/renderer/control/ControlScenePreview.module.css b/app/renderer/control/ControlScenePreview.module.css new file mode 100644 index 0000000..9762f6f --- /dev/null +++ b/app/renderer/control/ControlScenePreview.module.css @@ -0,0 +1,72 @@ +.root { + position: absolute; + inset: 0; +} + +.video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +.placeholder { + position: absolute; + inset: 0; + background: var(--color-overlay-dark-6); +} + +.controls { + position: absolute; + left: 12px; + right: 12px; + bottom: 12px; + display: grid; + gap: 8px; + pointer-events: auto; +} + +.scrub { + height: 10px; + border-radius: var(--radius-pill); + border: 1px solid var(--stroke); + background: var(--color-overlay-dark-3); + overflow: hidden; +} + +.scrubPointer { + cursor: pointer; +} + +.scrubDefault { + cursor: default; +} + +.scrubFill { + height: 100%; + background: var(--accent-fill-solid); +} + +.row { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--text2); + font-size: var(--text-xs); +} + +.transport { + display: flex; + gap: 8px; +} + +.transportBtn { + width: 34px; + height: 30px; + border-radius: var(--radius-sm); + border: 1px solid var(--stroke-light); + background: var(--color-overlay-dark-3); + color: var(--text-muted-on-dark-2); + cursor: pointer; +} diff --git a/app/renderer/control/ControlScenePreview.tsx b/app/renderer/control/ControlScenePreview.tsx new file mode 100644 index 0000000..d8f3cc6 --- /dev/null +++ b/app/renderer/control/ControlScenePreview.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { computeTimeSec } from '../../main/video/videoPlaybackStore'; +import type { SessionState } from '../../shared/ipc/contracts'; +import { RotatedImage } from '../shared/RotatedImage'; +import { useAssetUrl } from '../shared/useAssetImageUrl'; +import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState'; + +import styles from './ControlScenePreview.module.css'; + +type Props = { + session: SessionState | null; + videoRef: React.RefObject; + onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void; +}; + +function fmt(sec: number): string { + if (!Number.isFinite(sec) || sec < 0) return '0:00'; + const s = Math.floor(sec); + const m = Math.floor(s / 60); + const r = s % 60; + return `${String(m)}:${String(r).padStart(2, '0')}`; +} + +export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) { + const [vp, video] = useVideoPlaybackState(); + const scene = + session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined; + const url = useAssetUrl(scene?.previewAssetId ?? null); + const rot = scene?.previewRotationDeg ?? 0; + const isVideo = scene?.previewAssetType === 'video'; + const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null; + + const [tick, setTick] = useState(0); + const dur = useMemo( + () => { + const v = videoRef.current; + if (!v) return 0; + return Number.isFinite(v.duration) ? v.duration : 0; + }, + // tick: перечитываем duration из video ref на каждом кадре RAF + // eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно + [tick, videoRef], + ); + const cur = useMemo( + () => { + const v = videoRef.current; + if (!v) return 0; + return Number.isFinite(v.currentTime) ? v.currentTime : 0; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно + [tick, videoRef], + ); + const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; + + useEffect(() => { + if (!isVideo) return; + let raf = 0; + const loop = () => { + setTick((x) => x + 1); + raf = window.requestAnimationFrame(loop); + }; + raf = window.requestAnimationFrame(loop); + return () => window.cancelAnimationFrame(raf); + }, [isVideo]); + + useEffect(() => { + if (!isVideo) return; + void video.dispatch({ + kind: 'target.set', + assetId, + autostart: scene.previewVideoAutostart, + }); + }, [assetId, isVideo, scene, video]); + + useEffect(() => { + const v = videoRef.current; + if (!v) return; + if (!vp) return; + if (vp.targetAssetId !== assetId) return; + v.playbackRate = vp.playbackRate; + const desired = computeTimeSec(vp, vp.serverNowMs); + if (Number.isFinite(desired) && Math.abs(v.currentTime - desired) > 0.25) { + v.currentTime = Math.max(0, desired); + } + if (vp.playing) { + void v.play().catch(() => undefined); + } else { + v.pause(); + } + }, [assetId, vp, videoRef]); + + const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' '); + + return ( +
+ {url && scene?.previewAssetType === 'image' ? ( + + ) : url && isVideo ? ( + + ) : ( +
+ )} + + {isVideo ? ( +
+
0 ? Math.round(dur) : 0} + aria-valuenow={Math.round(cur)} + className={scrubClass} + onClick={(e) => { + const v = videoRef.current; + if (!v || !dur) return; + const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); + const next = (e.clientX - rect.left) / rect.width; + void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) }); + setTick((x) => x + 1); + }} + onKeyDown={(e) => { + if (!dur) return; + if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) }); + if (e.key === 'ArrowRight') + void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) }); + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1); + }} + title="Клик — перемотка" + > +
+
+
+
+ + + +
+
+ {fmt(cur)} / {dur ? fmt(dur) : '—:—'} +
+
+
+ ) : null} +
+ ); +} diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts new file mode 100644 index 0000000..d585262 --- /dev/null +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +function readControlApp(): string { + return fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8'); +} + +function readControlAppCss(): string { + return fs.readFileSync(path.join(here, 'ControlApp.module.css'), 'utf8'); +} + +void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => { + const src = readControlApp(); + assert.ok(src.includes('ЭФФЕКТЫ')); + assert.ok(src.includes('title="Туман"')); + assert.ok(src.includes('ariaLabel="Туман"')); + assert.ok(src.includes('iconOnly')); + assert.ok(src.includes('title="Очистить эффекты"')); + assert.ok(src.includes('ariaLabel="Очистить эффекты"')); + assert.ok(src.includes('#e5484d')); + const fx = src.indexOf('ЭФФЕКТЫ'); + const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ'); + assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии'); +}); + +void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => { + const src = readControlApp(); + const css = readControlAppCss(); + const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ'); + assert.ok(story !== -1); + assert.ok(src.includes('className={styles.storyScroll}')); + assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/); + assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/); + assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/); +}); + +void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => { + const src = readControlApp(); + const css = readControlAppCss(); + assert.ok(!src.includes("tool.tool === 'eraser' ? 'not-allowed'")); + assert.ok(src.includes('className={styles.brushLayer}')); + assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/); +}); + +void test('ControlApp: радиус кисти не в блоке предпросмотра', () => { + const src = readControlApp(); + const previewLabel = src.indexOf('Предпросмотр экрана'); + const radius = src.indexOf('Радиус кисти'); + assert.ok(previewLabel !== -1 && radius !== -1); + assert.ok( + radius < previewLabel, + 'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)', + ); +}); diff --git a/app/renderer/control/main.tsx b/app/renderer/control/main.tsx new file mode 100644 index 0000000..aed9cb8 --- /dev/null +++ b/app/renderer/control/main.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import '../shared/ui/globals.css'; +import { ControlApp } from './ControlApp'; + +const rootEl = document.getElementById('root'); +if (!rootEl) { + throw new Error('Missing #root element'); +} + +createRoot(rootEl).render( + + + , +); diff --git a/app/renderer/editor.html b/app/renderer/editor.html new file mode 100644 index 0000000..7ede2ff --- /dev/null +++ b/app/renderer/editor.html @@ -0,0 +1,13 @@ + + + + + + + DnD Player — Editor + + +
+ + + diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css new file mode 100644 index 0000000..ae83663 --- /dev/null +++ b/app/renderer/editor/EditorApp.module.css @@ -0,0 +1,619 @@ +.topBarRow { + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +.brandButton { + display: flex; + align-items: center; + gap: 10px; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + color: inherit; + font: inherit; +} + +.brandLogo { + flex-shrink: 0; + display: block; +} + +.brandTitle { + font-weight: 700; +} + +.fileToolbar { + display: flex; + align-items: center; + gap: 14px; + color: var(--text2); +} + +.fileMenuTrigger { + border: none; + background: transparent; + color: var(--text2); + cursor: pointer; + padding: 0; + font: inherit; +} + +.flex1 { + flex: 1; +} + +.appVersion { + flex-shrink: 0; + font-size: var(--text-xs); + color: var(--text2); + user-select: none; +} + +.headerActions { + display: flex; + align-items: center; + gap: 10px; +} + +.editorSidebar { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto 1fr; + padding: 12px; + border-right: 1px solid var(--stroke); + background: var(--editor-column-bg); + overflow: hidden; +} + +.editorGraphHost { + height: 100%; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--bg0); + overflow: hidden; +} + +.editorInspector { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 14px; + border-left: 1px solid var(--stroke); + background: var(--editor-column-bg); +} + +.gridTools { + display: grid; + gap: 10px; +} + +.spacer14 { + height: 14px; +} + +.sidebarScroll { + overflow: auto; + padding-right: 2px; + min-height: 0; + align-self: stretch; +} + +.sceneListGrid { + display: grid; + gap: 12px; + align-content: start; + justify-items: stretch; +} + +.centerEmpty { + height: 100%; + flex: 1; + background: var(--bg0); +} + +.inspectorTitle { + font-weight: 800; + margin-bottom: 12px; + flex-shrink: 0; +} + +.inspectorScroll { + flex: 1; + min-height: 0; + overflow: auto; + padding-right: 4px; +} + +.muted { + color: var(--text2); +} + +.fileMenu { + position: fixed; + min-width: 220px; + border-radius: var(--radius-md); + border: 1px solid var(--stroke); + background: var(--color-surface-elevated-2); + box-shadow: var(--shadow-lg); + padding: 6px; + display: grid; + gap: 4px; + z-index: var(--z-file-menu); +} + +.fileMenuItem { + text-align: left; + padding: 10px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--text0); + cursor: pointer; + font: inherit; +} + +.modalBackdrop { + position: fixed; + inset: 0; + z-index: var(--z-modal-backdrop); + border: none; + padding: 0; + margin: 0; + background: var(--color-scrim); + cursor: default; +} + +.modalDialog { + position: fixed; + z-index: var(--z-modal); + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 520px; + max-width: calc(100vw - 32px); + border-radius: var(--radius-lg); + border: 1px solid var(--stroke); + background: var(--color-surface-elevated); + box-shadow: var(--shadow-xl); + padding: 16px; + display: grid; + gap: 12px; +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.modalTitle { + font-weight: 900; + font-size: var(--text-lg); +} + +.modalClose { + border: none; + background: var(--panel2); + color: var(--text2); + border-radius: var(--radius-sm); + width: 34px; + height: 34px; + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.fieldGrid { + display: grid; + gap: 6px; +} + +.fieldLabel { + color: var(--text2); + font-size: var(--text-xs); + font-weight: 800; +} + +.fieldError { + color: var(--color-danger); + font-size: var(--text-xs); +} + +.selectInput { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--stroke); + background: var(--bg0); + color: var(--text0); + font: inherit; +} + +.rowFlex { + display: flex; + gap: 8px; + align-items: center; +} + +.modalFooter { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + +.fileSuffix { + color: var(--text2); + font-size: var(--text-xs); + flex-shrink: 0; +} + +.projectPicker { + display: grid; + gap: 12px; + min-height: 0; +} + +.projectPickerTitle { + font-weight: 900; +} + +.projectPickerForm { + display: grid; + gap: 10px; +} + +.spacer6 { + height: 6px; +} + +.sectionLabel { + color: var(--text2); + font-size: var(--text-xs); + font-weight: 900; + letter-spacing: 0.6px; +} + +.projectListScroll { + overflow: auto; + padding-right: 2px; +} + +.projectList { + display: grid; + gap: 10px; +} + +.projectCard { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px; + border-radius: 14px; + border: 1px solid var(--stroke); + background: var(--color-overlay-dark-2); +} + +.projectCardBody { + flex: 1; + min-width: 0; + cursor: pointer; + border-radius: 10px; +} + +.projectCardMenuBtn { + flex-shrink: 0; + margin: -4px -4px 0 0; + border: none; + background: transparent; + color: var(--text2); + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-sm); + font-size: 18px; + line-height: 1; +} + +.projectCardMenuBtn:hover { + background: var(--panel2); + color: var(--text0); +} + +.projectCardName { + font-weight: 800; +} + +.projectCardMeta { + color: var(--text2); + font-size: var(--text-xs); +} + +.sceneInspector { + display: grid; + gap: 10px; +} + +.labelSm { + color: var(--text2); + font-size: var(--text-xs); + font-weight: 700; +} + +.spacer8 { + height: 8px; +} + +.textarea { + min-height: 92px; + padding: 12px; + border-radius: var(--radius-md); + border: 1px solid var(--stroke); + background: var(--color-overlay-dark-3); + resize: none; + color: var(--text1); + outline: none; +} + +.hint { + color: var(--text2); + font-size: var(--text-xs); + line-height: 1.4; +} + +.previewBox { + border-radius: var(--radius-md); + border: 1px solid var(--stroke); + overflow: hidden; + background: var(--color-overlay-dark-3); + aspect-ratio: 16 / 9; + max-height: 140px; + display: flex; + align-items: center; + justify-content: center; +} + +.videoCover { + width: 100%; + height: 100%; + object-fit: cover; +} + +.previewEmpty { + color: var(--text2); + font-size: var(--text-xs); + padding: 12px; +} + +.actionsRow { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.checkboxLabel { + display: flex; + gap: 8px; + align-items: center; + color: var(--text2); +} + +.checkboxLabelSm { + display: flex; + gap: 6px; + align-items: center; + cursor: pointer; +} + +.spanSm { + font-size: var(--text-xs); + font-weight: 700; +} + +.spanXs { + font-size: 11px; +} + +.audioDrop { + border-radius: var(--radius-md); + border: 1px dashed var(--stroke2); + padding: 10px; + display: grid; + gap: 8px; +} + +.audioList { + display: grid; + gap: 6px; + max-height: 160px; + overflow: auto; +} + +.audioRow { + font-size: var(--text-xs); + padding: 6px 8px; + border-radius: var(--radius-xs); + background: var(--color-overlay-dark-3); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-width: 0; +} + +.audioName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.audioControls { + color: var(--text2); + flex-shrink: 0; + display: flex; + gap: 8px; + align-items: center; + min-width: 0; +} + +.audioRemove { + flex-shrink: 0; + min-width: 28px; + min-height: 28px; + padding: 4px; + margin: -4px; + border: none; + background: transparent; + color: var(--text-muted-on-dark); + cursor: pointer; + line-height: 1; + display: grid; + place-items: center; +} + +.audioRemove:hover { + color: rgba(255, 255, 255, 0.72); +} + +.audioRemoveIcon { + display: block; + opacity: 0.92; +} + +.hintBlock { + color: var(--text2); +} + +.sceneCard { + border-radius: var(--scene-tile-radius); + overflow: hidden; + cursor: grab; + border: 1px solid transparent; + box-sizing: border-box; + background: transparent; +} + +.sceneCard:not(.sceneCardActive):hover { + background: var(--scene-list-hover-bg); +} + +.sceneCardActive { + border-color: var(--scene-list-selected-border); + background: var(--scene-list-selected-bg); +} + +.sceneThumb { + height: 92px; + position: relative; + box-sizing: border-box; + padding: 8px; +} + +.sceneThumbInner { + width: 100%; + height: 100%; + border-radius: 10px; + overflow: hidden; +} + +.sceneThumbVideo { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.sceneThumbEmpty { + height: 92px; + position: relative; + box-sizing: border-box; + padding: 8px; +} + +.sceneThumbEmptyInner { + width: 100%; + height: 100%; + border-radius: 10px; + background: var(--bg0); +} + +.sceneCardBody { + padding: 10px; + display: grid; + gap: 6px; +} + +.sceneCardHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.badgeCurrent { + font-size: var(--text-xs); + color: var(--accent2); +} + +.sceneMenuBtn { + margin-left: auto; + border: none; + background: var(--panel2); + border-radius: var(--radius-xs); + padding: 4px 10px; + cursor: pointer; + color: var(--text2); + line-height: 1; + font-size: 16px; + flex-shrink: 0; +} + +.sceneCardTitle { + font-weight: 750; +} + +.menuBackdrop { + position: fixed; + inset: 0; + z-index: var(--z-menu-backdrop); + border: none; + padding: 0; + margin: 0; + background: transparent; + cursor: default; +} + +.sceneCtxMenu { + position: fixed; + z-index: var(--z-file-menu); + min-width: 160px; + padding: 6px; + border-radius: var(--radius-sm); + border: 1px solid var(--stroke); + background: var(--color-surface-menu); + box-shadow: var(--shadow-menu); + display: grid; + gap: 2px; +} + +.sceneCtxDanger { + text-align: left; + padding: 8px 10px; + border-radius: var(--radius-xs); + border: none; + background: transparent; + color: var(--color-danger); + font-size: 13px; + cursor: pointer; + width: 100%; +} diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx new file mode 100644 index 0000000..b87eaba --- /dev/null +++ b/app/renderer/editor/EditorApp.tsx @@ -0,0 +1,1066 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { ipcChannels } from '../../shared/ipc/contracts'; +import type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types'; +import { AppLogo } from '../shared/branding/AppLogo'; +import { getDndApi } from '../shared/dndApi'; +import { RotatedImage } from '../shared/RotatedImage'; +import { Button, Input } from '../shared/ui/controls'; +import { LayoutShell } from '../shared/ui/LayoutShell'; +import { useAssetUrl } from '../shared/useAssetImageUrl'; + +import styles from './EditorApp.module.css'; +import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph'; +import { useProjectState } from './state/projectState'; + +type SceneCard = { + id: SceneId; + title: string; + active: boolean; + previewAssetId: AssetId | null; + previewAssetType: 'image' | 'video' | null; + previewVideoAutostart: boolean; + previewRotationDeg: 0 | 90 | 180 | 270; +}; + +export function EditorApp() { + const [appVersionText, setAppVersionText] = useState(null); + const [query, setQuery] = useState(''); + const [fileMenuOpen, setFileMenuOpen] = useState(false); + const [projectMenuOpen, setProjectMenuOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [state, actions] = useProjectState(); + const fileMenuBtnRef = useRef(null); + const projectMenuBtnRef = useRef(null); + const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null); + const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null); + const scenes = useMemo(() => { + const p = state.project; + if (!p) return []; + return Object.values(p.scenes).map((s) => ({ + id: s.id, + title: s.title, + active: s.id === state.selectedSceneId, + previewAssetId: s.previewAssetId, + previewAssetType: s.previewAssetType, + previewVideoAutostart: s.previewVideoAutostart, + previewRotationDeg: s.previewRotationDeg, + })); + }, [state.project, state.selectedSceneId]); + + const filtered = useMemo( + () => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())), + [query, scenes], + ); + + const sceneMediaAssets = useMemo(() => { + const p = state.project; + const sid = state.selectedSceneId; + if (!p || !sid) return []; + const scene = p.scenes[sid]; + if (!scene) return []; + const ids = [...scene.media.videos, ...scene.media.audios.map((a) => a.assetId)]; + return ids.map((id) => p.assets[id]).filter((a): a is MediaAsset => Boolean(a)); + }, [state.project, state.selectedSceneId]); + + const sceneAudioRefs = useMemo(() => { + const p = state.project; + const sid = state.selectedSceneId; + if (!p || !sid) return []; + const scene = p.scenes[sid]; + if (!scene) return []; + return scene.media.audios; + }, [state.project, state.selectedSceneId]); + + const graphStartSceneId = useMemo(() => { + const p = state.project; + if (!p) return null; + const gn = p.sceneGraphNodes.find((n) => n.isStartScene); + return gn?.sceneId ?? null; + }, [state.project]); + const graphStartGraphNodeId = useMemo(() => { + const p = state.project; + if (!p) return null; + const gn = p.sceneGraphNodes.find((n) => n.isStartScene); + return gn?.id ?? null; + }, [state.project]); + + const currentProjectName = state.project?.meta.name ?? ''; + const currentFileBaseName = state.project?.meta.fileBaseName ?? ''; + const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]); + const existingFileBaseNames = useMemo(() => { + return state.projects.map((p) => p.fileName.replace(/\.dnd\.zip$/iu, '')); + }, [state.projects]); + + useEffect(() => { + if (!fileMenuOpen) return; + const r = fileMenuBtnRef.current?.getBoundingClientRect() ?? null; + queueMicrotask(() => { + if (r) { + setFileMenuPos({ left: r.left, top: r.bottom + 10 }); + } else { + setFileMenuPos(null); + } + }); + const onDown = (e: MouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + if (t.closest('[data-filemenu-root="1"]')) return; + setFileMenuOpen(false); + }; + window.addEventListener('mousedown', onDown); + return () => window.removeEventListener('mousedown', onDown); + }, [fileMenuOpen]); + + useEffect(() => { + if (!projectMenuOpen) return; + const r = projectMenuBtnRef.current?.getBoundingClientRect() ?? null; + queueMicrotask(() => { + if (r) { + setProjectMenuPos({ left: r.left, top: r.bottom + 10 }); + } else { + setProjectMenuPos(null); + } + }); + const onDown = (e: MouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + if (t.closest('[data-projectmenu-root="1"]')) return; + setProjectMenuOpen(false); + }; + window.addEventListener('mousedown', onDown); + return () => window.removeEventListener('mousedown', onDown); + }, [projectMenuOpen]); + + useEffect(() => { + void (async () => { + try { + const r = await getDndApi().invoke(ipcChannels.app.getVersion, {}); + const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`; + setAppVersionText(label); + } catch { + setAppVersionText(null); + } + })(); + }, []); + + const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null; + + return ( + <> + + +
+ + {state.project ? ( + + ) : null} +
+
+ {appVersionText ? ( +
+ {appVersionText} +
+ ) : null} +
+ {state.project ? ( + + ) : null} +
+
+ } + left={ +
+ {state.project ? ( + <> +
+ + +
+
+
+
+ {filtered.map((s) => ( + void actions.selectScene(s.id)} + onDeleteScene={(id) => void actions.deleteScene(id)} + /> + ))} +
+
+ + ) : ( + + )} +
+ } + center={ +
+ {state.project ? ( + void actions.selectScene(id)} + onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)} + onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)} + onNodePositionCommit={(nodeId, x, y) => + void actions.updateSceneGraphNodePosition(nodeId, x, y) + } + onRemoveGraphNodes={(ids) => { + void Promise.all(ids.map((id) => actions.removeSceneGraphNode(id))); + }} + onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)} + onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)} + onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)} + /> + ) : ( +
+ )} +
+ } + right={ +
+
Свойства сцены
+
+ {state.project && state.selectedSceneId ? ( + (() => { + const proj = state.project; + const sid = state.selectedSceneId; + const sc = proj.scenes[sid]; + return ( + void actions.updateScene(sid, { media: { audios: next } })} + onPreviewVideoAutostartChange={(next) => + void actions.updateScene(sid, { previewVideoAutostart: next }) + } + onTitleChange={(title) => void actions.updateScene(sid, { title })} + onDescriptionChange={(description) => void actions.updateScene(sid, { description })} + onImportPreview={() => void actions.importScenePreview(sid)} + onClearPreview={() => void actions.clearScenePreview(sid)} + onRotatePreview={(previewRotationDeg) => + void actions.updateScene(sid, { previewRotationDeg }) + } + onUploadMedia={() => void actions.importMediaToScene(sid)} + /> + ); + })() + ) : ( +
Откройте проект, чтобы редактировать сцену.
+ )} +
+
+ } + /> + {projectMenuOpen && projectMenuPos + ? createPortal( +
+ + +
, + document.body, + ) + : null} + {fileMenuOpen && fileMenuPos && state.project + ? createPortal( +
+ +
, + document.body, + ) + : null} + {state.project ? ( + setRenameOpen(false)} + onSave={async (name, fileBaseName) => { + await actions.renameProject(name, fileBaseName); + }} + /> + ) : null} + setExportModalOpen(false)} + onExport={async (projectId) => { + await actions.exportProject(projectId); + }} + /> + + ); +} + +type ExportProjectModalProps = { + open: boolean; + projects: { id: ProjectId; name: string; fileName: string }[]; + initialProjectId: ProjectId | null; + onClose: () => void; + onExport: (projectId: ProjectId) => Promise; +}; + +function ExportProjectModal({ + open, + projects, + initialProjectId, + onClose, + onExport, +}: ExportProjectModalProps) { + const [projectId, setProjectId] = useState(initialProjectId); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setProjectId(initialProjectId); + setSaving(false); + setError(null); + }, [initialProjectId, open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, open]); + + if (!open) return null; + + const canExport = projectId !== null && projects.some((p) => p.id === projectId); + + return createPortal( + <> + +
+ +
+
ПРОЕКТ
+ +
+ Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip — будет создана копия + архива проекта. +
+
+ + {error ?
{error}
: null} + +
+ + +
+
+ , + document.body, + ); +} + +function isValidFileBaseName(input: string): boolean { + const trimmed = input.trim(); + if (trimmed.length < 3) return false; + return !/[<>:"/\\|?*]/gu.test(trimmed); +} + +function normalizeName(input: string): string { + return input.trim().toLowerCase(); +} + +type RenameProjectModalProps = { + open: boolean; + projectNameInitial: string; + fileBaseNameInitial: string; + existingProjectNames: string[]; + existingFileBaseNames: string[]; + onClose: () => void; + onSave: (projectName: string, fileBaseName: string) => Promise; +}; + +function RenameProjectModal({ + open, + projectNameInitial, + fileBaseNameInitial, + existingProjectNames, + existingFileBaseNames, + onClose, + onSave, +}: RenameProjectModalProps) { + const [projectName, setProjectName] = useState(projectNameInitial); + const [fileBaseName, setFileBaseName] = useState(fileBaseNameInitial); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setProjectName(projectNameInitial); + setFileBaseName(fileBaseNameInitial); + setSaving(false); + setError(null); + }, [fileBaseNameInitial, open, projectNameInitial]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, open]); + + const trimmedProjectName = projectName.trim(); + const trimmedFileBase = fileBaseName.trim(); + const projectNameOk = trimmedProjectName.length >= 3; + const fileNameOk = isValidFileBaseName(trimmedFileBase); + + const projectNameDup = + normalizeName(trimmedProjectName) !== normalizeName(projectNameInitial) && + existingProjectNames.some((n) => normalizeName(n) === normalizeName(trimmedProjectName)); + const fileNameDup = + normalizeName(trimmedFileBase) !== normalizeName(fileBaseNameInitial) && + existingFileBaseNames.some((n) => normalizeName(n) === normalizeName(trimmedFileBase)); + + const canSave = projectNameOk && fileNameOk && !projectNameDup && !fileNameDup && !saving; + + if (!open) return null; + + return createPortal( + <> + +
+ +
+
НАЗВАНИЕ ПРОЕКТА
+ + {!projectNameOk ?
Минимум 3 символа.
: null} + {projectNameDup ? ( +
Проект с таким названием уже существует.
+ ) : null} +
+ +
+
НАЗВАНИЕ ФАЙЛА ПРОЕКТА
+
+
+ +
+
.dnd.zip
+
+ {!fileNameOk ? ( +
Минимум 3 символа, без символов {'<>:"/\\|?*'}
+ ) : null} + {fileNameDup ? ( +
Файл проекта с таким названием уже существует.
+ ) : null} +
+ + {error ?
{error}
: null} + +
+ + +
+
+ , + document.body, + ); +} + +type ProjectPickerProps = { + projects: { id: ProjectId; name: string; updatedAt: string }[]; + onCreate: (name: string) => Promise; + onOpen: (id: ProjectId) => Promise; + onDelete: (id: ProjectId) => Promise; +}; + +function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerProps) { + const [name, setName] = useState('Моя кампания'); + const [rowMenuFor, setRowMenuFor] = useState(null); + const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null); + + useEffect(() => { + if (!rowMenuFor) return; + const onDown = (e: MouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + if (t.closest('[data-project-row-menu-root="1"]')) return; + setRowMenuFor(null); + setRowMenuPos(null); + }; + window.addEventListener('mousedown', onDown); + return () => window.removeEventListener('mousedown', onDown); + }, [rowMenuFor]); + + return ( +
+
Проекты
+
+ + +
+
+
СУЩЕСТВУЮЩИЕ
+
+
+ {projects.map((p) => ( +
+
void onOpen(p.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') void onOpen(p.id); + }} + > +
{p.name}
+
{new Date(p.updatedAt).toLocaleString('ru-RU')}
+
+ +
+ ))} + {projects.length === 0 ?
Пока нет проектов.
: null} +
+
+ {rowMenuFor && rowMenuPos + ? createPortal( +
+ +
, + document.body, + ) + : null} +
+ ); +} + +type SceneInspectorProps = { + title: string; + description: string; + previewAssetId: AssetId | null; + previewAssetType: 'image' | 'video' | null; + previewVideoAutostart: boolean; + previewRotationDeg: 0 | 90 | 180 | 270; + mediaAssets: MediaAsset[]; + audioRefs: SceneAudioRef[]; + onAudioRefsChange: (next: SceneAudioRef[]) => void; + onPreviewVideoAutostartChange: (next: boolean) => void; + onTitleChange: (v: string) => void; + onDescriptionChange: (v: string) => void; + onImportPreview: () => void; + onClearPreview: () => void; + onRotatePreview: (deg: 0 | 90 | 180 | 270) => void; + onUploadMedia: () => void; +}; + +function SceneInspector({ + title, + description, + previewAssetId, + previewAssetType, + previewVideoAutostart, + previewRotationDeg, + mediaAssets, + audioRefs, + onAudioRefsChange, + onPreviewVideoAutostartChange, + onTitleChange, + onDescriptionChange, + onImportPreview, + onClearPreview, + onRotatePreview, + onUploadMedia, +}: SceneInspectorProps) { + const previewUrl = useAssetUrl(previewAssetId); + const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); + return ( +
+
НАЗВАНИЕ СЦЕНЫ
+ +
+
ОПИСАНИЕ
+