解码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,现在可以自动移除数字水印了。

一个更好的Book Walker的网页版的dump方式

2021-10-27更新:

BW又更新了JS,这次和上次间隔如此短,让我有点担忧。下面的脚本已经移除。如果需要,请留言 GitHub 邮箱。

2021-10-19更新:

BW更新了他们的JS到2021-09-30,许多变量名都变了于是修改了一波。同时也完全重写了,现在不再hook,而是完全调用自带的那些函数来运行,速度会快很多(也支持Chrome了!)。旧版的直接删掉了,如果需要请留言。

注意bw.py也小更新了一下,必须同时更新。

2021-08-30更新:

感谢评论区的HuHu菊苣指点,已经更新(见文末的 gist.github.com)了另外一个仅hook渲染函数而非drawImage()的userscript。虽然使用上体验应该没有区别。bw.py也更新,同时兼容新旧两种方式。


之前已经大致讲过如何通过canvas来提取BW的图片。

之前脚本的问题

脚本的原理很简单,就是覆盖BW的viewer JS的某个渲染函数来让其把图片解码绘制到我们提供的一个canvas中,然后dump那个canvas的内容。

这个方法一切都好,但是有个小毛病:就是图像最下面会有2px的白边。

注意,这里的白边指的不是dummy width/height:BW的资源文件(JPEG)有时候会有一定程度的无效内容”出血“,但是在元数据里已经标记出来,在浏览器显示的时候,自动就把这部分切掉了。我之前的脚本也会正常将这个部分切掉。

这里说的白边的毛病是,即使对于完全没有dummy height/width的图,渲染/解码到尺寸和图片完全一致的canvas里,也会在下方出现2px的白边。如果只是单纯添加白边就罢了,他还导致所有上面的内容resize一次(例如1448×2048->1448×2046),整个图片变糊,在文字部分尤其明显:

能否通过给canvas多加2px高度来解决呢?答案是否定的,只会变成这样(笑):

其实你在浏览器里浏览的时候,就会发现这个白边(严格来讲是杂色边)本身就存在的(下图)。所以不是我们的破解搞出的问题。

理论上来讲,如果我们hack更底层一些,应该是可以在浏览器端解决这个问题的,但是我实在是懒得搞了,就这么一直忍了下来。

2021-08-30更新:根据评论区HuHu的信息,这个2px高的杂色条其实是用来track用户账号的条码!那就更应该一定要把它去掉了。

更好的dump方式

直到今天有网友推荐了这篇aloxaf的文章,开启了新的大门。

此文中,作者通过另外一种方法:直接hook了drawImage(),然后记录下来所有的swap块的操作dump下来,这样本地就可以还原了。

(顺便一提,作者提到了在Firefox里直接dump canvas会提示“The operation is insecure”的问题——也就是上文里提到的Chrome里的tainted canvas的问题。但是很吊诡的是,在成文之后更新了数个Chrome版本之后可以完全直接dump不用加--disable-web-security了,但是试了下Firefox还是不行。)

这个方法有2个优点:1是完全规避了上面提到的BW本身viewer的2px白边劣化;2是dump下来的原始JPEG文件没有经过2次保存,存档节省空间。观看的时候动态重新解码即可。

不过也有2个问题。

首先,这个脚本不支持Chrome。至于为什么不支持,是因为在drawImage()操作时,Firefox里收到的第一个参数是Image,但是在Chrome里却是ImageBitmap。浏览器本身的原版函数这两种对象都是支持的,暂不清楚为什么会有区别,可能是BW的JS对不同浏览器进行了(没有意义的)适配(根据这篇文章,如果输入ImageBitmap,性能要好一些,但是似乎没有考虑到转换ImageImageBitmap消耗的时间?)。Image对象转换成ImageBitmap就丢失了src,所以导致匹配页码什么的变得很麻烦。虽然应该改改也很容易,但是因为hook这种底层函数老导致浏览器卡死,也没什么心情继续折腾了,就用Firefox呗。

另外一个问题则比较关键,就是上面提到的dummyHeight、dummyWidth的问题。如果丢失这两个参数,会导致下载下来的图片解码后,无法得知应该切掉多少无效内容。而且,每页的出血也不一定是一致的,所以也不能无脑裁剪。

还好,稍微研究了下全局对象,把元数据的部分下载的时候直接一并dump下来,再在本地处理即可。

这里多说几句,根据我的观察,一般而言BW的书大概都是这么个结构:

第一页一般是无混淆、无出血的。估计是因为封面图会在别的地方,比如目录,展示用。这有个副作用会导致我们记录 drawImage()记录出一些无效的信息,暂时直接在Python那边忽略了。

第二页起有混淆和一致的出血。出血都是在右边、下边(我有确认过是真·无效内容,基本都是乱色)。目前见过:

  • 无出血 (VOICE BRODY)
  • 5, 5 (宽,高,下同) 出血 (声A)
  • 6, 0 出血 (My Girl)

我的修改版本

我的修改版本文章最下方,主要改动如下。

JS部分改动

原始脚本里,有个比较原始的自动翻页的功能,是调用了viewer本身的function(具体字段名称升级经常变动。我这里修改成了最新版本)。不过,并不太好用——偶尔会漏页,而且翻太快会出现403之类的。

我改写了个更robust的。虽然非常啰嗦,但是一时想不出更好的写法,就这样吧。

我之前的脚本是直接复制到控制台运行(或者做成bookmarklet)的,这个脚本作者写成了userscript的形式。这点是很必需的:如果在页面渲染完才输入,肯定会有很多次drawImage()事件就错过了。

不过用userscript会无法访问BW viewer生成的那一堆全局对象(因为注入时还没生成),在添加一些功能时有一定局限性(比如作者最开始的设计要手动在命令行输入SaveToZip())。我懒得折腾MutationObserver之类的,简单粗暴加了个延时改写一下。

2021-08-30更新:更了V2版,直接调用他原本的函数取得交换参数。V1版保留(毕竟那个应该更耐艹)。

Python部分改动

py合成的部分基本重写了,加上了切出血的步骤。另外额外增加了自动切声A和Animedia的功能(这个社的杂志源文件就自带白边)。使用方法就是bw.py {zip文件名或者解压后的目录名}

旧文备份:汉字的全角半角

今天在BGM闲逛看到有人发了一篇Eric Q. LIU写的《全角半角碎碎念》。在学到了很多知识之外,突然想起自己当年(不晚于16年)在S1也扯过这个话题。翻了半天发现有在Evernote备份,可能是当初想稍微扩展一下写成博文,但是后来搁置了。和上面这篇专业人士所写的相比,实在有点太小巫见大巫,所以也没有什么再继续写的必要;但是好歹是自己创作的东西,就在这里不做修改备份一下吧。稍微看了下,除了排版方面的纯粹胡诌以外,其他的倒也算没太多事实错误(笑)。


首先你需要定义到底什么叫做“全角”

到底是指字符(Glyph)宽度(占一个汉字的字符宽度),还是指字符编码的字节数(2字节,和GB编码下的一般汉字一样)

如果是指编码的字节数:
这是“半角”的双引号(不分左右):”(U+0022)(只要一个字节)
这是“全角”的双引号(分左右):“”(U+201C,U+201D)(各要两个字节)

p.s. 这是“全角”的双引号(不分左右)"(U+FF02)←这个非常少用 下面略去不讲

具体显示成什么样的字符(glyph)根据字体不同,但是编码是定死的,你复制出去用Unicode decoder查永远是上面显示的那些

如果指的是字符宽度,那则和字体有关系:
Win9X年代的中文字体,如宋体,一般会把前者显示为半个(一个)字符宽,后者显示为一个字符宽,所以许多人习惯称后者为全角引号,前者半角;
在雅黑下,两个字符的字形虽然不一样,但是都会显示成一个字符宽;
在绝大部分英文字体下,两者都会显示成半个字符宽。

但是问题在于,对于非等宽字体(如雅黑,和世界上绝大部分字体),区分“半个字符宽”和“一个字符宽”没有任何意义——在这些字体下,符号、字符都只会占据其需要的宽度。这也是为什么所谓的“全角引号”看上去好像也是“半角”一样——重复一遍,这取决于你的定义。

大部分时间,这两个定义是统一的——一个“全角”字符会占用两个字节并且显示为一个字符的宽度,和汉字一样。

另外需要厘清一点,全角字符这一概念基本只有汉字圈儿才用。因为拉丁字母等除非特殊用途(如代码)以外,根本没有列对齐的需求(字母天然都是不等宽的,不像汉字)。所以字符意义上说,他们用的符号都是“半角”的,自然这也会反映到字体的字形设计上。所以对于字符“AvsA”(前者全角,后者半角),外国人几乎从来不会用到前者(事实上,许多英文字体,例如Arial,根本就不会包括前面那个字符)。

但是“”(U+201C,U+201D)这对双引号有点不一样,这大概也是你问题的来源。和很多中国人的想象不同,其实英文是会用这对引号的——因为普通的所谓“半角”双引号,”,是不分左右的。而英文的引文符号其实和中文一样,是需要分左右的。所以无论是中文还是英文,在正式文章中你需要使用引用时,你想要的是分左右的双引号——至于到底是一个字符宽还是半个,长什么样,和你的字体有关。但是他的编码是双字节的这点不变——从这个角度来说,你即可以说它是全角,也可以说他是半角。
一般人会把”(U+0022)叫做半角,“”(U+201C,U+201D)叫成全角,但这只是一种说法而已。

但是问题在于,普通PC键盘,默认用shift+’(L右边两个那个键)键入的是”(U+0022)这个引号(英文输入法下)。这个不分左右的双引号,这说白了也是打字机时代键位紧张、外加ASCII(单字节编码)字符位紧张的遗留问题罢了。但是一般(外国人)日常打字用它代替漂亮的弯引号也不会产生什么歧义就是了。如果你用英文word试试就知道,word就会自动把它纠正成“”(U+201C,U+201D)。

说了这么多废话,总结一下就是

如果你从编码的角度出发,分别存在全角和半角的引号
如果你从字形宽度的角度出发,“、”在许多中文字体下会被显示为“全角”,“会被显示为半角;但是”、“、”在英文字体下和许多新的中文字体下全部都会显示成半角。

—-

最后修改时间:2016/3/8

ShadowPlay录屏的色域问题及如何正确处理BT.601 (SMPTE 170M) 视频

前几天因为某些需求,使用nV家的ShadowPlay(下称:SP)进行录屏。我录屏一般都是用较高的码率(60Mbps)录制,然后再手动二压一次。这次由于视频比较长而且质量不是很关键,就用NVEnc直接压了遍。

压完之后随便拖了下进度条对比——咦,颜色怎么有点不一样?

左:原始SP录制视频用Pot播放;右:NVEnc压制后用Pot播放

可以明显看到,左边的图红色要暗一些,右边(压制后)则艳丽许多。

根据我的经验,知道这肯定是YUV色彩空间的问题。赶紧来看看metatag:好家伙,SP录出来的视频居然是:

Color range                              : Limited
Color primaries                          : BT.601 NTSC
Transfer characteristics                 : BT.601
Matrix coefficients                      : BT.601

(注:比较老版本的SP,transfer用的是 BT.470 System M。)

那么看来,似乎应该很简单就是NVEnc转换时没有进行任何处理,生成的视频又没有tag,导致播放器默认按照BT.709播放错误了呗。至于原始视频,虽然是BT.601,但是由于有加正确的tag,那么肯定解码、转换成RGB域之后是正确的。

不过为了谨慎起见,让我们换个播放器来试试。一般而言,如果出现播放异常的情况,我第一个排查的方式就是分别用:MPV、LAV+MadVR(MPC-BE)、LAV+EVR各播放一遍来和PotPlayer的结果对比。即是说,我一般是不信任Pot的结果的“正确性”的。虽然我的PotPlayer在我调教下已经基本可以*正确*播放99%的视频,但是总有无法覆盖的奇怪情况不是。

这么一对比果然就发现了问题:同样播放原始视频的情况下,MPV和LAV+EVR结果一致,但是和LAV或者Pot内置解码器+MadVR的结果不一致。不过这里的不一致和上面情况不一样,更加微小:

左:MPV 右:Pot或LAV+MadVR

这里可以看到,会导致不同的是MadVR,而不是解码器部分。在继续分析为什么不同之前,我们需要知道哪个是“正确”的结果。这里有个很简单的方案:用PS绘制一张红色(BT.601和709矩阵区别在红色最明显)图片(我测试时没用纯红而是用了250,0,0,防止clipping影响对比),然后SP录屏,查看结果用吸管工具取色看哪个对即可。图我懒得贴了,结果就是,用MPV的结果是正确的,MadVR错误。

接下来的过程,我尝试使用ffmpeg的各种色域转换命令对视频”修复“,搜索了不少网上的帖子,花了几个小时也绕了很多弯路,感觉按时间顺序叙述就过于啰嗦,所以我直接写最终结论。

结论1:SP录制的视频确实是BT.601的color matrix,但是并不是BT.601的color primaries。metatag标注错误。

这里首先要复习下我在这个帖里讲过的matrix、primaries、和transfer function的区别(下称M、P、T)。

  • Color primaries:指定gamut的范围(三个原色[即“primaries”]的位置,白点位置)。理论上回放时只和校准(显示器)颜色有关,所以绝大部分播放器不会使用这个属性。
  • Transfer characteristics:指定gamma ramp。譬如,你可以指定为linear等。几乎所有播放器都无视,直接用那个标准的nonlinear的ramp(见下文)。
  • Matrix coefficients:这个是比较关键的也几乎是唯一会被播放器读取并采用的,指定了YUV和RGB的转换矩阵。

然后他们的值的区别:

  • P:BT.601 NTSC=SMPTE 170M,不等于BT.601 PAL=BT.470 B/G,不等于BT.709,标错理论上会导致显示错误(下述)
  • T:所有常见标准的 transfer function 基本都一致,所以即使标错也影响不大
  • M:BT.601=BT.470 B/G (PAL)=SMPTE 170M但是同样不等于 BT.709,标错几乎一定会导致显示错误

SP录制出来的视频,确实是用了BT.601的矩阵(M),但是实际上用的依然是BT.709的P——这里可以通过强行修改metatag之后再播放来验证。至于T,由于所有的标准其实都一样,所以标成601还是709不重要。

结论2:一般播放器会无视P,所以metatag写成什么都无所谓;但是MadVR开启calibration之后会对P进行转换。

如上所述,由于P的特性导致在屏幕没有校准的情况下是无法去适配的,所以一般播放器都完全无视了这个metatag。这也是为什么上述SP录制出来的视频用MPV或者LAV+EVR播放显示“正确”。但是MadVR这边,虽然我的屏幕并没有校准,但是为了能让BT.2020的HDR内容tone-mapping到感知上比较合理的SDR颜色,我“被迫”开启了MadVR的“我的屏幕已经calibrated“的选项并且把校准目标设置为BT.709:

否则,HDR视频的颜色直出,和屎一样:

我不知道为什么MadVR没有单独的tone-mapping HDR content的选项,反正我试了hdr tab下的所有选项,都没有用。MPV其实也需要设置,需要开启vo=gpu(或者直接开启copyback的硬解hwdec=auto-copy,会自动开启vo=gpu)。

这里提供一段BT.2020的视频谁想用可以拿去测试自己的视频播放方案: https://1drv.ms/u/s!Akq11jtCTJYwguclNUTq5MsccSRZ3w

呃,有点跑题了。总之,为了能”正确“显示BT.2020的内容,我开启了这个“我的屏幕已经calibrated”的选项。然而这就导致MadVR会把BT.601的Primaries也转换到BT.709。而SP的视频虽然metatag标注是P=BT.601,但是实际是只有按照P=BT.709来播放才能显示正确的RGB颜色,所以在M转换之后(这个是正确的、应该的)MadVR又进行了一次额外的P转换,导致显示结果错误。

细心点可以看到这里transfer function也被指定了。根据我的测试,如果在发生P转换的时候,这个T选项(尤其是gamma)也是会对结果有影响的。不过,如果如果仅有M转换发生时,这个选项选成什么都没有区别。

要不重新编码修复这个视频,最简单的办法是:

  1. 用ffmpeg指定P:
ffmpeg -i shadowplay.mp4 -color_primaries bt709 -c copy fixed.mkv

这里注意,要保存成mkv,mp4容器无法覆盖掉视频流的P的metatag。麻烦点也可以先直接转换成MKV,然后用MKVToolNix的header editor编辑。总之,最后出来的MKV用mediainfo查看大概是这样:

Color range                              : Limited
Color primaries                          : BT.709
colour_primaries_Original                : BT.601 NTSC
Transfer characteristics                 : BT.601
Matrix coefficients                      : BT.601

播放是正常的,会认为是BT.709的P,MadVR不会再进行转换,结果和MPV一致了(这里Pot的OSD是显示错误的,大概他直接读取了视频流的tag,没有显示MKV容器覆盖上的,请无视):

以上就是SP视频的Primaries metatag的错误和修复方式。

MadVR转换Primaries的正确性

虽然我们看到了如果开启calibrated,MadVR会对P不为BT.709的视频进行一次转换,但是这个转换结果是否正确呢?

让我们试验一下。还是用250,0,0的颜色来举例——我先用zscale制作一个BT.709的视频,用MPV和MadVR播放都正常显示为250,0,0。然后,我们用ffmpeg的colorspace vf,分别转成bt601-6-625、smpte170m、bt601-6-525:

ffmpeg -i zscale.mp4 -vf colorspace=iall=bt709:all={target_colorspace}:format=yuv420p -sws_flags accurate_rnd  zscale_to_{target_colorspace}.mp4

