视频下载回来,先别急着剪:时长、帧率、分辨率、比例是不是你要的?要求循环的视频,首尾能不能接上? 这篇做
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 |
先验货,再剪辑。