ffmpeg分割视频导致A/V不同时开始的问题

更新:2023-11-21 更新追记1,在最后。

昨天马娘 live 出现了花井美春不小心喊出 producerさん的名场面,当时恰好在录制的我就顺手截了一段出来发到了群里。没想到这段视频后来传了好多地方,包括有人发B站

不过这就引出一个问题,该视频在B站如果用app观看,会发现有明显的音画不同步(我用网页反而不会)——而且这个问题如果用 Chrome / Firefox 播放(或者一切基于Chromium的框架,例如 Electron,包含 Discord 等)也都会重现。那么这是为什么呢?

其实这个问题并不复杂,视频本身也没本质问题,只是这些播放器的实现不够标准导致的。

这个视频有两个比较特殊的特性:一个是其 两条 stream 的 start time 不一致:视频是0.444,音频则是0。不过这个本身一般大多数播放器都能对付,问题不大。

另外一个则比较罕见,也是导致 chromium bug 的根本原因。让我们提取最前面的 packets 展示一下(这里的v是我自己写的py小脚本):

>v p producer-san.mp4
Extracted 2069 packets from producer-san.mp4.
Video packets: 781
Audio packets: 1288
audio -1.004667 KD_
audio -0.983333 KD_
audio -0.962000 KD_
audio -0.940667 KD_
audio -0.919333 KD_
audio -0.898000 KD_
audio -0.876667 KD_
audio -0.855333 KD_
audio -0.834000 KD_
audio -0.812667 KD_
audio -0.791333 KD_
audio -0.770000 KD_
audio -0.748667 KD_
audio -0.727333 KD_
audio -0.706000 KD_
audio -0.684667 KD_
audio -0.663333 KD_
audio -0.642000 KD_
audio -0.620667 KD_
audio -0.599333 KD_
audio -0.578000 KD_
audio -0.556667 KD_
audio -0.535333 KD_
audio -0.514000 KD_
audio -0.492667 KD_
audio -0.471333 KD_
audio -0.450000 KD_
audio -0.428667 KD_
audio -0.407333 KD_
audio -0.386000 KD_
audio -0.364667 KD_
audio -0.343333 KD_
audio -0.322000 KD_
audio -0.300667 KD_
audio -0.279333 KD_
audio -0.258000 KD_
audio -0.236667 KD_
audio -0.215333 KD_
audio -0.194000 KD_
audio -0.172667 KD_
audio -0.151333 KD_
audio -0.130000 KD_
audio -0.108667 KD_
audio -0.087333 KD_
audio -0.066000 KD_
audio -0.044667 KD_
audio -0.023333 KD_
audio -0.002000 K__
audio 0.019333 K__
audio 0.040667 K__
audio 0.062000 K__
audio 0.083333 K__
audio 0.104667 K__
audio 0.126000 K__
audio 0.147333 K__
audio 0.168667 K__
audio 0.190000 K__
audio 0.211333 K__
audio 0.232667 K__
audio 0.254000 K__
audio 0.275333 K__
audio 0.296667 K__
audio 0.318000 K__
audio 0.339333 K__
audio 0.360667 K__
audio 0.382000 K__
audio 0.403333 K__
video 0.444000 K__
audio 0.424667 K__
video 0.510733 ___
audio 0.446000 K__
audio 0.467333 K__
video 0.477367 ___
audio 0.488667 K__
audio 0.510000 K__
video 0.577467 ___
audio 0.531333 K__
video 0.544100 ___

可以看到,其音频的 packets 大概前面有几十个,是有负的 PTS 外加 flag D (discarded) 的。也就是说,正确处理的情况下,这些 packets 应该被播放器舍弃,而不播放。另外由于第一个 V 包是0.444秒才有,所以最前面会有0.44秒是只有声音没有视频的(一般播放器表现为第一视频帧静止画面)。但是等到两者都开始播放后,音画是同步的。

当然这些 pakcets的元数据都是 ffmpeg 处理过后生成的了,这个特性的具体实现,其实是MP4的一个非常少用到的功能:edit list (moov.trak.edts.elst)。通过这个元数据,可以进行一些很高级的控制,除了跳过部分段落外,还可以实现重复播放某段、加速减速播放等等。

如果用 ffprobe -report producer-san.mp4 来生成一个详细报告,可以看到里面有如下语句:

[mov,mp4,m4a,3gp,3g2,mj2 @ 000001f4670fe840] Processing st: 0, edit list 0 - media time: -1, duration: 39960
[mov,mp4,m4a,3gp,3g2,mj2 @ 000001f4670fe840] Processing st: 0, edit list 1 - media time: 3003, duration: 2345400
[mov,mp4,m4a,3gp,3g2,mj2 @ 000001f4670fe840] Processing st: 1, edit list 0 - media time: 88160, duration: 1270704

不过,大多数播放器对 edit list 都没有很好的支持就是了。这个视频用本地播放器播放,虽然音画都同步,行为也不太一样:

  • MPV: 永远从第一个 video pakcet 开始播放,所以不只是前面的 -1 秒音频不会播放,提前开始的那0.44秒也不会播放。
  • PotPlayer: 从pts=0处开始播放音频,视频先静止帧0.44秒。
  • MPC-HC: 同 PotPlayer
  • MPC-BE: 完整播放所有packet,即先视频静止帧+播放前1.44秒音频,然后AV同时播放。

B站的压制我是没法很方便地测试啦,但是 Chromium 这边的表现则是,前面的有 D flag 的音频确实被扔掉了,音轨是正确地从 PTS=0 的 packets 开始播放,但是其视频流直到1秒多而不是0.44秒(没法准确确定是1秒还是1.44秒)才开始播放,自然音画就不同步了。可以看到,其对 PTS 的处理不正确。这点我已经汇报到官方的 issue tracker

问题视频产生的根源

但是这就引出一个更重要的话题:为什么会切出这种奇怪的视频?这么多年切 TS 视频,其实经常出现这种问题。不过因为不影响播放,其实我也没深究过。现在想来,虽然这个视频是完全”合法“的,但是为了兼容性显然我们还是想要最大程度规避这种现象。而且即使是正确播放,最前面0.4秒视频不动看着也不太舒服,所以为啥切出来的视频V/A两轨的开头时间会不一样呢?

我切视频都是用我的自己写的一个 CLI 交互小脚本,因为我经常给日本音番切片。我们这里切片并不需要特别准确(需求精确时我就用 TMPGEnc 了),所以 ffmpeg 的 stream copy 只能切到视频的关键帧,或者时间不是完全准确都是无关紧要的。其实切 TS 的其他乱七八糟的小问题也一大堆,我已经尽量在脚本里把各种情况都处理了,不过这里按下不表。总之,这里最后实际切片的命令如下(去掉了一些无关紧要的):

ffmpeg -ss 11:39:06.444 -i "XXX.ts" -t 0:00:29.192000 -c copy cut.mp4

这里我的 .ts 是在下载 m3u8 过程中即时二进制合并 segments 出来的一个大文件。我使用了 input seeking 的方式跳到我想要的时间戳,然后再在 output option 里加了一个长度来切片。

插播:简单科普 ffmpeg seeking

