import { existsSync } from 'node:fs'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { mkdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { artifactPathFor } from './artifact-store.js'; import { readSceneCandidatesArtifact } from './scene-candidate-store.js'; import { readSceneSelectionArtifact } from './scene-selection-store.js'; import { readSeedanceAssets } from './seedance-asset-library.js'; import { readFlowCharacters } from './flow-character-library.js'; import { assertReferenceBudget } from './native-seedance.js'; import { assertRouteRequestValid, isOmniFirstFrameEnabled } from './provider-platform/route-capabilities.js'; import { resolveProjectWorkspace, readProjectManifest } from './workspace.js'; import { augmentPromptWithContinuity } from './gemini-continuity.js'; import { repasteContinuityDescriptors, resolveAssetTags } from './prompt-rules.js'; import { extractFlowMarkers, injectFlowCharacterMarkers, planFlowCharacterSlots, stripFlowMarkers, type FlowCharacterSlotPlan, } from './flow-markers.js'; import { buildAssetTagLookup } from './asset-tag-lookup.js'; import { listCharacterProfiles } from './characters.js'; import { readEnvironmentAssets } from './environment-assets.js'; import { readVoiceClones } from './voice-clone.js'; import { readShowBible } from './show-bible.js'; import { buildShowBibleSourcesForRoute, matchSceneLocation, routeFamilyFor, type ShowBibleSourcesForRoute, } from './show-bible-attach.js'; import { hostChainSeedAsImage, defaultChainSeedHostDeps, type ChainSeedHostDeps, } from './seedance-chain-host.js'; import type { StoryBibleArtifact } from './story-bible.js'; import type { FilmmakingPromptsArtifact, FilmmakingSeedancePacket } from './filmmaking-prompts.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import type { VideoExecutionCancelResult, VideoExecutionPayload, VideoExecutionPlan, VideoExecutionPollResult, VideoExecutionTask, } from './types.js'; function adapterEnvVarForRoute(routeId: ProviderRouteId): string { switch (routeId) { case 'veo-useapi': return 'VCLAW_VEO_USEAPI_ADAPTER'; case 'seedance-direct': return 'VCLAW_SEEDANCE_DIRECT_ADAPTER'; case 'runway-useapi': return 'VCLAW_RUNWAY_USEAPI_ADAPTER'; case 'dreamina-useapi': return 'VCLAW_DREAMINA_USEAPI_ADAPTER'; case 'magnific-rest': return 'VCLAW_MAGNIFIC_REST_ADAPTER'; } } function builtinAdapterCommandForRoute(routeId: ProviderRouteId): string | null { if (!(routeId === 'seedance-direct' || routeId === 'veo-useapi' || routeId === 'runway-useapi' || routeId === 'dreamina-useapi' || routeId === 'magnific-rest')) { return null; } const scriptPath = fileURLToPath(new URL('../cli/provider-adapter.js', import.meta.url)); return `${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)} --route ${routeId}`; } function resolveAdapterCommand(routeId: ProviderRouteId, env: NodeJS.ProcessEnv): string { const override = env[adapterEnvVarForRoute(routeId)]; if (override && override.trim()) { return override; } // In-tree free Higgsfield engine (ADR 0006): once bootstrapped, seedance-direct // renders for $0 via the vendored engine by default. Gate on BOTH the venv and the // authenticated .cloak-profile existing — a half-bootstrapped checkout (deps but no // session) safely falls through to the paid builtin below rather than failing at // render time. VCLAW_SEEDANCE_DIRECT_NATIVE=1 forces the paid native path. if (routeId === 'seedance-direct' && !env.VCLAW_SEEDANCE_DIRECT_NATIVE) { const engineDir = new URL('../../engines/seedance-direct/', import.meta.url); const engineVenv = fileURLToPath(new URL('.venv/bin/python', engineDir)); const engineProfile = env.HIGGS_VCLAW_PROFILE ?? fileURLToPath(new URL('.cloak-profile', engineDir)); if (existsSync(engineVenv) && existsSync(engineProfile)) { return JSON.stringify(fileURLToPath(new URL('run.sh', engineDir))); } } const builtin = builtinAdapterCommandForRoute(routeId); if (builtin) { return builtin; } throw new Error(`Live execution for ${routeId} requires ${adapterEnvVarForRoute(routeId)} to point at an adapter command.`); } function unique(values: string[]): string[] { return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; } /** * Assemble a scene's reference set, merging continuity + identity instead of * choosing one. A chain-from-prev keyframe video (when present) leads so * downstream adapters treat it as the primary seed; character Asset:// identity * refs follow. Only the "assets + chain" case differs from legacy behavior — * the other three branches are byte-identical to the prior either/or. * * Routing note: on seedance-direct, classifyReferencePaths sends Asset:// to * reference_images and the .mp4 keyframe to reference_videos, so the merged * array lands in the correct provider slots within the ≤9 img / ≤3 vid budget. */ export function resolveSceneReferencePaths(input: { resolvedAssetUris: string[]; chainSeedPath: string | null; packetOrBaseReferencePaths: string[]; /** * Always-appended references (e.g. a voice-clone video). Unlike image identity * URIs, these do NOT replace the base/packet refs — a voice clip is additive, so * a scene keeps its identity image (in packetOrBaseReferencePaths on Runway, or * the Asset:// URI on seedance) AND gains the voice. Default [] -> byte-identical. */ additionalReferencePaths?: string[]; }): string[] { const { resolvedAssetUris, chainSeedPath, packetOrBaseReferencePaths, additionalReferencePaths = [] } = input; // The image-identity "winner" set (Asset:// URIs / @tag image refs) replaces the // base/packet refs as before; voice clips are appended on top of whichever wins. const base = resolvedAssetUris.length > 0 ? (chainSeedPath ? [chainSeedPath, ...resolvedAssetUris] : resolvedAssetUris) : (chainSeedPath ? [chainSeedPath, ...packetOrBaseReferencePaths] : packetOrBaseReferencePaths); return unique([...base, ...additionalReferencePaths]); } /** * Structurally pin each scene character's locked costume into the scene prompt. * * For every character listed on the scene that has a registered profile with a * `costume`, append a deterministic clause `Keep in exactly.` * (using the profile's canonical display name, even if the scene listed a * differently-cased name). This is the wardrobe analog of the visual-descriptor * identity lock: it stops a recurring character drifting clothing/colour between * scenes (the real Satyavan dhoti-colour bug) by binding the on-screen body to * the locked costume rather than leaving it to prose. * * Pure & deterministic: * - Only scene characters are considered (cast order preserved). * - Characters without a costume contribute nothing. * - Idempotent: a clause already present in the prompt is not re-appended. * - No matching costume -> the prompt is returned BYTE-IDENTICAL. */ export function appendCostumeClauses( promptText: string, sceneCharacters: string[], costumeByLowerName: Map, ): string { let result = promptText; const appended = new Set(); for (const sceneName of sceneCharacters) { const key = sceneName.trim().toLowerCase(); if (!key || appended.has(key)) continue; const profile = costumeByLowerName.get(key); const costume = profile?.costume?.trim(); if (!profile || !costume) continue; appended.add(key); const clause = `Keep ${profile.name} in ${costume} exactly.`; // Idempotent: don't re-append a clause already present (e.g. a re-run over a // prompt that already carries it). if (result.includes(clause)) continue; result = result.length > 0 ? `${result} ${clause}` : clause; } return result; } /** * Canonical, project-agnostic per-scene render rules baked into EVERY scene's * animation/motion prompt by `buildExecutionPayload` (default-on; opt-out via the * `standingRenderRules` manifest flag or the `VCLAW_DISABLE_STANDING_RULES` * env kill-switch). Learned from real failures on the Savitri film: * * - MOTION: natural, physically-correct motion only; no fidgeting/twitching/ * jitter/morphing of faces or bodies; no fiddling with cloth; nothing new * appears/fades in/materializes; no duplicates or extra figures; full frame, * no border. * - AUDIO: only diegetic ambient sound; NO speech, dialogue, voices, singing, or * music (lips may mime only). Voices/music are overlaid in post, so the * rendered clip must be ambient-only. * * The first line is a stable SENTINEL used for idempotent injection — never * reword it without updating `appendStandingRenderRules`'s containment check. */ export const STANDING_RENDER_RULES = [ 'Standing render rules:', 'Motion: natural, physically-correct motion only; no fidgeting, twitching, jitter, or morphing of faces or bodies; no fiddling with cloth; nothing new appears, fades in, or materializes; no duplicates or extra figures; full frame, no border.', 'Audio: only diegetic ambient sound appropriate to the scene; NO speech, dialogue, voices, singing, or music — characters’ lips may mime only (no spoken words).', ].join(' '); /** * Idempotently append the canonical {@link STANDING_RENDER_RULES} block to a * prompt. Pure & deterministic: * - Appends the block once, space-separated (no leading separator on an empty * prompt). * - If the block (matched by its leading sentinel) is already present anywhere * in the prompt, the prompt is returned BYTE-IDENTICAL (never double-appended). * * Gating is the caller's job — when the standing rules are disabled this helper * is simply not called, so the prompt is byte-identical to today. */ export function appendStandingRenderRules(promptText: string): string { // Idempotent: the leading sentinel line uniquely identifies an already-injected // block, so a re-run (or a prompt that already carries it) is a no-op. const sentinel = STANDING_RENDER_RULES.split(' ')[0] === 'Standing' ? 'Standing render rules:' : STANDING_RENDER_RULES; if (promptText.includes(sentinel)) return promptText; return promptText.length > 0 ? `${promptText} ${STANDING_RENDER_RULES}` : STANDING_RENDER_RULES; } const REFERENCE_VIDEO_EXTS = new Set(['.mp4', '.mov', '.webm', '.avi', '.mkv']); const REFERENCE_AUDIO_EXTS = new Set(['.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg']); // Routes that consume a blank-video-with-audio voice clone as a video reference to // lock a cloned voice (the black-frame video carries the voice; a raw MP3 does NOT // lock it as reliably — that is the whole point of the trick): // • seedance-direct — the .mp4 rides into `reference_videos` // • runway-useapi — Seedance-2 via the UseAPI gateway → `videoAssetId` + audio:true // • dreamina-useapi — Seedance-2 via Dreamina Omni Reference → `omni_N_videoRef` // (the omni video ref is EXPECTED to drive the voice on its own; unverified // live — if a render is mute, VCLAW_DREAMINA_AUDIO=1 forces `audio:true`) // Other routes leave voice clones un-injected (byte-identical). const VOICE_REF_ROUTES = new Set(['seedance-direct', 'runway-useapi', 'dreamina-useapi']); /** * Classifies a scene's reference paths into image/video/audio counts using the * same extension buckets the native transports use. Anything that is not a * recognized video/audio file (including `Asset://` URIs, which the Seedance * transport routes into `reference_images`) is counted as an image reference. */ function countReferencesByKind(referencePaths: string[]): { images: number; videos: number; audios: number } { let images = 0; let videos = 0; let audios = 0; for (const path of referencePaths) { if (!path) continue; const ext = (path.split('?')[0]?.match(/\.[^.\\/]+$/)?.[0] ?? '').toLowerCase(); if (REFERENCE_VIDEO_EXTS.has(ext)) videos += 1; else if (REFERENCE_AUDIO_EXTS.has(ext)) audios += 1; else images += 1; } return { images, videos, audios }; } export interface BuildExecutionPayloadOptions { /** * Restricts the payload to these scene indices. When omitted, all storyboard * scenes are included (legacy behavior). When provided, scenes not in the * list are dropped. */ sceneIndices?: number[]; /** * When true, resolves `chainFromPrev` seeds from the previous scene's * selected candidate. Only honored in candidate mode. */ resolveChainSeeds?: boolean; /** * Environment used for the pre-submission route-capability check (reads the * `VCLAW_ALLOW_UNSAFE_MODELS` escape hatch). Defaults to `process.env`. */ env?: NodeJS.ProcessEnv; /** * Opt-in PHASE-3 continuity loop. When true, for each task that has a * chain-from-prev seed we enrich `task.prompt` with a continuity cue derived * from the prior scene's rendered keyframe (Gemini when a key + an image seed * are available, else deterministic story-bible descriptors), and re-paste * the full cast/setting/prop descriptor block (StoryCraft anti-drift). * * When false/absent the payload is BYTE-IDENTICAL to today: no Gemini call, * no prompt mutation. Honored only when `resolveChainSeeds` produced seeds. */ continuityFeedback?: boolean; /** * Injected fetch for the continuity Gemini call (tests). Defaults to global * fetch via the key pool. Only used when `continuityFeedback` is true. */ continuityFetcher?: typeof fetch; /** * Injected I/O for hosting an auto-chain seed on the seedance-direct route * (ffmpeg last-frame extraction + Go Bananas upload). Tests pass fakes; in * production this defaults to `defaultChainSeedHostDeps(env)`. Only consulted * when the route is seedance-direct AND a local-video chain seed was resolved. */ chainSeedHost?: ChainSeedHostDeps; } /** * Error thrown when `resolveChainSeeds` is enabled and the upstream scene has * no selected candidate (or no usable video output). */ export class ChainFromPrevSourceMissingError extends Error { readonly code = 'chain-from-prev-source-missing'; readonly sceneIndex: number; readonly sourceSceneIndex: number; constructor(sceneIndex: number, sourceSceneIndex: number, reason: string) { super( `chain-from-prev-source-missing: scene ${sceneIndex} requested chain from scene ${sourceSceneIndex} but ${reason}`, ); this.sceneIndex = sceneIndex; this.sourceSceneIndex = sourceSceneIndex; this.name = 'ChainFromPrevSourceMissingError'; } } export async function buildExecutionPayload( projectSlug: string, plan: VideoExecutionPlan, root = resolveWorkspaceRootFromEnv(), options: BuildExecutionPayloadOptions = {}, ): Promise { if (!plan.recommendedRouteId) { throw new Error(`Cannot build execution payload for ${projectSlug}: recommendedRouteId missing.`); } const workspace = resolveProjectWorkspace(projectSlug, root); const storyboard = JSON.parse(await readFile(artifactPathFor(workspace, 'storyboard'), 'utf-8')) as { scenes?: Array<{ sceneIndex?: number; description?: string; scenePrompt?: { animationPrompt?: string; }; characters?: string[]; durationSeconds?: number; voicePreset?: string; referenceVideoMediaId?: string; firstFrame?: boolean; }>; }; const assetManifest = existsSync(artifactPathFor(workspace, 'asset-manifest')) ? JSON.parse(await readFile(artifactPathFor(workspace, 'asset-manifest'), 'utf-8')) as { assets?: Array<{ id?: string; kind?: string; path?: string; sceneIndex?: number; backend?: string }>; } : { assets: [] }; const filmmakingPrompts = existsSync(artifactPathFor(workspace, 'filmmaking-prompts')) ? JSON.parse(await readFile(artifactPathFor(workspace, 'filmmaking-prompts'), 'utf-8')) as FilmmakingPromptsArtifact : null; const readyPromptPacketsByScene = new Map(); for (const packet of filmmakingPrompts?.seedancePackets ?? []) { if (isExecutionReadyPromptPacket(packet)) { readyPromptPacketsByScene.set(packet.sceneIndex, packet); } } const assetsByScene = new Map>(); for (const asset of assetManifest.assets ?? []) { if (!Number.isInteger(asset.sceneIndex)) continue; const sceneAssets = assetsByScene.get(asset.sceneIndex as number) ?? []; sceneAssets.push(asset); assetsByScene.set(asset.sceneIndex as number, sceneAssets); } // Chain-from-prev resolution is only considered when the caller opts in. // Reads both selection + candidates so we can locate the upstream scene's // selected candidate's video output. const chainSeedsByScene = new Map(); if (options.resolveChainSeeds) { const selection = await readSceneSelectionArtifact(root, projectSlug); const candidates = await readSceneCandidatesArtifact(root, projectSlug); const sceneList = (storyboard.scenes ?? []).map((s) => s.sceneIndex ?? 0); const sceneIndexFilter = options.sceneIndices ? new Set(options.sceneIndices) : new Set(sceneList); for (const sel of selection.scenes) { if (!sel.chainFromPrev) continue; if (!sceneIndexFilter.has(sel.sceneIndex)) continue; // Explicit chain source (auto-chain fallback ladder) wins; absent → the // immediately previous scene (legacy behavior). const sourceSceneIndex = sel.chainFromSceneIndex ?? sel.sceneIndex - 1; const upstreamSelection = selection.scenes.find((s) => s.sceneIndex === sourceSceneIndex); if (!upstreamSelection || !upstreamSelection.selectedCandidateId) { throw new ChainFromPrevSourceMissingError( sel.sceneIndex, sourceSceneIndex, upstreamSelection ? 'upstream scene has no selected candidate' : 'upstream scene has no selection entry', ); } const upstreamEntry = candidates.scenes.find((s) => s.sceneIndex === sourceSceneIndex); const upstreamCandidate = upstreamEntry?.candidates.find( (c) => c.id === upstreamSelection.selectedCandidateId, ); if (!upstreamCandidate) { throw new ChainFromPrevSourceMissingError( sel.sceneIndex, sourceSceneIndex, `candidate ${upstreamSelection.selectedCandidateId} missing from candidates artifact`, ); } const firstVideo = upstreamCandidate.outputs.find((o) => o.kind === 'video'); if (!firstVideo) { throw new ChainFromPrevSourceMissingError( sel.sceneIndex, sourceSceneIndex, `candidate ${upstreamSelection.selectedCandidateId} has no video output to chain from`, ); } chainSeedsByScene.set(sel.sceneIndex, { path: firstVideo.path, sourceCandidateId: upstreamCandidate.id, }); } // seedance-direct rejects local file references (a chain seed is the prior // scene's downloaded `.mp4`), so host it: extract the last frame and upload // it as an image, replacing the seed path with a hosted URL that seedance // routes into `reference_images` (the keyframe). runway/dreamina upload // local refs themselves, so this is gated to seedance-direct; non-local // seeds (already a URL / Asset://) pass through `hostChainSeedAsImage` // untouched. Off-route the map is byte-identical to today. if (plan.recommendedRouteId === 'seedance-direct') { const env = options.env ?? process.env; const chainSeedHost = options.chainSeedHost ?? defaultChainSeedHostDeps(env); const chainSeedWorkDir = join(workspace.projectDir, 'artifacts', 'chain-seeds'); for (const [sceneIndex, seed] of chainSeedsByScene) { const hosted = await hostChainSeedAsImage(seed.path, chainSeedWorkDir, chainSeedHost); if (hosted !== seed.path) { chainSeedsByScene.set(sceneIndex, { ...seed, path: hosted }); } } } } const sceneFilter = options.sceneIndices ? new Set(options.sceneIndices) : null; // Seedance character/product identity is locked via managed Asset Library // avatars (Asset:// URIs). When the project has registered them, each scene's // cast names resolve to Asset:// URIs that become that scene's reference set. // Absent artifact -> empty map -> behavior identical to today (no injection). // Gated to the Seedance route so Veo/Runway payloads are untouched. const assetUriByName = plan.recommendedRouteId === 'seedance-direct' ? (await readSeedanceAssets(workspace.root, projectSlug)).assetUriByName : new Map(); // Google Flow saved-character refs (flow-characters.json) → scene characters // become `character_1..7` on the veo-useapi route. Absent artifact -> empty map // -> no `--character` flags emitted (byte-identical to today). Gated to the // veo-useapi route so Seedance/Runway/Dreamina payloads are untouched. const characterRefByName = plan.recommendedRouteId === 'veo-useapi' ? (await readFlowCharacters(workspace.root, projectSlug)).characterRefByName : new Map(); // Voice clones (the blank-video-with-audio trick). The .mp4 voice clip becomes a // video reference the transport routes into the provider's voice slot to lock the // cloned voice — see VOICE_REF_ROUTES for the routes + their slots (seedance-direct // reference_videos, runway-useapi videoAssetId, dreamina-useapi omni_N_videoRef). // Other routes -> null -> byte-identical no-op; absent artifact -> empty -> no-op. // • `@VoiceName` tags -> voiceEntryByName (explicit, resolved via assetTagLookup) // • a character bound with `voice-clone --character ` -> voiceByCharacter, // auto-injected into every scene that features that character (no tag needed). const voiceClones = VOICE_REF_ROUTES.has(plan.recommendedRouteId) ? await readVoiceClones(workspace.root, projectSlug) : null; const voicesByName = voiceClones?.voiceEntryByName ?? new Map(); // Registered character profiles, read once per payload and reused below for // (a) the @Name tag lookup and (b) the per-scene costume-lock injection. const characterProfiles = await listCharacterProfiles(workspace); // Locked-costume lookup keyed by lowercased name. A profile with a `costume` // pins that character's wardrobe into every scene that lists them (the dhoti // colour-drift fix). Empty when no profile carries a costume -> the costume // injection in the scene loop is a no-op (byte-identical to today). const costumeByLowerName = new Map(); for (const profile of characterProfiles) { if (!profile.name) continue; costumeByLowerName.set(profile.name.trim().toLowerCase(), { name: profile.name, ...(profile.costume ? { costume: profile.costume } : {}), }); } // Standing per-scene render rules (the no-speech / natural-motion / nothing- // appears block) are baked into every scene prompt by default. Opt-out wins // from EITHER control: the project manifest `standingRenderRules: false` // (the primary, documented control) OR the `VCLAW_DISABLE_STANDING_RULES=1` // env kill-switch. Default (flag absent + env unset) -> rules ON. When OFF the // per-scene injection is skipped, so the payload is byte-identical to today. const standingRulesEnv = options.env ?? process.env; const manifest = await readProjectManifest(workspace); const standingRulesEnabled = manifest?.standingRenderRules !== false && standingRulesEnv.VCLAW_DISABLE_STANDING_RULES !== '1'; // Show-bible-driven, route-aware auto-attach (the cartoon-show method). // When `artifacts/show-bible.json` exists, it back-fills the SAME name-keyed // maps the per-route resolvers above already consume — a LOW-PRIORITY source, // never a parallel reference path. Absent bible -> null -> every block below // is skipped -> byte-identical to today. Even WITH a bible, each seed uses // has()-guarded set() semantics so per-project artifacts always win and a // fully-provisioned project sees no change. const showBible = await readShowBible(workspace.root, projectSlug); const showRouteFamily = routeFamilyFor(plan.recommendedRouteId); let bibleSources: ShowBibleSourcesForRoute | null = null; if (showBible) { bibleSources = buildShowBibleSourcesForRoute({ bible: showBible, routeId: plan.recommendedRouteId, projectDir: workspace.projectDir, workspaceRoot: workspace.root, }); // (a) seedance-direct Asset:// identity: back-fill names the // seedance-assets.json artifact did not register (exact-case keys to match). if (plan.recommendedRouteId === 'seedance-direct') { for (const [name, uri] of bibleSources.assetUriByName) { if (!assetUriByName.has(name)) assetUriByName.set(name, uri); } } // (b) voice-by-character: back-fill characters with no voice-clones.json // binding so a bible-bound voice still auto-injects on the seedance family. if (voiceClones && showRouteFamily === 'seedance') { for (const [character, ref] of bibleSources.voiceRefByCharacter) { if (!voiceClones.voiceByCharacter.has(character)) { // The voice auto-inject path reads `hostedUrl ?? clipPath`; seed a // minimal entry whose clipPath IS the resolved ref (already URL/local). voiceClones.voiceByCharacter.set(character, { name: character, character, sourceAudio: ref, clipPath: ref, }); } } } } // @Name tagging lookup: characters (+ seedance Asset:// URIs) + locked // environment plates (so @location tags resolve) + voice clones (so @VoiceName // tags resolve). Built once per payload; empty -> resolveAssetTags is a no-op // (byte-identical to today). The show-bible cast/location descriptors are // merged LAST-but-guarded: characters.json / environment-assets win, the bible // only fills names neither artifact supplied. const bibleTagEntries = bibleSources?.assetTagEntries ?? null; const assetTagLookup = buildAssetTagLookup({ characters: characterProfiles, assetUriByName, // On seedance-direct a raw character portrait both fails submit (needs a // hosted URL / Asset:// URI) and trips the real-person filter, so an @Name // tag only injects a reference when the character is registered in the Asset // Library — its descriptor text still substitutes. Off-route: unchanged. assetUriOnly: plan.recommendedRouteId === 'seedance-direct', environmentsByName: (await readEnvironmentAssets(workspace.root, projectSlug)).environmentEntryByName, voicesByName, // Show-bible cast/location descriptors + refs — guarded back-fill (only // names not already supplied by characters.json / environments). ...(bibleTagEntries ? { showBibleByName: bibleTagEntries } : {}), }); // omni-flash First-Frame is gated: when the operator has not opted in, a // stored scene.firstFrame is dropped (warned) so behavior is byte-identical. const omniFirstFrameEnabled = isOmniFirstFrameEnabled(options.env ?? process.env); const droppedFirstFrameScenes: number[] = []; const tasks: VideoExecutionTask[] = (storyboard.scenes ?? []) .filter((scene) => { if (!sceneFilter) return true; const sceneIndex = scene.sceneIndex ?? 0; return sceneFilter.has(sceneIndex); }) .map((scene) => { if (scene.firstFrame && !omniFirstFrameEnabled) { droppedFirstFrameScenes.push(scene.sceneIndex ?? 0); } const sceneIndex = scene.sceneIndex ?? 0; const sceneAssets = assetsByScene.get(sceneIndex) ?? []; const chainSeed = chainSeedsByScene.get(sceneIndex); const promptPacket = readyPromptPacketsByScene.get(sceneIndex); const promptPacketReferencePaths = promptPacket ? promptPacket.references.map((reference) => reference.path ?? '') : []; // A chain seed is normally the prior scene's video, but on seedance-direct // it was hosted as a last-frame IMAGE above — so classify by its actual // extension rather than assuming a chain seed is always a video. const chainSeedIsVideo = chainSeed ? REFERENCE_VIDEO_EXTS.has((chainSeed.path.split('?')[0]?.match(/\.[^.\\/]+$/)?.[0] ?? '').toLowerCase()) : false; const hasVideo = chainSeedIsVideo || sceneAssets.some((asset) => asset.kind === 'video'); // A non-video chain seed (the seedance hosted last-frame image) counts as // an image input so `inputKind` resolves to 'image', not 'text'. const chainSeedIsImage = !!chainSeed && !chainSeedIsVideo; const hasImage = chainSeedIsImage || sceneAssets.some((asset) => asset.kind === 'image') || promptPacketReferencePaths.length > 0; // When we chain from the previous scene's output, the seed video path // must lead `referencePaths` so downstream adapters pick it as the // primary input. We also force `inputKind: 'video'`. const baseReferencePaths = unique( sceneAssets .filter((asset) => asset.kind === 'image' || asset.kind === 'video' || asset.kind === 'audio') .map((asset) => asset.path ?? ''), ); // A ready prompt packet supplies the IMAGE references, but the scene's // own audio/video reference assets must still survive — otherwise // reference_audios (and any non-packet video reference) silently // disappears from the provider payload for packet-driven scenes. const baseNonImageReferencePaths = unique( sceneAssets .filter((asset) => asset.kind === 'video' || asset.kind === 'audio') .map((asset) => asset.path ?? ''), ); const packetOrBaseReferencePaths = promptPacketReferencePaths.length > 0 ? unique([...promptPacketReferencePaths, ...baseNonImageReferencePaths]) : baseReferencePaths; // When the project has registered Seedance Asset Library avatars, this // scene's resolved cast/product Asset:// URIs become its reference set // (the proven identity-lock mechanism). Names that don't resolve are // dropped. Empty map (no artifact) -> falls through to today's behavior. // Resolve @Name tags in the scene prompt: substitute descriptors and // collect tagged references. No '@' tokens -> unchanged text, no refs. const rawPrompt = promptPacket?.promptText.trim() || scene.scenePrompt?.animationPrompt?.trim() || scene.description || ''; // veo-useapi auto-inject (useapi blog 260609): plan the character_1..7 // body slots FIRST (cast order, then tag-only registered names), then // rewrite each registered @Name tag into its canonical @character_N // marker BEFORE resolveAssetTags — the reserved marker grammar there // preserves the injected tokens verbatim. INTENDED behavior change: a // replaced @Name is no longer seen by resolveAssetTags, so its loose // portrait referencePath is NOT collected — the saved Flow character // already bundles its identity images, and the loose portrait would // waste the shared image-reference budget. Characters WITHOUT a Flow ref // keep today's descriptor-substitution path (incl. referencePath // collection). Tagless prompts: the planned refs equal today's list and // the text is unchanged → byte-identical payload. Off-route (empty map) // this block is skipped entirely. let flowSlotPlan: FlowCharacterSlotPlan | null = null; let taggedPrompt = rawPrompt; if (plan.recommendedRouteId === 'veo-useapi' && characterRefByName.size > 0) { flowSlotPlan = planFlowCharacterSlots(rawPrompt, scene.characters ?? [], characterRefByName); if (flowSlotPlan.overflow.length > 0) { // Operator-visible, never fatal (stdout stays clean JSON) — same // channel as the unresolved-tag and marker-strip warnings. console.warn( `Flow character slots are capped at 7; scene ${sceneIndex} drops: ${flowSlotPlan.overflow.join(', ')}.`, ); } taggedPrompt = injectFlowCharacterMarkers(rawPrompt, flowSlotPlan.slotByLowerName).text; } const tagResult = resolveAssetTags(taggedPrompt, assetTagLookup); let promptText = tagResult.text; for (const name of tagResult.unresolved) { // Operator-visible, never fatal (stdout stays clean JSON). console.warn(`@Name tag "@${name}" in scene ${sceneIndex} did not resolve to a known character/asset; left as plain text.`); } // Google Flow inline @-markers (useapi blog 260609) are a veo-useapi-only // grammar; on every other route the literal tokens would leak into the // provider prompt, so strip them and warn (same operator-visible channel // as unresolved tags, never fatal). On veo-useapi they pass through // untouched. Marker-free prompts are byte-identical either way. if (plan.recommendedRouteId !== 'veo-useapi' && extractFlowMarkers(promptText).length > 0) { const strippedMarkers = stripFlowMarkers(promptText); promptText = strippedMarkers.text; console.warn( `Flow @-markers are veo-useapi only; removed for ${plan.recommendedRouteId} in scene ${sceneIndex}: ${strippedMarkers.stripped.join(', ')}.`, ); } // Costume lock: for every scene character whose registered profile carries // a `costume`, append `Keep in exactly.` so the on-screen // wardrobe is bound to the locked costume rather than left to prose (the // dhoti colour-drift fix). No costumed scene character -> byte-identical. promptText = appendCostumeClauses(promptText, scene.characters ?? [], costumeByLowerName); // Standing render rules: bake the canonical no-speech / natural-motion / // nothing-appears block into the finalized prompt for EVERY route, after // @Name/Flow-marker resolution and the costume lock. Default-on; skipped // (byte-identical) when the manifest flag or env kill-switch disabled it. // Idempotent — re-running over a prompt that already carries the block is // a no-op (see appendStandingRenderRules). if (standingRulesEnabled) { promptText = appendStandingRenderRules(promptText); } // A character bound to a voice clone (`voice-clone --character`) auto-injects // its black-frame voice clip into every scene it appears in — the cartoon-show // voice-lock, no @tag required. The .mp4 routes into Seedance `reference_videos` // and is counted against the video budget below. No bound voice -> no-op. const sceneVoiceClips = unique(scene.characters ?? []) .map((name) => { const v = voiceClones?.voiceByCharacter.get(name.toLowerCase()); // Prefer the durable hosted URL — the remote seedance-direct API can't // read a local path. Fall back to the local clip when unhosted. return v?.hostedUrl ?? v?.clipPath; }) .filter((path): path is string => Boolean(path)); // Show-bible auto-attach (the cartoon-show method) — SEEDANCE family only. // For each scene cast member, attach its bible character-sheet image ref; // for the scene's matched location, attach its plate image ref. These join // the identity "winner" set (so referenceRole becomes 'character'). They // are submittable-only (resolveBibleRef + per-route gate already dropped // raw/local refs on seedance-direct). Off the seedance family / no bible -> // empty, byte-identical. Note we attach by scene.characters only (never the // whole show cast), which bounds the reference budget. const bibleImageRefs: string[] = []; if (bibleSources && showRouteFamily === 'seedance') { for (const name of unique(scene.characters ?? [])) { const ref = bibleSources.imageRefByName.get(name.toLowerCase()); if (ref) bibleImageRefs.push(ref); } const sceneText = `${scene.description ?? ''} ${scene.scenePrompt?.animationPrompt ?? ''}`; const matchedLocation = matchSceneLocation( sceneText, (showBible?.locations ?? []).map((l) => l.name), ); if (matchedLocation) { const plate = bibleSources.imageRefByName.get(matchedLocation); if (plate) bibleImageRefs.push(plate); } } // Image identity refs (Asset:// URIs + @tag image paths) are the "winner" set // that replaces the base/packet refs. Voice clips are NOT folded in here — // they are additive (see resolveSceneReferencePaths) so the identity image is // never dropped to make room for the voice (the runway talking-head case). const resolvedAssetUris = unique([ ...unique(scene.characters ?? []) .map((name) => assetUriByName.get(name)) .filter((uri): uri is string => Boolean(uri)), ...tagResult.referencedPaths, ...bibleImageRefs, ]); const referencePaths = resolveSceneReferencePaths({ resolvedAssetUris, chainSeedPath: chainSeed ? chainSeed.path : null, packetOrBaseReferencePaths, additionalReferencePaths: sceneVoiceClips, }); // Fail fast if the injected reference set exceeds the per-generation limits // (reuses the canonical budget; does not duplicate the limits). Runs whenever // this feature populated references — identity URIs OR a voice clip — so // non-asset payloads stay byte-identical to today. if (resolvedAssetUris.length > 0 || sceneVoiceClips.length > 0) { assertReferenceBudget(referencePaths); } const backendHints = unique([ ...sceneAssets.map((asset) => asset.backend ?? ''), ...(promptPacket ? ['filmmaking-prompts', `prompt-variant:${promptPacket.variant}`] : []), ]); const durationSeconds = promptPacket?.durationSeconds ?? scene.durationSeconds; // OUTPUT-DEPENDENT render resolution, threaded from the prompt packet. // Absent on legacy packets -> field omitted (no change to existing tasks). const resolution = promptPacket?.resolution; // Tag how these references should be DELIVERED so the transport never // turns a lone character sheet into the video's first frame (which made // solo clips open on the character grid). Asset-Library avatars and // filmmaking-prompts packet images are identity references ('character'); // a chain-from-prev seed is a continuity keyframe ('keyframe'); plain // ambiguous scene assets stay unset → today's first-frame behavior. const referenceRole: 'character' | 'keyframe' | undefined = resolvedAssetUris.length > 0 ? 'character' : chainSeed ? 'keyframe' : promptPacketReferencePaths.length > 0 ? 'character' : undefined; return { sceneIndex, prompt: promptText, inputKind: hasVideo ? 'video' : hasImage ? 'image' : 'text', referencePaths, ...(referenceRole ? { referenceRole } : {}), ...(promptPacket ? { referenceSlots: promptPacket.references.map((reference) => ({ slot: reference.slot, role: reference.role, label: reference.label, ...(reference.path ? { path: reference.path } : {}), })), promptPacketVariant: promptPacket.variant, } : {}), // Asset ids only — packet reference slot names are surfaced separately // via `referenceSlots` and must not pollute the asset-id provenance // contract (telemetry/reports join these back to the asset manifest). sourceAssetIds: unique(sceneAssets.map((asset) => asset.id ?? '')), backendHints, characters: unique(scene.characters ?? []), // veo-useapi only: scene characters registered in flow-characters.json // resolve to their Flow character refs (character_1..7). The slot plan // built above is the single source of truth — its ordering contract is // "cast order first, then tag-only characters", so a tagless scene // yields exactly the legacy cast-derived list. Empty/absent -> no // characterRefs field (byte-identical legacy on every other route). ...(flowSlotPlan && flowSlotPlan.orderedRefs.length > 0 ? { characterRefs: flowSlotPlan.orderedRefs } : {}), ...(Number.isFinite(durationSeconds) ? { durationSeconds } : {}), ...(resolution ? { resolution } : {}), ...(scene.voicePreset ? { voicePreset: scene.voicePreset } : {}), ...(scene.referenceVideoMediaId ? { referenceVideoMediaId: scene.referenceVideoMediaId } : {}), // omni-flash First-Frame carrier — only stamped when the operator has // opted in via VCLAW_OMNI_FIRST_FRAME. Gate off → a stored scene.firstFrame // is silently dropped here (warned below), so behavior is byte-identical. ...(scene.firstFrame && omniFirstFrameEnabled ? { firstFrame: true } : {}), ...(chainSeed ? { chainedFromCandidateId: chainSeed.sourceCandidateId } : {}), }; }); // PHASE-3 opt-in continuity loop. Only mutates prompts for tasks that carry a // chain-from-prev seed, and only when the caller opts in. Default-off path is // byte-identical to today (no Gemini call, no prompt change). if (options.continuityFeedback && chainSeedsByScene.size > 0) { // Deterministic continuity source + Gemini fallback: the project's // story-bible cast/setting/prop descriptors (graceful when absent). let storyBible: StoryBibleArtifact | null = null; const storyBiblePath = artifactPathFor(workspace, 'story-bible'); if (existsSync(storyBiblePath)) { try { storyBible = JSON.parse(await readFile(storyBiblePath, 'utf-8')) as StoryBibleArtifact; } catch { storyBible = null; } } const castDescriptors = (storyBible?.characters ?? []) .map((character) => (character.description ? `${character.name}: ${character.description}` : character.name)) .filter(Boolean); const settingDescriptors = (storyBible?.settings ?? []) .map((setting) => `${setting.name}: ${setting.description}`) .filter(Boolean); const propDescriptors = (storyBible?.props ?? []) .map((prop) => `${prop.name}: ${prop.description}`) .filter(Boolean); const priorDescriptors = [...castDescriptors, ...settingDescriptors, ...propDescriptors]; for (const task of tasks) { const chainSeed = chainSeedsByScene.get(task.sceneIndex); if (!chainSeed) continue; // Defense-in-depth: augmentPromptWithContinuity is documented to never // throw (it catches all Gemini/network/parse/readFile errors and falls // back to the deterministic path), but an unexpected escape (e.g. an OOM // or a V8-internal error from a very large base64 image conversion) must // NOT abort the whole payload build. Wrap per-task so one task degrading // leaves its prompt unchanged and the remaining tasks still process. try { const augmented = await augmentPromptWithContinuity({ nextPrompt: task.prompt, priorReferenceImagePath: chainSeed.path, priorDescriptors, ...(options.continuityFetcher ? { fetcher: options.continuityFetcher } : {}), env: options.env ?? process.env, }); // StoryCraft anti-drift: re-state the full descriptor block verbatim // (idempotent — won't double-inject if already present). task.prompt = repasteContinuityDescriptors(augmented.prompt, { cast: castDescriptors, settings: settingDescriptors, props: propDescriptors, }); } catch (error) { // Preserve the documented degradation contract unconditionally: keep // the original prompt for this task and continue with the rest. // eslint-disable-next-line no-console console.warn( `[continuity] scene ${task.sceneIndex} continuity augmentation failed; using original prompt: ${ error instanceof Error ? error.message : String(error) }`, ); } } } const outputDir = join(workspace.projectDir, 'outputs'); await mkdir(outputDir, { recursive: true }); // Pre-submission route-capability check. Validates only the profile-level // resolution (always 720p|1080p, both in every live route's matrix) and the // worst-case per-task reference-budget counts against the target route's // declared limits. Refs are already capped downstream by the transports at // the same numbers, so a valid payload is never newly rejected here; this // only fails fast on a genuinely out-of-matrix combo (and downgrades to // warnings when VCLAW_ALLOW_UNSAFE_MODELS is set). Durations are intentionally // not validated here because the transports clamp per-task durations. const maxImageRefs = tasks.reduce( (max, task) => Math.max(max, countReferencesByKind(task.referencePaths).images), 0, ); const maxVideoRefs = tasks.reduce( (max, task) => Math.max(max, countReferencesByKind(task.referencePaths).videos), 0, ); // Audio refs are intentionally NOT validated here: some transports (e.g. // runway-useapi) silently ignore audio reference assets today rather than // erroring, so failing on them would change existing behavior. Image/video // ref budgets are the proven, transport-enforced limits. // Advisory: a stored storyboard requested omni-flash First-Frame but the gate // is off, so those scenes fall back to legacy R2V. Emit to stderr (JSON stdout // stays clean) so the operator knows why nothing changed. if (droppedFirstFrameScenes.length > 0) { console.warn( `[vclaw] omni-flash First-Frame requested for scene(s) [${droppedFirstFrameScenes.join(', ')}] but VCLAW_OMNI_FIRST_FRAME is not set — ignoring (legacy R2V). Set VCLAW_OMNI_FIRST_FRAME=1 to enable (build-ahead; unverified-live until useapi.net ships omni-flash frames mode).`, ); } // veo-useapi model gate: voice narration is omni-flash-only. We resolve the // model the transport will actually pass (veoModel ?? quality) and flag any // scene voice preset. hasVideoRef (dedicated V2V edit, not the scene-chaining // seed) is wired once referenceVideoMediaId exists. Both are no-ops for legacy // payloads (no voicePreset / non-veo route) so nothing is newly rejected. assertRouteRequestValid( plan.recommendedRouteId, { resolution: plan.executionProfile.resolution, imageRefs: maxImageRefs, videoRefs: maxVideoRefs, veoModel: plan.executionProfile.veoModel ?? plan.executionProfile.quality, hasVoice: tasks.some((task) => !!task.voicePreset), hasVideoRef: tasks.some((task) => !!task.referenceVideoMediaId), // First-Frame is already gated when stamping task.firstFrame above, so any // present firstFrame means the operator opted in — it satisfies the // omni-flash voice-needs-a-visual-anchor precondition. hasStartImage: tasks.some((task) => !!task.firstFrame), }, { env: options.env ?? process.env }, ); return { workspaceRoot: workspace.root, projectSlug, productionMode: plan.productionMode, routeId: plan.recommendedRouteId, operationKind: plan.operationKind, executionProfile: plan.executionProfile, generatedAt: new Date().toISOString(), outputDir, tasks, promptGuidance: plan.promptGuidance, }; } function isExecutionReadyPromptPacket(packet: FilmmakingSeedancePacket): boolean { if (!packet.promptText.trim()) return false; if (packet.references.some((reference) => reference.status !== 'ready')) return false; if (packet.references.some((reference) => !reference.path?.trim())) return false; return true; } export async function submitExecutionPayload( payload: VideoExecutionPayload, options: { env?: NodeJS.ProcessEnv; } = {}, ): Promise<{ adapterCommand: string; externalJobId: string | null; rawResult: unknown; }> { const env = options.env ?? process.env; const adapterCommand = resolveAdapterCommand(payload.routeId, env); const result = await new Promise<{ stdout: string; stderr: string; code: number | null; }>((resolve, reject) => { const child = spawn('sh', ['-lc', adapterCommand], { env, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', reject); child.on('close', (code) => { resolve({ stdout, stderr, code }); }); child.stdin.write(JSON.stringify(payload)); child.stdin.end(); }); if (result.code !== 0) { throw new Error(`Adapter command failed for ${payload.routeId}: ${result.stderr.trim() || `exit ${result.code}`}`); } const trimmed = result.stdout.trim(); let rawResult: unknown = trimmed; if (trimmed) { try { rawResult = JSON.parse(trimmed) as unknown; } catch { rawResult = trimmed; } } const externalJobId = rawResult && typeof rawResult === 'object' && 'externalJobId' in rawResult ? String((rawResult as { externalJobId?: unknown }).externalJobId ?? '') || null : null; // Adapter submit contract (CLAUDE.md "Provider routes and adapters"): submit // MUST print JSON with an externalJobId. Silently accepting a malformed // response used to leave the scene permanently "submitted" with nothing to // poll — fail loudly instead. if (!externalJobId) { throw new Error( `Adapter submit for ${payload.routeId} returned no externalJobId ` + `(stdout: ${trimmed ? trimmed.slice(0, 200) : ''}). ` + 'Adapters must print JSON {"externalJobId":"..."} on submit.', ); } return { adapterCommand, externalJobId, rawResult, }; } export async function pollExecutionPayload( input: { projectSlug: string; routeId: ProviderRouteId; externalJobId: string; outputDir: string; // Required so the adapter can load `/.env.local` for provider // credentials (e.g. SUTUI_API_KEY) on the poll path — exactly as submit and // cancel already do. Omitting it made seedance-direct polls fail with // "requires SUTUI_API_KEY" even though submit succeeded. workspaceRoot: string; }, options: { env?: NodeJS.ProcessEnv; } = {}, ): Promise { const env = options.env ?? process.env; const adapterCommand = resolveAdapterCommand(input.routeId, env); const result = await new Promise<{ stdout: string; stderr: string; code: number | null; }>((resolve, reject) => { const child = spawn('sh', ['-lc', adapterCommand], { env, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', reject); child.on('close', (code) => { resolve({ stdout, stderr, code }); }); child.stdin.write(JSON.stringify({ action: 'poll', ...input, })); child.stdin.end(); }); if (result.code !== 0) { throw new Error(`Adapter poll failed for ${input.routeId}: ${result.stderr.trim() || `exit ${result.code}`}`); } const trimmed = result.stdout.trim(); let rawResult: unknown = trimmed; if (trimmed) { try { rawResult = JSON.parse(trimmed) as unknown; } catch { rawResult = trimmed; } } const status = rawResult && typeof rawResult === 'object' && 'status' in rawResult ? String((rawResult as { status?: unknown }).status ?? '') : ''; if (!(status === 'pending' || status === 'completed' || status === 'failed')) { throw new Error(`Adapter poll for ${input.routeId} returned invalid status: ${status || 'missing'}`); } const outputs = rawResult && typeof rawResult === 'object' && 'outputs' in rawResult && Array.isArray((rawResult as { outputs?: unknown }).outputs) ? ((rawResult as { outputs: Array<{ id?: unknown; kind?: unknown; path?: unknown; sceneIndex?: unknown; backend?: unknown }> }).outputs .filter((asset) => typeof asset.id === 'string' && typeof asset.kind === 'string' && typeof asset.path === 'string') .map((asset) => ({ id: String(asset.id), kind: ['image', 'video', 'audio', 'subtitle', 'other'].includes(String(asset.kind)) ? String(asset.kind) as 'image' | 'video' | 'audio' | 'subtitle' | 'other' : 'other', path: String(asset.path), ...(Number.isInteger(asset.sceneIndex) ? { sceneIndex: asset.sceneIndex as number } : {}), ...(typeof asset.backend === 'string' && asset.backend.trim() ? { backend: asset.backend } : {}), }))) : []; const issues = rawResult && typeof rawResult === 'object' && 'issues' in rawResult && Array.isArray((rawResult as { issues?: unknown }).issues) ? (rawResult as { issues: unknown[] }).issues.map((value) => String(value)) : []; const externalJobId = rawResult && typeof rawResult === 'object' && 'externalJobId' in rawResult ? String((rawResult as { externalJobId?: unknown }).externalJobId ?? '') || input.externalJobId : input.externalJobId; return { status, externalJobId, outputs, issues, rawResult, }; } export async function cancelExecutionPayload( input: { projectSlug: string; routeId: ProviderRouteId; externalJobId: string; outputDir: string; workspaceRoot: string; }, options: { env?: NodeJS.ProcessEnv; } = {}, ): Promise { const env = options.env ?? process.env; const adapterCommand = resolveAdapterCommand(input.routeId, env); const result = await new Promise<{ stdout: string; stderr: string; code: number | null; }>((resolve, reject) => { const child = spawn('sh', ['-lc', adapterCommand], { env, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', reject); child.on('close', (code) => { resolve({ stdout, stderr, code }); }); child.stdin.write(JSON.stringify({ action: 'cancel', ...input, })); child.stdin.end(); }); if (result.code !== 0) { throw new Error(`Adapter cancel failed for ${input.routeId}: ${result.stderr.trim() || `exit ${result.code}`}`); } const trimmed = result.stdout.trim(); let rawResult: unknown = trimmed; if (trimmed) { try { rawResult = JSON.parse(trimmed) as unknown; } catch { rawResult = trimmed; } } const status = rawResult && typeof rawResult === 'object' && 'status' in rawResult ? String((rawResult as { status?: unknown }).status ?? '') : ''; if (!(status === 'cancelled' || status === 'unsupported')) { throw new Error(`Adapter cancel for ${input.routeId} returned invalid status: ${status || 'missing'}`); } const issues = rawResult && typeof rawResult === 'object' && 'issues' in rawResult && Array.isArray((rawResult as { issues?: unknown }).issues) ? (rawResult as { issues: unknown[] }).issues.map((value) => String(value)) : []; const externalJobId = rawResult && typeof rawResult === 'object' && 'externalJobId' in rawResult ? String((rawResult as { externalJobId?: unknown }).externalJobId ?? '') || input.externalJobId : input.externalJobId; return { status: status as 'cancelled' | 'unsupported', externalJobId, issues, rawResult, }; }