AI 工具

用 Luma Ray 3.2 制作关键帧分镜:秒数校验与请求预检

Ray 3.2 支持把多张图片钉在视频的指定帧上(keyframes),用来控制镜头从哪里开始、中途经过什么、最后停在哪。 这篇做 storyboard.py:用“秒 + 图片”写分镜表,自动换算成 24fps 网格上的帧号,按官方规则在本地校验(范围、唯一性、loop/HDR/360p 的组合),生成 request.json;确认无误后再加 --subm

BINBHO · 图文教程

Ray 3.2 支持把多张图片钉在视频的指定帧上(keyframes),用来控制镜头从哪里开始、中途经过什么、最后停在哪。 这篇做 storyboard.py:用“秒 + 图片”写分镜表,自动换算成 24fps 网格上的帧号,按官方规则在本地校验(范围、唯一性、loop/HDR/360p 的组合),生成 request.json;确认无误后再加 --submit 提交。

先看清成品

  • 3 张关键帧(0s、2.5s、5s)换算为帧号 0、60、120,生成请求。
  • 2.5s 和 2.52s 落在同一帧时,本地直接拒绝,不浪费一次调用。
  • 发现真实问题:同一张本地图内嵌两次,请求体达到 1678 KB;加上 file_id 支持和体积提醒。pytest 5 项通过。

