YouTube的画质选择指南(含Premium)

不知道什么时候养成了备份 YouTube 视频的习惯,至今也下了小五千了。

事实证明除了松鼠之外,还是稍有意义的,很多视频尤其是日本版权方的经常莫名其妙过一段时间就删除了。现在回想,确实也有个契机:当初看了フィロソフィーのダンス的《オール・ウィ・ニード・イズ・ラブストーリー》这首MV非常喜欢,但是没有保存。结果后来官方将其删除(可能是因为成员变动)后,现在这首MV居然全网都找不到高清版(是的,B站也没有。目前只能找到这个2年前网友上传的超低画质版)!这个仇我记住了,以后绝对不会忘了下载。

YouTube简介

既然要下载,那肯定要下载最高画质版。由于是全球最大视频网站,YouTube估计是被人研究的最彻底的。所以一般而言,用著名命令行下载工具youtube-dl或现在更流行的fork yt-dlp(下文都用这个)直接下载,应该默认就会下载最高画质了,对吧?很可惜,没有这么简单。

YouTube可能是主流视频网站中提供画质版本最多的。和大部分视频网站仅提供不同分辨率不同,YouTube的视频即使在同样分辨率有时也会提供多个版本。另外,在不同的平台(Web,Android,iOS等)有时也会提供不同的格式。顺便一提,YouTube算是最早使用音视频分开的网站,虽然现在为了向后兼容也会提供少许音视频合体的格式(最高画质720p),但是基本99%的情况都是音视频分开下载再本地合成。所幸YouTube每个格式都会有一个对应的唯一编号,倒也挺好区分。

哦顺便说一下,现在的主流视频网站对于用户上传的视频100%都是会再次压缩的,所以什么抓取原始画质早已经是不可能的老黄历了。原因也很好理解,由于视频技术之复杂,用户上传的视频可能有一亿种奇怪的奇怪或者非标准之处,显然只有再次压缩标准化才是唯一出路(当然,B站那种即使二压也会压出音画不同步、而且十几年了也没修好的技术水平就……)。

YouTube到底哪个格式画质好?

使用 yt-dlp的 -F 命令,可以很容易列出一个视频的所有格式:

对于最常见的1080p视频,视频轨方面YouTube就会提供137和248两个格式,分别是AVC(即H264)和VP9。图里还有616和614这两个来自iOS API的格式,这里我们先不讨论。一般而言,VP9作为更先进的格式,如果两者体积相仿、input相同(用户上传的原始视频),那么理论上应该是VP9画质更优。然而这只是理论上——实践上则完全不同。

首先要注意一个细节:图中的各种格式的size,有的是白字,有的是灰字带一个约号~。这里的白字的来源是http(s),所以体积是死的,写多少就是多少。灰字则是HLS (m3u8),并无法得知文件大小,所以是用m3u8里的名义码率推算的。但是!YouTube的m3u8其实是不写平均码率的,只写了“最大码率”,也就是这里的下一列里的数字。所以这里的灰字显示的filezie 100%都是错误的、偏大很多的。这点很重要。

前面说过其他变量相同VP9强于AVC,但是很显然这得是体积类似的前提下。但是这里248的体积是26.8M,AVC则有47.6M,足足大了70%多!你还能很有自信地说是前者好吗?如果对比一下的话(NN放缩200%):

可以看到AVC在细节方面把VP9秒得渣都不剩。而且VP9的整体颜色偏“脏”,色度channel的压缩明显非常狠,比较艳的红色等都变得很黯淡。当然,我知道有人反而喜欢VP9或者H265这种比较平滑/模糊的压缩风格(相对于H264的线条噪点压缩风格),所以也有一定的主观成分,但是我个人肯定会选择细节更多的AVC版本就是了。

YouTube的二次压缩现象

这个问题我在各种场所说过多次:

不过这里还是再赘述一下。当一个视频刚上传时,会有一个比较高的码率,画质也会好很多。但是,在过了一段时间之后,视频会被YouTube再次压缩。目前不知道这个“二压”发生的规律,不过可以想象是YouTube的服务器在有空闲资源时,就会对播放量比较大的视频进行如此操作,来节约流量。

这里需要说明一下以防误解:这里说的再次压缩是指YouTube从他服务器内部保存的raw再次进行压缩,并不是说他直接拿一次压缩的视频(也就是你能下到的)再次压缩(那样肯定画质只可能变差)。我们在后面对比版本的时候其实基本可以确定YouTube确实是从raw开始重新压的,因为“二压”的版本有时候会有一压版没有的细节。不过为了说起来方便,下面还是都直接简称再次压缩或者二次压缩了。

以这个视频

为例,其137版在二压前为48.8M(我之前有下载),二压后就只有34.3M了。

除了看大小,其实也有个很简单的方法判断。一般YouTube的视频下下来,元数据里都是没有“Writing library”的,但是二压后的AVC版本,会有:

Writing library                          : x264 core 155 r2901 7d0ff22

的字样。根据这个就很容易判断二压。VP9的格式也是会二压的,但是没有好的区分方式。不过基本上,如果二压所有格式都会被一起压。另外,有的视频很久也不会有二压。比如上面贴过的ピンキーフック的MV,至今也是非二压版。

注意这里说的“刚上传”是指的后台上传,有些视频虽然刚发布,比如这个视频在成文时才发布了10分钟,但是是早已经上传好了,所以已经是二压后的版本(对于137格式,你甚至可以看到压制时间,比如这个视频就是一个月前了。另外注意writing lib metadata的存在):

Title                                    : ISO Media file produced by Google Inc.
Writing library                          : x264 core 155 r2901 7d0ff22
Encoded date                             : 2023-06-15 23:52:49 UTC
Tagged date                              : 2023-06-15 23:52:49 UTC

