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的样子。

如果用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的转换滤镜开启时,它并不收P010,如果你强制在LAV那边只勾选P010,Pot会直接强制自行关闭转换滤镜……嘛,纯粹是好奇罢了,毕竟我们的目的恰恰是LAV不要用10bit输出。

另外,使用EVR (Custom Present)+开启auto coloir space开关+LAV这个组合时,除了会有前面多次提过的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,所以还是算了。

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

一个更好的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文件名或者解压后的目录名}