python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python录屏片段无损合并

Python使用ffmpeg concat实现录屏片段无损合并

作者:Bruce_xiaowei

还在为漏洞复现视频分段录制难合并发愁,本文用真实案例教你用ffmpeg concat无损拼接QuickTime录屏,不重编码、画质零损失,附脚本和避坑清单,轻松搞定CNVD报送材料,需要的朋友可以参考下

一次真实的漏洞复现录屏整理记录:两段 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.mov97.47 s33.0 MB前半程:登录、漏洞触发路径
录屏2026-09-11 23.03.50.mov37.91 s22.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 把两段的参数拉出来对照,是避免"合并成功但结果不可用"的关键一步:

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"

三个参数值得单独解释:

4.2 结果与守恒校验

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

项目片段 ①片段 ②合并输出校验
时长97.47 s37.91 s135.36 s97.47 + 37.91 = 135.38 ≈ 135.36 ✔
视频帧3473124047133473 + 1240 = 4713 ✔
音频帧4569177163444569 + 1771 = 6344 ✔
大小33.0 MB22.7 MB58,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

一旦检出不一致,脚本默认拒绝合并(而不是"拼了再说"),要么退出让人处理,要么显式加 --forcefilter_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          # 参数不一致时重编码

七、踩坑清单

#现象 / 坑原因处理
1Immediate 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
8GUI 启动的脚本里 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
SHA11c965cbad80ffdd63196d99b1f81f17de9bbda5b
获取方式夸克网盘外链 + 提取码(另可通过 vreport@cert.org.cn 补件)

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

九、小结

本次用到的完整脚本已随本文一并落盘: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录屏片段无损合并的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文