关于 ffmpeg 两种 seeking 如果不了解可以先看看官方 wiki 补课(有些内容稍显过时,问题不大)。简单来说,如果用 input seeking (也就是把相关参数填写在 -i 前面),会在读取该 input 的时候直接 seek 到这个位置,不会解码前面的内容,速度基本是瞬时的;如果用 output seeking (把相关参数填写到 -i 后面),则需要解码整个视频至此处,需要很长的时间,而且对于我这种前面有11小时内容的更是不现实。

注意这里解释一下我在网上经常看到有人误解的地方:对于正常的视频,无论用哪个 seeking,出来的结果都是精确的:并不是说只有 output seeking 才能精确到毫秒级时间戳。当你 transode 的时候,即使你用的是 input seeking,而且切割点不在关键帧上,ffmpeg也会自动先 seek 到上一个关键帧然后解码到你需要的帧处(这个行为由 -accurate_seek 这个来控制,默认是开启的,可以用 -noaccurate_seek 关闭),再进行 transcode。当然,如果是 stream copy (本文的议题),则就只能 seek 到最近的关键帧了,这个无论是用 input seeking 还是 output seeking 都是一样的, -accurate_seek 这个开关对于 stream copy 也是完全没有任何效果的。

所以我用 input seeking 来 -ss 的原因也很好理解了。至于为什么 -t 反而要用 output seeking,则是为了规避 ffmpeg 当年 seek MEPG-TS 格式的一个bug:当年如果在 input 同时用 -ss 和 -to/-t,会出现并不会在指定的 duration 或者结束时间戳结束的问题。不过这个 bug 在我汇报后过了几个月已经修复了,其实现在已经没有必要再这样,不过既然没有副作用就先不改了(另外注意,不要 -ss input seeking 但是 -to output seeking。当视频读取到 output 侧后,其时间戳会被重置,所以你的 -to 是从 ss 处重新开始计算的而不是原始视频的时间戳。对 -t 则没有区别)。

另外对于一般视频,其音频部分的 packets 可以理解为每个都是“关键帧”,也就是任意可分的。只有视频会有 GOP 的概念只能切割到关键帧,不能在任意 packet 处无损切割。再加上一般音频的包本来就比视频包短(本文中此视频音频包长度只有20ms),基本可以认为能任意位置无损切割了。

出问题的 input 简介

让我们回到正题。这个问题一言以蔽之,主要产生于 MPEG-TS 这个格式的问题上,尤其是 m3u8 直播时产出的 segment 的文件里。其实这个问题只需要取此次直播的任意一个 segment 即可实现,所以问题和我们后期 binary 合并过多个 segment 没有关系。我们以 index_4_6992.ts (下文重命名为 raw.ts)为例,其长度是6s。我们先按顺序罗列下他的所有 packets:

Image

表格中第一列是类型(视频 or 音频),第二列是 PTS time,第三列是 DTS time,第四列是 flags。顺序是从左往右,从上往下。

可以看到,对于这种 livestream 的 segment,其 PTS 都是连续的所以并不会从零开始。这里问题不大。但是比较奇怪的是其 packets 的排列方式:可以看到在最前面有几十个 video 的 packets,然后是比较正常的两者交替进行 (interlaced),最后又变成有一大堆 audio 的 packets。

我们把所有的 time 都用 PTS/DTS的最小值 offset 一下,看起来更方便些:

Image

可以看到,他是先包含了快2秒的视频 packets,然后是前 0.36秒的音频的 packets,然后又跟了0.3秒的视频 packets.. 以此类推。但是这样音频还是追不上视频,所以最后又一口气塞了快2秒的音频的 packets。

很显然,这些 packets 并不是按照 DTS (或 PTS) 排列的。如果你按照 DTS 重新排序,会变成:

Image

这就和一个正常的视频没什么区别了。事实上,如果直接播放这个 segment,会发现没有任何问题,音画同步且同时都从最开始开始。我的理解是,正常播放器都有一个足够大的 buffer,而不是指望视频的 packets 一定会 DTS 单调增(事实上很多视频的DTS都不是单调增的)。这样他就会一次读取足够多的 pakcets 进去然后按照 DTS 解码、 PTS 顺序播放。

但是到了 ffmpeg 这里,作为 input,seeking这个(种)视频就会有各种问题。这个问题的具体表现形式对于不同的 output 封装形式还不太一样。接下来,让我们罗列下使用不同 seek + copy 或 transcode,对于此问题 input 会出现什么后果。

Input seek + stream copy

如果我 stream copy 到 mp4 容器:

ffmpeg -ss 00:00:03 -i raw.ts -c copy input_seeking_copy_tomp4.mp4 -y

Format start time: 0.0
Stream video start time: 1.004
Stream audio start time: 0.0
Earliest video packet pts time: 1.004
Earliest audio packet pts time: -1.010667
Image

这就是万恶之源,上文提到的那种有负 PTS + discarded packets 的视频。DTS倒是单调增。

对于负 PTS 的问题,可以通过增加 -avoid_negative_ts make_zero 参数来解决:

ffmpeg -ss 00:00:03 -i raw.mp4 -c copy -avoid_negative_ts make_non_negative temp.mp4 -y

这样出来的视频就会和下面的MKV容器的结果一样。

如果是 copy 到 .ts 容器 (ffmpeg -ss 00:00:03 -i raw.ts -c copy input_seeking_copy.ts -y),output 则是

Format start time: 1.4
Stream video start time: 4.033333
Stream audio start time: 1.4
Earliest video packet pts time: 4.033333
Earliest audio packet pts time: 1.4
Image

可以看到整个视频会有一个1.4的 start time,但是 video更晚在4。1.4 产生的原因SO有个问题提到了,可以通过 -muxdelay 0 来消除。另外注意,这个视频的DTS也不是单调增(在V和V或者A和A之间是,但是跨类型不是),但比 raw.ts 好多了。

如果是 copy 到 .mkv 容器:

Format start time: 0.0
Stream video start time: 2.633
Stream audio start time: 0.0
Earliest video packet pts time: 2.633
Earliest audio packet pts time: 0.0
Image

容器 start time是0,视频要在2.6秒后才开始,DTS完美单调增*,没有负数 PTS。

*:这个视频的第一个 V packet 很奇怪地并没有DTS的数据(图中显示为0)。我理解是 MKV 容器第一帧视频必须是首先解码,所以可以默认为0?

我们把几种方法产生的视频具体包含的 packets 给 visualize 一下:

Image

图中 audio 是交替颜色显示逐个 packets;视频则是按照GOP来交替显示。PTS 按照 raw 来对齐,有D flag的部分显示为红色。可以看到,视频都是只有最后一个GOP(从约4秒开始),但是音频都反而要比我们指定的地点提前不少:TS / MKV / 禁用了 edit list 的 MP4 都是提前了比音频提前2.6秒(比指定切割点提前1.6秒),而 MP4 如果不加 -avoid_negative_ts make_non_negative,和TS/MKV相比又短了些(比视频提前2秒,比切割点提前1秒,但是有D flag来丢弃到正好到切割点处),不是很懂。

顺便还可以看到对于 mkv 格式,因为其对时间戳的处理和其他容器不同(之前看过一次已经记不太清了,大概简单来说似乎是因为其他格式本质上类似于 time code 累加的形式,mkv则是对于每个 packet 都有定死的时间,然后考虑到舍入误差?),所以偶尔会出现相邻的两个 packet 没有完全连续,而是有 1~2个 time base 的间隔现象——尤其是对于NTSC的 29.97/23.974帧率的视频来说(上面的 plot 由于图像分辨率的问题(香农采样原理!)并不能把所有的细小间隔都显示出来,只是随机显示了几个)。