(Where target_colorspace = bt601-6-625, bt601-6-525, smpte170m)

接下来我们分别用Pot和MPV播放生成的视频,然后截图取色,结果如下(前两行其实是一样的):

New colorspacemediainfo Pmediainfo Tmediainfo MMadVRMPV
smpte170mBT.601 NTSCBT.601BT.601235,37,0243,0,0
bt601-6-525BT.601 NTSCBT.601BT.601235,37,0243,0,0
bt601-6-625BT.601 PALBT.601BT.470 System B/G249,0,0245,0,0

这里辨析一下几个非常迷惑的term,基本上:

NTSC标准系列:BT.601 NTSC = smpte170m = bt601-6-525(转换矩阵在mediainfo里就叫“BT.601”无后缀)

PAL标准系列:BT.601 PAL = BT.470 System B/G = bt601-6-625

有些细节问题(比如这俩的M其实是一样的;又比如T在FFMPEG里SMPTE-170M, BT.601-6 625 or BT.601-6 525都用同一个,bt470bg反而是略有不同)这里按下不表。

其中,MPV的结果就是仅做M转换,不做P转换的数值。而MadVR做了P转换之后,可以看到只有PAL的结果是比较正确的,如果是NTSC,反而比不做转换还要差好远。为什么?鬼知道。这里我不是说MadVR的结果一定是错误,毕竟整个流程可以出错的地方太多了(ffmpeg滤镜可能算法有误,我对整个流程的理解可能有误,等等)。但是总体而言,不同matrix的回放已经很成熟,也一般都很准确;但是我们尽可能要规避回放时的Primaries的转换。

我在前文“用ffmpeg静态图转视频”提到过静态图转视频需要进行一次BT.601到BT.709的转换,这个说法是没错的。但是这个转换仅限于矩阵,P是不需要转换的。所以如果真的想用我提到的“不转换,只添加metatag”的方式,也只需要添加M的tag,而不需要添加P的(添加了反而错了——虽然大部分播放器不会表现出区别)。同理,如果要用colorspace vf,也别用iall和all了,而是用ispace和space(但是这个vf要求你必须把所有的转换都显式写出来才能运行,所以你得用超长的 -vf colorspace=iprimaries=bt709:primaries=bt709:ispace=smpte170m:space=bt709:itrc=bt709:trc=bt709:format=yuv420p)。(这里用 bt601 或者 smpte170m 都可以,一样的。)

转换BT.601 Matrix到BT.709

OK,我们已经讲了如何修复SP录制的视频的P的tag的问题,但是他这个BT.601 NTSC的Matrix,虽然正常的播放器播放不会有啥大问题,但是为了兼容性(而且我们本来就要二压),还是把他转成BT.709吧。我们已经知道了P和T没有转换的必要,所以只需要转换M。

基本上,之前提到过的那些转静态图的命令都能用。唯一需要注意的是某些滤镜,例如scale,会复制旧视频的metatag而不是生成新的BT.709的,需要显式覆盖掉。

# in_color_matrix不用写,默认是auto。需要手动加output的metatag覆盖。
ffmpeg -i bt601.mp4 -vf scale=out_color_matrix=bt709:flags=accurate_rnd+full_chroma_int -colorspace bt709 -color_primaries bt709 -color_trc bt709 scale.mp4

# 因为P的tag错误,所以别用iall,逐个显式声明罢。Output metatag自动加。
ffmpeg -i bt601.mp4 -vf colorspace=iprimaries=bt709:primaries=bt709:ispace=smpte170m:space=bt709:itrc=bt709:trc=bt709:format=yuv420p -sws_flags accurate_rnd+full_chroma_int colorspace.mp4

# Input不能自动识别,需要显式声明。Output metatag自动加。
ffmpeg -i bt601.mp4 -vf colormatrix=smpte170m:bt709,format=yuv420p -sws_flags accurate_rnd+full_chroma_int colormatrix.mp4

# 还是推荐用zscale了,精度高。Input自动识别,output metatag自动加
ffmpeg -i bt601.mp4 -vf zscale=matrix=709,format=yuv420p zscale.mp4

用NVEnc的话,是

nvencc64 -i input.mp4 --vpp-colorspace matrix=smpte170m:bt709 -o output.mp4 --audio-copy --colormatrix bt709 --colorprim bt709 --transfer bt709 --colorrange limited

NVEnc的输出默认没tag,这里显式加上正确的tag(虽然一般播放器都会guess所以还好)。

参考文献