/** * Mograph execute — the first-class live render path for a motion pack, on the * operator's Omni transport (`veo-useapi` omni-flash). * * Shape mirrors the repo's other resumable drivers (`execute-render-scenes.ts`, * `studio/execute.ts`): a PURE scheduler (`runMographExecute`) plus an injectable * per-block runner (`MographBlockRunner`), so the whole scheduler is * offline-testable with a simulated runner. The REAL runner (in * `src/cli/handlers/mograph.ts`) does NOT re-implement submit/poll/download — it * shells to the existing `vclaw video` produce chain per block (synthesize a * one-scene storyboard from the assembled prompt, no image asset → prose-only on * Omni, produce, map `outputs/scene-0.mp4` to the block's output), reusing the * proven transport. One produce per block keeps the driver dead-simple and * resumable at block granularity. * * Credit safety: the CLI handler is spend-gated (`requireSpendConfirmation`); * this scheduler is pure control flow and spends nothing itself. */ import type { MographPriority, MographSubmission } from './types.js'; /** Outcome of rendering one block through the injected runner. */ export interface MographBlockRunResult { status: 'done' | 'failed'; /** Absolute path to the rendered clip (set on 'done'). */ outputPath?: string; /** Absolute path to the written sidecar contract (prompt+refs+route). */ sidecarPath?: string; /** Failure reason when status is 'failed'. */ error?: string; } /** * Injectable per-block runner: render ONE mograph submission and resolve its * outcome. The real runner shells to the produce chain; tests inject a stub. * A throw is treated as a per-block failure (never a run abort). */ export type MographBlockRunner = (submission: MographSubmission) => Promise; export interface MographExecuteBlockResult { blockId: string; priority: MographPriority; status: 'done' | 'failed' | 'skipped'; outputPath?: string; sidecarPath?: string; error?: string; } export interface MographExecuteReport { schemaVersion: 1; /** Blocks that rendered this run. */ ranCount: number; /** Blocks skipped because an output already existed (resume). */ skippedCount: number; /** The first block that failed, if any (fail-fast stops the run there). */ stoppedAt?: string; results: MographExecuteBlockResult[]; } export interface MographExecuteOptions { /** Planned submissions in narrative order (already ref-free for Omni). */ submissions: MographSubmission[]; /** Injectable per-block runner (submit + poll + map + sidecar for one block). */ runner: MographBlockRunner; /** * Resume hook: when it returns true for a block id, that block is recorded * 'skipped' and never handed to the runner (its clip already exists). */ isAlreadyRendered?: (blockId: string) => boolean; /** Optional progress sink (one message per block). */ onProgress?: (msg: string) => void; } /** * Render the planned submissions in order, one block at a time. * * - A block for which `isAlreadyRendered(id)` is true is recorded 'skipped' * (resume) and never handed to the runner. * - Otherwise the runner renders it; a 'done' result records the output + * sidecar paths; a 'failed' result (or a throw) is FAIL-FAST: it is recorded, * `stoppedAt` is set, and the run stops — no further blocks are rendered (so a * provider outage or a bad prompt doesn't burn the whole scope before you see * it). Re-running resumes from the failed block once fixed. * * PURE: no fs / provider / timer access beyond awaiting the injected runner. */ export async function runMographExecute(opts: MographExecuteOptions): Promise { const { submissions, runner, isAlreadyRendered, onProgress } = opts; const results: MographExecuteBlockResult[] = []; const report: MographExecuteReport = { schemaVersion: 1, ranCount: 0, skippedCount: 0, results }; for (const submission of submissions) { const base = { blockId: submission.blockId, priority: submission.priority } as const; if (isAlreadyRendered?.(submission.blockId)) { results.push({ ...base, status: 'skipped' }); report.skippedCount += 1; onProgress?.(`${submission.blockId}: skipped (already rendered)`); continue; } onProgress?.(`${submission.blockId}: rendering on ${submission.route}`); let outcome: MographBlockRunResult; try { outcome = await runner(submission); } catch (error) { outcome = { status: 'failed', error: error instanceof Error ? error.message : String(error) }; } if (outcome.status === 'done') { results.push({ ...base, status: 'done', ...(outcome.outputPath ? { outputPath: outcome.outputPath } : {}), ...(outcome.sidecarPath ? { sidecarPath: outcome.sidecarPath } : {}), }); report.ranCount += 1; onProgress?.(`${submission.blockId}: done -> ${outcome.outputPath ?? '(no path)'}`); } else { results.push({ ...base, status: 'failed', ...(outcome.error ? { error: outcome.error } : {}) }); report.stoppedAt = submission.blockId; onProgress?.(`${submission.blockId}: FAILED (${outcome.error ?? 'unknown'}) — stopping`); return report; } } return report; }