Skip to content

Instantly share code, notes, and snippets.

@creasty
Last active October 7, 2023 13:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save creasty/df5725d27a2cf5152c9bf7f3b3aa9bc8 to your computer and use it in GitHub Desktop.
Save creasty/df5725d27a2cf5152c9bf7f3b3aa9bc8 to your computer and use it in GitHub Desktop.
Stream custom messages as a video in Google Meet (or any web-based clients)

How to use

  1. Install User JavaScript and CSS extension
  2. Go to the extension options page:
  3. Click on 'Add new site':
  4. Enter name and domain:
  5. Copy & paste the script, and 'Save'
class TextLayoutEngine {
#ctx;
baselineRatio = 0.88;
constructor() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
this.#ctx = ctx;
}
#getFontFor(fontSize) {
return `${fontSize}px sans-serif`;
}
#measure(fontSize, text) {
this.#ctx.font = this.#getFontFor(fontSize);
return this.#ctx.measureText(text);
}
#wrapText(text, fontSize, width) {
const lines = [];
const line = { text: "", width: 0 };
for (let pos = 0, len = text.length; pos < len; pos++) {
const char = text[pos];
const metrics = this.#measure(fontSize, line.text + char);
if (metrics.width > width) {
lines.push({ ...line });
line.text = char;
line.width = metrics.width - line.width; // Might be slightly off due to kerning
} else {
line.text += char;
line.width = metrics.width;
}
}
if (line.text) {
lines.push(line);
}
return lines;
}
#fitText(text, width, height, maxWrap) {
let minFontSize = Math.floor(height / maxWrap);
let maxFontSize = height;
while (minFontSize <= maxFontSize) {
const fontSize = Math.floor((minFontSize + maxFontSize) / 2);
const lines = this.#wrapText(text, fontSize, width);
const numOfLines = lines.length;
const hasOverflow =
numOfLines > maxWrap || fontSize * numOfLines > height;
if (maxFontSize - minFontSize <= 1) {
return { fontSize, lines };
} else if (hasOverflow) {
maxFontSize = fontSize - 1;
} else {
minFontSize = fontSize;
}
}
}
calcLayout(text, width, height, maxWrap) {
const { fontSize, lines } = this.#fitText(text, width, height, maxWrap);
// Discard overflowed lines;
// Add ellipsis to the last line if needed
const hasOverflow = lines.length > maxWrap;
if (hasOverflow) {
lines.length = maxWrap;
lines.at(-1).text += "…";
}
// Calculate coordinates
const offsetY = (height - fontSize * lines.length) / 2;
for (let i = 0, len = lines.length; i < len; i++) {
const line = lines[i];
line.x = (width - line.width) / 2;
line.y = offsetY + fontSize * (i + this.baselineRatio);
}
return {
font: this.#getFontFor(fontSize),
lines,
};
}
}
function runLoop(callback, fps) {
let timerId = -1;
let rafId = -1;
const loop = () => {
timerId = window.setTimeout(() => {
callback();
rafId = window.requestAnimationFrame(loop);
}, 1000 / fps);
};
loop();
return () => {
window.clearTimeout(timerId);
window.cancelAnimationFrame(rafId);
};
}
const getUserMedia = navigator.mediaDevices.getUserMedia.bind(
navigator.mediaDevices
);
const MessageMode = {
text: "text",
overlay: "overlay",
};
class MessageStreamer {
text = "";
mode = MessageMode.text;
#disposers = [];
#textLayoutEngine = new TextLayoutEngine();
async getModifiedUserMedia(constraints) {
if (!constraints?.video) {
return getUserMedia(constraints);
}
if (
typeof constraints.video === "object" &&
constraints.video.mandatory?.chromeMediaSource === "desktop"
) {
return getUserMedia(constraints);
}
try {
const deviceStream = await getUserMedia(constraints);
const video = await this.#createVideo(deviceStream);
const canvasStream = await this.#createCanvasStream(video);
return new MediaStream([
...canvasStream.getVideoTracks(),
...deviceStream.getAudioTracks(),
]);
} catch (e) {
console.error(e);
throw e;
}
}
hijackUserMedia() {
window.__hijackUserMedia = this;
navigator.mediaDevices.getUserMedia = this.getModifiedUserMedia.bind(this);
}
restoreUserMedia() {
navigator.mediaDevices.getUserMedia = getUserMedia;
}
start() {
this.hijackUserMedia();
const ctrl = this.#createController();
document.body.append(ctrl);
this.#disposers.push(() => ctrl.remove());
}
stop() {
this.restoreUserMedia();
this.#disposers.forEach((d) => d());
this.#disposers = [];
}
async #createVideo(deviceStream) {
const video = document.createElement("video");
video.muted = true;
video.srcObject = deviceStream;
try {
await video.play();
} catch (err) {
console.error(err);
}
return video;
}
async #createCanvasStream(video) {
const canvas = document.createElement("canvas");
const canvasCtx = canvas.getContext("2d");
if (!canvasCtx) {
throw new Error("Failed to get canvas context");
}
const canvasStream = canvas.captureStream();
if (!canvasStream) {
throw new Error("Failed to capture stream from canvas");
}
const stop = runLoop(() => {
if (!canvasStream.active) return;
try {
this.#updateCanvas(canvas, canvasCtx, video);
} catch (err) {
console.error(err);
throw err;
}
}, 24);
this.#disposers.push(stop);
return canvasStream;
}
#updateCanvasCache = {};
#updateCanvas(dom, ctx, video) {
const cache = this.#updateCanvasCache;
// Resize canvas
const { videoWidth, videoHeight } = video;
if (cache.videoWidth != videoWidth || cache.videoHeight != videoHeight) {
dom.width = videoWidth;
dom.height = videoHeight;
cache.videoWidth = videoWidth;
cache.videoHeight = videoHeight;
}
// Fallback
if (!this.text) {
ctx.drawImage(video, 0, 0);
return;
}
// Background
if (this.mode == MessageMode.overlay) {
ctx.drawImage(video, 0, 0);
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillRect(0, 0, videoWidth, videoHeight);
} else {
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, videoWidth, videoHeight);
}
// Message
const textLayout = this.#calcTextLayout(videoWidth, videoHeight, this.text);
ctx.font = textLayout.font;
if (this.mode == MessageMode.overlay) {
ctx.fillStyle = "#fff";
} else {
ctx.fillStyle = "#999";
}
for (const line of textLayout.lines) {
ctx.fillText(line.text, line.x, line.y);
}
}
#calcTextLayoutCache = {};
#calcTextLayout(width, height, text) {
const cache = this.#calcTextLayoutCache;
if (cache.width == width && cache.height == height && cache.text == text) {
return cache.result;
}
// Add paddings
const maxWidth = Math.floor(Math.min(width * 0.8, (height * 0.9 * 4) / 3));
const maxHeight = Math.floor(height * 0.8);
const compactText = text.replace(/\s+/g, " ").trim();
const result = this.#textLayoutEngine.calcLayout(
compactText,
maxWidth,
maxHeight,
3
);
// Adjust the position to the original frame
const offsetX = (width - maxWidth) / 2;
const offsetY = (height - maxHeight) / 2;
for (const line of result.lines) {
line.x += offsetX;
line.y += offsetY;
}
cache.width = width;
cache.height = height;
cache.text = text;
cache.result = result;
return result;
}
#createController() {
const container = document.createElement("div");
container.style.position = "fixed";
container.style.top = "0";
container.style.left = "0";
container.style.zIndex = 10000;
container.style.display = "flex";
const input = document.createElement("input");
input.addEventListener("input", (e) => {
this.text = e.currentTarget.value;
});
input.addEventListener("focus", (e) => {
e.currentTarget.select();
});
container.append(input);
const select = document.createElement("select");
select.addEventListener("change", (e) => {
this.mode = e.currentTarget.value;
});
container.append(select);
const option1 = document.createElement("option");
option1.value = MessageMode.text;
option1.innerText = "Text Only";
select.append(option1);
const option2 = document.createElement("option");
option2.value = MessageMode.overlay;
option2.innerText = "Overlay";
select.append(option2);
return container;
}
}
new MessageStreamer().start();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment