/** * modelark.ts — the BytePlus ModelArk video-generation client behind the * `seedance-modelark` route (the international Volcengine Ark: the official * Dreamina Seedance 2.5 / 2.0 API). * * This is a PURE protocol module: it turns a `VideoExecutionTask` into the * exact `content[]` request ModelArk accepts, and wraps the three HTTP verbs. * It owns no job state and touches no disk — `native-modelark.ts` does that. * Nothing here is shared with `seedance-direct` (the xskill aggregator, a * different host, key, body shape and biller — ADR 0007): the two are kept * apart so one key can never silently mean two billers. * * Contract (docs.byteplus.com ModelArk 1520757 / 1521309 / 1521720 / * 2607688 / 2298881, read 2026-09-21): * POST /contents/generations/tasks → { id } * GET /contents/generations/tasks/{id} → { status, content.video_url, error, usage, … } * DELETE /contents/generations/tasks/{id} → {} (queued → cancelled; running cannot be cancelled; * a finished task's RECORD is deleted — never call it * on a task whose result has not been downloaded) * A first/last-frame task and an omni reference task are mutually exclusive in * one request; a first/last-frame task must use `ratio: 'adaptive'`. * `content.video_url` is valid for 24 hours and at most 100 downloads. */ import type { VideoExecutionPayload, VideoExecutionTask } from '../types.js'; import { type WithRetryOptions } from '../with-retry.js'; export declare const MODELARK_DEFAULT_BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/v3"; export declare const MODELARK_TASKS_PATH = "/contents/generations/tasks"; export declare const MODELARK_API_KEY_ENV = "ARK_API_KEY"; export declare const MODELARK_MODEL_ENV = "VCLAW_MODELARK_MODEL"; export declare const MODELARK_BASE_URL_ENV = "VCLAW_MODELARK_BASE_URL"; /** * How a chained scene continues the previous one. `first-frame` (default): the * previous clip's last frame becomes this scene's `first_frame`. `extend`: the * previous clip itself rides as `@Video 1` and the task is an explicit video * extension (`omni_reference_task_type: extend`) — the vendor bills the input * clip's seconds on top of the output, and documents the field for 2.5 only. */ export declare const MODELARK_CHAIN_MODE_ENV = "VCLAW_MODELARK_CHAIN_MODE"; export type ModelArkChainMode = 'first-frame' | 'extend'; /** * The planner options for the chain mode. An unknown value is carried as an * error and refused only for CHAINED scenes — a scene that does not chain (a * batch job, scene 0) is not blocked by a setting it never reads. */ export declare function modelArkChainOptions(env: NodeJS.ProcessEnv): { chainMode?: ModelArkChainMode; chainModeError?: string; }; /** The chain mode from the environment; unset means `first-frame`, anything unknown is refused, never guessed. */ export declare function resolveModelArkChainMode(env?: NodeJS.ProcessEnv): ModelArkChainMode; export type ModelArkModelId = 'dreamina-seedance-2-5-260628' | 'dreamina-seedance-2-0-fast-260128' | 'dreamina-seedance-2-0-mini-260615'; export declare const DEFAULT_MODELARK_MODEL: ModelArkModelId; export interface ModelArkModelLimits { family: '2.5' | '2.0'; /** Inclusive integer range of `duration` seconds the model accepts. */ durationRange: readonly [number, number]; maxImageRefs: number; maxVideoRefs: number; maxAudioRefs: number; /** Combined length of every reference video and audio clip, in seconds. */ maxReferenceSeconds: number; /** Each reference video must be at least this long (seconds). */ minReferenceVideoSeconds: number; /** Whether a request may carry audio references and nothing else. */ audioOnlyReferences: boolean; /** Output resolutions the model sells (the 2.0 fast/mini models stop at 720p). */ resolutions: readonly ModelArkResolution[]; /** * List price in USD per million output tokens, by resolution, for a request * WITHOUT and WITH a video input (the vendor bills the two differently). * From the ModelArk pricing page, read 2026-09-21; the 1080p launch discount * ended 2026-09-17, so these are the prices that apply. A resolution the * model does not sell has no row. The vendor's minimum-token floors for * video input are NOT modelled: this is the list rate × the tokens reported. */ usdPerMillionTokens: Readonly>>; } export type ModelArkResolution = '480p' | '720p' | '1080p'; /** * Per-model limits, copied from the vendor's create-task reference. These are * what the preflight enforces BEFORE the first paid submit; a model missing * here is refused rather than guessed at. */ export declare const MODELARK_MODELS: Record; /** * The list-price cost of one task, from the `usage` the vendor returns on a * succeeded task and the rate for (model, resolution, video input). The rate * is per OUTPUT token, so `completion_tokens` is read first and `total_tokens` * only when that is absent (on the one measured job both were 87,300 — a 4 s * 720p text-to-video clip on 2.5, ≈ 21,800 tokens per second, USD 0.934 — so * the vendor's precedence is unverified; if a prompt component ever appears, * this order under-reports rather than over-reports). Returns null when the * usage is not a finite token count, the resolution is not one the model * sells, or the fields are missing (a job-state file from before cost * accounting) — a cost is reported or absent, never guessed. Unrounded: the * job total is rounded once, by the caller. */ export declare function modelArkCostUsd(model: ModelArkModelId, resolution: ModelArkResolution | undefined, videoInput: boolean | undefined, usage: unknown): { tokens: number; usd: number; } | null; export declare function isModelArkModelId(value: unknown): value is ModelArkModelId; /** `VCLAW_MODELARK_MODEL` or the 2.5 default; an unknown id throws (never a silent fallback). */ export declare function resolveModelArkModel(env: NodeJS.ProcessEnv): ModelArkModelId; export declare function resolveModelArkApiKey(env: NodeJS.ProcessEnv): string; export declare function modelArkBaseUrl(env: NodeJS.ProcessEnv): string; export type ModelArkReferenceKind = 'image' | 'video' | 'audio'; export type ModelArkReferenceRole = 'first_frame' | 'last_frame' | 'reference_image' | 'reference_video' | 'reference_audio'; export interface ModelArkReferencePlan { kind: ModelArkReferenceKind; role: ModelArkReferenceRole; /** Local path, http(s) URL, or ModelArk `asset://` URI, as the task carried it. */ source: string; } export interface ModelArkTaskPlan { sceneIndex: number; model: ModelArkModelId; mode: 'text' | 'first-frame' | 'omni'; /** The prompt as it will be submitted (markers / frame directives already applied). */ prompt: string; ratio: '16:9' | '9:16' | '1:1' | 'adaptive'; resolution: '480p' | '720p' | '1080p'; duration: number; generateAudio: boolean; refs: ModelArkReferencePlan[]; omniReferenceTaskType?: 'auto' | 'extend'; /** Advisory notes the operator should see (never blocking). */ issues: string[]; } export interface ModelArkContentItem { type: 'text' | 'image_url' | 'video_url' | 'audio_url'; text?: string; image_url?: { url: string; }; video_url?: { url: string; }; audio_url?: { url: string; }; role?: ModelArkReferenceRole; } export interface ModelArkCreateBody { model: ModelArkModelId; content: ModelArkContentItem[]; resolution: ModelArkTaskPlan['resolution']; ratio: ModelArkTaskPlan['ratio']; duration: number; generate_audio: boolean; watermark: false; omni_reference_task_type?: 'auto' | 'extend'; } export type ModelArkTaskStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'expired'; export interface ModelArkTask { id: string; status: ModelArkTaskStatus | string; content?: { video_url?: string; last_frame_url?: string; }; error?: { code?: string; message?: string; } | null; usage?: unknown; [key: string]: unknown; } /** * Bucket references by kind, using the same extension rule the other transports * use so a voice-clone `.mp4` lands in the video bucket everywhere. A ModelArk * `asset://` digital character counts as an image. The xskill Asset Library's * `Asset://` URIs (capital A) belong to a different registry and are refused: * ModelArk has never heard of them, and sending one would fail after the * request was already billed a queue slot. */ export declare function classifyModelArkReferences(referencePaths: readonly string[]): { images: string[]; videos: string[]; audios: string[]; }; /** * ModelArk's omni mode wants each reference named in the prompt as * `@Image 1` / `@Video 2` / `@Audio 1` (1-based, in `content[]` order). The * vendor's own examples write both `@Image 1` and `@Image1`, so either spelling * counts as present. Missing markers are prepended; existing ones are left in * place and never duplicated. Pure. */ export declare function ensureModelArkMarkers(prompt: string, counts: { images: number; videos: number; audios: number; }): string; /** * Decide, without touching the network, exactly what one scene will ask * ModelArk for. This is the contract the operator reviews in a dry run and * the preflight that runs over EVERY scene before the first paid submit. * * Mode selection: * - no references → text-to-video, the profile's aspect ratio. * - one image, no video/audio, and the reference is a keyframe * (`referenceRole` !== 'character') → strict first-frame i2v * (`first_frame`, plus `last_frame` from `endKeyframePath`); the vendor * requires `ratio: 'adaptive'` here, so the profile aspect is not sent. * - anything else → omni reference-to-video. A keyframe that arrives with a * voice clip cannot use `first_frame` (the two modes cannot mix), so it * becomes `@Image 1` and the prompt is told it is the first frame — a SOFT * lock the vendor documents as the way to combine the two. */ export declare function planModelArkTask(task: VideoExecutionTask, profile: VideoExecutionPayload['executionProfile'], model: ModelArkModelId, options?: { chainMode?: ModelArkChainMode; chainModeError?: string; }): ModelArkTaskPlan; /** * The wire body. `resolvedUrls[i]` is what `plan.refs[i]` became after hosting * or inlining (see `modelark-references.ts`); order is preserved because the * `@Image N` markers count in `content[]` order. */ export declare function buildModelArkCreateBody(plan: ModelArkTaskPlan, resolvedUrls: readonly string[]): ModelArkCreateBody; export type ModelArkErrorClass = 'content-violation' | 'task-constraint' | 'other'; /** Group a vendor error so the transport can word its remedy; never used to retry a paid submit. */ export declare function classifyModelArkError(code: string | undefined, message: string | undefined): ModelArkErrorClass; export declare class ModelArkHttpError extends Error { readonly status: number; readonly code: string | undefined; readonly vendorMessage: string | undefined; readonly rawBody: string; constructor(status: number, code: string | undefined, vendorMessage: string | undefined, rawBody: string); } export interface ModelArkFetchResponse { ok: boolean; status: number; text(): Promise; json(): Promise; arrayBuffer(): Promise; } export type ModelArkFetchLike = (input: string, init?: { method?: string; headers?: Record; body?: string; }) => Promise; export interface ModelArkClientContext { fetchImpl: ModelArkFetchLike; baseUrl: string; apiKey: string; /** Retry policy for the idempotent verbs (GET / DELETE); tests inject `{ retries: 0 }`. */ retry?: Pick; } /** * ONE paid POST, never retried: the create endpoint bills and is not * idempotent, so replaying it on a gateway error could queue two charged * tasks. A transient failure surfaces to the caller as-is. */ export declare function modelArkCreateTask(ctx: ModelArkClientContext, body: ModelArkCreateBody): Promise<{ id: string; raw: unknown; }>; /** * Idempotent GET with transient retry (network errors and 5xx). Throws a * `ModelArkHttpError` on a 4xx and the transient error when the retries run * out — the poll isolates that per scene so one task's error never hides * another's progress. */ export declare function modelArkGetTask(ctx: ModelArkClientContext, taskId: string): Promise; /** * A task's `created_at` as unix seconds. It is documented as unix seconds; a * numeric string or an ISO string is read too, because a row silently dropped * from the list would let the resolver claim a task was never created. */ export declare function modelArkCreatedAtSeconds(value: unknown): number | undefined; export interface ModelArkTaskSummary { id: string; status: string; model?: string; /** Unix seconds. */ created_at?: number; } /** * `GET /contents/generations/tasks?filter.model=…` — the vendor's own list of * the key's tasks (last 7 days, newest first, up to 500 a page). It echoes no * client reference, so the only way to find a task whose create response was * lost is by model and creation time; the transport does that with a narrow * window and refuses to bind when the window holds more than one task. */ export declare function modelArkListTasks(ctx: ModelArkClientContext, filter: { model: string; status?: string; pageSize?: number; }): Promise; /** * Whether a failed create might still have created (and billed) a task. A 4xx * is the vendor saying no; anything else — a network error, a timeout, a 5xx * that outlived nothing (the create is never retried), a 2xx with no id — is * a lost answer, and the task may exist. */ export declare function isAmbiguousCreateError(error: unknown): boolean; /** * DELETE cancels a QUEUED task. It cannot stop a running one (the vendor * returns an error, which is reported here rather than thrown so the caller * can say "still billing"), and on a finished task it deletes the record — * which is why the transport only ever calls it on a scene it is still waiting for. * NEVER throws: a network error or a 5xx that outlives the retries comes back * as `{ ok: false, status, body }` too, because "we could not cancel it" is * exactly the answer the caller has to relay, not swallow. */ export declare function modelArkDeleteTask(ctx: ModelArkClientContext, taskId: string): Promise<{ ok: boolean; status: number; body: string; }>; //# sourceMappingURL=modelark.d.ts.map