设计实践

用分镜表调用 Runway 逐镜生成视频并拼接成片

写一张分镜表(图片、镜头描述、时长、字幕),程序逐个镜头调用 Runway 生成视频,再用 ffmpeg 统一规格后拼接成片,烧录字幕,并在右上角加上“AI 生成”标识。已经成功的镜头记录在 manifest 里,重跑时跳过,不会重复花 credits。

BINBHO · 图文教程

写一张分镜表(图片、镜头描述、时长、字幕),程序逐个镜头调用 Runway 生成视频,再用 ffmpeg 统一规格后拼接成片,烧录字幕,并在右上角加上“AI 生成”标识。已经成功的镜头记录在 manifest 里,重跑时跳过,不会重复花 credits。

先看清成品

  • 测试: pytest 1 项通过。
  • 模拟服务器: 3 个镜头(横图、竖图、方图)都生成成功,其中一个先经过 2 次 THROTTLED 排队。拼成 1280×720、15.0 秒的成片,字幕和标识都正确显示(截图见下方的检查点)。
  • 重跑: 3 个镜头全部跳过。
  • 画面说明: 成片画面是测试图案,不是真实的生成结果。

没有在 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 页为准,本文不列价格。

做完之后你会得到什么?

成果 说明
storyboard.py 读取分镜表 → 逐镜头生成(可续跑)→ 统一为 1280×720、24fps → concat → 根据时长生成 SRT → 烧录字幕和“AI 生成”标识
runway_i2v.py 和 A 篇相同
shots.csv、shot1-3.png 示例分镜和示例图片
test_srt.py SRT 时间格式

需要准备哪些环境?

项目 要求 说明
ffmpeg 带 libass 和 drawtext ffmpeg -filters 里能看到 subtitles 和 drawtext
中文字体 例如 Noto Sans CJK 用 FONT 环境变量指定字体文件
Runway 同 A 篇

文件结构

47-runway-B-src/
├── storyboard.py
├── runway_i2v.py
├── shots.csv
├── shot1.png / shot2.png / shot3.png
└── test_srt.py

第 1 步:分镜表和代码

shots.csv(第 3 行的 THROTTLE 只用于让模拟服务器演示排队,真实使用时删掉):

image,prompt,duration,subtitle
shot1.png,清晨的茶园,镜头缓慢横移,5,清晨,茶园刚刚醒来
shot2.png,采茶人的手特写,轻轻摘下嫩芽,4,一芽一叶,全凭手感
shot3.png,茶叶在铁锅中翻炒,热气升腾,THROTTLE,6,高温杀青,锁住香气

storyboard.py:

"""分镜表 → 逐镜头调用 Runway 图生视频 → 统一规格后拼接成片,配上字幕和“AI 生成”标识。
- 镜头之间按顺序提交(先跑通再考虑并发;账号的并发上限以控制台为准,超出会进入 THROTTLED 排队)
- 已经成功的镜头记录在 outputs/manifest.json,重跑时跳过,不重复花 credits
- 各镜头的 ratio 可能不同(按图片选最接近的比例)→ 拼接前统一缩放并补边到 1280x720、24fps
- 字幕按镜头时长生成 SRT,烧录进画面;右上角加“AI 生成”标识(《人工智能生成合成内容标识办法》要求显式标识)
依赖:ffmpeg(带 libass、drawtext),一个中文字体(FONT 环境变量,默认 Noto CJK)
用法:python storyboard.py shots.csv 成片.mp4"""
import csv, json, os, subprocess, sys
import runway_i2v as rw

