Skip to content

Instantly share code, notes, and snippets.

@jialiang
Last active August 19, 2026 07:37
Show Gist options
  • Select an option

  • Save jialiang/34dd6f442bf7208293da6d34080a46e4 to your computer and use it in GitHub Desktop.

Select an option

Save jialiang/34dd6f442bf7208293da6d34080a46e4 to your computer and use it in GitHub Desktop.
Freeze Cam - Hold your webcam on its last frame in any tab
// ==UserScript==
// @name Freeze Cam
// @namespace local
// @version 1.2
// @description Hold your webcam on its last frame during a call (ctrl + shift + F).
// @match https://meet.google.com/*
// @match https://*.zoom.us/*
// @match https://*.zoom.com/*
// @match https://teams.microsoft.com/*
// @match https://teams.live.com/*
// @run-at document-start
// @inject-into page
// @sandbox raw
// @grant none
// ==/UserScript==
// Replacing navigator.mediaDevices.getUserMedia only affects the page if we run
// in the page's own context, which each script manager spells differently.
// Tampermonkey gets us there through @grant none, which turns off its sandbox,
// with @sandbox raw saying the same thing explicitly instead of relying on it
// being the default. @inject-into page is the Violentmonkey spelling, ignored
// by Tampermonkey as an unknown key.
//
// None of them is a hard guarantee: a strict CSP can push either manager out of
// page context (raw falls back to another sandbox, auto to the content script).
// That is the failure worth recognising, because there this patch runs, throws
// nothing and does nothing at all. The camera reaches the page untouched and
// the only symptom is a freeze key that quietly stopped working.
//
// The site list is kept narrow on purpose. Any frame in the tab can post the
// freeze message below, so a wider match would hand every third-party iframe on
// the web a switch on the camera.
//
// Chrome needs two settings on top of that, or the patch can land after the page
// has already read getUserMedia and gets bypassed: "Allow User Scripts" enabled
// for Tampermonkey in chrome://extensions, and Tampermonkey's inject mode set to
// "instant".
(() => {
"use strict";
// Past 30 the frame copying costs more than the extra smoothness is worth.
const MAX_FRAMES_PER_SECOND = 30;
const METADATA_TIMEOUT_MILLISECONDS = 3000;
const WATCHDOG_MILLISECONDS = 1000;
const BADGE_TAG_NAME = "freeze-cam-badge";
const BADGE_LINGER_MILLISECONDS = 1500;
const FREEZE_MESSAGE = "freeze-cam:set";
let isFrozen = false;
let badge = null;
let badgeTimer = null;
function createCanvas(width, height) {
return Object.assign(document.createElement("canvas"), { width, height });
}
function createHiddenVideo(stream) {
const video = document.createElement("video");
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
// Kept in the page but invisible: a fully detached video is not guaranteed
// to keep decoding frames, which would leave the canvas black.
video.style.cssText = "position:fixed;top:-9999px;width:1px;height:1px;opacity:0";
return video;
}
// The camera's real size, which getSettings() is not obliged to report.
function waitForVideoSize(video) {
if (video.videoWidth > 0) return Promise.resolve();
return new Promise((resolve, reject) => {
const giveUp = () => reject(new Error("no video size"));
const timer = setTimeout(giveUp, METADATA_TIMEOUT_MILLISECONDS);
video.addEventListener("loadedmetadata", () => {
clearTimeout(timer);
resolve();
}, { once: true });
});
}
// A worker's timer keeps its rate in a hidden tab, where a main-thread
// setInterval is clamped to once a second and would turn the outgoing video
// into a slideshow the moment you look at another tab.
function startWorkerTicker(intervalMilliseconds, onTick) {
const source = `setInterval(() => postMessage(0), ${intervalMilliseconds});`;
const sourceUrl = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
const worker = new Worker(sourceUrl);
worker.onmessage = onTick;
return () => {
worker.terminate();
URL.revokeObjectURL(sourceUrl);
};
}
function startMainThreadTicker(intervalMilliseconds, onTick) {
const timer = setInterval(onTick, intervalMilliseconds);
return () => clearInterval(timer);
}
// A strict CSP refuses the blob: worker outright, and an opaque origin can
// fail it asynchronously instead, so ticks that never arrive fall back as
// well: the alternative is a camera stuck on one frame for the whole call.
function startTicker(intervalMilliseconds, onTick) {
let tickCount = 0;
let stopTicker = null;
const countTick = () => {
tickCount += 1;
onTick();
};
try {
stopTicker = startWorkerTicker(intervalMilliseconds, countTick);
} catch {
return startMainThreadTicker(intervalMilliseconds, onTick);
}
const watchdog = setTimeout(() => {
if (tickCount > 0) return;
stopTicker();
stopTicker = startMainThreadTicker(intervalMilliseconds, onTick);
}, WATCHDOG_MILLISECONDS);
return () => {
clearTimeout(watchdog);
stopTicker();
};
}
// The page is handed a canvas track, which carries none of the camera's
// identity: apps that read deviceId to remember the chosen camera, or call
// applyConstraints to switch resolution, would otherwise break. Canvas
// geometry wins over the camera's, since that is what the page receives.
function inheritCameraIdentity(outgoingTrack, realTrack) {
const canvasSettings = outgoingTrack.getSettings.bind(outgoingTrack);
Object.defineProperties(outgoingTrack, {
label: {
get: () => realTrack.label,
configurable: true,
},
getSettings: {
value: () => ({ ...realTrack.getSettings(), ...canvasSettings() }),
configurable: true,
},
getCapabilities: {
value: () => realTrack.getCapabilities?.() ?? {},
configurable: true,
},
applyConstraints: {
value: (constraints) => realTrack.applyConstraints(constraints),
configurable: true,
},
});
}
async function wrapStream(realStream) {
const video = createHiddenVideo(realStream);
document.documentElement.appendChild(video);
try {
await video.play();
await waitForVideoSize(video);
} catch (error) {
video.remove();
throw error;
}
const [realTrack] = realStream.getVideoTracks();
// Match the camera's own rate so we are not resampling its frames.
const { frameRate } = realTrack.getSettings();
const cameraFramesPerSecond = Math.round(frameRate) || MAX_FRAMES_PER_SECOND;
const framesPerSecond = Math.min(cameraFramesPerSecond, MAX_FRAMES_PER_SECOND);
// alpha: false suits opaque camera frames. desynchronized is deliberately
// left off: it is meant for drawing straight to the screen, and has been
// reported to drop frames out of captureStream.
const canvas = createCanvas(video.videoWidth, video.videoHeight);
const context = canvas.getContext("2d", { alpha: false });
let heldFrame = null;
const paint = () => {
// An app can change resolution mid-call through applyConstraints, and
// resizing clears the canvas, so a held copy is stale and gets retaken.
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
heldFrame = null;
}
if (!isFrozen) {
heldFrame = null;
context.drawImage(video, 0, 0);
return;
}
// First tick of a freeze: keep the frame we are about to repeat. A null
// heldFrame doubles as "the stream was live a moment ago".
if (!heldFrame) {
heldFrame = createCanvas(canvas.width, canvas.height);
heldFrame.getContext("2d", { alpha: false }).drawImage(video, 0, 0);
}
context.drawImage(heldFrame, 0, 0);
};
paint();
const stopTicker = startTicker(Math.round(1000 / framesPerSecond), paint);
const outgoingStream = canvas.captureStream(framesPerSecond);
realStream.getAudioTracks().forEach((track) => outgoingStream.addTrack(track));
const [outgoingTrack] = outgoingStream.getVideoTracks();
const stopOutgoingTrack = outgoingTrack.stop.bind(outgoingTrack);
inheritCameraIdentity(outgoingTrack, realTrack);
let isReleased = false;
// When the page lets go of our fake track, release the real camera too,
// otherwise the camera light stays on. Video tracks only: the page owns the
// audio tracks we passed straight through and may still be using them.
const release = () => {
if (isReleased) return;
isReleased = true;
stopTicker();
realStream.getVideoTracks().forEach((track) => track.stop());
video.remove();
stopOutgoingTrack();
window.removeEventListener("pagehide", release);
};
outgoingTrack.stop = release;
// A camera that dies (unplugged, or claimed by another app) has to look dead
// to the page, which would otherwise see our canvas track stay live forever.
// Releasing before the event is what makes readyState agree with it.
realTrack.addEventListener("ended", () => {
release();
outgoingTrack.dispatchEvent(new Event("ended"));
});
// Backstop for an app that drops the stream without stopping it.
window.addEventListener("pagehide", release, { once: true });
return outgoingStream;
}
function createBadge() {
// An invented tag name keeps page rules like "div { display: none }" from
// matching the host, and a closed shadow root keeps the page's CSS and JS
// away from the badge itself.
const host = document.createElement(BADGE_TAG_NAME);
const element = document.createElement("div");
element.style.cssText = "position:fixed;top:14px;right:14px;z-index:2147483647;" +
"padding:6px 12px;border-radius:6px;color:#fff;pointer-events:none;" +
"font:600 13px system-ui,sans-serif;transition:opacity .3s";
host.attachShadow({ mode: "closed" }).appendChild(element);
(document.body ?? document.documentElement).appendChild(host);
return element;
}
function showBadge() {
// isConnected goes false when a single-page app swaps the body out.
if (!badge?.isConnected) badge = createBadge();
badge.textContent = isFrozen ? "camera frozen" : "camera live";
badge.style.background = isFrozen ? "#c0392b" : "#27ae60";
badge.style.opacity = "1";
clearTimeout(badgeTimer);
badgeTimer = setTimeout(() => { badge.style.opacity = "0"; }, BADGE_LINGER_MILLISECONDS);
}
function applyFrozen(nextIsFrozen) {
isFrozen = nextIsFrozen;
// One badge per tab: every frame runs this script, and the top document's
// badge is fixed to the viewport, so it is on screen either way.
if (window === window.top) showBadge();
}
// The keypress only reaches the frame that has focus, but the camera can be
// held by any frame in the tab, so the new state goes up to the top document
// and is relayed back down to everyone. Relaying is downward only, which is
// what keeps it from looping.
function relayDown(message) {
for (let index = 0; index < window.length; index += 1) {
window[index].postMessage(message, "*");
}
}
window.addEventListener("message", (event) => {
const message = event.data;
if (message?.source !== FREEZE_MESSAGE) return;
if (typeof message.isFrozen !== "boolean") return;
applyFrozen(message.isFrozen);
relayDown(message);
});
// Capture phase, so call apps that bind their own shortcuts do not swallow it.
window.addEventListener("keydown", (event) => {
if (event.repeat || !event.shiftKey || event.code !== "KeyF") return;
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
const nextIsFrozen = !isFrozen;
// Applied locally as well: if the top document is not running this script,
// nothing relays the state back down and this frame would be left out.
applyFrozen(nextIsFrozen);
(window.top ?? window).postMessage({ source: FREEZE_MESSAGE, isFrozen: nextIsFrozen }, "*");
}, true);
// Absent on insecure origins and in some sandboxed frames, where reading
// through it would throw and take the shortcut down with it.
if (!navigator.mediaDevices?.getUserMedia) return;
const realGetUserMedia = MediaDevices.prototype.getUserMedia;
// Patched on the prototype rather than on navigator.mediaDevices: a page that
// calls MediaDevices.prototype.getUserMedia.call(...) would slip past an
// override that only sits on the instance.
MediaDevices.prototype.getUserMedia = async function (constraints) {
const realStream = await realGetUserMedia.call(this, constraints);
if (realStream.getVideoTracks().length === 0) return realStream;
// Anything that goes wrong in here costs the freeze feature, never the
// camera: hand the page the real stream instead of a rejected promise.
try {
return await wrapStream(realStream);
} catch (error) {
console.warn("[freeze-cam] passing the camera through unwrapped:", error);
return realStream;
}
};
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment