/** * reapi-seedance.ts — the pure HTTP contract of ByteDance Seedance 2.5 on * reAPI, on its "Less Restriction" row (`content_filter:false`): a photograph * of a real person is accepted as the subject reference and a voice clip as * the speech reference. Read in full from https://reapi.ai/docs/seedance-2-5 * and the treg catalog row `reapi.video-gen.seedance-2-5.unrestricted` on * 2026-09-21; the plan file holds the extracted contract. * * Two ways to reach the same endpoint, chosen explicitly by the caller: * • `treg` — `POST https://treg.to/call/reapi.video-gen.seedance-2-5.unrestricted` * and `GET https://treg.to/call/reapi.tasks.get?id=`, * on the treg token; billed to the treg team balance. * • `direct` — `POST https://reapi.ai/api/v1/videos/generations` and * `GET https://reapi.ai/api/v1/tasks/`, on a reAPI * bearer key; billed to that reAPI account. * Submit and poll share one envelope: `{ id, model, status, created_at, * output:{ video_urls[], last_frame_url? }, error, usage:{ credits } }` * (1 credit = $0.001). Polling is free. There is no cancel, and there is no * endpoint that LISTS tasks — a task is only ever reachable by the id its own * create returned, which is why a lost create here can never be resolved from * the API (see `isAmbiguousReapiCreateError` and the transport's handling). * * This module does no I/O of its own beyond the injected `fetchImpl`, reads no * environment, and never hosts a file — the native transport does that. */ import { withRetry, fetchTransientRetry } from '../with-retry.js'; import { safeErrorBody } from '../http-error-safety.js'; import { REAPI_MAX_AUDIO_REFS, REAPI_MAX_DURATION_SEC, REAPI_MAX_IMAGE_REFS, REAPI_MAX_VIDEO_REFS, REAPI_MIN_DURATION_SEC, } from '../provider-platform/route-capabilities.js'; import { describeTregHttpError, isTregSaturated, tregCallEndpoint, tregHeaders, type TregAuth } from './treg-client.js'; export const REAPI_SEEDANCE_MODEL = 'doubao-seedance-2.5-face'; export const REAPI_TREG_SUBMIT_ENDPOINT_ID = 'reapi.video-gen.seedance-2-5.unrestricted'; export const REAPI_TREG_TASK_ENDPOINT_ID = 'reapi.tasks.get'; export const REAPI_DIRECT_BASE_URL = 'https://reapi.ai/api/v1'; /** Reference audio and reference video are each capped at 30 s combined (docs/seedance-2-5). */ export const REAPI_MAX_AUDIO_SECONDS_COMBINED = 30; export const REAPI_MAX_VIDEO_SECONDS_COMBINED = 30; /** Each reference clip and audio track must be 2–30 s on its own. */ export const REAPI_MIN_REFERENCE_SECONDS = 2; export type ReapiResolution = '480p' | '720p' | '1080p'; export type ReapiSize = 'adaptive' | '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9'; export type ReapiFrameRole = 'first_frame' | 'last_frame' | 'reference_image'; export type ReapiFetchLike = ( input: string, init?: { method?: string; headers?: Record; body?: string | Uint8Array }, ) => Promise<{ ok: boolean; status: number; text: () => Promise; json: () => Promise; headers?: { get(name: string): string | null }; }>; /** Where a call goes and what it carries for auth. Chosen by the caller, never inferred. */ export type ReapiTransportTarget = | { kind: 'treg'; auth: TregAuth } | { kind: 'direct'; apiKey: string; baseUrl?: string }; export interface ReapiSubmitBodyInput { prompt: string; /** Whole seconds, 4–30. Never -1: auto length reserves 30 s of credit. */ duration: number; resolution: ReapiResolution; size: ReapiSize; /** Plain reference images (`@image1..N` in send order). Exclusive with `imageWithRoles`. */ imageUrls?: string[]; /** First/last frame stills. Exclusive with `imageUrls`; forces `size:'adaptive'`. */ imageWithRoles?: Array<{ url: string; role: ReapiFrameRole }>; /** Reference clips (`@video1..N`); BILLED on top of the output. */ videoUrls?: string[]; /** Reference audio (`@audio1..N`); free; drives the generated speech. */ audioUrls?: string[]; generateAudio?: boolean; /** Default true here: the last frame is free and seeds chain-from-prev. */ returnLastFrame?: boolean; seed?: number; } function assertPublicHttpsUrl(url: string, field: string): void { if (!/^https?:\/\//i.test(url)) { throw new Error(`reapi-seedance ${field} must be a public http(s) URL the provider can fetch, got: ${url}`); } } /** * Build the JSON body for `POST /videos/generations`, enforcing every rule the * docs check at submit so a violation fails here (free) rather than there: * • `prompt` is required on the unmoderated route in every mode; * • `duration` is a whole second 4–30; * • `image_urls` and `image_with_roles` never travel together (HTTP 400 code 20003); * • at most one `first_frame` and one `last_frame`, a last frame needs a first * frame, frame roles never share an array with `reference_image`; * • a frame role forces `size:'adaptive'`; * • the per-kind reference caps. */ export function buildReapiSubmitBody(input: ReapiSubmitBodyInput): Record { const prompt = input.prompt.trim(); if (!prompt) { throw new Error('reapi-seedance requires a prompt: content_filter:false makes it mandatory in every mode, including reference jobs.'); } if (prompt.length > 20_000) { throw new Error(`reapi-seedance prompt is ${prompt.length} characters; the limit is 20,000.`); } if (!Number.isInteger(input.duration) || input.duration < REAPI_MIN_DURATION_SEC || input.duration > REAPI_MAX_DURATION_SEC) { throw new Error( `reapi-seedance duration must be a whole second ${REAPI_MIN_DURATION_SEC}–${REAPI_MAX_DURATION_SEC} (got ${input.duration}); -1 (auto) is never sent because it reserves 30 s of credit.`, ); } const imageUrls = (input.imageUrls ?? []).filter(Boolean); const imageWithRoles = (input.imageWithRoles ?? []).filter((entry) => entry && entry.url); const videoUrls = (input.videoUrls ?? []).filter(Boolean); const audioUrls = (input.audioUrls ?? []).filter(Boolean); if (imageUrls.length > 0 && imageWithRoles.length > 0) { throw new Error('reapi-seedance: image_urls and image_with_roles are mutually exclusive (provider HTTP 400 code 20003); send one or the other.'); } if (imageUrls.length > REAPI_MAX_IMAGE_REFS || imageWithRoles.length > REAPI_MAX_IMAGE_REFS) { throw new Error(`reapi-seedance accepts at most ${REAPI_MAX_IMAGE_REFS} image references.`); } if (videoUrls.length > REAPI_MAX_VIDEO_REFS) { throw new Error(`reapi-seedance accepts at most ${REAPI_MAX_VIDEO_REFS} video references.`); } if (audioUrls.length > REAPI_MAX_AUDIO_REFS) { throw new Error(`reapi-seedance accepts at most ${REAPI_MAX_AUDIO_REFS} audio references.`); } const firstFrames = imageWithRoles.filter((entry) => entry.role === 'first_frame'); const lastFrames = imageWithRoles.filter((entry) => entry.role === 'last_frame'); const roleRefs = imageWithRoles.filter((entry) => entry.role === 'reference_image'); if (firstFrames.length > 1 || lastFrames.length > 1) { throw new Error('reapi-seedance: at most one first_frame and one last_frame per request.'); } if (lastFrames.length === 1 && firstFrames.length === 0) { throw new Error('reapi-seedance: a last_frame always needs a first_frame (there is no last-frame-only task).'); } if (roleRefs.length > 0 && (firstFrames.length > 0 || lastFrames.length > 0)) { throw new Error('reapi-seedance: frame roles and reference_image cannot share one image_with_roles array; put reference images in image_urls.'); } const hasFrameRole = firstFrames.length > 0; if (hasFrameRole && input.size !== 'adaptive') { throw new Error(`reapi-seedance: a first_frame/last_frame forces size 'adaptive' (got '${input.size}').`); } for (const url of imageUrls) assertPublicHttpsUrl(url, 'image_urls'); for (const entry of imageWithRoles) assertPublicHttpsUrl(entry.url, 'image_with_roles'); for (const url of videoUrls) assertPublicHttpsUrl(url, 'video_urls'); for (const url of audioUrls) assertPublicHttpsUrl(url, 'audio_urls'); const body: Record = { model: REAPI_SEEDANCE_MODEL, content_filter: false, prompt, duration: input.duration, resolution: input.resolution, size: input.size, generate_audio: input.generateAudio ?? true, return_last_frame: input.returnLastFrame ?? true, }; if (imageUrls.length > 0) body.image_urls = imageUrls; if (imageWithRoles.length > 0) body.image_with_roles = imageWithRoles.map((entry) => ({ url: entry.url, role: entry.role })); if (videoUrls.length > 0) body.video_urls = videoUrls; if (audioUrls.length > 0) body.audio_urls = audioUrls; if (Number.isInteger(input.seed)) body.seed = input.seed; return body; } function directBaseUrl(target: Extract): string { return (target.baseUrl ?? REAPI_DIRECT_BASE_URL).replace(/\/+$/, ''); } export function reapiSubmitEndpoint(target: ReapiTransportTarget): string { if (target.kind === 'treg') return tregCallEndpoint(target.auth, REAPI_TREG_SUBMIT_ENDPOINT_ID); return `${directBaseUrl(target)}/videos/generations`; } export function reapiTaskEndpoint(target: ReapiTransportTarget, taskId: string): string { if (target.kind === 'treg') return tregCallEndpoint(target.auth, REAPI_TREG_TASK_ENDPOINT_ID, { id: taskId }); return `${directBaseUrl(target)}/tasks/${encodeURIComponent(taskId)}`; } export function reapiAuthHeaders(target: ReapiTransportTarget): Record { if (target.kind === 'treg') return tregHeaders(target.auth); return { Authorization: `Bearer ${target.apiKey}` }; } function describeHttpFailure(target: ReapiTransportTarget, status: number, bodyText: string, retryAfter: string | null): string { if (target.kind === 'treg') return describeTregHttpError(status, bodyText, retryAfter); return `reAPI HTTP ${status}: ${safeErrorBody(bodyText)}`; } export type ReapiPollStatus = 'pending' | 'completed' | 'failed'; /** `processing` (and anything unknown) is pending; only `completed`/`failed` are terminal. */ export function mapReapiStatus(raw: string): ReapiPollStatus { const s = raw.trim().toLowerCase(); if (s === 'completed' || s === 'succeeded' || s === 'success') return 'completed'; if (s === 'failed' || s === 'error' || s === 'cancelled' || s === 'canceled') return 'failed'; return 'pending'; } export interface ReapiTaskView { taskId: string | null; status: ReapiPollStatus; videoUrl: string | null; lastFrameUrl: string | null; /** Credits the task settled at (1 credit = $0.001), when reported. */ credits: number | null; error: string | null; raw: Record; } /** Parse the shared submit/poll envelope. Tolerates a missing `output`/`usage`. */ export function parseReapiTaskResponse(json: unknown): ReapiTaskView { const record = (json && typeof json === 'object' ? json : {}) as Record; const output = (record.output && typeof record.output === 'object' ? record.output : {}) as Record; const usage = (record.usage && typeof record.usage === 'object' ? record.usage : {}) as Record; const videoUrls = Array.isArray(output.video_urls) ? output.video_urls.filter((v): v is string => typeof v === 'string') : []; let error: string | null = null; if (typeof record.error === 'string' && record.error.trim()) error = record.error; else if (record.error && typeof record.error === 'object') { const err = record.error as Record; error = typeof err.message === 'string' ? err.message : JSON.stringify(err).slice(0, 300); } return { taskId: typeof record.id === 'string' ? record.id : null, status: mapReapiStatus(typeof record.status === 'string' ? record.status : ''), videoUrl: videoUrls[0] ?? null, lastFrameUrl: typeof output.last_frame_url === 'string' ? output.last_frame_url : null, credits: typeof usage.credits === 'number' ? usage.credits : null, error, raw: record, }; } /** 1 credit = $0.001. */ export function reapiCreditsToUsd(credits: number): number { return Math.round(credits) / 1000; } /** * A create that did not come back with a usable task id, carrying what the * caller needs to decide whether the vendor might still have taken (and billed) * the job. `kind` says which way the create ended: * • `http` — the provider/relay answered with a non-2xx. * • `no-task-id` — a 2xx whose body carried no id: the task may exist under * an id we never learned, which on reAPI is unrecoverable. * • `refused` — a 2xx that came back already `failed`: the vendor said no * (and refunds it); `taskId` names it when one was given. */ export class ReapiCreateError extends Error { readonly kind: 'http' | 'no-task-id' | 'refused'; readonly status?: number; readonly bodyText?: string; readonly taskId?: string; /** The answer itself said nothing was billed (treg 402 out of balance, 503 saturated). */ readonly retrySafe: boolean; constructor(message: string, init: { kind: 'http' | 'no-task-id' | 'refused'; status?: number; bodyText?: string; taskId?: string; retrySafe?: boolean; }) { super(message); this.name = 'ReapiCreateError'; this.kind = init.kind; if (init.status !== undefined) this.status = init.status; if (init.bodyText !== undefined) this.bodyText = init.bodyText; if (init.taskId !== undefined) this.taskId = init.taskId; this.retrySafe = init.retrySafe ?? false; } } /** * Whether a failed create might still have created (and billed) a task. * * A 4xx is the vendor saying no before it queued anything, and treg's own * "nothing was billed" answers (402 out of balance, 503 `treg_saturated`) say * the relay never reached reAPI. Everything else — a network error, a timeout, * a 5xx from a gateway sitting in front of a queue that may have taken the job, * a 2xx with no id — is a LOST ANSWER, and on this route it is terminal: reAPI * publishes no endpoint that lists tasks, so nothing can look the task up later. * 408 (the request timed out server-side) and 499 (the client went away) are the * two 4xx that say nothing about whether the body was processed. */ export function isAmbiguousReapiCreateError(error: unknown): boolean { if (error instanceof ReapiCreateError) { if (error.kind === 'refused') return false; if (error.kind === 'no-task-id') return true; if (error.retrySafe) return false; if (error.status !== undefined && error.status >= 400 && error.status < 500) { return error.status === 408 || error.status === 499; } return true; } return true; } export async function submitReapiJob(input: { target: ReapiTransportTarget; body: Record; fetchImpl?: ReapiFetchLike; }): Promise<{ taskId: string; raw: Record }> { const fetchImpl = input.fetchImpl ?? (fetch as unknown as ReapiFetchLike); // A paid, non-idempotent POST: ONE attempt, never wrapped in withRetry — // replaying it on a gateway 5xx could reserve credits twice. const response = await fetchImpl(reapiSubmitEndpoint(input.target), { method: 'POST', headers: { ...reapiAuthHeaders(input.target), 'Content-Type': 'application/json' }, body: JSON.stringify(input.body), }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new ReapiCreateError( `reapi-seedance submit failed: ${describeHttpFailure(input.target, response.status, text, response.headers?.get('retry-after') ?? null)}`, { kind: 'http', status: response.status, bodyText: text, retrySafe: input.target.kind === 'treg' && (response.status === 402 || (response.status === 503 && isTregSaturated(text))), }, ); } const view = parseReapiTaskResponse(await response.json()); // The REFUSAL is read first: a body that already says `failed` is the vendor // answering, whether or not it named an id, and an id-less refusal must not be // mistaken for a lost answer (which on this route can never be resolved). if (view.status === 'failed') { throw new ReapiCreateError(`reapi-seedance submit was refused: ${view.error ?? JSON.stringify(view.raw).slice(0, 300)}`, { kind: 'refused', ...(view.taskId ? { taskId: view.taskId } : {}), }); } if (!view.taskId) { throw new ReapiCreateError(`reapi-seedance submit returned no task id: ${JSON.stringify(view.raw).slice(0, 300)}`, { kind: 'no-task-id' }); } return { taskId: view.taskId, raw: view.raw }; } export async function pollReapiJob(input: { target: ReapiTransportTarget; taskId: string; fetchImpl?: ReapiFetchLike; }): Promise { const fetchImpl = input.fetchImpl ?? (fetch as unknown as ReapiFetchLike); const response = await withRetry(() => fetchTransientRetry(fetchImpl, reapiTaskEndpoint(input.target, input.taskId), { method: 'GET', headers: reapiAuthHeaders(input.target), })); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(`reapi-seedance poll failed: ${describeHttpFailure(input.target, response.status, text, response.headers?.get('retry-after') ?? null)}`); } return parseReapiTaskResponse(await response.json()); }