1080P Premium画质

YouTube在几个月前,推出了面向Premium用户的所谓“1080p Premium”画质选项。最开始只登录iOS设备。这个选项目前只对最高视频为1080p的视频有效,也就是说4K或者8K的视频即使切成1080P画质也不会有这个选项。

我之前为了尝试这个画质专门开通了Premium的会员,结果我的iPad上无论如何都不能获取到这个选项,和客服扯皮了快一个月也无果(至今也不能!)。这几天,YouTube终于开始在网页版推送这个选项,这样测试起来就方便多了,也是为什么突然现在写这个文章的缘故。

不过,其实即使没有网页版,或者甚至没有Premium会员,其实都是可以获取到这个画质的:在最新版(也不新了,有几个月了)的yt-dlp,可以从iOS的API用游客身份直接获取到1080p Premium画质的格式,也就是616。也因为这个的原因,现在yt-dlp默认会抓取iOS的API,之前是不会的(需要通过--extractor-args "youtube:player_client=ios"手动加上)。所以上面第一张图里有616和614格式(614等价于 web 的 248,不重要)。

当然,现在网页版更新了之后,你可以给cookie(--cookies-from-browser chrome)来登录你的preimum账号,从而获取网页版API的1080p Premium画质格式:356。

这两个格式本身是没有区别的,只是封装不同(iOS是m3u8,web是直接https)。编码是VP9。码率上一般是比137/248高一些的。那么这个格式一定就是上位替代吗?不一定!

影响这个所谓的Premium画质“优越性”的最大因素其实就是上面说的,YouTube的二压现象。因为我早已经知道此现象,所以下载视频都是会趁早的,所以我现在的绝大部分YouTube视频都是二压前的,也就很容易和Premium对比画质。可以发现,码率上,Premium画质的体积经常小于压缩前的137版本。如果用肉眼对比,还是我之前的结论,AVC编码的一般细节更多。配合更高的码率,画质经常是优于616的。甚至有的时候,616的码率虽然比二压前的137高一些,但是画质上仍然不如。

另外,Premium画质的产生时间也并不是即时的。事实上,刚上传的新视频,比如撰文时的此视频,是没有Premium画质选项的。而恰好此视频目前是没有被二压的。所以我怀疑,Premium画质只有在视频被二压后,才会产生。但是目前还没有十足的证据(虽然已经在6、7个视频上复现),这个说法暂时为猜想,我以后会慢慢验证。

而且我有发现一个特例,就是Premium画质本身质量变化(下降)的情况。

我在23年5月16日,当时Premium画质才实装不久的时候,就通过yt-dlp的邪路下载了616的夏川椎菜 『ユエニ』Music Video这个视频。这个视频的137(当时,未二压)体积为48.8M,616的体积则为76.2M,还是大了蛮多的。我也没仔细对比画质,都保存了。

今天,我再去下载此视频,就会发现137的体积已经缩水成了34.3M,而616或356,居然也缩水了,变成了41.0M!

不过我觉得这个视频只是个特例。毕竟当时才刚加的Premium画质,可能谷歌那边产品还不成熟,后面改动过。而且这个视频是音雨那老套路,上传后先用 YouTube 自带的剪辑功能剪辑一次成short ver放出,等碟发售了再恢复给你看完整版。可能也有影响。总之,现在应该就像我上面说的一样,616只有在137二压之后才会出现,而616本身不会再被压了。

那么来对比一下(点击看大图,同样是用NN放缩到200%):

呃,其实这种东西side by side比较难比较,还是用图片查看器前后张翻动眼睛盯着像素比较明显。不过我还是大概说下:首先右上的二压版137基本一眼瞎,颜色尤其是红唇被毁的基本没法看了。相比之下616二压版猛一看观看也还是可以的。但是如果你仔细对比,就会发现其“平滑程度”非常高,基本都没啥细节了。后面背景光斑的锐度差也是非常明显。至于二压前的137和616,其实区别不大,616的锐度高一些,可能甚至有点ringing。但是欠码导致的线条噪点问题确实是616要好一些。

顺便一提,我对比的时候都是刻意不找关键帧对比,不知道这个 methodology 算好还是坏。

实战篇:yt-dlp的默认格式选择的问题和解决方案

先稍微总结下结论:如果能下到未二压的版本,那么对于1080P视频,137我认为是要强于616版本的,而248由于码率过低,直接不考虑。不过如果下载手速太慢没能下到未二压的版本,那么616比起二压137还是有巨大提升的。另外还是码率为王:如果616因为某些原因比二压前的137还高很多,那么还是有保存价值的。

那么,我们应该如何设置yt-dlp来达成这一目的呢?

yt-dlp有一套极其复杂的格式排序和选择方式,详细可以参见这里。自动选择最高分辨率这种简单的自然没问题,也不需要我们干涉。但是

yt-dlp全局默认为:

lang,quality,res,fps,hdr:12,vcodec:vp9.2,channels,acodec,size,br,asr,proto,ext,hasaud,source,id

youtube extractor 默认则稍加修改,为

quality, res, fps, hdr:12, source, vcodec:vp9.2, channels, acodec, lang, proto

两者合体后在下载YouTube时的默认就是:

hasvid, ie_pref, quality, res, fps, hdr:12(7), source, vcodec:vp9.2(10), channels, acodec, lang, proto, size, br, asr, vext, aext, hasaud, id

(有些细节区别属于内部实现的用不到的东西,不要在意。)

有这么几个问题:

  • 同等分辨率、fps下,优先选择VP9视频(vcodec:vp9.2)。音频轨也是优先opus(acodec)。
  • 对于YouTube,在更新了最新版后,其优先选择Premium画质(通过source排序关键词来实现)。
  • 另外早期 youtube-dl 还有个行为是优先选择码率较高的视频

