Python使用ffmpeg concat实现录屏片段无损合并
作者:Bruce_xiaowei
一次真实的漏洞复现录屏整理记录:两段 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 把两段的参数拉出来对照,是避免"合并成功但结果不可用"的关键一步:
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'
然后:
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 探测:把容器信息结构化
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 一致性判定:决定"能不能无损"
# 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 '<路径>',路径本身用单引号包裹,因此路径里的单引号必须转义——这是踩过一次的坑,标准写法是 '\'':
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 拼接与复核
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 字段(部分容器根本不写),要用解码器真实计数:
ffprobe -v error -select_streams v -count_frames \ -show_entries stream=nb_read_frames -print_format json out.mov
六、跑起来是什么样
用两段测试素材(1280×800 / 25fps / 3s + 2s,模拟同参录屏)实跑脚本:
$ 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若混入一段异分辨率素材,脚本在动手之前就会拦下来:
[!] 检测到参数不一致,无法无损拼接:
- 视频 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 触发重编码拼接(画质有一次编码损失)用法速查:
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
源码:
#!/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()
以上就是Python使用ffmpeg concat实现录屏片段无损合并的详细内容,更多关于Python录屏片段无损合并的资料请关注脚本之家其它相关文章!
