AI 工具

用 FFmpeg 检查 Luma 视频:核对规格与循环首尾帧

视频下载回来,先别急着剪:时长、帧率、分辨率、比例是不是你要的?要求循环的视频,首尾能不能接上? 这篇做 clipqc.py:用 ffprobe 读取参数,按请求核对;对 loop 视频用 ffmpeg 计算首帧和末帧的 PSNR,太低就提醒循环处可能跳变。

BINBHO · 图文教程

视频下载回来,先别急着剪:时长、帧率、分辨率、比例是不是你要的?要求循环的视频,首尾能不能接上? 这篇做 clip_qc.py:用 ffprobe 读取参数,按请求核对;对 loop 视频用 ffmpeg 计算首帧和末帧的 PSNR,太低就提醒循环处可能跳变。

先看清成品

  • 用 ffmpeg 生成 5 段测试视频,帧率错(30fps)、分辨率错(540p)都被检出。
  • 发现并修复真实问题:首尾帧 PSNR 一直是 None——psnr 滤镜按时间戳配对,首帧和末帧永远配不上。
  • 修复后:静态循环素材 75.3 dB 通过,测试图案 22.7 dB 被标出。pytest 3 项通过。

合规提醒:只上传你有权使用的图片作为关键帧或参考图;不生成真实人物的误导性内容。本文只使用 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 参考。没有真正生成过视频。 本篇的视频都是 ffmpeg 生成的测试片段,不是 Luma 的输出。

做完之后你会得到什么?

成果 说明
clip_qc.py 参数核对 + 循环检查
clips/ 测试视频(命令见运行部分)
test_qc.py 3 项测试(需要 ffmpeg)

需要准备哪些环境?

项目 说明
ffmpeg 本文为 7.1.5(Debian);ffmpeg -version 确认
Python 3.9 以上,pytest

关键事实

事实 来源
Ray 3.2 视频按 24fps 输出:5s → 0..120 帧网格,10s → 0..240 官方 API 参考
分辨率档位 360p / 540p / 720p / 1080p 同上
psnr 滤镜比较两路视频的对应帧 ffmpeg 文档

文件结构

77-luma-dream-machine-C-src/
├── clip_qc.py
└── test_qc.py

第 1 步:脚本

"""检查下载回来的视频是否符合请求:时长(5s/10s)、帧率、分辨率档位、画面比例;loop 视频再检查首尾帧是否接得上。
依赖 ffmpeg / ffprobe。"""
import argparse, json, re, subprocess, sys
from fractions import Fraction

TIERS = {"360p": 360, "540p": 540, "720p": 720, "1080p": 1080}

def probe(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                          "stream=width,height,avg_frame_rate,nb_frames:format=duration", "-of", "json", path],
                         capture_output=True, text=True, check=True).stdout
    d = json.loads(out); s = d["streams"][0]
    return {"w": s["width"], "h": s["height"], "fps": float(Fraction(s["avg_frame_rate"])),
            "frames": int(s.get("nb_frames", 0) or 0), "dur": float(d["format"]["duration"])}

def seam_psnr(path, frames):
    """首帧和末帧的 PSNR(dB);越高越接近,inf 表示完全相同。
    psnr 滤镜按时间戳配对两路画面:首帧 pts=0、末帧 pts≈4.96s 永远配不上,所以两路都 setpts=0。"""
    fc = f"[0:v]select=eq(n\\,0),setpts=0[a];[1:v]select=eq(n\\,{frames - 1}),setpts=0[b];[a][b]psnr"
    err = subprocess.run(["ffmpeg", "-hide_banner", "-i", path, "-i", path, "-filter_complex", fc, "-f", "null", "-"],
                         capture_output=True, text=True).stderr  # PSNR 汇总是 info 级日志:不能加 -v error
    m = re.search(r"average:(inf|[\d.]+)", err)
    if not m: return None
    return float("inf") if m.group(1) == "inf" else float(m.group(1))

def check(path, *, duration="5s", resolution=None, aspect=None, loop=False, seam_min=30.0):
    p = probe(path); probs = []
    want = 10.0 if duration == "10s" else 5.0
    if abs(p["dur"] - want) > 0.25: probs.append(f"时长 {p['dur']:.2f}s,请求 {duration}")
    if abs(p["fps"] - 24) > 0.05: probs.append(f"帧率 {p['fps']:.2f},预期 24")
    if resolution and min(p["w"], p["h"]) != TIERS[resolution]:
        probs.append(f"分辨率 {p['w']}x{p['h']},短边应为 {TIERS[resolution]}({resolution})")
    if aspect:
        a, b = map(int, aspect.split(":"))
        if abs(p["w"] / p["h"] - a / b) > 0.02: probs.append(f"比例 {p['w']}:{p['h']},请求 {aspect}")
    seam = None
    if loop:
        seam = seam_psnr(path, p["frames"])
        if seam is None or seam < seam_min: probs.append(f"首尾帧 PSNR {seam if seam is None else round(seam, 1)} dB < {seam_min}:循环处可能跳变")
    return p, seam, probs

def main(argv=None):
    ap = argparse.ArgumentParser(); ap.add_argument("files", nargs="+")
    ap.add_argument("--duration", default="5s"); ap.add_argument("--resolution"); ap.add_argument("--aspect"); ap.add_argument("--loop", action="store_true")
    a = ap.parse_args(argv); bad = 0
    for f in a.files:
        p, seam, probs = check(f, duration=a.duration, resolution=a.resolution, aspect=a.aspect, loop=a.loop)
        info = f"{p['w']}x{p['h']} {p['fps']:.2f}fps {p['dur']:.2f}s {p['frames']}帧" + (f" 首尾PSNR {seam:.1f}dB" if seam not in (None, float('inf')) else (" 首尾相同" if seam else ""))
        print(f"{'OK ' if not probs else '!! '} {f}: {info}" + "".join(f"\n     - {x}" for x in probs)); bad += bool(probs)
    return 1 if bad else 0

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

