生成的片段下载下来后,经常需要检查并拼接成一条完整的视频:画面比例对不对、时长对不对、结尾有没有黑屏、帧率和音轨是否一致。 这篇做
clip_qc.py:用 ffprobe 和 ffmpeg 检查每个片段,再统一分辨率、帧率和音轨后拼接。
先看清成品
- 4 个测试片段中,竖屏片段(540x960)和结尾 1.5 秒黑屏的片段被标出。
- 帧率不同(24/30)、有的有音轨有的没有的片段,统一后拼接为 960x540、24fps、带音轨的 20.52 秒视频。
- 实测直接用 concat 分离器
-c copy拼接:出现大量 “Non-monotonic DTS” 警告,第 2 段的音频丢失,竖屏片段被标成 960x540。pytest 3 项通过。
合规提醒:只上传你有权使用的图片和视频;不生成真实人物的误导性内容,不做未经同意的换脸;发布时按平台规则标注 AI 生成;商用前确认所用套餐的授权范围(以 PixVerse 官方条款为准)。本文只使用 PixVerse 官方网页/App 和官方 API。API key 只放在服务端,不放进前端代码或日志。本文不列价格,积分消耗以控制台为准。
运行说明:box 上没有 PixVerse 账号,测试片段是我用 ffmpeg 的 testsrc2 合成的画面(make_clips.sh),不是 PixVerse 的输出。所有命令和测试都在 box 上真实运行过(ffmpeg 7.1.5)。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
clip_qc.py |
检查并拼接 |
make_clips.sh |
生成 4 个测试片段 |
conftest.py |
测试前自动生成片段 |
test_qc.py |
3 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,pip install pytest |
| ffmpeg | 带 ffprobe,建议 6.0 以上 |
关键事实
| 事实 | 来源 |
|---|---|
| ffmpeg 的 concat 分离器要求各片段编码参数一致;参数不同时应使用 concat 滤镜 | FFmpeg Wiki: Concatenate |
blackdetect 滤镜检测黑屏片段,d 为最短时长,pix_th 为像素阈值 |
FFmpeg 滤镜文档 |
文件结构
72-pixverse-C-src/
├── clip_qc.py
├── make_clips.sh
├── conftest.py
└── test_qc.py
第 1 步:生成测试片段
#!/bin/sh
# 生成 4 段测试片段(ffmpeg 合成画面,代替 PixVerse 下载的视频;不是 PixVerse 的输出)
set -e; mkdir -p clips
ffmpeg -v error -y -f lavfi -i testsrc2=s=960x540:r=24:d=5 -c:v libx264 -pix_fmt yuv420p clips/shot1.mp4
ffmpeg -v error -y -f lavfi -i testsrc2=s=960x540:r=30:d=5 -f lavfi -i sine=f=440:d=5 -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest clips/shot2.mp4
ffmpeg -v error -y -f lavfi -i testsrc2=s=540x960:r=24:d=5 -c:v libx264 -pix_fmt yuv420p clips/shot3.mp4
ffmpeg -v error -y -f lavfi -i "testsrc2=s=960x540:r=24:d=4,format=yuv420p[a];color=black:s=960x540:r=24:d=1.5,format=yuv420p[b];[a][b]concat=n=2:v=1" -c:v libx264 clips/shot4.mp4
第 2 步:检查和拼接
"""检查下载的视频片段是否符合计划(分辨率方向、时长、帧率、黑屏),再统一规格拼接成一条。
需要 ffmpeg / ffprobe。片段来自你自己的 PixVerse 账号;不要拼接没有授权的素材。"""
import json, re, subprocess, sys
def probe(path):
j = json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", path], capture_output=True, text=True, check=True).stdout)
v = next(s for s in j["streams"] if s["codec_type"] == "video")
num, den = map(int, v["r_frame_rate"].split("/"))
return {"w": v["width"], "h": v["height"], "fps": round(num / den, 3), "dur": round(float(j["format"]["duration"]), 2),
"audio": any(s["codec_type"] == "audio" for s in j["streams"])}
def black_segments(path, min_d=0.5):
err = subprocess.run(["ffmpeg", "-v", "info", "-i", path, "-vf", f"blackdetect=d={min_d}:pix_th=0.10", "-an", "-f", "null", "-"], capture_output=True, text=True).stderr
return [(float(a), float(b)) for a, b in re.findall(r"black_start:([\d.]+) black_end:([\d.]+)", err)]
def qc(path, want_aspect="16:9", want_dur=None):
p = probe(path); issues = []
aw, ah = map(int, want_aspect.split(":"))
if abs(p["w"] / p["h"] - aw / ah) > 0.02: issues.append(f"画面比例 {p['w']}x{p['h']},计划是 {want_aspect}")
if want_dur and abs(p["dur"] - want_dur) > 0.5: issues.append(f"时长 {p['dur']} 秒,计划 {want_dur} 秒")
for a, b in black_segments(path):
issues.append(f"黑屏 {a:.1f}–{b:.1f} 秒")
return p, issues
def concat(paths, out, w=960, h=540, fps=24):
"""各片段帧率/分辨率/有无音轨不一致时,concat 分离器会出错或音画不同步,所以先统一规格再用 concat 滤镜。"""
args, parts = ["ffmpeg", "-v", "error", "-y"], []
for p in paths: args += ["-i", p]
for i, p in enumerate(paths):
parts.append(f"[{i}:v]scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps},format=yuv420p[v{i}]")
d = probe(p)["dur"]
parts.append(f"[{i}:a]aresample=48000,aformat=channel_layouts=stereo[a{i}]" if probe(p)["audio"] else f"anullsrc=r=48000:cl=stereo,atrim=0:{d}[a{i}]")
parts.append("".join(f"[v{i}][a{i}]" for i in range(len(paths))) + f"concat=n={len(paths)}:v=1:a=1[v][a]")
subprocess.run(args + ["-filter_complex", ";".join(parts), "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-c:a", "aac", out], check=True)
return probe(out)
def main(argv=None):
argv = argv or sys.argv[1:]; out = "final.mp4"; paths = [a for a in argv if a.endswith(".mp4")]
bad = 0
for p in paths:
info, issues = qc(p, want_dur=5); bad += bool(issues)
print(f"{p}: {info['w']}x{info['h']} {info['fps']}fps {info['dur']}s 音轨={'有' if info['audio'] else '无'}", "通过" if not issues else "")
for i in issues: print(" -", i)
if "--concat" in argv:
r = concat(paths, out); print(f"拼接 → {out}: {r['w']}x{r['h']} {r['fps']}fps {r['dur']}s")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
第 3 步:测试
import os, subprocess, pytest
@pytest.fixture(scope="session", autouse=True)
def clips():
if not os.path.exists("clips/shot4.mp4"): subprocess.run(["sh", "make_clips.sh"], check=True)
# test_qc.py
import clip_qc as Q
def test_probe():
assert Q.probe("clips/shot2.mp4") == {"w": 960, "h": 540, "fps": 30.0, "dur": 5.0, "audio": True}
def test_issues():
assert Q.qc("clips/shot1.mp4", want_dur=5)[1] == []
assert Q.qc("clips/shot3.mp4", want_dur=5)[1] == ["画面比例 540x960,计划是 16:9"]
assert Q.qc("clips/shot4.mp4", want_dur=5)[1] == ["黑屏 4.0–5.5 秒"]
assert Q.qc("clips/shot3.mp4", want_aspect="9:16")[1] == []
def test_concat_mixed_inputs(tmp_path):
r = Q.concat([f"clips/shot{i}.mp4" for i in (1, 2, 3)], str(tmp_path / "out.mp4"))
assert (r["w"], r["h"], r["fps"], r["audio"]) == (960, 540, 24.0, True) and abs(r["dur"] - 15) < 0.1
第 4 步:运行
$ sh make_clips.sh
$ python clip_qc.py clips/shot*.mp4 --concat
clips/shot1.mp4: 960x540 24.0fps 5.0s 音轨=无 通过
clips/shot2.mp4: 960x540 30.0fps 5.0s 音轨=有 通过
clips/shot3.mp4: 540x960 24.0fps 5.0s 音轨=无
- 画面比例 540x960,计划是 16:9
clips/shot4.mp4: 960x540 24.0fps 5.5s 音轨=无
- 黑屏 4.0–5.5 秒
拼接 → final.mp4: 960x540 24.0fps 20.52s
$ python -m pytest -q
3 passed
怎么检查结果?
- 画面比例与计划不符的片段,重新生成,不要强行拉伸。
- 黑屏片段剪掉或重新生成。
- 拼接结果的时长应等于各片段之和(本例 5 + 5 + 5 + 5.5 = 20.5 秒)。
- 竖屏片段在横屏成片中会加黑边(pad),这是有意的,不会变形。
调试示例
直接 -c copy 拼接的结果。 我先按很多教程的做法,用 concat 分离器不重新编码地拼接这 4 个片段:
$ ffmpeg -f concat -safe 0 -i list.txt -c copy naive.mp4
[vost#0:0/copy] Non-monotonic DTS; previous: 152120, current: 150528; changing to 152121. This may result in incorrect timestamps in the output file.
...
$ ffprobe -show_entries stream=codec_type,width,height,r_frame_rate naive.mp4
stream|codec_type=video|width=960|height=540|r_frame_rate=24/1
命令没有报错,但:①时间戳错乱;②输出只有视频流,第 2 段的音频没了(因为第 1 段没有音轨);③文件头写着 960x540,第 3 段其实是 540x960 的竖屏画面,很多播放器会花屏或变形。
修复:每段先缩放并加边到 960x540、统一 24fps,没有音轨的段补一段等长的静音,再用 concat 滤镜重新编码。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 拼接后音画不同步 | 某段没有音轨 | 脚本会补静音;检查补的长度 |
| 黑屏检测太敏感 | 画面本身很暗 | 提高 pix_th 或 d |
| 拼接很慢 | 需要重新编码 | 这是统一规格的代价;片段多时用 -preset veryfast |
模板规则
- 先检查,再拼接。
- 规格不同的片段不要用 -c copy 拼接。
- 只拼接你有权使用的素材。
项目资源
| 用途 | 地址 |
|---|---|
| PixVerse 官网 | https://pixverse.ai/ |
| PixVerse API:Start Here(官方) | https://docs.platform.pixverse.ai/start-here-2394989m0 |
| PixVerse API:错误码(官方) | https://docs.platform.pixverse.ai/error-codes-2394992m0 |
| FFmpeg Wiki:Concatenate | https://trac.ffmpeg.org/wiki/Concatenate |
| FFmpeg 滤镜文档:blackdetect | https://ffmpeg.org/ffmpeg-filters.html#blackdetect |
生成交给 PixVerse,把关交给 ffprobe。