動画生成 API のポーリング実測:202 は 0.8 秒、動画は 83〜253 秒

POST の 202 は 0.82 秒で返り、そこから同一条件の 8 ジョブが 83.3〜253.2 秒とばらつきました。走り出したらキャンセルは通らず、結果 URL は 24 時間で切れます。

動画生成 API のポーリング実測:202 は 0.8 秒、動画は 83〜253 秒

202 は 1 秒以内に返ってきて、動画はそこからさらに 2〜4 分かかります。しかもどれだけかかるかはリクエストからは分かりません。 動画生成 API の面倒な部分は、この 2 つの事実のあいだの空白に全部住んでいます。

送信:       POST /v1/videos -> 202、0.82 秒
レスポンス: {id, status: "queued", polling_url}   3 フィールドのみ
5 秒クリップ:同一 8 ジョブで 83.3〜253.2 秒、中央値 104.6 秒
ポーリング: キーあたり 5 req/s・バースト 20、超過で 429 + Retry-After: 1
終了状態:   completed | failed | cancelled | expired  4 つ全部見ないと回り続ける
キャンセル: 上流が動き出すと 400 cancel_failed
結果 URL:   unsigned_urls は 24 時間の署名付き。Seedance に mirror_urls なし
計測:       2026-08-24、POST /v1/videos 経由で 13 ジョブ

最終更新 2026-08-24。時間はある午後の 1 経路での値なので、そのままは再現しません。移せるのは秒数ではなく分布の形です。

POST /v1/videos は実際に何を返すか

3 フィールドだけの 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 フィールドはありません。UI にどうしても必要なら、経過時間を過去の中央値に当てた「推測」を出すしかなく、次の節を読めばそのバーが推測であることを隠してはいけない理由が分かります。

完了すると unsigned_urlsusage の 2 つのキーが増えます。

ofox の動画エンドポイントへの POST が 0.797 秒で HTTP 202 を返し、ポーリングループが 1.2 秒で queued、15.2 秒で in_progress、105.9 秒で completed を出力し、完了時のボディで usage の video_cost が 0.08、続いて不明な id への 404 とプライベートネットワーク宛コールバックの 400 が並ぶターミナル画面

4 秒 480p クリップ 1 本の最初から最後まで。下の 8 ジョブ表は別の 5 秒クリップ群なので、ここの 105.9 秒は表の 1 行ではなく追加のサンプルとして読んでください。

動画ジョブは実際どれくらいかかるのか

同じリクエストで 83〜253 秒。 bytedance/seedance-2.0-mini の 5 秒 480p を 8 本、同じキーで同じ午後に:

番号内容終了までの秒数
1text-to-video、16:983.3
2text-to-video、9:16、3 本同時送信のうち 1 本85.1
3参照画像 2 枚95.6
4first / last frame、ratio 指定あり103.9
5first / last frame、ratio 指定なし105.3
6text-to-video、9:16、3 本同時送信のうち 1 本126.7
7text-to-video、16:9139.9
8text-to-video、9:16、3 本同時送信のうち 1 本253.2

中央値 104.6 秒、最遅 ÷ 最速で 3.0 倍。速い 3 本と遅い 3 本をリクエスト側で分けるものは何もありません。2・6・8 番は同じモデル、同じ尺、同じ解像度、同じアスペクト比で、同じ秒に送信して、85 秒・127 秒・253 秒後に終わりました。

実務上の帰結は 2 つです。

タイムアウトは裾に合わせる。 クライアント側 120 秒だと 8 番は切られ、上流では生成も課金も続いています。ハード上限は 900 秒、300 秒を超えたらログに残す、という運用にしています。

ETA を約束しない。 クリップの列は終わるときに終わります。プロダクトでカウントダウンを出すなら、自分の直近ジョブのローリング中央値を基準にして、嘘をつくくらいなら超過させてください。

直感に反しますが、大きいモデルが自動的に遅いわけではありません。同じ午後、bytedance/seedance-2.5 の 5 秒 480p は 53.1 秒で終わり、上の Mini のどれよりも速い一方、その first-last-frame 版は 212.9 秒かかりました。モデルの階層よりモードのほうが数字を動かします。この比較の残りはfirst / last frame の実測記事にあります。

ポーリング間隔はどれくらいか

2〜5 秒。 ステータス API はキーあたり毎秒 5 リクエスト・バースト 20 と明記していて、超えると 429 rate_limitedRetry-After: 1 付きで返ります。作成とキャンセルは対象外です。同時に「毎秒 1 回より速くしない」ことも求めているので、行儀のいい間隔と絞られる間隔のあいだには十分な幅があります。

