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; }