这几点我们上面都针对地说过了:VP9不一定比AVC画质好(事实上如果去掉premium,几乎一定差);对于m3u8的源,yt-dlp无法获取真实码率,而m3u8的名义码率偏高,所以按码率排不靠谱;premium画质不一定更好。

我修改后的排序方式为:

-S "quality,res,fps,hdr:12,channels,size,br,asr"

其实主要就是去掉了按照编码排序的部分(vcodecacodec)和premium优先的部分(source),另外size这个关键词,是会把那种通过码率推算的体积(上文提到的灰字)给排到最后,所以一定不会优先于有真实体积的格式(https的)。我把它提前来保证优先选择体积最大的格式,虽然有一定风险,但是据我观察基本不会错过更好的格式。而且现在137和248都变成https(之前不是),所以不用太担心。

唯一有个缺陷就是因为我覆盖掉了source,所以默认不会再优先premium格式,而616这个可以白嫖的premium格式又没有真实体积参数,在我的设置下一定会优先级低于137。这里对我倒是无所谓啦,因为我真要下可以带cookie下356(有真实体积,所以可以参加size排序),但是如果有需求,就把source加回到hdr:12后面,size前面就是。

另外话有个参数也推荐,yt-dlp默认下载dash和HLS是可以用-N多线程下载切片的(我config一直有 -N 10),但是暂时不支持https的多线程。不过,YouTube extractor 有个专门的hack(是当初规避YouTube限速开发出来的),就是直接在http的query(不是header)加上range=xxx的参数,可以实现分段下载。这个开关现在还保留呢,可以通过--extractor-arg youtube:formats=dashy启用,实现对YouTube http源原生多线程下载(当然你也可以调用aria2)。

补记

在上文已经提到我一个猜想:生成 Premium 画质和二次压缩其他格式画质是同时发生的,并且提到了这个视频作为证据。现在,这个视频符合我们的猜测,已经提供了 Premium 画质并且被二压了。虽然多一个例子不能说明什么,但是我恰好保存了此视频的旧 info.json,可以和新的对比一下了。我简单处理了数据比较了下新旧都有的格式的大小:

可以看到,对于已知确切大小的(表格下半部分),几乎全都缩水了40%左右,包括我们最想要的137格式。至于上半部分的那些格式,本来就多是从iOS API扒出来的m3u8,上面已经说过这个nominal的比特率是最大比特率而非平均比特率所以没有太大意义。事实上让现在下载一个614格式,会发现其大小只有区区 160 MB 左右,也就是差不多 1284 kb/s,远没有 2843 kb/s 那么多。不过我没有下旧的二压前614,所以没法对比了。至于新增加的Premium的616格式,大小则是 423 MB,其实算是满高的码率,足足比旧137也高了1/3。但是画质真的有变好吗?

我还是之前那个结论:在线条的平滑程度上确实比137强(比如黄框处137就有奇怪的锯齿),但是细节真的杀了太多……看看红框部分的额头皮肤纹理和小胡子,完全都找不到了。

所以,还是老老实实下最速的低压缩率的137吧!

PotPlayer无MadVR设置方案

又来折腾这玩意了。其实之前的方案完全正常啦,但是最近组了个新电脑没买显卡,本来以为反正自己也不玩游戏够用了,没想到居然用MadVR随便播个东西就GPU占用100%:

直接卡到12fps!把所有的缩放算法全部改成最低端的Cubic也只能勉强到50多fps(显示器是75的),看来是没得救了。那让我们找个替代品吧。

面临的问题

如果有读过前文和前前文就应该知道,之所以用MadVR并不是我对画质有什么极致的追求,而是在很多基础的东西譬如10bit抖8、格式转换等上PotPlayer是非常的差,MadVR恰好可以简单地修正这些问题罢了。

具体系统性的细节就不再赘述,如果需要可以复习前面两篇文章。这里直接说我们要解决哪些问题。

其实对于最常见的,4:2:0、limited range、8bit的视频,PotPlayer开箱的默认配置也不是完全不能用。但是有以下三个特例需要处理:

  1. 10bit视频——显示器是8bit的,所以10需要抖到8,无抖动大量banding。10bit视频现在已经非常常见了,动画民间压制组几乎全都是用HEVC 10bit出片。所以这个dithering是必须要有的,否则白瞎了。
  2. full range视频——商业发行的影音产品不算太常见,但是直播等非常常见。
  3. 非4:2:0视频(例如4:2:2、4:4:4视频)——更少见,除了某些民间压制组会用,其他基本见不到。

例如,Pot默认的用EVR (Custom Preset) (下面简称EVR)这个渲染器的最大问题就是不能正常显示full range视频。无论用软解硬解还是外置LAV都不行:

所以我们直接pass。

另外,一个thumb rule是所有转换都应该只做必要的转换,比如如果本来是422,那就不要劣化成420再转RGB;本来是8bit也别转10bit,反之亦然。

PotPlayer近年来的改进以及10bit输出

在最早那篇文章提过,PotPlayer当年最大的问题是他内部默认使用YUY2这个4:2:2的格式来处理,所以常见的8bit 4:2:0格式会被Pot拉伸一次,而且默认还是极差的NN算法。现在,Pot修复了这个问题,基本正常情况都会用NV12、YV12之类的格式通到渲染器。单这一点就把其播放一般普通视频的画质提升了一个量级,完全达到了能用的水平。所以如果没有特殊需求(下述),这两个之前提过的选项可以用Auto不用动了:

不过为了以防万一,还是把下面的高质量也给勾上吧。

另外,在输出方面增加了10bit的输出的选项:

开启10bit输出后会尽量在解码器端输出为10bit,然后到renderer会转成A2RBGB10之类的10bit RGB格式。但是因为我显示器只有8bit,最后还是会banding成8bit(且和P010这类10bit YCbCr格式直接转RGBA的banding还不太一样)。这里自然不开启。而且这个10bit输出还有一些bug:

  • 用EVR,会发生奇怪的颜色反转。蓝色变成黄色
  • 用D3D9 native 解码再输出到D3D9 renderer,会出现一个奇怪的绿条在下面:

最糟糕的问题其实是8bit的视频会被拉成10bit(然后最后在display层面再降回去)——据我观察D3D11 renderer即使这样折腾一次也问题不大,但是D3D9 renderer整个画面会变得超级糊,一定要避免。

处理10bit视频

这里先明确几个前提:我们只追求抖动的有无,质量不关键。另外我是8bit屏幕,所以一定要10转8;如果是用的原生10bit,则自行保证最后renderer是10bit输出即可。

这个转换,可以在编码器阶段进行,也可以在renderer阶段进行。之前用MadVR的时候就是在renderer阶段进行,所以一定要把解码后的视频保持10bit的格式(一般为P010)输送到renderer。

不过现在不用MadVR了,我们应该在哪里进行效果好呢?经过一些基本的测试,结论如下:

首先,如果在硬解native里进行10转8,是没有dithering的,效果很差。

如果用LAV调用硬解,我测试中是发现用DXVA2 native或者 D3D11都会banding,DXVA2 copy-back和软解则无问题:

测试时强制了LAV用RGB输出,这样保证不会在接下来的任何步骤再修改。LAV自己的10转8是有dithering的。我的理解是,用了DXVA2和D3D11 native的话,LAV就完全放手了让他们来输出为NV12之类的的格式,自己仅负责转换为RGB的样子。

240105更新:上面的DXVA的测试结果仅限于 Intel CPU 带的 iGPU;我使用N卡测试时,即使使用native也是自带dithering的!

如果用Pot内置解码测试,默认是用D3D9,只有用D3D11的renderer的时候才会用D3D11的解码,不过可以手动修改。也可以用D3D9 copy-back和D3D11 copy-back。搭配MadVR的时候,这里无论选哪个都是可以直出P010的,也是我之前一直用的。顺便一提,我在这台电脑上用D3D9 native + madVR播放full range视频,会出一个很奇妙的问题:

可以看到色域虽然没有错误伸张、压缩,但是16/235外的被clip掉了。然而我用我的笔记本无法复现。不过无伤大雅,改成copy-back或者D3D11即可。

回到正题。既然我们不能用MadVR,我们可以用默认的EVR、D3D9 renderer或者D3D11的renderer。使用硬解的时候,基本都会直出10bit的P010给renderer。但是凡是在上述三个renderer里发生P010转RGBA(即10转8),也都会banding。开启10bit输出虽然可以正常转成RGB10,但是最后在display层也会转到8bit所以也是同理。一言以蔽之,不要在非MadVR之外的renderer这一层来进行10转8,因为没有dithering。

所以,如果要用LAV内置解码器,就一定要用软解或者硬解copy-back(copy-back的硬解基本在实践上和软解区别不大,应该也是用了ffmpeg来处理)来输出NV12,然后renderer直接转RGB就完事儿了。Pot默认设置不开硬解其实就是这样的。注意一点就是如果你开启了direct conversion (change default output color space) 这个选项的话,可能又变回P010输出坏事儿。

另外一个小细节:不能选D3D11 native解码+D3D9渲染。会强制给你改成D3D9。反之则可以。

总结一下:

  • 硬解直接10转8输出:banding
  • 无论软解硬解如果输出10bit,渲染器里10转8:banding
  • 所以要用:软件解码器或者硬解copy-back来完成10转8的过程。

软解的选择可以用Pot内置(即FFmpeg)、LAV(可以手选dithering的方式)。记住,Pot默认强制HEVC用硬解,即使不勾“Use DXVA”也是。可以通过修改这里为FFmpeg.dll来强行取消:

FFmpeg根据我的观察应该是用的ordered dithering。

这里来比较一下。先来一张banding的(所有的截图都是1080p片源1440p播放,然后截图后调整曲线来增加对比度,最后再NN放大到3x):

再比较一下dithering:

可以很明显看到两种dithering的区别。观感上来说其实都差不多,不过还是用random吧。中间的则是对比用的MadVR——MadVR dithering最好的地方在于他工作在全分辨率(也就是1440p),所以pattern极其小,可以说100%时肉眼完全看不出任何颗粒感。别的都是先生成dithering、然后再被放大,那自然效果远不如。不过,看片的时候还是OK的。

至于渲染器的选择在这里不影响,我们都是8bit输出了反正。

视频resize的表现

确定了10bit没问题,我们再回来看看经典8bit视频resize的效果。这里有点出乎我的意料:无论是用D3D默认的DXVA Video Processor,还是D3D11带的D3D11 Video Processor(Pot里选成Auto就好),效果都出奇的好,可以和说我MadVR拉满不相上下!

我的理论是,他这个不知道用了什么Intel的劳什子视频后处理科技,估计是在全屏分辨率级别搞了点锐化之类的?无论如何,至少看高锐度的东西效果很好,清晰,也没有太明显的ringing,远强于Cubic之类的了。我们尽量选用能调用Video Processor来resize的方案。顺便一提,EVR默认也是调用DXVA Video Processor来当resizer。

Full range、422/444视频

Full range视频不出意外地都没有问题,甚至EVR (Vista/.NET3)那个都没问题——只要别用EVR custom preset那个。

422/444视频则比较tricky。理想情况,当然是422就全程用YUY2,444就全程AYUV,不要出现被转化为420格式再喂给渲染器的现象,自降分辨率是大忌。

