PixVerse 提供官方 API,可以在自己的服务端批量生成视频。 这篇做
pixverse_job.py:提交文生视频任务、按官方建议轮询状态、下载结果,并把每次请求的 trace id 和错误码记进日志。重点是官方文档强调的一点:HTTP 200 不等于成功。
先看清成品
- 用无效 key 真实调用:返回 HTTP 200 + ErrCode 10005。只检查 HTTP 状态码的客户端会把它当成成功。
- 本地替身上完整跑通:限流 712008 → 退避后提交成功 → 轮询两次 → 下载;每个请求的 trace id 都不同。
- 发现并修复两个问题:错误信息丢掉了处理建议;
sleep=time.sleep默认参数导致测试无法替换等待。pytest 6 项通过。
合规提醒:只上传你有权使用的图片和视频;不生成真实人物的误导性内容,不做未经同意的换脸;发布时按平台规则标注 AI 生成;商用前确认所用套餐的授权范围(以 PixVerse 官方条款为准)。本文只使用 PixVerse 官方网页/App 和官方 API。API key 只放在服务端,不放进前端代码或日志。本文不列价格,积分消耗以控制台为准。
运行说明:box 上没有 PixVerse 账号和 API key,没有生成过任何视频,也没有花费积分。我用无效 key 真实调用了文生视频接口和查询接口,两次都得到 HTTP 200 和
{"ErrCode":10005,"ErrMsg":"apiKey is not registered"}(2026-10-21 20:06,UTC+8)。其余接口响应来自我写的本地替身(错误码和状态值按官方文档)。C 篇的视频片段是我用 ffmpeg 合成的测试画面,不是 PixVerse 的输出。所有脚本和测试都在 box 上真实运行过(Python 3.13、ffmpeg 7.1.5)。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
pixverse_job.py |
提交、轮询、下载 |
fake_pixverse.py |
本地替身(按官方错误码和状态值) |
demo_local.py |
用替身演示 |
test_job.py |
6 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,pip install requests pytest |
| PixVerse API | A 篇需要在 PixVerse API 平台创建 key;B、C 篇不需要 |
| ffmpeg | C 篇需要 ffmpeg / ffprobe |
关键事实
| 事实 | 来源 |
|---|---|
Base URL https://app-api.pixverse.ai;请求头 API-KEY,每个请求都要新的 Ai-trace-id(UUID v4) |
官方 Start Here |
| “A response with HTTP 200 is successful only when ErrCode=0”;缺少 key、key 未注册、余额不足、限流、并发已满都返回 HTTP 200 | 官方 Error Codes |
文生视频 POST /openapi/v2/video/text/generate,必填 model、prompt、duration、quality、aspect_ratio;prompt 最长 5000 字符 |
官方 Text-to-Video |
查询 GET /openapi/v2/video/result/{video_id},每 3~5 秒一次;status 1 完成、5 生成中、7 审核未通过、8 失败;status 不是 1 时即使有 url 也不能当作完成 |
官方 Query Task Status |
| 任务一直是 5 时不要重复提交,继续查原来的 video_id;并发满(500044)时等待并指数退避 | 官方 Concurrency Rules |
文件结构
72-pixverse-A-src/
├── pixverse_job.py
├── fake_pixverse.py
├── demo_local.py
└── test_job.py
第 1 步:创建 API key(手动)
登录 PixVerse API 平台,在账户设置中创建 key,放进服务端环境变量 PIXVERSE_API_KEY。(本文没有账号,没有实际操作这一步。)
第 2 步:主脚本
"""PixVerse 官方 API:提交文生视频任务 → 轮询状态 → 下载结果,并把每次请求记到 jobs.jsonl。
官方要点(docs.platform.pixverse.ai):
- 请求头 API-KEY,以及每个请求都要新的 Ai-trace-id(UUID v4)
- **HTTP 200 不等于成功**:只有 ErrCode=0 才算成功(key 无效、余额不足、限流都返回 HTTP 200)
- 轮询 GET /openapi/v2/video/result/{video_id},每 3~5 秒一次;status 1 完成、5 生成中、7 审核未通过、8 失败
- 任务一直是 5 时不要重复提交,继续查原来的 video_id
key 只放在服务端环境变量 PIXVERSE_API_KEY 里,不要放进前端或日志。"""
import argparse, json, os, sys, time, uuid, requests
API = os.environ.get("PIXVERSE_API", "https://app-api.pixverse.ai")
RETRY = {712008: "请求频率超限", 500044: "并发数已满", 500069: "服务繁忙"}
STOP = {10004: "缺少 API key", 10005: "API key 未注册", 400012: "账号无效", 500020: "没有权限", 500090: "积分余额不足",
500054: "内容审核未通过", 500063: "预审核未通过", 400018: "提示词太长", 400013: "参数类型或取值错误"}
class PixVerseError(Exception):
def __init__(self, code, msg, trace):
label = STOP.get(code) or RETRY.get(code) or ""
super().__init__(f"ErrCode {code}({label + ':' if label else ''}{msg})trace={trace}"); self.code = code
class Client:
def __init__(self, key=None, sleep=None, log="jobs.jsonl"):
# sleep 默认写成 time.sleep 会在定义函数时就绑定,测试里替换 time.sleep 不起作用,所以运行时再取
self.key = key or os.environ.get("PIXVERSE_API_KEY"); self.sleep = sleep or (lambda s: time.sleep(s)); self.log = log
if not self.key: raise SystemExit("没有设置 PIXVERSE_API_KEY")
def call(self, method, path, body=None, tries=4):
for i in range(tries):
trace = str(uuid.uuid4()) # 每个请求一个新的 trace id
r = requests.request(method, API + path, json=body, timeout=60,
headers={"API-KEY": self.key, "Ai-trace-id": trace, "User-Agent": "binbho-tutorial/1.0"})
j = r.json() if r.headers.get("Content-Type", "").startswith("application/json") else {}
code = j.get("ErrCode", -1 if r.status_code != 200 else 0)
self._log({"t": time.strftime("%F %T"), "path": path, "http": r.status_code, "ErrCode": code, "ErrMsg": j.get("ErrMsg"), "trace": trace})
if r.status_code == 200 and code == 0: return j["Resp"]
if code in RETRY and i < tries - 1: self.sleep(2 ** i * 2); continue # 2、4、8 秒退避
raise PixVerseError(code, j.get("ErrMsg") or f"HTTP {r.status_code}", trace)
def _log(self, rec):
with open(self.log, "a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n")
def text_to_video(self, prompt, model="v6", duration=5, quality="540p", aspect_ratio="16:9", seed=None):
body = {"model": model, "prompt": prompt, "duration": duration, "quality": quality, "aspect_ratio": aspect_ratio}
if seed is not None: body["seed"] = seed
return self.call("POST", "/openapi/v2/video/text/generate", body)["video_id"]
def wait(self, video_id, every=4, timeout=900):
t0 = time.monotonic()
while True:
r = self.call("GET", f"/openapi/v2/video/result/{video_id}")
if r["status"] == 1 and r.get("url"): return r
if r["status"] == 7: raise PixVerseError(500054, "生成结果审核未通过:修改内容后再新建任务,不要原样重试", "-")
if r["status"] == 8: raise PixVerseError(8, "生成失败:保留 trace id,在账户流水中核对积分是否退回", "-")
if time.monotonic() - t0 > timeout: raise TimeoutError(f"{video_id} 超过 {timeout} 秒仍未完成;不要重复提交,稍后继续查询")
self.sleep(every)
def download(url, path):
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(1 << 16): f.write(chunk)
return path
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("prompt"); ap.add_argument("--model", default="v6"); ap.add_argument("--duration", type=int, default=5)
ap.add_argument("--quality", default="540p"); ap.add_argument("--aspect", default="16:9"); ap.add_argument("--seed", type=int); ap.add_argument("--out", default="clip.mp4")
ap.add_argument("--resume", type=int, help="继续查询已有的 video_id,而不是新建任务"); a = ap.parse_args(argv)
c = Client()
try:
vid = a.resume or c.text_to_video(a.prompt, a.model, a.duration, a.quality, a.aspect, a.seed); print("video_id", vid)
r = c.wait(vid); print("完成", r["outputWidth"], "x", r["outputHeight"], "seed", r.get("seed")); print("保存", download(r["url"], a.out))
except PixVerseError as e:
print("PixVerse 错误:", e); return 1
return 0
if __name__ == "__main__":
sys.exit(main())
第 3 步:本地替身
"""本地替身(我写的,不是 PixVerse 官方的)。错误码和 HTTP 状态按官方 Error Codes 页面;10005 的响应体是对真实接口的实测结果;
status 取值按官方 Query Task Status 页面。生成的“视频”只是几个字节的占位数据。"""
import json, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class State:
def __init__(self): self.calls = []; self.traces = []; self.script = []; self.polls_until_done = 2; self.final = 1; self.next_err = []
def make(st):
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def send(self, code, obj, ctype="application/json"):
b = obj if isinstance(obj, bytes) else json.dumps(obj).encode(); self.send_response(code); self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
def auth(self):
st.calls.append((self.command, self.path)); st.traces.append(self.headers.get("Ai-trace-id"))
if self.path.startswith("/files/"): return True
k = self.headers.get("API-KEY")
if not k: self.send(200, {"ErrCode": 10004, "ErrMsg": "apiKey is missing"}); return False
if k != "good": self.send(200, {"ErrCode": 10005, "ErrMsg": "apiKey is not registered"}); return False
if st.next_err: c = st.next_err.pop(0); self.send(200, {"ErrCode": c, "ErrMsg": "simulated"}); return False
return True
def do_POST(self):
n = int(self.headers.get("Content-Length", 0)); body = json.loads(self.rfile.read(n) or b"{}")
if not self.auth(): return
if self.path == "/openapi/v2/video/text/generate":
if not all(k in body for k in ("model", "prompt", "duration", "quality", "aspect_ratio")): return self.send(200, {"ErrCode": 400011, "ErrMsg": "missing param"})
if not isinstance(body["duration"], int): return self.send(400, {"ErrCode": 400013, "ErrMsg": "invalid type"})
return self.send(200, {"ErrCode": 0, "ErrMsg": "success", "Resp": {"video_id": 123456789}})
self.send(404, {"ErrCode": 99999, "ErrMsg": "not found"})
def do_GET(self):
if not self.auth(): return
if self.path.startswith("/files/"): return self.send(200, b"\x00\x00\x00\x18ftypmp42demo", "video/mp4")
if self.path.startswith("/openapi/v2/video/result/"):
st.polls_until_done -= 1; done = st.polls_until_done <= 0
status = st.final if done else 5
url = f"http://{self.headers['Host']}/files/123456789.mp4"
return self.send(200, {"ErrCode": 0, "ErrMsg": "success", "Resp": {"id": 123456789, "status": status, "url": url, # 生成中也可能已有 url
"outputWidth": 960 if status == 1 else 0, "outputHeight": 540 if status == 1 else 0, "seed": 12345}})
self.send(404, {"ErrCode": 99999, "ErrMsg": "not found"})
srv = ThreadingHTTPServer(("127.0.0.1", 0), H); threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, f"http://127.0.0.1:{srv.server_address[1]}"
第 4 步:演示脚本
"""用本地替身演示一次完整流程(不连接 PixVerse,不花积分):python demo_local.py [key]"""
import os, sys, fake_pixverse as F
st = F.State(); st.next_err = [712008] # 第一次提交模拟“请求频率超限”
srv, url = F.make(st); os.environ["PIXVERSE_API_KEY"] = sys.argv[1] if len(sys.argv) > 1 else "good"
import pixverse_job as P
P.API = url
try: code = P.main(["A blue glass marble rolls across a clean white table", "--duration", "1", "--quality", "360p", "--out", "clip.mp4"])
finally: srv.shutdown()
sys.exit(code)
第 5 步:测试
import json, pytest, fake_pixverse as F, pixverse_job as P
@pytest.fixture
def env(monkeypatch, tmp_path):
st = F.State(); srv, url = F.make(st); monkeypatch.setattr(P, "API", url); monkeypatch.chdir(tmp_path)
slept = []; yield st, slept; srv.shutdown()
def client(env, key="good"): return P.Client(key, sleep=env[1].append)
def test_http200_but_bad_key(env):
# 真实接口:无效 key 返回 HTTP 200 + {"ErrCode":10005,"ErrMsg":"apiKey is not registered"}
with pytest.raises(P.PixVerseError, match="10005"):
client(env, "bad").text_to_video("a marble")
def test_full_flow_and_trace_ids(env):
st, slept = env; c = client(env)
vid = c.text_to_video("A blue glass marble rolls across a clean white table", duration=1, quality="360p")
r = c.wait(vid)
assert vid == 123456789 and r["status"] == 1 and r["outputWidth"] == 960
assert slept == [4] and len(set(st.traces)) == len(st.traces) == 3 # 每个请求的 trace id 都不同
assert P.download(r["url"], "clip.mp4") and open("clip.mp4", "rb").read(8)[4:8] == b"ftyp"
log = [json.loads(l) for l in open("jobs.jsonl")]
assert [l["ErrCode"] for l in log] == [0, 0, 0] and "API-KEY" not in open("jobs.jsonl").read()
def test_url_present_while_generating_is_not_done(env):
st, _ = env; st.polls_until_done = 3; c = client(env)
assert c.wait(1)["status"] == 1 and len([x for x in st.calls if "result" in x[1]]) == 3
def test_retry_on_rate_limit_then_stop_on_balance(env):
st, slept = env; st.next_err = [712008, 712008]
assert client(env).text_to_video("x") == 123456789 and slept == [2, 4]
st.next_err = [500090]
with pytest.raises(P.PixVerseError, match="积分余额不足"): client(env).text_to_video("x")
def test_moderation_and_failure_states(env):
st, _ = env; st.final = 7
with pytest.raises(P.PixVerseError, match="不要原样重试"): client(env).wait(1)
st.final = 8; st.polls_until_done = 1
with pytest.raises(P.PixVerseError, match="积分是否退回"): client(env).wait(1)
def test_resume_does_not_resubmit(env, monkeypatch):
st, _ = env; monkeypatch.setenv("PIXVERSE_API_KEY", "good"); monkeypatch.setattr(P.time, "sleep", lambda s: None)
assert P.main(["unused", "--resume", "123456789"]) == 0
assert not any(m == "POST" for m, _ in st.calls)
第 6 步:运行
$ python demo_local.py
video_id 123456789
完成 960 x 540 seed 12345
保存 clip.mp4
$ cat jobs.jsonl
{"t": "2026-10-21 20:25:01", "path": "/openapi/v2/video/text/generate", "http": 200, "ErrCode": 712008, "ErrMsg": "simulated", "trace": "78f68d75-a393-4be2-986a-bf2273c35ce8"}
{"t": "2026-10-21 20:25:03", "path": "/openapi/v2/video/text/generate", "http": 200, "ErrCode": 0, "ErrMsg": "success", "trace": "622164c6-6555-4de8-a035-187c06b5a4c1"}
{"t": "2026-10-21 20:25:03", "path": "/openapi/v2/video/result/123456789", "http": 200, "ErrCode": 0, "ErrMsg": "success", "trace": "3ba9a29b-afd1-4c91-9361-12e57e0baaaf"}
{"t": "2026-10-21 20:25:07", "path": "/openapi/v2/video/result/123456789", "http": 200, "ErrCode": 0, "ErrMsg": "success", "trace": "c07c3627-dc37-49e5-bd55-f9c084105a8c"}
$ python demo_local.py bad
PixVerse 错误: ErrCode 10005(API key 未注册:apiKey is not registered)trace=d0f6a771-e76f-4efb-8b14-71b0b2030986
$ curl -s -w ' HTTP %{http_code}' -X POST https://app-api.pixverse.ai/openapi/v2/video/text/generate \
-H 'API-KEY: invalid' -H "Ai-trace-id: $(uuidgen)" -H 'Content-Type: application/json' \
-d '{"model":"v6","prompt":"A blue glass marble rolls across a white table","duration":1,"quality":"360p","aspect_ratio":"16:9"}' # 真实接口
{"ErrCode":10005,"ErrMsg":"apiKey is not registered"} HTTP 200
$ python -m pytest -q
6 passed
怎么检查结果?
- 日志中每一行的 trace 都不同,且不包含 API key。
- 限流(712008)、并发满(500044)、服务繁忙(500069)会退避重试;余额不足、审核未通过、key 错误不会重试。
--resume video_id只查询,不会新建任务(测试检查没有 POST)。- 联系客服时提供 trace id 和 video_id,不要提供完整 key。
调试示例
1. HTTP 200 的失败。 用无效 key 调用真实接口,返回的是 HTTP 200 和 {"ErrCode":10005,"ErrMsg":"apiKey is not registered"}。如果按一般习惯写 r.raise_for_status(),这一步不会报错,后面读取 Resp.video_id 时才会出现 KeyError,很难看出原因。官方错误码页面也明确写了:只有 ErrCode=0 才算成功。脚本对每个响应都同时检查 HTTP 状态和 ErrCode。
2. 错误信息丢了处理建议。 第一版的错误信息只显示错误码对应的名称。生成结果审核未通过时,我传入的提示“修改内容后再新建任务,不要原样重试”被名称“内容审核未通过”替换掉了,测试因此失败。修复:名称和具体说明都显示。
3. 测试用了 7 秒。 Client.__init__(sleep=time.sleep) 中的默认值在函数定义时就已绑定,测试里替换 time.sleep 不起作用,轮询时真的等了 4 秒。修复:默认值改为 None,运行时再取 time.sleep。测试时间降到约 3 秒。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| ErrCode 10005 | key 未注册或复制错了 | 重新复制 key;确认使用的是 API 平台的 key |
| ErrCode 500090 | 积分余额不足 | 在控制台充值后再提交 |
| ErrCode 500054 / status 7 | 内容审核未通过 | 修改提示词或素材,不要原样重试 |
| 一直是 status 5 | 生成排队中 | 继续查询原 video_id,不要重复提交 |
| ErrCode 500044 | 并发已满 | 等待已有任务结束;减少并行提交 |
模板规则
- 同时检查 HTTP 状态和 ErrCode。
- 每个请求都用新的 trace id,并记入日志。
- 只在 status=1 时下载。
- 不重复提交仍在生成的任务。
- API key 只放在服务端。
项目资源
| 用途 | 地址 |
|---|---|
| PixVerse 官网 | https://pixverse.ai/ |
| PixVerse API:Start Here(官方) | https://docs.platform.pixverse.ai/start-here-2394989m0 |
| PixVerse API:错误码(官方) | https://docs.platform.pixverse.ai/error-codes-2394992m0 |
| PixVerse API:Text-to-Video(官方) | https://docs.platform.pixverse.ai/text-to-video-13016634e0 |
| PixVerse API:Query Task Status(官方) | https://docs.platform.pixverse.ai/query-task-status-43051212e0 |
| PixVerse API:Concurrency Rules(官方) | https://docs.platform.pixverse.ai/concurrency-rules-2394990m0 |
把错误码处理好,积分才不会浪费在重复提交上。