视频生成 API 轮询实测:202 只要 0.8 秒,视频要等 83 到 253 秒
POST 拿到 202 用了 0.82 秒,之后 8 个完全一样的 5 秒任务跑出 83.3 到 253.2 秒,相差 3 倍。跑起来就取消不掉,结果链接 24 小时后失效。
202 一秒内就回来了,视频还要再等两到四分钟,而且等多久这件事你从请求里看不出来。 视频生成 API 上所有难受的地方,都住在这两个事实中间的那段空白里。
提交: POST /v1/videos -> 202,耗时 0.82 秒
响应体: {id, status: "queued", polling_url} 三个字段,没别的
5 秒片的等待:8 个相同任务 83.3 到 253.2 秒,中位数 104.6 秒
轮询限速: 每 key 5 req/s、突发 20,超了 429 + Retry-After: 1
终态: completed | failed | cancelled | expired 四个都要判,否则空转
取消: 上游跑起来之后是 400 cancel_failed
结果链接: unsigned_urls 签名 24 小时;Seedance 上没有 mirror_urls
实测: 2026-08-24,13 个任务,全部走 POST /v1/videos
最后更新 2026-08-24。时延是一个下午、一条路由上的数据,跟你测出来的不会一样;能迁移的是这个分布的形状,不是具体秒数。
POST /v1/videos 到底返回什么
一个 202,三个字段。 没有视频,没有百分比,没有预计时间:
{
"id": "5e6f69b1-8ffe-430c-a687-8241366a90f5",
"status": "queued",
"polling_url": "https://api.ofox.ai/v1/videos/5e6f69b1-8ffe-430c-a687-8241366a90f5"
}
这次调用花了 0.82 秒。polling_url 是给你省事的:它就是你自己拼出来的那个 GET /v1/videos/{id}。用返回的字段,别自己拼字符串 —— 今天 id 是 UUID 格式,没人保证它一直是。
任务在跑的时候,状态返回体故意做得很薄:
{"id": "...", "status": "in_progress", "model": "bytedance/seedance-2.0-mini",
"prompt": "...", "created_at": 1787567608, "updated_at": 1787567608}
没有 progress 字段可以拿来画进度条。如果你的界面非要有一根,那它只能是「按已耗时对着历史中位数估出来的」假进度条 —— 读完下一节你就明白为什么这根条必须诚实地承认自己是猜的。
任务完成后会多出两个键:unsigned_urls 和 usage。

