ANSI转义序列的应用

pic

25 Nov 2020

Author:wuguanxi


引子

在阅读 expressjs/generator源码 时,发现一句神奇的代码。

function write (file, str, mode) {
  fs.writeFileSync(file, str, { mode: mode || MODE_0666 })
  console.log('   \x1b[36mcreate\x1b[0m : ' + file)
}

其中的 console.log 输出的 create 会变成青色。

pic

搜索一些资料后发现,原来是 ANSI转义序列 的作用。

历史

ANSI转义序列(ANSI escape sequences)是一种带内信号的转义序列标准,用于控制终端上的光标位置、颜色和其他选项。

ANSI转义序列 第一个标准是1976年 ECMA(European Computer Manufacturers Association,欧洲计算机制造商协会)发布的 ECMA-48。

但是 “ANSI转义序列” 这个名称起源于 1979 年 ANSI(American National Standards Institute,美国国家标准学会) 发布的 ANSI X3.64,这个标准几乎与 ECMA-48 相同

即使 1994年,ANSI 取消了其标准,以支持国际标准, “ANSI转义序列” 这个名称依然保留下来。

ECMA-48已经经历了多次更新换代,目前是从1991年开始的第5版。它也被ISO和IEC用作标准ISO/IEC 6429。

ANSI 序列是在二十世纪七十年代引入的标准,用以取代特定于终端供应商的序列,并在二十世纪八十年代早期开始在计算机设备市场上广泛使用。

vt100

(vt100 是第一台支持ANSI转义序列的终端)

在 21 世纪,尽管硬件文本终端已经越来越少了,但 ANSI 标准依然存在,因为大多数终端模拟器会对部分 ANSI 转义序列进行解释。一个值得注意的例外是,在微软 Windows 10 更新 TH2 之前,Windows 操作系统的 Win32 控制台是不支持 ANSI 转义序列的。

ANSI转义序列 与 ASCII

ASCII 是基于拉丁字母的一套电脑编码系统,是美国国家标准学会(ANSI)发布的 ANSI X3.4 。ASCII 由电报码发展而来,至今为止共定义了 128 个字符;其中 33 个非显示字符, 95 个可显示的字符。用键盘敲下空白键所产生的空白字符也算 1 个可显示字符(显示为空白)。

pic

ANSI转义序列,就是利用了 ASCII 中的十进制27,十六进制1B,八进制033所定义的那个字符 -- ESC (退出键)。

下面是 ESC 在 javascript 中的字符串表达:

"\33"
"\033"
"\x1b"
"\u001b"
"\u{1b}"

转义序列

序列具有不同的长度。所有序列都以ASCII字符ESC(27 / 十六进制 0x1B)开头,第二个字节则是0x40–0x5F(ASCII @A–Z[\]^_)范围内的字符。

pic

ANSI转义序列有很多种,应用比较多的是 CSI序列。

CSI序列

