@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,317 @@
1
+ /*
2
+ on veut pouvoir:
3
+ - mettre le terminal dans un "fake terminal" genre
4
+ avec le style de celui svg
5
+ - enregister une vidéo du terminal
6
+ ça faut que je regarde les apis canvas
7
+
8
+ https://github.com/welefen/canvas2video/blob/master/src/index.ts
9
+ https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream
10
+ https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder
11
+ https://webglfundamentals.org/webgl/lessons/webgl-tips.html
12
+ https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts
13
+ */
14
+
15
+ import "xterm";
16
+ import "xterm-addon-webgl";
17
+ import { createGifEncoder } from "./gif_encoder.js";
18
+
19
+ const { Terminal } = window;
20
+ const { WebglAddon } = window.WebglAddon;
21
+
22
+ export const initTerminal = ({
23
+ cols = 80,
24
+ rows = 25,
25
+ paddingLeft = 15,
26
+ paddingRight = 15,
27
+ fontFamily = "SauceCodePro Nerd Font, Source Code Pro, Courier",
28
+ fontSize = 12,
29
+ convertEol = true,
30
+ gif,
31
+ video,
32
+ logs,
33
+ }) => {
34
+ if (video === true) video = {};
35
+ if (gif === true) gif = {};
36
+ if (!video && !gif) {
37
+ throw new Error("video or gif must be enabled");
38
+ }
39
+
40
+ const log = (...args) => {
41
+ if (logs) {
42
+ console.log(...args);
43
+ }
44
+ };
45
+
46
+ const cssUrl = new URL("xterm/css/xterm.css", import.meta.url);
47
+ const link = document.createElement("link");
48
+ link.rel = "stylesheet";
49
+ link.href = cssUrl;
50
+ document.head.appendChild(link);
51
+
52
+ const term = new Terminal({
53
+ convertEol,
54
+ disableStdin: true,
55
+ // cursorBlink: false,
56
+ cursorInactiveStyle: "none",
57
+ cols,
58
+ rows,
59
+ fontFamily,
60
+ fontSize,
61
+ // lineHeight: 18,
62
+ theme: {
63
+ background: "#282c34",
64
+ foreground: "#abb2bf",
65
+ selectionBackground: "#001122",
66
+ selectionForeground: "#777777",
67
+ brightRed: "#B22222",
68
+ brightBlue: "#87CEEB",
69
+ },
70
+ });
71
+ term.loadAddon(
72
+ new WebglAddon(
73
+ // we pass true to enable preserveDrawingBuffer
74
+ // it's not actually useful thanks to term.write callback +
75
+ // call on _innerRefresh() before context.drawImage
76
+ // but let's keep it nevertheless
77
+ true,
78
+ ),
79
+ );
80
+ const terminalElement = document.getElementById("terminal");
81
+ term.open(terminalElement);
82
+
83
+ const canvas = document.querySelector("#canvas");
84
+ const context = canvas.getContext("2d", { willReadFrequently: true });
85
+ context.imageSmoothingEnabled = true;
86
+ const xtermCanvas = document.querySelector(
87
+ "#terminal canvas.xterm-link-layer + canvas",
88
+ );
89
+ const headerHeight = 40;
90
+ canvas.width = paddingLeft + xtermCanvas.width + paddingRight;
91
+ canvas.height = headerHeight + xtermCanvas.height;
92
+
93
+ draw_header: {
94
+ drawRectangle(context, {
95
+ x: 0,
96
+ y: 0,
97
+ width: canvas.width,
98
+ height: canvas.height,
99
+ fill: "#282c34",
100
+ stroke: "rgba(255,255,255,0.35)",
101
+ strokeWidth: 10,
102
+ radius: 8,
103
+ });
104
+ drawCircle(context, {
105
+ x: 20,
106
+ y: headerHeight / 2,
107
+ radius: 6,
108
+ fill: "#ff5f57",
109
+ });
110
+ drawCircle(context, {
111
+ x: 40,
112
+ y: headerHeight / 2,
113
+ radius: 6,
114
+ fill: "#febc2e",
115
+ });
116
+ drawCircle(context, {
117
+ x: 60,
118
+ y: headerHeight / 2,
119
+ radius: 6,
120
+ fill: "#28c840",
121
+ });
122
+
123
+ // https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Drawing_text
124
+ const text = "Terminal";
125
+ context.font = `${fontSize}px ${fontFamily}`;
126
+ context.textBaseline = "middle";
127
+ context.textAlign = "center";
128
+ // const textSize = context.measureText(text);
129
+ context.fillStyle = "#abb2bf";
130
+ context.fillText(text, canvas.width / 2, headerHeight / 2);
131
+ }
132
+
133
+ return {
134
+ startRecording: async () => {
135
+ const records = {};
136
+ const frameCallbackSet = new Set();
137
+ const stopCallbackSet = new Set();
138
+ if (video) {
139
+ log("start recording video");
140
+ const { mimeType = "video/webm;codecs=h264", msAddedAtTheEnd } = video;
141
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
142
+ throw new Error(`MediaRecorder does not support "${mimeType}"`);
143
+ }
144
+ const stream = canvas.captureStream();
145
+ const mediaRecorder = new MediaRecorder(stream, {
146
+ videoBitsPerSecond: 2_500_000,
147
+ mimeType,
148
+ });
149
+ const chunks = [];
150
+ mediaRecorder.ondataavailable = (e) => {
151
+ if (e.data && e.data.size) {
152
+ chunks.push(e.data);
153
+ }
154
+ };
155
+ log("starting media recorder");
156
+ const startPromise = new Promise((resolve, reject) => {
157
+ mediaRecorder.onstart = () => {
158
+ resolve();
159
+ };
160
+ mediaRecorder.onerror = (e) => {
161
+ log("media recorder error");
162
+ reject(e);
163
+ };
164
+ mediaRecorder.start();
165
+ });
166
+ await startPromise;
167
+ log("media recorder started");
168
+
169
+ stopCallbackSet.add(async () => {
170
+ if (msAddedAtTheEnd) {
171
+ await new Promise((resolve) => {
172
+ setTimeout(resolve, msAddedAtTheEnd);
173
+ });
174
+ replicateXterm();
175
+ } else {
176
+ replicateXterm();
177
+ }
178
+ await new Promise((resolve) => {
179
+ setTimeout(resolve, 150);
180
+ });
181
+ await new Promise((resolve, reject) => {
182
+ mediaRecorder.onstop = () => {
183
+ resolve();
184
+ };
185
+ mediaRecorder.onerror = (e) => {
186
+ reject(e);
187
+ };
188
+ mediaRecorder.stop();
189
+ });
190
+ const blob = new Blob(chunks, { type: mediaRecorder.mimeType });
191
+ const videoBinaryString = await new Promise((resolve, reject) => {
192
+ const reader = new FileReader();
193
+ reader.onloadend = () => resolve(reader.result);
194
+ reader.onerror = reject;
195
+ reader.readAsBinaryString(blob);
196
+ });
197
+ records.video = videoBinaryString;
198
+ });
199
+ }
200
+ if (gif) {
201
+ log("start recording gif");
202
+ const { repeat = -1, quality, msAddedAtTheEnd } = gif;
203
+ const gifEncoder = createGifEncoder({
204
+ width: canvas.width,
205
+ height: canvas.height,
206
+ repeat: repeat === true ? 0 : repeat === false ? -1 : repeat,
207
+ quality,
208
+ });
209
+
210
+ const startTime = Date.now();
211
+ let previousTime = startTime;
212
+
213
+ frameCallbackSet.add(() => {
214
+ const now = Date.now();
215
+ const msSincePreviousFrame = now - previousTime;
216
+ previousTime = now;
217
+ log(`add frame to gif with delay of ${msSincePreviousFrame}ms`);
218
+ gifEncoder.addFrame(context, { delay: msSincePreviousFrame });
219
+ });
220
+ stopCallbackSet.add(async () => {
221
+ if (msAddedAtTheEnd) {
222
+ drawFrame();
223
+ previousTime = Date.now() - msAddedAtTheEnd;
224
+ drawFrame();
225
+ } else {
226
+ drawFrame();
227
+ }
228
+ gifEncoder.finish();
229
+ log("gif recording stopped");
230
+ const gifBinaryString = gifEncoder.readAsBinaryString();
231
+ records.gif = gifBinaryString;
232
+ });
233
+ log("gif recorder started");
234
+ }
235
+
236
+ const drawFrame = () => {
237
+ for (const frameCallback of frameCallbackSet) {
238
+ frameCallback();
239
+ }
240
+ };
241
+
242
+ const replicateXterm = () => {
243
+ context.drawImage(
244
+ xtermCanvas,
245
+ 0,
246
+ 0,
247
+ xtermCanvas.width,
248
+ xtermCanvas.height,
249
+ paddingLeft,
250
+ headerHeight,
251
+ xtermCanvas.width,
252
+ xtermCanvas.height,
253
+ );
254
+ };
255
+
256
+ replicateXterm();
257
+ drawFrame();
258
+
259
+ return {
260
+ writeIntoTerminal: async (data) => {
261
+ await new Promise((resolve) => {
262
+ term.write(data, () => {
263
+ term._core._renderService._renderDebouncer._innerRefresh();
264
+ replicateXterm();
265
+ drawFrame();
266
+ resolve();
267
+ });
268
+ });
269
+ },
270
+ stopRecording: async () => {
271
+ const promises = [];
272
+ for (const stopCallback of stopCallbackSet) {
273
+ promises.push(stopCallback());
274
+ }
275
+ await Promise.all(promises);
276
+ return records;
277
+ },
278
+ };
279
+ },
280
+ };
281
+ };
282
+
283
+ const drawRectangle = (
284
+ context,
285
+ { x, y, width, height, radius, fill, stroke, strokeWidth },
286
+ ) => {
287
+ context.beginPath();
288
+ if (radius) {
289
+ context.roundRect(x, y, width, height, [radius]);
290
+ } else {
291
+ context.rect(x, y, width, height);
292
+ }
293
+ if (fill) {
294
+ context.fillStyle = fill;
295
+ context.fill();
296
+ }
297
+ if (stroke) {
298
+ context.strokeWidth = strokeWidth;
299
+ context.strokeStyle = stroke;
300
+ context.stroke();
301
+ }
302
+ };
303
+ const drawCircle = (context, { x, y, radius, fill, stroke, borderWidth }) => {
304
+ context.beginPath();
305
+ context.arc(x, y, radius, 0, 2 * Math.PI, false);
306
+ if (fill) {
307
+ context.fillStyle = fill;
308
+ context.fill();
309
+ }
310
+ if (borderWidth) {
311
+ context.lineWidth = 5;
312
+ }
313
+ if (stroke) {
314
+ context.strokeStyle = stroke;
315
+ context.stroke();
316
+ }
317
+ };
@@ -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 "./terminal_recorder.js";
21
+
22
+ window.initTerminal = initTerminal;
23
+ window.resolveXtermReadyPromise();
24
+ </script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,48 @@
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 type="module">
15
+ import { initTerminal } from "./terminal_recorder.js";
16
+
17
+ const { startRecording } = initTerminal({
18
+ gif: {
19
+ repeat: true,
20
+ msAddedAtTheEnd: 3_000,
21
+ },
22
+ video: true,
23
+ });
24
+ const { writeIntoTerminal, stopRecording } = await startRecording();
25
+ writeIntoTerminal("hello\n");
26
+ await new Promise((resolve) => setTimeout(resolve, 500));
27
+ writeIntoTerminal("world");
28
+ await new Promise((resolve) => setTimeout(resolve, 1000));
29
+ const terminalRecords = await stopRecording();
30
+ const video = terminalRecords.video;
31
+ const gif = terminalRecords.gif;
32
+
33
+ const webmDataUrl = `data:video/webm;codecs=h264;base64,${window.btoa(
34
+ video,
35
+ )}`;
36
+ const gifDataUrl = `data:image/gif;base64,${window.btoa(gif)}`;
37
+
38
+ document.querySelector("#video").src = webmDataUrl;
39
+ document.querySelector("#gif").src = gifDataUrl;
40
+ </script>
41
+ <div id="records">
42
+ Video:
43
+ <video id="video" src="data:," controls></video>
44
+ GIF:
45
+ <img id="gif" src="data:," />
46
+ </div>
47
+ </body>
48
+ </html>
package/src/main.js ADDED
@@ -0,0 +1,4 @@
1
+ // not yet available
2
+ // export { renderTerminalHtml } from "./render_terminal_html.js";
3
+
4
+ export { startTerminalRecording } from "./terminal_recording.js";