Last active
August 19, 2026 07:37
-
-
Save jialiang/34dd6f442bf7208293da6d34080a46e4 to your computer and use it in GitHub Desktop.
Freeze Cam - Hold your webcam on its last frame in any tab
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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