@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.
@@ -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
+ };