Files
DndGamePlayer/app/main/project/zipStore.ts
T

1236 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> = Promise.resolve();
/** Пока идёт сборка zip, в кэш не пишем — иначе yauzl/yazl: «unexpected number of bytes». */
private isPacking = false;
private async waitWhilePacking(): Promise<void> {
while (this.isPacking) {
await new Promise((r) => setTimeout(r, 15));
}
}
private async packZipExclusive(cacheDir: string, zipPath: string): Promise<void> {
this.isPacking = true;
try {
await this.packZipFromCache(cacheDir, zipPath);
} finally {
this.isPacking = false;
}
}
async ensureRoots(): Promise<void> {
await fs.mkdir(getProjectsRootDir(), { recursive: true });
await fs.mkdir(getProjectsCacheRootDir(), { recursive: true });
await this.migrateLegacyProjectZipsIfNeeded();
}
/** Копирует .dnd.zip из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */
private async migrateLegacyProjectZipsIfNeeded(): Promise<void> {
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<ProjectIndexEntry[]> {
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<Project> {
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<Project> {
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<Project> {
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<AssetId, MediaAsset> = { ...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<Project> {
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<Project> {
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<Scene> {
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<Scene> {
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<Project> {
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<SceneId, Scene>;
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<Project> {
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<Project> {
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<Project> {
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<Project> {
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<Project> {
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<Project> {
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<void> {
const open = this.openProject;
if (!open) return;
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
}
async renameOpenProject(name: string, fileBaseName: string): Promise<Project> {
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<void> {
const sessionAtStart = this.projectSession;
const run = async (): Promise<void> => {
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<void> {
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<Project> {
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<void> {
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<void> {
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<SceneId, Set<SceneId>> {
const gnMap = new Map(nodes.map((n) => [n.id, n]));
const outgoing = new Map<SceneId, Set<SceneId>>();
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<SceneId, Scene>,
outgoing: Map<SceneId, Set<SceneId>>,
): Record<SceneId, Scene> {
const next: Record<SceneId, Scene> = { ...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<SceneId, Scene>): {
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<SceneId, Scene> = {};
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<string> {
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<void> {
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<void> {
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<void> {
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<Project> {
return new Promise<Project>((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<void> {
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<void>((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<void> {
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<string[]> {
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;
}