この記事のジョブはすべて 2〜3 秒間隔でポーリングし、エラーは 1 回も返りませんでした。

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)

このループが正しくやっていて、公開されている例の多くが外していることが 3 つあります。終了状態 4 つすべてで抜けること。上限があるので詰まったジョブがワーカーを永久に握らないこと。そして URL を組み立て直さず、送信レスポンスの polling_url を読むこと。

どの状態が終了状態か

7 つのうち 4 つ。 ドキュメントの状態機械:

ステータス終了意味
pending受理済み、上流未送信
queued上流に送信済み、待機中
in_progress生成中
completed動画 URL が利用可能
failed失敗。タイムアウトもここで error.code: "expired"
cancelledキャンセル済み
expired期限切れ

processing は存在しません。 他ベンダーの SDK からコピーしたループはこれを待ち続けることがあります。

実際には pending は一度も観測できませんでした。queued の滞在時間は動きます。多くのジョブでは送信 0.3 秒後の初回ポーリングですでに in_progress でしたが、上のスクリーンショットのジョブは 14 秒間 queued に居ました。バグではなくキューの深さです。それでも pending は処理してください。テストで一度も見なかった状態こそ、規模を上げた週に出てきます。

タイムアウトの扱いは一言添える価値があります。生成のタイムアウトは単独の expired ではなく、failed かつ error.code: "expired" で届きます。同じ単語が 2 か所に別の意味で存在するので、エラーリファレンスが安定したフィールドと呼んでいる error.code で分岐し、メッセージ文字列では分岐しないでください。

実行中の動画ジョブはキャンセルできるか

たいていできず、その拒否こそが誠実な答えです。 ジョブを送信し、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.\"}}"}}

停止ボタンを作る前に知っておくことが 2 つ。

ドキュメントは cancel_failed を「すでに終了状態のジョブ」のコード、cancel_not_supported を「中断できないプロバイダー」のコードとしています。今回踏んだのはそのどちらでもなく、実行中のジョブに対する上流の拒否が cancel_failed として上流の 409 を包んで出てきたケースでした。このコードで分岐するなら、両方の意味を許容してください。

さらにその瞬間、GET はまだ queued と答えていました。つまりステータスの queued はキャンセル可能を意味しません。上流はすでに走り出していたからです。キャンセルが成功すると事前に分かるステータスは存在しません。 試して、204 を確認し、400 が返ったらそのクリップの代金は払うものと考えてください。

設計上の結論は人気がありませんが単純です。送信をコミットポイントとして扱う。 プロンプトも参照画像も尺も POST の前に検証しましょう。id を手にした時点で、そのクリップはたぶんもう買っています。

動画 URL が使えなくなる理由

unsigned_urls は寿命付きの上流署名アドレスだからです。 返ってきた URL のクエリには X-Tos-Expires=86400、つまり 24 時間が入っており、ドキュメントもこのフィールドを一時的でおよそ 24 時間で失効すると説明しています。

mirror_urls という 2 つ目のフィールドもあり、永続的で推奨、プロバイダーが CDN ミラーを有効にしている場合に現れる、とされています。しかしこの日引いた Seedance の完了レスポンスのキーはちょうどこれだけでした。

created_at, id, model, prompt, status, unsigned_urls, updated_at, usage

mirror_urls はありません。つまりこのモデル系統では「mirror_urls を優先」は「URL は 1 本しかなく、それは失効する」に解決されます。completed を観測したワーカーの中でバイト列をダウンロードし、自分のストレージに置いてください。URL を DB に保存してジョブ完了とするのは、翌日に死んだリンクの表が残るやり方です。

ついでに usage も読みましょう。

"usage": {"video_seconds": 5, "video_cost": "0.1000000000"}

video_cost は数値ではなく文字列で、これは意図的です。精度を失わないための 10 桁固定小数点文字列だと明記されています。float ではなく decimal としてパースし、請求は自分が指定した尺ではなく video_seconds から計算してください。5 秒のリクエストは 5.04 秒のファイルで返ってきます。

すべての動画モデルに 1 つのポーリングループを書くには

上のループは 15 行ほどですが、丁寧に一度書く価値があるのは、動画ベンダーが各社それぞれの版を発明しているからです。ジョブオブジェクトと別の結果エンドポイントを返すもの、ヘッダーの URL をポーリングさせるもの、別名のステータス列挙を持つもの、成功したと思ったキャンセルに課金するもの。3 つの動画モデルをネイティブに支えるとは、3 つのループ、3 組の終了状態、3 通りの課金の例外を抱えることで、どれも面白い仕事ではありません。

