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

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

from pathlib import Path
import zipfile
import sys
import json
from shutil import copy2
from PIL import Image
# pip install Pillow -U
SETTING_SAVE_FORMAT = '.bmp'
SETTING_AUTO_CROP_ANIMEDIA = True
def load_json(filename):
filename = Path(filename)
with filename.open('r', encoding='utf-8') as f:
data = json.load(f)
return data
def main(input_file):
input_file = Path(input_file)
if input_file.is_file() and input_file.suffix == '.zip':
p = input_file.with_suffix('')
with zipfile.ZipFile(input_file, 'r') as zf:
zf.extractall(p)
elif input_file.is_dir():
p = input_file
else:
print(f'[E] Invalid input {input_file}.')
sys.exit(1)
coords = load_json(p / 'coords.json')
metadata = load_json(p / 'metadata.json')
def get_dummy(identifier):
dummy_width = 0
dummy_height = 0
keyword = f'/{identifier}.xhtml'
idx = None
for key, value in metadata['normal_default']['t1b'].items():
if keyword in key:
idx = value
break
assert idx is not None
info = metadata['normal_default']['files'][idx]['FileLinkInfo']['PageLinkInfoList'][0]['Page']
if 'DummyWidth' in info:
dummy_width = info['DummyWidth']
if 'DummyHeight' in info:
dummy_height = info['DummyHeight']
return dummy_width, dummy_height
raw = p / 'raw'
raw.mkdir(exist_ok=True)
for f in [f for f in p.iterdir() if f.suffix == '.jpeg']:
processed = False
print(f'[I] Processing {f.name}…')
img = Image.open(f)
new = Image.new("RGB", img.size)
try:
coord = coords[f.name]
if coord: # images that are NOT encoded have [] as coord
for pos in coord:
if isinstance(pos, dict) and 'srcX' in pos:
part = img.crop((pos['destX'], pos['destY'], pos['destX'] + pos['width'], pos['destY'] + pos['height']))
new.paste(part, (pos['srcX'], pos['srcY'], pos['srcX'] + pos['width'], pos['srcY'] + pos['height']))
else:
part = img.crop((pos[0], pos[1], pos[0] + pos[2], pos[1] + pos[3]))
new.paste(part, (pos[4], pos[5], pos[4] + pos[6], pos[5] + pos[7]))
processed = True
except Exception as e:
print(f.name, e)
dummy_width, dummy_height = get_dummy(f.stem)
if dummy_width > 0 or dummy_height > 0:
processed = True
print(f'[I] {f.name} has bleeding of ({dummy_width}, {dummy_height}). Cropping…')
new = new.crop(
(0, 0, new.size[0] dummy_width, new.size[1] dummy_height))
# Only re-save if the image was actually processed (cropped, re-arranged, etc.)
if processed:
f.rename(raw / f.name)
new.save(f.with_suffix(SETTING_SAVE_FORMAT))
else:
print(f'[W] {f.name} is not actually processed. Keep the original JPEG file.')
copy2(f, raw / f.name)
# automatically crop images from Seiyuu Animedia, Animedia and Megami (all from the publisher Gakken Plus)
if SETTING_AUTO_CROP_ANIMEDIA and any(mag in p.name for mag in ['アニメディア', 'メガミマガジン']):
backup = p / 'rearrange_only'
backup.mkdir(exist_ok=True)
print(f'[I] This book ({p.name}) is SeiA or Animedia. Cropping…')
for f in p.iterdir():
if f.suffix in ['.jpeg', SETTING_SAVE_FORMAT]:
print(f'Processing {f.name}…')
im = Image.open(f)
w, h = im.size
if w == 1851 and h == 2339:
im_cropped = im.crop((8, 18, w 8, h 2))
else:
print(f'[E] {f.name} has wrong dimension!')
continue
f.rename(backup / f.name)
im_cropped.save(f.with_suffix(SETTING_SAVE_FORMAT))
print('[I] Done!')
if __name__ == '__main__':
if len(sys.argv) == 2:
input_file = sys.argv[1]
main(input_file)
else:
print('Usage: bw.py <input zip file or folder>')
view raw bw.py hosted with ❤ by GitHub
// ==UserScript==
// @name bookwalker 图片提取
// @namespace Aloaxf
// @version 2.1
// @description 提取 bookwaler 中的图片
// @author Aloxaf, fireattack
// @updateURL https://gist.github.com/fireattack/384f01e76c3e42f6e24d97e8b56a6387/raw/d676569681ee71b45cf3a724499cd3c702d527fa/userscript.user.js
// @match https://pcreader.bookwalker.com.tw/*
// @match https://viewer.bookwalker.jp/*
// @match https://viewer-subscription.bookwalker.jp/*
// @icon https://www.google.com/s2/favicons?domain=bookwalker.com.tw
// @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@2.0.4/dist/FileSaver.min.js
// @require https://cdn.jsdelivr.net/gh/Stuk/jszip@3.5.0/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/gh/stuk/jszip-utils@0.1.0/dist/jszip-utils.min.js
// @grant unsafeWindow
// ==/UserScript==
function addToZip(name, url) {
let content = new Promise((resolve, reject) => {
JSZipUtils.getBinaryContent(url, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
imagepack.file(name, content, { binary: true });
}
function drawImageHook(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) {
if (image.src && image.src.indexOf('.jpeg') != 1) {
let name = image.src.match(/(?<=\/)[^/]+(?=\.xhtml)/)[0] + ".jpeg";
let count = Object.keys(coords).length + 1;
if (!coords[name]) {
console.log(`[${count}] downloading ${name}`);
addToZip(name, image.src);
coords[name] = new Set();
}
coords[name].add([sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight]);
}
return CanvasRenderingContext2D.prototype._drawImage.apply(this, arguments);
}
function setToJson(key, value) {
if (typeof value === 'object' && value instanceof Set) {
// remove duplicates
let arr = [];
let arr_str = [];
for (let item of value) {
if (!arr_str.includes(item.join(','))) {
arr.push(item);
arr_str.push(item.join(','));
}
}
return arr;
}
return value;
}
let imagepack = new JSZip();
let coords = {};
let autoscroll = false;
CanvasRenderingContext2D.prototype._drawImage = CanvasRenderingContext2D.prototype.drawImage;
CanvasRenderingContext2D.prototype.drawImage = drawImageHook;
unsafeWindow.saveAs = saveAs;
unsafeWindow.saveAsZip = () => {
console.log('Start saving zip file, it may take awhile…')
imagepack.file('metadata.json', JSON.stringify(NFBR.a6G.Initializer.F5W.menu.model.get('o3r')['content']));
imagepack.file('coords.json', JSON.stringify(coords, setToJson));
imagepack
.generateAsync({ type: "blob" })
.then(blob => {
saveAs(blob, NFBR.a6G.Initializer.F5W.menu.model.get('contentTitle') + " [BW].zip");
console.log('Done.');
});
};
setTimeout(function () {
if (confirm('Start auto-scroll?')) {
var totalPageCount = NFBR.a6G.Initializer.F5W.menu.model.get('F8O');
var index = {};
var o = NFBR.a6G.Initializer.F5W.menu.model.get('o3r').content.normal_default.t1b;
for (const property in o) {
index[o[property]] = property.match(/(?<=\/)[^/]+(?=\.xhtml)/)[0] + ".jpeg";
}
NFBR.a6G.Initializer.F5W.menu.a6l.moveToFirst();
setTimeout(function () {
var timer = setInterval(function () {
var currentPage = NFBR.a6G.Initializer.F5W.menu.model.get('viewerPage');
var nextPage = 1;
for (const i in index) {
if (!Object.keys(coords).includes(index[i])) {
nextPage = Number(i);
break;
}
}
if (nextPage > currentPage + 2) { //在首页只会自动load 0、1、2这三页,所以最多+2 否则会卡住
console.log('Changing page to ' + nextPage);
NFBR.a6G.Initializer.F5W.menu.a6l.moveToPage(Number(nextPage));
}
if (Object.keys(coords).length == totalPageCount) {
console.log('Downloaded all the pages!');
if (confirm('Parsing finished. Save zip?')) unsafeWindow.saveAsZip();
clearInterval(timer);
}
}, 200);
}, 1000); //+1s
}
}, 6000); //6秒后开始自动载入
view raw userscript.user.js hosted with ❤ by GitHub
// ==UserScript==
// @name bookwalker 图片提取 V2
// @namespace Aloaxf
// @version 3.1
// @description 提取 bookwaler 中的图片
// @author Aloxaf, fireattack
// @updateURL https://gist.github.com/fireattack/384f01e76c3e42f6e24d97e8b56a6387
// @match https://pcreader.bookwalker.com.tw/*
// @match https://viewer.bookwalker.jp/*
// @match https://viewer-subscription.bookwalker.jp/*
// @icon https://www.google.com/s2/favicons?domain=bookwalker.com.tw
// @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@2.0.4/dist/FileSaver.min.js
// @require https://cdn.jsdelivr.net/gh/Stuk/jszip@3.5.0/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/gh/stuk/jszip-utils@0.1.0/dist/jszip-utils.min.js
// @grant unsafeWindow
// ==/UserScript==
function addToZip(name, url) {
let content = new Promise((resolve, reject) => {
JSZipUtils.getBinaryContent(url, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
imagepack.file(name, content, { binary: true });
}
let imagepack = new JSZip();
let coords = {};
let downloaded = new Set();
unsafeWindow.saveAs = saveAs;
unsafeWindow.saveAsZip = () => {
console.log('Start saving zip file, it may take awhile…')
imagepack.file('metadata.json', JSON.stringify(NFBR.a6G.Initializer.F5W.menu.model.get('o3r')['content']));
imagepack.file('coords.json', JSON.stringify(coords));
imagepack
.generateAsync({ type: "blob" })
.then(blob => {
saveAs(blob, NFBR.a6G.Initializer.F5W.menu.model.get('contentTitle') + " [BW].zip");
console.log('Done.');
});
};
function hook() {
const backup = NFBR.a6G.a5x.prototype['U8j'];
NFBR.a6G.a5x.prototype['U8j'] = function () {
const [targetCanvas, page, image, drawRect, flag] = arguments;
if (image && image.src && image.src.indexOf('.jpeg') != 1) {
let name = image.src.match(/(?<=\/)[^/]+(?=\.xhtml)/)[0] + ".jpeg";
let count = Object.keys(coords).length + 1;
if (!coords[name]) {
console.log(`[${count}] downloading ${name}`);
addToZip(name, image.src);
if (page.u5T === undefined) coords[name] = [];
else coords[name] = NFBR.a6G.Initializer.F5W.renderer['n0v'](page, image.width, image.height);
downloaded.add(page.index);
}
}
return backup.apply(this, arguments);
};
console.log('Hook successful!')
}
setTimeout(function () {
hook();
if (confirm('Start auto-scroll?')) {
var totalPageCount = NFBR.a6G.Initializer.F5W.menu.model.get('F8O');
NFBR.a6G.Initializer.F5W.menu.a6l.moveToFirst();
setTimeout(function () {
var timer = setInterval(function () {
let nextPage = Math.max(downloaded) + 1;
let currentPage = NFBR.a6G.Initializer.F5W.menu.model.get('viewerPage');
if (nextPage > currentPage + 2) { //在首页只会自动load 0、1、2这三页,所以最多+2 否则会卡住
console.log('Changing page to ' + nextPage);
NFBR.a6G.Initializer.F5W.menu.a6l.moveToPage(nextPage);
}
if (downloaded.length == totalPageCount) {
console.log('Downloaded all the pages!');
if (confirm('Parsing finished. Save zip?')) unsafeWindow.saveAsZip();
clearInterval(timer);
}
}, 200);
}, 1000); //+1s
}
}, 6000); //6秒后开始自动载入

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

今天在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所以还好)。

参考文献

直播平台Stagecrowd的画质问题

Stagecrowd(下称:SC)是大网络直播时代索尼赶鸭子上架搞的第一方直播平台。各个地方都透出一股草班台子的味道,比如不同直播的账号不互通等等,这里按下不表,专门来黑一下画质问题。

Go for a Sail STUDIO LIVE

第一次用SC是看8月的“LAWSON presents TrySail 5th Anniversary Go for a Sail STUDIO LIVE”。当然,这个活动本身就是音雨骗钱登峰造极之作,下面简单罗列下:

  1. 分割放送上下part,而且事先不公布,硬是在part1播完后才说还有part2。
  2. 每个part都有两个aftertalk,预购只能看aftertalk1,需要专门购买回放门票(“見逃し配信チケット”)才能看(且只能看)aftertalk2。
  3. 播放前有个所谓的“直前talk”,只有预购并且直播才能看到,timeshift没有。(但是,由于他们弱智,part1的timeshift其实并没有剪掉,是可以看到直前talk的233。在part2里修复了这个“bug”。)

这玩意的复杂程度之高,官方甚至“很贴心地”专门准备了个表格来罗列不同票的情况。总之,要看到所有内容,你需要付3800*4+手续费差不多小两万。

另外,官方还在B站进行了转播。票价便宜得多倒是无所谓,但是在回放时,居然事先无预告的突然包含了aftertalk2,让刚交了4000的我和吃了苍蝇一样恶心(甚至没忍住去动态喷了一波)。

不过搞笑的是,放part2的时候(只有录播,part1时延迟半小时突击加的字幕由于太差闹了大笑话,估计不敢了),虽然官方说明依然是是包含aftertalk2,但是实际并没有(不过好像也没有任何人发现的样子)。另外也没有直前talk。总之算是有点心理安慰……

呃跑题了,让我们来继续说画质。这个活动是直播talk+播片的形式,客观来讲直播部分的画质还是不错的,码率够。

但是播片的部分(正片)画质奇差,码率低全屏马赛克不提,最重要的是颜色完全错误,整个画面偏深,这点和B站版的片源一比就可以很明显看出:

SC直播版
B站直播版

注意这里还仅仅是B站直播的720P版,在回放时升级成1080P画质会更好一些。不过,这个我怀疑不是SC的问题,是片源没提供对(反正你们都是索尼一起骂了吧),因为在Part2没有这个颜色问题。更吊诡的是我搞不懂这个画质是怎么搞出来的,一开始以为是缺了一次TV/PC色域伸张,但是研究了下发现并不是,不懂了不懂了,反正有BD,不纠结。

另外一个问题就是timeshift(archive)的画质差异问题了。一般而言,这类平台的timeshift有很多种情况,有的就是把直播的源直接再摆出来(比较常见),也有回放重新压制的——由于没有串流的严格要求,回放可以码率、压制参数更高一点。当然,这里有个重要的前提,就是你得用的是更“raw”的源来压制才有意义,否则直接把直播的视频流二压一遍,不管用多高的参数,画质只可能劣化。比如YouTube的回放就是这样,经过服务器转换过一次后,画质只会变差(之前虾泥的感谢祭直播,甚至出现了回放视频比例变扁的搞笑现象)。

SC这里,很不幸地就属于后者。根据前面描述的商法,可以知道有三个视频档:直播,直播的archive,“見逃し配信チケット”进的录播。由于每个都有剪辑的需求(直播的archive不包含直前talk,录播版不包括直前talk且aftertalk从1换成了2),所以内容是不一样的。这里我是不清楚SC是在线提供粗剪功能还是线下剪好再上传(大概率是后者),无论如何,事实就是每个版本的视频画质都有肉眼可见的下降,估计是直接用直播流剪辑二压的:

直播版
直播的archive
見逃し配信チケット

这里截取了正片前的短暂talk(不是直前talk),也就是三个片源都有的部分来对比。当然介于不同视频关键帧不一定一样,这样随便选一帧对比不是100%科学,但是看衣服的纹理也可以看出二压有多过分了:

花纹逐渐消失

再补个B站版对比。虽然加了一次硬字幕,但是画质几乎和SC直播版一致,赞一下。

B站版

哦对了,码率方面SC的直播、录播都是4Mbps左右,当然这个数据本身没啥意义,颜色都不对、或者你播片的片子本身都糊成一坨了nominal码率再高有啥用呢。

TrySailのMusicRainbow07

时间来到前几天,这次观看的是MR07,播片(非生放送)。直播观看其实感觉画质还好,有点欠码但是没大问题。于是让我们来看看archive版。

这次和上次不同,archive版是个单独的片源,而且码率有提升(直播:4Mbps,archive:7Mbps)。但是,又出了新的乌龙:颜色又㕛叒叕错了。先上对比:

直播版
Archive版

如果对比细节,会发现archive版画质确实略微有所提升,但是这个颜色是怎么回事?为什么又变深了?还好,这次的原因很简单:两者错了一次gamma——一个是gamma1.8,一个是gamma2.2的效果(至于为啥是这俩数字,一个是Mac一个是PC,具体自己看维基)。如果要修复,只要把后者加个1.22 (2.2/1.8)即可还原成和前者一样的效果(不同软件对gamma或者gamma correction的定义不同,有的软件可能需要用倒数。这里是指PS色阶gamma改 1.22):

PS修改gamma方式 很多播放器也提供播放时修改gamma的功能

但是我们怎么知道哪个是正确的呢?毕竟有的人可能会觉得反而是上面的太亮。这就需要客观对比了。在转场画面,会出现本次活动的logo:

虽然我没有找到这个logo本身的的高清大图,但是在音雨商店的banner里可以找到一个类似的大图

我们这里只需要把七色色段部分提取出来,然后对比颜色就行了。其实都不需要进PS吸管,肉眼就可以看出明显是直播版颜色更接近这个。

以上是SC内部的颜色问题。那么和其他直播平台比如何呢?这里用Zaiko的来对比。之前就有听说Zaiko码率更高一点(5Mbps),逐帧对比的话,确实细节要多点。但是重点不在这里,而是两者的颜色又有细微的差别:

SC直播版
Zaiko版

可以看到,两者的颜色基本是一致的——但是仔细看,会发现SC版会在接近白色的地方有过爆的问题,注意右上角背景和鼻子高光。下面这帧可能更明显:

左:SC 右:Zaiko

为什么会出现这种现象?鬼知道。可以确定的是并不是错误地进行了一次色域伸张那么过分,两者的平均亮度只错了1(RGB)而已。另外也不是BT601/709的区别。我拆成YUV通道对比了下,可以看到只有U通道有较大区别:

左:SC 右:Zaiko

更搞笑的是,如果把archive版那个gamma错误的图,用PS色阶修复一下……

反而是和Zaiko版一样的正确版,而不是直播的那个过爆版(注意看手上的高光)!

(顺便Mocho这皮肤该保养了。)

这次是没办法已经买过票,以后可能尽量不会再买SC了。但是听说Zaiko不能多端登录(我自己没试过),不便于和别人拼车。

僕だけに見える星

好久没更新,正好今天Live,fan from home(笑)也没啥事做随便写点相关杂记。

歌曲

这次两首都不错,尤其喜欢c/w的『あしあと』,瞬间可以挤进前五!Mocho的single的c/w一向很对我胃口,掐指一算甚至比主打更喜欢的次数更多点(花に赤い糸、箱庭ボーダーライン、No Distance、シュークリーム,さよなら観覧車也算和365×LOVE打平)。说实话我也不知道这种曲风叫啥(访谈是说R&B),和三森那个『アレコレ』差不多。

主打么感觉副歌部分一般,mocho对于这类高音的驾驭能力还不太行,发声方式感觉不是很符合甜美的声线。之前专辑里的『秘密のアフレイド』也有同样的问题。

MV

和上次的『今すぐに』一样,这次依然可以在Apple Music/iTunes获取1080p版。不过这次有个小插曲,我购买了之后本来想顺手塞份b站,结果被提示撞车:

结果下面那个“撞车”的链接还打不开。用biliplus的API获取了下发现是索尼音乐中国传的,挂代理看了下那个账号发现,和其他流媒体服务类似,索尼也开始在B站提供最新MV的更新了。好处是介于国内付费习惯的问题,不像Apple Music或者YouTube Premium Music,不用购买高会即可观看(只是要有大陆IP)。当时看到时候(刚更新几个小时)这个MV还只有十几的播放,毕竟用的是罗马字名字+tag,一般估计很难搜到。后来好像在圈里传开了,现在已经飙到了一千多。

画质方面,iTunes版虽然是1440×1080,但是凭借着5Mbps的码率还是在动态方面秒了B站的3M出头的版本;但是如果是静态画面,那B站版毕竟像素多还是要锐度更高一些。总之各有优缺。这里share下iTunes版。

重点批评下辣鸡YouTube Music,本来那屎一样的UI(作为音乐服务)如果不是订阅频道有推送,根本几乎无可能找到那MV(地址);而且非日本时区好像得等到当地时间到了11日才能看(否则得挂日本代理)。最重要的是,画质非常差,码率低不谈,颜色不对,甚至比例也不对——要横向缩小到1904px才对(外面的像素?自然被它给出血掉了)。难道你也是我之前黑过的mora那样按ITU DAR瞎转换?

下面对比:

iTunes版
B站版
YouTube h264版 (注意颜色,深色部分已经有点clip)
YouTube VP9版 (只有30M,画质最可怕)
附赠DVD版:DAR已经修正。注意上下出血

MV内容没啥好点评的,很喜欢这个条纹衣服(想起kraz去年Live那个睡衣)。

封面

这次封面倒是简单,没有一万个版本,基本所有的颜色都是一致的,那只需要找个画质最好的就行了。目前看到最大的是akiba-souken的1644p(图片URL去掉t640_获得原始画质)(Amazon也是同样大小,但是压缩率极高)。

iTunes的3000px依然是upscale的,不要用(顺便吐槽,这些流媒体网站对于非正方形的封面总是很多余的加白边,在很多播放器里都巨丑,求求你们不要了好吗)。

mocho的碟似乎通常半身照/初回全身照都快成惯例了。我还是喜欢大头照,一般移动设备都用那个。

公式照

除了封面还有个同一时期拍摄的新公式照,被用在官推等大量地方。这个图的最大图同样在上面的访谈的第一页可以找到。但是,这个接近正方形的其实是剪裁版,原始长方形版可以在SonyMusicShop以及mocho SME官网看到(虽然尺寸小很多)。不幸的是这里则出现了颜色的不一致:SME官网的和别人都不一样,明显发蓝。

按照经验,发蓝一般是CMYK的CMY通道错误强制当成RGB用导致的;但是我按照前文所述的方法反向了下(试了PS自带的大部分CMYK profile),色调还是差不少。右边这图也有点意思,虽然是RGB但是里面却莫名其妙的内嵌的有个“SMC_coated_CMYK2006”的CMYK的ICC文件,直接导致在我explorer没法生成略缩图(PS倒是能打开,会报个错)。这有两种解释:图片确实正确地转换成RGB了,只是ICC莫名地没去掉;或者图像其实应该是CMYK但是错误地保存成了CMYK。介于几乎所有地方都用的是这个版本,外加和其他宣传图(特典等)的色调也一致,我倾向于这图颜色本身是没有错的,只是残留了ICC而已。我也试过把这个ICC提取出来套到SME那张偏蓝的图上当做profile,没什么进展。

更新:刚写完就发现,Apple Music的公式照更新了,也是这个偏蓝的颜色;外加animate的特典似乎也是类似的颜色(明度高一些)。但是A/G的宣传卡则是和官图一致,我现在已经彻底不确定哪个才是正确的了……