🎬 第四章 FFmpeg命令行实现音视频转封装
📄 一句话概括

合并的本质不是「首尾相接」,是「给后一段的时间戳加偏移」。 不加偏移,MP4 封装器会直接拒绝写入。

📝 本节和前面几节的关系

4.2.3 是把两条流合成一份,这一节是把两份合成一份。 差别全在时间戳上——这也是它比分合难的原因。

一、两种合并,选错会白干

「合并」在 ffmpeg 里有两条完全不同的路:

做法 原理 局限


concat 分离器 把多个文件当同一个流的片段 参数必须完全一致 concat 协议 字节层面直接连起来 只对部分格式成立

第一行是首选:要求所有片段的编码参数、分辨率、帧率、时间基都一致。 满足条件时它是纯转封装,快得几乎不花时间。

第二行更粗暴,只在「本来就是一整段被切开」时可靠。 参数稍有不同就会拼出坏文件,所以实际用得少。

二、不加偏移会怎样

这是本节最值钱的一段,因为它有确定的报错:

做法 结果 时长


不改时间戳 写入被拒(封装器拒绝时间戳倒退) — 改时间戳 正常写入 2.00 秒

两段各 1 秒,第二段的 pts 又从 0 开始了,于是「时间倒退」。 MP4 的封装器不允许这件事,直接返回错误码 22——连文件都不给你。

这条限制其实是好事:它把「接错了」变成一条明确的报错, 而不是一个看起来正常、实际只有一半内容的文件。TS 这类宽容的盒子就没这么客气。

三、偏移量怎么算

规则只有一句:后一段的每个包,减去它自己的起点,再加上前一段的总时长。

量 怎么来 注意


前一段总时长 读它的 duration 或末包 pts 别用文件大小估 后一段起点 它第一包的 pts 常常不是 0 每个包 pts 减起点加偏移 dts 要同步改

第三行容易漏 dts:只改 pts 不改 dts,带 B 帧的片子会立刻报错。 两个都要一起减、一起加,差值保持不变。

用 concat 分离器时这些都不用自己算,它读每个片段的时长自动累加。 自己写脚本时才是硬功夫——这也是为什么这类活儿一般交给命令行。

四、动手 Demo:切两段再拼回去:改与不改时间戳的对照

先把 2 秒素材按时间切成两段各 1 秒,再尝试把两段拼回一份。 两种做法只差「改不改时间戳」,结果一个报错一个成功。

import av

# 切段:每段把第一包的 pts 记下来,之后每个包都减掉它
for dst, lo, hi in ((u"h1.mp4", 0.0, 1.0), (u"h2.mp4", 1.0, 2.0)):
    c0 = av.open(u"m4.mp4")
    c1 = av.open(dst, u"w")
    mp = {s.index: c1.add_stream_from_template(s) for s in c0.streams}
    base = {}
    for pk in c0.demux():
        if not pk.size:
            continue
        t = float(pk.pts * pk.time_base) if pk.pts is not None else 0.0
        if not (lo <= t < hi):
            continue
        if pk.stream.index not in base:
            base[pk.stream.index] = pk.pts
        pk.pts -= base[pk.stream.index]
        if pk.dts is not None:
            pk.dts -= base[pk.stream.index]
        pk.stream = mp[pk.stream.index]
        c1.mux(pk)
    c1.close()
    c0.close()

# 拼回:不改时间戳,第二段的 pts 会倒退
c1 = av.open(u"bad.mp4", u"w")
mp = {}
try:
    for p in (u"h1.mp4", u"h2.mp4"):
        c0 = av.open(p)
        for s in c0.streams:
            if s.index not in mp:
                mp[s.index] = c1.add_stream_from_template(s)
        for pk in c0.demux():
            if not pk.size:
                continue
            pk.stream = mp[pk.stream.index]
            c1.mux(pk)
        c0.close()
except Exception as e:
    print(u"写入被拒:%s" % str(e)[:40])

实跑输出:

--------------------------------------------------------------------
4.2.4 把两个 MP4 合并成一个
--------------------------------------------------------------------

**① 先把素材切成两段**

  文件       字节      时长
  --------------------------------
  原文件     37982     2.00 秒
  前半段     19975     1.02 秒
  后半段     19013     1.00 秒

**② 直接首尾接:不改时间戳会怎样**

  做法           结果                                    时长
  ------------------------------------------------------------------
  不改时间戳     写入被拒:封装器拒绝(returned 22)     1.02 秒
  改时间戳       正常写入                                2.00 秒

**第一行不是「叠在一起」而是「直接写不进去」——MP4 的封装器拒绝时间戳倒退。**
**这条限制其实是好事:它把「接错了」这件事故变成了一条明确的报错,而不是一个看起来正常、**
**实际只有一半内容的文件。换成某些宽容的盒子(比如 TS),同样的错会安静地产生错乱结果。**

**③ 所以「合并」的本质是给后一段的时间戳加偏移**

**偏移量等于前一段的总时长。命令行里的 concat 分离器就是自动做这件事。**
**手工写脚本时最容易忘的就是这一步,而忘了之后两种盒子的表现完全不同:**
**MP4 当场报错(上面那行),TS 则安静地写出一份时间轴错乱的文件。**

最后那几行会在 c1.mux(pk) 上抛异常:returned 22。 这就是「时间戳倒退」的具体表现——第二段的 pts 比已经写进去的小。

改法只有一句:写第二段之前,把所有包的 pts/dts 加上前一段的总时长。 本机这份素材上前段 1.02 秒,所以偏移量就取 1.02 而不是 1.00。

注意切段时那个 base 字典是「按流分别记」的: 视频和音频的起始 pts 通常不一样,共用一份起点会把其中一条提前或推迟。

30 秒实验

  1. 列片段:把两段路径写进 list.txt,每行 file 'h1.mp4' 这种格式。
  2. 用分离器合并:ffmpeg -f concat -safe 0 -i list.txt -c copy 全.mp4。
  3. 看结果时长:ffprobe -show_format 全.mp4,应是两段之和。

常见误区

  1. 以为「拼起来」就是字节连字节。真正的合并要重排时间戳,不然后一段会被拒。
  2. 改 pts 不改 dts。带 B 帧时立刻报错,两个必须一起改。
  3. 片段参数不一致还硬用 concat 分离器。分辨率或帧率不同时必须先统一再拼。
自测三问(点开对答案) 1. 为什么第二段直接写进去会被拒绝? —— 因为它的 pts 又从 0 开始,比已写入的时间戳小,MP4 封装器不允许时间戳倒退。 2. 合并时偏移量等于什么? —— 等于前一段的总时长,也就是后一段整体的时间轴平移量。 3. concat 分离器对片段有什么要求? —— 编码参数、分辨率、帧率、时间基都要一致,否则拼出来的文件会坏。

相关笔记