a6cbcc273e
Made-with: Cursor
1236 lines
43 KiB
TypeScript
1236 lines
43 KiB
TypeScript
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;
|
||
}
|