Input seek + transcode

因为问题是由于 seeking 导致的,所以即使 transcode 也会有问题。对于下列命令:

ffmpeg -ss 00:00:03 -i raw.ts -c:v libx264 -c:a aac input_seeking_encode.ts -y
ffmpeg -ss 00:00:03 -i raw.ts -c:v libx264 -c:a aac input_seeking_encode_tomp4.mp4 -y
ffmpeg -ss 00:00:03 -i raw.ts -c:v libx264 -c:a aac input_seeking_encode_tomkv.mkv -y

>v start input_seeking_encode.ts
Format start time: 1.4
Stream video start time: 2.422333
Stream audio start time: 1.4
Earliest video packet pts time: 2.422333
Earliest audio packet pts time: 1.4

>v start input_seeking_encode_tomp4.mp4
Format start time: 0.0
Stream video start time: 0.0
Stream audio start time: 0.0
Earliest video packet pts time: 0.0
Earliest audio packet pts time: -0.021333

>v start input_seeking_encode_tomkv.mkv 
Format start time: -0.021
Stream video start time: 1.001
Stream audio start time: -0.021
Earliest video packet pts time: 1.001
Earliest audio packet pts time: -0.021

可以看到,现在变成这样:

  • TS 容器:AV start time 依然不同,错1秒。
  • MP4 容器:看似一切都正常了,但是实际播放可以确认,只是前面几帧被 ffmpeg 默认给 duplicate 来实现 CFR 罢了。如果加上 -vsync 0 ,又变成
Format start time: 0.0
Stream video start time: 1.001
Stream audio start time: 0.0
Earliest video packet pts time: 1.001
Earliest audio packet pts time: -0.021333

这样子了。

  • MKV 容器:同上 vysnc=0 的情况。

也就是说,无论哪种情况,都是从原视频 audio 3秒处、视频4秒处(第三个GOP处)开始 encode 的。

而理论上,transcode 的情况下应该是可以 accurate seek 才对。介于音频正常,我们可以大概猜测这个 mpegts 的 input 的本质问题在于他导致 ffmpeg 没能正确判断切割点(t=3)的上一个 keyframe (t=2)在哪里,于是直接给切到下一个(t=4)去了。

Output seek + stream copy

那么,如果我们改用 output seeking,能否改善这个问题呢?

ffmpeg -i raw.ts -ss 00:00:03 -c copy output_seeking_copy.ts -y
ffmpeg -i raw.ts -ss 00:00:03 -c copy output_seeking_copy_tomp4.mp4 -y
ffmpeg -i raw.ts -ss 00:00:03 -c copy output_seeking_copy_tomkv.mkv -y
output_seeking_copy.ts
Format start time: 1.413333
Stream video start time: 2.404
Stream audio start time: 1.413333
Earliest video packet pts time: 2.404
Earliest audio packet pts time: 1.413333

output_seeking_copy_tomp4.mp4
Format start time: 0.013
Stream video start time: 1.004
Stream audio start time: 0.013
Earliest video packet pts time: 1.004
Earliest audio packet pts time: 0.013

start output_seeking_copy_tomkv.mkv
Format start time: 0.013
Stream video start time: 1.004
Stream audio start time: 0.013
Earliest video packet pts time: 1.004
Earliest audio packet pts time: 0.013

视频和音频起点不一致的问题依然存在,但是现在所有格式都会是固定的从原视频 audio 3秒处、视频4秒处(第三个GOP处)开始。这里对于MP4,即使不加 -avoid_negative_ts make_non_negative 也不会出现负的PTS了(output seeking 原理所致,TS 是重新计算的),所以 Chromium 也可以正确播放。

Image

Output seek + transcode

如果使用 output seeking + 重编码,倒是可以完美解决:

ffmpeg -i raw.ts -ss 00:00:03 -c:v libx264 -c:a aac output_seeking_encode.ts -y
ffmpeg -i raw.ts -ss 00:00:03 -c:v libx264 -c:a aac output_seeking_encode_tomp4.mp4 -y
ffmpeg -i raw.ts -ss 00:00:03 -c:v libx264 -c:a aac output_seeking_encode_tomkv.mkv -y

>v start output_seeking_encode.ts
Format start time: 1.4454
Stream video start time: 1.466733
Stream audio start time: 1.4454
Earliest video packet pts time: 1.466733
Earliest audio packet pts time: 1.4454

>v start output_seeking_encode_tomp4.mp4
Format start time: 0.0
Stream video start time: 0.0
Stream audio start time: 0.0
Earliest video packet pts time: 0.0
Earliest audio packet pts time: -0.021333

>v start output_seeking_encode_tomkv.mkv
Format start time: -0.021
Stream video start time: 0.0
Stream audio start time: -0.021
Earliest video packet pts time: 0.0
Earliest audio packet pts time: -0.021

这些视频不但时间戳都正常,实际观看也可以确认,确实是视频音频同时开始,没有重复帧等问题。

Workaround

这里先强调一下,上面切出来的这些“有问题”的文件一个是 AV 开始点不同的问题,一个是MP4容器特有的 edit list 导致部分播放器无法正常播放的问题。第二个问题如上所述可以通过切成别的格式、加 avoid_negative_ts 解决,甚至你切出来的MP4再重新封装一次也行(-ignore_editlist 1 加到 input option);但是第一个问题则是实打实的缺少那些 packets,是切了之后就救不回来的。

这个问题最简单或者说唯一的解决办法其实就是重新 remux 一下原视频,无论是用 ffmpeg 还是 mkvmerge,无论是 remux 成 mkv 还是 mp4(可别再 remux 成ts),都会重新生成 PTS/DTS 且重新对 packets 进行排序,从而会出来一个你随便切也不会切出问题的 input。例如我们简单地用 ffmpeg -i raw.ts -c copy raw.mp4,再去 inspect 这个 raw.mp4:

Image

可以看到 packets 的排序就比较正常了。

这个新的 input(或用 ffmpeg 或 mkvmerge 重新封装成 MKV 当 input),无论你怎么切,音视频起始点都一致的。不过上面提到过的 output 各种容器的 quirk 依然存在:

  • MKV: AV 的 PTS 都是从0开始到4。
  • MP4: AV 都从-1开始到3。0之前的都是有 D flag(也就是edit list)。
  • TS: AV 都从1.4开始,到5.4。

可以看到,总长度都是4s左右,因为原始视频有3个2秒的GOP,我们的切割点恰好在中间,这次和之前不同,都是往前切了一点从2s开始,重点是音频终于和视频开始时间相同,而不会像上面一样错开。

对于 MP4 格式,FFMPEG 再次试图通过 edit list (discarded flag+负 PTS)的方式来藏起来最前面1S(这次是同时藏视频和音频)。这里问题就来了……试着将这个视频放到 Chrome 里播放,果然又出现了音画不同步的问题!果然还是老老实实加上 -avoid_negative_ts make_zero 吧!

顺便一提,用播放器播放这个 mp4 视频,行为也和之前不太一样(音画同步都没问题):

  • MPV: 播放时,会从-1开始播放视频,但是音频只有0秒才开始,也就是说第一秒没有声音。
  • PotPlayer: 从-1同时播放视频和音频。
  • MPC-HC: 同 MPV
  • MPC-BE: 同 PotPlayer

