Kindle 竟也可以播放 Bad Apple?!

当然.

但本文是一篇水文.因为就目前而言,我所使用的播放方法不能在保持分辨率足够的情况下以较高帧率(如 24fps) 播放,而且我也不是第一个研究 Kindle 播放视频的人.虽然我的玩法可以说是前无古人(借助 Node.js), 但很显然是不完美的.所以这篇文章不是严格的技术向文章,同时也没有演示视频——因为播放效果真的很不理想.


接触 Bad Apple!! 这玩意,始于在 linux 吧里看到的这个视频: https://www.bilibili.com/video/av1815346/ (至今我仍然认为这个视频是技术含量最高的几部作品之一).那是一两年以前的事了.当时感觉打开了新世界的大门,看了各路大神的各种玩法,心向往之.后来入了 Kindle, 玩越狱,在国外的 MobileRead 论坛上看到有大神给 Kindle 移植了一份 mplayer, 居然支持以 framebuffer 方式输出,无比震惊,于是开始尝试在 Kindle 上播放视频.然而,也许是由于移植版 mplayer 年久失修,我从来没有成功过一次,非常沮丧.又过了一段时间,入 Node.js 坑.毕竟这些年来(好吧,也没几年)看了不少关于 Bad Apple!! 的文章,各种玩法我都略知一二,于是觉得借助于 Node.js 绝对可以搞定.

是的,我的思路是以 ASCII art 方式输出视频. ASCII art 领域自古大神辈出,已有多种成熟的解决方案:

  1. 在播放时读取每一帧的像素信息,转换为灰度数值,再转换为相应的字符绘制到屏幕上;
  2. 预先将视频分解为每一帧的截图,把每幅图片转换为单个文本文件,再循环遍历所有文本文件内容并输出到屏幕上;
  3. 还有其它的奇葩方法如:先把视频转换为 GIF 动图,再把 GIF 每一帧抽取出来转换为字符形式,等等.

基本上,这些思路都是可以通过 JavaScript 实现的.然而实际操作起来并没有那么简单.第一个拦路虎是,我必须自行为 Kindle 交叉编译 Node.js. 官方提供的 ARM 预编译版本不支持 Kindle, 民间也没有人尝试过把 Node.js 移植到 Kindle 上,只能自己动手.实际上,很久以前我就尝试过为 Kindle 交叉编译 mplayer, 以失败告终.不过幸运的是,这一次我排除万难,终于成功了.相关内容见:在 Kindle 电子书上部署 Node.js 运行环境.

(实际上,随便一种脚本语言都可以实现上述那些思路,而且 Kindle 也支持 Bash, Lua, Python 等语言,执意用 JavaScript 是有点吃力不讨好)

完成 Node.js 移植意味着我朝目标前进了一大步.接下来的问题是,该选用哪种思路去实现?我在 Google 和 GitHub 上查阅了大量相关项目,很多项目给我了莫大的启发,但没有一个是适合 Kindle 这个特殊的环境的.有一些实现了图片/视频转字符的 JavaScript 库只能在浏览器环境中运行,不支持 Node.js; 另外一些支持 Node.js 的库,要么不能处理视频,要么依赖于 C/C++ 模块,难以在 Kindle 上部署.所幸, aaa.js - Animated ASCII Art with Audio 这篇文章简单地提及了将视频转换为文本文件的方法(即上述的方案 2), 给了我很大的帮助(虽然那篇文章的主角 aaa.js 根本没法在 Node.js 里面跑).

那就这样愉快地决定了,我最终选择了(技术含量最低的)方案 2. 需要使用以下两个非常重要的脚本:

# -- to_aa.sh --
#!/bin/bash

mplayer -nosound -vo png bad-apple.mp4
# Use MPlayer to convert video to PNG

for i in $(ls *.png); do
# echo "$(basename $i .png)"

  convert "$i" "$(basename $i .png).pgm" && rm "$i"
  # Convert PNG to PGM with ImageMagick

  echo -e "s1\b\b80\n\b\b60\n$(basename $i .png)\n18nq" | aview -kbddriver stdin -driver stdout "$(basename $i .png).pgm" > /dev/null && rm "$(basename $i .png).pgm"
  # Generate ASCII Art with `aview`
done

以上脚本会将视频转换为文本文件.需要先安装 mplayer, ImageMagick 和 aview 三个程序.第 12 行的 80 和 60 分别定义了最终产出文本文件的横向/纵向字符数.

