import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { extname, isAbsolute, relative, resolve } from 'node:path'; import type { VideoProjectWorkspace } from './workspace.js'; export interface CharacterProfile { id: string; name: string; goBananasId?: number; description?: string; /** * Locked wardrobe/clothing for this character (e.g. "crimson-red dhoti, * rudraksha bead necklace, top-knot"). When present, `buildExecutionPayload` * appends a deterministic "Keep in exactly." clause to every * scene that lists this character — structurally pinning the costume so it * cannot drift colour/wardrobe between scenes. Optional; absent = no clause. */ costume?: string; referenceAssets: string[]; notes?: string[]; createdAt: string; updatedAt: string; } export interface CharacterProfileStore { characters: CharacterProfile[]; } async function readStore(workspace: VideoProjectWorkspace): Promise { if (!existsSync(workspace.charactersPath)) { return { characters: [] }; } return JSON.parse(await readFile(workspace.charactersPath, 'utf-8')) as CharacterProfileStore; } async function writeStore(workspace: VideoProjectWorkspace, store: CharacterProfileStore): Promise { await writeFile(workspace.charactersPath, `${JSON.stringify(store, null, 2)}\n`); } /** Derive the stable character id from a display name (exported so callers can * compute the id before persisting — e.g. to name ingested reference files). */ export function characterIdFromName(value: string): string { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } const URI_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i; export interface IngestCharacterReferenceResult { relativePath: string; ingested: boolean; missing: boolean; } /** * Normalize a character reference so it is portable and discoverable by the * preview portal (which only renders project-relative paths). Behaviour: * - URI refs (`gobananas://`, `http(s)://`) are returned verbatim (not files). * - A ref that does not exist on disk is returned verbatim with `missing:true` * (callers surface a warning; we never throw — the project stays usable, and * this keeps pure-unit expectations that pass placeholder paths intact). * - A ref already inside the project is stored project-relative as-is. * - A ref outside the project is COPIED into `characters/-.` so * the project is self-contained, and the new relative path is returned. */ export async function ingestCharacterReference( workspace: VideoProjectWorkspace, characterId: string, srcPath: string, index: number, ): Promise { if (URI_SCHEME_RE.test(srcPath)) { return { relativePath: srcPath, ingested: false, missing: false }; } const abs = isAbsolute(srcPath) ? resolve(srcPath) : resolve(workspace.projectDir, srcPath); if (!existsSync(abs)) { return { relativePath: srcPath, ingested: false, missing: true }; } const relToProject = relative(workspace.projectDir, abs); if (relToProject && !relToProject.startsWith('..') && !isAbsolute(relToProject)) { // Already inside the project — portable and discoverable as-is. return { relativePath: relToProject.replaceAll('\\', '/'), ingested: false, missing: false }; } // Outside the project — copy into characters/ so the project is self-contained. await mkdir(workspace.charactersDir, { recursive: true }); const destName = `${characterId}-${index}${extname(abs).toLowerCase() || '.png'}`; const dest = resolve(workspace.charactersDir, destName); await copyFile(abs, dest); return { relativePath: relative(workspace.projectDir, dest).replaceAll('\\', '/'), ingested: true, missing: false, }; } export async function addCharacterProfile( workspace: VideoProjectWorkspace, input: { name: string; goBananasId?: number; description?: string; costume?: string; referenceAssets?: string[]; notes?: string[]; }, ): Promise { const store = await readStore(workspace); const now = new Date().toISOString(); const id = characterIdFromName(input.name); const existing = store.characters.find((character) => character.id === id); const character: CharacterProfile = existing ? { ...existing, ...(input.goBananasId !== undefined ? { goBananasId: input.goBananasId } : {}), description: input.description ?? existing.description, costume: input.costume ?? existing.costume, referenceAssets: input.referenceAssets ?? existing.referenceAssets, notes: input.notes ?? existing.notes, updatedAt: now, } : { id, name: input.name, ...(input.goBananasId !== undefined ? { goBananasId: input.goBananasId } : {}), ...(input.description ? { description: input.description } : {}), ...(input.costume ? { costume: input.costume } : {}), referenceAssets: input.referenceAssets ?? [], ...(input.notes ? { notes: input.notes } : {}), createdAt: now, updatedAt: now, }; const nextCharacters = store.characters.filter((candidate) => candidate.id !== id); nextCharacters.push(character); nextCharacters.sort((left, right) => left.name.localeCompare(right.name)); await writeStore(workspace, { characters: nextCharacters }); return character; } export async function listCharacterProfiles( workspace: VideoProjectWorkspace, ): Promise { return (await readStore(workspace)).characters; } export async function readCharacterProfile( workspace: VideoProjectWorkspace, idOrName: string, ): Promise { const normalized = characterIdFromName(idOrName); const store = await readStore(workspace); return store.characters.find((character) => character.id === normalized || characterIdFromName(character.name) === normalized) ?? null; }