结合播放这个、上面那个既有负 PTS 又有 AV 不同时开始的 mp4 视频、以及其他一些 单纯 AV 不同步开始但是没有负 PTS 的视频,可以大概猜测下每个播放器的特性了:

  • MPV: 从第一个视频包开始播放(即使有 D flag)。早于第一个视频包的音频不播放(即使是正PTS+无D flag)。不会播放有 D flag的音频包。
  • PotPlayer: 从第一个视频包(即使有D flag)或者第一个没有 D flag 的音频开始播放。会播放有 D flag的音频包。
  • MPC-HC: 从第一个视频包(即使有D flag)或者第一个没有 D flag 的音频开始播放。不会播放有 D flag的音频包。
  • MPC-BE: 无视一切 D flag,会完整播放所有存在的 pakcets,无论 PTS 正负。

最后还是上张图:

Image

另外还有一点,如果使用 output seeking + stream copy,即使是使用重新封装过的 input,也会产出和上面使用 raw.ts 作为 input 一样的 4-6这2秒视频 + 3-6这3秒音频的结果。

大概可以猜到是为啥:output seeking时会解码原视频,当解码完第1个GOP时还不到 -ss,那就只能继续解码下一个,结果就超过到了4了;音频同理但是音频可分区间更小。也就是说,是音视频分别自动采取了指定 -ss 之后的最近分割点。下图为 output seeking+stream copy,统一使用修复过的 raw.mp4 作为 input,使用 mkv 作为 ouput 容器,不同 -ss 的情况:

Image

而 input seek 则似乎是先选定一个离指定ss之前最近的视频分割点(关键帧),然后再以此时间戳寻找音轨的分割点。下图为 input seeking+sream copy,使用修复过的 raw.mp4 作为 input,不同 -ss 的情况:

Image

实战

Workaround 虽然有了,但是问题来了,对于 input 过于庞大的,总不能重新封装几十G的文件吧?

所以我们只能用曲线救国的方式:先粗切一次切出一个 intermediate 文件,比如从 -ss 前 5秒开始切,然后再进行第二次切割。

至于这个中间文件,我们知道它本身会有AV不同时开始的问题,是否需要重新封装一次再切第二次呢?我实验了一下:

Image

稍微解释一下,图中,raw.ts 就是原始视频,这次我换了个稍微长点的(18s)。我们先用 ffmpeg -ss 3 -i raw.ts -c copy 切出两个中间文件(没用 9-5=4 是因为想搞个正好在 GOP 中间的情景),分别封装为 .ts 和 .mp4;然后,再在此文件基础上再 ffmpeg -ss 6 -i intermediate.XX -c copy 一下,同样是保存为 mp4 和 ts,这样一共就有了四个文件。作为对比,我们同样把 raw.ts 直接封装成 raw.mp4,然后直接 -ss 9 到另外两个文件(known good)。

可以看到,虽然两个中间文件本身重现了问题,但是使用 MP4 的中间文件再切割时,完全不影响最后的结果,最后效果和直接先转整个 raw 到 MP4 再直接切9秒完全一样,包括时间戳。但是用 MPEGTS 做中间文件就不行了,所以还是别用了吧。这样就省事了,我们不需要再封装一下中间文件了。当然还是提示一下,如果不喜欢 MP4 这种带 negative TS + D flag 的,可以加那个 flag 来干掉。

总结

此次的文章废话有点多,来写个TL;DR。

问题:

  1. 当用 ffmpeg input seeking 切割 mpegts 视频时,会出现 A/V 没有切割到同一开始点的问题。
  2. 对于任意 input 格式,当 input seeking stream copy 以及输出封装为 mp4 时,ffmpeg会默认使用 edit list 来给部分 packets 赋予负 PTS + discarded flag,试图隐藏掉这些部分实现精确切割,可惜这一特性兼容性很差(不同播放器处理不同),尤其是在浏览器播放会导致音画不同步。

解决方案:

  1. 要修复1,要么将 input 视频整个重新 remux 成 mp4 或 mkv 再切,要么先粗切一次到 mp4/mkv,然后再细切至最终结果。
  2. 要修复2,可以在 output option 中加 -avoid_negative_ts make_zero

其他细节:

  1. 上述均为 input seeking。对于 stream copy,output seeking 虽然可以规避 mpegts带来的 seek 问题,但是:1) 解码到 ss 时间戳太慢;2) 无论怎么封装都会出现A/V切割点不一致的问题(因为A/V是分别从切割点后第一个key frame开始输出),强烈不推荐使用。
  2. transcode 的时候则相对无所谓,如果用 input seeking 则依然需要规避上述的问题1。

本文中出现的所有脚本(包括查看视频属性、画图、以及我个人用的一个切割视频的小工具)源代码以及两个测试用的文件都已经公布:点击这里查看

追记1:切割成有 edit list 的 mp4 的一个细节

在 Workaround 章节我有提到,只要把 raw 封装成 mp4,就万事大吉了,“论你怎么切,音视频起始点都一致的”,只不过如果输出是 mp4 会“FFMPEG 再次试图通过 edit list 同时藏视频和音频”。

另外我还对比了input seeking + stream copy时,不同 -ss 时产出的 mkv 的视频,可以看到视频都是向前取整GOP,然后音频和视频切割点基本一致,无论离得多远。

但是这个其实是不适用于 mp4 输出的!实际上,如果切成有 edit list 的 mp4,并不能总是切出音视频起始点一致的视频:

Image

可以看到,视频总是切到上一个整GOP没错,这点和MKV输出一致,但是音频实际上是总是多切且只多切一秒,而不是一定和视频一致。我上面得出那个结论是因为我恰好选择了在GOP前一秒处切割囧。虽然对于支持 edit list 的播放器来说,这都没差,因为多的部分都加了D flag,但是别忘了我们的根本目的就是规避音视频不等长的情况来增强兼容性。所以……还是老老实实用 -avoid_negative 吧。这个出来的结果和 mkv 是完全一致的。

Repo 也更新了相关代码。

Vimeo API HLS 格式的一个小bug

短文。最近 Vimeo 稍微改了下其 HLS 格式的实现方式,但是带来了一个很 tricky 的 bug。

以神秘代码 860577139 的视频为例,其 API 提供的HLS的master.m3u8的地址是:

https://40vod-adaptive.akamaized.net/exp=1694254738~acl=%2F08d39511-13b0-4fa6-94c6-e9d0633463cf%2F%2A~hmac=4b836a67bb8c342e537e8430364d7cdb751a48e5ad9dc47da41411f56fb4b2ce/08d39511-13b0-4fa6-94c6-e9d0633463cf/sep/video/31768320,655ce4bc,8f6e33ac,9c2f6240,eb34421a/audio/20c8c301/master.m3u8?query_string_ranges=1

可以看到是有 query_string_ranges=1 这个 flag。是什么意思呢?

原来,最近开始 vimeo 的 m3u8 的实际内容会变成 dash 那种,本质只有一个 mp4, 通过服务器切片的形式来实现的,而不是直接提前分割成.ts文件存储。

而具体实现又有两种,一种是用 query string 来指定分割的范围,一种是用 #EXT-X-BYTERANGE 来指定分割的范围,这个参数就由 query_string_ranges=1 来控制。

如果query_string_ranges=1,m3u8里就是形如:

../../../parcel/video/8f6e33ac.mp4?r=dXMtZWFzdDE%3D&range=925-4521259

如果query_string_ranges=0,就是:

#EXT-X-BYTERANGE:4520335@925
../../../parcel/video/8f6e33ac.mp4?r=dXMtZWFzdDE%3D

另外,这个参数理论上必须要配上?f=dash来使用,也就是类似于playlist.m3u8?f=dash&query_string_ranges=1.

如果你加了 query_string_ranges=1 在 master.m3u8,那么提供的所有 playlist.m3u8 就也会加上。

对于上面那个例子,如果你 curl 一下,会看到结果如下

>curl "https://40vod-adaptive.akamaized.net/exp=1694254738~acl=%2F08d39511-13b0-4fa6-94c6-e9d0633463cf%2F%2A~hmac=4b836a67bb8c342e537e8430364d7cdb751a48e5ad9dc47da41411f56fb4b2ce/08d39511-13b0-4fa6-94c6-e9d0633463cf/sep/video/31768320,655ce4bc,8f6e33ac,9c2f6240,eb34421a/audio/20c8c301/master.m3u8?query_string_ranges=1"
#EXTM3U
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-high",NAME="audio",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2",URI="../../../../audio/20c8c301/playlist.m3u8?query_string_ranges=1"

#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=3174153,AVERAGE-BANDWIDTH=2984000,RESOLUTION=1280x720,FRAME-RATE=30.000,CODECS="avc1.640020,mp4a.40.2",AUDIO="audio-high"
../../../31768320/playlist.m3u8?query_string_ranges=1
#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=532587,AVERAGE-BANDWIDTH=523000,RESOLUTION=426x240,FRAME-RATE=30.000,CODECS="avc1.640015,mp4a.40.2",AUDIO="audio-high"
../../../655ce4bc/playlist.m3u8?query_string_ranges=1
#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=6155410,AVERAGE-BANDWIDTH=5784000,RESOLUTION=1920x1080,FRAME-RATE=30.000,CODECS="avc1.64002A,mp4a.40.2",AUDIO="audio-high"
../../../8f6e33ac/playlist.m3u8?query_string_ranges=1
#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=972778,AVERAGE-BANDWIDTH=958000,RESOLUTION=640x360,FRAME-RATE=30.000,CODECS="avc1.64001E,mp4a.40.2",AUDIO="audio-high"
../../../9c2f6240/playlist.m3u8?query_string_ranges=1
#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=1863832,AVERAGE-BANDWIDTH=1825000,RESOLUTION=960x540,FRAME-RATE=30.000,CODECS="avc1.64001F,mp4a.40.2",AUDIO="audio-high"
../../../eb34421a/playlist.m3u8?query_string_ranges=1

可以看到 subplaylist的 URL 是并没有加 ?f=dash 的。这个BUG在其他的视频master.m3u8无法复现,比如这个从神秘代码 858924828 里来的 master.m3u8:

>curl "https://165vod-adaptive.akamaized.net/exp=1694254221~acl=%2Fecdace23-c08c-44e6-a924-e6ae2c126dcc%2F%2A~hmac=6dd75a90a8ce0abf28a34204b492e29998e4ff3b62a7a5e8cd2205a5aa9f0366/ecdace23-c08c-44e6-a924-e6ae2c126dcc/sep/video/0b4688b9,1f7b1fa1,3d2a8c99,8fdf82e0,e2164f29/audio/e88bde4a/master.m3u8?query_string_ranges=1"
#EXTM3U
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-high",NAME="Original",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",URI="../../../../audio/e88bde4a/playlist.m3u8?f=dash&query_string_ranges=1"

#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=3157139,AVERAGE-BANDWIDTH=2915000,RESOLUTION=1280x720,FRAME-RATE=30.000,CODECS="avc1.640020,mp4a.40.2",AUDIO="audio-high"
../../../0b4688b9/playlist.m3u8?f=dash&query_string_ranges=1
#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=1002861,AVERAGE-BANDWIDTH=950000,RESOLUTION=640x360,FRAME-RATE=30.000,CODECS="avc1.64001E,mp4a.40.2",AUDIO="audio-high"
../../../1f7b1fa1/playlist.m3u8?f=dash&query_string_ranges=1
#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=6171339,AVERAGE-BANDWIDTH=5637000,RESOLUTION=1920x1080,FRAME-RATE=30.000,CODECS="avc1.64002A,mp4a.40.2",AUDIO="audio-high"
../../../3d2a8c99/playlist.m3u8?f=dash&query_string_ranges=1
#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=544405,AVERAGE-BANDWIDTH=520000,RESOLUTION=426x240,FRAME-RATE=30.000,CODECS="avc1.640015,mp4a.40.2",AUDIO="audio-high"
../../../8fdf82e0/playlist.m3u8?f=dash&query_string_ranges=1
#EXT-X-STREAM-INF:CLOSED-CAPTIONS=NONE,BANDWIDTH=1924466,AVERAGE-BANDWIDTH=1808000,RESOLUTION=960x540,FRAME-RATE=30.000,CODECS="avc1.64001F,mp4a.40.2",AUDIO="audio-high"
../../../e2164f29/playlist.m3u8?f=dash&query_string_ranges=1

有加。

这个不加的副作用就是,实际出来的m3u8会变成这样:

>curl "https://40vod-adaptive.akamaized.net/exp=1694254638~acl=%2F08d39511-13b0-4fa6-94c6-e9d0633463cf%2F%2A~hmac=eb373980d5cc6270e0076d5a911abe52195e5def54dbd48e70eab2fc2293d88c/08d39511-13b0-4fa6-94c6-e9d0633463cf/sep/video/8f6e33ac/playlist.m3u8?query_string_ranges=1"
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TARGETDURATION:7
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.066667,
../../../parcel/video/8f6e33ac.mp4?r=dXMtZWFzdDE%3D
#EXTINF:6.066667,
../../../parcel/video/8f6e33ac.mp4?r=dXMtZWFzdDE%3D
#EXTINF:6.033333,
../../../parcel/video/8f6e33ac.mp4?r=dXMtZWFzdDE%3D
#EXTINF:6.066667,
../../../parcel/video/8f6e33ac.mp4?r=dXMtZWFzdDE%3D
#EXTINF:7.200000,
../../../parcel/video/8f6e33ac.mp4?r=dXMtZWFzdDE%3D
#EXT-X-ENDLIST

可以看到,引用了同一个mp4,但是并没有加上range或者 byte range,这样就会导致下载的时候,会重复下载同一个文件五次并尝试合并,导致各种问题。

如果我们无脑删除所有playlist.m3u8的query parameters,能否回归到之前的segment-N.ts呢?

对于上面这个 video_id = 8f6e33ac,是可以的:

>curl "https://40vod-adaptive.akamaized.net/exp=1694254638~acl=%2F08d39511-13b0-4fa6-94c6-e9d0633463cf%2F%2A~hmac=eb373980d5cc6270e0076d5a911abe52195e5def54dbd48e70eab2fc2293d88c/08d39511-13b0-4fa6-94c6-e9d0633463cf/sep/video/8f6e33ac/playlist.m3u8"                       
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TARGETDURATION:7
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.066667,
chop/segment-1.ts?r=dXMtZWFzdDE%3D
#EXTINF:6.066667,
chop/segment-2.ts?r=dXMtZWFzdDE%3D
#EXTINF:6.033333,
chop/segment-3.ts?r=dXMtZWFzdDE%3D
#EXTINF:6.066667,
chop/segment-4.ts?r=dXMtZWFzdDE%3D
#EXTINF:7.200000,
chop/segment-5.ts?r=dXMtZWFzdDE%3D
#EXT-X-ENDLIST

