import { getConnectedComponent, getSideStoryComponentNodeIds, listSideStoryStarts, } from './sceneGraphLineage'; import { reconcileSceneListOrder } from './sceneListOrder'; import type { ExportedStorylineRef, GraphNodeId, Project, Scene, SceneGraphEdge, SceneGraphNode, SceneId, } from '../types'; import type { AssetId, ProjectId } from '../types/ids'; import { asAssetId, asGraphNodeId, asMaterialId, asNpcId, asNpcRelationId, asProjectId, asSceneId, } from '../types/ids'; export type StorylineKind = 'main' | 'side'; /** Выбор сюжетной линии для экспорта/импорта. */ export type StorylineSelection = | { kind: 'main' } | { kind: 'side'; startGraphNodeId: GraphNodeId }; export type StorylineListItem = { selection: StorylineSelection; label: string; disabled?: boolean; disabledReason?: string; }; export type SceneImportResolution = | { sourceSceneId: SceneId; mode: 'create' } | { sourceSceneId: SceneId; mode: 'use'; targetSceneId: SceneId }; export type SceneTitleConflict = { sourceSceneId: SceneId; sourceTitle: string; matches: { sceneId: SceneId; title: string }[]; }; export type StorylineImportMergeReport = { storylinesImported: number; scenesCreated: number; scenesReused: number; graphNodesAdded: number; edgesAdded: number; assetsCopied: number; assetsReused: number; renamedSideTitles: string[]; }; export type StorylineLabels = { main: string; untitled: string; }; function generateId(): string { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } export function storylineSelectionKey(sel: StorylineSelection): string { return sel.kind === 'main' ? 'main' : `side:${sel.startGraphNodeId}`; } export function storylineSelectionsEqual(a: StorylineSelection, b: StorylineSelection): boolean { return storylineSelectionKey(a) === storylineSelectionKey(b); } export function sideStoryDisplayLabel( node: SceneGraphNode, scenes: Record, untitledLabel: string, ): string { const custom = node.sideStoryLineTitle.trim(); if (custom) return custom; const scene = scenes[node.sceneId]; const sceneTitle = scene?.title.trim(); if (sceneTitle) return sceneTitle; return untitledLabel; } function findMainStartNode(nodes: SceneGraphNode[]): SceneGraphNode | null { return nodes.find((n) => n.isStartScene) ?? null; } export function collectStorylineGraphNodeIds( project: Project, selection: StorylineSelection, ): Set { const { sceneGraphNodes, sceneGraphEdges } = project; if (selection.kind === 'main') { const mainStart = findMainStartNode(sceneGraphNodes); if (!mainStart) return new Set(); return getConnectedComponent(sceneGraphNodes, sceneGraphEdges, mainStart.id); } return getSideStoryComponentNodeIds(sceneGraphNodes, sceneGraphEdges, selection.startGraphNodeId); } export function collectSelectionsGraphNodeIds( project: Project, selections: StorylineSelection[], ): Set { const out = new Set(); for (const sel of selections) { for (const id of collectStorylineGraphNodeIds(project, sel)) { out.add(id); } } return out; } export function listExportableStorylines(project: Project, labels: StorylineLabels): StorylineListItem[] { const items: StorylineListItem[] = []; if (findMainStartNode(project.sceneGraphNodes)) { items.push({ selection: { kind: 'main' }, label: labels.main }); } for (const gn of listSideStoryStarts(project.sceneGraphNodes)) { items.push({ selection: { kind: 'side', startGraphNodeId: gn.id }, label: sideStoryDisplayLabel(gn, project.scenes, labels.untitled), }); } return items; } export function listImportableStorylines( source: Project, labels: StorylineLabels, targetHasMainStart: boolean, ): StorylineListItem[] { const fromManifest = source.exportedStorylines; if (fromManifest && fromManifest.length > 0) { return fromManifest.map((entry) => { const selection: StorylineSelection = entry.kind === 'main' ? { kind: 'main' } : { kind: 'side', startGraphNodeId: entry.startGraphNodeId! }; const item: StorylineListItem = { selection, label: entry.label }; if (entry.kind === 'main' && targetHasMainStart) { item.disabled = true; item.disabledReason = 'main_exists'; } return item; }); } return listExportableStorylines(source, labels).map((item) => { if (item.selection.kind === 'main' && targetHasMainStart) { return { ...item, disabled: true, disabledReason: 'main_exists' }; } return item; }); } function recomputeOutgoing( nodes: SceneGraphNode[], edges: SceneGraphEdge[], ): Map> { const gnMap = new Map(nodes.map((n) => [n.id, n])); const outgoing = new Map>(); for (const e of edges) { 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); } return outgoing; } function applyConnectionSets(scenes: Record, outgoing: Map>): Record { const next: Record = { ...scenes }; for (const sid of Object.keys(next) as SceneId[]) { const prev = next[sid]; if (!prev) continue; const c = outgoing.get(sid); next[sid] = { ...prev, connections: c ? [...c] : [] }; } return next; } function selectionToExportedRef( project: Project, selection: StorylineSelection, labels: StorylineLabels, ): ExportedStorylineRef { if (selection.kind === 'main') { return { kind: 'main', startGraphNodeId: null, label: labels.main }; } const start = project.sceneGraphNodes.find((n) => n.id === selection.startGraphNodeId); return { kind: 'side', startGraphNodeId: selection.startGraphNodeId, label: start ? sideStoryDisplayLabel(start, project.scenes, labels.untitled) : labels.untitled, }; } export function collectSceneIdsForSelections( project: Project, selections: StorylineSelection[], ): SceneId[] { const nodeIds = collectSelectionsGraphNodeIds(project, selections); const sceneIds = new Set(); for (const gn of project.sceneGraphNodes) { if (nodeIds.has(gn.id)) sceneIds.add(gn.sceneId); } return [...sceneIds]; } export function buildPartialExportProject( source: Project, selections: StorylineSelection[], opts: { newProjectId: ProjectId; exportTitle: string; labels: StorylineLabels; }, ): Project { const nodeIds = collectSelectionsGraphNodeIds(source, selections); const sceneIds = new Set(collectSceneIdsForSelections(source, selections)); const sceneGraphNodes = source.sceneGraphNodes .filter((n) => nodeIds.has(n.id)) .map((n) => ({ ...n })); const sceneGraphEdges = source.sceneGraphEdges.filter( (e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId), ); const scenes: Record = {}; for (const sid of sceneIds) { const sc = source.scenes[sid]; if (sc) scenes[sid] = { ...sc }; } let draft: Project = { ...source, id: opts.newProjectId, meta: { ...source.meta, name: opts.exportTitle, updatedAt: new Date().toISOString(), }, scenes, sceneListOrder: reconcileSceneListOrder(scenes, source.sceneListOrder), sceneGraphNodes, sceneGraphEdges, currentSceneId: null, currentGraphNodeId: null, exportedStorylines: selections.map((s) => selectionToExportedRef(source, s, opts.labels)), }; const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges); draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) }; const assetIds = collectReferencedAssetIdsForProject(draft); const assets: Project['assets'] = {} as Project['assets']; for (const id of assetIds) { const a = source.assets[id]; if (a) assets[id] = { ...a }; } const mainStart = findMainStartNode(draft.sceneGraphNodes); const firstSide = listSideStoryStarts(draft.sceneGraphNodes)[0]; return { ...draft, assets, campaignAudios: source.campaignAudios.map((a) => ({ ...a })), materials: (source.materials ?? []).map((m) => ({ ...m })), npcs: (source.npcs ?? []).map((n) => ({ ...n })), npcRelations: (source.npcRelations ?? []).map((r) => ({ ...r })), currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null, currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null, }; } function collectReferencedAssetIdsForProject(p: Project): Set { const refs = new Set(); for (const sc of Object.values(p.scenes)) { if (sc.previewAssetId) refs.add(sc.previewAssetId); if (sc.previewThumbAssetId) refs.add(sc.previewThumbAssetId); for (const vid of sc.media.videos) refs.add(vid); for (const au of sc.media.audios) refs.add(au.assetId); } for (const au of p.campaignAudios) refs.add(au.assetId); for (const m of p.materials ?? []) refs.add(m.assetId); for (const n of p.npcs ?? []) refs.add(n.avatarAssetId); return refs; } export function findSceneTitleConflicts( target: Project, source: Project, sourceSceneIds: SceneId[], ): SceneTitleConflict[] { const targetByTitle = new Map(); for (const sc of Object.values(target.scenes)) { const key = sc.title.trim().toLowerCase(); if (!key) continue; const list = targetByTitle.get(key) ?? []; list.push({ sceneId: sc.id, title: sc.title }); targetByTitle.set(key, list); } const conflicts: SceneTitleConflict[] = []; const seen = new Set(); for (const sid of sourceSceneIds) { if (seen.has(sid)) continue; seen.add(sid); const src = source.scenes[sid]; if (!src) continue; const key = src.title.trim().toLowerCase(); if (!key) continue; const matches = targetByTitle.get(key); if (matches && matches.length > 0) { conflicts.push({ sourceSceneId: sid, sourceTitle: src.title, matches }); } } return conflicts; } function remapSceneAssetRefs(scene: Scene, assetMap: Map): Scene { const mapId = (id: AssetId | null): AssetId | null => (id ? (assetMap.get(id) ?? id) : null); return { ...scene, previewAssetId: mapId(scene.previewAssetId), previewThumbAssetId: mapId(scene.previewThumbAssetId), media: { videos: scene.media.videos.map((id) => assetMap.get(id) ?? id), audios: scene.media.audios.map((a) => ({ ...a, assetId: assetMap.get(a.assetId) ?? a.assetId, })), }, }; } function uniqueSideTitle(existing: Set, desired: string): string { const base = desired.trim() || 'Побочная'; if (!existing.has(base.toLowerCase())) { existing.add(base.toLowerCase()); return base; } let n = 2; while (existing.has(`${base} (${String(n)})`.toLowerCase())) n += 1; const next = `${base} (${String(n)})`; existing.add(next.toLowerCase()); return next; } export function mergeStorylinesIntoProject( target: Project, source: Project, selections: StorylineSelection[], sceneResolutions: SceneImportResolution[], opts: { graphOffsetX: number }, ): { project: Project; report: StorylineImportMergeReport; assetCopies: { fromId: AssetId; toId: AssetId }[] } { const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r])); const nodeIds = collectSelectionsGraphNodeIds(source, selections); const sourceSceneIds = collectSceneIdsForSelections(source, selections); const neededAssetIds = new Set(); for (const sid of sourceSceneIds) { const sc = source.scenes[sid]; if (!sc) continue; const res = resolutionBySource.get(sid); if (res?.mode === 'use') continue; if (sc.previewAssetId) neededAssetIds.add(sc.previewAssetId); if (sc.previewThumbAssetId) neededAssetIds.add(sc.previewThumbAssetId); for (const vid of sc.media.videos) neededAssetIds.add(vid); for (const au of sc.media.audios) neededAssetIds.add(au.assetId); } for (const au of source.campaignAudios) neededAssetIds.add(au.assetId); for (const m of source.materials ?? []) neededAssetIds.add(m.assetId); for (const n of source.npcs ?? []) neededAssetIds.add(n.avatarAssetId); const assetMap = new Map(); const targetSha = new Map(); for (const a of Object.values(target.assets)) { targetSha.set(a.sha256, a.id); } let assetsCopied = 0; let assetsReused = 0; const assetCopies: { fromId: AssetId; toId: AssetId }[] = []; for (const id of neededAssetIds) { const srcAsset = source.assets[id]; if (!srcAsset) continue; const existing = targetSha.get(srcAsset.sha256); if (existing) { assetMap.set(id, existing); assetsReused += 1; } else { const newId = asAssetId(`a_${generateId()}`); assetMap.set(id, newId); targetSha.set(srcAsset.sha256, newId); assetCopies.push({ fromId: id, toId: newId }); assetsCopied += 1; } } const sceneIdMap = new Map(); let scenesCreated = 0; let scenesReused = 0; const scenes: Record = { ...target.scenes }; for (const sid of sourceSceneIds) { const res = resolutionBySource.get(sid); const srcScene = source.scenes[sid]; if (!srcScene) continue; if (res?.mode === 'use') { sceneIdMap.set(sid, res.targetSceneId); scenesReused += 1; } else { const newId = asSceneId(`s_${generateId()}`); sceneIdMap.set(sid, newId); scenes[newId] = remapSceneAssetRefs({ ...srcScene, id: newId }, assetMap); scenesCreated += 1; } } const importedNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id)); const minX = importedNodes.length > 0 ? Math.min(...importedNodes.map((n) => n.x)) : 0; const xShift = opts.graphOffsetX - minX; const graphNodeIdMap = new Map(); for (const gn of importedNodes) { graphNodeIdMap.set(gn.id, asGraphNodeId(`gn_${generateId()}`)); } const existingSideTitles = new Set( target.sceneGraphNodes .filter((n) => n.isSideStoryStart) .map((n) => (n.sideStoryLineTitle.trim() || 'Побочная').toLowerCase()), ); const renamedSideTitles: string[] = []; const importMain = selections.some((s) => s.kind === 'main'); const targetHasMain = !!findMainStartNode(target.sceneGraphNodes); const newGraphNodes: SceneGraphNode[] = importedNodes.map((gn) => { const newId = graphNodeIdMap.get(gn.id)!; let sideStoryLineTitle = gn.sideStoryLineTitle; if (gn.isSideStoryStart) { const desired = sideStoryDisplayLabel(gn, source.scenes, 'Без названия'); const resolved = uniqueSideTitle(existingSideTitles, desired); if (resolved !== desired) renamedSideTitles.push(resolved); sideStoryLineTitle = resolved; } return { ...gn, id: newId, sceneId: sceneIdMap.get(gn.sceneId) ?? gn.sceneId, x: gn.x + xShift, y: gn.y, isStartScene: importMain && !targetHasMain ? gn.isStartScene : false, sideStoryLineTitle, }; }); const newEdges: SceneGraphEdge[] = source.sceneGraphEdges .filter((e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId)) .map((e) => ({ id: `e_${generateId()}`, sourceGraphNodeId: graphNodeIdMap.get(e.sourceGraphNodeId)!, targetGraphNodeId: graphNodeIdMap.get(e.targetGraphNodeId)!, })); const assets: Project['assets'] = { ...target.assets }; for (const [srcId, tgtId] of assetMap) { if (assets[tgtId]) continue; const srcAsset = source.assets[srcId]; if (srcAsset) { assets[tgtId] = { ...srcAsset, id: tgtId }; } } const campaignAudios = [...target.campaignAudios]; const campaignAssetIds = new Set(campaignAudios.map((a) => a.assetId)); for (const au of source.campaignAudios) { const mapped = assetMap.get(au.assetId) ?? au.assetId; if (!campaignAssetIds.has(mapped)) { campaignAudios.push({ ...au, assetId: mapped }); campaignAssetIds.add(mapped); } } const materials = [...(target.materials ?? [])]; const materialNameKeys = new Set(materials.map((m) => m.name.trim().toLowerCase())); const materialAssetIds = new Set(materials.map((m) => m.assetId)); for (const m of source.materials ?? []) { const mapped = assetMap.get(m.assetId) ?? m.assetId; if (materialAssetIds.has(mapped)) continue; let name = m.name.trim(); const baseKey = name.toLowerCase(); if (materialNameKeys.has(baseKey)) { let n = 2; while (materialNameKeys.has(`${baseKey} (${String(n)})`)) n += 1; name = `${name} (${String(n)})`; } materials.push({ id: asMaterialId(`mat_${generateId()}`), name, assetId: mapped, rotationDeg: m.rotationDeg ?? 0, }); materialNameKeys.add(name.toLowerCase()); materialAssetIds.add(mapped); } const npcs = [...(target.npcs ?? [])]; const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase())); const npcAssetIds = new Set(npcs.map((n) => n.avatarAssetId)); const npcIdMap = new Map(); for (const n of source.npcs ?? []) { const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId; const existingByAsset = npcs.find((x) => x.avatarAssetId === mappedAvatar); if (existingByAsset || npcAssetIds.has(mappedAvatar)) { if (existingByAsset) npcIdMap.set(n.id, existingByAsset.id); continue; } let name = n.name.trim(); const baseKey = name.toLowerCase(); if (npcNameKeys.has(baseKey)) { let i = 2; while (npcNameKeys.has(`${baseKey} (${String(i)})`)) i += 1; name = `${name} (${String(i)})`; } const newId = asNpcId(`npc_${generateId()}`); npcIdMap.set(n.id, newId); npcs.push({ id: newId, name, avatarAssetId: mappedAvatar, description: n.description ?? '', x: n.x, y: n.y, }); npcNameKeys.add(name.toLowerCase()); npcAssetIds.add(mappedAvatar); } const npcRelations = [...(target.npcRelations ?? [])]; for (const r of source.npcRelations ?? []) { const sourceId = npcIdMap.get(r.sourceNpcId) ?? r.sourceNpcId; const targetId = npcIdMap.get(r.targetNpcId) ?? r.targetNpcId; if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue; const label = r.label.trim(); if (!label) continue; const dup = npcRelations.some( (x) => x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId, ); if (dup) continue; npcRelations.push({ id: asNpcRelationId(`nrel_${generateId()}`), sourceNpcId: asNpcId(sourceId), targetNpcId: asNpcId(targetId), label, }); } let merged: Project = { ...target, scenes, assets, campaignAudios, materials, npcs, npcRelations, sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes], sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges], }; const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges); merged = { ...merged, scenes: applyConnectionSets(merged.scenes, outgoing), sceneListOrder: reconcileSceneListOrder(scenes, target.sceneListOrder), }; return { project: merged, report: { storylinesImported: selections.length, scenesCreated, scenesReused, graphNodesAdded: newGraphNodes.length, edgesAdded: newEdges.length, assetsCopied, assetsReused, renamedSideTitles, }, assetCopies, }; } export function computeGraphImportOffsetX(target: Project, padding = 120): number { const maxX = target.sceneGraphNodes.reduce((m, n) => Math.max(m, n.x), 0); return maxX + padding; } export function newExportBundleProjectId(): ProjectId { return asProjectId(`p_${generateId()}`); }