@jsenv/terminal-recorder 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/dist/css/xterm.css +177 -0
- package/dist/js/terminal_recorder.js +1470 -0
- package/dist/xterm.html +26 -0
- package/package.json +47 -0
- package/src/client/gif_encoder.js +398 -0
- package/src/client/gif_encoder_dev/gif_encoder.js +567 -0
- package/src/client/gif_encoder_dev/gif_encoder_dev.html +130 -0
- package/src/client/gif_encoder_dev/lzwencoder.js +275 -0
- package/src/client/gif_encoder_dev/neuquant.js +510 -0
- package/src/client/gif_encoder_jsenv_dev/gif_encoder_jsenv_dev.html +94 -0
- package/src/client/lzw_encoder.js +253 -0
- package/src/client/neuquant.js +498 -0
- package/src/client/terminal_recorder.js +317 -0
- package/src/client/xterm.html +26 -0
- package/src/client/xterm_dev.html +48 -0
- package/src/main.js +4 -0
- package/src/svg/parse_ansi.js +375 -0
- package/src/svg/render_terminal_html.js +5 -0
- package/src/svg/render_terminal_svg.js +300 -0
- package/src/svg/xml_generator.js +149 -0
- package/src/terminal_recording.js +205 -0
package/dist/xterm.html
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Title</title>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<link rel="icon" href="data:,">
|
|
7
|
+
</head>
|
|
8
|
+
|
|
9
|
+
<body>
|
|
10
|
+
<div style="display: flex">
|
|
11
|
+
<div id="terminal"></div>
|
|
12
|
+
<canvas id="canvas"></canvas>
|
|
13
|
+
</div>
|
|
14
|
+
<script>
|
|
15
|
+
window.xtreamReadyPromise = new Promise((resolve) => {
|
|
16
|
+
window.resolveXtermReadyPromise = resolve;
|
|
17
|
+
});
|
|
18
|
+
</script>
|
|
19
|
+
<script type="module">
|
|
20
|
+
import { initTerminal } from "/js/terminal_recorder.js";
|
|
21
|
+
|
|
22
|
+
window.initTerminal = initTerminal;
|
|
23
|
+
window.resolveXtermReadyPromise();
|
|
24
|
+
</script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jsenv/terminal-recorder",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Record terminal output as .svg, .gif, .webm, .mp4",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/jsenv/core",
|
|
9
|
+
"directory": "packages/independent/terminal-recorder"
|
|
10
|
+
},
|
|
11
|
+
"author": {
|
|
12
|
+
"name": "dmail",
|
|
13
|
+
"email": "dmaillard06@gmail.com",
|
|
14
|
+
"url": "https://twitter.com/damienmaillard"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/main.js",
|
|
22
|
+
"./*": "./*"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"/dist/",
|
|
26
|
+
"/src/"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "node ./scripts/dev.mjs",
|
|
30
|
+
"test": "node ./scripts/test.mjs",
|
|
31
|
+
"build": "node ./scripts/build.mjs",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@jsenv/server": "15.2.0",
|
|
36
|
+
"@playwright/browser-chromium": "1.40.1",
|
|
37
|
+
"ansi-regex": "6.0.1",
|
|
38
|
+
"he": "1.2.0",
|
|
39
|
+
"playwright": "1.40.1",
|
|
40
|
+
"strip-ansi": "7.1.0",
|
|
41
|
+
"webm-to-mp4": "1.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"xterm": "5.3.0",
|
|
45
|
+
"xterm-addon-webgl": "0.16.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// https://github.com/incubated-geek-cc/video-to-GIF/blob/main/js/GIFEncoder.js
|
|
2
|
+
// https://github.com/jnordberg/gif.js
|
|
3
|
+
// https://github.com/jnordberg/gif.js/blob/master/src/GIFEncoder.js
|
|
4
|
+
|
|
5
|
+
import { createNeuQuant } from "./neuquant.js";
|
|
6
|
+
import { createLzwEncoder } from "./lzw_encoder.js";
|
|
7
|
+
|
|
8
|
+
export const createGifEncoder = ({
|
|
9
|
+
width,
|
|
10
|
+
height,
|
|
11
|
+
/*
|
|
12
|
+
* Sets quality of color quantization (conversion of images to the maximum 256
|
|
13
|
+
* colors allowed by the GIF specification). Lower values (minimum = 1)
|
|
14
|
+
* produce better colors, but slow processing significantly. 10 is the
|
|
15
|
+
* default, and produces good color mapping at reasonable speeds. Values
|
|
16
|
+
* greater than 20 do not yield significant improvements in speed.
|
|
17
|
+
*/
|
|
18
|
+
quality = 10,
|
|
19
|
+
/**
|
|
20
|
+
* Sets the number of times the set of GIF frames should be played.
|
|
21
|
+
* -1 means no repeat, 0 means indefinitely
|
|
22
|
+
*
|
|
23
|
+
* int number of iterations.
|
|
24
|
+
*/
|
|
25
|
+
repeat = -1,
|
|
26
|
+
comment = "",
|
|
27
|
+
}) => {
|
|
28
|
+
const byteArray = [];
|
|
29
|
+
const writeByte = (data) => {
|
|
30
|
+
byteArray.push(data);
|
|
31
|
+
};
|
|
32
|
+
const writeUTFBytes = (string) => {
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < string.length) {
|
|
35
|
+
writeByte(string.charCodeAt(i));
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const writeBytes = (array, offset = 0, length = array.length) => {
|
|
40
|
+
let i = offset;
|
|
41
|
+
while (i < length) {
|
|
42
|
+
writeByte(array[i]);
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
let charCodeMap;
|
|
47
|
+
const fillCharCodes = () => {
|
|
48
|
+
if (charCodeMap) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
charCodeMap = new Map();
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < 256) {
|
|
54
|
+
charCodeMap.set(i, String.fromCharCode(i));
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const readAsBinaryString = () => {
|
|
59
|
+
fillCharCodes();
|
|
60
|
+
let binaryString = "";
|
|
61
|
+
for (const byte of byteArray) {
|
|
62
|
+
binaryString += charCodeMap.get(byte);
|
|
63
|
+
}
|
|
64
|
+
return binaryString;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let transparent = null; // transparent color if given
|
|
68
|
+
let transIndex = 0; // transparent index in color table
|
|
69
|
+
let colorDepth; // number of bit planes
|
|
70
|
+
let usedEntry = []; // active palette entries
|
|
71
|
+
let palSize = 7; // color table size (bits-1)
|
|
72
|
+
let dispose = -1; // disposal code (-1 = use default
|
|
73
|
+
let firstFrame = true;
|
|
74
|
+
let finished = false;
|
|
75
|
+
|
|
76
|
+
const writeShort = (pValue) => {
|
|
77
|
+
writeByte(pValue & 0xff);
|
|
78
|
+
writeByte((pValue >> 8) & 0xff);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const encoder = {
|
|
82
|
+
/**
|
|
83
|
+
* Sets the GIF frame disposal code for the last added frame and any
|
|
84
|
+
* subsequent frames. Default is 0 if no transparent color has been set,
|
|
85
|
+
* otherwise 2.
|
|
86
|
+
* @param code
|
|
87
|
+
* int disposal code.
|
|
88
|
+
*/
|
|
89
|
+
setDispose: (code) => {
|
|
90
|
+
if (code >= 0) dispose = code;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sets the transparent color for the last added frame and any subsequent
|
|
95
|
+
* frames. Since all colors are subject to modification in the quantization
|
|
96
|
+
* process, the color in the final palette for each frame closest to the given
|
|
97
|
+
* color becomes the transparent color for that frame. May be set to null to
|
|
98
|
+
* indicate no transparent color.
|
|
99
|
+
* @param
|
|
100
|
+
* Color to be treated as transparent on display.
|
|
101
|
+
*/
|
|
102
|
+
setTransparent: (c) => {
|
|
103
|
+
transparent = c;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The addFrame method takes an incoming BitmapData object to create each frames
|
|
108
|
+
* @param
|
|
109
|
+
* BitmapData object to be treated as a GIF's frame
|
|
110
|
+
*/
|
|
111
|
+
addFrame: (context, options) => {
|
|
112
|
+
let delay = 0;
|
|
113
|
+
if (options) {
|
|
114
|
+
if (options.quality) {
|
|
115
|
+
quality = options.quality < 1 ? 1 : options.quality;
|
|
116
|
+
}
|
|
117
|
+
if (options.delay) {
|
|
118
|
+
delay = Math.round(options.delay / 10);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const canvas = context.canvas;
|
|
123
|
+
try {
|
|
124
|
+
const imageData = context.getImageData(
|
|
125
|
+
0,
|
|
126
|
+
0,
|
|
127
|
+
canvas.width,
|
|
128
|
+
canvas.height,
|
|
129
|
+
).data;
|
|
130
|
+
|
|
131
|
+
const pixels = [];
|
|
132
|
+
// build pixels
|
|
133
|
+
{
|
|
134
|
+
var count = 0;
|
|
135
|
+
let y = 0;
|
|
136
|
+
while (y < height) {
|
|
137
|
+
let x = 0;
|
|
138
|
+
while (x < width) {
|
|
139
|
+
const b = y * width * 4 + x * 4;
|
|
140
|
+
pixels[count++] = imageData[b];
|
|
141
|
+
pixels[count++] = imageData[b + 1];
|
|
142
|
+
pixels[count++] = imageData[b + 2];
|
|
143
|
+
x++;
|
|
144
|
+
}
|
|
145
|
+
y++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let colorTab;
|
|
149
|
+
let indexedPixels = [];
|
|
150
|
+
// analyze pixels ( build color table & map pixels)
|
|
151
|
+
{
|
|
152
|
+
var len = pixels.length;
|
|
153
|
+
var nPix = len / 3;
|
|
154
|
+
const neuQuant = createNeuQuant({
|
|
155
|
+
pixels,
|
|
156
|
+
len,
|
|
157
|
+
sample: quality,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// initialize quantizer
|
|
161
|
+
colorTab = neuQuant.process(); // create reduced palette
|
|
162
|
+
|
|
163
|
+
// map image pixels to new palette
|
|
164
|
+
var k = 0;
|
|
165
|
+
for (var j = 0; j < nPix; j++) {
|
|
166
|
+
var index = neuQuant.map(
|
|
167
|
+
pixels[k++] & 0xff,
|
|
168
|
+
pixels[k++] & 0xff,
|
|
169
|
+
pixels[k++] & 0xff,
|
|
170
|
+
);
|
|
171
|
+
usedEntry[index] = true;
|
|
172
|
+
indexedPixels[j] = index;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
pixels.length = 0;
|
|
176
|
+
colorDepth = 8;
|
|
177
|
+
palSize = 7;
|
|
178
|
+
|
|
179
|
+
// get closest match to transparent color if specified
|
|
180
|
+
if (transparent !== null) {
|
|
181
|
+
/**
|
|
182
|
+
* Returns index of palette color closest to c
|
|
183
|
+
*/
|
|
184
|
+
const findClosest = (c) => {
|
|
185
|
+
var r = (c & 0xff0000) >> 16;
|
|
186
|
+
var g = (c & 0x00ff00) >> 8;
|
|
187
|
+
var b = c & 0x0000ff;
|
|
188
|
+
var minpos = 0;
|
|
189
|
+
var dmin = 256 * 256 * 256;
|
|
190
|
+
var len = colorTab.length;
|
|
191
|
+
|
|
192
|
+
for (var i = 0; i < len; ) {
|
|
193
|
+
var dr = r - (colorTab[i++] & 0xff);
|
|
194
|
+
var dg = g - (colorTab[i++] & 0xff);
|
|
195
|
+
var db = b - (colorTab[i] & 0xff);
|
|
196
|
+
var d = dr * dr + dg * dg + db * db;
|
|
197
|
+
var index = i / 3;
|
|
198
|
+
if (usedEntry[index] && d < dmin) {
|
|
199
|
+
dmin = d;
|
|
200
|
+
minpos = index;
|
|
201
|
+
}
|
|
202
|
+
i++;
|
|
203
|
+
}
|
|
204
|
+
return minpos;
|
|
205
|
+
};
|
|
206
|
+
transIndex = colorTab === null ? -1 : findClosest(transparent);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const writePalette = () => {
|
|
211
|
+
writeBytes(colorTab);
|
|
212
|
+
var n = 3 * 256 - colorTab.length;
|
|
213
|
+
for (var i = 0; i < n; i++) writeByte(0);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (firstFrame) {
|
|
217
|
+
// write logical screen descriptior
|
|
218
|
+
{
|
|
219
|
+
// logical screen size
|
|
220
|
+
writeShort(width);
|
|
221
|
+
writeShort(height);
|
|
222
|
+
// packed fields
|
|
223
|
+
writeByte(
|
|
224
|
+
0x80 | // 1 : global color table flag = 1 (gct used)
|
|
225
|
+
0x70 | // 2-4 : color resolution = 7
|
|
226
|
+
0x00 | // 5 : gct sort flag = 0
|
|
227
|
+
palSize,
|
|
228
|
+
); // 6-8 : gct size
|
|
229
|
+
|
|
230
|
+
writeByte(0); // background color index
|
|
231
|
+
writeByte(0); // pixel aspect ratio - assume 1:1
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// write palette
|
|
235
|
+
writePalette(); // global color table
|
|
236
|
+
|
|
237
|
+
// Writes Netscape application extension to define repeat count.
|
|
238
|
+
if (repeat >= 0) {
|
|
239
|
+
writeByte(0x21); // extension introducer
|
|
240
|
+
writeByte(0xff); // app extension label
|
|
241
|
+
writeByte(11); // block size
|
|
242
|
+
writeUTFBytes("NETSCAPE2.0"); // app id + auth code
|
|
243
|
+
writeByte(3); // sub-block size
|
|
244
|
+
writeByte(1); // loop sub-block id
|
|
245
|
+
writeShort(repeat); // loop count (extra iterations, 0=repeat forever)
|
|
246
|
+
writeByte(0); // block terminator
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// write graphic control extension
|
|
251
|
+
{
|
|
252
|
+
writeByte(0x21); // extension introducer
|
|
253
|
+
writeByte(0xf9); // GCE label
|
|
254
|
+
writeByte(4); // data block size
|
|
255
|
+
var transp;
|
|
256
|
+
var disp;
|
|
257
|
+
if (transparent === null) {
|
|
258
|
+
transp = 0;
|
|
259
|
+
disp = 0; // dispose = no action
|
|
260
|
+
} else {
|
|
261
|
+
transp = 1;
|
|
262
|
+
disp = 2; // force clear if using transparent color
|
|
263
|
+
}
|
|
264
|
+
if (dispose >= 0) {
|
|
265
|
+
disp = dispose & 7; // user override
|
|
266
|
+
}
|
|
267
|
+
disp <<= 2;
|
|
268
|
+
// packed fields
|
|
269
|
+
writeByte(
|
|
270
|
+
0 | // 1:3 reserved
|
|
271
|
+
disp | // 4:6 disposal
|
|
272
|
+
0 | // 7 user input - 0 = none
|
|
273
|
+
transp,
|
|
274
|
+
); // 8 transparency flag
|
|
275
|
+
|
|
276
|
+
writeShort(delay); // delay x 1/100 sec
|
|
277
|
+
writeByte(transIndex); // transparent color index
|
|
278
|
+
writeByte(0); // block terminator
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// write comment
|
|
282
|
+
if (comment !== "") {
|
|
283
|
+
writeByte(0x21); // extension introducer
|
|
284
|
+
writeByte(0xfe); // comment label
|
|
285
|
+
writeByte(comment.length); // Block Size (s)
|
|
286
|
+
writeUTFBytes(comment);
|
|
287
|
+
writeByte(0); // block terminator
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// write image descriptor
|
|
291
|
+
{
|
|
292
|
+
writeByte(0x2c); // image separator
|
|
293
|
+
writeShort(0); // image position x,y = 0,0
|
|
294
|
+
writeShort(0);
|
|
295
|
+
writeShort(width); // image size
|
|
296
|
+
writeShort(height);
|
|
297
|
+
|
|
298
|
+
// packed fields
|
|
299
|
+
if (firstFrame) {
|
|
300
|
+
// no LCT - GCT is used for first (or only) frame
|
|
301
|
+
writeByte(0);
|
|
302
|
+
} else {
|
|
303
|
+
// specify normal LCT
|
|
304
|
+
writeByte(
|
|
305
|
+
0x80 | // 1 local color table 1=yes
|
|
306
|
+
0 | // 2 interlace - 0=no
|
|
307
|
+
0 | // 3 sorted - 0=no
|
|
308
|
+
0 | // 4-5 reserved
|
|
309
|
+
palSize,
|
|
310
|
+
); // 6-8 size of color table
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!firstFrame) {
|
|
315
|
+
writePalette(); // local color table
|
|
316
|
+
}
|
|
317
|
+
colorTab = null;
|
|
318
|
+
|
|
319
|
+
// encode and write pixel data
|
|
320
|
+
{
|
|
321
|
+
const lzwEncoder = createLzwEncoder({
|
|
322
|
+
width,
|
|
323
|
+
height,
|
|
324
|
+
pixels: indexedPixels,
|
|
325
|
+
colorDepth,
|
|
326
|
+
});
|
|
327
|
+
lzwEncoder.encode({
|
|
328
|
+
writeByte,
|
|
329
|
+
writeBytes,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
firstFrame = false;
|
|
333
|
+
indexedPixels.length = 0;
|
|
334
|
+
return true;
|
|
335
|
+
} catch (e) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Adds final trailer to the GIF stream, if you don't call the finish method
|
|
342
|
+
* the GIF asDataUrl will not be valid.
|
|
343
|
+
*/
|
|
344
|
+
finish: () => {
|
|
345
|
+
if (finished) return false;
|
|
346
|
+
finished = true;
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
writeByte(0x3b); // gif trailer
|
|
350
|
+
return true;
|
|
351
|
+
} catch (e) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
readAsBinaryString,
|
|
357
|
+
toDataUrl: () => {
|
|
358
|
+
const gifAsBinaryString = readAsBinaryString();
|
|
359
|
+
return `data:image/gif;base64,${encode64(gifAsBinaryString)}`;
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
writeUTFBytes("GIF89a"); // header
|
|
364
|
+
|
|
365
|
+
return encoder;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const encode64 = (input) => {
|
|
369
|
+
let output = "";
|
|
370
|
+
let i = 0;
|
|
371
|
+
let l = input.length;
|
|
372
|
+
let key = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
|
373
|
+
let chr1;
|
|
374
|
+
let chr2;
|
|
375
|
+
let chr3;
|
|
376
|
+
let enc1;
|
|
377
|
+
let enc2;
|
|
378
|
+
let enc3;
|
|
379
|
+
let enc4;
|
|
380
|
+
while (i < l) {
|
|
381
|
+
chr1 = input.charCodeAt(i++);
|
|
382
|
+
chr2 = input.charCodeAt(i++);
|
|
383
|
+
chr3 = input.charCodeAt(i++);
|
|
384
|
+
enc1 = chr1 >> 2;
|
|
385
|
+
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
|
|
386
|
+
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
|
|
387
|
+
enc4 = chr3 & 63;
|
|
388
|
+
if (isNaN(chr2)) enc3 = enc4 = 64;
|
|
389
|
+
else if (isNaN(chr3)) enc4 = 64;
|
|
390
|
+
output =
|
|
391
|
+
output +
|
|
392
|
+
key.charAt(enc1) +
|
|
393
|
+
key.charAt(enc2) +
|
|
394
|
+
key.charAt(enc3) +
|
|
395
|
+
key.charAt(enc4);
|
|
396
|
+
}
|
|
397
|
+
return output;
|
|
398
|
+
};
|