但是现实很骨感,这里面有好几个局限性:

  • Renderer只能接受部分格式输入
  • 某些格式用LAV+开启Pot转换滤镜时,Pot不收(虽然用内置的可以)
  • Pot的自动格式选择非常的弱

让我们先来个一览表:

这里面绿色是我们想要的,黄色的是非最佳的,红色的是无法接受的。这里面第三列就是前面提过那个开关:所谓的“direct conversion (auto change color space)”是开还是关。

简单概括下:如上面说的第三点,Pot如果把输出设为Auto,其实基本就是只会用NV12/YV12这种8bit/420的格式。唯一的例外是用硬解native的时候,会直通P010(10bit 420)到renderer(但是我们已经说过我们不想这样,因为非MadVR的renderer没有dithering)。所以,这样下来面对422/444的视频,都会被Pot给降低到420,bad。

这个问题可以通过这个开关来改善——开了之后,对于EVR,解码器会自动改用422和444的输出,对于D3D9,至少能修复422(但是444会crash)。D3D11则比较怪,即使开了开关,还是只能被喂420(或者RGB);我稍微查了下应该是支持至少422才对,可能是Pot的implementation有问题吧。

但是这个开关也有个副作用,就是用内置解码器的时候,会把10bit本来我们想要的8bit输出(抖动后)也给变成了10bit直通。

如果是用LAV,就很容易解决这个问题,LAV里把10bit的格式全部去掉,自然就只可能输出抖动后的8bit。

(这里有个疑似BUG:Pot的转换滤镜开启时,它并不收来自LAV的P010,如果你强制在LAV那边只勾选P010,Pot会直接强制自行关闭转换滤镜……嘛,纯粹是好奇罢了,毕竟我们的目的恰恰是LAV不要用10bit输出。)

另外,使用“LAV+EVR (Custom Present)+开启auto coloir space开关”这个组合时,除了会有前面多次提过的full range色域错误的问题(未伸张,即0变成16),另外播放444视频时,会出现色域过度伸张的错误(即16变成0)。

但是如果把渲染器换成EVR (Vista/.net3),看表格里似乎完美?

很可惜,EVR (Vista/.NET3)有个更阴间的BUG……字母某些特效会变成这鸟样(请无视我没安装字体。下面是对比用的EVR (CP)):

结语

既然没有一个完美方案,我们只能两害取其轻了。我最后采用的是内置软件解码器+D3D11渲染器+不开auto色域转换开关的方案(换成D3D9渲染器也行)。这样只有422、444不能完美播放(但是也不至于不能播放),而我除了我自己造的测试视频其实根本没下过这种视频w。我还检查了下BT601的视频,也是没问题的。

哦,其实还有个方案就是上面提过的LAV+强制RGB输出的方案。可以一揽子规避所有转换的坑。但是,这样就没法享受到高质量的DXVA/D3D11 Video Processor做resizer,所以还是算了。

20230520补记

最近碰到一个视频是 SMPTE 240M 的matrix,发现用D3D9的 renderer 有问题,D3D11的OK(和MPV的效果一致),所以虽然都行还是推荐用D3D11。

另外,我发现我上面明明还有一个全绿的组合:使用LAV+D3D9渲染器+开启auto coloir space开关,但是我为啥没使用?我自己也忘了,大概还是为了尽量不用外部滤镜来让截图不偏差吧。

Twitter对于有内嵌ICC profile PNG文件的处理乱象

最近换了新手机。前日在Twitter发手机截图的时候,一眼就注意到颜色不对(饱和度过低)。颜色不对的原因很显然:内嵌的色彩空间ICC profile没有正确处理。最近几年的手机基本都是广色域(Display P3为多),对于截图比较常见的处理方式是自动在截图的图片里加一个Display P3的ICC。iOS已经是这样很久,我之前的OnePlus手机并没有这么处理(而且我之前的手机截图直接就是JPEG,所以也不会有本文所述的问题),换了这个新手机才第一次在安卓端遇到。

对于使用非标准/默认sRGB的图片的处理方式大概有两种,一种是保留ICC,一种是转换为sRGB。前者最优但是向后兼容性有限,后者兼容性强。但是错误的方式是不转换(即保持像素点的原始RGB值)但是却丢掉ICC。这个具体的前文已经说过了就不赘述。显然,Twitter这里就犯了这样的错误。

但是这里有两点很难理解:第一,我用iPad上传截图从没遇到过这样的问题。前面已经说过iOS的截图也是用了Display P3的ICC。第二,我感觉Twitter的Media Team不应该这么菜。

推特的图片处理概述

既然说到2,那就得从当年Twitter对于图片上传处理的改版说起。推特原来是对于上传一切图片都二压的。从2019年12月起,改成了JPEG不二压(除非超大,见下述)。元数据自然是strip的,但是ICC profile保留。这里可以参见当年Media Team的dev发的推(注:此人已经跳槽到Meta。否则这次的问题还想at他反馈下呢)。PNG的话,则是一般都二压,除非:1) 转出来的JPEG比PNG还大、2) 图片特别小、3) 是palette(即所谓PNG8)、4) 有透明度等特殊情况。

这里有个日本网友总结的:

这个改动是非常好的,尤其是会对ICC进行保留这点。所以我想当然地认为,如果你上传PNG且被二压了,那么至少ICC是会保留的。结果居然不会?!

测试

OK,那为什么iPad就可以?这时候我脑子里大概已经确定了推特给了iOS特殊待遇,毕竟这种事情已经屡见不鲜了。不过到底是如何给的特殊待遇?我们得详细测试下。那么启动一个小号,开始大量上传测试。用Photshop制作一个Adobe RGB的PNG上传,丢失ICC,符合预测。用web上传一张iPad截图,咦,颜色怎么是正常的?看来特殊待遇不是在app端?那让我们把图片剪裁一下,再上传——咦,怎么又丢失ICC了?!难道我动到了什么元数据?不服,我用Exiftool把原始文件的metadata一字不漏的复制过来:

