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 { 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 { 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 }> { 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; /** foundry path or abs path → assetId */ bySourceKey: Map; }; async function importFileAsAsset( ctx: AssetWriteCtx, absPath: string, opts?: { optimizeImage?: boolean; forceKind?: MediaKind }, ): Promise { 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 { 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 { 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 { 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[] { 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 }, ): Promise { 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(); const scenes: Record = {}; 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: [], npcTokens: [], 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(); 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, ringColor: '#c9a227', imageOffset: { x: 0, y: 0 }, imageScale: 1, }); npcIndex += 1; } const sceneGraphNodes: SceneGraphNode[] = []; const sceneGraphEdges: SceneGraphEdge[] = []; const foundryToGraphNode = new Map(); 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>(); 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; } { 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(); for (const f of actorFolders) { foundryFolderToGroupId.set(f._id, asNpcGroupId(`ng_${randomId()}`)); } const nameKeysByParent = new Map>(); const npcGroups: ProjectNpcGroup[] = []; // Parents first const remaining = [...actorFolders]; const placed = new Set(); 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; }> { 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; } }