/** * Flow hand-off pack + restitch — the productised "manual Omni" loop. * * The source skill's real pipeline is: *write prompts → upload each take + paste * its prompt into Google Flow (Omni) by hand → download → stitch*. The Omni step * was never automated (the skill marks it "under construction") and Flow's current * web UI does not accept video uploads through the API or the Agent editor — so the * hand-off stays manual. This module turns that manual loop into first-class * artifacts instead of throwaway shell scripts: * * - `emitFlowPack(plan, outDir)` — write a drop-in folder: each take's clip + a * clean, ready-to-paste prompt `.txt` (the prompt body extracted from the * `prompts/take-NN.md` the planner already wrote) + a README of the workflow. * - `restitchFlowOutputs(plan, flowOutDir, outputPath)` — once the operator drops * the animated takes back, re-mux each with its ORIGINAL audio (CRITICAL RULE 1) * and concatenate into the finished reel. * * Pure builders (prompt extraction, README, output pairing, concat args) are * unit-tested; the fs copy + ffmpeg runs are injectable seams, so the whole module * is exercised offline with no ffmpeg. */ import { mkdir, copyFile as fsCopyFile, writeFile as fsWriteFile, readFile as fsReadFile, readdir } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { buildAudioRestoreArgs } from './execute.js'; import type { MotionOverlayPlan, MotionTake } from './types.js'; /** Pure: pull the prompt body out of a `prompts/take-NN.md` — the text between the * first pair of ``` fences (the ready-to-paste Omni prompt). Falls back to the * whole doc if no fence is present. */ export function extractPromptBlock(md: string): string { const m = md.match(/```[^\n]*\n([\s\S]*?)\n```/); return (m ? m[1] : md).trim(); } /** One pack entry: a take's clip + its prompt file (basenames, inside the pack). */ export interface FlowPackEntry { index: number; clip: string; prompt: string; span: string; } /** Pure: the README walking the operator through the Flow web hand-off. */ export function flowPackReadme(args: { entries: FlowPackEntry[]; style: string; layout: string }): string { const rows = args.entries .map((e) => `| \`${e.clip}\` | \`${e.prompt}\` | ${e.span} |`) .join('\n'); return `# Flow hand-off pack Google Flow's API blocks the V2V "add overlays to my video" edit, and its web editor won't accept a video upload — so the Omni step is **manual** (exactly how the source skill's author does it). The prompts are already written; you upload + paste. Style: **${args.style}** · Layout: **${args.layout}** ## Takes (one prompt per ≤10s clip) | Clip to upload | Prompt to paste | Span | |---|---|---| ${rows} ## Steps (per take) 1. Open Google Flow and **upload** \`take-NN.mp4\`. 2. **Paste** the entire contents of \`take-NN.txt\` as the prompt. 3. Generate → **download** the result into a \`done/\` folder here, keeping the take order (e.g. \`done/take-01_out.mp4\`). The prompt preserves your original audio and renders only the quoted on-screen text. ## Then restitch \`\`\` vclaw video motion-overlay --output-dir --restitch \`\`\` That re-muxes each animated take with its **original audio** and concatenates the finished reel (\`motion-overlay-reel.mp4\`). `; } /** Injectable fs seam for the pack writer (tests pass fakes). */ export interface EmitFlowPackDeps { copyFile?: (src: string, dst: string) => Promise; readFile?: (p: string) => Promise; writeFile?: (p: string, data: string) => Promise; } export interface FlowPackResult { packDir: string; entries: FlowPackEntry[]; } /** * Emit the hand-off pack into `outDir`: copy each take clip, write its prompt as a * clean `.txt`, and a README. Reads the take + prompt files the planner already * wrote under `plan.workdir`. Pure control flow over injectable fs. */ export async function emitFlowPack( plan: MotionOverlayPlan, outDir: string, deps: EmitFlowPackDeps = {}, ): Promise { const copyFile = deps.copyFile ?? fsCopyFile; const readFile = deps.readFile ?? ((p: string) => fsReadFile(p, 'utf-8')); const writeFile = deps.writeFile ?? ((p: string, d: string) => fsWriteFile(p, d, 'utf-8')); await mkdir(outDir, { recursive: true }); const ordered = [...plan.takes].sort((a, b) => a.index - b.index); const entries: FlowPackEntry[] = []; for (const take of ordered) { const clipName = basename(take.file); const stem = clipName.replace(/\.mp4$/i, ''); const promptName = `${stem}.txt`; await copyFile(join(plan.workdir, take.file), join(outDir, clipName)); const md = await readFile(join(plan.workdir, take.promptFile)); await writeFile(join(outDir, promptName), extractPromptBlock(md)); entries.push({ index: take.index, clip: clipName, prompt: promptName, span: `${fmtT(take.start)}–${fmtT(take.end)}s` }); } await writeFile(join(outDir, 'README.md'), flowPackReadme({ entries, style: plan.style, layout: plan.layout })); return { packDir: outDir, entries }; } function fmtT(s: number): string { return Number.isInteger(s) ? String(s) : s.toFixed(1); } /** * Pure: pair each take (in order) with its returned animated clip. Prefers a file * whose name contains the take's `take-0N` token; otherwise falls back to sorted * positional order. Returns `null` for a take with no matching output. */ export function pairFlowOutputs(takes: MotionTake[], files: string[]): Array<{ take: MotionTake; file: string | null }> { const ordered = [...takes].sort((a, b) => a.index - b.index); const sortedFiles = [...files].sort(); const used = new Set(); const result = ordered.map((take) => { const token = `take-${String(take.index + 1).padStart(2, '0')}`; const hit = sortedFiles.find((f) => !used.has(f) && basename(f).toLowerCase().includes(token)); if (hit) used.add(hit); return { take, file: hit ?? null }; }); // Second pass: fill any unmatched take from the remaining files in sorted order. const remaining = sortedFiles.filter((f) => !used.has(f)); let r = 0; for (const entry of result) { if (!entry.file && r < remaining.length) { entry.file = remaining[r++]; } } return result; } /** * Pure: ffmpeg concat-filter args joining N restored clips into one reel (re-encode; * robust across heterogeneous inputs). `-y` is prepended by the runner. */ export function buildConcatArgs(clips: string[], outputPath: string): string[] { const inputs = clips.flatMap((c) => ['-i', c]); const refs = clips.map((_, i) => `[${i}:v][${i}:a]`).join(''); const filter = `${refs}concat=n=${clips.length}:v=1:a=1[v][a]`; return [ ...inputs, '-filter_complex', filter, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-crf', '18', '-preset', 'fast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', outputPath, ]; } /** Injectable ffmpeg seam for restitch. */ export type FfmpegRunner = (args: string[]) => Promise; export interface RestitchResult { outputPath: string; takeCount: number; restored: string[]; } /** * Restitch: for each take, re-mux the operator's downloaded animated clip with the * take's ORIGINAL audio (`buildAudioRestoreArgs`), then concat into the final reel. * Throws if a take has no matching animated output. */ export async function restitchFlowOutputs( plan: MotionOverlayPlan, flowOutDir: string, outputPath: string, deps: { ffmpeg: FfmpegRunner; listDir?: (d: string) => Promise }, ): Promise { const listDir = deps.listDir ?? (async (d: string) => (await readdir(d)).filter((n) => n.endsWith('.mp4')).map((n) => join(d, n))); const files = await listDir(flowOutDir); const pairs = pairFlowOutputs(plan.takes, files); const missing = pairs.filter((p) => !p.file).map((p) => p.take.index + 1); if (missing.length) { throw new Error(`restitch: no animated output found for take(s) ${missing.join(', ')} in ${flowOutDir}`); } const restoredDir = join(plan.workdir, 'restored'); await mkdir(restoredDir, { recursive: true }); const restored: string[] = []; for (const { take, file } of pairs) { const out = join(restoredDir, basename(take.file)); await deps.ffmpeg(buildAudioRestoreArgs(file as string, join(plan.workdir, take.file), out)); restored.push(out); } await mkdir(join(outputPath, '..'), { recursive: true }).catch(() => {}); await deps.ffmpeg(buildConcatArgs(restored, outputPath)); return { outputPath, takeCount: restored.length, restored }; }