/** * The scene contract — the EXACT submit payload (prompt, reference slots, * voice clones, JSON) the review surfaces show before spend. "Preview is the * contract": this output is the operator's review contract, so it is kept * byte-identical across moves. Extracted verbatim from `render.ts` (roadmap * Phase 3d). */ import type { PreviewPortalAsset, PreviewPortalProject, PreviewPortalReferenceSlot, PreviewPortalScene, PreviewPortalVoiceClone } from './types.js'; import { aspectForProject, esc, escAttr } from './render-primitives.js'; /** * The per-scene "contract": the exact prompt text that will be submitted to the * provider plus the identity-lock reference sheets for the scene's characters. * This turns each storyboard card from a thumbnail-and-caption gallery into a * "what you will actually get" review surface — the operator sees the submit * prompt and the locked references inline, before any spend. Rendered expanded * (no click-to-reveal) so the contract is always visible. Returns an empty * string when a scene carries neither a prompt nor a matchable reference. */ export function renderSceneContract(scene: PreviewPortalScene, project: PreviewPortalProject): string { const prompt = scene.scenePrompt; const rows: string[] = []; // The resolved provider submit text (13-block packet) is the authoritative // contract; show it first and in full when present. if (scene.renderPrompt) rows.push(promptRow('Submit', scene.renderPrompt)); if (prompt?.imagePrompt) rows.push(promptRow('Image', prompt.imagePrompt)); if (prompt?.animationPrompt) rows.push(promptRow('Motion', prompt.animationPrompt)); if (prompt?.styleFooter) rows.push(promptRow('Style', prompt.styleFooter)); const promptBlock = rows.length ? `
Submit prompt · what the model receives
${rows.join('')}
` : ''; // Identity-lock references: the character reference sheets discovered for the // names this scene casts. A character image can surface either from the // characters.json appender (label " reference") or the generic dir scan // (filename-based label/path), so match the slugified character name against // both the label and the path. De-duped by path so a character listed twice // does not double-render. const wantNames = (scene.characters ?? []).map(contractSlug).filter(Boolean); const seen = new Set(); const refs = project.assets.filter((asset) => { if (asset.kind !== 'image' || asset.section !== 'characters' || seen.has(asset.path)) return false; const haystacks = [contractSlug(asset.label), contractSlug(asset.path)]; if (!wantNames.some((name) => haystacks.some((hay) => hay.includes(name)))) return false; seen.add(asset.path); return true; }); // Reference-slot contract: which slot locks this scene, ready vs pending, and // the bound Asset:// URI for identity. Falls back to the discovered character // thumbnails when no resolved slots exist yet. const slotRows = (scene.referenceSlots ?? []).map(renderReferenceSlot).join(''); const thumbs = refs.length ? `
${refs .map( (ref) => `${escAttr(ref.label)}`, ) .join('')}
` : ''; const refBlock = slotRows || thumbs ? `
References · identity lock
${slotRows ? `
${slotRows}
` : ''}${thumbs}
` : ''; // Voice design: the cloned-voice references bound to this scene's cast. Each // cast character with a matching voice clone gets a labeled row carrying a // videoAssetId/videoAssetId2-style pill AND a playable