exiftool -TagsFromFile input.png "-all:all>all:all" output.png

怎么还是丢失?到这里我已经开始抓头发了。

不要急,让我们仔细对比一下两张图片的所有的技术细节区别——唔,看起来iPad的截图还挺可怕的,是用了16bit/ch(一般是8),而且有alpha通道[*]。转成8bit/ch的再上传,果然ICC又丢失了!难道是Twitter对于8bit和16bit的处理不同?那我们自己造一张16bit的图总可以了吧!咦,怎么还是丢失了,这……

[*] PNG支持alpha通道(约等于透明度)。但是注意,许多软件,例如Photoshop,在保存的时候,如果检测到你的alpha channel是全1(即全opaque,没有任何像素有任何透明度)的时候,会自动删除alpha通道。可以使用magick的

magick convert input.png -alpha on output.png

来强行开启alpha通道。

我反复地把不同图片的内容进行复制粘贴、缩小放大来上传,结果就是有时候行有时候不行。在我已经开始怀疑推特使用了什么heuristics来分析图片内容、又或者有奇怪的cache的时候,突然想到:咦,同样一张图8bit会丢失16bit不会,但是我自己制作的比较小的16bit却又不会,难道……和文件大小有关?想到上面规则里有的“JPEG不能超过5MB否则二压”,我于是制作了2张 PNG + display P3的图,一张小于5M 一张大于5M,然后上传……

真相大白。I’m speechless.

……但是还没完。我们把这两张图下下来对比一下。记得要下载原图,也就是

咦,似乎还有点奇怪?两张图的技术参数是完全一样的,都是85% quality的4:2:0的JPEG,符合上面二压的说法。但是两者其实都没有保留Display P3的ICC:正确的那张图,是正确转换到了sRGB,而且加了sRGB的ICC:

错误的那张则是和原来一样,是直接丢掉了原来的ICC,也没有内嵌新的ICC。

赶紧掏出iPad确认了下,果然是不一样的——用iPad上传,是保留了原始的ICC而非转换:

果然,iPad客户端还是有特殊待遇的。那么我们再多试几次,发现如下现象:

如果用iPad客户端上传,所有的PNG二压都会保留ICC——即使是小于5MB的。而且也不限于iPad截图,我把我自己造的AdobeRGB的图、以及Android截图用iPad上传,全都是正常保留ICC:

与之相反,Android就惨咯:用Android上传任意PNG都会丢失ICC,哪怕是在web端可以正确处理的>5MB的图:

那么如果上传会被二压的超大的JPEG,会怎么样呢?我以为会和PNG一样,结果还稍有不同:

iPad – 保留ICC; Android – 保留ICC; Web – 转换ICC为sRGB

区别在于,Android这次正常了。

总结一下:

InputiPad appAndroid appWeb
PNG, <5MBKeep ICCLose ICCLose ICC
PNG, >5MBKeep ICCLose ICCConvert ICC to sRGB
Large JPEGKeep ICCKeep ICCConvert ICC to sRGB
How ICC is processed when Twitter recompress the image

使用Twitter内置的裁剪功能的结果

另外一个有趣之处是使用Twitter内置的crop功能时的结果。我分别拿各种组合试了下,不赘述直接贴结果:

iPad:如果原图是sRGB或者无ICC,会产生一个无ICC的图片;如果原图是非sRGB(Display P3、Adobe RGB),会一律转换为Display P3(含ICC)

Android:保留原图ICC。注意这里profile并不是直接copy,而是会用一套谷歌的equivalent的ICC,比如如果原图是苹果的Display P3:

会变成谷歌的:

如果原图是Adobe RGB:

同样会变成谷歌家的:

但是颜色都是一样的/对的。

另外一个小插曲:

用 Android Twitter打开修图时,会看到在filter等功能时图片的预览明显颜色是错误的(丢失ICC)(左),但是在crop的页面却正确(右):

如果试图打开那张10M的iPad截图,就更草了,filter直接无法处理(应该是凡是用了iPad这个color profile的都会有问题:我把这图用PS改小了点照样不行)(左),但是同样,crop是工作的(右):

估计是调用了不同的系统接口吧。

Web的话,则是无脑(无论图片大小、格式)调用上面提到过的PNG>5MB时的编码器,产出(正确转换的)sRGB的图(含ICC)。

总结如下:

InputiPad appAndroid appWeb
sRGB or no ICCNo ICCsRGB ICCsRGB ICC
Other ICCsConvert to Display P3Use Google equivalent ICCConvert to sRGB

结语

从上面的一些现象可以大概猜到,很多东西应该也不是Twitter自己写的,而是调用了系统的图形接口,所以这个问题也不能完全甩锅给Twitter;但是至少我们知道,如果他们用心,至少完全是可以做到正确转换/保留ICC的,所以在安卓端上传PNG会完全丢失ICC的问题还是要推特背锅。

这里不得不顺便提一个很让人frustrated的问题:对于这种大型服务,通常完全没有任何顺畅反馈的通道。比如Twitter的客服,基本只会处理账号和内容相关的问题,凡是任何技术问题,反馈就如同对牛弹琴。Twitter另有面向dev的渠道,但是严格只回答和API相关的问题——如果只是“使用”上的技术问题,他们是不管的。如果没有在Twitter上班的朋友,几乎没有任何办法传达到相关人士耳中。之前有人劝我,别白费功夫了,他们不care;但是我想反馈也不是单纯为了帮他们改善产品,而是这种问题切实地影响到了我个人的用户体验。(耸肩)

