import { readSceneSelectionArtifact } from './scene-selection-store.js'; import { readSceneCandidatesArtifact } from './scene-candidate-store.js'; import { refreshExecutionStatus } from './execution-status.js'; import type { SceneCandidatesArtifact, VideoProductionMode } from './types.js'; 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); }