# Seedance video generation

Source: https://platform.lyrouter.com/ai_gateway/docs/video-seedance/en
Last updated: 2026-10-19

Generate videos with Volcengine Ark's Seedance models: text to video, first-frame and first-and-last-frame video, video from reference images or a reference video, plus video editing and extension. Video generation runs as an asynchronous task: you get a task ID right after submitting, poll until the task completes, then download the video. The endpoints follow the shape of OpenAI's Videos API, and the gateway converts the common parameters into Volcengine Ark's format. The official OpenAI SDK's `videos.create()` can submit tasks directly; for querying and downloading, call the HTTP endpoints directly. For the task lifecycle in general, see [Asynchronous tasks](https://platform.lyrouter.com/ai_gateway/docs/async/en).

## Supported models

| Model ID | Highlights | Duration and resolution | Billing basis |
| --- | --- | --- | --- |
| `bytedance/doubao-seedance-2.5` | Most capable: up to 30 reference images, 1080p (10-bit) and `mov` output, and the task type can be declared with `omni_reference_task_type` | 4–30 s; 480p, 720p, 1080p | Per output token; tiered by resolution (480p / 720p in one tier, 1080p in another) and by whether a video is supplied as input |
| `bytedance/doubao-seedance-2.0-fast` | 2.0 series; up to 9 reference images; `mp4` output only | 4–15 s; 480p, 720p | Per output token; tiered by whether a video is supplied as input |
| `bytedance/doubao-seedance-2.0-mini` | Same capabilities as 2.0-fast | 4–15 s; 480p, 720p | Per output token; tiered by whether a video is supplied as input |

All three models support text to video, first-frame and first-and-last-frame video, image reference, video reference, video editing and extension, and video with sound. Output aspect ratios are 21:9, 16:9, 4:3, 1:1, 3:4, 9:16 or adaptive. A model must be enabled in the workspace's model marketplace before you can call it; the model marketplace shows the current prices.

## Endpoints

| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/v1/videos` | Submit a video generation task; returns a task ID right away |
| GET | `/v1/videos/{id}` | Query the task status |
| GET | `/v1/videos/{id}/content` | Download the video (302 redirect to the video URL) |
| DELETE | `/v1/videos/{id}` | Delete a finished task; a task in progress cannot be canceled |

The full URL is `https://api.lyrouter.com` followed by the path in the table.

## Authentication

Send your API key in a request header:

```text
Authorization: Bearer <API Key>
```

`Bearer` is case-sensitive. You can use `x-api-key: <API Key>` instead; when both are present, only `Authorization` is read. Create API keys on the workspace's **API Keys** page. Your API key is used only to authenticate with LYRouter and is never forwarded to the upstream provider. See [Base URLs and authentication](https://platform.lyrouter.com/ai_gateway/docs/call-gateway/en).

Any API key in the same workspace can query and download that workspace's tasks; a key from another workspace gets 404.

## Headers

| Header | Required | Description |
| --- | --- | --- |
| `Content-Type` | Required when submitting | `application/json`. `POST /v1/videos` also accepts `multipart/form-data`; see "Submitting a form" below |
| `X-Gateway-Strict-Parameters` | No | When `true`, a request containing parameters that would be dropped returns 400 instead, without submitting a task or charging; see [Gateway headers and extensions](https://platform.lyrouter.com/ai_gateway/docs/gateway-headers/en) |

## Submit a task

`POST /v1/videos`

Submits a video generation task. The gateway validates the parameters, converts them into Volcengine Ark's format and submits the task to the provider, then returns a task object with `status` `queued` right away; the video is generated in the background. The submit request itself incurs no cost.

### Request body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | Yes | A model ID from the table above. It must be a specific model ID: a virtual model `@vmodel/<name>` or `lyrouter/auto` returns 403, and a failure does not fall back to the workspace's Default Model |
| `prompt` | string | Yes | The prompt. Refer to reference materials as `@image1` and `@video1`; video editing and extension tasks rely on the prompt to express the intent; see "Task types and constraints" below |
| `seconds` | integer | No | Video duration in seconds; must be a positive integer. 4–30 for 2.5 and 4–15 for the 2.0 series; when omitted, the provider default applies. **Do not send it for video editing tasks** |
| `size` | string | No | Resolution, written as `WxH` (such as `1280x720`) or a tier name (`480p`, `720p`, `1080p`); see "The two ways to write `size`" below. `1080p` is supported only by 2.5 |
| `first_frame` | string | No | URL of the first-frame image (first-frame video); 1 image. The output aspect ratio follows the first-frame image |
| `last_frame` | string | No | URL of the last-frame image, used together with `first_frame` (first-and-last-frame video) |
| `reference_image` | string \| string[] | No | URLs of reference images: a string for one image, an array for several. Up to 30 for 2.5 and up to 9 for the 2.0 series |
| `input_reference` | string | No | URL of a reference **video**, used for reference-to-video, video editing and video extension. Only one URL is accepted; an array returns 400 |
| `generate_audio` | boolean | No | Whether to generate a video with sound; supported by all three models. On by default when omitted: the generated video has an audio track |
| `watermark` | boolean | No | When `true`, an "AI生成" (AI-generated) label is added to the bottom-right corner of the video; off by default |
| `output_format` | string | No | Output format: `mp4` (default) or `mov`. `mov` is supported only by 2.5 |
| `omni_reference_task_type` | string | No | Designed for 2.5; the 2.0 series currently does not reject it, and the task is generated and charged as usual. Declares the task type explicitly: `auto` (default), `reference`, `edit` or `extend`; see "Task types and constraints" below |

- Fields not in the table are dropped, and the `X-Gateway-Unsupported-Parameters` response header lists their names; with `X-Gateway-Strict-Parameters: true`, the request returns 400 instead. This includes Volcengine Ark's native `duration`, `resolution`, `ratio` and `content`; use `seconds`, `size` and the material fields above instead.
- This endpoint does not support `n`; each request generates one video.
- `reference_audio` (reference audio) is an exception: it is currently not forwarded to Seedance, sending it raises no error, and it does not appear in `X-Gateway-Unsupported-Parameters`; the reference audio is simply ignored. If you need reference audio, use [MiniMax H3 video generation](https://platform.lyrouter.com/ai_gateway/docs/video-minimax/en) instead. `source_video` is only for MiniMax H3 video upscaling; sending it to Seedance returns 400 `source_video is not supported for this model`.
- The provider must be able to reach the material URLs. Use public URLs for large files rather than Base64; the request body must not exceed 64 MB in total.

### Submitting a form

`POST /v1/videos` also accepts `multipart/form-data`, which is what the official OpenAI SDK's `videos.create()` uses. In a form, the gateway reads these fields: `model`, `prompt`, `seconds`, `size`, `input_reference`, `watermark`, `generate_audio`, `output_format` and `omni_reference_task_type`:

- **`input_reference` accepts only a URL; an uploaded file raises no error but is ignored**: pass a publicly reachable `https://` URL. Currently, when you upload a file, the task is generated as if there were no reference video and is charged as usual.
- `watermark` and `generate_audio` accept only the literal values `true` or `false`.
- Submit `first_frame`, `last_frame` and `reference_image` in a JSON request body. Currently these fields in a form are replaced with a placeholder value and forwarded to the provider; with `first_frame`, for example, the provider returns 400 `The parameter content[1].image_url specified in the request is not valid`, and the error does not point to the real cause.

### Request examples

The examples read the API key from the `LYROUTER_API_KEY` environment variable; to set it, see [Your first model call](https://platform.lyrouter.com/ai_gateway/docs/quickstart/en).

**Request examples**

cURL:
```bash
curl https://api.lyrouter.com/v1/videos \
  -H "Authorization: Bearer $LYROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "bytedance/doubao-seedance-2.0-mini",
    "prompt": "A corgi running across a lawn, with sunlight shining through the leaves",
    "size": "1280x720",
    "seconds": 5,
    "generate_audio": true
  }'
```

Python:
```python
import os
import httpx

resp = httpx.post(
    "https://api.lyrouter.com/v1/videos",
    headers={"Authorization": f"Bearer {os.environ['LYROUTER_API_KEY']}"},
    json={
        "model": "bytedance/doubao-seedance-2.0-mini",
        "prompt": "A corgi running across a lawn, with sunlight shining through the leaves",
        "size": "1280x720",
        "seconds": 5,
        "generate_audio": True,
    },
    timeout=60,
)
resp.raise_for_status()
dropped = resp.headers.get("X-Gateway-Unsupported-Parameters")
if dropped:
    print("These parameters did not take effect:", dropped)
task = resp.json()
print(task["id"], task["status"])
```

Node.js:
```js
const resp = await fetch("https://api.lyrouter.com/v1/videos", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LYROUTER_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "bytedance/doubao-seedance-2.0-mini",
    prompt: "A corgi running across a lawn, with sunlight shining through the leaves",
    size: "1280x720",
    seconds: 5,
    generate_audio: true,
  }),
});
if (!resp.ok) throw new Error(await resp.text());
const dropped = resp.headers.get("X-Gateway-Unsupported-Parameters");
if (dropped) console.log("These parameters did not take effect:", dropped);
const task = await resp.json();
console.log(task.id, task.status);
```

### Response

A successful submission returns HTTP 200 and a task object with `status` `queued`; for the fields, see "Retrieve a task" below. The submit response has no `usage`, because the cost is settled when the task completes. Below, the 400 is a `seconds` value that is not a positive integer, the 403 is a model that is not enabled in this workspace (or a virtual model or `lyrouter/auto`), and the 429 means the provider account has reached its limit of concurrent tasks.

**Response examples**

200:
```json
{
  "id": "aigwt_volcengine_videos_MXgno8VlzuzZtJl0kq83jA",
  "object": "video",
  "model": "doubao-seedance-2-0-mini-260615",
  "status": "queued",
  "progress": 0,
  "created_at": 1791950400,
  "expires_at": 1792555200,
  "seconds": 5,
  "size": "1280x720"
}
```

400:
```json
{
  "error": {
    "message": "seconds must be a positive whole number of seconds",
    "type": "invalid_request_error",
    "code": "invalid_parameter"
  }
}
```

403:
```json
{
  "error": {
    "message": "Model is not available in the model plaza",
    "type": "access_denied",
    "code": "model_not_available"
  }
}
```

429:
```json
{
  "error": {
    "message": "upstream concurrency full",
    "type": "rate_limit_error",
    "code": "upstream_concurrency_full"
  }
}
```

- Keep the returned `id`. If a query hits a network error or times out, keep querying with the same ID rather than resubmitting: resubmitting creates a new task and charges again.
- The concurrency limit behind 429 `upstream_concurrency_full` is shared per provider account, so switching to another API key does not help; resubmit later.

## Retrieve a task

`GET /v1/videos/{id}`

Reads the task's current status. The gateway polls the provider for progress in the background, and this endpoint only returns the status the gateway has recorded, so querying more often does not get you the result sooner.

### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The task ID returned at submission, starting with `aigwt_`. The provider's own task ID cannot be queried and returns 404 |

### Request examples

**Request examples**

cURL:
```bash
curl https://api.lyrouter.com/v1/videos/$TASK_ID \
  -H "Authorization: Bearer $LYROUTER_API_KEY"
```

Python:
```python
import os
import httpx

task_id = "aigwt_volcengine_videos_MXgno8VlzuzZtJl0kq83jA"
resp = httpx.get(
    f"https://api.lyrouter.com/v1/videos/{task_id}",
    headers={"Authorization": f"Bearer {os.environ['LYROUTER_API_KEY']}"},
    timeout=60,
)
resp.raise_for_status()
task = resp.json()
print(task["status"], task.get("usage"), task.get("error"))
```

Node.js:
```js
const taskId = "aigwt_volcengine_videos_MXgno8VlzuzZtJl0kq83jA";
const resp = await fetch(`https://api.lyrouter.com/v1/videos/${taskId}`, {
  headers: { Authorization: `Bearer ${process.env.LYROUTER_API_KEY}` },
});
if (!resp.ok) throw new Error(await resp.text());
const task = await resp.json();
console.log(task.status, task.usage, task.error);
```

### Response

When the task exists, the response is always HTTP 200, even if the task itself failed; rely on `status` for the outcome. A task ID that does not exist, is older than 7 days or belongs to another workspace returns 404.

**Response examples**

200 in progress:
```json
{
  "id": "aigwt_volcengine_videos_MXgno8VlzuzZtJl0kq83jA",
  "object": "video",
  "model": "doubao-seedance-2-0-mini-260615",
  "status": "in_progress",
  "progress": 0,
  "created_at": 1791950400,
  "expires_at": 1792555200,
  "seconds": 5,
  "size": "1280x720"
}
```

200 completed:
```json
{
  "id": "aigwt_volcengine_videos_MXgno8VlzuzZtJl0kq83jA",
  "object": "video",
  "model": "doubao-seedance-2-0-mini-260615",
  "status": "completed",
  "progress": 100,
  "created_at": 1791950400,
  "completed_at": 1791950538,
  "expires_at": 1792555200,
  "seconds": 5,
  "size": "1280x720",
  "usage": { "duration_seconds": 5, "completion_tokens": 108900 }
}
```

200 failed:
```json
{
  "id": "aigwt_volcengine_videos_Qw3hTz0bVn1sLk8pYc4dRe",
  "object": "video",
  "model": "doubao-seedance-2-0-mini-260615",
  "status": "failed",
  "progress": 0,
  "created_at": 1791950400,
  "completed_at": 1791950482,
  "expires_at": 1792555200,
  "seconds": 5,
  "error": {
    "code": "OutputVideoSensitiveContentDetected",
    "message": "The request failed because the output video may contain sensitive information.",
    "provider_request_id": "021791950538123abcdef",
    "retryable": false
  }
}
```

404:
```json
{
  "error": {
    "message": "video task not found",
    "type": "invalid_request_error",
    "code": "not_found"
  }
}
```

| `status` | Meaning | What to do |
| --- | --- | --- |
| `queued` | Accepted; generation has not started | Keep polling |
| `in_progress` | The provider is generating | Keep polling |
| `completed` | Finished | Call `/content` to download the video |
| `failed` | Failed | Read `error`; you are not charged |

| Field | Description |
| --- | --- |
| `id` | The gateway's task ID, in the format `aigwt_<provider>_<task type>_<random string>`. It is not the provider's task ID; when you report a problem to the provider, use `error.provider_request_id` |
| `object` | Always `video` |
| `model` | The upstream model name actually called, such as `doubao-seedance-2-0-mini-260615`, which is written differently from the model ID in the request |
| `status` | One of `queued`, `in_progress`, `completed` or `failed` |
| `progress` | `0` before completion and `100` after; it does not show intermediate progress, so rely on `status` |
| `created_at` / `completed_at` | Submission time and end time (Unix seconds). `completed_at` appears only after the task ends |
| `expires_at` | When the task record expires, 7 days after submission. After that, queries return 404 |
| `seconds` / `size` | Echo the values you submitted |
| `error` | Present only when the task is `failed`: `code` and `message`, plus `provider_request_id` (the provider's request identifier) and `retryable`, each present only when it has a value |
| `usage` | Present after the task completes: `duration_seconds` is the video length in seconds, and `completion_tokens` is the output token count reported by the provider, which is what Seedance bills on |

For failed tasks, `error.code` is usually the provider's error code, returned as is:

| `error.code` | Meaning and fix |
| --- | --- |
| `InvalidParameter.TaskTypeConstraint` | The task type conflicts with the parameters, for example a video editing task that sent `seconds`. Fix the parameters against "Task types and constraints" below |
| `InvalidParameter.TaskTypeMismatch` | The type declared with `omni_reference_task_type` disagrees with what the model inferred. Write the prompt with the keywords in the task type table, or switch to `auto` |
| `OutputVideoSensitiveContentDetected` and similar | Content moderation did not pass; change the prompt or materials and resubmit |
| `upstream_error` | The provider reported a failure without a recognizable error code |

- `error.retryable` `false` means retrying unchanged will not succeed, so you need to change the request; `true` means you can retry; a missing field means the gateway cannot tell, so do not treat it as `false`. For all values, see [Asynchronous tasks](https://platform.lyrouter.com/ai_gateway/docs/async/en).
- **Polling interval**: start querying about 10 seconds after submitting, then gradually widen to once every 30 seconds.
- **Waiting limit**: a task still unfinished 24 hours after submission will never produce a result and is not charged, so you can stop polling. At that point the query may still show `queued` or `in_progress`; to see the task's actual state, search by task ID on the workspace's **Multimedia generation** page in the console (State shows Expired).

## Download the video

`GET /v1/videos/{id}/content`

Call it after the task becomes `completed`. It returns 302, and the `Location` response header is the signed video URL provided by the provider. Let the client follow the redirect to download: add `-L` in cURL, set `follow_redirects=True` in Python `httpx`; Node.js `fetch` follows redirects by default.

### Request examples

**Request examples**

cURL:
```bash
# Download to a local file
curl -L https://api.lyrouter.com/v1/videos/$TASK_ID/content \
  -H "Authorization: Bearer $LYROUTER_API_KEY" \
  -o video.mp4

# Get only the video URL without downloading
curl -s -o /dev/null -w '%{redirect_url}\n' \
  https://api.lyrouter.com/v1/videos/$TASK_ID/content \
  -H "Authorization: Bearer $LYROUTER_API_KEY"
```

Python:
```python
import os
import httpx

task_id = "aigwt_volcengine_videos_MXgno8VlzuzZtJl0kq83jA"
with httpx.stream(
    "GET",
    f"https://api.lyrouter.com/v1/videos/{task_id}/content",
    headers={"Authorization": f"Bearer {os.environ['LYROUTER_API_KEY']}"},
    follow_redirects=True,
    timeout=600,
) as video:
    video.raise_for_status()
    with open("video.mp4", "wb") as f:
        for chunk in video.iter_bytes():
            f.write(chunk)
```

Node.js:
```js
import { writeFile } from "node:fs/promises";

const taskId = "aigwt_volcengine_videos_MXgno8VlzuzZtJl0kq83jA";
const video = await fetch(`https://api.lyrouter.com/v1/videos/${taskId}/content`, {
  headers: { Authorization: `Bearer ${process.env.LYROUTER_API_KEY}` },
});
if (!video.ok) throw new Error(`download failed: ${video.status}`);
await writeFile("video.mp4", Buffer.from(await video.arrayBuffer()));
```

### Response

**Response examples**

302:
```text
Status: 302
Location: <signed video URL provided by the provider>
```

409:
```json
{
  "error": {
    "message": "video is not ready yet",
    "type": "invalid_request_error",
    "code": "not_ready"
  }
}
```

404:
```json
{
  "error": {
    "message": "video task not found",
    "type": "invalid_request_error",
    "code": "not_found"
  }
}
```

- The video URL is fixed when the task completes, and every later call redirects to the same URL. How long the URL stays valid is up to the provider, usually 24 hours; once it expires, it cannot be fetched again through the gateway, so download it or copy it to your own storage as soon as you get it.
- Calling it before the task completes (or after it failed) returns 409. These 4xx responses also count toward the API key's error rate, and frequent calls can trip the [circuit breaker](https://platform.lyrouter.com/ai_gateway/docs/rate-errors/en), so query until the status is `completed` before downloading.

## Delete a task

`DELETE /v1/videos/{id}`

For a finished task (`completed` or `failed`), returns HTTP 200 and the task object with `"deleted": true`; the task record is not deleted, and you can still query and download it until `expires_at`. Calling it on a task in progress returns 409 `cancel_not_supported`: the provider offers no cancel endpoint, so wait for the task to end. The official OpenAI SDK's `videos.delete()` reads this `deleted` field.

## Seedance-specific parameters and limits

### The two ways to write `size`

| Notation | What the gateway does |
| --- | --- |
| `WxH` (such as `1280x720`) | Takes the **short edge** as the resolution tier (`1280x720` → `720p`); when the aspect ratio is within 2% of a ratio the provider supports, that ratio is used, otherwise the reduced ratio is sent as is and the provider returns 400 (for `1000x480`: `the parameter ratio specified in the request is not valid ...`) |
| Tier name (such as `720p`, `1080p`) | Passed through; the provider picks the aspect ratio from the prompt |

- For first-frame and first-and-last-frame video, the output aspect ratio follows the first-frame image.
- Seedance 2.5 renders 1080p as 10-bit H.265/HEVC, which some players cannot open; `mov` is H.264 + yuv444p + PCM. Use VLC, mpv, ffplay or IINA to play them.

### Task types and constraints

Seedance **infers the task type** from the materials and the prompt, and each type has its own constraints:

| Task type | How it is triggered | Constraints |
| --- | --- | --- |
| Text to video | `prompt` only | None |
| First-frame / first-and-last-frame video | `first_frame` (+ `last_frame`) | The output aspect ratio follows the first-frame image |
| Reference to video | `reference_image` / `input_reference` | None |
| **Video editing** | `input_reference`, with an editing intent in the prompt | **Do not send `seconds`**; the output aspect ratio and duration follow the source video; the source video must be 4–30 s |
| **Video extension** | `input_reference`, with an extension intent in the prompt | The output aspect ratio follows the source video |

The duration of a video editing task is fixed to the source video, and the gateway's `seconds` accepts only positive integers, so simply omit `seconds` for editing tasks; sending it is rejected by the provider (`InvalidParameter.TaskTypeConstraint`).

With Seedance 2.5, you can declare the task type explicitly with `omni_reference_task_type`, which turns the provider's asynchronous failure into a synchronous error at submission:

| Value | Meaning | Keywords the prompt must contain |
| --- | --- | --- |
| `auto` (default) | The model decides | — |
| `reference` | Reference to video | — |
| `edit` | Video editing | 编辑视频 / 增加 / 加上 / 删除 / 去掉 / 修改 / 替换 / 改成 (edit video / add / add on / delete / remove / modify / replace / change to) |
| `extend` | Video extension | 向前延长 / 向后延长 / 延续 / 续写 (extend forward / extend backward / continue / write a continuation) |

`edit` and `extend` apply only to scenarios with reference materials: used for text to video (without reference materials), the submission returns 400 right away with the error code `InvalidParameter.TaskTypeConstraint`, and the error message contains `This parameter is only supported in the multimodal reference scenario, and is not applicable to text-to-video generation or first-frame / first-last-frame generation`.

If the declared type disagrees with what the model infers, the task may fail asynchronously (`InvalidParameter.TaskTypeMismatch`).

### Input material limits (Seedance 2.5)

| Material | Limits |
| --- | --- |
| Images | jpeg, png, webp, bmp, tiff, gif, heic, heif; sides [300, 6000] px; aspect ratio [0.4, 2.5]; ≤ 30 MB each; 1 first frame / 2 for first-and-last frame / 1–30 reference images |
| Video | mp4, mov (H.264 or H.265; audio AAC / MP3 / PCM); resolution 480p or 720p; duration [2, 30] s (editing tasks [4, 30] s); sides [300, 6000] px; aspect ratio [0.4, 2.5]; ≤ 200 MB each; frame rate [24, 60] |
| Request body | ≤ 64 MB in total; use public URLs for large files rather than Base64 |

Seedance **does not accept reference images or reference videos containing real human faces**; they are blocked by input moderation.

The provider's web search and last-frame return have no corresponding gateway parameters, so they are not available through LYRouter.

### Billing

Seedance bills on the **output tokens** reported by the provider (`usage.completion_tokens` on the task object), tiered by resolution and by whether a video is supplied as input:

| Model | Tiers |
| --- | --- |
| `bytedance/doubao-seedance-2.5` | 480p / 720p and 1080p, each split again by whether a video is supplied as input |
| `bytedance/doubao-seedance-2.0-fast` | Two tiers, split only by whether a video is supplied as input |
| `bytedance/doubao-seedance-2.0-mini` | Two tiers, split only by whether a video is supplied as input |

- **A video input moves the task to a different tier: the unit price is lower, but the total is higher**: tasks with `input_reference` (reference video, video editing, video extension) are metered at roughly twice the tokens of the same task without a reference video (see "equivalent seconds" below), and the lower unit price does not make up for the extra usage. First-frame / first-and-last-frame image to video does not count as a video input.
- Tokens = a fixed overhead + tokens per second × equivalent seconds, so token counts already reflect the differences in resolution and duration (measured: 480p 4 s ≈ 40,594 tokens, 8 s ≈ 80,770, 720p 4 s ≈ 87,300), and there is no separate duration tier. Without a reference video, the equivalent seconds are simply the output seconds.
- **With a reference video, the equivalent seconds are not the output duration**: for an output of up to 6 seconds, equivalent seconds = output seconds + reference video seconds; above 6 seconds, equivalent seconds = 1.5 × output seconds + 2, regardless of the reference video's length. 6 s is the break point. For example, with a 5-second reference video, a 5-second output is metered as 10 s and a 10-second output as 17 s. Video editing and extension are metered by the same rule (measured).
- The price is locked at submission; when the task becomes `completed`, it is settled on the token count actually reported; `failed` tasks (including timeouts) are not charged. To see a task's cost in the logs, see [Asynchronous tasks](https://platform.lyrouter.com/ai_gateway/docs/async/en).

## Complete example

Submits a first-frame video task, polls until it ends, then downloads the video. Replace `first_frame` with your own public image URL. The cURL example requires `jq`; save it as a script file and run it with `bash` (it uses `exit` on errors).

**Complete example**

cURL:
```bash
BASE=https://api.lyrouter.com/v1
AUTH="Authorization: Bearer $LYROUTER_API_KEY"

# 1. Submit the task; without a task ID, print the response and exit instead of polling
RESP=$(curl -s "$BASE/videos" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{
    "model": "bytedance/doubao-seedance-2.0-mini",
    "prompt": "The camera slowly pushes in as the kitten turns to look out of the window",
    "first_frame": "https://example.com/cat.jpg",
    "size": "720p",
    "seconds": 5
  }')
TASK_ID=$(echo "$RESP" | jq -r '.id // empty')
if [ -z "$TASK_ID" ]; then echo "Submission failed: $RESP"; exit 1; fi
echo "task: $TASK_ID"

# 2. Poll: start at 10 seconds and widen gradually to 30 seconds until the task ends; wait at most 2 hours
DELAY=10
GIVE_UP_AT=$(( $(date +%s) + 2 * 3600 ))
while true; do
  if [ "$(date +%s)" -gt "$GIVE_UP_AT" ]; then
    echo "$TASK_ID is still generating; query the same ID again later"; exit 1
  fi
  sleep $DELAY
  DELAY=$(( DELAY * 3 / 2 > 30 ? 30 : DELAY * 3 / 2 ))
  TASK=$(curl -s "$BASE/videos/$TASK_ID" -H "$AUTH")
  STATUS=$(echo "$TASK" | jq -r '.status')
  echo "status: $STATUS"
  case "$STATUS" in
    completed|failed) break ;;
    queued|in_progress|"") ;;  # An empty value is usually a network glitch: keep the task ID and query again
    *) echo "Query error: $TASK"; exit 1 ;;
  esac