合规提醒:只上传你有权使用的图片作为关键帧或参考图;不生成真实人物的误导性内容。本文只使用 Luma 官方的 Agents API(https://agents.lumalabs.ai/v1),key 在 Luma 控制台创建,放在环境变量 LUMA_AGENTS_API_KEY 中,不写进代码、不分享给别人。费用以 Luma 官方价格页为准(本文不列价格)。Dream Machine 网页版的套餐、水印和商用权利以官网为准。

运行说明:box 上没有 Luma key。我用无效 key 真实调用了两个接口(2026-10-21 20:35 和 20:47,UTC+8):Agents API 返回 HTTP 401 {"detail":"Missing or invalid API key"}(key 不是 luma-api- 开头时)或 {"detail":"Invalid API key"};旧版 Dream Machine API 返回 HTTP 403 {"detail":"Not authenticated"}。其余响应来自我写的本地替身,字段、状态名和 failure_code 取自官方 API 参考。没有真正生成过视频。 本篇默认只生成 request.json,不调用接口。

做完之后你会得到什么?

成果 说明
storyboard.py 分镜表 → 请求 + 校验
storyboard.json 示例分镜表
frames/ 替身关键帧(本地 sd-turbo 生成)
test_storyboard.py 5 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install requests pytest
Luma key Luma 控制台创建,以 luma-api- 开头,export LUMA_AGENTS_API_KEY=…

关键事实

事实 来源
keyframe_indexes 位于 duration × 24fps 网格:5s → 0..120,10s → 0..240;与 keyframes 一一对应、不能重复;1–64 张 官方 API 参考(video.keyframes)
keyframes 与 start_frame / end_frame / loop 互斥;只支持 ray-3.2 同上
loop 不支持 10s 或 hdr;360p 草稿档在 type=video 时只支持 SDR 同上
图片引用可以是 url、base64 data(必须带 media_type)、generation_id 或 file_id 同上
Files API:先 POST /files 上传,状态 ready 后用 file_id 引用 官方 Files API
输入媒体超过限制返回 413(50 MB 字节上限或单边像素上限) 官方错误处理指南

文件结构

77-luma-dream-machine-B-src/
├── storyboard.py
├── storyboard.json
├── storyboard_bad.json
├── frames/
│   ├── start.png
│   └── end.png
└── test_storyboard.py

第 1 步:脚本

"""把分镜表(秒 + 图片)转成 Luma Agents API 的 ray-3.2 keyframes 请求,并按官方文档的规则先在本地校验。
默认只生成 request.json(不调用接口);加 --submit 才用 77A 的 luma_job.run 提交。"""
import argparse, base64, json, mimetypes, sys
from pathlib import Path

FPS = 24  # 官方:keyframe_indexes 位于 duration x 24fps 网格(5s → 0..120,10s → 0..240)
MAX_KF = 64

def ref_of(f, base_dir):
    if "file_id" in f: return {"file_id": f["file_id"]}  # 先用官方 Files API(POST /files)上传,状态 ready 后引用
    img = f["image"]
    return ref(img if img.startswith("http") else str(Path(base_dir) / img))

def warnings(sb, body):
    w = []
    local = [f["image"] for f in sb["frames"] if "image" in f and not f["image"].startswith("http")]
    dup = sorted({x for x in local if local.count(x) > 1})
    if dup: w.append(f"同一张本地图被内嵌多次:{', '.join(dup)}。用 Files API 上传一次,在分镜表里写 file_id")
    size = len(json.dumps(body))
    if size > 1 << 20: w.append(f"请求体 {size / 1024:.0f} KB:内嵌 base64 太大,建议改用 Files API 的 file_id")
    return w

def ref(img):
    """图片引用:http(s) 链接直接用 url;本地文件转成 base64 data(必须带 media_type)。"""
    if img.startswith(("http://", "https://")): return {"url": img}
    mt = mimetypes.guess_type(img)[0] or "image/png"
    return {"data": base64.b64encode(Path(img).read_bytes()).decode(), "media_type": mt}

def build(sb, base_dir="."):
    errs = []
    dur = sb.get("duration", "5s")
    if dur not in ("5s", "10s"): errs.append(f"duration 只能是 5s 或 10s:{dur}")
    last = (10 if dur == "10s" else 5) * FPS
    v = {"duration": dur}
    for k in ("resolution", "hdr", "loop"):
        if k in sb: v[k] = sb[k]
    if v.get("loop") and (dur == "10s" or v.get("hdr")): errs.append("loop 不支持 10s 或 hdr")
    if v.get("hdr") and v.get("resolution") == "360p": errs.append("360p 草稿档只支持 SDR,不能和 hdr 一起用")
    frames = sb.get("frames", [])
    if not 1 <= len(frames) <= MAX_KF: errs.append(f"keyframes 需要 1-{MAX_KF} 张,现在 {len(frames)} 张")
    idx, seen = [], {}
    for f in frames:
        i = round(float(f["t"]) * FPS)
        if not 0 <= i <= last: errs.append(f"{f['t']}s → 第 {i} 帧,超出 0..{last}")
        if i in seen: errs.append(f"{seen[i]}s 和 {f['t']}s 都落在第 {i} 帧(1 帧 = 1/24 秒),索引必须唯一")
        seen[i] = f["t"]; idx.append(i)
    if errs: return None, errs
    v["keyframes"] = [ref_of(f, base_dir) for f in frames]
    v["keyframe_indexes"] = idx
    body = {"prompt": sb["prompt"], "type": "video", "model": "ray-3.2", "aspect_ratio": sb.get("aspect", "16:9"), "video": v}
    if len(body["prompt"]) > 6000: return None, ["prompt 超过 6000 字符"]
    return body, []

def preview(body):
    """打印时把 base64 缩短,便于检查。"""
    b = json.loads(json.dumps(body))
    for k in b["video"]["keyframes"]:
        if "data" in k: k["data"] = k["data"][:16] + f"…({len(k['data'])} 字符)"
    return json.dumps(b, ensure_ascii=False, indent=2)

def main(argv=None):
    ap = argparse.ArgumentParser(); ap.add_argument("storyboard"); ap.add_argument("--submit", action="store_true")
    a = ap.parse_args(argv)
    sb = json.loads(Path(a.storyboard).read_text(encoding="utf-8"))
    body, errs = build(sb, Path(a.storyboard).parent)
    if errs:
        print("分镜表有问题,未生成请求:", *errs, sep="\n- "); return 1
    Path("request.json").write_text(json.dumps(body), encoding="utf-8")
    print(preview(body)); print(f"request.json:{len(json.dumps(body)) / 1024:.0f} KB")
    for w in warnings(sb, body): print("提醒:" + w)
    if a.submit:
        import os; sys.path.insert(0, str(Path(__file__).parent.parent / "77-luma-dream-machine-A-src"))
        from luma_job import run
        print(run(os.environ["LUMA_AGENTS_API_KEY"], body))
    return 0

if __name__ == "__main__":
    sys.exit(main())

第 2 步:分镜表

{"prompt": "a calm park path in autumn, slow dolly forward, cinematic, soft overcast light",
 "duration": "5s", "resolution": "720p", "aspect": "1:1",
 "frames": [{"t": 0, "image": "frames/start.png"}, {"t": 2.5, "image": "frames/start.png"}, {"t": 5, "image": "frames/end.png"}]}

第 3 步:测试

import json
import storyboard as m

def sb(**kw):
    d = {"prompt": "p", "duration": "5s", "frames": [{"t": 0, "image": "https://e.com/a.png"}, {"t": 5, "image": "https://e.com/b.png"}]}
    d.update(kw); return d

def test_indexes_24fps():
    body, errs = m.build(sb(duration="10s", frames=[{"t": 0, "image": "https://e.com/a.png"}, {"t": 10, "image": "https://e.com/b.png"}]))
    assert errs == [] and body["video"]["keyframe_indexes"] == [0, 240] and body["model"] == "ray-3.2"

def test_out_of_range_and_duplicate():
    _, errs = m.build(sb(frames=[{"t": 0, "image": "https://e/a"}, {"t": 1.0, "image": "https://e/b"}, {"t": 1.01, "image": "https://e/c"}, {"t": 6, "image": "https://e/d"}]))
    assert any("索引必须唯一" in e for e in errs) and any("超出 0..120" in e for e in errs)

def test_loop_and_hdr_rules():
    _, e1 = m.build(sb(duration="10s", loop=True, frames=[{"t": 0, "image": "https://e/a"}]))
    _, e2 = m.build(sb(hdr=True, resolution="360p"))
    assert any("loop" in e for e in e1) and any("360p" in e for e in e2)

def test_local_file_base64_and_file_id(tmp_path):
    (tmp_path / "a.png").write_bytes(b"\\x89PNGxx")
    body, errs = m.build(sb(frames=[{"t": 0, "image": "a.png"}, {"t": 5, "file_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e"}]), tmp_path)
    k = body["video"]["keyframes"]
    assert k[0]["media_type"] == "image/png" and "data" in k[0] and k[1] == {"file_id": "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e"}

def test_duplicate_local_warning(tmp_path):
    (tmp_path / "a.png").write_bytes(b"x")
    s = sb(frames=[{"t": 0, "image": "a.png"}, {"t": 5, "image": "a.png"}])
    body, _ = m.build(s, tmp_path)
    assert any("内嵌多次" in w for w in m.warnings(s, body))

第 4 步:运行

$ python storyboard.py storyboard.json
…(JSON 预览,base64 已缩短)
    "keyframe_indexes": [
      0,
      60,
      120
    ]
  }
}
request.json:1678 KB
提醒:同一张本地图被内嵌多次:frames/start.png。用 Files API 上传一次,在分镜表里写 file_id
提醒:请求体 1678 KB:内嵌 base64 太大,建议改用 Files API 的 file_id
$ python storyboard.py storyboard_bad.json     # 在 2.52s 多加了一帧
分镜表有问题,未生成请求:
- 2.5s 和 2.52s 都落在第 60 帧(1 帧 = 1/24 秒),索引必须唯一
$ python -m pytest -q
5 passed

