Files
DndGamePlayer/app/main/project/zipStore.ts
T
Ivan Fontosh 4e6f7321b8 fix(project): race-safe batch scene preview import and close
Serialize updateProject and preview finalize, drain before pack/close, buffer zip entries, and abort finalize cleanly when leaving the project to avoid ENOENT on multi-image drops.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 09:44:45 +08:00

2917 lines
107 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 { ZipFile } from 'yazl';
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
import { canSetSideStoryStart, getSideStoryComponentNodeIds } from '../../shared/graph/sceneGraphLineage';
import {
prependSceneListOrder,
reconcileSceneListOrder,
removeFromSceneListOrder,
} from '../../shared/graph/sceneListOrder';
import {
buildPartialExportProject,
collectTokenIdsFromProject,
computeGraphImportOffsetX,
listExportableStorylines,
listImportableStorylines,
mergeStorylinesIntoProject,
newExportBundleProjectId,
remapProjectSceneTokenIds,
type NpcImportResolution,
type SceneImportResolution,
type StorylineImportMergeReport,
type StorylineLabels,
type StorylineListItem,
type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { ScenePatch } from '../../shared/ipc/contracts';
import {
isProjectZipFileName,
projectZipFileNameFromBase,
stripProjectZipExtension,
} from '../../shared/project/projectZipExtension';
import type {
MaterialLegend,
MediaAsset,
MediaAssetType,
Project,
ProjectId,
ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation,
Scene,
SceneGraphEdge,
SceneGraphNode,
SceneId,
SceneTrap,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_RING_COLOR,
normalizeSceneNpcToken,
type SceneNpcToken,
} from '../../shared/types/appPlayers';
import {
DEFAULT_NPC_DISPOSITION,
normalizeNpcDisposition,
npcDispositionRingColor,
type NpcDisposition,
} from '../../shared/types/npcDisposition';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type {
AssetId,
GraphNodeId,
MaterialId,
NpcGroupId,
NpcId,
NpcRelationId,
} from '../../shared/types/ids';
import {
asAssetId,
asGraphNodeId,
asMaterialId,
asNpcGroupId,
asNpcId,
asNpcRelationId,
asProjectId,
} from '../../shared/types/ids';
import {
DEFAULT_NPC_GROUP_COLOR,
normalizeHexColor,
normalizeNpcGroups,
resolveNpcGroupId,
wouldCreateGroupCycle,
} from '../../shared/npcs/npcGroups';
import {
buildProjectFromFoundryDocuments,
loadFoundryDocumentsForImport,
type FoundryImportProgress,
} from '../foundry/foundryImport';
import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
import { generateScenePreviewThumbnailBytes } from './scenePreviewThumbnail';
import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip';
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();
/**
* Serializes project mutations (mutator + reconcileAssetFiles).
* Parallel updateProject during batch scene-preview import caused lost updates and
* reconcileAssetFiles deleting preview files still referenced after a stale write (ENOENT).
*/
private projectUpdateChain: Promise<unknown> = Promise.resolve();
/** Serializes background preview optimize/thumb — avoids parallel sharp + orphan races. */
private previewFinalizeChain: Promise<unknown> = Promise.resolve();
/** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
private packChain: Promise<void> = Promise.resolve();
/** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */
private projectSwitchChain: Promise<void> = Promise.resolve();
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private enqueueProjectUpdate<T>(fn: () => Promise<T>): Promise<T> {
const task = this.projectUpdateChain.then(() => fn());
this.projectUpdateChain = task.then(
() => undefined,
() => undefined,
);
return task;
}
private enqueuePreviewFinalize<T>(fn: () => Promise<T>): Promise<T> {
const task = this.previewFinalizeChain.then(() => fn());
this.previewFinalizeChain = task.then(
() => undefined,
() => undefined,
);
return task;
}
/** Wait until preview optimize/thumb and project mutators finish (before pack/close). */
private async drainProjectMutations(): Promise<void> {
await this.previewFinalizeChain;
await this.projectUpdateChain;
}
private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
const next = this.packChain.then(async () => {
await this.packZipFromCache(cacheDir, zipPath);
});
this.packChain = next.catch(() => undefined);
return next;
}
private enqueueProjectSwitch<T>(fn: () => Promise<T>): Promise<T> {
const task = this.projectSwitchChain.then(() => fn());
this.projectSwitchChain = task.then(
() => undefined,
() => undefined,
);
return task;
}
private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> {
return this.enqueueProjectSwitch(() => this.openProjectByIdInner(projectId, onUnzipPercent));
}
/** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */
private async drainSavePipeline(): Promise<void> {
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
this.saveDebounceTimer = null;
}
// Finish preview finalizers before flush/pack — otherwise yazl ENOENT on deleted assets.
await this.drainProjectMutations();
if (this.saveQueued) {
this.saveQueued = false;
await this.flushSave();
}
while (this.saving) {
await new Promise((r) => setTimeout(r, 10));
}
await this.packChain;
await this.projectWriteChain;
await this.drainProjectMutations();
}
async ensureRoots(): Promise<void> {
await fs.mkdir(getProjectsRootDir(), { recursive: true });
await fs.mkdir(getProjectsCacheRootDir(), { recursive: true });
await this.migrateLegacyProjectZipsIfNeeded();
await recoverOrphanProjectZipTmpInRoot(getProjectsRootDir());
}
/** Копирует архивы проектов из каталогов с «чужим» 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) => isProjectZipFileName(n)));
for (const legacyRoot of getLegacyProjectsRootDirs()) {
let legacyNames: string[];
try {
legacyNames = await fs.readdir(legacyRoot);
} catch {
continue;
}
for (const name of legacyNames) {
if (!isProjectZipFileName(name)) 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;
// Переносим (а не копируем), чтобы:
// - не было дублей между разными appName
// - удалённые пользователем проекты не «возрождались» при следующем ensureRoots()
try {
await fs.rename(from, to);
} catch {
await fs.copyFile(from, to);
try {
await rmWithRetries(fs.rm, from, { force: true });
} catch {
// best effort: если zip уже скопирован в dest, миграцию считаем успешной;
// legacy-копия может остаться (например из-за lock/AV), но удаление проекта
// затем чистит legacy по fileName.
}
}
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() && isProjectZipFileName(e.name))
.map((e) => path.join(root, e.name));
const out: ProjectIndexEntry[] = [];
for (const filePath of files) {
try {
const project = await readProjectJsonFromZip(filePath);
out.push({
id: project.id,
name: project.meta.name,
updatedAt: project.meta.updatedAt,
fileName: path.basename(filePath),
});
} catch {
// Один битый архив не должен скрывать остальные проекты в списке.
}
}
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: {},
sceneListOrder: [],
assets: {},
campaignAudios: [],
materials: [],
npcs: [],
npcGroups: [],
npcRelations: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
};
const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(fileBaseName));
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.enqueuePack(cacheDir, zipPath);
return this.openProject.project;
}
async openProjectById(projectId: ProjectId): Promise<Project> {
return this.enqueueOpenProject(projectId);
}
private async openProjectByIdInner(
projectId: ProjectId,
onUnzipPercent?: (pct: number) => void,
): Promise<Project> {
await this.ensureRoots();
if (this.openProject?.id === projectId) {
return this.openProject.project;
}
const sessionAtStart = this.projectSession;
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
await this.drainSavePipeline();
if (sessionAtStart !== this.projectSession) {
throw new Error('Открытие проекта отменено');
}
this.projectSession += 1;
const openSession = this.projectSession;
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 });
try {
await unzipToDir(zipPath, cacheDir, (done, total) => {
if (!onUnzipPercent) return;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
onUnzipPercent(Math.max(0, Math.min(100, pct)));
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Не удалось открыть проект: архив повреждён или занят (${detail})`);
}
if (openSession !== this.projectSession) {
await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error('Открытие проекта отменено');
}
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;
}
private async openProjectByIdWithProgress(
projectId: ProjectId,
onUnzipPercent: (pct: number) => void,
): Promise<Project> {
return this.enqueueOpenProject(projectId, onUnzipPercent);
}
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: Project; assetId: AssetId; background: boolean }> {
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 kind0 = classifyMediaPath(filePath);
if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) {
throw new Error('Файл превью должен быть изображением или видео');
}
const buf = await fs.readFile(filePath);
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);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
const oldPreviewId = sc.previewAssetId;
const oldThumbId = sc.previewThumbAssetId ?? null;
await this.updateProject((p) => {
const scene = p.scenes[sceneId];
if (!scene) throw new Error('Scene not found');
let assets: Record<AssetId, MediaAsset> = { ...p.assets };
const drop = new Set<AssetId>();
if (oldPreviewId) drop.add(oldPreviewId);
if (oldThumbId) drop.add(oldThumbId);
if (drop.size > 0) {
assets = Object.fromEntries(
Object.entries(assets).filter(([k]) => !drop.has(k as AssetId)),
) as Record<AssetId, MediaAsset>;
}
assets[id] = asset;
return {
...p,
assets,
scenes: {
...p.scenes,
[sceneId]: {
...scene,
previewAssetId: id,
previewAssetType: kind0.type,
previewThumbAssetId: null,
previewVideoAutostart: kind0.type === 'video' ? scene.previewVideoAutostart : false,
},
},
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return { project: latest, assetId: id, background: true };
}
async finalizeScenePreviewImport(
sceneId: SceneId,
assetId: AssetId,
): Promise<{ project: Project; changed: boolean }> {
return this.enqueuePreviewFinalize(() => this.finalizeScenePreviewImportInner(sceneId, assetId));
}
private async finalizeScenePreviewImportInner(
sceneId: SceneId,
assetId: AssetId,
): Promise<{ project: Project; changed: boolean }> {
const sessionAtStart = this.projectSession;
const open = this.openProject;
if (!open) {
// Queued finalize after close — nothing to apply.
return { project: undefined as unknown as Project, changed: false };
}
const projectAtStart = open.project;
const sceneAtStart = projectAtStart.scenes[sceneId];
if (sceneAtStart?.previewAssetId !== assetId) {
return { project: projectAtStart, changed: false };
}
const sourceAsset = projectAtStart.assets[assetId];
if (!sourceAsset || (sourceAsset.type !== 'image' && sourceAsset.type !== 'video')) {
return { project: projectAtStart, changed: false };
}
const cacheDir = open.cacheDir;
const stillOpen = () =>
this.openProject !== null &&
this.projectSession === sessionAtStart &&
this.openProject.cacheDir === cacheDir;
const generatedRelPaths: string[] = [];
let finalAsset = sourceAsset;
let finalAssetId = assetId;
let finalAbs = path.join(cacheDir, sourceAsset.relPath);
/** Image bytes kept in memory so thumb/optimize do not re-open a path that reconcile may unlink. */
let imageBytes: Buffer | null = null;
if (sourceAsset.type === 'image') {
try {
imageBytes = await fs.readFile(finalAbs);
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err?.code === 'ENOENT') {
const latest = this.getOpenProject();
return { project: latest ?? projectAtStart, changed: false };
}
throw e;
}
if (!stillOpen()) {
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
const opt = await optimizeImageBufferVisuallyLossless(imageBytes);
if (!stillOpen()) {
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
if (!opt.passthrough) {
finalAssetId = asAssetId(this.randomId());
const optimizedName = `${path.parse(sourceAsset.originalName).name}.${opt.ext}`;
const safeOptimizedName = sanitizeFileName(optimizedName);
const optimizedRelPath = `assets/${finalAssetId}_${safeOptimizedName}`;
finalAbs = path.join(cacheDir, optimizedRelPath);
const optimizedBuffer = Buffer.from(opt.buffer);
imageBytes = optimizedBuffer;
await fs.writeFile(finalAbs, optimizedBuffer);
generatedRelPaths.push(optimizedRelPath);
const optimizedAsset = buildMediaAsset(
finalAssetId,
{ type: 'image', mime: opt.mime },
optimizedName,
optimizedRelPath,
crypto.createHash('sha256').update(optimizedBuffer).digest('hex'),
optimizedBuffer.length,
);
if (optimizedAsset.type !== 'image') {
throw new Error('Optimized preview asset must be an image');
}
finalAsset = optimizedAsset;
}
}
if (!stillOpen()) {
await Promise.all(
generatedRelPaths.map((relPath) =>
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
),
);
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
const thumbKind = finalAsset.type === 'image' ? 'image' : 'video';
const thumbBytes =
thumbKind === 'image' && imageBytes
? await generateScenePreviewThumbnailBytes(imageBytes, 'image')
: await generateScenePreviewThumbnailBytes(finalAbs, thumbKind);
let thumbAsset: MediaAsset | null = null;
let thumbId: AssetId | null = null;
if (thumbBytes !== null && thumbBytes.length > 0) {
thumbId = asAssetId(this.randomId());
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
const thumbAbs = path.join(cacheDir, thumbRelPath);
await fs.writeFile(thumbAbs, thumbBytes);
generatedRelPaths.push(thumbRelPath);
const thumbOrigName = `${path.parse(finalAsset.originalName).name}_preview_thumb.webp`;
thumbAsset = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
thumbOrigName,
thumbRelPath,
crypto.createHash('sha256').update(thumbBytes).digest('hex'),
thumbBytes.length,
);
}
if (!stillOpen()) {
await Promise.all(
generatedRelPaths.map((relPath) =>
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
),
);
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
let applied = false;
try {
await this.updateProject((p) => {
const scene = p.scenes[sceneId];
if (scene?.previewAssetId !== assetId) {
return p;
}
applied = true;
const assets: Record<AssetId, MediaAsset> = { ...p.assets, [finalAssetId]: finalAsset };
if (finalAssetId !== assetId) {
delete assets[assetId];
}
if (thumbAsset !== null && thumbId !== null) {
assets[thumbId] = thumbAsset;
}
return {
...p,
assets,
scenes: {
...p.scenes,
[sceneId]: {
...scene,
previewAssetId: finalAssetId,
previewAssetType: finalAsset.type,
previewThumbAssetId: thumbId,
previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false,
},
},
};
});
} catch (e) {
if (!stillOpen() || (e instanceof Error && e.message === 'No open project')) {
await Promise.all(
generatedRelPaths.map((relPath) =>
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
),
);
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
throw e;
}
const latest = this.getOpenProject();
if (!applied) {
await Promise.all(
generatedRelPaths.map((relPath) =>
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
),
);
}
return { project: latest ?? projectAtStart, changed: applied };
}
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;
const oldThumbId = sc.previewThumbAssetId ?? null;
if (!oldId && !oldThumbId) {
return open.project;
}
await this.updateProject((p) => {
const drop = new Set<AssetId>();
if (oldId) drop.add(oldId);
if (oldThumbId) drop.add(oldThumbId);
const assets = Object.fromEntries(
Object.entries(p.assets).filter(([k]) => !drop.has(k as AssetId)),
) as Record<AssetId, MediaAsset>;
return {
...p,
assets,
scenes: {
...p.scenes,
[sceneId]: {
...p.scenes[sceneId],
previewAssetId: null,
previewAssetType: null,
previewThumbAssetId: 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> {
return this.enqueueProjectUpdate(async () => {
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,
previewThumbAssetId: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { ...DEFAULT_SCENE_GRID },
} satisfies Scene);
const next: Scene = {
...base,
traps: base.traps ?? [],
tokens: base.tokens ?? [],
npcTokens: base.npcTokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(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.previewThumbAssetId !== undefined
? { previewThumbAssetId: patch.previewThumbAssetId }
: null),
...(patch.previewVideoAutostart !== undefined
? { previewVideoAutostart: patch.previewVideoAutostart }
: null),
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined
? {
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
}
: null),
...(patch.tokens !== undefined
? {
tokens: patch.tokens
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t)),
}
: null),
...(patch.npcTokens !== undefined
? {
npcTokens: patch.npcTokens
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => Boolean(t)),
}
: null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : 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) => {
const scenes = { ...p.scenes, [sceneId]: next };
const sceneListOrder =
existing != null
? reconcileSceneListOrder(scenes, p.sceneListOrder)
: prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId);
return { ...p, scenes, sceneListOrder };
});
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,
sceneListOrder: removeFromSceneListOrder(
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
sceneId,
),
currentSceneId,
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setSceneListOrder(sceneListOrder: SceneId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
sceneListOrder: reconcileSceneListOrder(p.scenes, sceneListOrder),
}));
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,
isSideStoryStart: false,
sideStoryLineTitle: '',
};
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) => {
return {
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
const isMain = graphNodeId !== null && n.id === graphNodeId;
if (isMain) {
return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' };
}
return { ...n, isStartScene: false };
}),
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setSceneGraphNodeSideStoryStart(graphNodeId: GraphNodeId | null): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (graphNodeId === null) {
throw new Error('Graph node id required');
}
if (!open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
throw new Error('Graph node not found');
}
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node) throw new Error('Graph node not found');
const enabling = !node.isSideStoryStart;
if (
enabling &&
!canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)
) {
return open.project;
}
await this.updateProject((p) => {
return {
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
if (n.id !== graphNodeId) return n;
if (enabling) {
return { ...n, isSideStoryStart: true, isStartScene: false };
}
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
}),
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateSideStoryLineTitle(graphNodeId: GraphNodeId, title: string): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node?.isSideStoryStart) throw new Error('Not a side story start node');
await this.updateProject((p) => ({
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) =>
n.id === graphNodeId ? { ...n, sideStoryLineTitle: title } : n,
),
}));
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 node = open.project.sceneGraphNodes.find((n) => n.id === nodeId);
if (!node) throw new Error('Graph node not found');
if (node.isSideStoryStart) {
const outgoing = open.project.sceneGraphEdges
.filter((e) => e.sourceGraphNodeId === nodeId)
.sort((a, b) => a.id.localeCompare(b.id));
if (outgoing.length > 0) {
const newStartId = outgoing[0]?.targetGraphNodeId;
if (!newStartId) throw new Error('Invalid graph edge');
const nextNodes = open.project.sceneGraphNodes
.filter((gn) => gn.id !== nodeId)
.map((gn) =>
gn.id === newStartId
? {
...gn,
isSideStoryStart: true,
isStartScene: false,
sideStoryLineTitle: node.sideStoryLineTitle,
}
: gn,
);
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 out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
const removeIds = getSideStoryComponentNodeIds(
open.project.sceneGraphNodes,
open.project.sceneGraphEdges,
nodeId,
);
const nextNodes = open.project.sceneGraphNodes.filter((gn) => !removeIds.has(gn.id));
const nextEdges = open.project.sceneGraphEdges.filter(
(e) => !removeIds.has(e.sourceGraphNodeId) && !removeIds.has(e.targetGraphNodeId),
);
await this.updateProject((p) => {
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
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 };
}
/**
* Copies audio files into cache `assets/` and registers them on the project as campaign audio.
*/
async importCampaignAudioFiles(filePaths: string[]): Promise<{ project: Project; imported: MediaAsset[] }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (filePaths.length === 0) {
return { project: open.project, imported: [] };
}
const staged: MediaAsset[] = [];
for (const filePath of filePaths) {
const kind = classifyMediaPath(filePath);
if (kind?.type !== 'audio') continue;
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
}
if (staged.length === 0) {
return { project: open.project, imported: [] };
}
await this.updateProject((p) => {
const assets = { ...p.assets };
const campaignAudios = [...p.campaignAudios];
for (const asset of staged) {
assets[asset.id] = asset;
if (asset.type !== 'audio') continue;
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false });
}
return { ...p, assets, campaignAudios };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return { project: latest, imported: staged };
}
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] }));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
/**
* Создаёт или обновляет материал кампании.
* При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку.
*/
async upsertMaterial(
input: {
materialId?: MaterialId;
name: string;
filePath?: string;
},
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
): Promise<Project> {
const report = (percent: number, stage: string, detail?: string) => {
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
};
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = input.name.trim();
if (name.length < 1) throw new Error('Material name is required');
const nameKey = name.toLowerCase();
const existing = open.project.materials ?? [];
const editingId = input.materialId ?? null;
if (existing.some((m) => m.id !== editingId && m.name.trim().toLowerCase() === nameKey)) {
throw new Error('Material name already exists');
}
report(2, 'start', 'Подождите…');
let nextAssetId: AssetId | null = null;
let stagedAsset: MediaAsset | null = null;
if (input.filePath) {
const kind = classifyMediaPath(input.filePath);
if (kind?.type !== 'image') throw new Error('Material must be an image (png/jpg/webp)');
const ext = path.extname(input.filePath).toLowerCase();
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
throw new Error('Material must be an image (png/jpg/webp)');
}
report(8, 'read', 'Чтение изображения…');
let buf = await fs.readFile(input.filePath);
report(18, 'optimize', 'Оптимизация изображения…');
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
// keep original buffer
}
report(72, 'write', 'Сохранение файла…');
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(input.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.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id;
}
report(88, 'project', 'Обновление проекта…');
await this.updateProject((p) => {
const materials = [...(p.materials ?? [])];
const assets = { ...p.assets };
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
if (editingId) {
const idx = materials.findIndex((m) => m.id === editingId);
if (idx < 0) throw new Error('Material not found');
const prev = materials[idx]!;
const assetId = nextAssetId ?? prev.assetId;
materials[idx] = {
id: editingId,
name,
assetId,
rotationDeg: prev.rotationDeg ?? 0,
...(prev.legend ? { legend: prev.legend } : {}),
};
} else {
if (!nextAssetId) throw new Error('Material image is required');
materials.push({
id: asMaterialId(`mat_${this.randomId()}`),
name,
assetId: nextAssetId,
rotationDeg: 0,
});
}
return { ...p, assets, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
report(100, 'done', 'Готово');
return latest;
}
async setMaterialRotation(materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const materials = (p.materials ?? []).map((m) => (m.id === materialId ? { ...m, rotationDeg } : m));
return { ...p, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setMaterialLegend(materialId: MaterialId, legend: MaterialLegend | null): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const normalized = legend ? normalizeMaterialLegend(legend) : undefined;
await this.updateProject((p) => {
const materials = (p.materials ?? []).map((m) => {
if (m.id !== materialId) return m;
if (
!normalized ||
(!normalized.enabled && normalized.items.length === 0 && normalized.markers.length === 0)
) {
const { legend: _drop, ...rest } = m;
void _drop;
return rest;
}
return { ...m, legend: normalized };
});
return { ...p, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteMaterial(materialId: MaterialId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
materials: (p.materials ?? []).filter((m) => m.id !== materialId),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setMaterialsOrder(materialIds: MaterialId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const byId = new Map((p.materials ?? []).map((m) => [m.id, m]));
const next: NonNullable<Project['materials']> = [];
for (const id of materialIds) {
const m = byId.get(id);
if (m) {
next.push(m);
byId.delete(id);
}
}
for (const m of byId.values()) next.push(m);
return { ...p, materials: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
/**
* Создаёт или обновляет НПС.
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
*/
async upsertNpc(
input: {
npcId?: NpcId;
name: string;
description?: string;
filePath?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: { x: number; y: number };
imageScale?: number;
},
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
): Promise<Project> {
const report = (percent: number, stage: string, detail?: string) => {
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
};
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = input.name.trim();
if (name.length < 1) throw new Error('NPC name is required');
const nameKey = name.toLowerCase();
const existing = open.project.npcs ?? [];
const editingId = input.npcId ?? null;
if (existing.some((n) => n.id !== editingId && n.name.trim().toLowerCase() === nameKey)) {
throw new Error('NPC name already exists');
}
report(2, 'start', 'Подождите…');
let nextAssetId: AssetId | null = null;
let stagedAsset: MediaAsset | null = null;
if (input.filePath) {
const kind = classifyMediaPath(input.filePath);
if (kind?.type !== 'image') throw new Error('NPC avatar must be an image (png/jpg/webp)');
const ext = path.extname(input.filePath).toLowerCase();
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
throw new Error('NPC avatar must be an image (png/jpg/webp)');
}
report(8, 'read', 'Чтение изображения…');
let buf = await fs.readFile(input.filePath);
report(18, 'optimize', 'Оптимизация изображения…');
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
// keep original buffer
}
report(72, 'write', 'Сохранение файла…');
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(input.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.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id;
}
report(88, 'project', 'Обновление проекта…');
await this.updateProject((p) => {
const npcs = [...(p.npcs ?? [])];
const assets = { ...p.assets };
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
const resolveGroup = (
raw: NpcGroupId | null | undefined,
prev: NpcGroupId | null,
): NpcGroupId | null => {
if (raw === undefined) return prev;
if (raw === null) return null;
return groupIds.has(raw) ? raw : null;
};
if (editingId) {
const idx = npcs.findIndex((n) => n.id === editingId);
if (idx < 0) throw new Error('NPC not found');
const prev = npcs[idx]!;
npcs[idx] = {
...prev,
name,
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
description: typeof input.description === 'string' ? input.description : prev.description,
groupId: resolveGroup(input.groupId, prev.groupId),
...(input.ringColor !== undefined
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
: {}),
...(input.disposition !== undefined
? {
disposition: normalizeNpcDisposition(input.disposition),
ringColor: npcDispositionRingColor(normalizeNpcDisposition(input.disposition)),
}
: {}),
...(input.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
: {}),
...(input.imageScale !== undefined
? { imageScale: clampPlayerImageScale(input.imageScale) }
: {}),
};
} else {
if (!nextAssetId) throw new Error('NPC avatar is required');
const count = npcs.length;
const disposition = normalizeNpcDisposition(input.disposition ?? DEFAULT_NPC_DISPOSITION);
npcs.push({
id: asNpcId(`npc_${this.randomId()}`),
name,
avatarAssetId: nextAssetId,
description: typeof input.description === 'string' ? input.description : '',
x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null),
disposition,
ringColor: npcDispositionRingColor(disposition),
imageOffset: clampPlayerImageOffset(input.imageOffset),
imageScale: clampPlayerImageScale(input.imageScale),
});
}
return { ...p, assets, npcs };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
report(100, 'done', 'Готово');
return latest;
}
async updateNpcFields(
npcId: NpcId,
patch: {
name?: string;
description?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
imageOffset?: { x: number; y: number };
imageScale?: number;
},
): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = typeof patch.name === 'string' ? patch.name.trim() : undefined;
if (name !== undefined) {
if (name.length < 1) throw new Error('NPC name is required');
const nameKey = name.toLowerCase();
if ((open.project.npcs ?? []).some((n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey)) {
throw new Error('NPC name already exists');
}
}
await this.updateProject((p) => {
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
const npcs = (p.npcs ?? []).map((n) => {
if (n.id !== npcId) return n;
let groupId = n.groupId;
if (patch.groupId !== undefined) {
groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
}
const disposition =
patch.disposition !== undefined
? normalizeNpcDisposition(patch.disposition)
: normalizeNpcDisposition(n.disposition);
return {
...n,
...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
groupId,
disposition,
ringColor:
patch.disposition !== undefined
? npcDispositionRingColor(disposition)
: patch.ringColor !== undefined
? normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR)
: n.ringColor || npcDispositionRingColor(disposition),
...(patch.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
: {}),
...(patch.imageScale !== undefined
? { imageScale: clampPlayerImageScale(patch.imageScale) }
: {}),
};
});
return { ...p, npcs };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateNpcPosition(npcId: NpcId, x: number, y: number): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
npcs: (p.npcs ?? []).map((n) => (n.id === npcId ? { ...n, x, y } : n)),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteNpc(npcId: NpcId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const scenes: Record<SceneId, Scene> = { ...p.scenes };
for (const sid of Object.keys(scenes) as SceneId[]) {
const sc = scenes[sid];
if (!sc) continue;
const npcTokens = (sc.npcTokens ?? []).filter((t) => t.npcId !== npcId);
if (npcTokens.length !== (sc.npcTokens ?? []).length) {
scenes[sid] = { ...sc, npcTokens };
}
}
return {
...p,
scenes,
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
npcRelations: (p.npcRelations ?? []).filter(
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
),
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setNpcsOrder(npcIds: NpcId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const byId = new Map((p.npcs ?? []).map((n) => [n.id, n]));
const next: ProjectNpc[] = [];
for (const id of npcIds) {
const n = byId.get(id);
if (n) {
next.push(n);
byId.delete(id);
}
}
for (const n of byId.values()) next.push(n);
return { ...p, npcs: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async upsertNpcGroup(input: {
groupId?: NpcGroupId;
name: string;
color?: string;
parentId?: NpcGroupId | null;
}): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = input.name.trim();
if (name.length < 1) throw new Error('Group name is required');
const color = normalizeHexColor(input.color, DEFAULT_NPC_GROUP_COLOR);
await this.updateProject((p) => {
const groups = [...(p.npcGroups ?? [])];
const editingId = input.groupId ?? null;
const parentId =
input.parentId === undefined
? editingId
? (groups.find((g) => g.id === editingId)?.parentId ?? null)
: null
: input.parentId;
if (parentId && !groups.some((g) => g.id === parentId)) {
throw new Error('Parent group not found');
}
if (editingId && parentId && wouldCreateGroupCycle(groups, editingId, parentId)) {
throw new Error('Invalid group parent');
}
const nameKey = name.toLowerCase();
const siblingConflict = groups.some(
(g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey,
);
if (siblingConflict) throw new Error('Group name already exists');
if (editingId) {
const idx = groups.findIndex((g) => g.id === editingId);
if (idx < 0) throw new Error('Group not found');
groups[idx] = { ...groups[idx]!, name, color, parentId };
} else {
groups.push({
id: asNpcGroupId(`ng_${this.randomId()}`),
name,
color,
parentId,
});
}
return { ...p, npcGroups: groups };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteNpcGroup(groupId: NpcGroupId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const groups = p.npcGroups ?? [];
if (!groups.some((g) => g.id === groupId)) throw new Error('Group not found');
const parentOfDeleted = groups.find((g) => g.id === groupId)?.parentId ?? null;
const nextGroups = groups
.filter((g) => g.id !== groupId)
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
const npcs = (p.npcs ?? []).map((n) => (n.groupId === groupId ? { ...n, groupId: null } : n));
return { ...p, npcGroups: nextGroups, npcs };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setNpcGroupsOrder(groupIds: NpcGroupId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const byId = new Map((p.npcGroups ?? []).map((g) => [g.id, g]));
const next: ProjectNpcGroup[] = [];
for (const id of groupIds) {
const g = byId.get(id);
if (g) {
next.push(g);
byId.delete(id);
}
}
for (const g of byId.values()) next.push(g);
return { ...p, npcGroups: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async upsertNpcRelation(input: {
relationId?: NpcRelationId;
sourceNpcId: NpcId;
targetNpcId: NpcId;
label: string;
}): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const label = input.label.trim();
if (label.length < 1) throw new Error('Relation label is required');
if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
const npcs = open.project.npcs ?? [];
if (!npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId)) {
throw new Error('NPC not found');
}
await this.updateProject((p) => {
const relations = [...(p.npcRelations ?? [])];
if (input.relationId) {
const idx = relations.findIndex((r) => r.id === input.relationId);
if (idx < 0) throw new Error('Relation not found');
relations[idx] = { ...relations[idx]!, label };
} else {
relations.push({
id: asNpcRelationId(`nrel_${this.randomId()}`),
sourceNpcId: input.sourceNpcId,
targetNpcId: input.targetNpcId,
label,
});
}
return { ...p, npcRelations: relations };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteNpcRelation(relationId: NpcRelationId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
npcRelations: (p.npcRelations ?? []).filter((r) => r.id !== relationId),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async saveNow(): Promise<void> {
const open = this.openProject;
if (!open) return;
// Let background preview finalizers finish before packing cache → zip.
await this.drainProjectMutations();
await this.projectWriteChain;
await this.enqueuePack(open.cacheDir, open.zipPath);
}
async closeOpenProject(): Promise<void> {
return this.enqueueProjectSwitch(async () => {
// Invalidate in-flight finalize early; then wait for queue to settle.
this.projectSession += 1;
if (!this.openProject) return;
await this.drainProjectMutations();
await this.saveNow();
await this.drainSavePipeline();
this.saveQueued = false;
this.openProject = null;
});
}
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 = projectZipFileNameFromBase(sanitizedBase);
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.enqueuePack(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;
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
}
this.saveDebounceTimer = setTimeout(() => {
this.saveDebounceTimer = null;
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.enqueuePack(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.packChain;
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);
const st = await fs.stat(tmpPath).catch(() => null);
if (!st?.isFile() || st.size < 22) {
await fs.unlink(tmpPath).catch(() => undefined);
throw new Error('Сборка архива проекта не удалась (пустой или повреждённый временный файл)');
}
await replaceFileAtomic(tmpPath, zipPath);
}
/**
* Копирует внешний `.dnd.zip` в каталог проектов и открывает его.
* Если архив уже лежит в `projects`, только открывает.
* При конфликте `id` с другим файлом перезаписывает `project.json` в копии с новым id.
*/
async importProjectFromExternalZip(
sourcePath: string,
onProgress?: (p: { stage: 'copy' | 'unzip' | 'done'; percent: number; detail?: string }) => void,
): 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 (!isProjectZipFileName(resolved)) {
throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .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 && isProjectZipFileName(baseName)) {
destPath = resolved;
destFileName = baseName;
} else {
destFileName = await uniqueProjectZipFileNameInRoot(root, baseName);
destPath = path.join(root, destFileName);
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(resolved, destPath, (pct) => {
if (!onProgress) return;
// Copy is ~70% of the operation; unzip/open happens after.
onProgress({ stage: 'copy', percent: Math.max(1, Math.min(70, pct)), detail: 'Копирование…' });
});
}
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 = stripProjectZipExtension(destFileName);
project = {
...project,
id: newId,
meta: {
...project.meta,
fileBaseName: project.meta.fileBaseName.trim().length ? project.meta.fileBaseName : stem,
},
};
await rewriteProjectJsonInZip(destPath, project);
}
this.projectSession += 1;
const opened = await this.openProjectByIdWithProgress(project.id, (pct) => {
if (onProgress) onProgress({ stage: 'unzip', percent: pct, detail: 'Распаковка…' });
});
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
return opened;
}
/**
* Импорт мира/модуля Foundry VTT (папка или .zip/.fvtt) в новый проект.
* Создаёт `.ttrpg.zip`, открывает проект и возвращает его.
*/
async importProjectFromFoundry(
sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void,
): Promise<Project> {
await this.ensureRoots();
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
try {
this.projectSession += 1;
const projectId = asProjectId(this.randomId());
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
const { project } = await buildProjectFromFoundryDocuments(
loaded.manifest,
loaded.docs,
cacheDir,
onProgress,
{ projectId },
);
const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(project.meta.fileBaseName));
const projectPath = path.join(cacheDir, 'project.json');
this.openProject = {
id: project.id,
zipPath,
cacheDir,
projectPath,
project,
};
await this.writeCacheProject(cacheDir, project);
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
await this.enqueuePack(cacheDir, zipPath);
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
return this.openProject.project;
} finally {
await loaded.cleanup();
}
}
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
async exportProjectZipToPath(
projectId: ProjectId,
destinationPath: string,
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<void> {
await this.ensureRoots();
// If exporting the currently open project, make sure pending debounced pack is flushed.
if (this.openProject?.id === projectId) {
await this.saveNow();
}
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const src = path.join(getProjectsRootDir(), entry.fileName);
const dest = path.resolve(destinationPath);
await fs.mkdir(path.dirname(dest), { recursive: true });
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(src, dest, (pct) => {
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
});
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
}
/** Удаляет архив проекта и кэш распаковки с диска. Если проект открыт — сбрасывает сессию. */
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.drainSavePipeline();
this.saveQueued = false;
this.openProject = null;
this.projectSession += 1;
}
await rmWithRetries(fs.rm, zipPath, { force: true });
await rmWithRetries(fs.rm, cacheDir, { recursive: true, force: true });
// Если проект подтянулся миграцией из legacy userData (другое имя приложения),
// то после удаления из текущей папки он может снова появиться при следующем ensureRoots().
// Поэтому удаляем и legacy-копии архива.
for (const legacyRoot of getLegacyProjectsRootDirs()) {
const legacyZipPath = path.join(legacyRoot, entry.fileName);
try {
await rmWithRetries(fs.rm, legacyZipPath, { force: true });
} catch {
/* ignore */
}
}
}
/** Снимок проекта для экспорта/списка линий (распаковывает во временный кэш при необходимости). */
private async loadProjectSnapshot(
projectId: ProjectId,
): Promise<{ project: Project; cacheDir: string; ownsCache: boolean }> {
await this.drainSavePipeline();
if (this.openProject?.id === projectId) {
await this.saveNow();
return {
project: structuredClone(this.openProject.project),
cacheDir: this.openProject.cacheDir,
ownsCache: false,
};
}
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 project = normalizeProject(await readProjectJsonFromZip(zipPath));
const cacheDir = path.join(tmpdir(), `dnd_snap_${crypto.randomBytes(8).toString('hex')}`);
await unzipToDir(zipPath, cacheDir);
return { project, cacheDir, ownsCache: true };
}
async getProjectStorylines(
projectId: ProjectId,
labels: StorylineLabels,
): Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }> {
const snap = await this.loadProjectSnapshot(projectId);
try {
return {
storylines: listExportableStorylines(snap.project, labels),
npcs: (snap.project.npcs ?? []).map((n) => ({ id: n.id, name: n.name })),
};
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async exportStorylinesZipToPath(
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
destinationPath: string,
labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
): Promise<void> {
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
await this.ensureRoots();
const snap = await this.loadProjectSnapshot(projectId);
const exportCache = path.join(tmpdir(), `dnd_pexport_${crypto.randomBytes(8).toString('hex')}`);
try {
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
const partial = buildPartialExportProject(snap.project, selections, {
newProjectId: newExportBundleProjectId(),
exportTitle: entry?.name ?? snap.project.meta.name,
labels,
npcIds,
});
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
const assetIds = Object.keys(partial.assets) as AssetId[];
for (let i = 0; i < assetIds.length; i += 1) {
const id = assetIds[i]!;
const a = partial.assets[id];
if (!a) continue;
const srcAbs = path.join(snap.cacheDir, a.relPath);
const destAbs = path.join(exportCache, a.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' });
}
}
if (packAppTokens) {
const tokenIds = collectTokenIdsFromProject(partial);
await packAppTokens(tokenIds, exportCache);
}
const now = new Date().toISOString();
const toWrite: Project = {
...partial,
meta: { ...partial.meta, updatedAt: now, appVersion: getAppSemanticVersion() },
};
await atomicWriteFile(path.join(exportCache, 'project.json'), JSON.stringify(toWrite, null, 2));
const tmpZip = `${path.resolve(destinationPath)}.tmp`;
if (onProgress) onProgress({ stage: 'zip', percent: 90, detail: 'Сборка архива…' });
await zipDir(exportCache, tmpZip);
await replaceFileAtomic(tmpZip, path.resolve(destinationPath));
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
} finally {
await fs.rm(exportCache, { recursive: true, force: true }).catch(() => undefined);
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async readExternalProjectForImport(sourcePath: string): Promise<Project> {
const resolved = path.resolve(sourcePath);
const st = await fs.stat(resolved).catch(() => null);
if (!st?.isFile()) throw new Error('Файл проекта не найден');
if (!isProjectZipFileName(resolved)) {
throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .dnd.zip');
}
return normalizeProject(await readProjectJsonFromZip(resolved));
}
async peekImportFromProjectId(
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
): Promise<{
sourceProjectId: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}> {
const snap = await this.loadProjectSnapshot(sourceProjectId);
try {
return {
sourceProjectId,
projectName: snap.project.meta.name,
storylines: listExportableStorylines(snap.project, labels).map((item) => {
if (item.selection.kind === 'main' && targetHasMainStart) {
return { ...item, disabled: true, disabledReason: 'main_exists' };
}
return item;
}),
sourceProject: structuredClone(snap.project),
};
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async peekImportFromZipPath(
sourcePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
): Promise<{
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}> {
const project = await this.readExternalProjectForImport(sourcePath);
return {
filePath: path.resolve(sourcePath),
projectName: project.meta.name,
storylines: listImportableStorylines(project, labels, targetHasMainStart),
sourceProject: project,
};
}
private assertStorylineMergeAllowed(selections: StorylineSelection[]): void {
if (!this.openProject) throw new Error('Нет открытого проекта');
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
if (
selections.some((s) => s.kind === 'main') &&
this.openProject.project.sceneGraphNodes.some((n) => n.isStartScene)
) {
throw new Error('В проекте уже есть основная сюжетная линия');
}
}
private async mergeStorylinesFromSourceCache(
source: Project,
sourceCache: string,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
if (!this.openProject) throw new Error('Нет открытого проекта');
let sourceForMerge = source;
if (importAppTokens) {
const remap = await importAppTokens(sourceCache);
sourceForMerge = remapProjectSceneTokenIds(source, remap);
}
const offsetX = computeGraphImportOffsetX(this.openProject.project);
const {
project: merged,
report,
assetCopies,
} = mergeStorylinesIntoProject(this.openProject.project, sourceForMerge, selections, sceneResolutions, {
graphOffsetX: offsetX,
...(npcResolutions ? { npcResolutions } : {}),
});
const targetCache = this.openProject.cacheDir;
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
for (let i = 0; i < assetCopies.length; i += 1) {
const { fromId, toId } = assetCopies[i]!;
const srcAsset = source.assets[fromId];
const tgtMeta = merged.assets[toId];
if (!srcAsset || !tgtMeta) continue;
const srcAbs = path.join(sourceCache, srcAsset.relPath);
const destAbs = path.join(targetCache, tgtMeta.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
onProgress({ stage: 'copy', percent: pct, detail: 'Копирование материалов…' });
}
}
const normalized = normalizeProject(merged);
const reconciled = await reconcileAssetFiles(this.openProject.project, normalized, targetCache);
await this.updateProject(() => reconciled);
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
return { project: latest, report };
}
async mergeStorylinesFromProjectId(
sourceProjectId: ProjectId,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const snap = await this.loadProjectSnapshot(sourceProjectId);
try {
return await this.mergeStorylinesFromSourceCache(
snap.project,
snap.cacheDir,
selections,
sceneResolutions,
onProgress,
npcResolutions,
importAppTokens,
);
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async mergeStorylinesFromExternalZip(
sourcePath: string,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const source = await this.readExternalProjectForImport(sourcePath);
const sourceCache = path.join(tmpdir(), `dnd_mimport_${crypto.randomBytes(8).toString('hex')}`);
await unzipToDir(path.resolve(sourcePath), sourceCache);
try {
return await this.mergeStorylinesFromSourceCache(
source,
sourceCache,
selections,
sceneResolutions,
onProgress,
npcResolutions,
importAppTokens,
);
} finally {
await fs.rm(sourceCache, { 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,
isSideStoryStart: false,
sideStoryLineTitle: '',
}));
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;
isSideStoryStart?: boolean;
sideStoryLineTitle?: string;
};
const isStartScene = raw.isStartScene === true;
const isSideStoryStart = raw.isSideStoryStart === true && !isStartScene;
return {
...n,
isStartScene,
isSideStoryStart,
sideStoryLineTitle: isSideStoryStart ? (raw.sideStoryLineTitle ?? '').trim() : '',
};
});
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 previewThumbAssetId =
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
const rawTraps = (s as unknown as { traps?: unknown[] }).traps;
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
.map((t) => normalizeSceneTrap(t))
.filter((t): t is SceneTrap => Boolean(t));
const rawTokens = (s as unknown as { tokens?: unknown[] }).tokens;
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t));
const rawNpcTokens = (s as unknown as { npcTokens?: unknown[] }).npcTokens;
const npcTokens = (Array.isArray(rawNpcTokens) ? rawNpcTokens : [])
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => Boolean(t));
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
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,
previewThumbAssetId,
previewVideoAutostart,
previewRotationDeg,
darkenScene,
traps,
tokens,
npcTokens,
grid,
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 rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
.map((a) => {
if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false };
if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
}
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
.map((m) => {
if (!m || typeof m !== 'object') return null;
const obj = m as {
id?: string;
name?: string;
assetId?: AssetId;
rotationDeg?: number;
legend?: unknown;
};
if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
if (!name) return null;
const rot = obj.rotationDeg;
const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
const legend = normalizeMaterialLegend(obj.legend);
return {
id: asMaterialId(String(obj.id)),
name,
assetId: obj.assetId,
rotationDeg,
...(legend ? { legend } : {}),
};
})
.filter(
(
x,
): x is {
id: MaterialId;
name: string;
assetId: AssetId;
rotationDeg: 0 | 90 | 180 | 270;
legend?: MaterialLegend;
} => Boolean(x),
);
const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
const groupIdSet = new Set(npcGroups.map((g) => g.id));
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
.map((n, index) => {
if (!n || typeof n !== 'object') return null;
const obj = n as {
id?: string;
name?: string;
avatarAssetId?: AssetId;
description?: string;
x?: number;
y?: number;
groupId?: string | null;
ringColor?: string;
imageOffset?: unknown;
};
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
if (!name) return null;
const x = typeof obj.x === 'number' && Number.isFinite(obj.x) ? obj.x : 80 + (index % 4) * 220;
const y =
typeof obj.y === 'number' && Number.isFinite(obj.y) ? obj.y : 80 + Math.floor(index / 4) * 200;
return {
id: asNpcId(String(obj.id)),
name,
avatarAssetId: obj.avatarAssetId,
description: typeof obj.description === 'string' ? obj.description : '',
x,
y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
ringColor: npcDispositionRingColor(
normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
),
imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
};
})
.filter((x): x is ProjectNpc => Boolean(x));
const npcIdSet = new Set(npcs.map((n) => n.id));
const rawNpcRelations = (p as unknown as { npcRelations?: unknown[] }).npcRelations;
const npcRelations: ProjectNpcRelation[] = (Array.isArray(rawNpcRelations) ? rawNpcRelations : [])
.map((r) => {
if (!r || typeof r !== 'object') return null;
const obj = r as {
id?: string;
sourceNpcId?: string;
targetNpcId?: string;
/** legacy undirected fields — трактуем как source→target */
npcAId?: string;
npcBId?: string;
label?: string;
};
const rawSource = obj.sourceNpcId ?? obj.npcAId;
const rawTarget = obj.targetNpcId ?? obj.npcBId;
if (!obj.id || !rawSource || !rawTarget || typeof obj.label !== 'string') return null;
const label = obj.label.trim();
if (!label) return null;
const sourceNpcId = asNpcId(String(rawSource));
const targetNpcId = asNpcId(String(rawTarget));
if (sourceNpcId === targetNpcId) return null;
if (!npcIdSet.has(sourceNpcId) || !npcIdSet.has(targetNpcId)) return null;
return { id: asNpcRelationId(String(obj.id)), sourceNpcId, targetNpcId, label };
})
.filter((x): x is ProjectNpcRelation => Boolean(x));
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';
})();
const scenesPruned: Record<SceneId, Scene> = {};
for (const sid of Object.keys(scenes) as SceneId[]) {
const sc = scenes[sid];
if (!sc) continue;
scenesPruned[sid] = {
...sc,
npcTokens: (sc.npcTokens ?? []).filter((t) => npcIdSet.has(t.npcId)),
};
}
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: scenesPruned,
campaignAudios,
materials,
npcs,
npcGroups,
npcRelations,
sceneGraphNodes,
sceneGraphEdges,
currentGraphNodeId,
sceneListOrder: reconcileSceneListOrder(
scenesPruned,
(p as { sceneListOrder?: SceneId[] }).sceneListOrder,
),
};
}
function sanitizeFileName(name: string): string {
const trimmed = name.trim();
const safe = trimmed.replace(/[<>:"/\\|?*]/gu, '_');
return safe.length > 0 ? safe : 'Untitled';
}
async function uniqueProjectZipFileNameInRoot(root: string, preferredBaseFileName: string): Promise<string> {
const stem = sanitizeFileName(stripProjectZipExtension(path.basename(preferredBaseFileName)) || 'project');
let candidate = projectZipFileNameFromBase(stem);
let n = 0;
for (;;) {
try {
await fs.access(path.join(root, candidate));
n += 1;
candidate = projectZipFileNameFromBase(`${stem}_${String(n)}`);
} catch {
return candidate;
}
}
}
async function copyFileWithProgress(
src: string,
dest: string,
onPercent: (pct: number) => void,
): Promise<void> {
const st = await fs.stat(src);
const total = st.size || 0;
if (total <= 0) {
await fs.copyFile(src, dest);
onPercent(100);
return;
}
await fs.mkdir(path.dirname(dest), { recursive: true });
await new Promise<void>((resolve, reject) => {
let done = 0;
const rs = fssync.createReadStream(src);
const ws = fssync.createWriteStream(dest);
const onErr = (e: unknown) => reject(e instanceof Error ? e : new Error(String(e)));
rs.on('error', onErr);
ws.on('error', onErr);
rs.on('data', (chunk: Buffer) => {
done += chunk.length;
const pct = Math.round((done / total) * 100);
try {
onPercent(Math.max(0, Math.min(100, pct)));
} catch {
// ignore
}
});
ws.on('close', () => resolve());
rs.pipe(ws);
});
}
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 '.bmp':
return { type: 'image', mime: 'image/bmp' };
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');
await replaceFileAtomic(tmp, filePath);
}
/** Уже сжатые контейнеры/кодеки — в 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, '/');
// Bufferize: yazl.addFile opens lazily and can ENOENT if reconcile unlinks mid-pack.
let buf: Buffer;
try {
buf = await fs.readFile(abs);
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err?.code === 'ENOENT') continue;
throw e;
}
zipfile.addBuffer(buf, 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;
}