但是对于其他一些就不行了,比如同master.m3u8里的另一个video_id = 31768320:

>curl "https://40vod-adaptive.akamaized.net/exp=1694254638~acl=%2F08d39511-13b0-4fa6-94c6-e9d0633463cf%2F%2A~hmac=eb373980d5cc6270e0076d5a911abe52195e5def54dbd48e70eab2fc2293d88c/08d39511-13b0-4fa6-94c6-e9d0633463cf/sep/video/31768320/playlist.m3u8" 
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TARGETDURATION:7
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.066667,
../../../parcel/video/31768320.mp4?r=dXMtY2VudHJhbDE%3D
#EXTINF:6.066667,
../../../parcel/video/31768320.mp4?r=dXMtY2VudHJhbDE%3D
#EXTINF:6.033333,
../../../parcel/video/31768320.mp4?r=dXMtY2VudHJhbDE%3D
#EXTINF:6.066667,
../../../parcel/video/31768320.mp4?r=dXMtY2VudHJhbDE%3D
#EXTINF:7.200000,
../../../parcel/video/31768320.mp4?r=dXMtY2VudHJhbDE%3D
#EXT-X-ENDLIST

上面说到的第二个播放列表里的所有视频则都不行。

事实上,这两者应该是有关联的:正是因为第一个这个master.m3u8里存在一个有旧式segment-N.ts的格式,才导致了他不会给playlist加f=dash。

更奇特的是,第一个视频里其他几个CDN的m3u8地址,甚至是同一个akamaized,仅仅是换成 http:

fastly_skyfire:
https://skyfire.vimeocdn.com/1694254738-0x623058db7f795c5c7779df6178300aa70387dfb4/08d39511-13b0-4fa6-94c6-e9d0633463cf/sep/video/31768320,655ce4bc,8f6e33ac,9c2f6240,eb34421a/audio/20c8c301/master.m3u8?query_string_ranges=1
google_mediacdn:
https://cme-media.vimeocdn.com/08d39511-13b0-4fa6-94c6-e9d0633463cf/edge-cache-token=Expires=1694254738&KeyName=media-cdn-key&Signature=vLqpp9yNeByOiqs1vUCIQT1ZXVedajsPNfxP5S4hbdhxn0lNBLtgxJgktMMtcH2TmKbObeGVqGJVrn2IpjqWAw==/sep/video/31768320,655ce4bc,8f6e33ac,9c2f6240,eb34421a/audio/20c8c301/master.m3u8?query_string_ranges=1
akfire_interconnect_quic, with http:
http://40vod-adaptive.akamaized.net/exp=1694254738~acl=%2F08d39511-13b0-4fa6-94c6-e9d0633463cf%2F%2A~hmac=4b836a67bb8c342e537e8430364d7cdb751a48e5ad9dc47da41411f56fb4b2ce/08d39511-13b0-4fa6-94c6-e9d0633463cf/sep/video/31768320,655ce4bc,8f6e33ac,9c2f6240,eb34421a/audio/20c8c301/master.m3u8?query_string_ranges=1

都无法复现这个 master.m3u8 不给 playlist.m3u 加 f=dash 的 BUG,同理这些地址里的 8f6e33ac的 playlist.m3u8 也无法通过删掉所有 query 来获取旧式 m3u8。估计是缓存还没有清理吧。

要在用户侧修复这个罕见的BUG,需要做的是检测返回的playlist.m3u8地址,如果包含query_string_ranges=1但是没有f=dash,就手动加上。虽然这样会导致无法使用到旧式的m3u8,也就无法用 minyami 等工具下载,但是至少大部分常见工具(ffmpeg, streamlink)都不会有问题了。

ffmpeg静态图转视频

注意:本文部分内容之前发表在此文。但是经过补充后单独成章比较好,就移动到这边来了。

将一张图片转成视频的基本命令很简单:

ffmpeg -loop 1 -i {img} -t {dur} -vf format=yuv420p output.mp4

默认的fps是25,可以用-r指定成别的。这里重点注意-vf format=yuv420p部分,这个保证视频会被正确转换为最常见的YUV420格式,否则会使用YUV444格式。这个等效于-pix_fmt yuv420p

同理,如果需要其他格式(YUV444、YUV422之类),只需要替换成其他的pixel format即可。如果想要full range,可以使用yuvj420p

但是仅仅这样做,有两个问题。

选择的正确RGB->YUV转换矩阵

第一个问题是RGB->YUV的转换矩阵。ffmpeg默认是使用BT.601矩阵来进行这个转换的,如果你的输出视频是SD分辨率,这并没有问题。但是如果是HD视频,HD默认的标准是BT.709,所以这样就不对了。最明显的就是纯红色255, 0, 0会变成255, 25, 0。

解决办法,首先自然可以给输出结果显式加上BT.601的metadata:

-colorspace smpte170m

(虽然NTSC和PAL有各种微妙的区别(参见此文),但是这里用PAL的bt470bg也可以,因为这单说矩阵,这俩实际上是一样的。)

但是,不推荐这种做法。有的滤镜/渲染器根本无视元数据。HD视频还是老老实实用BT.709就好。所以最好的办法是进行正确的BT.709转换。

ffmpeg有N个video filter可以实现这个,最常见的是scale

ffmpeg -loop 1 -i {img} -vf scale=out_color_matrix=bt709,format=yuv420p -color_primaries 1 -color_trc 1 -colorspace 1 -t {dur} output.mp4

其中-vf scale=out_color_matrix=bt709部分是把图片转换成BT.709(Rec. 709的另外一个名称)。因为我们还有个format的vf,直接把两者用逗号串起来(filter chain)。

后面的-color_primaries 1 -color_trc 1 -colorspace 1的是在元数据里标注。如果用x264编码(默认如此),也可以直接用-x264opts colormatrix=bt709。当然,如上所述,如果是HD视频可以略去不写(不推荐)。

除了-vf scale,还可以用:

  • -vf colormatrix=bt601:bt709,format=yuv420p:从命令来看,感觉实质是先转601再转709。
  • -vf colorspace=iall=bt601-6-625:all=bt709:format=yuv420p :同上。另外注意,这里的formatcolorspace这个vf的一部分(冒号分割而非逗号),而不是再调用format。 呃,这个有点问题,实际上你要只转换matrix不动Primaries和Transfer function,否则红色会色偏。太麻烦不推荐使用了。如果感兴趣的话,正确的命令是:
  • -vf colorspace=iprimaries=bt709:primaries=bt709:ispace=smpte170m:space=bt709:itrc=bt709:trc=bt709:format=yuv420p
  • -vf zscale=matrix=709:r=limited,format=yuv420p:这是一个比较新的filter,来自zlib21-08-08更新:在本文写成时,如果用yuv420p,会自动imply limited range(同理是yuvj420pfull range)。但是最新版本的ffmpeg下的zlib已经不是这样(疑似是因为这个改动)。所以,请显式加上limit range的参数r=limited来保证产出的视频是支持最广的limited range视频。

几个讨论可以参见这帖这帖

swscale的颜色误差bug