CSI序列 由 ESC [、若干个(包括0个)“参数字节”、若干个“中间字节”,以及一个“最终字节”组成。

pic

可以看出, CSI序列 主要作用是控制光标、擦除、设置显示样式。其中的 SGR – 选择图形再现(Select Graphic Rendition)可以设置多种样式

不同终端的支持程度不同,下图是在 mac 系统下 Terminal.app 的 SGR 表现 (截图不太清楚,缓慢闪烁是支持的)

SGR

最新版的 chrome 控制台支持少量的 SGR ,主要是颜色相关的指令。

Chrome1

Chrome2

应用

下面的代码都用 node.jsconsole.log 做事例。node.js 下的 console.log,相当于往进程中写入 stdout 流。

改变终端输出文字的样式

回到一开始的例子。

function write (file, str, mode) {
  fs.writeFileSync(file, str, { mode: mode || MODE_0666 })
  console.log('   \x1b[36mcreate\x1b[0m : ' + file)
}

pic

从上图可以看出来 create 在终端输出的颜色被改变了。关键在于他前后的特殊转义序列 \x1b[36m\x1b[0m

\x1b 就是前面说的 ESC (退出键) 说明开始转义

\x1b[ 说明是一个 CSI序列

\x1b[36m\x1b[0m 说明是个 SGR,其中 36 是 青色, 0 是重置所有 SGR 属性。

除了青色外,还可以设置 31 -- 红色、 32 -- 绿色、33 -- 黄色,等等。

大量的配置比较繁杂,我们可以用一些开源库封装好的函数来使用。比如 chalk

进度条

pic

常见的进度条也是 CSI序列 的又一经典应用,核心是用 CSI n K EL – 擦除行(Erase in Line) 将上次输出的进度清除,再覆盖成最新的进度。

这里用到一个很实用的开源库 single-line-log

看看他的核心代码

// 这位作者用 16 进制写字符串是优点骚
// '\u001b[1000D'
// 光标后移(Cursor Back)
var MOVE_LEFT = new Buffer('1b5b3130303044', 'hex').toString();

// '\u001b[0K'
// 擦除行(Erase in Line)
var CLEAR_LINE = new Buffer('1b5b304b', 'hex').toString();

// '\u001b[1A'
// 光标上移(Cursor Up)
var MOVE_UP = new Buffer('1b5b3141', 'hex').toString();
...
str = '';
// Clear screen
for (var i = 0; i < prevLineCount; i ++) {
  str += MOVE_LEFT + CLEAR_LINE + (i < prevLineCount -1 ? MOVE_UP : '');
}
// Actual log output
str += nextStr;
...

代码上可见 MOVE_LEFT 就是 '\u001b[1000D' 光标后移(退格键的方向) 1000 格,因为终端很少会有达到 1000 列,这里相当于是把光标移动到当前行最左端。

CLEAR_LINE 就是 '\u001b[0K' 表示清除从光标位置到该行末尾的部分,因为前面光标已经在行头,这里相当于是整行清除。

MOVE_UP 就是 '\u001b[1A' 光标上移一格。

三个指令加一个 for 循环达到清屏的效果。

选择器

loading 的例子里利用 CSI 几个命令组合的刷新功能,成为终端命令行的里进行 UI 交互的基础。基于此,我们可以实现终端里的 select 选择器。

pic

开源库 Inquirer.js 就是用这个方法实现的。

彩蛋

众所周知,动画就是一帧帧快速刷新的画面,所以 CSI 的刷新能力是可以用来做动画的。下面就用 CSI 做一个终端版的 Bad Apple!!(【東方】Bad Apple!! PV【影絵】)。

虽然终端不支持显示图片,但是我们可以将图片的像素信息生成 ASCII 字符画。

pic

然后通过 渲染 -> 清空画面 -> 再渲染 的方式让画面动起来。

利用 ffmpeg

我们利用开源视频工具 ffmpeg 提取 Bad Apple!! 的所有帧。

ffmpeg -i Bad_Apple.flv -qscale:v 2 -f image2 screenshotsPath/%08d.jpg

然后借助 jpeg-js 获取图片像素信息,并通过转换器将像素信息转换成 ASCII 字符,并保存起来。

const jpeg = require('jpeg-js');
const jpegData = fs.readFileSync(screenshotsFilePath);
const rawImageData = jpeg.decode(jpegData, {useTArray: true, formatAsRGBA: false});
const ASCIItext = convertJPG(rawImageData);

convertJPG 的转换逻辑,是将提取到的每个像素的 R , G , B , 三个通道信息,转化为单一的灰度(gray)信息

const calcGray = ([b, g, r]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;

根据上面公式得出的灰度信息介乎与 0~255 之间,0 对应的是 #000000 黑色, 255 是 #FFFFFF 白色。

最后将灰度信息转化为 ASCII 字符,先根据字符的点阵密度不同,由高到低定义一个字符梯度。然后奖不同的灰度信息映射成对应的密度字符。

const asciiChar = `░$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/|()1{}[]?-_+~<>i!lI;:,"^\`'. `;
const charLen = asciiChar.length;
const calcChar = (gray) => asciiChar[parseInt(gray / 256 * charLen)];

所有字符信息连起来,并根据图片宽度增加换行符,就可以得到一幅字符画的字符串信息。

pic

将所有字符画的字符串保存到成一个 JSON 文件里(frames_ff.json),下面开始播放动画。

动画在终端里播放,用到前面说的 CSI 刷新技术,原视频的帧率是每秒 30 帧,所以我们的动画播放频率也和这个一样,设个 interval 每 1000 / 30 毫秒更新一帧。

const frames = require('./data/frames_ff.json');
const len = frames.length;
const log = require('single-line-log').stdout;
const ms = 1000 / 30;
let count = 0;
let interval = setInterval(() => {
  log(frames[count ++]);
  if (count === len) clearInterval(interval);
}, ms);

pic

One more thing...

除了 ffmpeg ,还可以利用 OpenCV 达到相同的效果。

OpenCV 是由 C++ 编写的跨平台计算机视觉库,在 nodejs 中可以用 opencv4nodejs 进行方便调用。

ffmpeg 方案是先将视频所有帧图像提取出来,再对所有的图片像素信息进行统一的转换。

OpenCV 则可以做到视频提取帧的同时,将像素信息同时转换成 字符画 ,然后播放。基于此就可以对流媒体就行实时播放。

const cv = require('opencv4nodejs');
const log = require('single-line-log').stdout;
const { convert } = require('./convert.js');

const grabFrames = (videoFile, delay, onFrame) => {
  const cap = new cv.VideoCapture(videoFile);
  let done = false;
  const intvl = setInterval(() => {
    let frame = cap.read();
    if (frame.empty) {
      clearInterval(intvl);
      console.log('finish, exiting.');
    }
    onFrame(frame);

    const key = cv.waitKey(delay);
    done = key !== -1 && key !== 255;
    if (done) {
      clearInterval(intvl);
      console.log('Key pressed, exiting.');
    }
  }, 1000/30);
};

// src 为 视频路径
// const src = 'bad_apple.flv';
// src 为 0 使用 摄像头
const src = 0;

grabFrames(src, 1, frame => {
  const text = convert(frame);
  log(text);
});

pic

P.S. OpenCV 功能强大,做上面的彩蛋可以说是杀鸡用牛刀了[Doge]

Matrix

最后我们将 SGR 技术应用在字符画上,使其拥有更高的表现形式。

const convertMatrix = (text) => (`\x1b[40m\x1b[32m${text}\x1b[0m`);

这里我们将字符画加上黑色背景和绿色的字体。

pic

(黑客帝国风格完成)

Demo

完整 demo https://github.com/Rococolate/ansi_esc

参考资料

ANSI转义序列(https://zh.wikipedia.org/wiki/ANSI%E8%BD%AC%E4%B9%89%E5%BA%8F%E5%88%97)

VT100(https://en.wikipedia.org/wiki/VT100)

ASCII(https://en.wikipedia.org/wiki/ASCII)

Ecma国际(https://zh.wikipedia.org/wiki/Ecma%E5%9B%BD%E9%99%85)

American National Standards Institute(https://en.wikipedia.org/wiki/American_National_Standards_Institute)

chalk(https://github.com/chalk/chalk)

nodejs 终端打印进度条(https://www.jianshu.com/p/00d8f71d367d)

single-line-log(https://github.com/freeall/single-line-log/)

Inquirer.js(https://github.com/SBoudrias/Inquirer.js)

Bad Apple!!(https://zh.wikipedia.org/wiki/Bad_Apple!!)

FFmpeg视频抽帧那些事(https://zhuanlan.zhihu.com/p/85895180)

jpeg-js(https://github.com/eugeneware/jpeg-js)

opencv4nodejs(https://github.com/justadudewhohacks/opencv4nodejs)

【东方】用Python实现字符画 Bad Apple(https://www.bilibili.com/video/BV1p4411G7zX)