import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { resolveProjectWorkspace } from './workspace.js'; import { readSceneSelectionArtifact, writeSceneSelectionArtifact } from './scene-selection-store.js'; import { readSceneCandidatesArtifact } from './scene-candidate-store.js'; import { selectCandidate, setChainFromPrev, setChainFromSource } from './scene-selection.js'; import { executeProject } from './execute.js'; import { refreshExecutionStatus } from './execution-status.js'; import { planChainFallbackLadder, describeChainSource, type ChainSource } from './chain-fallback.js'; import { analyzeChainContinuity } from './continuation-handoff.js'; import type { SceneCandidatesArtifact, VideoExecutionReport, VideoProductionMode } from './types.js'; export type AutoChainSceneRunner = ( sceneIndex: number, ) => Promise<{ report: VideoExecutionReport; reportPath: string }>; export interface AutoChainOptions { root: string; productionMode?: VideoProductionMode; /** Ordered subset of scene indices. When omitted, read from the storyboard. */ sceneIndices?: number[]; /** Injectable per-scene runner (tests). Defaults to a single-scene executeProject call. */ runScene?: AutoChainSceneRunner; /** * Opt-in fallback ladder. When a chained scene produces no usable candidate * (e.g. the provider rejected its video reference — Seedance RejectFace), * retry it down the ladder: chain-from-prev → chain-from-anchor → image-only, * instead of fail-fast stopping. Default off → byte-identical fail-fast. */ chainFallback?: boolean; } export interface AutoChainSceneResult { sceneIndex: number; chainedFrom: number | null; selectedCandidateId: string | null; status: 'completed' | 'failed'; /** Set when the fallback ladder used a non-default chain source (e.g. 'chain-from-0', 'image-only'). */ fallback?: string; } export interface AutoChainReport { schemaVersion: 1; projectSlug: string; mode: 'auto-chain'; scenes: AutoChainSceneResult[]; stoppedAt: number | null; /** * Continuation advisories (chain-depth drift). Present only when non-empty — * additive, so shallow chains keep the current report shape. */ advisories?: string[]; } async function readStoryboardSceneOrder(root: string, projectSlug: string): Promise { const workspace = resolveProjectWorkspace(projectSlug, root); const path = join(workspace.artifactsDir, 'storyboard.json'); if (!existsSync(path)) { throw new Error(`auto-chain: storyboard artifact not found for "${projectSlug}". Run storyboard first.`); } const parsed = JSON.parse(await readFile(path, 'utf-8')) as { scenes?: Array<{ sceneIndex?: number }> }; return (parsed.scenes ?? []).map((s, i) => s.sceneIndex ?? i).sort((a, b) => a - b); } const DEFAULT_POLL_INTERVAL_MS = 15_000; const DEFAULT_MAX_POLL_ATTEMPTS = 60; // ~15 min/scene at 15s function candidatesHaveVideo(candidates: SceneCandidatesArtifact, sceneIndex: number): boolean { const scene = candidates.scenes.find((s) => s.sceneIndex === sceneIndex); return !!scene && scene.candidates.some((c) => (c.outputs ?? []).some((o) => o.kind === 'video')); } /** * Poll a just-submitted scene to completion. On async routes (e.g. * seedance-direct) `executeProject` returns after SUBMIT — the render finishes * later via polling — so auto-chain MUST wait for the scene's candidate to gain * a video output before the next scene can chain from it (otherwise the chain * seed resolves to a video-less candidate and `chain-from-prev-source-missing` * fires). Returns true once a video output exists; false if the job fails or the * attempts are exhausted. Dependencies are injectable for offline tests. */ export async function waitForSceneVideo( projectSlug: string, sceneIndex: number, deps: { root: string; productionMode?: VideoProductionMode; refresh?: typeof refreshExecutionStatus; readCandidates?: typeof readSceneCandidatesArtifact; sleep?: (ms: number) => Promise; intervalMs?: number; maxAttempts?: number; }, ): Promise { const refresh = deps.refresh ?? refreshExecutionStatus; const readCandidates = deps.readCandidates ?? readSceneCandidatesArtifact; const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); const intervalMs = deps.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; const maxAttempts = deps.maxAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS; if (candidatesHaveVideo(await readCandidates(deps.root, projectSlug), sceneIndex)) return true; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const status = await refresh(projectSlug, { root: deps.root, productionMode: deps.productionMode }); if (candidatesHaveVideo(await readCandidates(deps.root, projectSlug), sceneIndex)) return true; if (status.poll.status === 'failed') return false; await sleep(intervalMs); } return candidatesHaveVideo(await readCandidates(deps.root, projectSlug), sceneIndex); } /** * Adopt-in-flight guard (shared by all three unattended drivers). Returns the id * of a candidate that was ALREADY submitted for this scene on a prior/concurrent * pass — a candidate carrying a live provider job id (`source.externalJobId`), * not `failed`, for a scene with no selection yet — or null. * * The drivers' resume check is selection-only, so a scene that crashed AFTER the * provider submit but BEFORE `selectCandidate` (a poll window that can be many * minutes on async routes) reads as "not done" and gets RE-SUBMITTED → a second * paid render for the same scene. When this returns a candidate, the caller must * poll+adopt it via {@link resolveInFlightScene} instead of submitting again. * Prefers the highest generationRound (the most recent attempt). */ export async function findInFlightCandidateId( root: string, projectSlug: string, sceneIndex: number, deps: { readCandidates?: typeof readSceneCandidatesArtifact; readSelection?: typeof readSceneSelectionArtifact; } = {}, ): Promise { const readCandidates = deps.readCandidates ?? readSceneCandidatesArtifact; const readSelection = deps.readSelection ?? readSceneSelectionArtifact; const selection = await readSelection(root, projectSlug); if (selection.scenes.find((s) => s.sceneIndex === sceneIndex)?.selectedCandidateId) return null; const candidates = await readCandidates(root, projectSlug); const scene = candidates.scenes.find((s) => s.sceneIndex === sceneIndex); if (!scene) return null; const inFlight = [...scene.candidates] .filter((c) => c.status !== 'failed' && !!c.source?.externalJobId) .sort((a, b) => (b.generationRound ?? 0) - (a.generationRound ?? 0))[0]; return inFlight ? inFlight.id : null; } /** * Poll an in-flight scene (found via {@link findInFlightCandidateId}) to a * TRI-STATE outcome — deliberately NOT a boolean, because the distinction is * spend-critical: * - `'rendered'`: a video output landed → the caller selects the adopted * candidate (the common crash-during-poll case: the render finished server- * side while the operator was away). * - `'failed'`: the provider reported the job failed → the prior spend is * definitively resolved, so a fresh submit is safe (NOT a double charge). * - `'pending'`: still running at the provider after the poll budget → the * caller must NOT re-submit (that WOULD double-charge); re-running later * re-adopts it. Conflating this with 'failed' is what would reintroduce the * double-submit, so they are kept distinct. */ export async function resolveInFlightScene( projectSlug: string, sceneIndex: number, deps: { root: string; productionMode?: VideoProductionMode; refresh?: typeof refreshExecutionStatus; readCandidates?: typeof readSceneCandidatesArtifact; sleep?: (ms: number) => Promise; intervalMs?: number; maxAttempts?: number; }, ): Promise<'rendered' | 'failed' | 'pending'> { const refresh = deps.refresh ?? refreshExecutionStatus; const readCandidates = deps.readCandidates ?? readSceneCandidatesArtifact; const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); const intervalMs = deps.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; const maxAttempts = deps.maxAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS; // Failure is judged by the CANDIDATE'S OWN status, not the aggregate poll // status. refreshExecutionStatus() marks this candidate 'failed' only on a // genuine provider failure / completed-without-outputs (execution-status.ts), // whereas its aggregate poll.status can read 'failed' for an unrelated reason // (e.g. a missing execution-report in the narrow first-run crash-between- // candidate-and-report window). Keying off the candidate avoids a false // 'failed' → re-submit → double charge; a candidate whose job is genuinely // still running stays non-'failed', so we keep polling / return 'pending'. if (candidatesHaveVideo(await readCandidates(deps.root, projectSlug), sceneIndex)) return 'rendered'; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { await refresh(projectSlug, { root: deps.root, productionMode: deps.productionMode }); const candidates = await readCandidates(deps.root, projectSlug); if (candidatesHaveVideo(candidates, sceneIndex)) return 'rendered'; if (!sceneStillInFlight(candidates, sceneIndex)) return 'failed'; await sleep(intervalMs); } return candidatesHaveVideo(await readCandidates(deps.root, projectSlug), sceneIndex) ? 'rendered' : 'pending'; } /** * True while the scene still has at least one candidate carrying a live provider * job id that has NOT been marked 'failed' — i.e. a render is still in flight. * Once the in-flight candidate(s) are all marked 'failed' by the poll, this goes * false and {@link resolveInFlightScene} returns 'failed' (safe to re-submit). */ function sceneStillInFlight(candidates: SceneCandidatesArtifact, sceneIndex: number): boolean { const scene = candidates.scenes.find((s) => s.sceneIndex === sceneIndex); return !!scene && scene.candidates.some((c) => c.status !== 'failed' && !!c.source?.externalJobId); } function defaultRunScene(projectSlug: string, options: AutoChainOptions): AutoChainSceneRunner { return async (sceneIndex) => { const result = await executeProject(projectSlug, { root: options.root, productionMode: options.productionMode, sceneIndices: [sceneIndex], continuityFeedback: true, // bundled — locked during brainstorming }); // Async submit-only routes (seedance-direct etc.): wait for the render to // finish + download so the scene's candidate gains a video output before the // next scene chains from it. Synchronous/dry paths skip this. if (result.report.status === 'live-submitted') { const rendered = await waitForSceneVideo(projectSlug, sceneIndex, { root: options.root, productionMode: options.productionMode, }); if (!rendered) { // Render never produced a video → signal failure (no usable candidate). return { report: { ...result.report, candidatesByScene: [] }, reportPath: result.reportPath }; } } return result; }; } /** * Drive the existing chain-from-prev engine across a whole storyboard, * unattended. Renders scenes sequentially; each scene after the first seeds * from the previous scene's auto-selected output video (chainFromPrev) with * continuity prompt-augmentation bundled in. Resumable (skips scenes that * already have a selection) and fail-fast (halts at the first scene that yields * no usable candidate, recording stoppedAt). */ export async function runAutoChain( projectSlug: string, options: AutoChainOptions, ): Promise { const root = options.root; const sceneOrder = options.sceneIndices ?? (await readStoryboardSceneOrder(root, projectSlug)); const runScene = options.runScene ?? defaultRunScene(projectSlug, options); const scenes: AutoChainSceneResult[] = []; let stoppedAt: number | null = null; for (let i = 0; i < sceneOrder.length; i += 1) { const sceneIndex = sceneOrder[i]; const chainedFrom = i > 0 ? sceneOrder[i - 1] : null; // Resume: a scene that already has a selected candidate is left as-is. let selection = await readSceneSelectionArtifact(root, projectSlug); const existing = selection.scenes.find((s) => s.sceneIndex === sceneIndex); if (existing?.selectedCandidateId) { scenes.push({ sceneIndex, chainedFrom, selectedCandidateId: existing.selectedCandidateId, status: 'completed' }); continue; } // Adopt-in-flight: this scene was already SUBMITTED on a prior/crashed run // (a candidate with a live provider job id, not yet selected). Poll+adopt it // rather than re-submitting — a re-submit here is a second paid render. const inFlightId = await findInFlightCandidateId(root, projectSlug, sceneIndex); if (inFlightId) { const outcome = await resolveInFlightScene(projectSlug, sceneIndex, { root, productionMode: options.productionMode, }); if (outcome === 'rendered') { selection = selectCandidate(await readSceneSelectionArtifact(root, projectSlug), sceneIndex, inFlightId); await writeSceneSelectionArtifact(root, projectSlug, selection); scenes.push({ sceneIndex, chainedFrom, selectedCandidateId: inFlightId, status: 'completed' }); continue; } if (outcome === 'pending') { // Still running at the provider — do NOT re-submit (would double-charge). // Halt: a chained run cannot seed the next scene from an unfinished one. scenes.push({ sceneIndex, chainedFrom, selectedCandidateId: null, status: 'failed' }); stoppedAt = sceneIndex; break; } // outcome === 'failed' → the prior attempt is definitively dead; fall through // to a fresh submit below (not a double-charge). } // Chain rungs to try, in order. First scene: no chain. Non-first scene: // a single chain-from-prev rung (legacy fail-fast) unless `chainFallback` // is on, which walks the full ladder so a rejected video reference doesn't // stop the whole chain. const rungs: ChainSource[] = i === 0 ? [{ kind: 'image-only' }] : options.chainFallback ? planChainFallbackLadder({ positionInOrder: i, sceneOrder }) : [{ kind: 'chain', sourceSceneIndex: sceneIndex - 1 }]; let produced: { sceneIndex: number; candidateId: string } | undefined; let usedRung: ChainSource | null = null; let hardError = false; for (const rung of rungs) { if (i > 0) { // Apply the rung's chain source. `chain-from-(sceneIndex-1)` uses the // legacy flag (no explicit pin → byte-identical); an earlier anchor pins // `chainFromSceneIndex`; image-only clears the chain seed. let next = await readSceneSelectionArtifact(root, projectSlug); next = rung.kind === 'image-only' ? setChainFromPrev(next, sceneIndex, false) : rung.sourceSceneIndex === sceneIndex - 1 ? setChainFromPrev(next, sceneIndex, true) : setChainFromSource(next, sceneIndex, rung.sourceSceneIndex); await writeSceneSelectionArtifact(root, projectSlug, next); } let stepReport: VideoExecutionReport; try { ({ report: stepReport } = await runScene(sceneIndex)); } catch { hardError = true; // a thrown error is a hard failure — stop laddering break; } const got = (stepReport.candidatesByScene ?? []).find((c) => c.sceneIndex === sceneIndex); if (got) { produced = got; usedRung = rung; break; } // else: this rung produced no usable candidate → try the next rung } if (hardError || !produced) { scenes.push({ sceneIndex, chainedFrom, selectedCandidateId: null, status: 'failed' }); stoppedAt = sceneIndex; break; } // Auto-select so the next scene's chain seed resolves. selection = await readSceneSelectionArtifact(root, projectSlug); selection = selectCandidate(selection, sceneIndex, produced.candidateId); await writeSceneSelectionArtifact(root, projectSlug, selection); // Record the fallback label only when a non-default chain source was used. const fallbackLabel = i > 0 && usedRung && describeChainSource(usedRung) !== `chain-from-${sceneIndex - 1}` ? describeChainSource(usedRung) : undefined; scenes.push({ sceneIndex, chainedFrom, selectedCandidateId: produced.candidateId, status: 'completed', ...(fallbackLabel ? { fallback: fallbackLabel } : {}), }); } // Continuation advisory — additive, never changes render behavior. Warns when // the completed chain runs deep enough that compounding error is likely. const advisories = analyzeChainContinuity( scenes .filter((scene) => scene.status === 'completed') .map((scene) => ({ sceneIndex: scene.sceneIndex, chainedFrom: scene.chainedFrom })), ).map((advisory) => advisory.message); return { schemaVersion: 1, projectSlug, mode: 'auto-chain', scenes, stoppedAt, ...(advisories.length > 0 ? { advisories } : {}), }; }