AI 工具

PixVerse 视频片段质检与拼接:用 ffprobe 和 FFmpeg 统一规格

生成的片段下载下来后,经常需要检查并拼接成一条完整的视频:画面比例对不对、时长对不对、结尾有没有黑屏、帧率和音轨是否一致。 这篇做 clipqc.py:用 ffprobe 和 ffmpeg 检查每个片段,再统一分辨率、帧率和音轨后拼接。

BINBHO · 图文教程

生成的片段下载下来后,经常需要检查并拼接成一条完整的视频:画面比例对不对、时长对不对、结尾有没有黑屏、帧率和音轨是否一致。 这篇做 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。