// -- to_json.js --
var fs, results, source_path, txt, txts;

fs = require("fs");

source_path = "./";

txts = fs.readdirSync(source_path).filter(function(x) {
  return ".txt" === x.substr(-4);
}).sort();

results = (function() {
  var i, len, results1;
  results1 = [];
  for (i = 0, len = txts.length; i < len; i++) {
    txt = txts[i];
    results1.push(fs.readFileSync("" + source_path + txt).toString());
  }
  return results1;
})();

fs.writeFileSync("output.json", JSON.stringify(results));

以上脚本会将之前产出的大量文本文件全部塞进一个 JSON 文件里.这个脚本是从 CoffeeScript 里转译出来的,所以看起来有些奇怪.

以上脚本都来自 aaa.js - Animated ASCII Art with Audio, 经我小幅修改而成.

从网上下载到 Bad Apple!! 的 MP4 格式视频,通过以上脚本处理后得到 output.json ,传到 Kindle 上,用以下脚本来播放:

var fs = require('fs');

function sleep(milliSeconds) {
    var startTime = new Date().getTime();
    while (new Date().getTime() < startTime + milliSeconds);
 }

var data = fs.readFileSync('./output.json','utf8');
data = JSON.parse(data);

var interval = 1 / 24 * 1000;
var length = data.length;
for (var i = 0; i < length; i++) {
    console.log('\033[H\033[J');
    console.log(data[i]);
    sleep(interval);
}

以上脚本定义了播放速度为 24fps, 即每帧 41.67 毫秒(关于这个播放速度,后文还会提到).

倒数第四行向屏幕写入了一个奇怪的字符串 '\033[H\033[J' ,这其实是个转义序列,作用是清屏并复位光标位置.

在 Kindle 上运行 kterm, 执行上述脚本即可看到视频.顺便说下 kterm. 双指点击屏幕任意位置可以呼出 context menu, 建议在播放时把字号调小,并隐藏虚拟键盘.

等等...好像有点不对?原本分辨率为 512x384 的视频被以竖屏播放,而且长宽比还非常诡异, Bad Apple!! 里的小姐姐们被压得又长又扁,不忍直视.问题在于,虽然我们输出的文本文件确实是 80x60 的"分辨率",但由于行间距不等于列间距,而且每个字符的高度远大于宽度,使得最终的效果很是畸形.

文字间距和字体的宽高比其实不是那么容易调整的...好在我有一个比较靠谱的改善措施:把视频旋转 90°, 并增加字符数量(也就增加了最终播放时的分辨率).这样,既可以以近似全屏的方式播放,也可以减少由长宽比带来的畸变.旋转视频倒好办,随便找个工具都可以做到.但是,如何确定合适的字符数量?

上述脚本中使用了 80x60 的"分辨率",也就是每帧 4800 个字符.正如你所见到的那样,在较小字号下,画面只占了屏幕的很小一块区域. kterm 终端默认的字号是 7 (不知道是什么单位),最小可以调到 1. 在字号为 1 的情况下,屏幕上显示的每个字符只占几颗像素,已经完全不可阅读了.很显然,以如此小的字号来呈现视频,可以达到很好的清晰度.

通过 stty size 命令可以获知当前状态下终端每屏可显示的横纵字符数(或者叫 row/column 数).但由于 kterm 终端有一块虚拟键盘,会占用部分屏幕面积,你肯定不希望把这块面积浪费掉,所以需要这样做:执行 sleep 3 && stty size ,然后用你最快的手速,双指点击屏幕呼出 context menu, 选择隐藏键盘,然后看返回值是多少(好吧,没有这么紧张刺激,你可以自己调整延迟执行命令的时间).

于是我得知在字号为 1 时, kterm 最多能在一屏内显示 199x598 个字符.修改 to_aa.sh :

# -- to_aa.sh --
#!/bin/bash

mplayer -nosound -vo png Bad-apple-rotated.mp4
# Use MPlayer to convert video to PNG

for i in $(ls *.png); do
# echo "$(basename $i .png)"

  convert "$i" "$(basename $i .png).pgm" && rm "$i"
  # Convert PNG to PGM with ImageMagick

  echo -e "s1\b\b598\n\b\b199\n$(basename $i .png)\n18nq" | aview -kbddriver stdin -driver stdout "$(basename $i .png).pgm" > /dev/null && rm "$(basename $i .png).pgm"
  # Generate ASCII Art with `aview`
