设计实践

用 Runway API(gen4.5)把一张本地照片变成 5 秒视频

用 Runway API(gen4.5)把一张本地照片变成 5 秒视频:按文档预处理首帧(选择最接近的输出比例并居中裁剪,按编码后大小控制 data URI 不超过 5MB)→ 提交任务 → 每 5 秒轮询一次 → 成功后立刻下载(输出链接 24–48 小时内过期)→ 把 prompt、seed、任务 id 和 sha256 写入 manifest.json

BINBHO · 图文教程

用 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 才不会白花。