Files
DndGamePlayer/app/main/foundry/foundryImport.ts
T
Ivan Fontosh f270812219 feat(tokens): app-local non-player tokens with session moves and UI polish
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:04:12 +08:00

658 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
actorPortraitSrc,
extractActorDescriptionHtml,
filterScenesForImport,
journalDescriptionHtml,
planFoundrySceneGraph,
sceneBackgroundSrc,
} from '../../shared/foundry/foundryGraph';
import type {
FoundryFolderDoc,
FoundryLoadedDocuments,
FoundryPackageManifest,
FoundryPlaylistDoc,
FoundrySceneDoc,
} from '../../shared/foundry/foundryTypes';
import { noneBinding } from '../../shared/npcs/npcBinding';
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
import type {
MediaAsset,
Project,
ProjectNpc,
ProjectNpcGroup,
Scene,
SceneGraphEdge,
SceneGraphNode,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId, NpcGroupId, SceneId } from '../../shared/types/ids';
import {
asAssetId,
asGraphNodeId,
asNpcGroupId,
asNpcId,
asProjectId,
asSceneId,
} from '../../shared/types/ids';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
import { generateScenePreviewThumbnailBytes } from '../project/scenePreviewThumbnail';
import { unzipToDir } from '../project/yauzlProjectZip';
import { getAppSemanticVersion } from '../versionInfo';
import { loadModuleDocuments, loadWorldDocuments } from './foundryDb';
import { detectFoundryPackage, resolveFoundryAssetPath } from './foundryDetect';
export type FoundryImportProgress = {
stage: 'copy' | 'unzip' | 'zip' | 'done';
percent: number;
detail?: string;
};
/** Минимальный 1×1 PNG (серый), если у актёра нет портрета на диске. */
const PLACEHOLDER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
type MediaKind = { type: 'image' | 'video' | 'audio'; 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' };
case '.flac':
return { type: 'audio', mime: 'audio/flac' };
default:
return null;
}
}
function sanitizeFileName(name: string): string {
const cleaned = name
.split('')
.map((ch) => {
const code = ch.charCodeAt(0);
if (code < 32 || '<>:"/\\|?*'.includes(ch)) return '_';
return ch;
})
.join('')
.trim();
return cleaned.length > 0 ? cleaned.slice(0, 180) : 'file';
}
function randomId(): string {
return crypto.randomBytes(16).toString('hex');
}
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 isZipFile(filePath: string): Promise<boolean> {
try {
const st = await fs.stat(filePath);
if (!st.isFile()) return false;
const ext = path.extname(filePath).toLowerCase();
return ext === '.zip' || ext === '.fvtt';
} catch {
return false;
}
}
async function prepareSourceDir(
sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void,
): Promise<{ workDir: string; cleanup: () => Promise<void> }> {
const abs = path.resolve(sourcePath);
if (await isZipFile(abs)) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ttrpg-foundry-'));
onProgress?.({ stage: 'unzip', percent: 5, detail: 'Распаковка архива Foundry…' });
await unzipToDir(abs, workDir, (done, total) => {
const pct = total > 0 ? 5 + Math.round((done / total) * 25) : 15;
onProgress?.({ stage: 'unzip', percent: Math.min(30, pct), detail: 'Распаковка архива Foundry…' });
});
return {
workDir,
cleanup: async () => {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
const st = await fs.stat(abs);
if (!st.isDirectory()) {
throw new Error('Выберите папку мира/модуля Foundry или архив (.zip / .fvtt).');
}
return {
workDir: abs,
cleanup: () => Promise.resolve(),
};
}
type AssetWriteCtx = {
cacheDir: string;
assets: Record<AssetId, MediaAsset>;
/** foundry path or abs path → assetId */
bySourceKey: Map<string, AssetId>;
};
async function importFileAsAsset(
ctx: AssetWriteCtx,
absPath: string,
opts?: { optimizeImage?: boolean; forceKind?: MediaKind },
): Promise<AssetId | null> {
const key = path.resolve(absPath).toLowerCase();
const existing = ctx.bySourceKey.get(key);
if (existing) return existing;
let kind = opts?.forceKind ?? classifyMediaPath(absPath);
if (!kind) return null;
let buf = await fs.readFile(absPath);
if (kind.type === 'image' && opts?.optimizeImage !== false) {
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (!opt.passthrough && opt.buffer.length > 0) {
buf = Buffer.from(opt.buffer);
kind = { type: 'image', mime: opt.mime };
}
} catch {
// keep original
}
}
const id = asAssetId(randomId());
const orig = path.basename(absPath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const absOut = path.join(ctx.cacheDir, relPath);
await fs.mkdir(path.dirname(absOut), { recursive: true });
await fs.writeFile(absOut, buf);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
ctx.assets[id] = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
ctx.bySourceKey.set(key, id);
return id;
}
async function importPlaceholderAvatar(ctx: AssetWriteCtx, name: string): Promise<AssetId> {
const id = asAssetId(randomId());
const orig = `${sanitizeFileName(name)}_avatar.png`;
const relPath = `assets/${id}_${orig}`;
const absOut = path.join(ctx.cacheDir, relPath);
await fs.mkdir(path.dirname(absOut), { recursive: true });
await fs.writeFile(absOut, PLACEHOLDER_PNG);
const sha256 = crypto.createHash('sha256').update(PLACEHOLDER_PNG).digest('hex');
ctx.assets[id] = buildMediaAsset(
id,
{ type: 'image', mime: 'image/png' },
orig,
relPath,
sha256,
PLACEHOLDER_PNG.length,
);
return id;
}
async function resolveAndImport(
ctx: AssetWriteCtx,
manifest: FoundryPackageManifest,
foundryPath: string | null | undefined,
opts?: { optimizeImage?: boolean },
): Promise<AssetId | null> {
if (!foundryPath?.trim()) return null;
const abs = await resolveFoundryAssetPath(manifest, foundryPath);
if (!abs) return null;
return importFileAsAsset(ctx, abs, opts);
}
function uniqueNpcName(base: string, used: Set<string>): string {
const root = base.trim() || 'NPC';
if (!used.has(root.toLowerCase())) {
used.add(root.toLowerCase());
return root;
}
let i = 2;
while (used.has(`${root} (${String(i)})`.toLowerCase())) i += 1;
const name = `${root} (${String(i)})`;
used.add(name.toLowerCase());
return name;
}
function collectSceneAudioPaths(
scene: FoundrySceneDoc,
playlistsById: Map<string, FoundryPlaylistDoc>,
): string[] {
const paths: string[] = [];
const playlistId = typeof scene.playlist === 'string' ? scene.playlist : null;
if (!playlistId) return paths;
const playlist = playlistsById.get(playlistId);
if (!playlist?.sounds?.length) return paths;
const soundId = typeof scene.playlistSound === 'string' ? scene.playlistSound : null;
const sounds = soundId
? playlist.sounds.filter((s) => s._id === soundId)
: [...playlist.sounds].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
for (const s of sounds) {
if (typeof s.path === 'string' && s.path.trim()) paths.push(s.path.trim());
}
return paths;
}
export type FoundryBuiltProject = {
project: Project;
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
previewSources: { sceneId: SceneId; absPath: string }[];
};
export async function buildProjectFromFoundryDocuments(
manifest: FoundryPackageManifest,
docs: FoundryLoadedDocuments,
cacheDir: string,
onProgress?: (p: FoundryImportProgress) => void,
options?: { projectId?: ReturnType<typeof asProjectId> },
): Promise<FoundryBuiltProject> {
const projectId = options?.projectId ?? asProjectId(randomId());
const now = new Date().toISOString();
const appVer = getAppSemanticVersion();
const name = manifest.title.trim() || manifest.id;
const fileBaseName = `${sanitizeFileName(name)}_${projectId}`;
const ctx: AssetWriteCtx = { cacheDir, assets: {}, bySourceKey: new Map() };
const journalsById = new Map(docs.journals.map((j) => [j._id, j]));
const playlistsById = new Map(docs.playlists.map((p) => [p._id, p]));
const importScenes = filterScenesForImport(docs.scenes);
const graphPlan = planFoundrySceneGraph(importScenes, docs.adventures, docs.journals);
const sceneOrderIds =
graphPlan.orderedSceneIds.length > 0 ? graphPlan.orderedSceneIds : importScenes.map((s) => s._id);
const scenesByFoundryId = new Map(importScenes.map((s) => [s._id, s]));
const foundryToSceneId = new Map<string, SceneId>();
const scenes: Record<SceneId, Scene> = {};
const sceneListOrder: SceneId[] = [];
const previewSources: { sceneId: SceneId; absPath: string }[] = [];
const totalSteps = Math.max(1, sceneOrderIds.length + docs.actors.length);
let step = 0;
for (const foundrySceneId of sceneOrderIds) {
const doc = scenesByFoundryId.get(foundrySceneId);
if (!doc) continue;
step += 1;
onProgress?.({
stage: 'copy',
percent: 30 + Math.round((step / totalSteps) * 50),
detail: `Сцена: ${doc.name}`,
});
const sceneId = asSceneId(`s_${randomId()}`);
foundryToSceneId.set(foundrySceneId, sceneId);
let previewAssetId: AssetId | null = null;
let previewAssetType: 'image' | 'video' | null = null;
let previewThumbAssetId: AssetId | null = null;
const bg = sceneBackgroundSrc(doc);
if (bg) {
const abs = await resolveFoundryAssetPath(manifest, bg);
if (abs) {
const kind = classifyMediaPath(abs);
if (kind?.type === 'image' || kind?.type === 'video') {
previewAssetId = await importFileAsAsset(ctx, abs, { optimizeImage: kind.type === 'image' });
previewAssetType = kind.type;
previewSources.push({ sceneId, absPath: abs });
if (previewAssetId && kind.type === 'image') {
try {
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, 'image');
if (thumbBytes && thumbBytes.length > 0) {
const thumbId = asAssetId(randomId());
const thumbRel = `assets/${thumbId}_preview_thumb.webp`;
await fs.writeFile(path.join(cacheDir, thumbRel), thumbBytes);
ctx.assets[thumbId] = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
`${sanitizeFileName(doc.name)}_preview_thumb.webp`,
thumbRel,
crypto.createHash('sha256').update(thumbBytes).digest('hex'),
thumbBytes.length,
);
previewThumbAssetId = thumbId;
}
} catch {
// optional
}
}
}
}
}
const audioRefs: Scene['media']['audios'] = [];
for (const audioPath of collectSceneAudioPaths(doc, playlistsById)) {
const assetId = await resolveAndImport(ctx, manifest, audioPath);
if (!assetId) continue;
if (ctx.assets[assetId]?.type !== 'audio') continue;
audioRefs.push({ assetId, autoplay: true, loop: true });
}
scenes[sceneId] = {
id: sceneId,
title: doc.name.trim() || 'Scene',
description: journalDescriptionHtml(doc, journalsById),
previewAssetId,
previewAssetType,
previewThumbAssetId,
previewVideoAutostart: previewAssetType === 'video',
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: audioRefs },
settings: {
autoplayVideo: previewAssetType === 'video',
autoplayAudio: true,
loopVideo: true,
loopAudio: true,
},
connections: [],
layout: { x: 0, y: 0 },
};
sceneListOrder.push(sceneId);
}
const usedPlaylistIds = new Set(
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
);
const campaignAudios: Project['campaignAudios'] = [];
for (const pl of docs.playlists) {
if (usedPlaylistIds.has(pl._id)) continue;
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
for (const s of sounds) {
if (typeof s.path !== 'string' || !s.path.trim()) continue;
const assetId = await resolveAndImport(ctx, manifest, s.path);
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue;
campaignAudios.push({ assetId, autoplay: false, loop: true });
}
}
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
const npcs: ProjectNpc[] = [];
const usedNpcNames = new Set<string>();
let npcIndex = 0;
for (const actor of docs.actors) {
// Группы актёров Foundry (party/group) — не персонажи.
if (actor.type === 'group') continue;
step += 1;
onProgress?.({
stage: 'copy',
percent: 30 + Math.round((step / totalSteps) * 50),
detail: `НПС: ${actor.name}`,
});
const portrait = actorPortraitSrc(actor);
let avatarAssetId = portrait ? await resolveAndImport(ctx, manifest, portrait) : null;
if (!avatarAssetId || ctx.assets[avatarAssetId]?.type !== 'image') {
avatarAssetId = await importPlaceholderAvatar(ctx, actor.name);
}
const npcName = uniqueNpcName(actor.name, usedNpcNames);
const folderId = typeof actor.folder === 'string' ? actor.folder : null;
const groupId = folderId ? (foundryFolderToGroupId.get(folderId) ?? null) : null;
npcs.push({
id: asNpcId(`npc_${randomId()}`),
name: npcName,
avatarAssetId,
description: extractActorDescriptionHtml(actor),
x: 80 + (npcIndex % 4) * 220,
y: 80 + Math.floor(npcIndex / 4) * 200,
groupId,
binding: noneBinding(),
});
npcIndex += 1;
}
const sceneGraphNodes: SceneGraphNode[] = [];
const sceneGraphEdges: SceneGraphEdge[] = [];
const foundryToGraphNode = new Map<string, GraphNodeId>();
const startFoundryId = graphPlan.startSceneId;
const startSceneId =
(startFoundryId ? foundryToSceneId.get(startFoundryId) : null) ?? sceneListOrder[0] ?? null;
sceneListOrder.forEach((sceneId, index) => {
const foundryId = [...foundryToSceneId.entries()].find(([, sid]) => sid === sceneId)?.[0];
const col = index % 4;
const row = Math.floor(index / 4);
const gnId = asGraphNodeId(`gn_${randomId()}`);
sceneGraphNodes.push({
id: gnId,
sceneId,
x: 80 + col * 280,
y: 80 + row * 200,
isStartScene: startSceneId !== null && sceneId === startSceneId,
isSideStoryStart: false,
sideStoryLineTitle: '',
});
if (foundryId) foundryToGraphNode.set(foundryId, gnId);
});
for (const edge of graphPlan.edges) {
const source = foundryToGraphNode.get(edge.sourceId);
const target = foundryToGraphNode.get(edge.targetId);
if (!source || !target || source === target) continue;
sceneGraphEdges.push({
id: `e_${randomId()}`,
sourceGraphNodeId: source,
targetGraphNodeId: target,
});
}
// connections из рёбер графа
const outgoing = new Map<SceneId, Set<SceneId>>();
const gnMap = new Map(sceneGraphNodes.map((n) => [n.id, n]));
for (const e of sceneGraphEdges) {
const a = gnMap.get(e.sourceGraphNodeId);
const b = gnMap.get(e.targetGraphNodeId);
if (!a || !b || 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);
}
for (const [sid, set] of outgoing) {
const sc = scenes[sid];
if (sc) sc.connections = [...set];
}
const startGraphNodeId = sceneGraphNodes.find((n) => n.isStartScene)?.id ?? null;
const project: Project = {
id: projectId,
meta: {
name,
fileBaseName,
createdAt: now,
updatedAt: now,
createdWithAppVersion: appVer,
appVersion: appVer,
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes,
sceneListOrder,
assets: ctx.assets,
campaignAudios,
materials: [],
npcs,
npcGroups,
npcRelations: [],
currentSceneId: startSceneId,
currentGraphNodeId: startGraphNodeId,
sceneGraphNodes,
sceneGraphEdges,
};
return { project, previewSources };
}
/** Actor folders Foundry → дерево групп НПС. */
function buildNpcGroupsFromFoundryFolders(folders: FoundryFolderDoc[]): {
npcGroups: ProjectNpcGroup[];
foundryFolderToGroupId: Map<string, NpcGroupId>;
} {
const actorFolders = folders
.filter((f) => !f.type || f.type === 'Actor')
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.name.localeCompare(b.name));
const foundryFolderToGroupId = new Map<string, NpcGroupId>();
for (const f of actorFolders) {
foundryFolderToGroupId.set(f._id, asNpcGroupId(`ng_${randomId()}`));
}
const nameKeysByParent = new Map<string | null, Set<string>>();
const npcGroups: ProjectNpcGroup[] = [];
// Parents first
const remaining = [...actorFolders];
const placed = new Set<string>();
while (remaining.length > 0) {
let progress = false;
for (let i = 0; i < remaining.length; i += 1) {
const f = remaining[i]!;
const parentFoundry = typeof f.folder === 'string' && f.folder.trim() ? f.folder.trim() : null;
if (parentFoundry && !foundryFolderToGroupId.has(parentFoundry)) {
// orphan parent ref → root
} else if (parentFoundry && !placed.has(parentFoundry) && foundryFolderToGroupId.has(parentFoundry)) {
continue;
}
const parentId = parentFoundry ? (foundryFolderToGroupId.get(parentFoundry) ?? null) : null;
const parentKey = parentId;
let keys = nameKeysByParent.get(parentKey);
if (!keys) {
keys = new Set();
nameKeysByParent.set(parentKey, keys);
}
let name = f.name.trim() || 'Group';
const base = name.toLowerCase();
if (keys.has(base)) {
let n = 2;
while (keys.has(`${base} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
keys.add(name.toLowerCase());
const id = foundryFolderToGroupId.get(f._id)!;
npcGroups.push({
id,
name,
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
parentId,
});
placed.add(f._id);
remaining.splice(i, 1);
progress = true;
break;
}
if (!progress) {
// cycle / leftover → force as roots
for (const f of remaining) {
const id = foundryFolderToGroupId.get(f._id)!;
let name = f.name.trim() || 'Group';
const keys = nameKeysByParent.get(null) ?? new Set();
nameKeysByParent.set(null, keys);
if (keys.has(name.toLowerCase())) {
let n = 2;
while (keys.has(`${name.toLowerCase()} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
keys.add(name.toLowerCase());
npcGroups.push({
id,
name,
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
parentId: null,
});
}
break;
}
}
return { npcGroups, foundryFolderToGroupId };
}
export async function loadFoundryDocumentsForImport(
sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void,
): Promise<{
manifest: FoundryPackageManifest;
docs: FoundryLoadedDocuments;
cleanup: () => Promise<void>;
}> {
const prepared = await prepareSourceDir(sourcePath, onProgress);
try {
onProgress?.({ stage: 'copy', percent: 32, detail: 'Определение пакета Foundry…' });
const manifest = await detectFoundryPackage(prepared.workDir);
onProgress?.({
stage: 'copy',
percent: 36,
detail: manifest.kind === 'world' ? 'Чтение мира…' : 'Чтение модуля…',
});
const docs =
manifest.kind === 'world'
? await loadWorldDocuments(manifest.rootDir)
: await loadModuleDocuments(manifest.rootDir, manifest.packs);
if (docs.scenes.length === 0 && docs.actors.length === 0) {
throw new Error(
'В пакете Foundry не найдено сцен и актёров. Проверьте, что выбран мир/модуль с данными (не пустой шаблон).',
);
}
return { manifest, docs, cleanup: prepared.cleanup };
} catch (e) {
await prepared.cleanup();
throw e;
}
}