一段 4 秒 480p 视频的完整过程。下面那张 8 个任务的表是另一组 5 秒片,所以这里的 105.9 秒请当作额外一个样本,而不是表里的一行。
一个视频任务到底要跑多久
同一个请求,83 到 253 秒。 8 个任务,全是 bytedance/seedance-2.0-mini 上的 5 秒 480p,同一把 key、同一个下午:
| 序号 | 任务内容 | 到终态耗时(秒) |
|---|---|---|
| 1 | 文生视频,16:9 | 83.3 |
| 2 | 文生视频,9:16,三个一起提交的其中之一 | 85.1 |
| 3 | 两张参考图 | 95.6 |
| 4 | 首尾帧,带 ratio | 103.9 |
| 5 | 首尾帧,不带 ratio | 105.3 |
| 6 | 文生视频,9:16,三个一起提交的其中之一 | 126.7 |
| 7 | 文生视频,16:9 | 139.9 |
| 8 | 文生视频,9:16,三个一起提交的其中之一 | 253.2 |
中位数 104.6 秒,最慢除以最快是 3.0 倍。最慢的三个和最快的三个,在请求上找不出任何区别:2、6、8 号是同一个模型、同样时长、同样分辨率、同样画幅,在同一秒里提交,然后分别在 85 秒、127 秒、253 秒后结束。
两条实用结论。
超时按尾部设。 客户端设 120 秒的话,8 号任务会被你这边掐掉,而上游还在生成、还在计费。我们用 900 秒做硬上限,超过 300 秒的记一条日志。
别承诺完成时间。 一批片子什么时候好就是什么时候好。产品里非要显示倒计时的话,用你自己最近任务的滚动中位数,并且允许它超时,别让它撒谎。
还有个反直觉的点:大模型不一定更慢。同一个下午,bytedance/seedance-2.5 上一个 5 秒 480p 任务 53.1 秒就完成了,比上面所有 Mini 的任务都快;而它的首尾帧版本跑了 212.9 秒。模式对时间的影响比模型档位大。 这部分对比的其余内容在首尾帧实测里。
该多久轮询一次
2 到 5 秒一次。 状态接口文档写的是按 key 限速 5 req/s、突发 20,超了返回 429 rate_limited 并带 Retry-After: 1;创建和取消不算在内。文档同时要求不要快过每秒一次,所以「礼貌」和「被限流」之间有一段很宽松的区间。
本文所有任务都用 2 到 3 秒的间隔轮询,没有一次轮询报错。
import time, requests
H = {"Authorization": "Bearer YOUR_OFOX_API_KEY"}
TERMINAL = {"completed", "failed", "cancelled", "expired"}
def wait(job, timeout=900, interval=3):
t0 = time.time()
while True:
s = requests.get(job["polling_url"], headers=H).json()
if s["status"] in TERMINAL:
return s
if time.time() - t0 > timeout:
raise TimeoutError(f"{job['id']} still {s['status']} after {timeout}s")
time.sleep(interval)
这个循环做对了三件大多数示例代码做错的事:四个终态全判;有硬上限,卡住的任务不会永远占着一个 worker;用提交响应里的 polling_url,而不是自己拼。
哪些状态是终态
七个里面四个。 文档里的状态机:
| 状态 | 终态 | 含义 |
|---|---|---|
pending | 已接受,还没提交到上游 | |
queued | 已提交上游,在排队 | |
in_progress | 生成中 | |
completed | ✓ | 视频链接可用 |
failed | ✓ | 失败,超时也走这里,error.code 为 expired |
cancelled | ✓ | 已取消 |
expired | ✓ | 已过期 |
没有 processing 这个状态。 从别家 SDK 抄来的循环经常在等它,然后永远等下去。
实际跑下来我们一次都没见过 pending。queued 停留多久也飘:多数任务在提交后 0.3 秒的第一次轮询就已经是 in_progress,而上面截图里那个在 queued 待了 14 秒。这不是 bug,是队列深浅。pending 还是要处理 —— 测试时从没见过的状态,往往就是你上量那周冒出来的那个。
超时这一档值得单独说:生成超时是以 failed + error.code: "expired" 的形式回来的,而不是独立的 expired 状态。同一个词的两种写法同时存在、含义不同,所以请按 error 参考里说的那样匹配 error.code,别去匹配 message 文本。
跑起来的任务能取消吗
通常不能,而且这个「不能」是诚实的。 我们提交任务、等 6 秒、发 DELETE /v1/videos/{id}:
{"error": {"code": "cancel_failed",
"message": "upstream cancel failed: cancel failed: status 409, body:
{\"error\":{\"code\":\"InvalidAction.RunningTaskDeletion\",
\"message\":\"Cannot delete task `cgt-...` because it is currently running.\"}}"}}
在你做「停止」按钮之前有两件事要知道。
文档把 cancel_failed 描述成「任务已在终态」的错误码,把 cancel_not_supported 留给不支持中断的厂商。我们撞到的两者都不是:一个正在跑的任务,上游拒绝删除,以 cancel_failed 裹着上游 409 的形式冒出来。如果你按这个码分支,要把两种含义都考虑进去。
还有:在那个时刻 GET 读到的状态仍然是 queued。所以状态体里的 queued 并不代表任务可取消,因为上游其实已经开始了。没有任何一个你能读到的状态能可靠告诉你「这次取消会成功」。试一下,看有没有 204,拿到 400 就当这段片子你已经买下了。
结论不讨喜但很简单:把提交当成不可撤销的付款点。prompt、参考图、时长都在 POST 之前校验完,因为拿到 id 的那一刻,钱基本就花出去了。
视频链接为什么会失效
因为 unsigned_urls 是带有效期的上游签名地址。 我们拿到的那条 query 里写着 X-Tos-Expires=86400,即 24 小时;文档也把这个字段描述为临时、约 24 小时过期。
还有第二个字段 mirror_urls,文档说它是持久的、更推荐用,在厂商开了 CDN 镜像时才有。而我们当天拉到的所有 Seedance 完成响应,键就是这些:
created_at, id, model, prompt, status, unsigned_urls, updated_at, usage
没有 mirror_urls。也就是说在这个模型家族上,「优先用 mirror_urls」实际等于「只有一条链接,而且它会过期」。在看到 completed 的那个 worker 里就把字节流下载下来,存进自己的对象存储。不要把 URL 写进数据库然后认为任务完成了 —— 内容产线一天之后攒出一张全是死链的表,就是这么来的。
顺手读一下 usage:
"usage": {"video_seconds": 5, "video_cost": "0.1000000000"}
video_cost 是字符串不是数字,而且是有意为之:文档说它是 10 位定点小数字符串,避免精度丢失。按 decimal 解析,别按 float;计费按 video_seconds 算,别按你请求的时长算 —— 请求 5 秒,回来的文件是 5.04 秒。
怎么给所有视频模型写同一个轮询循环
上面那个循环大概 15 行,值得认真写一次的原因是:每家视频厂商都发明了自己的一套。有的返回一个 job 对象外加一个单独的结果接口,有的要你去轮询响应头里的 URL,有的状态枚举换了一套名字,还有的会为你以为成功了的取消收钱。原生对接三个视频模型,就是三个循环、三套终态、三种计费边界,没有一件是有价值的工作。
本文所有片子都走同一个 POST /v1/videos 和同一个 GET /v1/videos/{id},不管背后是哪个模型 —— 这也是上面那张等待时间表能把 Seedance 2.5 和 2.0 Mini 放进同一列的原因。这种归一化正是视频网关该做的事;我们跑在 ofox 的视频接口上,而无论你选哪家,要验的性质是同一个:换掉 model 字段之后,状态枚举和 usage 的形状要保持不变。做不到的话,你还是有三个循环,只不过藏在一个域名后面。
选模型这件事,按使用场景挑视频生成 API 讲了画质和价格两个轴,fal / Replicate / ofox 视频 API 价格对比 有每秒单价的细账。
要不要改用 webhook
有公网 HTTPS 端点的话,要。 创建时传 callback_url,任务落到终态时会收到一次 POST,带完整任务对象、X-Ofox-Signature(HMAC-SHA256)和 X-Ofox-Idempotency-Key。事件和终态一一对应。
注意校验发生在提交时,不是投递时。我们传了一个私网 http 地址,立刻收到:
400 invalid_callback_url
"callback_url must be a public HTTPS URL: ssrf blocked: target is private,
reserved, or scheme not allowed: scheme must be https"
这点在开发阶段很值钱,因为你最自然会去试的 localhost 或局域网地址,正好是 SSRF 检查要挡的东西。用带真实 HTTPS 域名的隧道,或者开发时先轮询、上生产再切 webhook。两条腿走路也完全合理:注册 webhook,同时留一个慢扫描,把超过十分钟还没落终态的任务捞出来轮一遍 —— 毕竟「没收到的 webhook」和「没跑完的任务」在你这边长得一模一样。
参考资料
常见问题
- POST /v1/videos 返回什么?
- HTTP 202,body 里只有三个字段:id、status(值为 queued)、polling_url。我们实测提交调用耗时 0.82 秒。响应里没有视频、没有进度百分比、也没有预计完成时间 —— 这正是 202 的含义:请求被接受了,事情还没做。
- 生成一个视频要多久?
- 比你以为的久,而且不好预测。8 个完全相同的 5 秒 480p 任务(Seedance 2.0 Mini,同一个下午、同一把 key)落在 83.3 到 253.2 秒之间,最慢是最快的 3 倍,中位数 104.6 秒。超时要按尾部设计,不能按中位数设计。
- 轮询间隔设多少合适?
- 2 到 5 秒足够。状态接口按 API key 限速 5 req/s、突发 20,超了返回 429 rate_limited 并带 Retry-After: 1;文档同时要求不要快过每秒一次。创建和取消不受这个限速约束。
- 哪些状态是终态?
- 四个:completed、failed、cancelled、expired。完整状态机有七个状态,非终态是 pending、queued、in_progress。只判断 completed 和 failed 的轮询循环,遇到被取消或过期的任务会一直空转。
- 能中途取消视频任务吗?
- 多数情况取消不了。我们对一个已经跑了 6 秒的任务发 DELETE,拿到 400 cancel_failed,里面包着上游的 409:任务正在运行,无法删除。能不能取消取决于上游厂商支不支持中断,网关不会在上游还在生成、还在计费的时候假装本地取消成功。
- 生成好的视频链接为什么会失效?
- 因为 unsigned_urls 是上游的临时签名地址。我们拿到的那条 query 里带 X-Tos-Expires=86400,也就是签发后 24 小时失效。要么把文件下载下来,要么用 mirror_urls(需要厂商开了 CDN 镜像)—— 而我们当天拉到的 Seedance 响应里根本没有 mirror_urls 这个字段。
- 失败的任务要不要付钱?
- 文档写明 usage 对象只在任务完成时出现,我们跑失败的任务拿回来的 usage 是 null。另外注意超时不是独立状态:它以 status failed 加 error.code 为 expired 的形式返回。
- 应该改用 webhook 吗?
- 如果你有公网 HTTPS 端点,应该。创建时传 callback_url,任务进入终态时会收到一次 POST,带 HMAC-SHA256 签名头和幂等键。URL 在创建时就会校验:我们传了一个私网 IP 的 http:// 地址,立刻被 400 invalid_callback_url 挡掉。


