@jsenv/terminal-recorder
Version:
Record terminal output as .svg, .gif, .webm, .mp4
388 lines (369 loc) • 11.9 kB
JavaScript
/*
on veut pouvoir:
- mettre le terminal dans un "fake terminal" genre
avec le style de celui svg
- enregister une vidéo du terminal
ça faut que je regarde les apis canvas
https://github.com/welefen/canvas2video/blob/master/src/index.ts
https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream
https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder
https://webglfundamentals.org/webgl/lessons/webgl-tips.html
https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts
*/
import "@xterm/addon-fit";
import "@xterm/addon-serialize?as_js_module";
// see https://github.com/microsoft/playwright/issues/30585 which is a problem for mac os 14
import "@xterm/addon-webgl"; // https://github.com/xtermjs/xterm.js/tree/master/addons/addon-webgl
import "@xterm/xterm";
import { createGifEncoder } from "./gif_encoder.js";
const { Terminal } = window;
const { FitAddon } = window.FitAddon;
const { WebglAddon } = window.WebglAddon;
const { SerializeAddon } = window.SerializeAddon;
export const initTerminal = ({
cols = 80,
rows = 25,
paddingLeft = 15,
paddingRight = 15,
fontFamily = "SauceCodePro Nerd Font, Source Code Pro, Courier",
fontSize = 12,
convertEol = true,
textInViewport,
gif,
video,
logs,
}) => {
if (textInViewport === true) textInViewport = {};
if (gif === true) gif = {};
if (video === true) video = {};
if (!textInViewport && !video && !gif) {
throw new Error("ansi, video or gif must be enabled");
}
const log = (...args) => {
if (logs) {
console.log(...args);
}
};
log("init", { cols, rows, fontFamily, fontSize, convertEol, gif, video });
const cssUrl = new URL("@xterm/xterm/css/xterm.css", import.meta.url);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = cssUrl;
document.head.appendChild(link);
const term = new Terminal({
convertEol,
disableStdin: true,
// cursorBlink: false,
cursorInactiveStyle: "none",
cols,
rows,
fontFamily,
fontSize,
// lineHeight: 18,
theme: {
background: "#282c34",
foreground: "#abb2bf",
selectionBackground: "#001122",
selectionForeground: "#777777",
brightRed: "#B22222",
brightBlue: "#87CEEB",
},
});
const serializeAddon = new SerializeAddon();
term.loadAddon(serializeAddon);
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const webglAddon = new WebglAddon(
// we pass true to enable preserveDrawingBuffer
// it's not actually useful thanks to term.write callback +
// call on _innerRefresh() before context.drawImage
// but let's keep it nevertheless
true,
);
term.loadAddon(webglAddon);
const terminalElement = document.querySelector("#terminal");
term.open(terminalElement);
fitAddon.fit();
const canvas = document.querySelector("#canvas");
if (!canvas) {
throw new Error('Cannot find <canvas id="canvas"> in the document');
}
const xTermCanvasSelector = "#terminal canvas.xterm-link-layer + canvas";
const xtermCanvas = document.querySelector(xTermCanvasSelector);
if (!xtermCanvas) {
throw new Error(`Cannot find xterm canvas (${xTermCanvasSelector})`);
}
return {
term,
startRecording: async () => {
const context = canvas.getContext("2d", { willReadFrequently: true });
context.imageSmoothingEnabled = true;
const headerHeight = 40;
canvas.width = paddingLeft + xtermCanvas.width + paddingRight;
canvas.height = headerHeight + xtermCanvas.height;
draw_header: {
drawRectangle(context, {
x: 0,
y: 0,
width: canvas.width,
height: canvas.height,
fill: "#282c34",
stroke: "rgba(255,255,255,0.35)",
strokeWidth: 10,
radius: 8,
});
drawCircle(context, {
x: 20,
y: headerHeight / 2,
radius: 6,
fill: "#ff5f57",
});
drawCircle(context, {
x: 40,
y: headerHeight / 2,
radius: 6,
fill: "#febc2e",
});
drawCircle(context, {
x: 60,
y: headerHeight / 2,
radius: 6,
fill: "#28c840",
});
// https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Drawing_text
const text = "Terminal";
context.font = `${fontSize}px ${fontFamily}`;
context.textBaseline = "middle";
context.textAlign = "center";
// const textSize = context.measureText(text);
context.fillStyle = "#abb2bf";
context.fillText(text, canvas.width / 2, headerHeight / 2);
}
const records = {};
let writePromise = Promise.resolve();
const frameCallbackSet = new Set();
const stopCallbackSet = new Set();
if (textInViewport) {
log("start recording text in viewport");
// https://github.com/xtermjs/xterm.js/issues/3681
// https://github.com/xtermjs/xterm.js/issues/3681
// get the first line
// term.buffer.active.getLine(term.buffer.active.viewportY).translateToString()
// get the last line
// term.buffer.active.getLine(term.buffer.active.length -1).translateToString()
// https://github.com/xtermjs/xterm.js/blob/a7952ff36c60ee6dce9141744b1355a5d582ee39/addons/addon-serialize/src/SerializeAddon.ts
stopCallbackSet.add(() => {
const startY = term.buffer.active.viewportY;
const endY = term.buffer.active.length - 1;
const range = {
start: startY,
end: endY,
};
log(`lines in viewport: ${startY}:${endY}`);
const output = serializeAddon.serialize({
range,
});
log(`text in viewport: ${output}`);
records.textInViewport = output;
});
}
if (video) {
log("start recording video");
const { mimeType = "video/webm;codecs=h264", msAddedAtTheEnd } = video;
if (!MediaRecorder.isTypeSupported(mimeType)) {
throw new Error(`MediaRecorder does not support "${mimeType}"`);
}
const stream = canvas.captureStream();
const mediaRecorder = new MediaRecorder(stream, {
videoBitsPerSecond: 2_500_000,
mimeType,
});
const chunks = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size) {
chunks.push(e.data);
}
};
log("starting media recorder");
const startPromise = new Promise((resolve, reject) => {
mediaRecorder.onstart = () => {
resolve();
};
mediaRecorder.onerror = (e) => {
log("media recorder error");
reject(e);
};
mediaRecorder.start();
});
let timeout;
let hasTimedout = false;
const MS_ALLOCATED_TO_MEDIA_RECORDER = 2_000;
await Promise.race([
startPromise,
new Promise((resolve) => {
timeout = setTimeout(() => {
hasTimedout = true;
resolve();
}, MS_ALLOCATED_TO_MEDIA_RECORDER);
}),
]);
if (hasTimedout) {
throw new Error(
`media recorder did not start in less than ${MS_ALLOCATED_TO_MEDIA_RECORDER}ms`,
);
}
clearTimeout(timeout);
log("media recorder started");
stopCallbackSet.add(async () => {
if (msAddedAtTheEnd) {
await new Promise((resolve) => {
setTimeout(resolve, msAddedAtTheEnd);
});
replicateXterm();
} else {
replicateXterm();
}
await new Promise((resolve) => {
setTimeout(resolve, 150);
});
await new Promise((resolve, reject) => {
mediaRecorder.onstop = () => {
resolve();
};
mediaRecorder.onerror = (e) => {
reject(e);
};
mediaRecorder.stop();
});
const blob = new Blob(chunks, { type: mediaRecorder.mimeType });
const videoBinaryString = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsBinaryString(blob);
});
records.video = videoBinaryString;
});
}
if (gif) {
log("start recording gif");
const { repeat = -1, quality, msAddedAtTheEnd } = gif;
const gifEncoder = createGifEncoder({
width: canvas.width,
height: canvas.height,
repeat: repeat === true ? 0 : repeat === false ? -1 : repeat,
quality,
});
frameCallbackSet.add(({ delay }) => {
log(`add frame to gif with delay of ${delay}ms`);
gifEncoder.addFrame(context, { delay });
});
stopCallbackSet.add(async () => {
const now = Date.now();
drawFrame({
delay: now - previousTime,
});
if (msAddedAtTheEnd) {
drawFrame({
delay: now - msAddedAtTheEnd,
});
}
gifEncoder.finish();
log("gif recording stopped");
const gifBinaryString = gifEncoder.readAsBinaryString();
records.gif = gifBinaryString;
});
log("gif recorder started");
}
const startTime = Date.now();
let previousTime = startTime;
let isFirstDraw = true;
const drawFrame = (options) => {
for (const frameCallback of frameCallbackSet) {
frameCallback(options);
}
};
const replicateXterm = () => {
context.drawImage(
xtermCanvas,
0,
0,
xtermCanvas.width,
xtermCanvas.height,
paddingLeft,
headerHeight,
xtermCanvas.width,
xtermCanvas.height,
);
};
replicateXterm();
return {
writeIntoTerminal: async (data, options = {}) => {
if (isFirstDraw) {
isFirstDraw = false;
drawFrame({ delay: 0 });
}
let { delay } = options;
if (delay === undefined) {
const now = Date.now();
delay = now - previousTime;
}
writePromise = new Promise((resolve) => {
log(`write data:`, data);
term.write(data, () => {
term._core._renderService._renderDebouncer._innerRefresh();
replicateXterm();
drawFrame({ delay });
resolve();
});
});
await writePromise;
},
stopRecording: async () => {
await writePromise;
const promises = [];
for (const stopCallback of stopCallbackSet) {
promises.push(stopCallback());
}
await Promise.all(promises);
return records;
},
};
},
};
};
const drawRectangle = (
context,
{ x, y, width, height, radius, fill, stroke, strokeWidth },
) => {
context.beginPath();
if (radius) {
context.roundRect(x, y, width, height, [radius]);
} else {
context.rect(x, y, width, height);
}
if (fill) {
context.fillStyle = fill;
context.fill();
}
if (stroke) {
context.strokeWidth = strokeWidth;
context.strokeStyle = stroke;
context.stroke();
}
};
const drawCircle = (context, { x, y, radius, fill, stroke, borderWidth }) => {
context.beginPath();
context.arc(x, y, radius, 0, 2 * Math.PI, false);
if (fill) {
context.fillStyle = fill;
context.fill();
}
if (borderWidth) {
context.lineWidth = 5;
}
if (stroke) {
context.strokeStyle = stroke;
context.stroke();
}
};