解码AES-128的一些小坑

短文,个人笔记用。

昨天因为某个需求需要手动解码下下来的加密的TS块。理论上很简单,我随便就搜到了Python:

from binascii import unhexlify

from Crypto.Cipher import AES

key = "614c9b1fa9ea1b1be878929c592d20e0"
key = unhexlify(key)
iv = 630
iv = iv.to_bytes(16, 'big')

decipher = AES.new(key, AES.MODE_CBC, iv)
encrypted = open('enc', 'rb').read()

decrypted = decipher.decrypt(encrypted)
open('dec_python', 'wb').write(decrypted)

Node.js(抄袭自Minyami):

const fs = require('fs');
const crypto = require('crypto');

let key = '614c9b1fa9ea1b1be878929c592d20e0';
let iv = (630).toString(16);
const algorithm = "aes-128-cbc";

if (iv.length % 2 == 1) {
    iv = "0" + iv;
}

const keyBuffer = Buffer.alloc(16);
const ivBuffer = Buffer.alloc(16);
keyBuffer.write(key, "hex");
ivBuffer.write(iv, 16 - iv.length / 2, "hex");

const input = 'enc'
const output = 'dec_node'

const decipher = crypto.createDecipheriv(algorithm, keyBuffer, ivBuffer);
const i = fs.createReadStream(input);
const o = fs.createWriteStream(output);
i.pipe(decipher).pipe(o);

OpenSSL:

openssl enc -aes-128-cbc -d -K 614c9b1fa9ea1b1be878929c592d20e0 -iv 276 -in enc -out dec_openssl.ts

多种方法。本来这事儿就完了,解码出来的文件也都可以正常播放;但是我闲着没事对比了下几种方式出来的文件,发现居然hash都不一致?!

Padding

以结果不match搜索,很快就知道了Python和Node的异同:Node这个decipher比较高级,默认就有auto Padding(对于解码,就是auto unpadding)。这个可以通过decipher.setAutoPadding(false);来取消掉。相对,Python我这个就比较底层,所以要手动unpad:

from Crypto.Util.Padding import unpad

decrypted_unpad = unpad(decrypted, AES.block_size)
open('dec_python_unpad', 'wb').write(decrypted_unpad)

我们这样生成了unpad(JS默认,Pythonunpad)和没unpad(Python默认,JS设为false)的两组文件,两两hash相等。

不过,对于我们的应用(解码HLS的视频块),是否真的需要unpad呢?rfc8216#section-4.3.2.4提到了要用标准的PKCS7 padding,那么我们就理解为需要unpad吧。

OpenSSL的IV补全问题

OK这俩一致了,那么OpenSSL是怎么回事?OpenSSL也是默认pad/unpad的,可以通过-nopad参数来取消。但是无论加还是不加都和上面两个产生的结果不一致。另外还有-nosalt,但是似乎对AES-128-CBC算法并没有区别。

我在网上搜了半天也没什么头绪,不过发现运行时OpenSSL有个提示:hex string is too short, padding with zero bytes to length。会不会有关系?我于是测试了下把IV改成0276、000276等等,发现结果居然都不一样!不是自动在前面填充0吗,为啥会有区别?百思不得其解下,我用这个warning作为关键词搜了半天,终于搜到了一个相关的文章——虽然这个文章没有直接说只是给你演示了一波,但是可以看出,原来天杀的他是在后面填充〇而不是前面!虽然现在看来,这也很正常,但是由于HLS这里的IV用的是默认的sequence number(一个int),所以我直觉地天真以为他肯定是在前面填充〇了。这个文章里引用了SO某答案里的一句:

It is always best to provide the exact size inputs to encryption functions, specify 32 hex digits for an AES 128-bit key.

zaph from https://stackoverflow.com/a/39908983/3939155

深表赞同。我要是一开始这么做也不会踩坑了。

其实这个文章也不需要那么大费周章对比,因为OpenSSL enc有个argument是-p,可以直接print相关的key/IV等:

G:\2>openssl enc -aes-128-cbc -d -K 614c9b1fa9ea1b1be878929c592d20e0 -iv 276 -in enc -out dec_openssl -p
hex string is too short, padding with zero bytes to length
salt=2831EC7600000000
key=614C9B1FA9EA1B1BE878929C592D20E0
iv =27600000000000000000000000000000

就直接可以看到问题所在了。

用FFMPEG解密

顺便一提,如果你直接搜索解密HLS加密的ts文件,搜到的绝大部分都是教你用ffmpeg;但是ffmpeg解码一个m3u8文件倒是很方便,但是直接解码单独ts文件怎么做却几乎没人提及。

我搜了半天终于搜到一个superuser的问题的评论里提到方法:

ffmpeg -key <key> -iv <iv> -i crypto:dec.ts

这里key和iv也都是hexstring(同上,自己手动填充到足bytes吧!),注意后面的-i要加crypto协议

如果要输出后面就-c copy out.ts就行了。但是注意,因为ffmpeg的操作,即使是copy,也会在容器级别有很多操作,所以和上面三种方法纯解码的是无法直接对比的。我只能说肉眼收货解码是成功的就是了。

IV对最后结果的影响

在最开始我用OpenSSL输入IV长度不够导致被错误 append zeros的时候,解码出来的文件也是可以完全正常播放的。因为AES-128-CBC的原理就是用第一个block明文+IV来加密成密文,然后再用第一个block的密文当做第二个block的IV来继续加密。所以,即使IV错误,也只会导致第一个block解码错误。由于mepg-ts是一个容错性非常强的格式,几乎完全不会导致解码结果有任何问题。但是既然我们都有IV了,那还是正确处理吧。