第 2 步:测试

import subprocess, pytest
import clip_qc as m

def mk(path, src, rate=24, dur=5, size="1280x720"):
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", f"{src}{':' if '=' in src else '='}size={size}:rate={rate}:duration={dur}", "-pix_fmt", "yuv420p", str(path)], check=True)
    return str(path)

def test_ok(tmp_path):
    _, _, probs = m.check(mk(tmp_path / "a.mp4", "testsrc2"), resolution="720p", aspect="16:9")
    assert probs == []

def test_duration_and_aspect(tmp_path):
    f = mk(tmp_path / "b.mp4", "testsrc2", dur=4, size="720x720")
    _, _, probs = m.check(f, resolution="720p", aspect="16:9")
    assert any("时长" in p for p in probs) and any("比例" in p for p in probs)

def test_loop_seam(tmp_path):
    good = mk(tmp_path / "g.mp4", "color=c=teal")
    bad = mk(tmp_path / "x.mp4", "testsrc2")
    assert m.check(good, loop=True)[2] == []
    _, seam, probs = m.check(bad, loop=True)
    assert seam is not None and seam < 30 and probs

第 3 步:生成测试视频

F="ffmpeg -v error -y"
$F -f lavfi -i testsrc2=size=1280x720:rate=24:duration=5 -pix_fmt yuv420p clips/ok_720p.mp4
$F -f lavfi -i testsrc2=size=1280x720:rate=30:duration=5 -pix_fmt yuv420p clips/wrong_fps.mp4
$F -f lavfi -i testsrc2=size=960x540:rate=24:duration=5 -pix_fmt yuv420p clips/is_540p.mp4
$F -f lavfi -i "color=c=teal:size=1280x720:rate=24:duration=5,noise=alls=3:allf=t" -pix_fmt yuv420p clips/loop_good.mp4
$F -f lavfi -i testsrc2=size=1280x720:rate=24:duration=5 -pix_fmt yuv420p clips/loop_bad.mp4

第 4 步:运行

$ python clip_qc.py clips/ok_720p.mp4 clips/wrong_fps.mp4 clips/is_540p.mp4 --resolution 720p --aspect 16:9
OK  clips/ok_720p.mp4: 1280x720 24.00fps 5.00s 120帧
!!  clips/wrong_fps.mp4: 1280x720 30.00fps 5.00s 150帧
     - 帧率 30.00,预期 24
!!  clips/is_540p.mp4: 960x540 24.00fps 5.00s 120帧
     - 分辨率 960x540,短边应为 720(720p)
$ python clip_qc.py clips/loop_good.mp4 clips/loop_bad.mp4 --loop --resolution 720p
OK  clips/loop_good.mp4: 1280x720 24.00fps 5.00s 120帧 首尾PSNR 75.3dB
!!  clips/loop_bad.mp4: 1280x720 24.00fps 5.00s 120帧 首尾PSNR 22.7dB
     - 首尾帧 PSNR 22.7 dB < 30.0:循环处可能跳变
$ python -m pytest -q
3 passed

怎么检查结果?

  • PSNR 30 dB 只是经验阈值:真实画面的循环视频首尾不会完全相同,先拿几段你满意的循环视频看看它们的数值再定阈值。
  • 时长允许 0.25 秒误差(编码会有少量偏差)。
  • 分辨率按短边判断:16:9 的 720p 是 1280×720,9:16 是 720×1280。

调试示例

PSNR 一直是 None。 第一版的循环检查:

!!  clips/loop_good.mp4: 1280x720 24.00fps 5.00s 120帧
     - 首尾帧 PSNR None dB < 30.0:循环处可能跳变
!!  clips/loop_bad.mp4: 1280x720 24.00fps 5.00s 120帧
     - 首尾帧 PSNR None dB < 30.0:循环处可能跳变

两个原因叠在一起:(1) 我加了 -v error,而 PSNR 汇总是 info 级日志,被屏蔽了;(2) 去掉 -v error 后仍没有结果——psnr 滤镜按时间戳配对两路画面,首帧 pts=0、末帧 pts≈4.96 秒,永远配不上。手动验证(我另外试过,-frames:v 1 不影响结果):

$ ffmpeg -i loop_bad.mp4 -i loop_bad.mp4 -filter_complex "[0:v]select=eq(n\,0),setpts=0[a];[1:v]select=eq(n\,119),setpts=0[b];[a][b]psnr" -f null -
PSNR y:24.382313 u:20.858797 v:20.177696 average:22.677768 min:22.677768 max:22.677768

修复:两路都 setpts=0,并去掉 -v error(改用 -hide_banner)。

常见问题

现象 原因 处理
ffprobe 找不到 没装 ffmpeg 安装 ffmpeg
帧率 23.98 素材是 24000/1001 核对请求;Ray 3.2 应为 24
PSNR 偏低但看起来没问题 画面有细微噪点 先用满意的样本定阈值

模板规则

  • 下载后先核对参数再剪辑。
  • 循环视频检查首尾帧。
  • 阈值用自己的样本校准。
  • 保留原始文件,不覆盖。

项目资源

用途 地址
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/
ffmpeg 文档 https://ffmpeg.org/documentation.html

先验货,再剪辑。