/** * Motion-overlay review surface (Phase 3). * * Renders a finished `MotionOverlayPlan` into a standalone, self-contained * `review/review.html` approval surface — the **contract** the operator signs * off before any provider spend. Each take card shows: * - the take clip filename + reel beat (anatomy), * - the take's anatomy / span / duration, * - the FULL composed prompt that will be sent to Omni Flash V2V verbatim. * * It reuses the preview-portal's `PORTAL_CSS` (including its aspect-aware media * classes via `body[data-aspect]`) for visual consistency, but it does NOT go * through the portal's project-discovery `render.ts` — that surface is driven by * on-disk project assets, whereas motion-overlay renders straight from the * deterministic plan manifest. Pure string assembly except for the single * file write in `writeMotionOverlayReview`. */ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { PORTAL_CSS } from '../preview-portal/shared-assets.js'; import { ELEMENT_VOCABULARY } from './motion-style.js'; import type { MotionOverlayPlan, MotionTake } from './types.js'; export interface RenderMotionOverlayReviewResult { /** Absolute path to the emitted review.html. */ reviewPath: string; /** The full rendered HTML (returned for tests / callers that want it inline). */ html: string; } /** Human-friendly label for the reel-anatomy beat. */ const ANATOMY_LABELS: Record = { hook: 'Hook', contextualize: 'Contextualize', burst: 'Burst', breath: 'Breath', cta: 'CTA', }; /** * Render the plan into the review HTML string. Pure — no I/O. The aspect * (`plan.input.aspect`) drives `body[data-aspect]` so portrait/square clips get * tall cards via PORTAL_CSS's aspect-aware rules. */ export function renderMotionOverlayReview(plan: MotionOverlayPlan): string { const aspect = plan.input.aspect; const takeCards = plan.takes.map((take) => renderTakeCard(take)).join('\n'); const designSheet = renderDesignSheet(plan); const totalDuration = plan.takes.reduce((sum, t) => sum + t.durationSeconds, 0); return ` Motion-overlay review — ${esc(layoutTitle(plan))}
motion-overlay review · approve before spend

Motion-overlay plan

Review each take's clip and the exact prompt that will be sent to Omni Flash V2V. This is the contract — nothing renders until you approve.

${plan.takes.length}
Takes
${esc(plan.layout)}
Layout
${esc(plan.style)}
Style
${fmt(totalDuration)}s
Total
Input ${esc(plan.input.path)} Frame ${plan.input.width}×${plan.input.height} (${esc(aspect)}) Accent ${esc(plan.accent)} Language ${esc(plan.language)}
${designSheet}

Per-take contract

${plan.takes.length} take${plan.takes.length === 1 ? '' : 's'}
Each card is one Omni Flash V2V generation: the base clip, its reel beat, and the full composed prompt verbatim.
${takeCards}
`; } /** * Render the plan into `/review/review.html` and return its path + html. * Creates the `review/` dir if needed. The only side effect. */ export async function writeMotionOverlayReview( workdir: string, plan: MotionOverlayPlan, ): Promise { const html = renderMotionOverlayReview(plan); const reviewDir = join(workdir, 'review'); await mkdir(reviewDir, { recursive: true }); const reviewPath = join(reviewDir, 'review.html'); await writeFile(reviewPath, html, 'utf-8'); return { reviewPath, html }; } /** The named elements the design sheet shows, one specimen each. */ export const DESIGN_SHEET_ELEMENTS = ELEMENT_VOCABULARY; /** * Per-style CSS for the design-sheet specimens. Real font stacks, real weights: * (Font names are single-quoted: the stacks are emitted inside a double-quoted * `style="…"` attribute, and a double quote inside it truncates every custom * property after it — which rendered all three styles identically until a * browser screenshot caught it.) * the point of an HTML sheet over a PNG is that the type is seen RENDERED with * the fonts the local renderer will actually use, before any frame is spent. */ const SHEET_TYPE: Record = { 'apple-clean': { family: "'Helvetica Neue', Helvetica, Arial, sans-serif", label: "'Helvetica Neue', Helvetica, Arial, sans-serif", weight: 600, transform: 'none', tracking: '0', }, 'editorial-dark': { family: "'Arial Narrow', 'Helvetica Neue', Arial, sans-serif", label: "'Arial Narrow', Helvetica, Arial, sans-serif", weight: 800, transform: 'uppercase', tracking: '0.02em', }, 'knowledge-tool': { family: "Georgia, 'Times New Roman', serif", label: 'Menlo, Consolas, monospace', weight: 500, transform: 'none', tracking: '0', }, }; /** * DESIGN SHEET — approved before render. One specimen per named element in the * resolved style and accent, plus the approval row. Sits above the takes so the * look is signed off before the per-take prompts are read. */ function renderDesignSheet(plan: MotionOverlayPlan): string { const t = SHEET_TYPE[plan.style] ?? SHEET_TYPE['apple-clean']; const accent = escAttr(plan.accent); const sample = (el: string): string => { switch (el) { case 'headline type': return `
Invented*
`; case 'outline type': return `
Never recorded
`; case 'pill': return `Cloned voice`; case 'strike': return `
Doesn't exist
`; case 'brackets': return `
This angle
`; case 'badge': return `
Confirmed
`; case 'underline': return `
The whole word
`; default: return `${esc(el)}`; } }; const cards = DESIGN_SHEET_ELEMENTS.map( (el) => `
${esc(el)}
${sample(el)}
`, ).join('\n'); return `

Design sheet

${esc(plan.style)} · accent ${esc(plan.accent)}
One specimen per named element, set in the real font stacks the local renderer uses. Approve the design sheet before any take renders; a wrong font here is a wrong font in every frame.
${cards}
`; } /** Render one take card: clip filename, beat, anatomy/span, and full prompt. */ function renderTakeCard(take: MotionTake): string { const beat = ANATOMY_LABELS[take.anatomy] ?? take.anatomy; const clipName = baseName(take.file); return `
TAKE ${String(take.index + 1).padStart(2, '0')} ${esc(beat)}
${esc(clipName)}
Beat
${esc(beat)}
Span
${fmt(take.start)}s → ${fmt(take.end)}s
Duration
${fmt(take.durationSeconds)}s
Clip
${esc(take.file)}
Composed prompt (the contract)
${esc(take.prompt)}
`; } /** A short title line summarizing the plan's layout + style. */ function layoutTitle(plan: MotionOverlayPlan): string { return `${plan.layout} / ${plan.style}`; } function baseName(p: string): string { const i = p.lastIndexOf('/'); return i >= 0 ? p.slice(i + 1) : p; } function fmt(seconds: number): string { return (Math.round(seconds * 10) / 10).toFixed(1); } function esc(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function escAttr(value: string): string { return esc(value).replaceAll("'", '''); } /** * Motion-overlay-specific styling layered on top of PORTAL_CSS. Cards are * aspect-aware: the clip