/** * Fallback-ladder scene renderer — the third resumable scene driver, alongside * `runAutoChain` (`execute-autochain.ts`, SEQUENTIAL + chained) and * `runScenePool` (`execute-pool.ts`, PARALLEL + independent). Where those two * each render a scene on the project's single resolved route, this driver * renders scenes SEQUENTIALLY and — per scene — walks a FALLBACK ROUTE LADDER: * try route A; if the provider rejects it (the runner returns status:'failed' or * throws), escalate to route B, then C, and so on. The first route that * succeeds wins and the ladder stops for that scene. * * This productizes the hand-written `voice-render.mjs` / `chain.mjs` loops * operators write to render scenes one at a time and, on a provider rejection, * escalate to a fallback method — resumable across crashes. Mirrors the * auto-chain / pool shape: a PURE scheduler (`runRenderScenes`) plus an * injectable per-scene runner (`RenderSceneRunner`), so the scheduler is fully * offline-testable with a simulated runner. The real runner + the CLI handler * live in `src/cli/handlers/execution.ts`. */ export interface RenderSceneRunner { /** * Render one scene on ONE specific route. Resolves with status:'done' (or * simply resolves without throwing) when that route produced a usable * candidate; status:'failed' (or a throw) when the route rejected it (e.g. a * Seedance RejectFace / moderation rejection) so the ladder should escalate. */ (sceneIndex: number, route: string): Promise<{ status: 'done' | 'failed'; error?: string }>; } export interface RenderSceneResult { sceneIndex: number; status: 'done' | 'failed' | 'skipped'; /** The route that produced the scene (set on 'done'). */ routeUsed?: string; /** How many ladder rungs were tried (skips report 0). */ attempts: number; /** The last error when every rung failed. */ error?: string; } export interface RenderScenesResult { results: RenderSceneResult[]; } export interface RunRenderScenesOptions { /** Scene indices to consider, in any order (processed ascending). */ scenes: number[]; /** * The fallback route ladder, in escalation order. A single-entry ladder = no * fallback (plain sequential render on that one route). An empty ladder throws * — there would be nothing to try. */ routeLadder: string[]; /** Injectable per-scene-per-route runner — submit + poll + select for one (scene, route). */ runner: RenderSceneRunner; /** * Resume hook: when it returns true for a scene, that scene is recorded * 'skipped' and never handed to the runner (it is already done/selected). */ isAlreadyDone?: (sceneIndex: number) => boolean; /** * Resume cursor: scenes with index < continueFrom are recorded 'skipped' and * never run, so an interrupted run can pick up where it stopped. */ continueFrom?: number; /** Optional progress sink (a started/escalated/settled message per scene). */ onProgress?: (msg: string) => void; } /** * Render `scenes` SEQUENTIALLY in ascending order, walking the `routeLadder` * per scene until one route succeeds. * * THE LADDER-ESCALATION MECHANISM (the inner loop): for each scene that must * run, iterate the routes of `routeLadder` IN ORDER. Call `runner(i, route)`. * - If it resolves with status:'done' (success), record * `status:'done', routeUsed:route, attempts:` and BREAK * the ladder — later rungs are never tried. * - If it resolves with status:'failed' OR THROWS, record the error and * continue to the NEXT rung (escalate). * If every rung is exhausted without a success, record * `status:'failed', attempts:routeLadder.length, error:` and CONTINUE * to the next scene — one bad scene never aborts the whole run (but it IS * recorded so the operator sees it). * * Resumable: a scene with `i < continueFrom` OR `isAlreadyDone(i) === true` is * recorded 'skipped' (attempts:0) and never handed to the runner. Results are * returned deterministically ordered by sceneIndex. * * PURE: no fs / provider / timer access beyond awaiting the injected runner. */ export async function runRenderScenes(opts: RunRenderScenesOptions): Promise { const { scenes, routeLadder, runner, isAlreadyDone, continueFrom, onProgress } = opts; if (routeLadder.length === 0) { throw new Error('runRenderScenes: routeLadder is empty — at least one route is required to render.'); } // Process in ascending sceneIndex order — sequential, deterministic. const ordered = [...scenes].sort((a, b) => a - b); const results: RenderSceneResult[] = []; for (const sceneIndex of ordered) { // Resume gates: continueFrom cursor OR the already-done predicate. if ((continueFrom !== undefined && sceneIndex < continueFrom) || isAlreadyDone?.(sceneIndex)) { results.push({ sceneIndex, status: 'skipped', attempts: 0 }); onProgress?.(`scene ${sceneIndex}: skipped (already done)`); continue; } onProgress?.(`scene ${sceneIndex}: started (ladder ${routeLadder.join(' -> ')})`); let done: { routeUsed: string; attempts: number } | undefined; let attempts = 0; let lastError: string | undefined; // --- the fallback ladder: try each route in order until one succeeds --- for (const route of routeLadder) { attempts += 1; try { const outcome = await runner(sceneIndex, route); if (outcome.status === 'done') { done = { routeUsed: route, attempts }; onProgress?.(`scene ${sceneIndex}: done via ${route} (attempt ${attempts})`); break; } // status:'failed' — record the reason and escalate to the next rung. lastError = outcome.error ?? `route ${route} failed`; onProgress?.(`scene ${sceneIndex}: ${route} failed (${lastError}) — escalating`); } catch (error) { // A thrown runner is a per-route failure, NOT a run abort — escalate. lastError = error instanceof Error ? error.message : String(error); onProgress?.(`scene ${sceneIndex}: ${route} threw (${lastError}) — escalating`); } } if (done) { results.push({ sceneIndex, status: 'done', routeUsed: done.routeUsed, attempts: done.attempts }); } else { // Every rung failed. Record it and CONTINUE — one bad scene does not abort // the run (later scenes still get their chance). results.push({ sceneIndex, status: 'failed', attempts: routeLadder.length, ...(lastError ? { error: lastError } : {}), }); onProgress?.(`scene ${sceneIndex}: failed after ${routeLadder.length} route(s)`); } } return { results }; }