done

# 3. Download on success; print the reason on failure
if [ "$STATUS" = "completed" ]; then
  curl -sL "$BASE/videos/$TASK_ID/content" -H "$AUTH" -o video.mp4
else
  echo "$TASK" | jq '.error'
fi
```

Python:
```python
import os
import time
import httpx

BASE = "https://api.lyrouter.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['LYROUTER_API_KEY']}"}

with httpx.Client(headers=HEADERS, timeout=60) as client:
    # 1. Submit the task
    resp = client.post(f"{BASE}/videos", json={
        "model": "bytedance/doubao-seedance-2.0-mini",
        "prompt": "The camera slowly pushes in as the kitten turns to look out of the window",
        "first_frame": "https://example.com/cat.jpg",
        "size": "720p",
        "seconds": 5,
    })
    resp.raise_for_status()
    task = resp.json()
    task_id = task.get("id")
    if not task_id:
        raise RuntimeError(f"No task ID returned: {task}")
    print("task:", task_id)

    # 2. Poll: start at 10 seconds and widen gradually to 30 seconds; wait at most 2 hours
    delay, give_up_at = 10, time.time() + 2 * 3600
    while task["status"] in ("queued", "in_progress"):
        if time.time() > give_up_at:
            raise TimeoutError(f"{task_id} is still generating; query the same ID again later")
        time.sleep(delay)
        delay = min(delay * 1.5, 30)
        try:
            resp = client.get(f"{BASE}/videos/{task_id}")
            resp.raise_for_status()
        except httpx.TransportError:
            continue  # Network glitch: keep the task ID and query again; do not resubmit
        task = resp.json()

    if task["status"] == "failed":
        err = task.get("error", {})
        raise RuntimeError(f"{err.get('code')}: {err.get('message')}")

    # 3. Download: the endpoint returns 302, so follow the redirect
    with client.stream("GET", f"{BASE}/videos/{task_id}/content",
                       follow_redirects=True, timeout=600) as video:
        video.raise_for_status()
        with open("video.mp4", "wb") as f:
            for chunk in video.iter_bytes():
                f.write(chunk)
    print("Saved video.mp4, usage:", task.get("usage"))