小心BW的数字水印

前文的一个小小的补充。

在评论区有人提出用上文所述的脚本提取出来的封面和试阅版的封面hash不一致。我一开始没当回事,说不定两张图根本只是单纯在服务器压缩了2次而已,hash不一样的可能性太多了。不过为了谨慎起见,还是测试了一下。没想到一测,就发现还真的有点东西。

留言的网友询问的是 BW 台湾站,那就拿台湾站来测试。现在角川所有的服务器都已经升级到了最新版的JS,所以直接改下US的@match就行。

检查无混淆的图

我随便找了本漫画,先是下载了试读版——试读版和正式版不同,是所有页面都没有混淆的,所以其实你控制台直接下载也行,和用我的脚本是一样的。因为试读版不登录就可以查看,所以应该是没有什么账号信息的。

然后使用网友提供的账号下载同一本作品的完整版——但是我偷懒没有下所有页数啦,得益于脚本的更新,现在可以选择页数了,我就下了前10页。

和BW日本站一样,封面是无混淆的图,后面的页面混淆。我们的重点是这个没有混淆的cover页。理论上,这个图应该和试读版一样,然而两者的文件大小错了有整整20KB。

那么就是一系列的对比啦,我用了下面这些步骤。

第一步最简单的,对比图像数据。我手头有一个我自己写的Py小脚本来对比两张图的像素,也可以用拖进PS->两个图层叠加->计算差值->合并->查看对比度的笨方法。嗯,两者确实是逐像素相等的。

那么第二部就是打开XnView MP查看元数据:

(我一般properties和ExifTool都看一遍)也没有任何区别。

于是我打开Beyond Compare想直接二进制对比,结果发现两者有巨大差别,打了个我个措手不及。明明逐像素相等,为什么图像数据的部分字节也对不上?没什么头绪的时候,突然想起之前研究JPEG spec的神器,JPEGsnoop,赶紧掏出来。

一比就发现了为什么二进制错那么多:原来两张图的霍夫曼表完全不一样——一个“优化”了(正式版),一个没有优化(试读版),具体区别可以查看旧文。难道这就是两者的唯一区别了吗?在我即将关闭JPEGSnoop之时,看到一个非常重要的信息:

好家伙,在EOF后面藏东西,那基本可以猜到是啥了:

基本不用猜,这32bytes 的数据肯定是用户的ID了。我赶紧测试了下其他的图,结果很意外地发现那些混淆后的图反而没有这串水印——不过确实也没啥意义吧?毕竟你加什么水印只要不是加到图像内容里,被我们重新拼图之后都消失了。

我又进行了以下测试:

  • 用同账号下载别的完整版书籍——封面图依然有水印而且ID一致;
  • 用我自己申请的新账号购买了同一本书——果然水印ID不一样。

那么基本可以99%肯定这就是角川加进来用来反查用户的数字水印了。而且这种加法很容易,服务器给文件的时候直接在文件后面append就行。他服务器也不需要存多份文件。

移除水印

移除这水印很简单,可以直接二进制删掉最后32 bytes。如果不熟悉二进制操作,我测试了下XnView MP自带的清理元数据功能:

其实这里勾啥都行,因为任何操作都会导致他重新生成一次JPEG文件架构,从而删掉EOF后面的多余字节。这里如果勾选第一个优化霍夫曼列表,最后出来的文件大小就是类似于完整版,否则就是类似于试读版。我们正好可以验证一下,圈中所有三个文件(试读版,账号1的完整版,账号2的完整版)clean一波,出来的三个文件 md5 完全一致。

当然,你也可以直接再存成PNG,那肯定啥水印都没了。虽然没必要,徒增体积。

混淆过的图的检查

上面已经说过混淆过的图并没有这个二进制的ID水印。不过内容我们还是检查下吧。检查很简单——先把账号1和账号2的图都解码,然后互相对比:结果是逐像素相等。那就说明没有任何可以识别出下载者账号的内容水印

我然后和预览版的对比——预想来说是不可能逐像素相等的,因为毕竟服务器给的资源是把原图(JPEG)拆成32px的块之后混淆后再存了一次JPEG,那自然有新的JPEG artifact引入。虽然我们还原时是用了bmp/png没有再引入新的JPEG artifact,但是也无法去掉第二次的。

结果一对比还挺出乎我意料的:居然除了最右边一排别的都是逐像素相等?怎么做到再存了一次JPEG没引入新的JPEG artifact的?如果认真学习过(误)我之前写的JPEG spec文章的应该就懂,JPEG的最小编码单元(MCU)是8×8的,所以是可以以最小8×8的尺度对原图进行无损重新组合的(或者旋转)。而角川的混淆的块儿是32×32,所以完全可以。没想到角川居然真的用到了JPEG的这个特性,混淆图片时直接是对原始JPEG流的MCU进行的swap操作!太低估他们了。

这还意味着什么呢?我们目前在 Python 里进行的还原混淆的canvas操作,其实也完全可以采用直接交换JPEG流的MCU的方式,来实现完美还原原始JPEG——好吧应该说90%完美,因为边缘非8的倍数的部分还是没法100%还原,这部分在角川生成混淆JPEG的时候已经给padding到了8的倍数了(也因此引入了二次压缩,所以上面也观察到最右边一排还是没能逐像素相等)。

Hmm,想了想意义很小而且好像很麻烦,就懒得折腾了。留作以后的课题。

结语

虽然上面都是说台湾站,但是日本站也是一样的,至少我随便看了下,无混淆图也是有ID的。

虽然我自己是无所谓,因为我dump BW全都只是自己收藏而已,但是如果要拿出去分享尤其是大范围分享,那确实得小心一点了。我更新了下之前的bw.py,现在可以自动移除数字水印了。