我之前一直是使用上述的-vf scale来转的,但是总是发现颜色有点偏——白色(255, 255, 255)会变成略微发黄的颜色(252, 255, 252),我以为这仅仅是精度不足的缘故也没太在意。直到有一天无意发现,如果我先将输入的BMP图片另存为PNG格式,就不会发生偏色。这完全不make sense!于是我报到了ffmpeg

经过快2个月无人问津后,我忍不住去ffmpeg的emaillist发了个帖,果然引来了玉——除了一些workaround之外(下述),最重要的是有人指向了真正的bug:#979

简单来说,swscale这个库(libswscale)——包括scale滤镜——有一个奇怪的bug:从bgr24转换为YUV会有色差,但是rgb24就不会(两者应可以无损转换)。上面BMP和PNG结果不同也是因为PNG用的是rgb24的pixel format,而BMP是bgr24。

既然知道了问题所在,我们只需要先转换一次即可,把上面的vf前再串一个format

-vf format=rgb24,scale=out_color_matrix=bt709,format=yuv420p

就可以啦!那么上面提到的其他几种转换滤镜,有没有同样的问题呢?

  • colormatrix:同样的bug,这个filter应该也是基于libswscale。
  • colorspace:如果你使用上述的方式,使用colorspace内置的format参数来转换成yuv420p而不是串一个format vf,可以避免这个bug。
  • zscale:无此bug。

另外,这个bug还可以通过添加scale的flag,accurate_rnd(精确rounding)来修复(这里还加上了另外一个增加精度的flag,full_chroma_int,不过这里accurate_rnd其实就够):

-vf scale=out_color_matrix=bt709:flags=full_chroma_int+accurate_rnd,format=yuv420p

当然,这并不是说这个bug仅仅是精度的问题:否则无法解释为什么rgb24就无问题。

另外,colormatrix之类的vf虽然没有flags参数,但是你可以增加-sws_flags accurate_rnd,也可以修复问题。

浏览器对full range视频的支持

为什么会去折腾full range video这个小众的东西?这次还真不是我闲的蛋疼,事实是我发现很多Twitch直播已经开始用full range了(例子有现在火热进行中的Supermajor(PGL)和之前的Epicenter),然后用Firefox看就发现颜色不对,对比度爆表。

之前有“控诉”过Firefox对视频的支持程度落后Chrome一个世纪,当时提出了几个Chorme支持、Firefox不支持的东西:

  • RGB视频
  • 4:4:4或者4:4:2视频
  • Full range视频
  • 对nV显卡的支持不好

当然,这几条还是对的,但是我今天发现Chrome对full range的支持也不是完美,所以还是客观起见,详细说明一下。

说到这个话题,不得不先重复一遍我之前在各种场合提过多次的所谓“对nV显卡的支持不好”具体啥意思。其实,就是在Win 7 + nVidia显卡的默认设置下,Firefox播放视频的色域(color range)会错误。这里的“错误”,严谨地说是limited range(就是99%的视频)不会正确拉伸到full range。

这个问题的产生原因是Win7不支持D3D11 DXVA,而Firefox没有对D3D9 DXVA进行优化。其实,之前Chrome也有这个bug,但是在我提出之后Chrome几个月后就修复了。这个问题的解决方案,除了换A/I GPU或者升级到Win 10这种废话,就是

  1. 软解视频,即把media.hardware-video-decoding.enabled设为false
  2. 修改nVidia控制面板的一个设置,从“通过视频播放器设置”改成手动设为full range:

QQ图片20180609164804

我之前一直是用2,倒是不会影响本地播放器的播放(本地播放器我本来就没开DXVA)。

