视频 API:报价、生成、轮询与下载
通过 BetterToken API Key 调用十款视频模型,使用可恢复的 Python 示例完成报价、异步生成、轮询和 MP4 下载。
视频生成是异步任务。先查询模型能力并取得报价,再提交生成;收到任务 ID 后轮询,只有成片可用时才下载。
接入信息
| 项目 | 值 |
|---|---|
| Base URL | https://bettertoken.ai/v1 |
| 认证 | Authorization: Bearer YOUR_API_KEY |
| 查询可用模型 | GET /v1/video-models |
| 生成前报价 | POST /v1/videos/quote |
| 提交生成 | POST /v1/videos |
| 查询任务 | GET /v1/videos/{id} |
| 下载成片 | GET /v1/videos/{id}/content |
| 兼容创建入口 | POST /v1/video/generations |
查询可用模型
export BETTERTOKEN_API_KEY="YOUR_API_KEY"
curl --fail-with-body "https://bettertoken.ai/v1/video-models" \ -H "Authorization: Bearer $BETTERTOKEN_API_KEY"目录中的每项由 id 与 operation 共同标识。按所选项的 fields、media_limits 和 constraints 填写请求;不同操作不能共用一套固定参数。
分系列起步设置
| 系列 | 示例采用的设置 |
|---|---|
| Seedance CN | 5 秒、720p、16:9,音频关闭 |
| Kling 3.0 | 5 秒、16:9,标准模式;不传通用分辨率字段 |
| MiniMax H3 | 5 秒、768p、16:9;音频沿用模型默认 |
| Wan 3.0 / Prime | 5 秒、720p、16:9,音频关闭 |
| Wan 2.7 T2V / I2V | 文生:5 秒、720p、16:9;图生:5 秒、720p,比例由素材决定 |
这些是脚本的起步组合,不是各模型的完整上限。切换系列时不要只替换模型名。十款精确 ID 和操作列表见 模型合集.
完整 Python 流程
使用 Python 3,无需安装额外依赖。先设置环境变量,再将下方代码存为 video_example.py 并运行。报价本身不启动生成;输入 GENERATE 后会提交真实任务,可能产生费用。
export BETTERTOKEN_API_KEY="YOUR_API_KEY"export VIDEO_MODEL="wan-2.7-t2v"export VIDEO_STATE_FILE="video-state.json"python3 video_example.py新任务使用新的 VIDEO_STATE_FILE。重试同一任务时,保留相同请求、BetterToken API Key 和状态文件;脚本会复用 Idempotency-Key 或已保存的任务 ID。脚本等待上限只是客户端设置,不是生成时长承诺。
import hashlibimport jsonimport osimport shutilimport timeimport uuidfrom pathlib import Pathfrom urllib.error import HTTPErrorfrom urllib.request import Request, urlopen
base_url = "https://bettertoken.ai/v1"api_key = os.environ["BETTERTOKEN_API_KEY"]model = os.getenv("VIDEO_MODEL", "wan-2.7-t2v")state_path = Path(os.getenv("VIDEO_STATE_FILE", "video-state.json"))sample_image = "https://docs.bettertoken.ai/images/nano-banana-guide/sample-07.png"
body = { "model": "wan-2.7-t2v", "operation": "text_to_video", "prompt": "A blue ceramic mug on a white studio table. The camera slowly moves closer. Soft daylight.", "duration": { "mode": "fixed", "seconds": 5 }, "output": { "resolution": "720p", "aspect_ratio": "16:9", "format": "mp4" }}seedance_models = { "seedance-2.5-cn", "seedance-2.0-cn", "seedance-2.0-fast-cn", "seedance-2.0-mini-cn",}allowed_models = seedance_models | { "kling-3.0", "minimax-h3", "wan-3.0", "wan-3.0-prime", "wan-2.7-t2v", "wan-2.7-i2v",}if model not in allowed_models: raise ValueError("Choose a Model ID from the documented batch")body["model"] = modelif model in seedance_models or model in {"wan-3.0", "wan-3.0-prime"}: body["audio"] = {"mode": "off"}elif model == "kling-3.0": body["output"].pop("resolution") body["audio"] = {"mode": "off"} body["provider_options"] = {"kling": {"mode": "std"}}elif model == "minimax-h3": body["output"]["resolution"] = "768p"elif model == "wan-2.7-i2v": body["operation"] = "image_to_video" body["prompt"] = "The camera slowly moves around the blue mug. Preserve its shape and the background." body["output"].pop("aspect_ratio") body["media"] = [{ "id": "first-frame", "type": "image", "role": "first_frame", "source": os.getenv("VIDEO_IMAGE_URL", sample_image), }]
request_file = os.getenv("VIDEO_REQUEST_FILE")if request_file: body = json.loads(Path(request_file).read_text(encoding="utf-8"))
def api(method, path, payload=None, extra_headers=None): headers = {"Authorization": f"Bearer {api_key}"} headers.update(extra_headers or {}) data = None if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" request = Request(base_url + path, data=data, headers=headers, method=method) try: with urlopen(request, timeout=60) as response: return json.load(response) except HTTPError as error: message = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"HTTP {error.code}: {message}") from error
digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode("utf-8")).hexdigest()if state_path.exists(): state = json.loads(state_path.read_text(encoding="utf-8")) if state["request_sha256"] != digest: raise ValueError("Request changed. Use a new VIDEO_STATE_FILE for a new task")else: state = {"request_sha256": digest, "idempotency_key": str(uuid.uuid4())} state_path.write_text(json.dumps(state), encoding="utf-8")
task_id = state.get("task_id")if not task_id: if not state.get("create_attempted"): catalogue = api("GET", "/video-models") if not any(row["id"] == body["model"] and row["operation"] == body["operation"] for row in catalogue["data"]): raise RuntimeError("This model and operation are not in the current catalogue") quote = api("POST", "/videos/quote", body) print(json.dumps(quote, ensure_ascii=False, indent=2)) if input("Review the quote. Type GENERATE to create a paid task: ") != "GENERATE": raise SystemExit("No generation request sent") if time.time() >= quote["expires_at"]: raise RuntimeError("Quote expired. Re-run to request a new quote") state["create_attempted"] = True state_path.write_text(json.dumps(state), encoding="utf-8") task = api("POST", "/videos", body, {"Idempotency-Key": state["idempotency_key"]}) task_id = task["id"] state["task_id"] = task_id state_path.write_text(json.dumps(state), encoding="utf-8")
print("Task ID:", task_id)deadline = time.monotonic() + 1800while time.monotonic() < deadline: task = api("GET", f"/videos/{task_id}") print(task["status"], task["stage"], task["delivery_status"], task["financial_status"]) if task["status"] in {"failed", "canceled"}: raise RuntimeError(json.dumps(task.get("error"), ensure_ascii=False)) if task["delivery_status"] in {"expired", "unavailable"}: raise RuntimeError("Video download is no longer available") if task["status"] == "completed" and task["delivery_status"] == "ready": print(json.dumps(task["billing"], ensure_ascii=False, indent=2)) break time.sleep(5)else: raise TimeoutError("Task still pending. Re-run with the same state file to resume")
request = Request(base_url + f"/videos/{task_id}/content", headers={"Authorization": f"Bearer {api_key}"})with urlopen(request, timeout=120) as response: if response.headers.get_content_type() != "video/mp4": raise RuntimeError("Expected an MP4 response; inspect the task before retrying") with Path("video.mp4").open("wb") as output: shutil.copyfileobj(response, output)print("Saved video.mp4")图生视频
将 VIDEO_MODEL 设为 wan-2.7-i2v,脚本会使用下方公开样图。也可通过 VIDEO_IMAGE_URL 换成自己有权使用、且服务端可访问的 HTTPS 图片。此入口不传 output.aspect_ratio。

