/** * 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 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 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)}

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 }; } /** 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