ANSI转义序列的应用
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
会变成青色。
搜索一些资料后发现,原来是 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 是第一台支持ANSI转义序列的终端)
在 21 世纪,尽管硬件文本终端已经越来越少了,但 ANSI 标准依然存在,因为大多数终端模拟器会对部分 ANSI 转义序列进行解释。一个值得注意的例外是,在微软 Windows 10 更新 TH2 之前,Windows 操作系统的 Win32 控制台是不支持 ANSI 转义序列的。
ANSI转义序列 与 ASCII
ASCII 是基于拉丁字母的一套电脑编码系统,是美国国家标准学会(ANSI)发布的 ANSI X3.4 。ASCII 由电报码发展而来,至今为止共定义了 128 个字符;其中 33 个非显示字符, 95 个可显示的字符。用键盘敲下空白键所产生的空白字符也算 1 个可显示字符(显示为空白)。
ANSI转义序列,就是利用了 ASCII 中的十进制27,十六进制1B,八进制033所定义的那个字符 -- ESC
(退出键)。
下面是 ESC 在 javascript 中的字符串表达:
"\33"
"\033"
"\x1b"
"\u001b"
"\u{1b}"
转义序列
序列具有不同的长度。所有序列都以ASCII字符ESC(27 / 十六进制 0x1B)开头,第二个字节则是0x40–0x5F(ASCII @A–Z[\]^_)范围内的字符。
ANSI转义序列有很多种,应用比较多的是 CSI序列。
CSI序列
CSI序列 由 ESC [、若干个(包括0个)“参数字节”、若干个“中间字节”,以及一个“最终字节”组成。
可以看出, CSI序列 主要作用是控制光标、擦除、设置显示样式。其中的 SGR – 选择图形再现(Select Graphic Rendition)可以设置多种样式
不同终端的支持程度不同,下图是在 mac 系统下 Terminal.app 的 SGR 表现 (截图不太清楚,缓慢闪烁是支持的)
最新版的 chrome 控制台支持少量的 SGR ,主要是颜色相关的指令。
应用
下面的代码都用 node.js
的 console.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)
}
从上图可以看出来 create
在终端输出的颜色被改变了。关键在于他前后的特殊转义序列 \x1b[36m
和 \x1b[0m
。
\x1b
就是前面说的 ESC
(退出键) 说明开始转义
\x1b[
说明是一个 CSI序列
\x1b[36m
和 \x1b[0m
说明是个 SGR,其中 36 是 青色, 0 是重置所有 SGR 属性。
除了青色外,还可以设置 31 -- 红色、 32 -- 绿色、33 -- 黄色,等等。
大量的配置比较繁杂,我们可以用一些开源库封装好的函数来使用。比如 chalk
进度条
常见的进度条也是 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 选择器。
开源库 Inquirer.js 就是用这个方法实现的。
彩蛋
众所周知,动画就是一帧帧快速刷新的画面,所以 CSI 的刷新能力是可以用来做动画的。下面就用 CSI 做一个终端版的 Bad Apple!!(【東方】Bad Apple!! PV【影絵】)。
虽然终端不支持显示图片,但是我们可以将图片的像素信息生成 ASCII 字符画。
然后通过 渲染 -> 清空画面 -> 再渲染 的方式让画面动起来。
利用 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)];
所有字符信息连起来,并根据图片宽度增加换行符,就可以得到一幅字符画的字符串信息。
将所有字符画的字符串保存到成一个 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);
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);
});
P.S. OpenCV 功能强大,做上面的彩蛋可以说是杀鸡用牛刀了[Doge]
Matrix
最后我们将 SGR 技术应用在字符画上,使其拥有更高的表现形式。
const convertMatrix = (text) => (`\x1b[40m\x1b[32m${text}\x1b[0m`);
这里我们将字符画加上黑色背景和绿色的字体。
(黑客帝国风格完成)
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)
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)