《再战视频渲染器:nVidia D3D11的BUG和应对方案》的小更新

短文。快速描述一下前文成文之后的一些变动。

直接concat HLS切片产生的TS用ffmpeg重封装后的视频,使用内置解码器播放拖进度条会卡顿

这个是最离奇的问题,而且我觉得99%的人都不会遇到,但是还是描述一下。这个问题和 PotPlayer 更新无关,因为用23年的旧版本也会复现。但是我只是在最近才注意到这个问题,因此我只能怀疑是显卡驱动什么的触发了这个bug了。

首先Pot复现需要的设置:只要用Pot全默认就好,或者更具体说只要使用内置的AVC解码器就会出现。如果用硬解,则更为明显。

出现问题的片源:这个则比较复杂。简单来说,就是对于HLS下载的ts切片,通过 binary concatenation 的方式合并(也是我最常用的方式),然后再用 ffmpeg 转一遍MP4或者MKV之后的文件。我经常这样处理我下载的 livestream,因为直接合并出来的TS文件本身播放时经常有奇怪的bug,转一遍mp4解千愁,连拖动进度条都会顺畅许多。其实mkv更好,但是mkv由于有时间戳精度的问题毫无意义的完美主义比较抗拒。其中许多视频由于HLS切片的方式,本身就有各种时间戳(TS)的问题,转换时ffmpeg会抱怨诸如:

[mpegts @ 000002a34ea91500] Invalid timestamps stream=1, pts=3060, dts=9000, size=148
[mpegts @ 000002a34ea91500] Invalid timestamps stream=1, pts=9000, dts=11970, size=113
[vost#0:0/copy @ 000002a34f474ac0] Invalid DTS: 9000 PTS: 3060, replacing by guess
[mpegts @ 000002a34ea91500] Invalid timestamps stream=1, pts=15030, dts=20970, size=116
[vost#0:0/copy @ 000002a34f474ac0] Invalid DTS: 11970 PTS: 9000, replacing by guess
[mpegts @ 000002a34ea91500] Invalid timestamps stream=1, pts=20970, dts=23940, size=106
[vost#0:0/copy @ 000002a34f474ac0] Invalid DTS: 20970 PTS: 15030, replacing by guess
[vost#0:0/copy @ 000002a34f474ac0] Invalid DTS: 23940 PTS: 20970, replacing by guess
[mpegts @ 000002a34ea91500] Invalid timestamps stream=1, pts=27000, dts=32940, size=114
[mpegts @ 000002a34ea91500] Invalid timestamps stream=1, pts=33030, dts=36000, size=122
[vost#0:0/copy @ 000002a34f474ac0] Invalid DTS: 32940 PTS: 27000, replacing by guess
[mpegts @ 000002a34ea91500] Invalid timestamps stream=1, pts=39060, dts=45000, size=115
[vost#0:0/copy @ 000002a34f474ac0] Invalid DTS: 36000 PTS: 33030, replacing by guess
[mpegts @ 000002a34ea91500] Invalid timestamps stream=1, pts=45000, dts=47970, size=113
[vost#0:0/copy @ 000002a34f474ac0] Invalid DTS: 45000 PTS: 39060, replacing by guess

云云。转换出来的文件自然也会有dts/pts不太连续的问题:

可以看到,这个文件不但PTS不连续,甚至会有重复的 packets;比如0.066秒就有两个。片源(eplus streaming+)估计切片的方式有点不标准,切割点前后估计有重复的数据包。事实上,如果用 mkvmerge transmux 一次,体积可以缩小27%!

但是正常来说,即使是这样的文件,也不是不影响正常播放的。

但是这次的问题恰恰就出在这样的文件:如果用Pot播放并快速地在进度条上seek,会发现每次seek完(关键帧seek)的前100毫秒左右播放时会出现卡顿、慢动作、快动作等反常现象。

另外注意制作测试视频时,只要使用 ffmpeg -c copy,不管是输出mp4还是mkv都会有问题。但是原始的.ts反而不会,或者ffmpeg输出也是.ts也不会。用 mkvmerge 输出 mkv 也不会。

如果改解码器为LAV,或者使用内置,但是修改为这个“System MFT Decoder”:

此BUG都会消失。而且这个bug只有在片源比较长(比如2hr+)的时候才会很明显,从中间切十几分钟几乎感知不出来,所以也很难做一个最小重现的示例片源。

但是这个“System MFT Decoder”又会有一个其他的bug,就是之前 Pot 其实一直会有的,播放TS封装的H.264/Main时,会导致视频比例显示不对(1920x1080p的视频显示为1920×1084)。这个BUG在用默认的Built-in FFMPEG Decoder的时候已经修复,但是看上去没有修复几乎没有人用的System MFT Decoder的情况。

为了规避这个bug,我现在又全盘换成了LAV解码器了(参见上文LAV方案的2)。

但是用LAV解码器通到Pot,并且使用D3D11渲染器时,又有个另外的BUG:就是对于没有色彩信息bitstream tag的视频(也就是用mediainfo查看信息,看不到诸如“Color range : Limited”这样的信息的),在240305版本后,会默认被视为PC range(然而实际上99%的情况都是Limited range的),导致显示变灰。关于这个问题,我几个月前已经发邮件给作者了,但是他坚称这不是bug,而且“don’t comment any further on this matter, and if you think current is problem, just use another program”。我也是很无语,那如果要用LAV的时候,请改回使用EVR渲染器(或者默认的auto)吧。

Pot最新版使用D3D11解码+D3D11渲染10bit视频时又没有dithering了

之前说过 PotPlayer 使用 D3D11 硬解码 + D3D11 渲染器时,虽然依然有发绿的 bug,但是至少 10to8 是有 dither 的。

然而我在 231220 到 240509 这两个版本之间,PotPlayer 不知道修改了什么,导致这个 dither 没有了,于是瞬间有了巨明显的 banding 问题:

请看这个对比。左边是 231220 版本,右边是最新的 240827。图片经过对比度增强。可以看到人嘴附近,从之前的几乎看不到 banding 变成了非常明显的 banding。

对比两者的 OSD,可以看到区别在于之前的 BackBuffer 这一层是工作在BGRA,也就是说已经进行了有 dither 的10to8才到 BackBuffer;现在则是 RGB10A2。也就是说 10to8 的步骤发生在了这后面的BackBuffer 到 Display 之间;而这一步是没有 dither 的(也符合上一文的分析)。其具体原理我就说不清楚了,因为我根本不知道 BackBuffer 是个什么东西。

我不知道为什么会有这样的改动。介于开发作者回邮件的态度我也不是很想给他再写信了,反正我个人也不用这个组合。

顺便贴一下从我测试的最早的 bad 版本 240509 到最后一个 good 版本之间的 changelog:

[240509]
----------------------------------------------------------
- Fixed an issue where the screen did not appear when playing some MP4 files
- Improved built-in D3D9 renderer stability
- Fixed an issue where files in compressed files could not be played properly
- Fixed an issue where the screen was broken after navigation when playing certain MPEG 2 with DXVA

----------------------------------------------------------
[240305]
----------------------------------------------------------
+ Added NVIDIA RTX Video HDR function
+ Added ability to move files to playlist

- Fixed an issue where damaged WAV files could not be played
- Fixed an issue where the screen was broken when playing certain MPEG4 codecs
- Fixed an issue where the screen did not appear when playing certain WMV videos
- Fixed an issue where certain SRT protocols did not work
- Fixed an issue where an error occurred when playing certain TS files
- Fixed an issue where an error occurred in certain situations when playing some VP8/9 codecs.

nVidia 修复了缩放糊的BUG

上文提过最离大谱的是 nV D3D11 video processer 缩放的 bug,更具体地说就是如果缩小到小于原始大小50%(长度比?)时会出现整个画面完全糊掉重影的现象。

果然这么过分的 bug 还是相对比较快地修复了。在 nV 驱动 560.70 的 changelog 中,有这么一条:

  • [OBS] Scaling 10-bit HVEC or AV1 content down below 50% in viewport shows corruption for some configurations [4496901]

说的就是这个bug啦。

再战视频渲染器:nVidia D3D11的BUG和应对方案

没想到又要再搞这个话题(叹气)。

在上一篇(PotPlayer无MadVR设置方案),因为我当时使用一台没有独立显卡的电脑,完全跑不动 MadVR,所以我研究了下 MadVR 之外的渲染器的方案,很欣喜地发现 D3D11 的效果其实很不错,故用之。

最近,添了一张 nV 4系的显卡,理论上很简单,只要再回到上上文用 MadVR 的方案不就行了呗?呃,其实用习惯了D3D11,发现MadVR有些小问题还能挺烦的:

  1. 估计是因为MadVR“太重”的缘故,在用它的时候,拖拽视频进度条有时候还是不够流畅。尤其是反向拖动的时候。实话说并不是很严重,一般来说你拖动前几次都非常流畅,但是如果你狂拖进度条几十次,前后来回(一般是找某个场景的时候),就会出现卡顿现象。
  2. 在开启反交错(双倍帧率)的时候,无论是LAV中开启还是Pot开启还是MadVR开启,在暂停的时候都会跳帧(画面往回跳一帧)。这个真的是个非常非常小的问题,但是强迫症很难忍受。

另外,每次放个高清视频动辄50%的GPU占用率感觉还是挺没必要的,不够环保。

那有人会问了,好吧,那你直接换回你上文说的D3D11渲染器的方案不就行了?没错,我就是这么干的。结果一干就出事了:nVidia GPU下的的D3D11,和iGPU的行为完全不一样,甚至有很多BUG!下面详述。

在开始之前,我先提一下另外一个渲染器,MPC Video Renderer(下称MPC-VR)。这个渲染器根据我的理解并不是MPC的一部分,但是是针对给MPC-BE使用为第一目标而开发的(反正他们的开发者都是那一堆人就是啦)。我第一次知道这个东西是之前搜索如何在视频播放器中调用nV的超分辨率功能时看到的。但是除了这个功能(和本文无关,后面附录谈一下)之外,这也是一个蛮优秀的渲染器,安装之后PotPlayer里也能调用(但是有坑,后谈)。

这个渲染器其实是一个很轻量的渲染器,其主要目的是方便你调用显卡自带的渲染器,再加上了一些很少的HDR相关和部分放缩算法调整,总体上而言和Pot用EVR-CP时,自带的一些设置选项差不多:

但是对于本文的目的就非常合适:非常便于我们测试各种DXVA2/D3D11相关的参数。

另外我们可以同时在MPC-BE和Pot中使用,这样可以控制变量,防止被一些PotPlayer自身引入的、而不是nVidia驱动的BUG干扰。而且其有极其详尽的OSD信息。

nVidia的D3D11视频处理器对于P010格式的BUG

要触发BUG,首先视频格式是 P010(一个10-bit的YUV格式,几乎所有的10-bit和HEVC和H.264解码后都是这个格式)。根据MPC-VR判断,这个错误其实发生在“D3D11 video processor”这一层。使用上图所示的默认设置就可以触发。如果用PotPlayer,最简单使用D3D11硬解+D3D11渲染器即可触发(不过还有一些其它条件)。

BUG1:整体颜色发绿

左为使用MPC-BE的默认渲染器(EVR-CP),右为Mad-VR

图片经过对比度、亮度加强。可以看到,右侧的使用MPC-VR明显有绿色的色调。

如果在MPC-VR的设置中进行以下任意修改:

  1. 去掉 Use Direct3D 11,也就是用 DXVA2(D3D9);
  2. 或在 DXVA2 and D3D11 video processor 中去掉P010/P016

这个BUG立刻就不见了。所以我的推测,这个BUG出现在 P010 转换到 R10G10B10A2_UNORM 这个YUV转RGB的过程中。因为如果用 D3D9,是转换成 A2R10G10B10 这个格式:

再放一张PotPlayer的绿图。作为对比的左边是使用 Intel iGPU 同样设置的结果。可以看到,颜色没有任何问题(但是有很严重的banding,这个下面单独说)。

左为使用Intel (正常),右为使用nVidia (发绿),其他设置完全一致

其实用Pot的时候(MPC-BE+MPC-VR不会),还有其他一种情况也会触发发绿BUG,就是使用D3D9解码+D3D9渲染器。但是使用D3D9解码+D3D11就不会:

左为D3D9+D3D11 (正常),右为D3D9+D3D9 (发绿)

虽然不是很确定,但通过观察 Renderer OSD里列出来的格式,我们可以揣测一下发生了什么。可以看到左边是从P010(被Pot自己?)先转成了RGB10A2才进的 Video Processor,所以规避了BUG;而右边则是很奇妙地直接P010一把梭转成了XRGB(一种4:4:4的RGB格式),可能这个过程中也触发了类似原理的变绿的BUG。

OK,所以为什么会这样?其实我很多年前就发现 nVidia 和视频相关的技术栈会有发绿的问题,当时是发现用 ShadowPlay 录制视频,有时候会发绿。虽然没有任何证据,但是我强烈怀疑这个bug和我之前在ffmpeg静态图转视频这篇blog里提过的、ffmpeg的swscale组件的一个有十几年的bgr->yuv颜色和rgb->yuv颜色不一致的BUG有关(虽然我们这里的 bug 是反向转换)。

更两人恼火的是我不知道应该去哪里汇报这个BUG。这种应该是得自己写一个不牵扯到视频渲染等一系列客户端软件layer、而是直接调用 Windows的 D3D11 API 的最小实现,再去nVidia Developers 相关论坛或者 bug tracker 汇报才会比较被重视,但是我实在没这个水平,更完全不懂C++,不知道从何下手。而且我想各种第三方渲染器的开发者应该早就知晓此问题了,但是也没有搜到比较任何相关的 documentation。比如我在MPC-VR的issues中发现了至少三个相关的问题:

https://github.com/Aleksoid1978/VideoRenderer/issues/48

这个完完全全就是这个BUG!开发者提到:

To convert from NV12 and P010 to RGB you use D3D11 VP. This conversion is controlled by the driver manufacturer and MicroSoft.
But you can disable NV12 and P010 options and convert with our shader.

v0lt

因此,提问者在关闭了 D3D11 的 Video Processor 选项(上述)之后就规避掉了bug,TA也就没有再深究。

https://github.com/Aleksoid1978/VideoRenderer/issues/98

这个虽然是HDR视频,但是我怀疑也是同样的问题,因为也是P010格式的。

https://github.com/Aleksoid1978/VideoRenderer/issues/22

这个其实是最有趣的:提报者(后来才知道,原来是某群群友)使用的其实并不是原生的 D3D11 VP,而是 MPC-VR 自带的 Shader 来做 Video process,但是居然出现了同样的bug!MPC-VR后面修复了这个BUG,理论上可以根据改动一窥端倪;但是我完全搞不懂是怎么修复的:因为并没有链接到对应的commit,根据评论中提供的2个分别反馈为bad和good的测试版本的commit hash,应该是这个diff——但是感觉完全没有相关的啊?!线索断了。

BUG2:Downscale时整个画面边糊出现重影

如果说上面那个BUG可能不是太明显,有的人估计看不出来的话,那么下面这个就非常离谱了。当使用 nVidia 的 D3D11 Video Processor(VP)进行 resize 时,如果是缩小,则整个画面会变得非常模糊且出现重影,这点在比较锐利的线条尤其是硬字幕的时候非常明显:

我相信不瞎的应该都能看出来吧!要规避这个BUG,除了上面的说的完全禁用D3D11 VP的办法之外,也可以在MPC-VR中单独关闭“Use for reszing“,或者在Pot里把 resizer 改成其他任意的。再次强调,这个BUG依然是只有P010格式的视频会触发。

实话说,很难相信这么明显的bug会一直没人修复!有点怀疑是不是最近的驱动才引入这个bug的。我又去MPC-VR的repo瞅了下,确实有两个汇报(第一个第二个),都是比较近期的。

再测 nVidia 语境下的 D3D11 渲染器的质量

OK,BUG说完,让我们来回到这一切折腾的起因——渲染器的质量。先回顾一下:

  1. MadVR质量最好/最可控,但是太重,在Pot调用时连带还有一些UX上的小毛病;
  2. 之前惊喜发现 D3D11 video renderer (Intel) 的效果其实非常好,尤其是缩放锐度和 halo 都很不赖,故用之。不过,10bit视频是直接截取,所以有非常明显的 banding 的问题,所以我们采用一些措施来尽量软解,靠前面的解码器部分来给我们dither到8bit再渲染(如果硬解会 P010 直通到 renderer)。主要的难点是Pot很弱智的内置解码器HEVC强制硬解,需要调用LAV来克服。

所以我们现在需要重新调研一下 nVidia 的D3D11(以及D3D9)在缩放和10 to 8这两个重点上的表现,毕竟已经知道了和 Intel 完全不同。当然还有 HDR tone-mapping 的问题。

不过我们先试试MPC-VR这款不错的渲染器在Pot上表现如何。如果不错,我们完全可以改用这个。很可惜,虽然没有上面提到的MadVR那些UX上的小bug,但是有个更离谱的:在调整播放窗口大小(包括切到全屏)时,会非常的卡,外加闪烁。同样,这只是个一个非常小的问题,但是我表示不能接受,故放弃。

缩放

OK,那么我们就来比较最常见的4:2:0 8-bit的1080p视频缩放到1440p的表现吧:

呃,这一比就比较尴尬了——nVidia的渲染效果比Intel差了不是一点半点。锐度不如而且 ringing 更大。

D3D9(DXVA2)和基于D3D9的EVR则更差,整体又多糊了一档;而且还有很奇怪的图像整体向左偏移约1像素的问题(假设MadVR为 ground truth),不过这个倒是不影响观看。加强对比度后对比:

当然,我们可以在 Pot 选用其他的基于 shader 的 resizer,但是那些效果也都挺差的,毕竟都是一些比较基础的算法。

10 to 8

上文提过, Intel 的渲染器上文有提到过完全没有 dithering,所以如果在那里进行10转8,会出现非常严重的 banding。这点 nVidia 这边终于有改善了!如果用 D3D11 renderer,会有 ordered dithering,而且还是渲染像素级的(非原始分辨率级),效果很好:

但是!别忘了我们上面提到的发绿的BUG!(其实这图里都能看出来绿了。)所以说,这个我们其实还是享受不到,还是得老老实实把 P010的解码设置为软解,让解码器输出NV12(也就是已经降过位深),然后再给 D3D11 VP/VR。

那么在nV下,其他几款渲染器效果如何呢?直接上结论,结合上面的BUG一起说:

  • D3D9解码+D3D9渲染:发绿,downscale重影,banding
  • D3D11解码+D3D11渲染:发绿,downscale重影
  • D3D9解码+EVR-VP渲染:完全OK!

怪了,说好的 EVR-CP 是基于 D3D9 的呢,怎么现在居然有 dithering 了?虽然不是工作在渲染分辨率(而是视频分辨率),但是效果完全OK啊!

从 OSD 可以看到其格式流程是 P010 先到 A2RGB10 进 Mixer,然后在 RGB 空间 dither 到 XRGB 进行后面的步骤。

也就是说,如果不是缩放质量太差,我们甚至可以换回 EVR-VP 了。

另外还有一点,上文提到修改内置解码器设置中,把 HEVC 的解码器改成 ffmpeg.dll 来强制软解,很可惜现在已经没有这个选项了!所以说现在没有任何办法可以关闭 Pot 对 HEVC 的硬解(也就是会导致10-bit输出 P010 导致 BUG),所以 HEVC 必须得调用外部的 LAV。

所以,最后下来,我的配置其实和之前差不多:

Pot 内置解码器设置中不开启硬解(但是先勾选一下然后勾选下面的D3D11后再取消以防万一:

对于HEVC,手动设置LAV为编码器,来规避P010直通D3D11 renderer导致的BUG:

使用内置解码器的时候(所有非HEVC的格式),其实都等价于会自动使用转换滤镜。对于调用LAV的情况(HEVC),开不开出来的结果对于D3D11渲染器其实都没差,我就开了。

不过要注意,如果 LAV 勾选了 P010、且开启转换滤镜的时候,LAV播放10-bit 会出P010给D3D11,导致触发 BUG。而且不管你在 Pot 的 Colorspaces 那里怎么设置都不行。如果真的不想开转换滤镜,那一定要进 LAV 把 P010 输出取消勾选。这点我们下面详细总结里会再赘述一遍。

BT.2020/HDR

设置完后,我们再去验证一下几个非标情况的播放结果。首先是 HDR——其实这个东西我也不是很熟,纯粹靠自己瞎看了。说错了请指教。

因为我的屏幕是SDR,所以需要tone mapping到 SDR。如果用MPC-BE先做个简单测试,会发现播放的时候其实MPC-VR端收到的 Transfer Function 变成了BT.709了(但是 Matrix 和 Primaries 还是 BT.2020):

MPC-VR OSD

EVP-CP的话则是显示了两个BT.709,搞不清楚哪个是哪个。

EVR-CP OSD

这个转换是完全不可控的,似乎也没有任何选项,无论硬解软解都会转换。当然我也不是想折腾,毕竟颜色是对的就行了。

而如果你用 MadVR,则是显示了两个BT.2020:

而且颜色是错的,你需要单独设置MadVR显示器校准那边才能把颜色给整对。而且MadVR那边的设置非常迷惑,我之前提过一次

(题外话,在MPC-VR的repo里有搜到有人说 VR 的 OSD 显示的 Primaries 和 Matrix 是和 MediaInfo 正好反了但是开发者不置可否,介于这三个东西经常有各种奇怪的别名,我不好说也不深究了。)

同理,用 Pot 调用各种渲染器也有这个类似的操作,但是有的时候没有 MPC-BE 那么灵光。比如,如果用LAV 输出 NV12(输出 P010 同理)然后喂给 MPC-VR:

可以看到和 MPC-BE 不同,这里 MPC-VR 是收到了 BT.2020 transfer function。然后出来的颜色也不对。虽然 MPC-VR 里有转换 SDR 的选项:

但是我折腾了一万年也没整明白怎么能触发他(触发了的话 OSD 中应该会有一行后处理:转换到 SDR 云云)。不过,如果我把 HEVC 的解码器改回内置(并被 Pot 强制硬解),则一切就又正常了,和 MPC-BE一致:

搞不懂啊搞不懂。还好,用 D3D11 renderer 的话没这毛病,无论什么解码器,都可以正常转 SDR。

另外有个细节,如果 resizer 选的是 auto,这里这个测试用的 4K BT.2020 视频无论解码器用的啥(Pot 自带硬解,LAV 各种格式输出,etc.),resizer都会变成 Texture Bilinear 而不是一般常见的 D3D11 Video Processor,也就是类似 EVR-CP 的画质。当然你可以手动选回去(我有想是不是因为视频是4K的缘故,但是另外找了一个 4K BT.709 的视频,没触发)。

Full range, 4:2:2, 4:4:4 等各种情况总结

还是直接先上表吧!

这次和上次的比稍微复杂了点,多加了几列,有两列还调换了下顺序,如果要对比请注意。标注思路还是一样:绿色 good,黄色可忍,红色不能忍。EVR Vista 啥的那个就不测试了,反正没人会用到。

我上次的表格没有区分是否勾选Pot的视频“转换滤镜”,后来发现这个对于LAV的还是有些区别的,故加上。

第四列那个再罗嗦一次,就是这个选项,名字太长了:

中文是叫:

下称“直接转换输出色彩空间”。其大概意思就是,如果条件允许绕过Pot自带的转换滤镜,直接从解码器通到渲染器。因此,3、4列也不是所有组合都会有区别,简单来说:

  • 使用内置解码器时,相当于始终启用了“转换滤镜”。实际上是否能跳过,取决于“直接转换输出色彩空间”是否启用。
  • 使用 LAV 等外部解码器时,只有开启转换滤镜时,开启“直接转换输出色彩空间”才有意义。因为如果不开启转换滤镜,其实就相当于可以直接转换输出色彩空间。唯一的区别就是,如果使用开启转换滤镜+直接转换输出色彩空间的组合时,Pot 不会接受LAV的P010输出为输入(所以 LAV 会变成NV12 喂 Pot),但是不开启转换滤镜则可以。

那么让我们仔细端详下这个表。

可以看到,D3D9对于 full range 的视频束手无策,总会clip,我们先一票否决。

使用内置解码器时:

对于422和444只能软解。如果不开启直接转换输出色彩空间,因为转换滤镜的缘故会用NV12,所以自然损失了不少 Chroma 空间的质量;即使开启,renderer最大也只吃422,所以444还是会损失。

对于视频本来就是8 bit YUV的,无论软硬解都OK,反正都是NV12。对于10-bit则比较tricky。如果是软解(H264的情况),内置的 ffmpeg 解码器会帮你 dithering 后 NV12 输出,除非开启了直接转换输出色彩空间,则会直通 P010。而硬解怎么搞都是 P010 ——别忘了,如果是 HEVC,Pot 的内置解码器是强制硬解的。

如上文所述,我们的渲染器碰到 P010,EVR-CP 是很OK的,D3D9、D3D11 都有严重的问题。所以如果和我一样要用 D3D11,就得不开启直接转换输出色彩空间来保证软解时输出正确。至于硬解的情况?我们直接选择 HEVC 的解码器为 LAV 来解决。

再来看看LAV:

基本来说,使用“禁用转换滤镜”和”启用转换滤镜+同时开启直接转换输出色彩空间”这两个组合都可以完美处理所有格式:对于 10-bit,如果是 D3D11 渲染器的情况,要手动取消掉 LAV 里的 P010 输出,在 LAV 中直接 dither 到 NV12。

如果使用转换滤镜但是却不开启直接转换输出色彩空间,因为Pot的转换滤镜只能工作在 NV12,这两种色彩空间,所以即使是10-bit 视频,也是会直接找 LAV 要 NV12,即使你 LAV 没有取消 P010 输出。所以很OK。但是副作用是,无法正确处理442和444的视频,这个和上面原因类似,就不赘述了。

介于我们想用 D3D11 渲染器,然后还要应对 P010 的BUG,我们其实有以下几种选择:

  1. 全局使用 LAV + 禁用转换滤镜 + LAV 中禁用 P010:所有格式都OK。缺点是如果其他情况调用LAV时无法用 P010。
  2. 全局使用 LAV + 转化滤镜 + 直接转换输出色彩空间:所有格式都OK。
  3. AVC使用内置(软解),HEVC 使用 LAV,同时禁用“直接转换输出色彩空间”防止内置软解直通 P010:422、444效果差。

理论上自然应该用2,但是因为某些很纠结的原因(主要是用内置滤镜比较流畅),我实际上是用的3……呃。我就是有毛病我承认。

再看看默认的 EVR-CP:

其中真·默认设置的是倒数第二行。可以看到,效果还是挺不错的(和我改了半天的结果基本一样)。

也就是说,如果不在意缩放画质(这个其实也可以修改算法来稍微改善),Pot的默认设置现在已经完全可以用了,因为实际上他现在已经解决了 banding (or lack of dithering) 这一大痛点。我突然感觉到一阵空虚:我干嘛要折腾这个!

附录:nVidia RTX Video Super Resolution

顺便提一嘴这个(下称VSR)。毕竟我最开始折腾有这个的因素。目前为止支持这个的播放器还不是很多,Chrome 是原生支持的,什么都不需要设置。VLC有个专门的魔改版,但是这个版本有个非常恶性的bug,播放一切非16:9等非标准宽屏分辨率的视频时会显示比例错误

说个题外话:这个BUG已经提出了9个月了,但是根本没人修复。对于这种大型知名开源项目,经常看到这种核心功能还算维护的OK、但是稍微非核心功能的、即使是严重bug也没人修复的窘状。但是与之相反,有些明明理论上规模相当的项目,比如MPV,就有非常充裕的核心开发者和路人在进行快速迭代。搞不懂这是什么原因,是项目太老不够吸引新人?还是PR审核太慢,久而久之就没有人愿意参与?(嘛,想起 ffmpeg 现在还得在 mailing list patch……)哦,对于 VLC 这个特例,丫的 repo 和报错网站(https://code.videolan.org/)居然需要审批才能注册账号,这能好吗?

对于 MPC-VR,已经很早就加入了对VSR的支持,勾选一个选项即可(必须得用D3D11,所以连带着上面各种BUG)。另外还有个fork据说是加强了对HDR的支持,不过我没试过。

至于Pot这边,虽然已经加上了选项:

(要启用D3D11渲染器才能选)但是我这边实际上并不好使,无法调用到 VSR。更离谱的是,如果我改用MPC-VR 渲染器,然后再里面勾选,在 Pot 里还是无法触发 VSR。所以我放弃了。(我有发邮件问作者,但是他说他没问题。)

效果方面,我只测试了开到4(最大):denoise 的强度还是蛮高,对于那种 compression artifact极多的超低分辨率视频效果还行:

但是看 720P 或者 1080P 之类的本身就不是很模糊的视频就油画感很强了,感觉没有必要,不如传统缩放算法。

哦对了,这个 VSR 对于小于 360P(以及大于 1080P?)的视频是无效的,这是 nV 那边写死的,不是播放器/渲染器的问题。

解压缩用 ANSI (GBK) 编码密码的 zip 文件

之前在某处下了个 zip 包,有汉字密码,试了下果然解压密码错误。我用了 WinRAR 和 7z 都不行。因为我的系统是英文,而且很多 zip 都有文件名用 ANSI 编码的问题,我大胆猜测是和密码的编码有关。

果然,随便找了台中文 Win10 系统的电脑测试下,就可以随便解压了。

但是,如何在英文 Win (系统编码: cp1252) 或者纯 Unicode 环境下(例如 *nix)解压这个压缩文件?我随便找了下,居然常见的解压软件或者 CLI 工具,例如 Linux 自带的 unzip,7z,WinRAR,居然全都不支持设定密码的编码或者直接 binary 输入密码?!这也太菜了吧。

使用 Python 处理

在 SO 系网站搜到这个问题,提到可以用 Python 自带的 zipfile 模块来解码,因为这个模块本身的 pwd 就是二进制输入。你自行随便怎么 encode 都行。这个思路是对的但是没想到 Python 的 unzip 根本不支持这个压缩包:

from zipfile import ZipFile

with ZipFile("XENOGLOSSIA.zip", "r", metadata_encoding="gb2312") as myzip:
    myzip.extractall(pwd="射命丸文".encode("gb2312"))

# NotImplementedError: That compression method is not supported

那么怎么检测具体一个压缩包的压缩方式呢?在 WinRAR 里其实就可以看到信息是 DEFLATE + AES:

Python 的 zipfile module 显然是支持 Deflate / store 的,那么不支持的应该就是 AES 了。

事实上我自己用 WinRAR 又重新压了一个果然也是 AES 无法解压,那么到底什么样的密码加密 zip Python支持?仔细定睛看了一下,WinRAR 在压缩时有个选项是

果然勾上之后生成的压缩包 zipfile 就可以解密了。

使用 pyzipper

我按图索骥,用这个报错信息搜索,很快就找到了一个第三方库叫 pyzipper,增加了对 AES encrypted zip files 的支持。试了下果然好用,解压文件本身没问题了!

但是这个库其实是 fork 了 Python 3.7 的 zipfile 然后修改的,所以代码很陈旧,不支持 pathlib 还是小问题,比较遗憾的的是它不支持 Python 3.11 才有的文件名编码指定的功能(也就是上述代码中出现的 metadata_encoding)。这个功能其实 WinRAR 等工具现在也都有了,但是他们没法处理这狗血的 GBK 编码的密码……

如果看一下源代码,其实也超简单,ZIP 有个 flag 可以指定是否是 Unicode 文件名 (_MASK_UTF_FILENAME),如果没有这个 flag 则默认使用 cp437 来编解码文件名。所以,可以简单修改这部分的 pyzipper 的源代码来 match 最新的 zipfile:

            if flags & _MASK_UTF_FILENAME:
                # UTF-8 file names extension
                filename = filename.decode('utf-8')
            else:
                # Historical ZIP filename encoding
                filename = filename.decode(self.metadata_encoding or 'cp437')

不过要去修改第三方库自然太不优雅了,我们自己脚本里写个后处理吧:

from pathlib import Path
import shutil

import pyzipper

def extract_encrypted_ANSI_zip(zipfile, password, encoding='gbk', create_new_folder=True):
    zipfile = Path(zipfile)

    output_dir = Path.cwd() / zipfile.stem if create_new_folder else Path.cwd()

    # create a temp folder to extract into, so we can fix the filenames before moving them to the output folder
    temp = Path('temp')
    while temp.exists():
        temp = temp.with_name(temp.stem + '_')

    temp.mkdir()

    with pyzipper.AESZipFile(str(zipfile), 'r', compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES) as extracted_zip:
        extracted_zip.extractall(str(temp), pwd=password.encode(encoding, errors='replace'))

    all_files = [f for f in temp.rglob('*') if f.is_file()]
    for f in all_files:
        relative_path = f.relative_to(temp)
        old_path = str(relative_path)
        new_path = old_path.encode('cp437', errors='replace').decode(encoding, errors='replace')
        if new_path != old_path:
            print(old_path, '-->', new_path)

        f2 = (output_dir / new_path)
        f2.parent.mkdir(parents=True, exist_ok=True)
        try:
            f.rename(f2)
        except FileExistsError:
            print(f2, 'already exists')

    shutil.rmtree(temp)

extract_encrypted_ANSI_zip('XENOGLOSSIA.zip', "射命丸文", 'gbk')

这套方案我已经补充到之前的 superuser 的答案中了。

主题已经解决,让我们来聊两个支线任务。

如何检测 zip 文件类型

前面说过 zipfile 无法处理 AES 加密的文件,那么如果没有 WinRAR,我们怎么知道它是 AES 加密的?

第一时间想到的自然是 unzip,但是我用 unzip 去分析:

$ unzip -lv XENOGLOSSIA.zip
Archive:  XENOGLOSSIA.zip
 Length   Method    Size  Cmpr    Date    Time   CRC-32   Name
--------  ------  ------- ---- ---------- ----- --------  ----
       0  Stored        0   0% 2023-09-16 21:37 00000000  XENOGLOSSIA/
 2010985  Unk:099 1972819   2% 2023-09-16 19:56 00000000  XENOGLOSSIA/img0001.jpg
 1585979  Unk:099 1557941   2% 2023-09-16 19:57 00000000  XENOGLOSSIA/img0002.jpg

他居然给我显示 unknown…… 随便搜了下搜到这个答案,说是“If you have a very new version of unzip available and it displays AES_WG in the Method column, you file is AES encrypted”。但是我的 unzip 已经是 6.00 最新了呀?

这个问题我在用 Linux 经常遇到,很多系统自带的命令行工具经常会有不 consistent 的行为,然后根本不知道无从下手也不知道怎么升级,有什么头绪吗?

Anyway,作者提出了另外一个方案,就是他自己写的工具 zipdetails,可以显示更详细的信息,也可以看到文件是 AES 加密的:

E4FB4D0 LOCAL HEADER #79      04034B50
E4FB4D4 Extract Zip Spec      33 '5.1'
E4FB4D5 Extract OS            00 'MS-DOS'
E4FB4D6 General Purpose Flag  0001
        [Bit  0]              1 'Encryption'
E4FB4D8 Compression Method    0063 'AES Encryption'
E4FB4DA Last Mod Time         57309EAB 'Sat Sep 16 19:53:22 2023'
E4FB4DE CRC                   00000000
E4FB4E2 Compressed Length     0000F7A2
E4FB4E6 Uncompressed Length   0001046F
E4FB4EA Filename Length       0015
E4FB4EC Extra Length          002B
E4FB4EE Filename              'XENOGLOSSIA/����5.jpg'
E4FB503 Extra ID #0001        7075 'up: Info-ZIP Unicode Path'
E4FB505   Length              001C
E4FB507   Version             01
E4FB508   NameCRC32           91103119
E4FB50C   UnicodeName         XENOGLOSSIA/封面5.jpg
E4FB523 Extra ID #0002        9901 'AES Encryption'
E4FB525   Length              0007
E4FB527   Vendor Version      0002 'AE-2'
E4FB529   Vendor ID           4541 'AE'
E4FB52B   Encryption Strength 03 '256-bit encryption key'
E4FB52C   Compression Method  0008 'Deflated'
E4FB52E AES Salt              DA 75 2F E1 51 36 C2 1A 4A 31 45 14 AE
                              CB 74 E4
E4FB53E AES Pwd Ver           F5 98
E4FB540 PAYLOAD
E50ACC6 AES Auth              9F 3F CC 5A 73 F5 2F 37 BB F2

ZIP 文件名的编码方式

通过使用这个工具,其实也顺便解决了另外一个支线任务:就是我之前就有发现,虽然这个压缩文件用 pyzipper 解压出来文件名是乱码(因为是 GBK 编码),但是在 WinRAR、7z 或者 unzip list 的时候都可以看到正确的文件名。当时就很好奇 why。

原因就在上面这里:这个压缩包其实每个文件的元数据里都有个 0x7075 的 extra field 叫做 Info-ZIP Unicode Path ;顾名思义,这个字段里提供了 Unicode 文件名。Winrar、unzip 等软件都能读取到它来替换掉有编码问题的文件名。但是 zipfile 亦或 pyzipper 就没有对其的支持了。

最后总结:哥们你 tm 别压缩成 zip 格式+中文密码了好吗!

哦对了,还有个笑话(彩蛋?)。

您猜我在这英文 Win10 系统里,用 WinRAR 生成一个带密码的 ZIP 压缩包,但是输入中文密码……会怎么着?

……

…………

嘿,您瞧,这密码=“密码”,变成了密码=“??”了,完全能正常解压呢!感情您就是简单一个 pwd.encode('cp1252', errors='replace') 是吧!

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:

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

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

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

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

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

这就和一个正常的视频没什么区别了。事实上,如果直接播放这个 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

这就是万恶之源,上文提到的那种有负 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

可以看到整个视频会有一个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

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

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

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

图中 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 也可以正确播放。

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:

可以看到 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 正负。

最后还是上张图:

另外还有一点,如果使用 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 的情况:

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

实战

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

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

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

稍微解释一下,图中,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,并不能总是切出音视频起始点一致的视频:

可以看到,视频总是切到上一个整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)都不会有问题了。