import { existsSync } from 'node:fs'; import { readdir, readFile, stat } from 'node:fs/promises'; import { basename, isAbsolute, join, relative, resolve } from 'node:path'; import { readBrandDefinition } from '../brand-definition.js'; import { probeMedia } from '../final-media.js'; import { readImageDimensions } from '../image-dimensions.js'; import { PREVIEW_PORTAL_TEMPLATE_REGISTRY } from './templates.js'; import type { PreviewPortalAsset, PreviewPortalBrand, PreviewPortalCastMember, PreviewPortalBrief, PreviewPortalCard, PreviewPortalProject, PreviewPortalReferenceSlot, PreviewPortalSoundtrack, PreviewPortalSoundtrackCandidate, PreviewPortalStatus, PreviewPortalStoryboard, PreviewPortalTemplateId, PreviewPortalVoiceClone, } from './types.js'; interface ScenePacketInfo { renderPrompt?: string; referenceSlots: PreviewPortalReferenceSlot[]; } export interface DiscoverPreviewPortalProjectOptions { root: string; projectSlug: string; runId?: string; client?: string | null; template?: PreviewPortalTemplateId; status?: PreviewPortalStatus; /** * Also discover the live-run state (per-generation status + diff-vs-contract * alarm + event log + show-bible nav + spend) onto `project.runState`. Off by * default because it is the heavier path (cost estimate, telemetry, contract * diff) — only the `run` surface reads it; review/preview/compare skip it. */ withRunState?: boolean; } export interface DiscoverPreviewPortalPortfolioOptions { root: string; client?: string | null; limit?: number; } export async function discoverPreviewPortalProject( options: DiscoverPreviewPortalProjectOptions, ): Promise { const projectDir = join(options.root, 'projects', options.projectSlug); const manifest = await readProjectJson(projectDir); const template = options.template ?? templateFromManifest(manifest) ?? 'generic-video'; const now = new Date().toISOString(); const assets = await discoverAssets(projectDir); const status = options.status ?? 'draft'; const summary = stringValue(manifest?.summary) ?? stringValue(manifest?.intent); const client = options.client ?? clientFromManifest(manifest); const soundtrack = await discoverSoundtrack(projectDir, manifest, assets); const brief = await discoverBrief(projectDir); const storyboard = await discoverStoryboard(projectDir); const brand = await discoverBrand(options.root, options.projectSlug, projectDir); const cast = await discoverCast(projectDir, assets); const voiceClones = await discoverVoiceClones(projectDir); const project: PreviewPortalProject = { client, slug: options.projectSlug, title: stringValue(manifest?.title) ?? titleFromSlug(options.projectSlug), template, status, projectDir, run: { runId: options.runId ?? 'run-001', label: options.runId ?? 'run-001', status, createdAt: now, updatedAt: now, publishedAt: null, approvedAt: null, declinedAt: null, }, ...(summary ? { summary } : {}), ...(brand ? { brand } : {}), ...(cast ? { cast } : {}), ...(brief ? { brief } : {}), ...(storyboard ? { storyboard } : {}), assets, cards: buildCards(assets), ...(soundtrack ? { soundtrack } : {}), ...(voiceClones && voiceClones.length ? { voiceClones } : {}), }; if (options.withRunState) { const { discoverRunState } = await import('./run-discovery.js'); project.runState = await discoverRunState(options.root, options.projectSlug, projectDir, project); } return project; } /** * Read `artifacts/voice-clones.json` (the cloned-voice references — the * black-frame-with-audio trick) into the per-character voice map the per-scene * review contract renders. Mirrors `discoverSoundtrack`'s graceful absence * handling: returns undefined when the artifact is absent or unparseable, so a * project without it renders byte-identically to today. The playable `src` is * the durable hosted URL when present, else the local clip path; the label is * built from name/character/description. */ async function discoverVoiceClones( projectDir: string, ): Promise { const path = join(projectDir, 'artifacts', 'voice-clones.json'); if (!existsSync(path)) return undefined; let parsed: { voices?: Array<{ name?: unknown; character?: unknown; clipPath?: unknown; hostedUrl?: unknown; description?: unknown; durationMs?: unknown; }>; }; try { parsed = JSON.parse(await readFile(path, 'utf-8')); } catch { return undefined; } if (!Array.isArray(parsed.voices)) return undefined; const out: PreviewPortalVoiceClone[] = []; for (const voice of parsed.voices) { const character = stringValue(voice?.character); const src = stringValue(voice?.hostedUrl) ?? stringValue(voice?.clipPath); if (!character || !src) continue; // a voice with no cast character can't bind to a scene const name = stringValue(voice?.name); const description = stringValue(voice?.description); const labelParts = [name ?? character]; if (name && name.toLowerCase() !== character.toLowerCase()) labelParts.push(character); if (description) labelParts.push(description); out.push({ character, label: labelParts.join(' · '), src, ...(typeof voice?.durationMs === 'number' && voice.durationMs > 0 ? { durationSeconds: Math.round(voice.durationMs / 100) / 10 } : {}), }); } return out.length ? out : undefined; } /** * Surface the production's locked cast — registered Flow Characters * (`artifacts/flow-characters.json`) and reference characters * (`characters/characters.json`) — so the portal can show "who is identity-locked * across every shot". Each member is matched, best-effort, to a still already * discovered in the project (by name slug) so a face renders where one exists; * otherwise the Cast card falls back to a monogram. Returns undefined when no * cast is registered (the section then does not render). */ async function discoverCast( projectDir: string, assets: PreviewPortalAsset[], ): Promise { const members: PreviewPortalCastMember[] = []; const seen = new Set(); const add = (name: string, source: PreviewPortalCastMember['source'], ref?: string) => { const trimmed = name.trim(); if (!trimmed || seen.has(trimmed.toLowerCase())) return; seen.add(trimmed.toLowerCase()); const stillPath = matchCastStill(trimmed, assets); members.push({ name: trimmed, source, ...(ref ? { ref } : {}), ...(stillPath ? { stillPath } : {}), ...(/master/i.test(trimmed) ? { role: 'product master' } : {}), }); }; const flowPath = join(projectDir, 'artifacts', 'flow-characters.json'); if (existsSync(flowPath)) { try { const parsed = JSON.parse(await readFile(flowPath, 'utf-8')) as { characters?: Array<{ name?: unknown; entityId?: unknown }>; }; for (const c of parsed.characters ?? []) { if (typeof c?.name === 'string') { add(c.name, 'flow-character', typeof c.entityId === 'string' ? c.entityId : undefined); } } } catch { // malformed flow-characters.json — skip the Flow cast, still try characters.json } } const charPath = join(projectDir, 'characters', 'characters.json'); if (existsSync(charPath)) { try { const parsed = JSON.parse(await readFile(charPath, 'utf-8')) as { characters?: Array<{ name?: unknown }>; }; for (const c of parsed.characters ?? []) { if (typeof c?.name === 'string') add(c.name, 'character'); } } catch { // doctor reports malformed characters.json separately } } return members.length ? members : undefined; } /** Best-effort still match: a discovered image whose path slug contains the * character's name slug. Searches the cast-likely sections only (characters, * images, keyframes). Returns undefined when nothing matches (monogram card). */ function matchCastStill(name: string, assets: PreviewPortalAsset[]): string | undefined { const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, ''); if (!slug) return undefined; const hit = assets.find( (asset) => asset.kind === 'image' && (asset.section === 'characters' || asset.section === 'images' || asset.section === 'keyframes') && asset.path.toLowerCase().replace(/[^a-z0-9]+/g, '').includes(slug), ); return hit?.path; } /** * Surface the client's locked brand system for portal theming: the * brand-definition artifact (via the canonical reader) plus a probed logo at * assets/brand/logo.*. Returns undefined when the artifact is absent or * malformed — the portal then renders its stock theme, visually unchanged. */ async function discoverBrand( root: string, projectSlug: string, projectDir: string, ): Promise { let artifact; try { artifact = await readBrandDefinition(root, projectSlug); } catch { return undefined; } if (!artifact || typeof artifact.brandName !== 'string' || !artifact.brandName.trim()) return undefined; const palette = Object.entries(artifact.palette ?? {}) .filter(([, color]) => color && typeof color.hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(color.hex)) .map(([role, color]) => ({ role, hex: color.hex, ...(color.name ? { name: color.name } : {}) })); const typography = (Array.isArray(artifact.typography) ? artifact.typography : []) .filter((t) => t && typeof t.level === 'string' && typeof t.font === 'string') .map((t) => ({ level: t.level, font: t.font, weight: typeof t.weight === 'string' ? t.weight : '' })); let logoPath: string | undefined; for (const ext of ['svg', 'png', 'webp', 'jpg', 'jpeg']) { const candidate = join(projectDir, 'assets', 'brand', `logo.${ext}`); if (existsSync(candidate)) { logoPath = `assets/brand/logo.${ext}`; break; } } const taglines = artifact.taglines && typeof artifact.taglines === 'object' ? { ...(artifact.taglines.functional ? { functional: artifact.taglines.functional } : {}), ...(artifact.taglines.emotional ? { emotional: artifact.taglines.emotional } : {}), ...(artifact.taglines.community ? { community: artifact.taglines.community } : {}), } : undefined; return { brandName: artifact.brandName.trim(), ...(typeof artifact.positioning === 'string' && artifact.positioning.trim() ? { positioning: artifact.positioning.trim() } : {}), ...(taglines && Object.keys(taglines).length ? { taglines } : {}), palette, typography, ...(logoPath ? { logoPath } : {}), }; } /** Read the brief artifact subset rendered in the preview header. Returns * undefined when absent or malformed (the portal index skips broken projects). */ async function discoverBrief(projectDir: string): Promise { const path = join(projectDir, 'artifacts', 'brief.json'); if (!existsSync(path)) return undefined; try { const brief = JSON.parse(await readFile(path, 'utf-8')) as { title?: unknown; intent?: unknown; metadata?: unknown }; if (typeof brief.title !== 'string' || typeof brief.intent !== 'string') return undefined; return { title: brief.title, intent: brief.intent, ...(brief.metadata && typeof brief.metadata === 'object' ? { metadata: brief.metadata as PreviewPortalBrief['metadata'] } : {}), }; } catch { return undefined; } } /** Read the storyboard scenes rendered as scene cards. Returns undefined when * absent or malformed. */ async function discoverStoryboard(projectDir: string): Promise { const path = join(projectDir, 'artifacts', 'storyboard.json'); if (!existsSync(path)) return undefined; // The resolved per-scene submit text and reference slots live in // filmmaking-prompts.json (seedancePackets[].promptText / .references). Pair // them onto each scene by sceneIndex so the preview can paint the exact // provider input and the identity-lock status before any render spend. const packets = await discoverScenePackets(projectDir); try { const parsed = JSON.parse(await readFile(path, 'utf-8')) as { scenes?: unknown }; if (!Array.isArray(parsed.scenes)) return undefined; const scenes = parsed.scenes .filter((scene): scene is Record => !!scene && typeof scene === 'object') .map((scene, index) => { const sceneIndex = typeof scene.sceneIndex === 'number' ? scene.sceneIndex : index; const packet = packets.get(sceneIndex); // The rendered per-scene clip lives at `outputs/scene-.mp4` // (0-based). Carry a project-relative `clipPath` so the storyboard card // can offer an inline Image/Video toggle; absent when not yet rendered. const clipRel = `outputs/scene-${sceneIndex}.mp4`; const clipExists = existsSync(join(projectDir, clipRel)); return { sceneIndex, description: typeof scene.description === 'string' ? scene.description : '', ...(Array.isArray(scene.characters) ? { characters: scene.characters.filter((c): c is string => typeof c === 'string') } : {}), ...(scene.scenePrompt && typeof scene.scenePrompt === 'object' ? { scenePrompt: scene.scenePrompt as PreviewPortalStoryboard['scenes'][number]['scenePrompt'] } : {}), ...(packet?.renderPrompt ? { renderPrompt: packet.renderPrompt } : {}), ...(packet && packet.referenceSlots.length ? { referenceSlots: packet.referenceSlots } : {}), ...(typeof scene.colorState === 'string' && scene.colorState ? { colorState: scene.colorState } : {}), ...(typeof scene.dialogue === 'string' ? { dialogue: scene.dialogue } : {}), ...(typeof scene.durationSeconds === 'number' ? { durationSeconds: scene.durationSeconds } : {}), ...(clipExists ? { clipPath: clipRel } : {}), }; }); return { scenes }; } catch { return undefined; } } /** * Read `artifacts/filmmaking-prompts.json` and return a sceneIndex → packet-info * map carrying the resolved 10-block submit prompt (`seedancePackets[].promptText`) * and the scene's reference slots (`seedancePackets[].references`). The submit * prompt is the convergence point where the multi-shot plan, the Joey * cinematography registers, and the story-bible continuity all land; the * reference slots are the identity-lock contract (which avatar locks which scene, * ready vs pending). Character-sheet slots are enriched with the bound `Asset://` * URI from `seedance-assets.json` when registered. Returns an empty map when the * artifact is absent or malformed (the contract block falls back gracefully). */ async function discoverScenePackets(projectDir: string): Promise> { const out = new Map(); const path = join(projectDir, 'artifacts', 'filmmaking-prompts.json'); if (!existsSync(path)) return out; const assetUris = await loadSeedanceAssetMap(projectDir); try { const parsed = JSON.parse(await readFile(path, 'utf-8')) as { seedancePackets?: Array<{ sceneIndex?: unknown; promptText?: unknown; references?: unknown }>; }; for (const packet of parsed.seedancePackets ?? []) { if (typeof packet?.sceneIndex !== 'number' || out.has(packet.sceneIndex)) continue; const renderPrompt = typeof packet.promptText === 'string' && packet.promptText.trim() ? packet.promptText : undefined; const referenceSlots: PreviewPortalReferenceSlot[] = []; for (const ref of Array.isArray(packet.references) ? packet.references : []) { if (!ref || typeof ref !== 'object') continue; const slot = ref as Record; if (typeof slot.slot !== 'string' || typeof slot.role !== 'string') continue; const characterName = typeof slot.characterName === 'string' ? slot.characterName : undefined; const assetUri = characterName ? assetUris.get(characterName) : undefined; referenceSlots.push({ slot: slot.slot, role: slot.role, label: typeof slot.label === 'string' ? slot.label : slot.slot, status: slot.status === 'ready' ? 'ready' : 'pending', ...(typeof slot.path === 'string' ? { path: slot.path } : {}), ...(characterName ? { characterName } : {}), ...(assetUri ? { assetUri } : {}), }); } out.set(packet.sceneIndex, { ...(renderPrompt ? { renderPrompt } : {}), referenceSlots }); } } catch { return out; } return out; } /** * Read `artifacts/seedance-assets.json` (the registered Asset Library avatars) * into a character-name → `Asset://` URI map. Prefers the international-profile * URI when present (the one Seedance moderation accepts globally). Returns an * empty map when the artifact is absent — identity slots then render as a bound * character name without an Asset URI (i.e. "pending registration"). */ async function loadSeedanceAssetMap(projectDir: string): Promise> { const out = new Map(); const path = join(projectDir, 'artifacts', 'seedance-assets.json'); if (!existsSync(path)) return out; try { const parsed = JSON.parse(await readFile(path, 'utf-8')) as { assets?: Array<{ name?: unknown; assetUri?: unknown; intlAssetUri?: unknown }>; }; for (const asset of parsed.assets ?? []) { if (typeof asset?.name !== 'string') continue; const uri = typeof asset.intlAssetUri === 'string' && asset.intlAssetUri.trim() ? asset.intlAssetUri : typeof asset.assetUri === 'string' && asset.assetUri.trim() ? asset.assetUri : undefined; if (uri) out.set(asset.name, uri); } } catch { return out; } return out; } /** * Discover the project soundtrack/score for the polished preview showcase. * Preference order: an explicit `soundtrack`/`audio` manifest field that points * at an existing project-local audio file, otherwise the first discovered audio * asset. Returns undefined when no soundtrack exists (so the preview renders no * `