```

Node.js:
```js
import { writeFile } from "node:fs/promises";

const BASE = "https://api.lyrouter.com/v1";
const headers = { Authorization: `Bearer ${process.env.LYROUTER_API_KEY}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// 1. Submit the task
const submit = await fetch(`${BASE}/videos`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "bytedance/doubao-seedance-2.0-mini",
    prompt: "The camera slowly pushes in as the kitten turns to look out of the window",
    first_frame: "https://example.com/cat.jpg",
    size: "720p",
    seconds: 5,
  }),
});
if (!submit.ok) throw new Error(await submit.text());
let task = await submit.json();
if (!task.id) throw new Error(`No task ID returned: ${JSON.stringify(task)}`);
console.log("task:", task.id);

// 2. Poll: start at 10 seconds and widen gradually to 30 seconds
let delay = 10_000;
while (task.status === "queued" || task.status === "in_progress") {
  await sleep(delay);
  delay = Math.min(delay * 1.5, 30_000);
  let resp;
  try {
    resp = await fetch(`${BASE}/videos/${task.id}`, { headers });
  } catch {
    continue; // Network glitch: keep the task ID and query again; do not resubmit
  }
  if (!resp.ok) throw new Error(await resp.text());
  task = await resp.json();
}
if (task.status === "failed") {
  throw new Error(`${task.error?.code}: ${task.error?.message}`);
}

// 3. Download: fetch follows the 302 redirect by default
const video = await fetch(`${BASE}/videos/${task.id}/content`, { headers });
if (!video.ok) throw new Error(`download failed: ${video.status}`);
await writeFile("video.mp4", Buffer.from(await video.arrayBuffer()));
console.log("saved video.mp4", task.usage);
```

## Common errors

Error responses take the form `{"error":{"message":"...","type":"...","code":"..."}}`.

| Status | `error.message` | `error.code` | Cause and fix |
| --- | --- | --- | --- |
| 400 | `seconds must be a positive whole number of seconds` | `invalid_parameter` | `seconds` is not a positive integer, for example `0`, a negative number or a decimal |
| 400 | `input_reference must be a non-empty URL string` | `invalid_parameter` | `input_reference` is empty or an array. Seedance accepts only one reference video URL |
| 400 | `generate_audio must be a boolean` | `invalid_parameter` | In JSON, `generate_audio` (or `watermark`) is not a boolean |
| 400 | `source_video is not supported for this model` | `invalid_parameter` | `source_video` is only for MiniMax H3 video upscaling; remove it from the request |
| 400 | `unsupported parameters for this model: <parameter name>` | `unsupported_parameters` | The request has `X-Gateway-Strict-Parameters: true` and contains parameters that would be dropped. Remove the listed parameters or use the gateway's parameter names |
| 403 | `Model is not available in the model plaza` | `model_not_available` | The model is not enabled in this workspace, or a virtual model or `lyrouter/auto` was used. Use a specific model ID that is enabled |
| 404 | `video task not found` | `not_found` | The task ID does not exist, is older than 7 days, belongs to another workspace, or is the provider's task ID |
| 409 | `video is not ready yet` | `not_ready` | `/content` was called before the task completed (or after it failed). Query until the status is `completed` first |
| 409 | `cancelling an in-flight video task is not supported: ...` | `cancel_not_supported` | `DELETE` was called on a task in progress. Wait for the task to end |
| 429 | `upstream concurrency full` | `upstream_concurrency_full` | The provider account has reached its limit of concurrent tasks. Switching API keys does not help; resubmit later |

- In a form submission, `input_reference` accepts only a URL. Currently an uploaded file **raises no error**: the file is ignored, and the task is generated as if there were no reference video and charged as usual. Only a non-URL text value (such as `video.mp4`) returns a 400 plain-text error: `input_reference must be a URL; uploading a file is not supported by this gateway (pass a publicly reachable https:// URL instead)`. These plain-text errors from the gateway currently still carry the response header `Content-Type: application/json`, so check whether the body is JSON before parsing it.
- When the resolution, duration, number of materials and so on exceed the model's range, the provider rejects the request, and the status code and error text are set by the provider; problems found only after submission come back as failed tasks, as described under "Retrieve a task" above. The full list is in [Errors and request identifiers](https://platform.lyrouter.com/ai_gateway/docs/errors/en).

## FAQ

### A video editing task failed with `InvalidParameter.TaskTypeConstraint`. What should I do?

**Answer:** The duration of a video editing task is fixed to the source video. Remove `seconds` from the request and resubmit. For the constraints of the other task types, see "Task types and constraints" above.

### A task failed with `InvalidParameter.TaskTypeMismatch`. What should I do?

**Answer:** The task type declared with `omni_reference_task_type` disagrees with what the model inferred. Rewrite the prompt with the keywords from the task type table (for an editing task, for example, say 替换 "replace" or 去掉 "remove"), or switch to `auto` and let the model decide.

### Why did the provider reject a request for 1080p or `mov`?

**Answer:** Only `bytedance/doubao-seedance-2.5` supports 1080p and `mov`; the 2.0 series outputs only 480p and 720p `mp4`. Switch to 2.5, or set `size` to `720p` and remove `output_format`.

### The downloaded 1080p video will not play. What should I do?

**Answer:** Seedance 2.5 renders 1080p as 10-bit H.265/HEVC, which some players do not support. Play it with VLC, mpv, ffplay or IINA, or transcode it yourself.

### Can I use the official OpenAI SDK?

**Answer:** You can submit tasks with `client.videos.create()`; the SDK submits a form, and the gateway converts it into the format the provider needs. The Python SDK needs `openai` 2.x: in testing, 1.58.1 has no `client.videos`, while 2.48.0 works. In a form, `input_reference` can only be a URL: writing `input_reference="https://…"` in the Python SDK raises `RuntimeError: Expected entry at input_reference to be bytes, an io.IOBase instance, PathLike or a tuple …` locally and the request is never sent, so pass it as `extra_body={"input_reference": "https://…"}` instead; the Node.js SDK accepts a string at runtime, but its TypeScript types do not allow it, so TypeScript needs a type-check workaround. Tier names for `size` (such as `720p`) are not in the SDK's type declarations either, so TypeScript needs a type-check workaround; for first frames, last frames and reference images, send a JSON request body over HTTP. For querying and downloading, call the HTTP endpoints as shown on this page.

### The video URL has expired. Can I download it again?

**Answer:** No. The video URL is fixed when the task completes, and calling `/content` again returns the same URL, so after it expires you need to submit a new task. Download the video or copy it to your own storage right after the task completes.

### The query keeps returning `in_progress`. What should I do?

**Answer:** Video generation usually takes several minutes. A task still unfinished 24 hours after submission will never produce a result and is not charged, so you can stop polling and check the task's state by task ID on the **Multimedia generation** page in the console. Do not resubmit the same request while you wait: each submission creates a new task that is charged separately.