怎么检查结果?

  • request.json 中 keyframes 和 keyframe_indexes 数量相同。
  • 帧号 = 秒 × 24 四舍五入;两个时间点间隔小于 1/24 秒(约 0.042 秒)会撞在同一帧。
  • 确认请求没问题后,--submit 会调用 77A 的 luma_job.run(需要两个 src 文件夹放在同一目录下)。

调试示例

同一张图内嵌了两次。 我想让镜头前一半停在起始画面,于是 0s 和 2.5s 都用了 frames/start.png。生成的请求:

        "data": "iVBORw0KGgoAAAAN…(551428 字符)",
request.json:1678 KB

每张 512×512 的 PNG 约 420 KB,base64 后约 550 KB,同一张图重复内嵌,请求体接近 1.7 MB。现在不会触发 413(上限 50 MB),但换成 1080p 素材、多放几张关键帧,很快就会变大。

修复:分镜表的帧可以写 "file_id": "…"(用官方 Files API 上传一次,状态 ready 后引用);同一张本地图出现多次、或请求体超过 1 MB 时给出提醒。

常见问题

现象 原因 处理
索引必须唯一 两个时间点太近 拉开至少 1/24 秒
超出 0..120 5s 视频写了 6s 改时间或改用 10s
loop 报错 loop 和 10s/hdr 一起用 去掉其中一个
413 内嵌图片太大 用 Files API 的 file_id

模板规则

  • 先本地校验,再提交。
  • 分镜表用秒写,让脚本换算帧号。
  • 重复使用的图用 file_id。
  • 只用有权使用的图片。

项目资源

用途 地址
Luma Agents API 参考(官方) https://docs.agents.lumalabs.ai/api/
创建生成任务(官方) https://docs.agents.lumalabs.ai/api/resources/generations/methods/create/
错误处理指南(官方) https://docs.agents.lumalabs.ai/guides/error-handling/
上传文件 Files API(官方) https://docs.agents.lumalabs.ai/api/resources/files/methods/create/

分镜先在本地对一遍,提交才不白花钱。