@jsenv/terminal-recorder 1.1.11 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/terminal-recorder",
3
- "version": "1.1.11",
3
+ "version": "1.2.0",
4
4
  "description": "Record terminal output as .svg, .gif, .webm, .mp4",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,16 +32,18 @@
32
32
  "prepublishOnly": "npm run build"
33
33
  },
34
34
  "dependencies": {
35
+ "@jsenv/humanize": "1.2.3",
35
36
  "@jsenv/server": "15.2.12",
36
37
  "@playwright/browser-webkit": "1.45.1",
37
38
  "ansi-regex": "6.0.1",
38
39
  "he": "1.2.0",
39
40
  "playwright": "1.45.1",
40
- "strip-ansi": "7.1.0",
41
41
  "string-width": "5.1.2",
42
+ "strip-ansi": "7.1.0",
42
43
  "webm-to-mp4": "1.0.0"
43
44
  },
44
45
  "devDependencies": {
46
+ "@xterm/addon-serialize": "0.13.0",
45
47
  "xterm": "5.3.0",
46
48
  "xterm-addon-webgl": "0.16.0"
47
49
  }
@@ -0,0 +1,2 @@
1
+ ✖ hello
2
+ world
@@ -14,10 +14,12 @@ https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts
14
14
 
15
15
  import "xterm";
16
16
  import "xterm-addon-webgl";
17
+ import "@xterm/addon-serialize?as_js_module";
17
18
  import { createGifEncoder } from "./gif_encoder.js";
18
19
 
19
20
  const { Terminal } = window;
20
21
  const { WebglAddon } = window.WebglAddon;
22
+ const { SerializeAddon } = window.SerializeAddon;
21
23
 
