用 Runway API(gen4.5)把一张本地照片变成 5 秒视频:按文档预处理首帧(选择最接近的输出比例并居中裁剪,按编码后大小控制 data URI 不超过 5MB)→ 提交任务 → 每 5 秒轮询一次 → 成功后立刻下载(输出链接 24–48 小时内过期)→ 把 prompt、seed、任务 id 和 sha256 写入 manifest.json。内容审核失败不重试。
先看清成品
- 测试: pytest 2 项通过。
- 模拟服务器: 完整跑通了“提交 → PENDING → RUNNING → SUCCEEDED → 下载”,manifest 记录完整。
- 失败场景:
SAFETY.INPUT.TEXT失败给出“不要重试”的提示;Key 格式错误返回 401。
没有在 box 上跑的部分: box 上没有 Runway API Key,没有生成过任何真实视频。对真实接口只做过一次无效 Key 的调用:返回 401,内容是“The provided API key in the Authorization header did not start with
key_.”。提交、轮询、下载在我写的模拟服务器fake_runway.py上运行:它按文档规则检查请求,返回的视频是 ffmpeg 生成的测试画面。
合规提醒:
RUNWAYML_API_SECRET只放在服务器端或环境变量里。首帧图片和参考素材必须是你有权使用的:自己拍的、获得授权的,或者许可允许的。不要用别人的肖像、商标或受版权保护的作品去生成视频,不要冒充真人。生成的视频发布时要按《人工智能生成合成内容标识办法》加上显式的“AI 生成”标识,并遵守 Runway 的使用条款和内容政策。费用按 credits 计算,以 Runway 开发者文档的 Pricing 页为准,本文不列价格。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
runway_i2v.py |
prepare()、submit()、wait()、download() 四个函数,B 篇直接复用 |
fake_runway.py |
模拟服务器(127.0.0.1:18470):检查 Key 前缀、X-Runway-Version、ratio 和 duration 的取值、data URI 是否不超过 5MB、宽高比是否在 0.5–2 之间 |
test_prepare.py |
比例选择、超宽图片的裁剪和大小控制 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Python | 3.9 以上 | pip install requests pillow flask pytest |
| Runway | 开发者平台的 API Key(以 key_ 开头) |
RUNWAYML_API_SECRET;每个请求都要带 X-Runway-Version: 2024-11-06 |
| ffmpeg | 只有模拟服务器需要 | 用来生成测试视频 |
文档要点(截至本文写作时):
- gen4.5 图生视频的
ratio可选 1280:720、1584:672、1104:832、720:1280、832:1104、672:1584、960:960;duration为 2–10 秒。 - 首帧宽高比要在 0.5–2 之间。
- 图片用 data URI 传入时上限是 5MB,按编码后的大小计算;用 URL 传入时上限是 16MB。
- 轮询任务不要快于每 5 秒一次。
文件结构
47-runway-A-src/
├── runway_i2v.py
├── fake_runway.py
└── test_prepare.py
第 1 步:代码
runway_i2v.py:
"""图生视频(Runway API,gen4.5):本地图片 → 预处理 → 提交任务 → 轮询 → 立刻下载 → 记录清单。
按官方文档处理:
- 首帧图宽高比必须在 0.5~2 之间(gen4.5);输出 ratio 只能取文档列出的几种 → 选最接近的 ratio,居中裁剪
- data URI 的 5MB 上限是“编码后”的大小,base64 会膨胀约 33% → 按编码后大小判断,超了就降低 JPEG 质量
- 轮询不要快于每 5 秒一次;状态有 PENDING / THROTTLED / RUNNING / SUCCEEDED / FAILED / CANCELLED
- output 里的链接 24~48 小时内过期 → 成功后立刻下载,保存 prompt、seed、任务 id 到 manifest.json
- SAFETY.* 失败不要重试(SAFETY.INPUT.* 不退还 credits)
环境变量:RUNWAYML_API_SECRET、RUNWAY_BASE(默认 https://api.dev.runwayml.com)、POLL_SECONDS(默认 5,不要改小)
用法:python runway_i2v.py 图片 "镜头描述" [--duration 5] [--seed 42] [--out 输出目录]"""
import argparse, base64, hashlib, io, json, os, sys, time
import requests
from PIL import Image
BASE = os.environ.get("RUNWAY_BASE", "https://api.dev.runwayml.com")
RATIOS = ["1280:720", "1584:672", "1104:832", "960:960", "832:1104", "720:1280", "672:1584"] # gen4.5 图生视频(文档)
MAX_URI = 5 * 1024 * 1024
POLL = float(os.environ.get("POLL_SECONDS", 5))
class RunwayError(Exception): pass
def headers():
k = os.environ.get("RUNWAYML_API_SECRET") or sys.exit("请先设置环境变量 RUNWAYML_API_SECRET")
return {"Authorization": f"Bearer {k}", "X-Runway-Version": "2024-11-06", "Content-Type": "application/json"}
def nearest_ratio(w, h):
return min(RATIOS, key=lambda r: abs(int(r.split(":")[0]) / int(r.split(":")[1]) - w / h))
def prepare(path):
"""返回 (data_uri, ratio, 说明)"""
im = Image.open(path); im = im.convert("RGB")
w, h = im.size; notes = []
ratio = nearest_ratio(w, h); rw, rh = map(int, ratio.split(":"))
target = rw / rh
if abs(w / h - target) > 0.01: # 居中裁剪到目标比例,避免模型自动裁掉主体
if w / h > target: nw = round(h * target); im = im.crop(((w - nw) // 2, 0, (w - nw) // 2 + nw, h))
else: nh = round(w / target); im = im.crop((0, (h - nh) // 2, w, (h - nh) // 2 + nh))
notes.append(f"裁剪 {w}x{h}→{im.size[0]}x{im.size[1]}")
if im.size[0] > rw: im = im.resize((rw, rh), Image.LANCZOS); notes.append(f"缩放到 {rw}x{rh}")
for q in (92, 85, 75, 60):
buf = io.BytesIO(); im.save(buf, "JPEG", quality=q)
uri = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
if len(uri) <= MAX_URI: break
else: raise RunwayError("压缩后仍超过 5MB,请改用 HTTPS 链接或 ephemeral upload")
notes.append(f"JPEG 质量 {q},文件 {buf.tell()/1024/1024:.2f}MB,编码后 {len(uri)/1024/1024:.2f}MB")
return uri, ratio, ";".join(notes)
def submit(uri, ratio, prompt, duration, seed=None):
body = {"model": "gen4.5", "promptImage": uri, "promptText": prompt, "ratio": ratio, "duration": duration}
if seed is not None: body["seed"] = seed
r = requests.post(f"{BASE}/v1/image_to_video", json=body, headers=headers(), timeout=60)
if r.status_code != 200: raise RunwayError(f"提交失败 HTTP {r.status_code}:{r.json().get('error', r.text)}")
return r.json()["id"]
def wait(tid, timeout=900):
t0, last = time.time(), None
while time.time() - t0 < timeout:
r = requests.get(f"{BASE}/v1/tasks/{tid}", headers=headers(), timeout=30); r.raise_for_status(); t = r.json()
if t["status"] != last: print(f" {time.time()-t0:5.1f}s {t['status']}" + (f" {t.get('progress', 0):.0%}" if t["status"] == "RUNNING" else ""))
last = t["status"]
if t["status"] == "SUCCEEDED": return t
if t["status"] in ("FAILED", "CANCELLED"):
code = t.get("failureCode", "")
advice = "内容审核未通过,不要重试,修改输入" if code.startswith(("SAFETY", "INPUT_PREPROCESSING.SAFETY")) else "可以修改后重试"
raise RunwayError(f"任务{t['status']}:{code} {t.get('failure', '')}({advice})")
time.sleep(POLL)
raise RunwayError("等待超时;任务可能还在排队,可稍后用任务 id 再查")
def download(t, outdir, meta):
os.makedirs(outdir, exist_ok=True); files = []
for i, url in enumerate(t["output"]):
p = os.path.join(outdir, f"{t['id']}_{i}.mp4")
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status(); h = hashlib.sha256()
with open(p, "wb") as f:
for chunk in r.iter_content(1 << 16): f.write(chunk); h.update(chunk)
files.append({"file": os.path.basename(p), "sha256": h.hexdigest()})
mf = os.path.join(outdir, "manifest.json")
items = json.load(open(mf, encoding="utf-8")) if os.path.exists(mf) else []
items.append({**meta, "task_id": t["id"], "files": files, "label": "AI 生成内容(Runway gen4.5)"})
json.dump(items, open(mf, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
return files
def main():
ap = argparse.ArgumentParser(); ap.add_argument("image"); ap.add_argument("prompt")
ap.add_argument("--duration", type=int, default=5); ap.add_argument("--seed", type=int); ap.add_argument("--out", default="outputs")
a = ap.parse_args()
try:
uri, ratio, note = prepare(a.image); print(f"✓ 预处理:ratio {ratio};{note}")
tid = submit(uri, ratio, a.prompt, a.duration, a.seed); print(f"✓ 已提交任务 {tid},每 {POLL:g}s 查询一次")
t = wait(tid)
files = download(t, a.out, {"image": os.path.basename(a.image), "prompt": a.prompt, "seed": a.seed, "ratio": ratio, "duration": a.duration})
print(f"✓ 已下载 {', '.join(f['file'] for f in files)},记录在 {a.out}/manifest.json")
except RunwayError as e:
sys.exit(f"✗ {e}")
if __name__ == "__main__":
main()
fake_runway.py(仅用于离线测试):
"""Runway API 的模拟服务器(仅用于离线测试;按 docs.dev.runwayml.com 的规则检查请求,生成的视频是 ffmpeg 做的测试画面)。
检查:key 必须以 key_ 开头(真实接口实测的报错文字)、X-Runway-Version 必须是 2024-11-06、ratio/duration 取值、
data URI 编码后 ≤ 5MB、首帧宽高比在 0.5~2 之间(gen4.5)。
任务:每查询一次前进一步 PENDING → RUNNING → SUCCEEDED;promptText 含 FAILTEST 时以 SAFETY.INPUT.TEXT 失败;含 THROTTLE 时先 THROTTLED 两次。
用法:python fake_runway.py (监听 127.0.0.1:18470)"""
import base64, io, os, subprocess, tempfile, uuid
from flask import Flask, jsonify, request, send_file
from PIL import Image
app = Flask(__name__); TASKS = {}
RATIOS = {"1280:720", "720:1280", "1104:832", "960:960", "832:1104", "1584:672", "672:1584"}
def clip(d): return os.path.join(tempfile.gettempdir(), f"fake_runway_clip_{d}s.mp4")
def err(msg, code=400): return jsonify(error=msg), code
@app.before_request
def auth():
if request.path.startswith("/files/"): return None
k = request.headers.get("Authorization", "")
if not k.startswith("Bearer key_"): return err("The provided API key in the Authorization header did not start with `key_`.", 401)
if request.headers.get("X-Runway-Version") != "2024-11-06": return err("X-Runway-Version header must be 2024-11-06")
@app.post("/v1/image_to_video")
def i2v():
b = request.get_json()
if b.get("model") != "gen4.5": return err("this fake server only supports model gen4.5")
if b.get("ratio") not in RATIOS: return err(f"invalid ratio {b.get('ratio')}")
if not isinstance(b.get("duration"), int) or not 2 <= b["duration"] <= 10: return err("duration must be an integer from 2 to 10")
img = b.get("promptImage", "")
if img.startswith("data:"):
if len(img) > 5 * 1024 * 1024: return err(f"data URI is {len(img)/1024/1024:.1f}MB, over the 5MB limit")
head, data = img.split(",", 1)
if ";base64" not in head: return err("data URI must use base64")
w, h = Image.open(io.BytesIO(base64.b64decode(data))).size
if not 0.5 <= w / h <= 2: return err(f"prompt image aspect ratio {w/h:.2f} outside 0.5-2")
tid = str(uuid.uuid4()); p = b.get("promptText", "")
TASKS[tid] = {"steps": (["THROTTLED"] * 2 if "THROTTLE" in p else []) + ["PENDING", "RUNNING", "FAILED" if "FAILTEST" in p else "SUCCEEDED"], "i": 0, "dur": b["duration"]}
return jsonify(id=tid)
@app.get("/v1/tasks/<tid>")
def task(tid):
t = TASKS.get(tid)
if not t: return err("not found", 404)
st = t["steps"][min(t["i"], len(t["steps"]) - 1)]; t["i"] += 1
out = {"id": tid, "createdAt": "2026-10-21T00:00:00Z", "status": st}
if st == "RUNNING": out["progress"] = 0.5
if st == "FAILED": out.update(failure="(模拟)输入的文字未通过审核", failureCode="SAFETY.INPUT.TEXT")
if st == "SUCCEEDED":
if not os.path.exists(clip(t["dur"])):
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", f"testsrc=size=1280x720:rate=24:duration={t['dur']}", "-pix_fmt", "yuv420p", clip(t["dur"])], check=True)
out["output"] = [f"{request.host_url}files/{t['dur']}"]
return jsonify(out)
@app.get("/files/<int:d>")
def files(d): return send_file(clip(d), mimetype="video/mp4")
if __name__ == "__main__":
app.run(port=18470)
test_prepare.py:
import os; os.environ.setdefault("RUNWAYML_API_SECRET", "key_test")
from PIL import Image
import runway_i2v as r
def test_nearest_ratio():
assert r.nearest_ratio(4000, 3000) == "1104:832" and r.nearest_ratio(1080, 1920) == "720:1280"
def test_prepare_crops_and_fits(tmp_path):
p = tmp_path / "wide.png"; Image.new("RGB", (5000, 1000), "red").save(p) # 5:1 超出范围 → 裁成 1584:672
uri, ratio, note = r.prepare(p)
assert ratio == "1584:672" and len(uri) <= r.MAX_URI and "裁剪" in note
第 2 步:运行
$ export RUNWAYML_API_SECRET=key_...
$ python runway_i2v.py photo.jpg "镜头缓慢推进,树叶在风中轻轻摆动" --seed 42
# box 上:python fake_runway.py 后设置 RUNWAY_BASE=http://127.0.0.1:18470 POLL_SECONDS=0.5(只有模拟服务器才可以改小轮询间隔)
✓ 预处理:ratio 1104:832;缩放到 1104x832;JPEG 质量 92,文件 0.65MB,编码后 0.87MB
✓ 已提交任务 b19bd304-…,每 0.5s 查询一次
0.0s PENDING
0.5s RUNNING 50%
1.6s SUCCEEDED
✓ 已下载 b19bd304-…_0.mp4,记录在 outputs/manifest.json
$ python runway_i2v.py photo.jpg "FAILTEST" # 模拟审核失败
✗ 任务FAILED:SAFETY.INPUT.TEXT (模拟)输入的文字未通过审核(内容审核未通过,不要重试,修改输入)
manifest.json 里记录了 image、prompt、seed、ratio、duration、task_id、文件 sha256,以及“AI 生成内容(Runway gen4.5)”的标注。以后想复现或者说明来源,都靠这份记录。
调试示例:4.45MB 的图片为什么超过了 5MB?
最省事的写法是“文件小于 5MB 就直接转成 base64 发送”。我用一张 2300×1725 的照片测试(文件 4.45MB),模拟服务器按文档规则拒绝了:
文件 4.45MB(<5MB)→ data URI 5.94MB
400 {"error":"data URI is 5.9MB, over the 5MB limit"}
文档写得很清楚:5MB 是编码后的 data URI 的上限,base64 会使大小增加约 33%,所以原文件要在 3.3MB 以内。prepare() 现在先缩放到输出尺寸(1104×832,再大也没有用),再按编码后的长度逐级降低 JPEG 质量。同一张照片处理后编码只有 0.79MB。图片实在太大时,改用 HTTPS 链接(上限 16MB),链接要求见 C 篇。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
401 did not start with key_ |
Key 填错了(实测的报错文字) | 到开发者平台复制以 key_ 开头的 Key |
| 400 ratio 无效 | 用了 16:9 这样的写法 | 必须用文档列出的像素比,例如 1280:720 |
| 一直是 THROTTLED | 超过了账号的并发上限,正在排队 | 等待即可;不要反复重新提交 |
| 输出链接打不开 | 已经过了 24–48 小时 | 重新获取任务拿新链接;成功后应该立刻下载 |
SAFETY.* 失败 |
内容审核未通过 | 不要重试;SAFETY.INPUT.* 失败不退还 credits |
模板规则
- 首帧按文档要求预处理:比例、宽高比、编码后的大小。
- 轮询间隔不少于 5 秒,成功后立刻下载。
- 每个成品都要记录 prompt、seed、任务 id 和 sha256。
项目资源
| 用途 | 地址 |
|---|---|
| Runway API 入门 | https://docs.dev.runwayml.com/guides/using-the-api/ |
| API 参考(Markdown 版) | https://docs.dev.runwayml.com/api.md |
| 输入要求(图片 / 视频 / 大小限制) | https://docs.dev.runwayml.com/assets/inputs/ |
| 任务失败码 | https://docs.dev.runwayml.com/errors/task-failures/ |
| Pricing(credits) | https://docs.dev.runwayml.com/guides/pricing/ |
把预处理和存档做好,每一次生成花掉的 credits 才不会白花。