done

(注意第 4 行,这里操作的是已旋转 90° 的视频文件)

经过漫长的等待后(并不奇怪,修改分辨率后运算量扩大了好几倍),数以千计的文本文件被顺利地产出.于是运行 to_json.js ...

最喜闻乐见的事情发生了! Core Dump, JavaScript 堆栈爆掉!看来我们还是太激进了,为了追求极致的分辨率,数据处理量已经超过了 Node.js 的上限(并且后续的事实证明,就算堆栈不爆,播放时高负载的运算也会让 Kindle 卡成一坨).

好吧,降低分辨率.个人认为,字号调到 3 是不损失观感的极限.在该字号下, kterm 每屏可显示 88x119 个字符.修改 to_aa.sh 并执行.我这次为了避免 coredump 惨剧重演,并没有把文本文件塞进 JSON 里,几千个 .txt 就这样原封不动地拷进 Kindle.于是这一次需要新的播放脚本:

var fs = require('fs');

function sleep(milliSeconds) {
    var startTime = new Date().getTime();
    while (new Date().getTime() < startTime + milliSeconds);
 }

function getFileName(name) {
    var str = '00000000' + name;
    return str.substring(str.length - 8, str.length)
} // 所有的 .txt 文件名都是八位纯数字,本函数用于补全位数

var data,
    interval = Math.floor(1 / 24 * 1000), // Math.floor() 是为了抵消即时读取文件内容产生的性能损失.但事实证明,毫无卵用.
    length = 6576; // 共有 6576 帧

for (var i = 1; i < length; i++) {
    data = fs.readFileSync(`./output/${getFileName(i)}.txt`, 'utf8');
    console.log('\033[H\033[J');
    console.log(data);
    sleep(interval);
}

我试着播了一下,哇草,不吹不黑,真·0.1fps.这 tm 还能看?

(不过就每一帧的视觉效果而言,真的很不错.点击可看大图.)

于是我又换用了不同的几种分辨率,都很不理想,要么根本没法播放(在 Kindle 上执行脚本后,直接返回一个 Killed ),要么刷新率感人.我这里说的"刷新率感人",指的是 Node.js 自身的运行效率,而不是 Kindle 墨水屏的刷新率.具体来说,我看过其他人在早期型号的 Kindle 上播放 Bad Apple!! 的视频,他们用的方案估计是 framebuffer, 虽然画面各种撕裂各种残影,但视频本身的播放速度是没变的,保持在 24fps; 但我搞出来的这玩意,虽然撕裂残影没那么严重,但视频本身的播放速度被严重地拖慢了,犹如慢动作.

为了兼顾分辨率与刷新率,我最终仍然选择了 80x60 的分辨率,在 4 字号下播放(每屏可显示 66x99 字符).这应该是使用 Node.js 能达到的最佳状态了.然而,刷新率依然一塌糊涂.纵使我丧心病狂地把每帧延时调小到 1/60 秒(即指定 60fps 帧率), Kindle 播放这个总长 3 分 39 秒的视频仍然需要 4 分 10 秒.这已经是极限了.

文章开头我说了,不会有演示视频.不过行文至此,还是给个视频吧.这毕竟是我第一次在一台非主流设备上把 Bad Apple!! 捣鼓出来,一直以来的愿望终于实现,还是很有纪念意义的.

更新:

视频已移除

(如果你仔细看了上边的视频,就会发现一个有趣的现象:在屏幕中间偏左的位置,有一条明显的分隔线.在分隔线左侧的画面闪烁非常严重,但右侧的画面则几乎没有闪烁或撕裂现象.究其原因,我认为是 Node.js 执行效率的锅:它无法在"一瞬间"完成写入一帧的所有数据到屏幕的工作,使得左侧的画面刷新总要慢半拍.由此,我猜想,如果进一步降低分辨率,使得画面的任一部分都不超出分隔线,那么也许会得到毫无闪烁的,非常完美的画面.但是我已经懒得再折腾了,就这样吧)

那么下一步,我将全力攻克 mplayer 的交叉编译,尽力实现在 Kindle 上以 framebuffer 方式播放视频.当然,难度可想而知.

以上.

2 thoughts on “Kindle 竟也可以播放 Bad Apple?!

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注