录屏片段无损合并:从 ffmpeg concat 原理到 Python 自动化脚本

录屏片段无损合并:从 ffmpeg concat 原理到 Python 自动化脚本

一次真实的漏洞复现录屏整理记录:两段 QuickTime 录屏(97.47s + 37.91s)合并为一条 135.36s 的完整复现视频,全程不重编码,画质与音轨零损失。

附可复用的 Python 脚本 merge_recordings.py(含参数体检、无损拼接、帧数守恒复核)。


一、场景:为什么需要"合并"这一步

漏洞复现(尤其是报送 CNVD / CNNVD 这类平台)通常要求提供一条完整、连续、时间顺序正确的过程视频。而实际录制时几乎不可能一次录完:

  • 中途需要切换环境、重启服务、等待页面响应,录屏会随手停下、重新开始;
  • 单个片段过长会造成文件臃肿,也不利于后续定位;
  • 一旦某一步录错,只重录那一段即可,不必从头再来。

本次的两段录屏来自 macOS 自带的 QuickTime 录屏(系统录屏命名为 录屏YYYY-MM-DD HH.MM.SS.mov,文件名天然带时间戳):

片段 时长 大小 内容
录屏2026-09-11 22.34.21.mov 97.47 s 33.0 MB 前半程:登录、漏洞触发路径
录屏2026-09-11 23.03.50.mov 37.91 s 22.7 MB 后半程:错误回显、证据固定

诉求很明确:按录制时间顺序拼成一条视频,且不要因为"处理一下"而损失画质------报送材料里的证据视频被压缩一次,关键字符的清晰度就可能不可读。


二、方案选型:为什么不用剪辑软件

