录屏片段无损合并:从 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 原理
ffmpeg 的 concat demuxer 做的事非常朴素:
- 读取清单文件里列出的每个文件;
- 依次读取每个文件的音视频包(packet),原样写入输出容器;
- 第二段开始的时间戳,按前一段的
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
一旦检出不一致,脚本默认拒绝合并 (而不是"拼了再说"),要么退出让人处理,要么显式加 --force 走 filter_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()