/** * Pure motion-overlay plan assembler. * * `buildMotionOverlayPlan` composes a `MotionOverlayPlan` (`schemaVersion: 1`) * from already-computed inputs: the input probe, the resolved style, the take * splits, each take's analysis, and each take's composed prompt. It performs NO * I/O and NO provider/ffmpeg/STT calls — those happen upstream (transcribe, * slice, analyze-reel, compose-prompt) and are passed in. This keeps the * manifest shape deterministic and unit-testable. * * The per-take `file` / `promptFile` paths are the work-folder-relative names * (`takes/…mp4`, `prompts/…md`) the `write.ts` emitter will produce; this module * only names them, it does not write them. */ import type { MotionLayout, MotionOverlayPlan, MotionStyleId, MotionTake, TakeAnalysis, TakeSplit, } from './types.js'; export interface BuildMotionOverlayPlanArgs { input: { path: string; durationSeconds: number; width: number; height: number; aspect: string }; layout: MotionLayout; style: MotionStyleId; /** Resolved accent (hex or already-resolved name) recorded on the manifest. */ accent: string; language: string; /** Work-folder path (absolute or relative; recorded verbatim). */ workdir: string; /** The take splits (one per output clip), in order. */ splits: TakeSplit[]; /** Per-take analysis, parallel to `splits` (keyed by index). */ analyses: TakeAnalysis[]; /** Per-take composed prompts, parallel to `splits` (keyed by index). */ prompts: string[]; } /** Build the work-folder-relative take clip filename for a take. */ export function takeFileName(split: TakeSplit): string { return `takes/${takeStem(split)}.mp4`; } /** Build the work-folder-relative prompt markdown filename for a take. */ export function takePromptFileName(split: TakeSplit): string { return `prompts/${takeStem(split)}.md`; } /** Shared stem, e.g. `take-01_0s-10s` (1-based index, whole-second bounds). */ export function takeStem(split: TakeSplit): string { const n = String(split.index + 1).padStart(2, '0'); const start = Math.round(split.start); const end = Math.round(split.end); return `take-${n}_${start}s-${end}s`; } /** * Assemble the `MotionOverlayPlan`. Pure. Throws if the analyses/prompts arrays * don't line up with the splits (a programmer error, surfaced loudly). */ export function buildMotionOverlayPlan(args: BuildMotionOverlayPlanArgs): MotionOverlayPlan { const { splits, analyses, prompts } = args; if (analyses.length !== splits.length) { throw new Error(`buildMotionOverlayPlan: ${splits.length} splits but ${analyses.length} analyses.`); } if (prompts.length !== splits.length) { throw new Error(`buildMotionOverlayPlan: ${splits.length} splits but ${prompts.length} prompts.`); } const takes: MotionTake[] = splits.map((split, i) => { const analysis = analyses[i]; if (!analysis || analysis.index !== split.index) { throw new Error(`buildMotionOverlayPlan: analysis ${i} does not match split index ${split.index}.`); } const prompt = prompts[i]; return { index: split.index, start: split.start, end: split.end, durationSeconds: round(split.end - split.start), file: takeFileName(split), promptFile: takePromptFileName(split), anatomy: analysis.anatomy, prompt, }; }); return { schemaVersion: 1, input: { path: args.input.path, durationSeconds: args.input.durationSeconds, width: args.input.width, height: args.input.height, aspect: args.input.aspect, }, layout: args.layout, style: args.style, accent: args.accent, language: args.language, workdir: args.workdir, takes, }; } function round(n: number): number { return Math.round(n * 1000) / 1000; }