/**
* 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) =>
``,
)
.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