Seedance video generation
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.
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:
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.
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 |
Submit a task
/v1/videosSubmits 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-Parametersresponse header lists their names; withX-Gateway-Strict-Parameters: true, the request returns 400 instead. This includes Volcengine Ark's nativeduration,resolution,ratioandcontent; useseconds,sizeand 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 inX-Gateway-Unsupported-Parameters; the reference audio is simply ignored. If you need reference audio, use MiniMax H3 video generation instead.source_videois only for MiniMax H3 video upscaling; sending it to Seedance returns 400source_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_referenceaccepts only a URL; an uploaded file raises no error but is ignored: pass a publicly reachablehttps://URL. Currently, when you upload a file, the task is generated as if there were no reference video and is charged as usual.watermarkandgenerate_audioaccept only the literal valuestrueorfalse.- Submit
first_frame,last_frameandreference_imagein a JSON request body. Currently these fields in a form are replaced with a placeholder value and forwarded to the provider; withfirst_frame, for example, the provider returns 400The 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.
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
}'
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"])
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.
{
"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"
}
{
"error": {
"message": "seconds must be a positive whole number of seconds",
"type": "invalid_request_error",
"code": "invalid_parameter"
}
}
{
"error": {
"message": "Model is not available in the model plaza",
"type": "access_denied",
"code": "model_not_available"
}
}
{
"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_fullis shared per provider account, so switching to another API key does not help; resubmit later.
Retrieve a task
/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
curl https://api.lyrouter.com/v1/videos/$TASK_ID \
-H "Authorization: Bearer $LYROUTER_API_KEY"
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"))
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.
{
"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"
}
{
"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 }
}
{
"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
}
}
{
"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.retryablefalsemeans retrying unchanged will not succeed, so you need to change the request;truemeans you can retry; a missing field means the gateway cannot tell, so do not treat it asfalse. For all values, see Asynchronous tasks.- 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
queuedorin_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
/v1/videos/{id}/contentCall 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
# 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"
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)
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
Status: 302
Location: <signed video URL provided by the provider>
{
"error": {
"message": "video is not ready yet",
"type": "invalid_request_error",
"code": "not_ready"
}
}
{
"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, so query until the status is
completedbefore downloading.
Delete a task
/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;
movis 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;failedtasks (including timeouts) are not charged. To see a task's cost in the logs, see Asynchronous tasks.
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).
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
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"))
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_referenceaccepts 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 asvideo.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 headerContent-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.
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.