OUT = "outputs"
FONT = os.environ.get("FONT", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc")

def srt_time(s): return f"{int(s//3600):02d}:{int(s%3600//60):02d}:{int(s%60):02d},{int(round(s*1000))%1000:03d}"

def done_shots():
    mf = os.path.join(OUT, "manifest.json")
    return {m["image"] + "|" + m["prompt"]: m for m in json.load(open(mf, encoding="utf-8"))} if os.path.exists(mf) else {}

def run_shot(row):
    key = row["image"] + "|" + row["prompt"]; old = done_shots().get(key)
    if old: print(f"↷ {row['image']} 已生成过,跳过"); return os.path.join(OUT, old["files"][0]["file"])
    uri, ratio, note = rw.prepare(row["image"]); print(f"→ {row['image']}:ratio {ratio};{note}")
    t = rw.wait(rw.submit(uri, ratio, row["prompt"], int(row["duration"])))
    f = rw.download(t, OUT, {"image": row["image"], "prompt": row["prompt"], "seed": None, "ratio": ratio, "duration": int(row["duration"])})
    return os.path.join(OUT, f[0]["file"])

def main():
    rows = list(csv.DictReader(open(sys.argv[1], encoding="utf-8")))
    clips, failed = [], []
    for row in rows:
        try: clips.append((run_shot(row), row))
        except rw.RunwayError as e: failed.append(row["image"]); print(f"✗ {row['image']}:{e}")
    if failed: sys.exit(f"有 {len(failed)} 个镜头失败({', '.join(failed)}),修改后重跑;成功的镜头不会重复生成")
    os.makedirs("work", exist_ok=True); parts, t, srt = [], 0.0, []
    for i, (clip, row) in enumerate(clips):
        dur = float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clip], capture_output=True, text=True, check=True).stdout)
        p = f"work/part{i}.mp4"
        subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", clip, "-vf",
                        "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=24,format=yuv420p",
                        "-an", "-c:v", "libx264", "-preset", "veryfast", p], check=True)
        parts.append(p); srt.append(f"{i+1}\n{srt_time(t)} --> {srt_time(t+dur)}\n{row['subtitle']}\n"); t += dur
    open("work/list.txt", "w").write("".join(f"file '{os.path.basename(p)}'\n" for p in parts))
    open("work/subs.srt", "w", encoding="utf-8").write("\n".join(srt))
    subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", "work/list.txt", "-c", "copy", "work/joined.mp4"], check=True)
    vf = (f"subtitles=work/subs.srt:force_style='FontName=Noto Sans CJK SC,FontSize=22',"
          f"drawtext=fontfile={FONT}:text='AI 生成':x=w-tw-24:y=24:fontsize=28:fontcolor=white:box=1:boxcolor=black@0.4:boxborderw=8")
    subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", "work/joined.mp4", "-vf", vf, "-c:v", "libx264", "-preset", "veryfast", sys.argv[2]], check=True)
    print(f"✓ 成片 {sys.argv[2]}:{len(parts)} 个镜头,共 {t:.1f}s,已烧录字幕和“AI 生成”标识")

if __name__ == "__main__":
    main()

第 2 步:运行

$ python storyboard.py shots.csv 成片.mp4
→ shot1.png:ratio 1280:720;缩放到 1280x720;…
    0.0s  PENDING
    0.3s  RUNNING  50%
    1.1s  SUCCEEDED
→ shot2.png:ratio 832:1104;裁剪 1080x1350→1017x1350;缩放到 832x1104;…
→ shot3.png:ratio 960:960;缩放到 960x960;…
    0.0s  THROTTLED
    0.6s  PENDING
    0.9s  RUNNING  50%
    1.7s  SUCCEEDED
✓ 成片 成片.mp4:3 个镜头,共 15.0s,已烧录字幕和“AI 生成”标识

$ python storyboard.py shots.csv 成片2.mp4          # 再跑一次
↷ shot1.png 已生成过,跳过
↷ shot2.png 已生成过,跳过
↷ shot3.png 已生成过,跳过

检查点:从第 7 秒截一帧,应该能看到第 2 个镜头的字幕“一芽一叶,全凭手感”,右上角有“AI 生成”。box 上实测都符合。

调试示例:直接 -c copy 拼接,退出码 0,成片却是坏的

竖图镜头输出 832×1104,横图镜头输出 1280×720。我用一段 1280×720 和一段 832×1104 的测试视频直接 ffmpeg -f concat -c copy 拼接:退出码是 0,ffprobe 显示的分辨率是 1280×720,看起来一切正常。但从第 3 秒(第二段)截出来的帧是竖的,播放器会变形或者花屏,因为拼接只是把两段码流接起来,不会统一分辨率。

现在每一段都先 scale=…:force_original_aspect_ratio=decrease,pad=1280:720,统一帧率和像素格式,然后再拼接。竖图镜头会被加上左右黑边,画面不变形。拼接前先统一规格,退出码是 0 不代表成片没问题,一定要截几帧看看。

常见问题

现象 原因 处理
字幕是方块 没有中文字体 安装 Noto CJK,并设置 FONT
某个镜头失败,整个程序退出 程序有意这样设计 修改这一镜头后重跑,成功的镜头会自动跳过
镜头之间画面跳得厉害 各镜头是分别生成的 用同一风格的首帧图,提示词里写明光线和色调

模板规则

  • 分镜表就是项目文件,成功的镜头都记录在 manifest 里。
  • 拼接前统一分辨率、帧率和像素格式。
  • 成片必须带“AI 生成”的显式标识。

项目资源

用途 地址
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/
FFmpeg concat 说明 https://trac.ffmpeg.org/wiki/Concatenate
FFmpeg 滤镜文档(subtitles / drawtext) https://ffmpeg.org/ffmpeg-filters.html

AI 只负责生成镜头,成片的质量还要靠分镜、规格统一和标识这些后期工作。