合并的本质不是「首尾相接」,是「给后一段的时间戳加偏移」。 不加偏移,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 秒实验
- 列片段:把两段路径写进
list.txt,每行file 'h1.mp4'这种格式。 - 用分离器合并:
ffmpeg -f concat -safe 0 -i list.txt -c copy 全.mp4。 - 看结果时长:
ffprobe -show_format 全.mp4,应是两段之和。
常见误区
- 以为「拼起来」就是字节连字节。真正的合并要重排时间戳,不然后一段会被拒。
- 改 pts 不改 dts。带 B 帧时立刻报错,两个必须一起改。
- 片段参数不一致还硬用 concat 分离器。分辨率或帧率不同时必须先统一再拼。
自测三问(点开对答案)
1. 为什么第二段直接写进去会被拒绝? —— 因为它的 pts 又从 0 开始,比已写入的时间戳小,MP4 封装器不允许时间戳倒退。 2. 合并时偏移量等于什么? —— 等于前一段的总时长,也就是后一段整体的时间轴平移量。 3. concat 分离器对片段有什么要求? —— 编码参数、分辨率、帧率、时间基都要一致,否则拼出来的文件会坏。- ☐ (本节完,下一节 4.3.1 MP4 转换为 FLV)