{ "model": "wan-2.7-i2v", "operation": "image_to_video", "prompt": "The camera slowly moves around the blue mug. Preserve the mug shape and the white background.", "duration": { "mode": "fixed", "seconds": 5 }, "output": { "resolution": "720p", "format": "mp4" }, "media": [ { "id": "first-frame", "type": "image", "role": "first_frame", "source": "https://docs.bettertoken.ai/images/nano-banana-guide/sample-07.png" } ]}参考生成与自定义请求
把下方 JSON 存为 request.json,设置 VIDEO_REQUEST_FILE=request.json,并为新任务使用新的状态文件。脚本会对同一请求完成报价、确认、提交、轮询与下载。素材必须满足所选模型的类型和大小要求。
{ "model": "seedance-2.5-cn", "operation": "reference_to_video", "prompt": "Create a studio product video of the blue mug in the reference image. Keep the mug design consistent.", "duration": { "mode": "fixed", "seconds": 5 }, "output": { "resolution": "720p", "aspect_ratio": "16:9", "format": "mp4" }, "audio": { "mode": "off" }, "media": [ { "id": "reference-1", "type": "image", "role": "reference_image", "source": "https://docs.bettertoken.ai/images/nano-banana-guide/sample-07.png" } ]}H3 成片再生成
先将占位符替换为自己的已完成、仍可下载且满足再生成条件的 768p H3 任务 ID。这是新的付费任务,可复用上面的自定义请求流程。不要添加新的提示词、时长、输出设置或素材;服务器会沿用原任务内容并验证条件。
{ "model": "minimax-h3", "operation": "video_edit", "provider_options": { "hailuo": { "source_task_id": "YOUR_COMPLETED_H3_TASK_ID" } }}怎样判断任务完成
| 字段或状态 | 处理方式 |
|---|---|
202 | 仅表示已受理,不是成片已完成 |
queued / in_progress | 任务仍在排队或执行,继续查询 |
completed + delivery_status: ready | 成片可下载 |
failed / canceled | 停止轮询,读取错误;不要自动新建任务 |
delivery_status: expired / unavailable | 不能再按当前任务下载 |
financial_status | 金额状态与生成、下载状态分开;等待账务确认 |
读取报价与结算
currency 是实际报价币种,不要在生成 JSON 中传入币种。reserved_amount 是预留额度,max_charge_amount 是报价上限;提交时会重新计算并冻结任务报价。最终查看 billing.currency、billing.net_amount 与 financial_status。金额为十进制字符串;缺失或 null 不表示免费。当前报价见 查看当前模型与价格.
常见错误
| 现象 | 下一步 |
|---|---|
401 | 核对 API Key,不要把 Key 放入 JSON 或公开日志 |
409 | 保留原幂等键和请求;已知任务 ID 时先查询 |
422 | 按错误中的字段位置和可选值修改参数 |
410 | 成片已过期;后续任务应在有效期内下载 |
429 / 503 / timeout | 先查询已有任务;提交结果不明时复用原幂等键,不自动换键重发 |

