4e6f7321b8
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>
2917 lines
107 KiB
TypeScript
2917 lines
107 KiB
TypeScript
import crypto from 'node:crypto';
|
||
import fssync from 'node:fs';
|
||
import fs from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import path from 'node:path';
|
||
|
||
import { 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;
|
||
}
|