OpenRouter 视频生成 API 代码实战指南:一个接口调用 Seedance、Veo、Wan
OpenRouter Video Generation API: A Code-First Guide
OpenRouter 发布视频生成 API 的代码实战指南,通过 POST /api/v1/videos 提交任务、轮询状态、下载 MP4,用同一套流程调用 Seedance 2.0、Veo 3.1、Wan 2.7 等模型。
OpenRouter 官方教程给出异步视频 API 的完整接入流程,读者可据此用同一套代码在 Seedance、Veo、Wan 之间切换。
当你在测试一个模型时,为应用添加视频生成功能很简单。但当你想要尝试另一个模型时,复杂性就显现出来了。每个提供商都可能有自己的端点、请求参数、任务状态、轮询逻辑和输出格式。这就把一次简单的模型更换变成了另一个需要构建和维护的集成工作。
我们把这一工作流封装在一个异步视频 API之后。你向 POST /api/v1/videos 提交提示词,收到一个任务 ID,轮询直到生成完成,然后下载生成的视频。
在本指南中,我们将从头到尾构建这一流程。我们会用 Seedance 提交任务,安全地轮询它,保存 MP4,然后用 Veo 和 Wan 运行同样的集成。
简而言之
- 一个端点,多个视频模型。通过
POST /api/v1/videos使用 Seedance、Veo、Wan 及其他支持的模型进行生成。 - 该工作流是异步的。提交任务,轮询其状态,然后下载完成的视频。
- 通过更改模型标识符来切换模型。某些模型特定的设置,例如时长和宽高比,仍需按模型进行调整,更多内容见第 4 步,但端点、认证、轮询循环和下载逻辑始终不变。
为什么异步 API 比其他方案更好
视频生成比典型的 API 响应耗时更长。模型必须生成并协调大量帧,保持它们之间的视觉一致性,有时还要生成匹配的音频。根据模型和所请求的设置,这一过程可能需要几秒到几分钟。
在整个这段时间内保持原始 HTTP 请求打开是很脆弱的。浏览器会话可能关闭,无服务器函数可能达到执行限制,或者代理可能在视频准备好之前就超时。
异步 API 将提交与完成分离:
- 提交生成请求。
- 立即收到一个任务 ID。
- 单独检查任务的状态。
- 在生成完成时下载视频。
你的应用可以在模型于后台工作时继续运行。它还可以在重启后恢复任务,因为生成是绑定到一个持久化的任务 ID,而不是一个长期存在的连接。
直接与单个提供商集成
当你已经知道自己想要哪个模型,并且不期望它发生变化时,直接与提供商集成可以很好地工作。你使用该提供商的认证、请求格式、任务状态、轮询端点和输出响应。
当你想比较另一个模型时,额外的工作就显现出来了。新的提供商可能对时长和分辨率使用不同的字段名,或者返回带有不同终止状态的不同任务对象。它还可能要求用另一种方法下载完成的资产。你的应用随后就需要第二个客户端、另一组环境变量,以及更多针对特定提供商的错误处理。
这种做法本身并没有什么问题。它只是意味着切换模型变成了一次集成变更,而不是一次配置变更,这会让实验变慢,并随着你的模型列表增长而提高维护成本。
在本地运行视频模型
本地生成给你最大的控制权。你可以选择模型权重、自定义工作流、将资产保留在你自己的环境中,并避免为每次生成向托管提供商付费。
这种控制伴随着基础设施方面的责任。你需要合适的 GPU 容量以及正确的 Python 和 CUDA 依赖。你还需要为每个模型系列提供足够的存储空间和可用的运行环境。更高的分辨率和更长的视频会增加内存和处理需求,而添加另一个模型可能意味着要下载更多权重或维护另一套工作流。
对于已经运营 GPU 基础设施或需要本地处理的团队来说,这可能是值得的。如果你的目标是快速添加视频生成并测试多个模型,那么这是一个较重的起点。托管的 OpenRouter 路径消除了大部分这类设置工作,本指南的其余部分将对此进行介绍。
通过 OpenRouter 使用一个托管 API
我们在所有支持的视频模型上保持一致的生成生命周期。无论所选模型是 Seedance、Veo、Wan 还是目录中的其他模型,应用程序都使用相同的 API 密钥、POST /api/v1/videos 端点、任务状态流程和输出检索过程。
这些模型仍然具有不同的能力。一个模型可能支持更长的时长,而另一个模型则提供额外的宽高比、更高的分辨率、音频生成或提供商特定的控制。我们通过视频模型端点来暴露这些差异,而不是强制每个模型都采用完全相同的功能集。
这样你就能获得稳定的集成,同时不会掩盖每个模型的独特之处。你的应用程序可以查询当前能力、构建有效的请求并切换模型,而无需替换周围的任务基础设施。
先决条件和设置
你只需要一个 OpenRouter API 密钥和一个能够发送 HTTP 请求的工具。这里的示例使用 Python 配合 requests 以及 TypeScript 配合内置的 fetch API,但该工作流适用于任何能够发出 HTTP 请求的语言。
首先从你的 OpenRouter 账户创建一个 API 密钥,然后将其存储在环境变量中,而不是直接添加到源代码中:
export OPENROUTER_API_KEY="sk-or-..."对于 Python 示例,如果尚未安装 requests,请先安装:
pip install requestsOpenRouter 使用 bearer token 对 API 请求进行身份验证。在 Python 中,我们将一次性定义共享值,并在整个指南中重复使用它们:
import os
import requests
API_KEY = os.environ["OPENROUTER_API_KEY"]
BASE_URL = "https://openrouter.ai/api/v1"
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}在提交任务之前,你还可以 查询视频模型端点,以查看当前有哪些模型可用以及每个模型支持什么:
curl "https://openrouter.ai/api/v1/videos/models" \
-H "Authorization: Bearer $OPENROUTER_API_KEY"响应包含每个模型支持的时长、分辨率、宽高比、帧图像支持、音频能力、定价 SKU 以及提供商特定的参数。这比假设一个视频模型接受的设置也能适用于另一个模型更可靠。
第 1 步:提交视频生成任务
向 /api/v1/videos 发送带有视频模型的 POST 请求。包含描述你想要生成内容的提示词。
每个请求都必须提供 model,而文本转视频必须提供 prompt。仅支持从图像输入生成视频的模型可以省略它。当所选模型支持时,你还可以提供可选设置,例如时长、分辨率、宽高比、音频生成、参考图像和种子。
我们将在整个指南中使用相同的提示词:
PROMPT = (
"A paper boat drifting down a rain-slicked gutter at night, "
"neon reflections, slow tracking shot, cinematic lighting"
)以下函数使用 Seedance 2.0 提交任务:
def submit_video(model: str, prompt: str) -> dict:
response = requests.post(
f"{BASE_URL}/videos",
headers=HEADERS,
json={
"model": model,
"prompt": prompt,
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": False,
},
timeout=60,
)
response.raise_for_status()
return response.json()
job = submit_video(
model="bytedance/seedance-2.0",
prompt=PROMPT,
)
print("Job ID:", job["id"])
print("Status:", job["status"])
print("Polling URL:", job["polling_url"])等效的 cURL 请求为:
curl "https://openrouter.ai/api/v1/videos" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "bytedance/seedance-2.0",
"prompt": "A paper boat drifting down a rain-slicked gutter at night, neon reflections, slow tracking shot, cinematic lighting",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": false
}'成功的请求会返回 HTTP 202 Accepted。响应表示一个后台任务,而不是最终视频:
{
"id": "job-abc123",
"status": "pending",
"polling_url": "https://openrouter.ai/api/v1/videos/job-abc123"
}在继续之前,请存储返回的任务 ID。如果你的进程重新启动,你应该能够恢复跟踪现有任务,而不是提交并支付另一次生成费用。
第 2 步:轮询任务直到完成
第 1 步返回的 polling_url 指向与你在 GET /api/v1/videos/{id} 处访问的相同任务资源,它们是同一个端点。视频任务可以经历以下状态:
| 状态 | 含义 |
|---|---|
pending | 任务已被接受,正在等待运行 |
in_progress | 提供方正在生成视频 |
completed | 视频已准备好下载 |
failed | 生成失败 |
cancelled | 任务已被取消 |
expired | 任务超出了允许的生命周期 |
你的轮询循环应在 completed 时返回,并在 failed、cancelled 或 expired 时停止并报错。否则,应用程序可能会一直检查一个永远不会生成视频的任务。
文档中记录的响应将 polling_url 作为完整 URL 返回。下面的 urljoin 调用是防御性编码,也能处理相对路径,因此无论哪种情况循环都能正常工作:
import time
from urllib.parse import urljoin
TERMINAL_ERROR_STATES = {
"failed",
"cancelled",
"expired",
}
def poll_video(
initial_job: dict,
interval: float = 30.0,
timeout: float = 3600.0,
) -> dict:
"""Poll until the video completes or reaches an error state."""
polling_url = urljoin(
"https://openrouter.ai",
initial_job["polling_url"],
)
deadline = time.monotonic() + timeout
job = initial_job
while True:
status = job["status"]
print("Status:", status)
if status == "completed":
return job
if status in TERMINAL_ERROR_STATES:
error = job.get("error") or "No error details were returned."
raise RuntimeError(
f"Video generation ended with status '{status}': {error}"
)
if status not in {"pending", "in_progress"}:
raise RuntimeError(
f"Received unexpected job status: {status}"
)
if time.monotonic() >= deadline:
raise TimeoutError(
f"Job {job['id']} did not complete within "
f"{timeout} seconds."
)
time.sleep(interval)
response = requests.get(
polling_url,
headers={
"Authorization": f"Bearer {API_KEY}",
},
timeout=30,
)
response.raise_for_status()
job = response.json()
completed_job = poll_video(job)这个循环包含两个快速示例中经常省略的保障措施。首先,它处理所有文档中记录的终止状态,而不是只等待 completed。其次,它设置了一小时的超时时间,这样任务就不会让进程无限期地运行下去。有一个值得了解的边缘情况:由于截止时间是在每次休眠之前而不是之后检查的,在最坏的情况下,任务可能会在循环捕获到它之前超出名义超时时间最多一个轮询间隔。对于后台任务来说,这是一个不错的权衡。如果你需要一个硬性上限,也可以在从休眠中醒来后立即再次检查截止时间。
我们当前的指导使用 30 秒轮询间隔。视频任务通常需要大约 30 秒到几分钟,每秒检查一次并不会让提供方更快完成。该间隔和上面的超时上限都是操作指导,而不是端点本身记录的契约,因此请根据你自己的工作负载进行调整。
TypeScript 中的相同轮询流程:
type VideoJobStatus =
| "pending"
| "in_progress"
| "completed"
| "failed"
| "cancelled"
| "expired";
type VideoJob = {
id: string;
polling_url: string;
status: VideoJobStatus;
error?: string;
unsigned_urls?: string[];
};
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const terminalErrorStates = new Set<VideoJobStatus>([
"failed",
"cancelled",
"expired",
]);
async function pollVideo(
initialJob: VideoJob,
intervalMs = 30_000,
timeoutMs = 3_600_000,
): Promise<VideoJob> {
const pollingUrl = new URL(
initialJob.polling_url,
"https://openrouter.ai",
);
const deadline = Date.now() + timeoutMs;
let job = initialJob;
while (true) {
console.log(`Status: ${job.status}`);
if (job.status === "completed") {
return job;
}
if (terminalErrorStates.has(job.status)) {
throw new Error(
job.error ?? `Video generation ${job.status}`,
);
}
if (Date.now() >= deadline) {
throw new Error(
`Video job ${job.id} did not complete before the timeout`,
);
}
await new Promise((resolve) =>
setTimeout(resolve, intervalMs),
);
const response = await fetch(pollingUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(
`Polling failed: ${response.status} ${await response.text()}`,
);
}
job = (await response.json()) as VideoJob;
}
}将失败的请求状态与失败的视频任务区别对待。轮询时的临时超时并不能证明生成本身失败了。应针对同一任务 ID 重试状态请求,而不是提交新任务。
第 3 步:检索并保存视频
当状态变为 completed 时,任务响应会包含一个已填充的 unsigned_urls 数组。每个条目都指向该任务经过身份验证的内容端点:
GET /api/v1/videos/{jobId}/content?index=0索引默认为 0。只有当模型返回多个视频输出时才需要更改。尽管字段名如此,这些 URL 并不是预签名的,因此请像轮询时一样在 Authorization 标头中发送你的 API 密钥。
下面的辅助函数会在存在第一个未签名 URL 时使用它,并在极少数不存在的情况下根据任务 ID 重建内容 URL。
def download_video(
job: dict,
output_path: str = "out.mp4",
index: int = 0,
) -> None:
unsigned_urls = job.get("unsigned_urls") or []
download_url = (
unsigned_urls[index]
if len(unsigned_urls) > index
else (
f"{BASE_URL}/videos/"
f"{job['id']}/content?index={index}"
)
)
with requests.get(
download_url,
headers={
"Authorization": f"Bearer {API_KEY}",
},
stream=True,
timeout=180,
) as response:
response.raise_for_status()
with open(output_path, "wb") as output_file:
for chunk in response.iter_content(
chunk_size=1024 * 1024
):
if chunk:
output_file.write(chunk)
print(f"Saved {output_path}")
download_video(completed_job)以块的形式流式传输响应可以避免在写入磁盘之前将整个 MP4 加载到内存中。
下面是 TypeScript 的等效实现。请注意,此版本将下载内容缓冲到内存中,而不是流式写入磁盘;这对于短片来说没问题,但如果你经常下载长视频或高分辨率视频,则值得换成管道流:
import { writeFile } from "node:fs/promises";
async function downloadVideo(
job: VideoJob,
outputPath = "out.mp4",
index = 0,
): Promise<void> {
const downloadUrl =
job.unsigned_urls?.[index] ??
`https://openrouter.ai/api/v1/videos/` +
`${job.id}/content?index=${index}`;
const response = await fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(
`Download failed: ${response.status} ` +
`${await response.text()}`,
);
}
const videoBuffer = Buffer.from(
await response.arrayBuffer(),
);
await writeFile(outputPath, videoBuffer);
console.log(`Saved ${outputPath}`);
}此时,你的磁盘上已经有了一个生成的 MP4。请将已完成的视频移动到你可控的存储中,而不是将生成端点当作永久文件托管。已完成的任务还可能包含一个 usage 对象,其中包含最终费用;无论你使用哪种语言,它都是响应正文的一部分:
usage = completed_job.get("usage") or {}
print("Generation cost:", usage.get("cost"))
print("Used BYOK:", usage.get("is_byok"))将该值与你的内部任务记录一起存储,以便跟踪每次生成的实际费用。
第 4 步:用一行代码切换模型
提交、轮询和下载函数并不绑定到 Seedance。要使用另一个受支持的视频模型,请更改模型标识符:
# Seedance
MODEL = "bytedance/seedance-2.0"
# Veo
# MODEL = "google/veo-3.1"
# Wan
# MODEL = "alibaba/wan-2.7"
job = submit_video(
model=MODEL,
prompt=PROMPT,
)
completed_job = poll_video(job)
download_video(completed_job)端点、身份验证、响应结构、状态处理和下载逻辑在三个示例中保持不变。不会自动沿用的是每一项可选设置。切换模型只需改一行代码,但这并不能保证任何给定的时长、分辨率或宽高比组合都能在新模型上通过验证。本配置恰好可在这份指南涵盖的三个模型之间通用:
{
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": false
}在撰写本文时,实时模型端点显示 Seedance 2.0、Veo 3.1 和 Wan 2.7 都支持这一特定组合:四秒、720p、16:9。这是这三个示例共有的配置,并不代表每项设置在每个模型上都能以相同方式工作。一旦超出这个范围,差异就会立刻显现:
- Veo 3.1 目前显示支持四秒、六秒和八秒时长。
- Seedance 2.0 目前显示支持四到 15 秒时长以及更多宽高比。
- Wan 2.7 目前显示支持两到 10 秒时长以及 720p 或 1080p 分辨率。
一个五秒的请求会在 Seedance 和 Wan 上通过验证,但在 Veo 上会失败。这就是为什么你的应用应该在提交请求前查询 /api/v1/videos/models,而不是假设一个模型接受的设置能在另一个模型上生效。在你依赖上述数字之前,值得对照那个实时端点重新核实,因为模型能力确实会变化。
同一个端点还会暴露 allowed_passthrough_parameters,用于模型特定功能。这些是你被允许在请求的 provider.options 对象中发送的键,该对象以提供商 slug 为键,例如 provider.options["google-vertex"].parameters。只有为你的请求提供服务的提供商的选项会被转发,无法识别的键会被丢弃。例如,Veo 目前列出了 negativePrompt 和 enhancePrompt 等控制项,而 Wan 暴露的选项包括 negative_prompt 和 prompt_extend。
在投入生产之前,有几件事值得了解
上面的代码足以生成并下载一个视频。一旦在生产环境中运行,问题就变了:你需要控制成本、将任务失败与网络故障区分开、避免重复处理,并在提交进程退出后继续跟踪任务。
在扩大规模前检查成本
视频生成定价因模型和配置而异。时长、分辨率、音频生成以及提供商的计费方式都会影响最终成本。本地生成完全改变了这种成本结构,没有按片段收费,但有实实在在的前期硬件和维护成本。托管 API 则让成本保持可变并随使用量变化,根据你的用量以及你是否已拥有硬件,这可能更便宜也可能更贵。
不要在你的应用中构建一个通用的成本公式。在显示估算或提交大批量任务之前,查询 /api/v1/videos/models 并读取所选模型的 pricing_skus。当任务完成时,响应可以包含一个 usage 对象,其中带有该次生成的实际成本:
{
"usage": {
"cost": 0.5,
"is_byok": false
}
}在运行大批量任务之前,使用当前模型数据估算成本,然后将估算值与已完成任务返回的实际 usage.cost 值进行比较。这也有助于你发现由更高分辨率、更长时长、生成的音频或不同模型引起的意外变化。
处理失败而不产生重复任务
一次失败的轮询请求与一次失败的视频生成任务并不相同。你的应用可能在检查状态时失去连接,而提供商仍在生成视频。如果你立即重新提交提示词,两个任务可能都会完成,导致一次用户请求产生两个视频和两笔费用。
一旦提交成功,就立即持久化保存 OpenRouter 任务 ID。一条有用的任务记录可能包含如下字段:
{
"internal_request_id": "req_9f21",
"openrouter_job_id": "job-abc123",
"model": "bytedance/seedance-2.0",
"status": "pending",
"attempt_number": 1,
"submitted_at": "2026-07-27T12:00:00Z",
"output_location": null,
"cost": null,
"error": null
}当状态请求因超时、连接错误或临时服务器响应而失败时,使用现有的任务 ID 重试状态请求。只有在任务本身达到 failed、cancelled 或 expired 之后,并且仅当你的应用的重试策略允许再次尝试时,才创建新的生成。
将任务重试与轮询重试分开。轮询重试是再次检查同一个任务,而生成重试会创建一个新的付费任务。限制生成重试次数,并保留为同一内部请求创建的每一个任务 ID,这样当你需要调查重复输出、提供商故障或意外费用时,就有完整的记录。
当轮询无法扩展时使用 webhook
轮询对于脚本、原型和少量任务来说是一个不错的默认选择。当你的应用可能同时运行数百个生成任务时,它的效率就会降低。
要自动接收结果,请在提交任务时包含一个 HTTPS callback_url:
{
"model": "bytedance/seedance-2.0",
"prompt": "A paper boat drifting through neon reflections",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"callback_url": "https://example.com/webhooks/openrouter-video"
}你可以为单个请求设置回调,也可以为工作区配置默认回调。请求级别的值优先于工作区默认值。
当任务达到终态时,我们会发送 webhook。每次投递都包含一个 X-OpenRouter-Idempotency-Key,例如:
job-abc123-completed在处理事件之前先存储该值。如果 webhook 被再次投递,你的处理程序可以识别出该任务已被处理,而不是重复下载视频或重复启动下一个工作流。
当配置了 webhook 签名密钥时,请求还会包含一个 X-OpenRouter-Signature。在解析或重新序列化原始请求体之前,请先根据原始请求体验证签名。生产环境的处理程序随后应保存新的任务状态,快速返回成功响应,并将下载、转码或存储工作交给后台 worker。
在持久化存储中跟踪并发任务
提交和等待是分开的操作,因此你的应用可以同时运行多个视频任务。不要为每个任务启动无限制的轮询循环。使用有界的 worker 池或任务队列,并控制可以同时运行多少状态请求和下载。
在 Python 中,你可以使用线程池或异步 worker 队列来处理有限数量的任务。在 TypeScript 中,使用并发受控的队列比将数千个轮询 promise 直接传给 Promise.all() 更安全。
具体实现不如以下规则重要:
- 在开始轮询之前保存每个任务 ID。
- 限制活跃的轮询和下载操作的数量。
- 在 worker 重启后恢复未完成的任务。
- 不要仅仅因为应用重启就重新提交任务。
- 及时将完成的视频移动到自己的存储中。
任务 ID 是你的应用与已在进行的生成之间的持久链接。应将其视为应用状态的一部分,而不是仅存在于某个运行进程中的值。
综合起来
我们已经介绍了四个步骤,无论你指向哪个模型,它们都不会改变。你只需用 POST /api/v1/videos 提交,轮询 GET /api/v1/videos/{id} 并留意全部四种终止状态,下载结果,然后在想换用不同模型时更改一个字符串。
一旦异步生命周期设置正确,模型就变成了一个设置项,而不是架构决策,无论你使用的是本文介绍的三个模型,还是之后添加的任何模型。
如果你正在选择起点,浏览视频模型目录,在做出决定前并排查看定价和能力。
常见问题
OpenRouter 支持视频生成吗?
支持,通过专用的异步 API。你向 POST /api/v1/videos 提交提示词,轮询 GET /api/v1/videos/{id} 直到状态为 completed,然后下载结果。支持的模型包括 Seedance、Veo、Wan 等,全部通过同一个端点。
如何用 API 从文本生成视频?
向 /api/v1/videos 发送 POST 请求,带上模型和提示词。你会得到任务 ID 和一个 polling_url,而不是视频本身。轮询直到状态达到 completed,然后从 unsigned_urls 或 /content 端点下载。
如何轮询异步视频生成任务?
按间隔调用 GET /api/v1/videos/{id},大约 30 秒比较合理,直到状态达到终止状态:completed、failed、cancelled 或 expired。设置超时上限,这样卡住的任务就不会永远挂起你的进程。
OpenRouter 支持哪些视频模型?
目录包括 Seedance、Veo、Wan 等,并且还在不断增长。查询 GET /api/v1/videos/models 获取当前列表,以及每个模型支持的分辨率、时长、宽高比和透传参数。
我可以在不重写代码的情况下切换视频模型吗?
可以。请求结构、身份验证和轮询循环在各模型之间完全相同,只有 model 字段会变化。模型特定的参数仍会通过 provider.options 透传对象到达提供商。
本地生成 AI 视频和通过 API 生成,哪个更便宜?
一旦你为硬件付了钱,本地生成就没有按片段计费的费用,但它需要一块性能足够的 GPU、依赖管理,并且每个模型系列都要单独配置。托管 API 按生成次数收费,但完全省去了 GPU 和配置。哪个对你更便宜,取决于你的使用量以及你是否已经拥有硬件。
AI 视频生成需要多长时间?
通常在三十秒到几分钟之间,取决于模型、分辨率和片段长度。这就是 API 采用异步方式而不是普通阻塞调用的原因。
视频生成符合零数据保留(Zero Data Retention)条件吗?
不符合。异步检索步骤需要短暂保留生成的输出以便下载,因此强制启用 ZDR 的请求不会被路由到视频生成。
来源:OpenRouter:Announcements(RSS) · openrouter.ai