この記事のクリップは、どのモデルが生成したかによらず同じ POST /v1/videos と同じ GET /v1/videos/{id} で返ってきました。待ち時間の表が Seedance 2.5 と 2.0 Mini を同じ列に並べられるのはそのためです。その正規化こそ動画ゲートウェイの役目で、私たちは ofox の動画エンドポイントで動かしています。どのゲートウェイでも確認すべき性質は同じで、model フィールドを変えてもステータス列挙と usage の形が変わらないこと。変わるなら、ループはやはり 3 つあり、1 つのホスト名の裏に隠れているだけです。

ループに入れるモデル選びは、用途から動画生成 API を選ぶが品質と価格の軸を、fal と Replicate と ofox の価格比較が秒単価を扱っています。

webhook に切り替えるべきか

公開 HTTPS エンドポイントがあるなら、はい。 作成時に callback_url を渡すと、タスクが確定したときに 1 回 POST が届き、タスクオブジェクト全体、X-Ofox-Signature(HMAC-SHA256)、X-Ofox-Idempotency-Key が付いてきます。イベントは終了状態と 1 対 1 で対応します。

検証は配信時ではなく送信時に走ります。プライベートネットワークの 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 や LAN のアドレスが、まさに SSRF チェックの弾く対象だからです。実在の HTTPS ホスト名を持つトンネルを使うか、開発中はポーリングして本番で webhook に切り替えてください。両方でも構いません。webhook を登録しつつ、10 分経っても開いたままのジョブを拾う遅いスイーパーを残す。受け取れなかった webhook と、終わらなかったジョブは、こちら側からは見分けがつかないからです。

参考資料

よくある質問

POST /v1/videos は何を返しますか?
HTTP 202 と、たった3つのフィールドです。id、queued が入った status、そして polling_url。実測で送信は 0.82 秒でした。動画も進捗率も完了予定時刻も入っていません。それが 202 の意味で、受け付けたが終わってはいない、ということです。
動画生成はどれくらい待ちますか?
思ったより長く、しかも読めません。Seedance 2.0 Mini の 5 秒 480p を同一条件で 8 本、同じ午後に同じキーで流したところ、83.3 秒から 253.2 秒まで 3 倍のばらつきが出ました。中央値は 104.6 秒です。タイムアウトは中央値ではなく裾に合わせて設計してください。
ポーリング間隔はどれくらいが適切ですか?
2〜5 秒で十分です。ステータス取得はキー単位で毎秒5リクエスト・バースト20の制限があり、超えると 429 rate_limited が Retry-After: 1 付きで返ります。ドキュメントは毎秒1回より速く叩かないよう求めています。作成とキャンセルはこの制限の対象外です。
終了状態はどれですか?
4つです。completed、failed、cancelled、expired。状態機械は全部で7つあり、pending・queued・in_progress が非終了状態にあたります。completed と failed だけで break するループは、キャンセルされたジョブや期限切れのジョブで永久に回り続けます。
実行中のジョブはキャンセルできますか?
たいていできません。6 秒走らせたジョブに DELETE を送ったところ、400 cancel_failed が返り、中に上流の 409(実行中のため削除できない)が包まれていました。中断できるかは上流プロバイダー次第で、上流が生成と課金を続けている間にゲートウェイがローカルで成功を装うことはしません。
生成した動画の URL が切れるのはなぜですか?
unsigned_urls が上流の一時署名 URL だからです。返ってきた URL のクエリには X-Tos-Expires=86400 が入っており、署名から 24 時間で失効します。ファイルを落とすか、プロバイダーが CDN ミラーを有効にしている場合は mirror_urls を使ってください。今回の Seedance のレスポンスには mirror_urls 自体が存在しませんでした。
失敗したジョブにも課金されますか?
usage オブジェクトは完了時にのみ現れるとドキュメントに書かれており、失敗したジョブでは usage は null でした。なおタイムアウトは独立した状態ではなく、status が failed、error.code が expired という形で返ります。
ポーリングより webhook を使うべきですか?
公開された HTTPS エンドポイントがあるなら、そのほうがいいです。作成時に callback_url を渡すと、終了状態になったタイミングでタスクごとに1回 POST が届き、HMAC-SHA256 の署名ヘッダーと冪等キーが付きます。URL は作成時に検証されます。プライベート IP の http:// アドレスは即座に 400 invalid_callback_url で弾かれました。