22
24
  export const initTerminal = ({
23
25
  cols = 80,
@@ -27,14 +29,16 @@ export const initTerminal = ({
27
29
  fontFamily = "SauceCodePro Nerd Font, Source Code Pro, Courier",
28
30
  fontSize = 12,
29
31
  convertEol = true,
32
+ textInViewport,
30
33
  gif,
31
34
  video,
32
35
  logs,
33
36
  }) => {
34
- if (video === true) video = {};
37
+ if (textInViewport === true) textInViewport = {};
35
38
  if (gif === true) gif = {};
36
- if (!video && !gif) {
37
- throw new Error("video or gif must be enabled");
39
+ if (video === true) video = {};
40
+ if (!textInViewport && !video && !gif) {
41
+ throw new Error("ansi, video or gif must be enabled");
38
42
  }
39
43
 
40
44
  const log = (...args) => {
@@ -79,6 +83,9 @@ export const initTerminal = ({
79
83
  true,
80
84
  ),
81
85
  );
86
+ const serializeAddon = new SerializeAddon();
87
+ term.loadAddon(serializeAddon);
88
+
82
89
  const terminalElement = document.getElementById("terminal");
83
90
  term.open(terminalElement);
84
91
 
@@ -133,10 +140,36 @@ export const initTerminal = ({
133
140
  }
134
141
 
135
142
  return {
143
+ term,
136
144
  startRecording: async () => {
137
145
  const records = {};
146
+ let writePromise = Promise.resolve();
138
147
  const frameCallbackSet = new Set();
139
148
  const stopCallbackSet = new Set();
149
+ if (textInViewport) {
150
+ log("start recording text in viewport");
151
+ // https://github.com/xtermjs/xterm.js/issues/3681
152
+ // https://github.com/xtermjs/xterm.js/issues/3681
153
+ // get the first line
154
+ // term.buffer.active.getLine(term.buffer.active.viewportY).translateToString()
155
+ // get the last line
156
+ // term.buffer.active.getLine(term.buffer.active.length -1).translateToString()
157
+ // https://github.com/xtermjs/xterm.js/blob/a7952ff36c60ee6dce9141744b1355a5d582ee39/addons/addon-serialize/src/SerializeAddon.ts
158
+ stopCallbackSet.add(() => {
159
+ const startY = term.buffer.active.viewportY;
160
+ const endY = term.buffer.active.length - 1;
161
+ const range = {
162
+ start: startY,
163
+ end: endY,
164
+ };
165
+ log(`lines in viewport: ${startY}:${endY}`);
166
+ const output = serializeAddon.serialize({
167
+ range,
168
+ });
169
+ log(`text in viewport: ${output}`);
170
+ records.textInViewport = output;
171
+ });
172
+ }
140
173
  if (video) {
141
174
  log("start recording video");
142
175
  const { mimeType = "video/webm;codecs=h264", msAddedAtTheEnd } = video;
@@ -277,8 +310,8 @@ export const initTerminal = ({
277
310
 
278
311
  return {
279
312
  writeIntoTerminal: async (data) => {
280
- await new Promise((resolve) => {
281
- log(`write data: "${data}"`);
313
+ writePromise = new Promise((resolve) => {
314
+ log(`write data:`, data);
282
315
  term.write(data, () => {
283
316
  term._core._renderService._renderDebouncer._innerRefresh();
284
317
  replicateXterm();
@@ -286,8 +319,10 @@ export const initTerminal = ({
286
319
  resolve();
287
320
  });
288
321
  });
322
+ await writePromise;
289
323
  },
290
324
  stopRecording: async () => {
325
+ await writePromise;
291
326
  const promises = [];
292
327
  for (const stopCallback of stopCallbackSet) {
293
328
  promises.push(stopCallback());
@@ -14,40 +14,82 @@
14
14
  <script type="module">
15
15
  import { initTerminal } from "./terminal_recorder.js";
16
16
 
17
+ const delayBetweenLines = 200;
17
18
  const { startRecording } = initTerminal({
19
+ textInViewport: true,
18
20
  gif: {
19
21
  repeat: true,
20
22
  msAddedAtTheEnd: 3_000,
21
23
  },
22
- logs: true,
24
+ // logs: true,
23
25
  // video: true,
24
26
  });
25
27
  const { writeIntoTerminal, stopRecording } = await startRecording();
26
- const sourceResponse = await fetch("./source.txt");
27
- const source = await sourceResponse.text();
28
- const lines = source.split("\n");
29
- for (const line of lines) {
30
- writeIntoTerminal(`${line}\n`);
31
- await new Promise((resolve) => setTimeout(resolve, 200));
32
- }
33
- await new Promise((resolve) => setTimeout(resolve, 1000));
34
- const terminalRecords = await stopRecording();
35
- const video = terminalRecords.video;
36
- const gif = terminalRecords.gif;
37
28
 
38
- const webmDataUrl = `data:video/webm;codecs=h264;base64,${window.btoa(
39
- video,
40
- )}`;
41
- const gifDataUrl = `data:image/gif;base64,${window.btoa(gif)}`;
29
+ window.renderFromSource = async (sourceUrl) => {
30
+ document.querySelector("#text_in_viewport").innerHTML = "...";
31
+ document.querySelector("#video").src = "";
32
+ document.querySelector("#gif").src = "";
42
33
 
43
- document.querySelector("#video").src = webmDataUrl;
44
- document.querySelector("#gif").src = gifDataUrl;
34
+ const sourceResponse = await fetch(sourceUrl);
35
+ const source = await sourceResponse.text();
36
+ let lines = source.split("\n");
37
+ // lines = lines.slice(0, 12);
38
+ for (const line of lines) {
39
+ writeIntoTerminal(`${line}\n`);
40
+ await new Promise((resolve) =>
41
+ setTimeout(resolve, delayBetweenLines),
42
+ );
43
+ }
44
+ await new Promise((resolve) => setTimeout(resolve, 1000));
45
+ const terminalRecords = await stopRecording();
46
+ const textInViewport = terminalRecords.textInViewport;
47
+ const video = terminalRecords.video;
48
+ const gif = terminalRecords.gif;
49
+
50
+ const webmDataUrl = `data:video/webm;codecs=h264;base64,${window.btoa(
51
+ video,
52
+ )}`;
53
+ const gifDataUrl = `data:image/gif;base64,${window.btoa(gif)}`;
54
+
55
+ document.querySelector("#text_in_viewport").innerHTML = textInViewport;
56
+ document.querySelector("#video").src = webmDataUrl;
57
+ document.querySelector("#gif").src = gifDataUrl;
58
+ };
59
+ </script>
60
+ <form>
61
+ <select name="source">
62
+ <option value="./fixtures/hello_world_2_lines.txt">
63
+ ./fixtures/hello_world_2_lines.txt
64
+ </option>
65
+ <option value="./fixtures/source.txt">./fixtures/source.txt</option>
66
+ </select>
67
+ <button name="submit" type="submit">Go</button>
68
+ </form>
69
+ <script>
70
+ const form = document.querySelector("form");
71
+ form.onsubmit = async (e) => {
72
+ e.preventDefault();
73
+ const source = form.elements.source.value;
74
+ const submit = form.elements.submit;
75
+ submit.disabled = true;
76
+ await window.renderFromSource(source);
77
+ submit.disabled = false;
78
+ };
45
79
  </script>
46
- <div id="records">
47
- Video:
48
- <video id="video" src="data:," controls></video>
49
- GIF:
50
- <img id="gif" src="data:," />
80
+ <div id="records" style="display: flex">
81
+ <div style="flex-grow: 1">
82
+ <h3>Text in viewport</h3>
83
+ <pre id="text_in_viewport"></pre>
84
+ </div>
85
+ <div style="flex-grow: 1">
86
+ <h3>GIF</h3>
87
+ <img id="gif" src="data:," />
88
+ </div>
89
+ <div style="flex-grow: 1">
90
+ <h3>Video</h3>
91
+ <video id="video" src="data:," controls></video>
92
+ </div>
51
93
  </div>
52
94
  </body>
53
95
  </html>
@@ -0,0 +1,107 @@
1
+ import { spawn } from "node:child_process";
2
+ import { UNICODE, ANSI } from "@jsenv/humanize";
3
+
4
+ import { startTerminalRecording } from "./terminal_recording.js";
5
+
6
+ export const recordCommandToSvg = async (
7
+ command,
8
+ options,
9
+ svgOptions,
10
+ terminalOptions = {},
11
+ ) => {
12
+ const terminalRecorder = await startTerminalRecording({
13
+ svg: {
14
+ title: "Terminal",
15
+ ...svgOptions,
16
+ },
17
+ ...terminalOptions,
18
+ });
19
+ await executeCommand(command, {
20
+ ...options,
21
+ onStdout: (data) => {
22
+ terminalRecorder.write(String(data));
23
+ },
24
+ });
25
+ const terminalRecords = await terminalRecorder.stop();
26
+ const terminalSvg = await terminalRecords.svg();
27
+ return terminalSvg;
28
+ };
29
+
30
+ const executeCommand = (
31
+ command,
32
+ {
33
+ signal = new AbortController().signal,
34
+ onStdout = () => {},
35
+ onStderr = () => {},
36
+ cwd,
37
+ env,
38
+ timeout,
39
+ } = {},
40
+ ) => {
41
+ return new Promise((resolve, reject) => {
42
+ let args = [];
43
+ let commandWithoutArgs = "";
44
+ const firstSpaceIndex = command.indexOf(" ");
45
+ if (firstSpaceIndex === -1) {
46
+ commandWithoutArgs = command;
47
+ } else {
48
+ commandWithoutArgs = command.slice(0, firstSpaceIndex);
49
+ const argsRaw = command.slice(firstSpaceIndex + 1).trim();
50
+ args = argsRaw.split(" ");
51
+ }
52
+ const commandProcess = spawn(commandWithoutArgs, args, {
53
+ signal,
54
+ cwd:
55
+ cwd && typeof cwd === "string" && cwd.startsWith("file:")
56
+ ? new URL(cwd)
57
+ : cwd,
58
+ env: {
59
+ ...process.env,
60
+ ...(ANSI.supported ? { FORCE_COLOR: "1" } : {}),
61
+ ...(UNICODE.supported ? { FORCE_UNICODE: "1" } : {}),
62
+ ...env,
63
+ },
64
+ timeout,
65
+ // silent: true,
66
+ });
67
+ commandProcess.on("error", (error) => {
68
+ if (error && error.code === "ETIMEDOUT") {
69
+ console.error(`timeout after ${timeout} ms`);
70
+ reject(error);
71
+ } else {
72
+ reject(error);
73
+ }
74
+ });
75
+ const stdoutDatas = [];
76
+ commandProcess.stdout.on("data", (data) => {
77
+ stdoutDatas.push(data);
78
+ onStdout(data);
79
+ });
80
+ let stderrDatas = [];
81
+ commandProcess.stderr.on("data", (data) => {
82
+ stderrDatas.push(data);
83
+ onStderr(data);
84
+ });
85
+ if (commandProcess.stdin) {
86
+ commandProcess.stdin.on("error", (error) => {
87
+ reject(error);
88
+ });
89
+ }
90
+ commandProcess.on("exit", (exitCode, signal) => {
91
+ if (signal) {
92
+ reject(new Error(`killed with ${signal}`));
93
+ }
94
+ if (exitCode) {
95
+ reject(
96
+ new Error(
97
+ `failed with exit code ${exitCode}
98
+ --- command stderr ---
99
+ ${stderrDatas.join("")}`,
100
+ ),
101
+ );
102
+ return;
103
+ }
104
+ resolve({ exitCode, signal });
105
+ });
106
+ });
107
+ };
package/src/main.js CHANGED
@@ -2,3 +2,4 @@
2
2
  // export { renderTerminalHtml } from "./render_terminal_html.js";
3
3
 
4
4
  export { startTerminalRecording } from "./terminal_recording.js";
5
+ export { recordCommandToSvg } from "./command_recording.js";
@@ -75,116 +75,111 @@ export const startTerminalRecording = async ({
75
75
  throw new Error("video not recorded");
76
76
  },
77
77
  };
78
-
79
- if (svg) {
80
- let ansi = "";
81
- writeCallbackSet.add((data) => {
82
- ansi += data;
83
- });
84
- stopCallbackSet.add(() => {
85
- terminalRecords.svg = async () => {
86
- const terminalSvg = await renderTerminalSvg(ansi, svg);
87
- ansi = "";
88
- return terminalSvg;
89
- };
90
- });
78
+ if (!svg && !gif && !video) {
79
+ throw new Error("svg, video or gif must be enabled ");
91
80
  }
92
- if (gif || video) {
93
- const { webkit } = await import("playwright");
94
- const server = await startLocalServer();
95
- const browser = await webkit.launch({
96
- // channel: "chrome", // https://github.com/microsoft/playwright/issues/7716#issuecomment-882634893
97
- headless: !debug,
98
- // needed because https-localhost fails to trust cert on chrome + linux (ubuntu 20.04)
99
- args: ["--ignore-certificate-errors"],
100
- });
101
- const page = await browser.newPage({
102
- ignoreHTTPSErrors: true,
103
- });
104
- page.on("pageerror", (error) => {
105
- throw error;
106
- });
107
- page.on("console", (consoleMessage) => {
108
- console.log(`chrome> ${consoleMessage.text()}`);
109
- });
110
- await page.goto(`${server.origin}/xterm.html`);
111
- await page.evaluate(
112
- /* eslint-env browser */
113
- async ({ cols, rows, convertEol, gif, video, logs }) => {
114
- await window.xtreamReadyPromise;
115
- const __term__ = await window.initTerminal({
116
- cols,
117
- rows,
118
- convertEol,
119
- gif,
120
- video,
121
- logs,
122
- });
123
- window.__term__ = __term__;
124
- },
125
- {
81
+ const { webkit } = await import("playwright");
82
+ const server = await startLocalServer();
83
+ const browser = await webkit.launch({
84
+ // channel: "chrome", // https://github.com/microsoft/playwright/issues/7716#issuecomment-882634893
85
+ headless: !debug,
86
+ // needed because https-localhost fails to trust cert on chrome + linux (ubuntu 20.04)
87
+ args: ["--ignore-certificate-errors"],
88
+ });
89
+ const page = await browser.newPage({
90
+ ignoreHTTPSErrors: true,
91
+ });
92
+ page.on("pageerror", (error) => {
93
+ throw error;
94
+ });
95
+ page.on("console", (consoleMessage) => {
96
+ console.log(`browser> ${consoleMessage.text()}`);
97
+ });
98
+ await page.goto(`${server.origin}/xterm.html`);
99
+ await page.evaluate(
100
+ /* eslint-env browser */
101
+ async ({ cols, rows, convertEol, textInViewport, gif, video, logs }) => {
102
+ await window.xtreamReadyPromise;
103
+ const __term__ = await window.initTerminal({
126
104
  cols,
127
105
  rows,
128
- convertEol: process.platform !== "win32",
106
+ convertEol,
107
+ textInViewport,
129
108
  gif,
130
109
  video,
131
110
  logs,
111
+ });
112
+ window.__term__ = __term__;
113
+ },
114
+ {
115
+ cols,
116
+ rows,
117
+ convertEol: process.platform !== "win32",
118
+ textInViewport: Boolean(svg),
119
+ gif,
120
+ video,
121
+ logs,
122
+ },
123
+ /* eslint-env node */
124
+ );
125
+ await page.evaluate(
126
+ /* eslint-env browser */
127
+ async () => {
128
+ const { writeIntoTerminal, stopRecording } =
129
+ await window.__term__.startRecording();
130
+ window.terminalRecording = { writeIntoTerminal, stopRecording };
131
+ },
132
+ /* eslint-env node */
133
+ );
134
+ writeCallbackSet.add(async (data) => {
135
+ await page.evaluate(
136
+ /* eslint-env browser */
137
+ async (data) => {
138
+ await window.terminalRecording.writeIntoTerminal(data);
132
139
  },
140
+ data,
133
141
  /* eslint-env node */
134
142
  );
135
- await page.evaluate(
143
+ });
144
+ stopCallbackSet.add(async () => {
145
+ const recordedFormats = await page.evaluate(
136
146
  /* eslint-env browser */
137
- async () => {
138
- const { writeIntoTerminal, stopRecording } =
139
- await window.__term__.startRecording();
140
- window.terminalRecording = { writeIntoTerminal, stopRecording };
147
+ () => {
148
+ return window.terminalRecording.stopRecording();
141
149
  },
142
150
  /* eslint-env node */
143
151
  );
144
-
145
- writeCallbackSet.add(async (data) => {
146
- await page.evaluate(
147
- /* eslint-env browser */
148
- async (data) => {
149
- await window.terminalRecording.writeIntoTerminal(data);
150
- },
151
- data,
152
- /* eslint-env node */
153
- );
154
- });
155
-
156
- stopCallbackSet.add(async () => {
157
- const recordedFormats = await page.evaluate(
158
- /* eslint-env browser */
159
- () => {
160
- return window.terminalRecording.stopRecording();
161
- },
162
- /* eslint-env node */
163
- );
164
- if (!debug) {
165
- server.stop();
166
- browser.close();
167
- }
168
-
169
- terminalRecords.gif = () => {
170
- const terminalGifBuffer = Buffer.from(recordedFormats.gif, "binary");
171
- return terminalGifBuffer;
172
- };
173
- terminalRecords.webm = () => {
174
- const terminalWebmBuffer = Buffer.from(recordedFormats.video, "binary");
175
- return terminalWebmBuffer;
176
- };
177
- terminalRecords.mp4 = async () => {
178
- const terminalWebmBuffer = Buffer.from(terminalRecords.video, "binary");
179
- const webmToMp4Namespace = await import("webm-to-mp4");
180
- const webmToMp4 = webmToMp4Namespace.default;
181
- const terminalMp4Buffer = Buffer.from(webmToMp4(terminalWebmBuffer));
182
- return terminalMp4Buffer;
183
- };
184
- });
185
- }
152
+ if (!debug) {
153
+ server.stop();
154
+ browser.close();
155
+ }
156
+ terminalRecords.svg = async () => {
157
+ const ansi = recordedFormats.textInViewport;
158
+ const terminalSvg = await renderTerminalSvg(ansi, svg);
159
+ return terminalSvg;
160
+ };
161
+ terminalRecords.gif = () => {
162
+ const terminalGifBuffer = Buffer.from(recordedFormats.gif, "binary");
163
+ return terminalGifBuffer;
164
+ };
165
+ terminalRecords.webm = () => {
166
+ const terminalWebmBuffer = Buffer.from(recordedFormats.video, "binary");
167
+ return terminalWebmBuffer;
168
+ };
169
+ terminalRecords.mp4 = async () => {
170
+ const terminalWebmBuffer = Buffer.from(terminalRecords.video, "binary");
171
+ const webmToMp4Namespace = await import("webm-to-mp4");
172
+ const webmToMp4 = webmToMp4Namespace.default;
173
+ const terminalMp4Buffer = Buffer.from(webmToMp4(terminalWebmBuffer));
174
+ return terminalMp4Buffer;
175
+ };
176
+ });
177
+ let stopped = false;
186
178
  return {
187
179
  write: async (data) => {
180
+ if (stopped) {
181
+ throw new Error("write after stop()");
182
+ }
188
183
  const promises = [];
189
184
  for (const writeCallback of writeCallbackSet) {
190
185
  promises.push(writeCallback(data));
File without changes