方案 是否重编码 耗时(本案例) 画质损失 可复现性
剪辑软件(剪映 / iMovie / PR)导出 数十秒~数分钟 有(二次编码) 手动,难以脚本化
ffmpeg concat + -c copy 毫秒级 一条命令 / 一个脚本
重编码拼接(filter_complex concat 与视频时长成正比 可脚本化,但没必要

结论 :片段来自同一台设备、同一编码参数时,直接选容器层无损拼接。它不碰像素、不碰采样点,只把封装好的音视频包(packet)按顺序重排到新容器里,因此不存在"画质下降"这个概念。


三、无损拼接的原理与三个前提

3.1 原理

ffmpegconcat demuxer 做的事非常朴素:

  1. 读取清单文件里列出的每个文件;
  2. 依次读取每个文件的音视频包(packet),原样写入输出容器;
  3. 第二段开始的时间戳,按前一段的 duration 做整体偏移,使多段在时间轴上首尾相接。

因为触发条件是 -c copy(stream copy),解码器、编码器全程不参与------这也是它快到"毫秒级"的原因:58 MB 的文件,实质只是重新封装。

3.2 三个前提(缺一不可)

前提 说明 违反的后果
视频编码参数一致 codec、profile、分辨率、像素格式(yuv420p)一致 播放器中途黑屏 / 花屏
时间基(time_base)一致 各段的 tbn 要相同 时间戳错乱,音画不同步
音频编码参数一致 codec、采样率、声道数一致 后段无声或爆音

判定顺序里最容易漏掉的是 time_base 。分辨率、编码都相同,但一个 1/12800、一个 1/15360-c copy 拼出来照样会出现时长异常。

3.3 先体检再动手

合并前先用 ffprobe 把两段的参数拉出来对照,是避免"合并成功但结果不可用"的关键一步:

bash 复制代码
ffprobe -v error -select_streams v:0 \
  -show_entries stream=codec_name,profile,width,height,pix_fmt,time_base,avg_frame_rate \
  -of default=noprint_wrappers=1 "录屏2026-09-11 22.34.21.mov"

本次两段录屏的实际参数(完全一致,因此可以直接无损拼接):

复制代码
h264 / Main@L5.2 / 2920x1820 / yuv420p / tb=1/12800 / AAC 48kHz stereo

四、实测:一次真实的合并

4.1 执行命令

写一份 concat_list.txt(每行一个文件,路径用单引号包裹):

复制代码
file '/Users/liuxiaowei/Desktop/录屏2026-09-11 22.34.21.mov'
file '/Users/liuxiaowei/Desktop/录屏2026-09-11 23.03.50.mov'

然后:

bash 复制代码
ffmpeg -f concat -safe 0 -i concat_list.txt \
       -c copy -movflags +faststart \
       "录屏合并2026-09-11-勤云漏洞复现完整视频.mov"

三个参数值得单独解释:

  • -f concat:启用 concat demuxer,按清单顺序拼接;
  • -safe 0:允许清单中出现绝对路径 (默认 -safe 1 只允许相对路径,写绝对路径会直接报错退出);
  • -movflags +faststart:把 moov(索引)原子移到文件头部。上传网盘、网页播放时才能"边下边播",否则要等整段下载完才出画面。

4.2 结果与守恒校验

合并结果不是"看起来没问题"就算完,而要对账------把输出与源文件的帧数、时长做守恒校验:

项目 片段 ① 片段 ② 合并输出 校验
时长 97.47 s 37.91 s 135.36 s 97.47 + 37.91 = 135.38 ≈ 135.36 ✔
视频帧 3473 1240 4713 3473 + 1240 = 4713 ✔
音频帧 4569 1771 6344 4569 + 1771 = 6344 ✔
大小 33.0 MB 22.7 MB 58,436,558 B(55.73 MiB) 与源合计基本一致(仅封装开销)✔

输出文件的 SHA1:1c965cbad80ffdd63196d99b1f81f17de9bbda5b

源文件全程只读,未做任何改动------这是无损方案的另一层价值:任何时候都可以推翻重做。


五、把它写成脚本:merge_recordings.py

命令行能搞定的事,为什么还要写脚本?因为真实使用中反复出问题的是边缘情况:路径带空格和中文、混进一段异分辨率的素材、AAC 拼接后音频帧少了两帧到底正不正常、判断标准到底看哪些字段......脚本把这些判断固化成流程:

复制代码
ffprobe 探测 → 参数一致性判定 → 生成 concat 清单 → -c copy 拼接 → 帧数/时长守恒复核

5.1 探测:把容器信息结构化

python 复制代码
def probe(path: str) -> dict:
    cmd = [FFPROBE, "-v", "error", "-print_format", "json",
           "-show_format", "-show_streams", path]
    data = json.loads(subprocess.run(cmd, capture_output=True, text=True).stdout)
    fmt = data.get("format", {})
    info = {"path": path,
            "size": int(fmt.get("size", 0)),
            "duration": float(fmt.get("duration", 0.0)),
            "video": None, "audio": None}
    for s in data.get("streams", []):
        if s.get("codec_type") == "video" and info["video"] is None:
            info["video"] = {
                "codec": s.get("codec_name", ""),
                "profile": s.get("profile", ""),
                "width": s.get("width", 0), "height": s.get("height", 0),
                "pix_fmt": s.get("pix_fmt", ""),
                "time_base": s.get("time_base", ""),
                "frames": int(s.get("nb_frames") or 0),
                "fps": s.get("avg_frame_rate", "0/0"),
            }
        elif s.get("codec_type") == "audio" and info["audio"] is None:
            info["audio"] = {
                "codec": s.get("codec_name", ""),
                "sample_rate": s.get("sample_rate", ""),
                "channels": s.get("channels", 0),
                "time_base": s.get("time_base", ""),
                "frames": int(s.get("nb_frames") or 0),
            }
    return info

5.2 一致性判定:决定"能不能无损"

python 复制代码
# level(规格等级)不参与判定:-c copy 不重编码,等级差异不影响拷贝拼接
VIDEO_KEYS = ("codec", "profile", "width", "height", "pix_fmt", "time_base")
AUDIO_KEYS = ("codec", "sample_rate", "channels", "time_base")

def compatibility(infos: list) -> list:
    """返回不一致项列表,空列表表示可无损拼接。"""
    problems, ref = [], infos[0]
    for info in infos[1:]:
        for key in VIDEO_KEYS:
            if ref["video"][key] != info["video"][key]:
                problems.append(f"视频 {key}: {ref['path']}={ref['video'][key]} "
                                f"vs {info['path']}={info['video'][key]}")
        if (ref["audio"] is None) != (info["audio"] is None):
            problems.append(f"音轨缺失不一致:{ref['path']} / {info['path']}")
        elif ref["audio"] and info["audio"]:
            for key in AUDIO_KEYS:
                if ref["audio"][key] != info["audio"][key]:
                    problems.append(f"音频 {key}: ...")
    return problems

一旦检出不一致,脚本默认拒绝合并 (而不是"拼了再说"),要么退出让人处理,要么显式加 --forcefilter_complex concat 重编码通道:宁可损失一次编码,也不交付一条时间轴错乱的证据视频。

5.3 生成清单:中文、空格、单引号的转义

concat demuxer 的清单语法是 file '<路径>',路径本身用单引号包裹,因此路径里的单引号必须转义 ------这是踩过一次的坑,标准写法是 '\''

python 复制代码
def write_concat_list(paths: list, list_file: str) -> str:
    with open(list_file, "w", encoding="utf-8") as f:
        for p in paths:
            safe = os.path.abspath(p).replace("'", "'\\''")   # ' -> '\''
            f.write(f"file '{safe}'\n")
    return list_file

QuickTime 的录屏文件名形如 录屏2026-09-11 22.34.21.mov既有中文又有空格,转义逻辑不能省。

5.4 拼接与复核

python 复制代码
def concat_copy(paths: list, output: str) -> None:
    workdir = tempfile.mkdtemp(prefix="merge_recordings_")
    list_file = write_concat_list(paths, os.path.join(workdir, "concat_list.txt"))
    cmd = [FFMPEG, "-hide_banner", "-loglevel", "warning", "-nostdin", "-y",
           "-f", "concat", "-safe", "0", "-i", list_file, "-c", "copy"]
    if output.lower().endswith((".mp4", ".mov", ".m4v")):
        cmd += ["-movflags", "+faststart"]
    cmd += [output]
    subprocess.run(cmd, check=True)

复核环节不能用 nb_frames 字段(部分容器根本不写),要用解码器真实计数

bash 复制代码
ffprobe -v error -select_streams v -count_frames \
  -show_entries stream=nb_read_frames -print_format json out.mov

六、跑起来是什么样

用两段测试素材(1280×800 / 25fps / 3s + 2s,模拟同参录屏)实跑脚本:

console 复制代码
$ python3 merge_recordings.py -o merged_demo.mov "录屏2026-09-11 22.34.21.mov" "录屏2026-09-11 23.03.50.mov"

== 片段参数 ==
  录屏2026-09-11 22.34.21.mov: h264 Main L5.2 1280x800 yuv420p tb=1/12800 | aac 48000Hz stereo
  录屏2026-09-11 23.03.50.mov: h264 Main L5.2 1280x800 yuv420p tb=1/12800 | aac 48000Hz stereo

[+] 参数完全一致,走无损拼接(-c copy)
[*] ffmpeg -hide_banner -loglevel warning -nostdin -y -f concat -safe 0 \
    -i /tmp/merge_recordings_fcp46ksk/concat_list.txt -c copy -movflags +faststart merged_demo.mov

序号  文件                                      时长(s)      视频帧          大小
1   录屏2026-09-11 22.34.21.mov                    3.00          75     94.62 KiB
2   录屏2026-09-11 23.03.50.mov                    2.00          50     66.13 KiB

[+] 输出:merged_demo.mov
    时长 5.02s(源合计 5.00s,差 0.02s)
    视频帧 125(源合计 125,一致 ✔)
    音频帧 237(源合计 235,偏差 2 帧(AAC priming/padding,属正常))
    大小 159.43 KiB

若混入一段异分辨率素材,脚本在动手之前就会拦下来:

console 复制代码
[!] 检测到参数不一致,无法无损拼接:
    - 视频 profile: ...22.34.21.mov=Main vs tmp_other.mov=High
    - 视频 width: ...22.34.21.mov=1280 vs tmp_other.mov=640
    - 视频 height: ...22.34.21.mov=800 vs tmp_other.mov=480
    - 视频 time_base: ...22.34.21.mov=1/12800 vs tmp_other.mov=1/15360
    - 音轨缺失不一致:...22.34.21.mov / tmp_other.mov
    可加 --force 触发重编码拼接(画质有一次编码损失)

用法速查:

bash 复制代码
python3 merge_recordings.py -o out.mov a.mov b.mov                  # 按命令行顺序
python3 merge_recordings.py -o out.mov --sort *.mov                 # 按文件名(时间)排序
python3 merge_recordings.py -o out.mov --check a.mov b.mov          # 只体检,不合并
python3 merge_recordings.py -o out.mp4 --force a.mov b.mov          # 参数不一致时重编码

七、踩坑清单

# 现象 / 坑 原因 处理
1 Immediate exit requested / 找不到文件 清单里写了绝对路径,但默认 -safe 1 -safe 0
2 路径含单引号时拼接失败 清单语法用单引号包裹路径 路径内 ' 转义为 '\''
3 合并"成功"但后段黑屏/无声 分辨率、像素格式或 time_base 不一致 合并前用 ffprobe 逐项比对,不一致就别硬拼
4 两段 avg_frame_rate 显示不同就以为不能拼 QuickTime 录屏是可变帧率(VFR),平均值天然有波动 不要拿 avg_frame_rate 当唯一判据,看 codec / 分辨率 / time_base
5 音频帧总数比源合计多 2 帧 AAC 每段自带 priming/padding 帧 属正常,偏差 ≤2% 无需处理
6 输出前出现 Non-monotonic DTS ... changing to ... 拼接处音频时间戳有微小非单调,ffmpeg 自动修正 可忽略;若担心,改用重编码通道
7 上传网盘后网页端迟迟不出画面 moov 原子在文件尾部 -movflags +faststart
8 GUI 启动的脚本里 ffmpeg: command not found 非交互 shell 的 PATH 不含 /opt/homebrew/bin 脚本内主动探测 shutil.which + 常见安装目录兜底
9 通配符把上一次的输出也卷进输入 *.mov 覆盖了结果文件 脚本里显式排除输出文件路径
10 合并后想"清理一下"源文件 ------ 不要删源:纯只读操作,保留源才有重做的余地

八、衔接报送流程:视频之外还要交代什么

无损合并只是材料准备的一环。视频体积通常远超报送表单的附件上限(本例 58 MB,CNVD 表单不收这么大的视频),实际做法是网盘外链 + 元信息说明,并把关键信息写进报送文档的"复现视频提交说明"小节:

项目 内容
视频文件名 录屏合并2026-09-11-勤云漏洞复现完整视频.mov
大小 58,436,558 字节(55.73 MiB)
时长 135.36 s(00:02:15)
分段结构 00:00--01:37 为 22.34.21 段;01:37--02:15 为 23.03.50
SHA1 1c965cbad80ffdd63196d99b1f81f17de9bbda5b
获取方式 夸克网盘外链 + 提取码(另可通过 vreport@cert.org.cn 补件)

为什么值得写"分段结构" :评审人员可以据此把视频里的操作步骤与文档章节一一对应,比一句"视频见链接"可信得多。为什么给 SHA1:证明提交后文件未被改动,是证据链的常规要求。


九、小结

  • 同一设备连续录制的分段素材,优先用 concat + -c copy 无损拼接:毫秒级完成、零画质损失、源文件不动;
  • 无损的前提是编码参数与时间基一致 ,动手前先用 ffprobe 体检,别拿 avg_frame_rate 当判据;
  • 拼接后做帧数 / 时长守恒校验,让"合并成功"从感觉变成数据;
  • 把判断逻辑固化成脚本,比每次手敲命令更可靠------边缘情况(中文空格路径、异源素材、输出自卷入)一定会出现。

本次用到的完整脚本已随本文一并落盘:merge_recordings.py(Python 3,仅依赖 ffmpeg/ffprobe,macOS / Linux / Windows 通用)。


环境:macOS (Apple Silicon) + ffmpeg

源码:

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
merge_recordings.py ------ 录屏片段无损合并工具(macOS / Linux / Windows 通用)

原理:调用 ffmpeg 的 concat demuxer + `-c copy`,在容器层把多段录屏首尾串联,
      视频与音频流均不重新编码,画质、音轨、时间戳零损失。

用法:
    # 1) 按命令行给定顺序合并(默认无损)
    python3 merge_recordings.py -o 合并结果.mov 片段A.mov 片段B.mov

    # 2) 让脚本按文件名(含时间)自动排序,等价于"按时间顺序拼接"
    python3 merge_recordings.py -o 合并结果.mov --sort 片段1.mov 片段2.mov 片段3.mov

    # 3) 各片段编码参数不一致时(无法无损拼接),强制重编码输出
    python3 merge_recordings.py -o 合并结果.mp4 --force 片段A.mov 片段B.mov

    # 4) 只做参数体检,不合并
    python3 merge_recordings.py -o /dev/null --check 片段A.mov 片段B.mov

依赖:ffmpeg / ffprobe(macOS: brew install ffmpeg)
作者:White_hat@9808    许可:MIT
"""

import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile

# ---------- 环境定位:兼容 GUI 应用启动的非交互 shell(PATH 里没有 /opt/homebrew/bin) ----------
EXTRA_BIN_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]


def find_binary(name: str) -> str:
    """定位 ffmpeg / ffprobe,找不到直接退出并给出安装提示。"""
    path = shutil.which(name)
    if path:
        return path
    for d in EXTRA_BIN_DIRS:
        cand = os.path.join(d, name)
        if os.path.isfile(cand) and os.access(cand, os.X_OK):
            return cand
    sys.exit(f"[!] 未找到 {name},请先安装:macOS 执行 `brew install ffmpeg`")


FFMPEG = find_binary("ffmpeg")
FFPROBE = find_binary("ffprobe")


# ---------- 探测:把每个片段的容器信息、视频流、音频流结构化成 dict ----------
def probe(path: str) -> dict:
    cmd = [FFPROBE, "-v", "error", "-print_format", "json",
           "-show_format", "-show_streams", path]
    out = subprocess.run(cmd, capture_output=True, text=True)
    if out.returncode != 0:
        sys.exit(f"[!] ffprobe 读取失败:{path}\n{out.stderr}")

    data = json.loads(out.stdout)
    fmt = data.get("format", {})
    info = {
        "path": path,
        "size": int(fmt.get("size", 0)),
        "duration": float(fmt.get("duration", 0.0)),
        "format_name": fmt.get("format_name", ""),
        "video": None,
        "audio": None,
    }
    for s in data.get("streams", []):
        if s.get("codec_type") == "video" and info["video"] is None:
            info["video"] = {
                "codec": s.get("codec_name", ""),
                "profile": s.get("profile", ""),
                "level": s.get("level", ""),
                "width": s.get("width", 0),
                "height": s.get("height", 0),
                "pix_fmt": s.get("pix_fmt", ""),
                "time_base": s.get("time_base", ""),
                "tag": s.get("codec_tag_string", ""),
                "frames": int(s.get("nb_frames") or 0),   # 部分容器不写 nb_frames,需靠解码复核
                "fps": s.get("avg_frame_rate", "0/0"),
            }
        elif s.get("codec_type") == "audio" and info["audio"] is None:
            info["audio"] = {
                "codec": s.get("codec_name", ""),
                "profile": s.get("profile", ""),
                "sample_rate": s.get("sample_rate", ""),
                "channels": s.get("channels", 0),
                "channel_layout": s.get("channel_layout", ""),
                "time_base": s.get("time_base", ""),
                "tag": s.get("codec_tag_string", ""),
                "frames": int(s.get("nb_frames") or 0),
            }
    return info


# ---------- 兼容性判定:这些字段全部一致,才允许 -c copy 直接拼接 ----------
# 注意 level(规格等级)不参与判定:-c copy 不重编码,等级差异不影响拷贝拼接
VIDEO_KEYS = ("codec", "profile", "width", "height", "pix_fmt", "time_base")
AUDIO_KEYS = ("codec", "sample_rate", "channels", "time_base")


def compatibility(infos: list) -> list:
    """返回不一致项列表,空列表表示可无损拼接。"""
    problems = []
    ref = infos[0]
    for info in infos[1:]:
        for key in VIDEO_KEYS:
            a, b = ref["video"][key], info["video"][key]
            if a != b:
                problems.append(f"视频 {key}: {ref['path']}={a} vs {info['path']}={b}")
        if (ref["audio"] is None) != (info["audio"] is None):
            problems.append(f"音轨缺失不一致:{ref['path']} / {info['path']}")
        elif ref["audio"] and info["audio"]:
            for key in AUDIO_KEYS:
                a, b = ref["audio"][key], info["audio"][key]
                if a != b:
                    problems.append(f"音频 {key}: {ref['path']}={a} vs {info['path']}={b}")
    return problems


# ---------- concat 清单:路径含空格/中文/单引号都要能安全转义 ----------
def write_concat_list(paths: list, list_file: str) -> str:
    with open(list_file, "w", encoding="utf-8") as f:
        for p in paths:
            safe = os.path.abspath(p).replace("'", "'\\''")  # 单引号 -> '\'' 标准转义
            f.write(f"file '{safe}'\n")
    return list_file


# ---------- 无损拼接 ----------
def concat_copy(paths: list, output: str) -> None:
    workdir = tempfile.mkdtemp(prefix="merge_recordings_")
    list_file = write_concat_list(paths, os.path.join(workdir, "concat_list.txt"))
    cmd = [FFMPEG, "-hide_banner", "-loglevel", "warning", "-nostdin", "-y",
           "-f", "concat", "-safe", "0", "-i", list_file,
           "-c", "copy"]
    if output.lower().endswith((".mp4", ".mov", ".m4v")):
        cmd += ["-movflags", "+faststart"]  # moov 前置,网盘/网页播放可边下边播
    cmd += [output]
    print("[*] " + " ".join(cmd))
    subprocess.run(cmd, check=True)


# ---------- 参数不一致时的兜底:重编码拼接 ----------
def concat_reencode(paths: list, output: str) -> None:
    inputs = []
    for p in paths:
        inputs += ["-i", p]
    has_audio = all(bool(probe(p)["audio"]) for p in paths)
    n = len(paths)
    if has_audio:
        streams = "".join(f"[{i}:v][{i}:a]" for i in range(n))
        fc = f"{streams}concat=n={n}:v=1:a=1[v][a]"
        maps = ["-map", "[v]", "-map", "[a]", "-c:a", "aac", "-b:a", "192k"]
    else:
        streams = "".join(f"[{i}:v]" for i in range(n))
        fc = f"{streams}concat=n={n}:v=1:a=0[v]"
        maps = ["-map", "[v]", "-an"]
    cmd = [FFMPEG, "-hide_banner", "-loglevel", "warning", "-nostdin", "-y", *inputs,
           "-filter_complex", fc, *maps,
           "-c:v", "libx264", "-crf", "20", "-preset", "medium", "-pix_fmt", "yuv420p"]
    if output.lower().endswith((".mp4", ".mov", ".m4v")):
        cmd += ["-movflags", "+faststart"]
    cmd += [output]
    print("[*] " + " ".join(cmd))
    subprocess.run(cmd, check=True)


# ---------- 复核:帧数与时长是否守恒 ----------
def count_frames(path: str, kind: str) -> int:
    """用解码器真实计数(nb_frames 在部分容器里为空或不准)。"""
    cmd = [FFPROBE, "-v", "error", "-select_streams", kind[0],
           "-count_frames", "-show_entries", f"stream=nb_read_frames",
           "-print_format", "json", path]
    out = subprocess.run(cmd, capture_output=True, text=True)
    try:
        streams = json.loads(out.stdout).get("streams", [])
        return int(streams[0].get("nb_read_frames") or 0) if streams else 0
    except Exception:
        return 0


def human(n: int) -> str:
    for unit in ("B", "KiB", "MiB", "GiB"):
        if n < 1024 or unit == "GiB":
            return f"{n:.2f} {unit}" if unit != "B" else f"{n} B"
        n /= 1024.0


def level_str(v: dict) -> str:
    """level=52 -> L5.2,便于和 QuickTime/FFmpeg 显示对上。"""
    lv = str(v.get("level") or "")
    if len(lv) == 2 and lv.isdigit():
        return f"L{lv[0]}.{lv[1]}"
    return f"L{lv}" if lv else ""


def report(infos: list, output: str) -> None:
    print("\n{:<4}{:<44}{:>12}{:>12}{:>12}".format("序号", "文件", "时长(s)", "视频帧", "大小"))
    for i, info in enumerate(infos, 1):
        name = os.path.basename(info["path"])
        print("{:<4}{:<44}{:>12.2f}{:>12}{:>12}".format(
            i, name[:42], info["duration"], info["video"]["frames"] or "-", human(info["size"])))

    out = probe(output)
    out_v = count_frames(output, "video")
    out_a = count_frames(output, "audio") if out["audio"] else 0
    src_v = sum(count_frames(i["path"], "video") for i in infos)
    src_a = sum(count_frames(i["path"], "audio") for i in infos) if out["audio"] else 0
    src_d = sum(i["duration"] for i in infos)

    print(f"\n[+] 输出:{output}")
    print(f"    时长 {out['duration']:.2f}s(源合计 {src_d:.2f}s,差 {abs(out['duration']-src_d):.2f}s)")
    print(f"    视频帧 {out_v}(源合计 {src_v},{'一致 ✔' if out_v == src_v else '不一致 ✘'})")
    if out["audio"]:
        diff = abs(out_a - src_a)
        if diff == 0:
            note = "一致 ✔"
        elif src_a and diff / src_a <= 0.02:
            # AAC 每段自带 priming/padding 帧,-c copy 拼接后允许 ≤2% 偏差,听感无影响
            note = f"偏差 {diff} 帧(AAC priming/padding,属正常)"
        else:
            note = f"不一致 ✘(差 {diff} 帧)"
        print(f"    音频帧 {out_a}(源合计 {src_a},{note})")
    print(f"    大小 {human(out['size'])}")


def main() -> None:
    ap = argparse.ArgumentParser(description="录屏片段无损合并(ffmpeg concat + stream copy)")
    ap.add_argument("inputs", nargs="+", help="待合并的视频文件(按命令行顺序拼接)")
    ap.add_argument("-o", "--output", required=True, help="输出文件路径")
    ap.add_argument("--sort", action="store_true", help="按文件名排序后再拼接(录制时间命名时等价于时间顺序)")
    ap.add_argument("--force", action="store_true", help="参数不一致时强制重编码拼接")
    ap.add_argument("--check", action="store_true", help="只做参数体检,不执行合并")
    args = ap.parse_args()

    inputs = sorted(args.inputs) if args.sort else args.inputs
    # 防止通配符把输出文件自身也卷进输入(重复合并的常见误操作)
    out_abs = os.path.abspath(args.output)
    inputs = [p for p in inputs if os.path.abspath(p) != out_abs]
    for p in inputs:
        if not os.path.isfile(p):
            sys.exit(f"[!] 文件不存在:{p}")
    if len(inputs) < 2:
        sys.exit("[!] 至少需要两个输入文件")

    infos = [probe(p) for p in inputs]

    print("== 片段参数 ==")
    for info in infos:
        v, a = info["video"], info["audio"]
        print(f"  {os.path.basename(info['path'])}: "
              f"{v['codec']} {v['profile']} {level_str(v)} {v['width']}x{v['height']} {v['pix_fmt']} "
              f"tb={v['time_base']} | "
              + (f"{a['codec']} {a['sample_rate']}Hz {a['channel_layout']}" if a else "无音轨"))

    problems = compatibility(infos)
    if problems:
        print("\n[!] 检测到参数不一致,无法无损拼接:")
        for p in problems:
            print("    - " + p)
        if not args.force:
            sys.exit("\n    可加 --force 触发重编码拼接(画质有一次编码损失)")
        print("\n[*] --force 已开启,改用 filter_complex concat 重编码拼接")
    else:
        print("\n[+] 参数完全一致,走无损拼接(-c copy)")

    if args.check:
        return

    if problems:
        concat_reencode(inputs, args.output)
    else:
        concat_copy(inputs, args.output)

    report(infos, args.output)


if __name__ == "__main__":
    main()
相关推荐
renzao_ai2 小时前
本地 35B 大模型部署实战:Ollama 跑 Ornith-35B 全流程
python·llama·免费ai大模型
the局外人2 小时前
学习 FastAPI 的 Day 4:完成用户系统与接口联调(完结)
后端·python·fastapi
AIFQuant2 小时前
Python股票实时价格告警系统:WebSocket订阅与REST快照实战
开发语言·python·websocket·a股行情
名字还没想好☜2 小时前
Java Collectors.groupingBy 进阶:多级分组、下游收集器与统计聚合一次搞定
java·windows·后端·python·spring
2601_962078233 小时前
Python接口自动化流程(pytest)
python·pytest·接口自动化·测试框架·断言
AlanBruce3 小时前
摩尔信使MThings CJ/T 188数据配置使用指南
自动化·modbus·摩尔信使
杨女士YRJ3 小时前
python+pytest+01
python·pytest
长谷深风1113 小时前
AI自动化中的关键闸门:何时必须人工审批?
大数据·人工智能·ai·自动化·ai agent·智能体·hitl