/** * native-reapi.ts — the `reapi-seedance` transport: Seedance 2.5 "Less * Restriction" on reAPI, reached through treg or a direct reAPI key. * * What is different from every other native transport here: * • reAPI takes ONLY public https URLs for references (no upload endpoint, no * base64). Local references are HOSTED HERE, immediately before the HTTP * call — treg's media host on the `treg` path, Go Bananas R2 on `direct` — * and never earlier: the cinema quote hashes `referencePaths` as local * bytes and the approval hash binds to those bytes, so a URL must not * appear in the payload before submit. Hosted URLs are cached by content * hash under `/.vclaw-hosted/` with their expiry. * • the credential path is CHOSEN (`VCLAW_REAPI_SEEDANCE_VIA=treg|direct`); * an unset selector is the `blocked` transport and refuses to submit. * • the poll emits `actualCost` from the task's own `usage.credits`, which * the paid cinema worker requires. * • a reference VIDEO is billed on top of the output; the transport says so * in the submit warnings, and a voice should ride `audio_urls` (free). * • a create is recorded as an INTENT before the POST, and reAPI has NO * task-list endpoint: when the answer is lost the scene is `submit-unknown` * for good — the task may exist and may be billing, nothing is re-submitted, * and every poll repeats that (ADR 0008) until `execute-abandon`. "Nothing * billed" is never claimed for such a scene; it can be for the scenes whose * POST was never made. * * Injectable: `fetchImpl`, `hostImpl`, `probeDurationMs`, `now` — every test * runs offline. */ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { ffprobeDuration, runFfmpeg } from './assemble/ffmpeg.js'; import { writeTextFileAtomic } from './atomic-write.js'; import { explicitReapiSeedancePath, type ReapiSeedancePath } from './execution-adapter.js'; import { safeErrorBody } from './http-error-safety.js'; import { uploadMediaToGoBananas } from './media-host.js'; import { ensureOmniMarkers } from './native-dreamina.js'; import { assertRouteRequestValid } from './provider-platform/route-capabilities.js'; import { buildReapiSubmitBody, isAmbiguousReapiCreateError, pollReapiJob, ReapiCreateError, reapiCreditsToUsd, REAPI_MAX_AUDIO_SECONDS_COMBINED, REAPI_MAX_VIDEO_SECONDS_COMBINED, REAPI_MIN_REFERENCE_SECONDS, submitReapiJob, type ReapiFetchLike, type ReapiFrameRole, type ReapiResolution, type ReapiSize, type ReapiTransportTarget, } from './providers/reapi-seedance.js'; import { hostTregMedia, type TregAuth } from './providers/treg-client.js'; import type { VideoExecutionCancelResult, VideoExecutionPayload, VideoExecutionPollResult, VideoExecutionTask } from './types.js'; const ROUTE_ID = 'reapi-seedance' as const; interface FetchLikeResponse { ok: boolean; status: number; text(): Promise; json(): Promise; arrayBuffer(): Promise; headers?: { get(name: string): string | null }; } export type ReapiNativeFetchLike = (input: string, init?: { method?: string; headers?: Record; body?: string | Uint8Array; }) => Promise; /** Hosts one local file and returns the public URL the provider will fetch. */ export type ReapiHostImpl = (input: { path: string; bytes: Buffer; contentType: string; }) => Promise<{ url: string; expiresAt: string | null }>; export interface ReapiNativeOptions { env?: NodeJS.ProcessEnv; fetchImpl?: ReapiNativeFetchLike; /** Overrides the path's default host (treg /media or Go Bananas R2). */ hostImpl?: ReapiHostImpl; /** Overrides ffprobe for reference-length checks; returns milliseconds. */ probeDurationMs?: (path: string) => Promise; /** * Overrides ffmpeg for pulling the audio track out of a black-frame voice * video (`task.voiceReferencePaths`), so the voice rides `audio_urls` (free) * rather than `video_urls` (billed). Writes a wav to `outWavPath`. */ extractAudioImpl?: (videoPath: string, outWavPath: string) => Promise; now?: () => Date; } interface ReapiJobSceneState { sceneIndex: number; prompt: string; /** The provider's task id. Absent while a create is in flight, or when its answer was lost. */ taskId?: string; outputPath: string; lastFramePath: string; /** * `submitting`: the intent is on file and the POST is in flight (a file still * saying so after the process died means the answer was lost). * `submit-unknown`: the create's answer was lost; the task may exist and may * be billing, and reAPI offers no way to look it up. */ status: 'submitting' | 'submit-unknown' | 'submitted' | 'completed' | 'failed'; error?: string; /** When the create was attempted (ISO) — the only anchor an operator has for finding a lost task. */ intentAt?: string; /** What went wrong with the create whose answer was lost. */ submitError?: string; /** * The exact JSON body sent — with the hosted URLs — so the review contract can * be diffed against it. Absent only on a scene that was never attempted. */ submittedBody?: Record; /** local path → hosted URL, in the order the references were sent. */ hostedRefs: Array<{ path: string; url: string; expiresAt: string | null }>; credits: number | null; /** * Why this scene's charge can never be read. Set when the wait is abandoned: * the task may still settle and be billed, and nobody will collect its credits. * While any scene carries one, the job states no `actualCost` and repeats this * as an issue instead of presenting the settled siblings as the whole bill. */ costUnknownReason?: string; } interface ReapiNativeJobState { externalJobId: string; routeId: typeof ROUTE_ID; via: ReapiSeedancePath; outputDir: string; createdAt: string; scenes: ReapiJobSceneState[]; /** * The settled charge, once every completed scene has reported its credits — * the same top-level fields the Veo transport records, which is where * `scripts/live-acceptance.mjs` reads a route's cost evidence from. */ actualCost?: { currency: string; amount: number }; costSource?: 'usage.credits'; } // --------------------------------------------------------------------------- // environment // --------------------------------------------------------------------------- function readDotEnvLike(raw: string): Record { const out: Record = {}; for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue; const [key, ...rest] = trimmed.split('='); out[key.trim()] = rest.join('=').trim().replace(/^['"]|['"]$/g, ''); } return out; } async function loadWorkspaceEnv(workspaceRoot: string, env: NodeJS.ProcessEnv): Promise { const envLocalPath = join(workspaceRoot, '.env.local'); if (!existsSync(envLocalPath)) return env; return { ...readDotEnvLike(await readFile(envLocalPath, 'utf-8')), ...env }; } /** The workspace `.env.local` layered UNDER the process env — shared with the quote adapter. */ export const loadReapiWorkspaceEnv = loadWorkspaceEnv; export interface ResolvedReapiTarget { via: ReapiSeedancePath; target: ReapiTransportTarget; } /** * The credential path the environment names, and the auth it carries. Throws * when the selector names no path (the `blocked` transport) or the path's key * is missing — naming exactly what to set, never guessing from the other key. * * `submittedVia` is the path recorded in a job's saved state. A submit always * needs the selector; a poll of a job that already exists does not — refusing * it would strand a paid task (the same rule `resolveAdapterCommand` keeps for * poll/cancel/lookup). So with the selector unset the saved path is used, and * with it set to the OTHER path the poll is refused rather than answered 403. */ export function resolveReapiTarget(env: NodeJS.ProcessEnv, submittedVia?: ReapiSeedancePath): ResolvedReapiTarget { const selected = explicitReapiSeedancePath(env); if (selected !== null && submittedVia !== undefined && selected !== submittedVia) { throw new Error(`reapi-seedance: this job was submitted via '${submittedVia}' but VCLAW_REAPI_SEEDANCE_VIA is now '${selected}'; poll it on the path that submitted it (the task belongs to that account).`); } const via = selected ?? submittedVia ?? null; if (via === null) { throw new Error( 'reapi-seedance: VCLAW_REAPI_SEEDANCE_VIA must be `treg` (TREG_TOKEN, billed to the treg balance) or `direct` (REAPI_API_KEY); ' + 'the route never infers the path from whichever key is set.', ); } if (via === 'treg') { const token = (env.TREG_TOKEN ?? '').trim(); if (!token) throw new Error('reapi-seedance via treg requires TREG_TOKEN (the token `treg login` stores; `treg login --token` mints one for agents).'); const auth: TregAuth = { token, ...(env.TREG_ORG?.trim() ? { org: env.TREG_ORG.trim() } : {}), ...(env.TREG_BASE_URL?.trim() ? { baseUrl: env.TREG_BASE_URL.trim() } : {}), }; return { via, target: { kind: 'treg', auth } }; } const apiKey = (env.REAPI_API_KEY ?? '').trim(); if (!apiKey) throw new Error('reapi-seedance via direct requires REAPI_API_KEY (reapi.ai/settings/apikeys).'); return { via, target: { kind: 'direct', apiKey, ...(env.REAPI_BASE_URL?.trim() ? { baseUrl: env.REAPI_BASE_URL.trim() } : {}), }, }; } function requestedResolution( env: NodeJS.ProcessEnv, profile: VideoExecutionPayload['executionProfile']['resolution'], ): { resolution: ReapiResolution; overridden: boolean } { const raw = (env.VCLAW_REAPI_SEEDANCE_RESOLUTION ?? '').trim().toLowerCase(); if (raw === '480p' || raw === '720p' || raw === '1080p') return { resolution: raw, overridden: true }; return { resolution: profile === '1080p' ? '1080p' : '720p', overridden: false }; } function sizeFor(aspect: VideoExecutionPayload['executionProfile']['aspectRatio']): ReapiSize { if (aspect === '9:16') return '9:16'; if (aspect === '1:1') return '1:1'; return '16:9'; } // --------------------------------------------------------------------------- // references // --------------------------------------------------------------------------- const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff', '.tif', '.gif', '.heic', '.heif']); const VIDEO_EXT = new Set(['.mp4', '.mov', '.webm', '.avi', '.mkv']); const AUDIO_EXT = new Set(['.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg']); function extensionOf(path: string): string { return (path.split('?')[0]?.match(/\.[^.\\/]+$/)?.[0] ?? '').toLowerCase(); } export function classifyReapiReferences(referencePaths: string[]): { images: string[]; videos: string[]; audios: string[] } { const images: string[] = []; const videos: string[] = []; const audios: string[] = []; for (const path of referencePaths) { if (!path) continue; const ext = extensionOf(path); if (VIDEO_EXT.has(ext)) videos.push(path); else if (AUDIO_EXT.has(ext)) audios.push(path); else if (IMAGE_EXT.has(ext)) images.push(path); else images.push(path); // unknown → image, never silently dropped } return { images, videos, audios }; } function referenceContentType(path: string): string { const ext = extensionOf(path); if (ext === '.png') return 'image/png'; if (ext === '.webp') return 'image/webp'; if (ext === '.gif') return 'image/gif'; if (ext === '.bmp') return 'image/bmp'; if (ext === '.tiff' || ext === '.tif') return 'image/tiff'; if (ext === '.heic') return 'image/heic'; if (ext === '.heif') return 'image/heif'; if (ext === '.mp4') return 'video/mp4'; if (ext === '.mov') return 'video/quicktime'; if (ext === '.webm') return 'video/webm'; if (ext === '.mp3') return 'audio/mpeg'; if (ext === '.wav') return 'audio/wav'; if (ext === '.m4a' || ext === '.aac') return 'audio/aac'; if (ext === '.flac') return 'audio/flac'; if (ext === '.ogg') return 'audio/ogg'; return 'image/jpeg'; } function isRemoteUrl(path: string): boolean { return /^https?:\/\//i.test(path); } function isUnhostable(path: string): boolean { return path.startsWith('Asset://') || path.startsWith('gobananas://'); } const HOSTED_CACHE_DIR = '.vclaw-hosted'; /** A hosted URL is reused only while it has at least this long left to live. */ const HOSTED_EXPIRY_MARGIN_MS = 60 * 60 * 1000; interface HostedRefRecord { path: string; sha256: string; via: ReapiSeedancePath; url: string; expiresAt: string | null; hostedAt: string; } function sha256(bytes: Buffer): string { return createHash('sha256').update(bytes).digest('hex'); } function defaultHostImpl(resolved: ResolvedReapiTarget, env: NodeJS.ProcessEnv, fetchImpl: ReapiNativeFetchLike): ReapiHostImpl { if (resolved.target.kind === 'treg') { const auth = resolved.target.auth; return async ({ bytes, contentType }) => { const hosted = await hostTregMedia({ auth, bytes, contentType, fetchImpl: fetchImpl as unknown as Parameters[0]['fetchImpl'] }); return { url: hosted.url, expiresAt: hosted.expiresAt }; }; } return async ({ path }) => { const apiKey = (env.GO_BANANAS_API_KEY ?? '').trim(); if (!apiKey) { throw new Error('reapi-seedance via direct hosts references on Go Bananas R2 and needs GO_BANANAS_API_KEY (reAPI has no upload endpoint and takes only public https URLs).'); } const uploaded = await uploadMediaToGoBananas(path, { apiKey, ...(env.GO_BANANAS_API_URL ? { apiUrl: env.GO_BANANAS_API_URL } : {}) }); return { url: uploaded.url, expiresAt: null }; }; } /** * Resolve one reference to a public URL: a remote URL passes through; a local * file is hosted (or its cached hosting reused while it has an hour left). */ async function hostReference( path: string, ctx: { outputDir: string; via: ReapiSeedancePath; hostImpl: ReapiHostImpl; now: () => Date }, ): Promise<{ url: string; expiresAt: string | null; reused: boolean }> { if (isRemoteUrl(path)) return { url: path, expiresAt: null, reused: true }; if (!existsSync(path)) throw new Error(`reapi-seedance reference not found on disk: ${path}`); const bytes = await readFile(path); const digest = sha256(bytes); const cacheDir = join(ctx.outputDir, HOSTED_CACHE_DIR); const cachePath = join(cacheDir, `${digest}.json`); if (existsSync(cachePath)) { try { const record = JSON.parse(await readFile(cachePath, 'utf-8')) as HostedRefRecord; const fresh = record.via === ctx.via && (record.expiresAt === null || Date.parse(record.expiresAt) - ctx.now().getTime() > HOSTED_EXPIRY_MARGIN_MS); if (fresh && typeof record.url === 'string') return { url: record.url, expiresAt: record.expiresAt, reused: true }; } catch { // a torn cache record is re-hosted, never trusted } } const hosted = await ctx.hostImpl({ path, bytes, contentType: referenceContentType(path) }); await mkdir(cacheDir, { recursive: true }); const record: HostedRefRecord = { path, sha256: digest, via: ctx.via, url: hosted.url, expiresAt: hosted.expiresAt, hostedAt: ctx.now().toISOString() }; await writeTextFileAtomic(cachePath, `${JSON.stringify(record, null, 2)}\n`); return { url: hosted.url, expiresAt: hosted.expiresAt, reused: false }; } async function assertReferenceSeconds( paths: string[], kind: 'audio' | 'video', sceneIndex: number, probe: (path: string) => Promise, ): Promise { let total = 0; const cap = kind === 'audio' ? REAPI_MAX_AUDIO_SECONDS_COMBINED : REAPI_MAX_VIDEO_SECONDS_COMBINED; for (const path of paths) { if (isRemoteUrl(path)) continue; // cannot measure without downloading; the provider measures server-side const seconds = (await probe(path)) / 1000; if (seconds < REAPI_MIN_REFERENCE_SECONDS) { throw new Error(`reapi-seedance scene ${sceneIndex}: ${kind} reference ${path} is ${seconds.toFixed(2)} s; each must be at least ${REAPI_MIN_REFERENCE_SECONDS} s.`); } if (seconds > cap) { throw new Error(`reapi-seedance scene ${sceneIndex}: ${kind} reference ${path} is ${seconds.toFixed(1)} s; each must be at most ${cap} s.`); } total += seconds; } if (total > cap) { throw new Error(`reapi-seedance scene ${sceneIndex}: ${kind} references total ${total.toFixed(1)} s; the combined limit is ${cap} s.`); } return total; } // --------------------------------------------------------------------------- // job state + download // --------------------------------------------------------------------------- function jobStateDir(outputDir: string): string { return join(outputDir, '.vclaw-jobs'); } function jobStatePath(outputDir: string, externalJobId: string): string { return join(jobStateDir(outputDir), `${externalJobId}.json`); } async function writeJobState(state: ReapiNativeJobState): Promise { await mkdir(jobStateDir(state.outputDir), { recursive: true }); await writeTextFileAtomic(jobStatePath(state.outputDir, state.externalJobId), `${JSON.stringify(state, null, 2)}\n`); } async function readJobState(outputDir: string, externalJobId: string): Promise { const path = jobStatePath(outputDir, externalJobId); if (!existsSync(path)) throw new Error(`reapi-seedance native job state not found for ${externalJobId}.`); const raw = await readFile(path, 'utf-8'); try { return JSON.parse(raw) as ReapiNativeJobState; } catch (error) { throw new Error(`reapi-seedance native job state for ${externalJobId} is corrupt (invalid JSON at ${path}): ${error instanceof Error ? error.message : String(error)}`); } } async function downloadToFile(fetchImpl: ReapiNativeFetchLike, url: string, outputPath: string): Promise { const response = await fetchImpl(url); if (!response.ok) { throw new Error(`reapi-seedance download failed (HTTP ${response.status}): ${safeErrorBody(await response.text().catch(() => ''))}`); } await mkdir(dirname(outputPath), { recursive: true }); const tmpPath = `${outputPath}.tmp`; try { await writeFile(tmpPath, Buffer.from(await response.arrayBuffer())); await rename(tmpPath, outputPath); } catch (error) { await unlink(tmpPath).catch(() => {}); throw error; } } // --------------------------------------------------------------------------- // submit // --------------------------------------------------------------------------- interface TaskWithExtras extends VideoExecutionTask { /** Set by the rap lane's driver: a motion donor clip, sent as a (billed) video reference. */ motionReferencePath?: string; } interface PlannedScene { task: TaskWithExtras; images: string[]; videos: string[]; /** Audio references in send order; a voice video's slot is filled after extraction. */ audios: string[]; /** Black-frame voice videos whose audio track is extracted and sent as audio. */ voiceVideos: string[]; frameMode: boolean; endKeyframe: string | null; duration: number; resolution: ReapiResolution; } function planScene(task: TaskWithExtras, warnings: string[], resolution: ReapiResolution, resolutionOverridden: boolean): PlannedScene { const voiceSet = new Set((task.voiceReferencePaths ?? []).filter(Boolean)); const classified = classifyReapiReferences(task.referencePaths); // A voice clip is audio whatever its container: a black-frame voice video is // extracted to a wav before hosting, never sent as a (billed) video reference. const voiceVideos = classified.videos.filter((path) => voiceSet.has(path)); const videos = classified.videos.filter((path) => !voiceSet.has(path)); const images = classified.images; const audios = [...classified.audios, ...voiceVideos]; const donor = task.motionReferencePath?.trim(); if (donor) videos.push(donor); const endKeyframe = task.endKeyframePath?.trim() || null; for (const path of [...images, ...videos, ...audios, ...(endKeyframe ? [endKeyframe] : [])]) { if (isUnhostable(path)) { throw new Error(`reapi-seedance scene ${task.sceneIndex}: ${path} is a provider-scoped reference (Asset:// / gobananas://) that reAPI cannot fetch; resolve it to a file or an https URL first.`); } } const prompt = task.prompt.trim(); if (!prompt) { throw new Error(`reapi-seedance scene ${task.sceneIndex}: the prompt is empty, and content_filter:false makes a prompt mandatory in every mode.`); } // A per-second route never invents a length: the storyboard (or the packet) // says how long the clip is, or the render does not happen. The first paid // probe went out at a 5 s default the operator never chose; never again. if (task.durationSeconds === undefined || task.durationSeconds === null) { throw new Error(`reapi-seedance scene ${task.sceneIndex}: no durationSeconds — this route bills per second and will not assume one. Set the scene length (vclaw video storyboard --duration , or the packet's duration) and re-plan.`); } const rawDuration = task.durationSeconds; // A packet-level resolution wins over the profile; the env probe override wins over both. const taskResolution = task.resolution?.trim().toLowerCase(); const sceneResolution: ReapiResolution = (taskResolution === '480p' || taskResolution === '720p' || taskResolution === '1080p') && !resolutionOverridden ? taskResolution : resolution; const validation = assertRouteRequestValid(ROUTE_ID, { durationSec: rawDuration, resolution: sceneResolution, imageRefs: images.length, videoRefs: videos.length, audioRefs: audios.length, }); for (const note of validation.warnings) warnings.push(`reapi-seedance scene ${task.sceneIndex}: ${note}`); // A single keyframe (role 'keyframe', the default) is the literal first frame; // a character sheet, or several images, is a reference set. The two cannot // mix in one request, so keyframe + character refs falls to reference mode // with the keyframe at @image1 — said out loud, never dropped. const isCharacter = task.referenceRole === 'character'; let frameMode = !isCharacter && images.length === 1; if (!isCharacter && images.length > 1) { warnings.push(`reapi-seedance scene ${task.sceneIndex}: ${images.length} images with the keyframe role — sent as reference images (image_urls) with the first at @image1, not as a locked first frame.`); frameMode = false; } if (endKeyframe && !frameMode) { warnings.push(`reapi-seedance scene ${task.sceneIndex}: endKeyframePath ignored — a last_frame needs exactly one first_frame, and this scene is in reference mode.`); } if (videos.length > 0) { warnings.push(`reapi-seedance scene ${task.sceneIndex}: ${videos.length} video reference(s) — reAPI bills the reference footage on top of the ${rawDuration} s output (at least ceil(5×${rawDuration}/3) s). A voice should ride audio_urls (free), not a video.`); } return { task, images, videos, audios, voiceVideos, frameMode, endKeyframe: frameMode ? endKeyframe : null, duration: rawDuration, resolution: sceneResolution }; } /** Pull the audio track of a black-frame voice video into a wav beside the hosted cache. */ async function ffmpegExtractAudio(videoPath: string, outWavPath: string): Promise { await runFfmpeg(['-y', '-i', videoPath, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '1', outWavPath]); } /** * Replace each black-frame voice video in a scene's audio list with its * extracted wav (cached by the video's content hash), so what is hosted and * measured is the audio itself. Returns the paths in send order. */ async function materialiseVoiceAudio( scene: PlannedScene, ctx: { outputDir: string; extractAudio: (videoPath: string, outWavPath: string) => Promise }, ): Promise { if (scene.voiceVideos.length === 0) return scene.audios; const cacheDir = join(ctx.outputDir, HOSTED_CACHE_DIR); await mkdir(cacheDir, { recursive: true }); const out: string[] = []; for (const path of scene.audios) { if (!scene.voiceVideos.includes(path)) { out.push(path); continue; } if (!existsSync(path)) throw new Error(`reapi-seedance voice reference not found on disk: ${path}`); const wav = join(cacheDir, `voice-${sha256(await readFile(path)).slice(0, 16)}.wav`); if (!existsSync(wav)) await ctx.extractAudio(path, wav); out.push(wav); } return out; } /** * The one sentence a lost create gets, at submit and on EVERY poll afterwards. * It never says "nothing was billed" — on this route that cannot be known — and * it names the only two things that can settle it: the vendor's own console and * `execute-abandon`. */ function describeLostCreate(scene: ReapiJobSceneState): string { return `reapi-seedance scene ${scene.sceneIndex}: the create's answer was lost (${scene.submitError ?? 'the process stopped mid-create'}), ` + 'so the task MAY exist and MAY be billing. Nothing was re-submitted. reAPI publishes no endpoint that lists tasks — only ' + '`GET /tasks/{id}`, which needs the id this create never returned — so this cannot be resolved by polling: look in the reAPI ' + `dashboard (or treg's call history, on the treg path) for a task created near ${scene.intentAt ?? 'an unknown time'}, then run ` + '`vclaw video execute-abandon` to stop waiting for it.'; } export async function submitReapiSeedanceNative( payload: VideoExecutionPayload, options: ReapiNativeOptions = {}, ): Promise<{ externalJobId: string; rawResult: unknown }> { const env = await loadWorkspaceEnv(payload.workspaceRoot, options.env ?? process.env); const resolved = resolveReapiTarget(env); const fetchImpl = options.fetchImpl ?? (fetch as unknown as ReapiNativeFetchLike); const hostImpl = options.hostImpl ?? defaultHostImpl(resolved, env, fetchImpl); const probe = options.probeDurationMs ?? ((path: string) => ffprobeDuration(path)); const extractAudio = options.extractAudioImpl ?? ffmpegExtractAudio; const now = options.now ?? (() => new Date()); const { resolution, overridden } = requestedResolution(env, payload.executionProfile.resolution); const baseSize = sizeFor(payload.executionProfile.aspectRatio); const externalJobId = `reapi-seedance-${now().getTime()}`; const warnings: string[] = []; // Plan and check EVERY task before any hosting or submit, so an invalid task // N never leaves tasks 0..N-1 charged. Voice videos are turned into audio // first (local, free), so the length check measures what will be sent. const planned = payload.tasks.map((task) => planScene(task as TaskWithExtras, warnings, resolution, overridden)); for (const scene of planned) { scene.audios = await materialiseVoiceAudio(scene, { outputDir: payload.outputDir, extractAudio }); await assertReferenceSeconds(scene.audios, 'audio', scene.task.sceneIndex, probe); await assertReferenceSeconds(scene.videos, 'video', scene.task.sceneIndex, probe); } const scenes: ReapiJobSceneState[] = []; const responses: unknown[] = []; const issues: string[] = []; const state: ReapiNativeJobState = { externalJobId, routeId: ROUTE_ID, via: resolved.via, outputDir: payload.outputDir, createdAt: now().toISOString(), scenes }; for (const [planIndex, scene] of planned.entries()) { const hostedRefs: ReapiJobSceneState['hostedRefs'] = []; const host = async (path: string): Promise => { const hosted = await hostReference(path, { outputDir: payload.outputDir, via: resolved.via, hostImpl, now }); hostedRefs.push({ path, url: hosted.url, expiresAt: hosted.expiresAt }); return hosted.url; }; const imageUrls: string[] = []; const imageWithRoles: Array<{ url: string; role: ReapiFrameRole }> = []; if (scene.frameMode) { imageWithRoles.push({ url: await host(scene.images[0]!), role: 'first_frame' }); if (scene.endKeyframe) imageWithRoles.push({ url: await host(scene.endKeyframe), role: 'last_frame' }); } else { for (const path of scene.images) imageUrls.push(await host(path)); } const videoUrls: string[] = []; for (const path of scene.videos) videoUrls.push(await host(path)); const audioUrls: string[] = []; for (const path of scene.audios) audioUrls.push(await host(path)); // `@imageN/@videoN/@audioN` name the references by send order; an exact // prompt is never rewritten, and operator markers are never duplicated. // `@imageN` names `image_urls` only; first/last frame stills carry roles, not slots. const prompt = scene.task.promptPolicy === 'exact' ? scene.task.prompt : ensureOmniMarkers(scene.task.prompt, { images: scene.frameMode ? 0 : imageUrls.length, videos: videoUrls.length, audios: audioUrls.length, }); const body = buildReapiSubmitBody({ prompt, duration: scene.duration, resolution: scene.resolution, size: scene.frameMode ? 'adaptive' : baseSize, ...(scene.frameMode ? { imageWithRoles } : { imageUrls }), videoUrls, audioUrls, generateAudio: payload.executionProfile.generateAudio, returnLastFrame: true, }); // The INTENT goes on file BEFORE the POST, and it carries the exact body: // reAPI has no task-list endpoint, so an answer lost after this point leaves // the intent as the only record that a task may have been created at all. const sceneState: ReapiJobSceneState = { sceneIndex: scene.task.sceneIndex, prompt, outputPath: join(payload.outputDir, `scene-${scene.task.sceneIndex}.mp4`), lastFramePath: join(payload.outputDir, `scene-${scene.task.sceneIndex}-last-frame.png`), status: 'submitting', intentAt: now().toISOString(), submittedBody: body, hostedRefs, credits: null, }; scenes.push(sceneState); await writeJobState(state); let submit: { taskId: string; raw: Record }; try { // Nothing but the POST belongs in this try: a hosting or planning failure // must not be recorded as a create that may have been billed. submit = await submitReapiJob({ target: resolved.target, body, fetchImpl: fetchImpl as unknown as ReapiFetchLike }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const ambiguous = isAmbiguousReapiCreateError(error); let notice: string; if (ambiguous) { // The answer was lost. The task MAY exist and MAY be billing, and reAPI // publishes nothing that could find it, so it is never re-submitted and // never called unbilled. sceneState.status = 'submit-unknown'; sceneState.submitError = message; notice = describeLostCreate(sceneState); } else { // The vendor (or the relay) refused before queueing anything for THIS // scene. A refusal that names a task id (a 2xx already `failed`) keeps // it, so the "refunded by the provider" claim can be audited. sceneState.status = 'failed'; sceneState.error = `reapi-seedance scene ${scene.task.sceneIndex}: ${message}`; if (error instanceof ReapiCreateError && error.taskId) sceneState.taskId = error.taskId; notice = `reapi-seedance scene ${scene.task.sceneIndex} was refused, so the scenes after it were not attempted: ${message}`; } // Whether this throws is decided by what is ALREADY in flight, not by the // kind of failure. With nothing billed yet, a refusal must be thrown: the // caller has to see a rejection (`render-scenes`' ladder escalates to the // next route on one, and a returned job would read as work to wait for). // Once an earlier scene is billing, a throw would leave the run `blocked` // with no job id — nothing would poll this file, nothing could abandon it, // and a paid task would be orphaned. So the job is RETURNED (ADR 0008). const billingAlready = scenes.some((other) => other !== sceneState && other.status === 'submitted' && other.taskId); if (!ambiguous && !billingAlready) { await writeJobState(state); throw error; } issues.push(notice); // Also a warning: `warnings` is the stream the callers PRINT (the rap // lane's door captures the transport's stderr and shows only this), and a // possibly-billing task is the last thing to let pass silently. warnings.push(notice); const because = ambiguous ? `scene ${scene.task.sceneIndex}'s create answer was lost first` : `scene ${scene.task.sceneIndex} was refused first`; for (const later of planned.slice(planIndex + 1)) { scenes.push({ sceneIndex: later.task.sceneIndex, prompt: later.task.prompt, outputPath: join(payload.outputDir, `scene-${later.task.sceneIndex}.mp4`), lastFramePath: join(payload.outputDir, `scene-${later.task.sceneIndex}-last-frame.png`), status: 'failed', error: `reapi-seedance scene ${later.task.sceneIndex} was not attempted: ${because}. Nothing billed for this scene.`, hostedRefs: [], credits: null, }); } await writeJobState(state); process.stderr.write(`[reapi-seedance] ${notice}\n`); break; } responses.push({ sceneIndex: scene.task.sceneIndex, taskId: submit.taskId, hostedRefs, body }); sceneState.taskId = submit.taskId; sceneState.status = 'submitted'; // Persisted after EVERY accepted submit: reAPI has no task-list endpoint, so // a task id lost to a later scene's failure could never be polled or collected. await writeJobState(state); } const ambiguousScenes = scenes.filter((scene) => scene.status === 'submit-unknown').map((scene) => scene.sceneIndex); return { externalJobId, rawResult: { externalJobId, via: resolved.via, resolution, // `intentAt` rides along: on an ambiguous scene it is the only anchor a // caller (or an operator in the reAPI dashboard) has to search by. submittedScenes: scenes.map((scene) => ({ sceneIndex: scene.sceneIndex, taskId: scene.taskId ?? null, status: scene.status, intentAt: scene.intentAt ?? null, })), ambiguousScenes, responses, ...(issues.length > 0 ? { issues } : {}), ...(warnings.length > 0 ? { warnings } : {}), }, }; } // --------------------------------------------------------------------------- // poll / abandon / cancel // --------------------------------------------------------------------------- export async function pollReapiSeedanceNative( input: { outputDir: string; externalJobId: string; workspaceRoot: string }, options: ReapiNativeOptions = {}, ): Promise { const env = await loadWorkspaceEnv(input.workspaceRoot, options.env ?? process.env); const state = await readJobState(input.outputDir, input.externalJobId); // The saved path, not the selector, names the account this task belongs to. const resolved = resolveReapiTarget(env, state.via); const fetchImpl = options.fetchImpl ?? (fetch as unknown as ReapiNativeFetchLike); const outputs: VideoExecutionPollResult['outputs'] = []; const issues: string[] = []; const rawResults: unknown[] = []; let anyPending = false; let anyFailed = false; // A lost create. It can never be resolved here (no task list), so it keeps the // job pending even beside a failed sibling: a terminal `failed` would stop the // polling that is the only place the operator is told the task may be billing. // `execute-abandon` is the exit, and nothing times it out. const ambiguousScenes: number[] = []; for (const scene of state.scenes) { if (scene.status === 'completed' && existsSync(scene.outputPath)) { outputs.push({ id: `generated-scene-${scene.sceneIndex}`, kind: 'video', path: scene.outputPath, sceneIndex: scene.sceneIndex, backend: ROUTE_ID }); continue; } if (scene.status === 'failed') { anyFailed = true; if (scene.error) issues.push(scene.error); continue; } const taskId = scene.taskId; if (scene.status === 'submitting' || scene.status === 'submit-unknown' || !taskId) { // No network call and no re-submit: there is nothing to ask for. A scene // left `submitting` by a process that died mid-create is the same case. if (scene.status !== 'submit-unknown') scene.status = 'submit-unknown'; issues.push(describeLostCreate(scene)); ambiguousScenes.push(scene.sceneIndex); anyPending = true; continue; } const polled = await pollReapiJob({ target: resolved.target, taskId, fetchImpl: fetchImpl as unknown as ReapiFetchLike }); rawResults.push({ sceneIndex: scene.sceneIndex, taskId, status: polled.status, credits: polled.credits }); if (polled.status === 'completed') { if (!polled.videoUrl) { scene.status = 'failed'; scene.error = `reapi-seedance scene ${scene.sceneIndex} completed without a video URL.`; issues.push(scene.error); anyFailed = true; continue; } if (!existsSync(scene.outputPath)) await downloadToFile(fetchImpl, polled.videoUrl, scene.outputPath); if (polled.lastFrameUrl && !existsSync(scene.lastFramePath)) { // Best effort: the last frame seeds chain-from-prev but is not the deliverable. await downloadToFile(fetchImpl, polled.lastFrameUrl, scene.lastFramePath).catch((error) => { issues.push(`reapi-seedance scene ${scene.sceneIndex}: last frame not saved (${error instanceof Error ? error.message : String(error)}).`); }); } scene.status = 'completed'; scene.credits = polled.credits; outputs.push({ id: `generated-scene-${scene.sceneIndex}`, kind: 'video', path: scene.outputPath, sceneIndex: scene.sceneIndex, backend: ROUTE_ID }); } else if (polled.status === 'failed') { scene.status = 'failed'; scene.error = `reapi-seedance scene ${scene.sceneIndex} failed: ${polled.error ?? JSON.stringify(polled.raw).slice(0, 400)} (refunded by the provider).`; issues.push(scene.error); anyFailed = true; } else { anyPending = true; } } // Failed outranks pending, as on every route — except while a create's answer // is lost, which keeps the job open until the operator abandons it. const status: VideoExecutionPollResult['status'] = ambiguousScenes.length > 0 ? 'pending' : anyFailed ? 'failed' : anyPending ? 'pending' : 'completed'; // The paid cinema worker refuses a completion without an authoritative cost, // so it is stated from the tasks' own settled credits — for every scene that // settled, whatever a sibling did (a refused scene is refunded; the others // were billed) — and its absence is said, not papered over with a guess. // An ambiguous scene is NOT settled: a total that leaves out a task which may // be billing would read as this job's charge, so nothing is stated at all. // An abandoned scene is the same case after the fact: its task may still // settle and be billed, and nobody is collecting its credits. const costUnknown = state.scenes.filter((scene) => typeof scene.costUnknownReason === 'string'); let actualCost: VideoExecutionPollResult['actualCost']; const settled = state.scenes.filter((scene) => scene.status === 'completed'); if (ambiguousScenes.length > 0 || costUnknown.length > 0) { if (ambiguousScenes.length > 0 && settled.length > 0) { issues.push( `reapi-seedance: actualCost is not stated while scene ${ambiguousScenes.join(', ')} may be billing on a task this route cannot look up; ` + 'the scenes that did settle are not the whole charge. Read it from `treg audit` / the reAPI dashboard.', ); } for (const scene of costUnknown) issues.push(scene.costUnknownReason!); delete state.actualCost; delete state.costSource; } else if (settled.length > 0) { const credits = settled.map((scene) => scene.credits); if (credits.every((value): value is number => typeof value === 'number')) { actualCost = { currency: 'USD', amount: reapiCreditsToUsd(credits.reduce((sum, value) => sum + value, 0)) }; } else { issues.push('reapi-seedance: the provider reported no usage.credits on at least one completed task, so actualCost is not stated; read the settled charge from `treg audit` / the reAPI dashboard.'); } } if (actualCost) { state.actualCost = actualCost; state.costSource = 'usage.credits'; } await writeJobState(state); return { status, externalJobId: input.externalJobId, outputs, issues, rawResult: { polls: rawResults, ambiguousScenes }, ...(actualCost ? { actualCost } : {}), }; } /** * Stop WAITING for scenes the provider has left in flight. Not a cancel — reAPI * publishes none, so the task keeps running and settles on its own — and no * network call is made. Covers a scene whose create answer was lost as well: * that is the ONLY exit for one, since this route can never resolve it. */ export async function abandonReapiSeedanceNativeJob( input: { outputDir: string; externalJobId: string; dryRun?: boolean }, ): Promise<{ abandonedScenes: number[]; untouchedScenes: Array<{ sceneIndex: number; status: string }> }> { const state = await readJobState(input.outputDir, input.externalJobId); const abandonedScenes: number[] = []; const untouchedScenes: Array<{ sceneIndex: number; status: string }> = []; for (const scene of state.scenes) { if (scene.status === 'submitted' || scene.status === 'submitting' || scene.status === 'submit-unknown') { const which = scene.taskId ? `task ${scene.taskId}` : 'a reAPI task that may or may not exist (its create\'s answer was lost, and reAPI publishes no task list; look in the reAPI dashboard ' + `— or treg's call history — for a task created near ${scene.intentAt ?? 'an unknown time'})`; scene.status = 'failed'; scene.error = `reapi-seedance scene ${scene.sceneIndex} was abandoned: nobody is waiting for ${which} any more. It was NOT cancelled; if it completes it is billed, and its clip will not be collected.`; // Its credits will never be read, so this job can no longer state a total: // a sum of the siblings that did settle would read as the whole bill. scene.costUnknownReason = `reapi-seedance scene ${scene.sceneIndex} was abandoned while ${which} was still in flight, so its charge is never collected and this job states no actualCost; read the settled charge from \`treg audit\` / the reAPI dashboard.`; abandonedScenes.push(scene.sceneIndex); } else { untouchedScenes.push({ sceneIndex: scene.sceneIndex, status: scene.status }); } } if (abandonedScenes.length > 0 && !input.dryRun) await writeJobState(state); return { abandonedScenes, untouchedScenes }; } export async function cancelReapiSeedanceNative( input: { outputDir: string; externalJobId: string; workspaceRoot: string }, ): Promise { // No cancel endpoint is published. Fail closed and leave job state alone so // `execute-status` still collects the render when it completes. return { status: 'unsupported', externalJobId: input.externalJobId, issues: [ 'reapi-seedance does not support cancel: reAPI publishes no cancel endpoint, so the task keeps running and settles on its own. Nothing was changed; `vclaw video execute-status` will still collect the render. To stop waiting for a task that will never finish, use `vclaw video execute-abandon`.', ], rawResult: null, }; }