现在说回full range视频的解码。先说Chrome那边:软解的情况下(设置chrome://flags/#disable-accelerated-video-decode 为Disabled),完美无缺。硬解就不太行了,会把视频强行进行一次伸张,导致颜色被clip。我发的bug ticket在此,这里有个视频可以测试:

当然,你也可以去上述的控制面板手动改成“limited”,来让他不伸张,所以正好得到正确的颜色,但是很显然这样又会毁掉所有的limited range的视频的颜色,所以还是老老实实软解吧。

Firefox除了硬解和Chrome有一样的问题以外,软解也不行——我发的ticket在此

基本内容就是这些了,顺便讲下上面那个视频是怎么造出来的。首先自然是ps里把那个图画出来,然后用FFMPEG做。上次已经提到了记得要加-vf scale=out_color_matrix=bt709来保证输出的YUV是BT.709的,那么full range怎么处理?我网上搜了下意外地发现相关信息相当少,一开始搜到一个什么-color_range 2(这个参数ffmpeg的documentation根本没提到啊喂),后来发现效果其实就是强行在元数据里塞个full range,结果视频的像素数值还都是16-235范围内的,出来就是个灰暗的视频囧。

最后在这贴搜到原来要用-pix_fmt yuvj420p才能输出0-255的视频。-color_range 2都不用加,出来的视频自动metatag都是full range了。我用的完整命令行如下:

ffmpeg -loop 1 -i colortest_hd.bmp -vf scale=out_color_matrix=bt709 -color_primaries 1 -color_trc 1 -colorspace 1 -t 30 -pix_fmt yuvj420p out_420_709_full.mp4

压制ゆうゆ演唱会视频小记

文章里的事情大概是几个月前的了其实,不过为了叙述方便还是按照时序讲。

之前从YouTube下载了这套非常罕见的ゆうゆ(岩井由紀子)的唯一演唱会(至少是唯一有视频记录的)《ボクらは元気なゆうゆ印》的全集。虽然画质不怎么地,但是能看到已经是万幸了。

将整套视频共7个part用youtube-dl下载完毕后,有几个问题导致欣赏起来不是很方便:

  1. 分段
  2. 视频比例不对,外加有没切割干净的黑边。考虑到该演唱会只发布在VHS上,应该是录屏的结果(外加ntsc各种奇怪的劳什子)。

于是自然就想到把这7段自行压制一下,整合成一个视频。

最先想到的自然是MKV无损拼接;但是随便试了下就放弃了:我发现这几个视频居然连分辨率都不一样——有的是360p,有的是480p——一开始是怀疑yotube-dl下载的问题,但是double check了之后发现Y2B上也是如此。也因此,导致在YouTube给出的编码格式都不一样:有的音频是Opus,有的是Vorbis。

切边

第二个想到的是megui。megui里切黑边很方便,有可视化,可以切的非常干净。最后生成对应的AVS脚本。至于比例问题,考虑到原始视频自然是4:3的,所以无脑把切剩的拉到640×480,目测比例没大问题:

crop.png
左:切割前 右:切割后

使用的参数是:

crop(2, 28, -14, -32)
LanczosResize(640,480) # Lanczos (Sharp)

但是切完之后,发现后续步骤没法进行了:megui本身并没有一个特别好用的视频合并工具。而且和大多数视频一样,这些视频有个问题:音频和视频的长度并不一样。就拿第一段为例,视频的长度为00:07:54.507000000,音频的则是00:07:55.021000000,足足错了快1秒!这在播放单独每个段落的时候自然没问题(超出的音频直接就掐掉了),但是如果贸然将每段单独转换并一一合并的话,会出现(第二段起)音频不断滞后或者提前的音画不同步问题。我在megui里找了半天,似乎没有办法简单地克服这个问题。我总不能自己找个音频编辑软件把每个音频都手动切割一下吧?另外,考虑到megui处理视频的时候,是把音频和视频分开处理的,这也不甚方便:每次压完之后都得手动合并一下音频视频流(我知道可以用脚本自动化,但是实在是费不起那个功夫)。

于是我在网上漫无目的地搜索,搜到了avidemux这个从名字到UI都略蠢的软件:

003.png

当初选这个软件是因为这个软件直接就有合并文件的功能(把N个一起拖进来),但是试了下就发现,依然有上面所述的音画不同步的问题——而且不同分辨率也是合成不了。

但是你别说,这玩意虽然bug奇多,但是功能还挺全。其最方便的是各种filter,和XnView之于图片一样,可以添加一堆依次适配。这对于我那几个360p的分段有奇效,因为我可以直接添加三个filter:第一个先拉大到640×480,第二个用之前提过的crop,第三个再拉到640×480。这样做(而不是直接在360p上crop再拉伸)的好处是可以保证能和480p那几个完全对齐。虽然我猜这些filter的原理也都是avs了,但是至少这界面人性化很多。所以,我决定就用这个软件替代megui来进行前面的crop+resize的工作。

对于输出的视频和音频编码,我视频选了ultra fast+-crf 12的超高质量(外加节省时间),音频选了copy,来最大可能地减少二次压缩的损失,因为后面反正还要合并。还是要吐槽下,这个软件确实很笨,没法导入导出设置,那些filter每次都要重选。还好只有7个,否则我怕不是要疯掉。

合并

于是经过烦人的点点点,我终于有了7个MKV文件,每个都是640×480分辨率,有视频流和音频流,无黑边比例对,就差合并了。这里要动终极武器ffmpeg了。ffmpeg有一篇很不错的合并视频的教程,不过前几章都是教你如何无损(不重编码)合并同编码视频文件的,可以跳过。我们这里要用的是“Concat filter”那个。这里是官方的范例:

ffmpeg -i input1.mp4 -i input2.webm \
-filter_complex "[0:v:0] [0:a:0] [1:v:0] [1:a:0] concat=n=2:v=1:a=1 [v] [a]" \
-map "[v]" -map "[a]" <encoding options> output.mkv

可以看到这玩意语法也相当啰嗦,不过还是那句话只有七个,所以我们就手写吧…结果如下:

ffmpeg -i 001_edit.mkv -i 002_edit.mkv -i 003_edit.mkv -i 004_edit.mkv -i 005_edit.mkv -i 006_edit.mkv -i 007_edit.mkv -filter_complex "[0:v:0] [0:a:0] [1:v:0] [1:a:0] [2:v:0] [2:a:0] [3:v:0] [3:a:0] [4:v:0] [4:a:0] [5:v:0] [5:a:0] [6:v:0] [6:a:0] concat=n=7:v=1:a=1 [v] [a]" -map "[v]" -map "[a]" -c:v libx264 -preset slower -crf 18 -profile:v high -level 5.0 -c:a libvorbis -qscale:a 6 result_final.mp4

这里,AVC我用了slower的preset,high@5.0的profile外加-crf 18,音频用了Vorbis可变编码率-qscale:a 6

压制耗时不长(毕竟只是一个标清的视频),大概1.6x的速度。检查结果,完美无瑕(当然,每段衔接处会顿卡,这个无法避免),前面说的音频滞后/不同步的问题完全没有,果然ffmpeg还是专业啊。

章节

对于演唱会类的视频,没有章节怎么能忍。之前我在整理CoCo的演唱会视频时已经研究过如何制作章节,虽然MKVToolNix似乎有逐个添加章节的功能,但是还是手动写XML来得方便。

制作起来倒也很简单了,就是繁琐些:自己看视频找节点,然后用g(PotPlayer快捷键)查看复制时间戳,然后填写到XML里就是。章节名称网上找了好几个都不全,我用的是这个(手动添加了个“yuuyu即兴创作”(Special: ゆうゆアドリブソング)的段落)。

结果:


<?xml version="1.0"?>
<Chapters>
<EditionEntry>
<ChapterAtom>
<ChapterTimeStart>00:0:00.000000000</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>Opening</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:00:31.266</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>アッというMAにMEっ!</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:03:46.312</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>25セントの満月</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:07:55.995</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>アラジンの魔法ビン</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:11:28.369</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>メトロポリス・ハネムーン</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:15:07.825</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>へへへのへ</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:19:04.265</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>はてなが咲いた</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:23:25.139</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>Special: ゆうゆアドリブソング</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:26:25.334</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>ついて行けない</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:15:07.825</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>へへへのへ</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:19:04.265</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>はてなが咲いた</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:29:37.354</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>爪を噛んでた</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:33:51.745</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>-3℃</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:37:32.261</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>Panic'n Roll</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:44:23.560</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>天使のボディーガード</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
<ChapterAtom>
<ChapterTimeStart>00:48:06.486</ChapterTimeStart>
<ChapterDisplay>
<ChapterString>マグネット・マジック (Encore)</ChapterString>
<ChapterLanguage>jpn</ChapterLanguage>
</ChapterDisplay>
</ChapterAtom>
</EditionEntry>
</Chapters>

view raw

chapter.xml

hosted with ❤ by GitHub

有了这个,用MKVToolNix合并进MKV里就是了。chapter的选项在Output里:

005

结果不用说,很完美。

关于视频本身

当我第一次看这个演唱会的时候,还是略显感伤的。作为小猫俱乐部里我的最爱,ゆうゆ的巅峰自然还是小猫俱乐部活跃的时期。当时在ゆうゆ小猫里是固定前排,要单曲有单曲,要组合更有人气爆棚的うしろゆびさされ組。和大多数偶像一样,在小猫俱乐部解散后,单飞的ゆうゆ人气一路下跌,最后在89年后直接停止了歌手活动专心做telent,但也就是在三四线游荡,最后撑到97年结婚彻底退出娱乐圈。不过比起许多80年代偶像现在徐娘半老还在辛苦地走穴赚钱,安心做家庭主妇大概也是一种幸福(ゆうゆ最后一次出现在公众眼界应该是02年的富士台FNS歌謡祭的小猫再聚首)。

这场ゆうゆ单飞后的首次(可能也是唯一)演唱会,虽然仅仅是小猫解散后不到一年的88年1月24日,却已经和组合时期的画风完全不同了,当然这大多是单飞以及年龄增长导致的路线选择问题,倒也没什么奇怪的。不过我个人对这个搞怪的路线不是很感冒,可能更怀念那个唱『夏休みは終わらない』那个元气小不点吧。不过这场收录了我最喜欢的一首,4单的c/w『爪を噛んでた』的现场版,光是这个就值回票价了。我单独压了一份发了Y2B:

分割方法:

ffmpeg -i 006_edit.mkv -ss 00:00:00 -t 00:04:13.940 -c:v libx264 -preset fast -crf 18 -c:a flac cut.mkv

缩略图自己P的,用了meiryo字体+投影。

另外值得一提的是本场演唱会的举办地点是中野サンプラザ,即著名的偶像圣地。后来比较有名的应该是早安系长期固定在此演出,不过我第一次知道还是从CoCo的演唱会里。

后记

成文之时随便搜了下,果然(为什么我要说果然)在我搞完这一切之后的几个月内就有人在Y2B直接放了一段的完整版……好吧唯一可以自我安慰的是他那个有水印不是么(笑)。