
Why Build ScreenCam
I was looking to record a few coding / WordPress tutorials and wanted an overlay of my face while demonstrating with my screen. Naturally, I thought, ‘Use Loom’. Of course everyones heard of it, and I’d used it before for product demos & presentations. After I recorded a full WordPress tutorial, I looked to download my video, I realized I needed a paid account (frustrating). The amount of scaffolding to turn something as simple as a webcam overlay over a screen record into a SaaS is pretty incredible. Even more incredible is it’s recent valuation. So I thought, how hard could it be to quickly build this as a simple tool I needed. In the end, I did get this shipped in the Chrome Store, if it could be useful to anyone else.
What it does
Preview webcam and pick a screen, window, or tab
Circular webcam overlay on top of the capture
Mic and, for tab capture, tab audio
Start, small controller with timer, Stop
Save As to
.webm, with a download fallback
What went wrong at first
The first version froze a lot. Two main reasons:
Chrome slows or stops rendering when the window is not in front.
If the shared screen is not changing, Chrome may stop sending new frames.
When either happens, a simple loop that draws only on new frames will stop updating the webcam bubble.
What fixed it
I changed the design so it keeps drawing at a steady rate, even if the screen is still.
I read screen frames with
MediaStreamTrackProcessor. This works even if the window is not focused.I always draw on a 30 fps timer. If there is no new screen frame, I reuse the last one. The webcam keeps updating.
I keep a tiny hidden
<video>element playing the webcam stream. Some cameras are more reliable when a real video element is consuming frames.
I also capped the output at 1080p and set everything to 30 fps. This keeps it smooth on a laptop.
Core Pieces
Get streams with sensible constraints
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30 },
audio: true
});
const sTrack = screenStream.getVideoTracks()[0];
try { await sTrack.applyConstraints({ frameRate: 30 }); sTrack.contentHint = "detail"; } catch {}
const camStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280, max: 1920 },
height:{ ideal: 720, max: 1080 },
frameRate: { ideal: 30, max: 30 }
},
audio: true
});
const cTrack = camStream.getVideoTracks()[0];
try { await cTrack.applyConstraints({ frameRate: 30 }); cTrack.contentHint = "motion"; } catch {}
Read the screen in the background
const TP = window.MediaStreamTrackProcessor;
const scrProc = new TP({ track: sTrack });
const scrReader = scrProc.readable.getReader();
let latestScreenBitmap = null;
(async () => {
while (true) {
const { value: frame, done } = await scrReader.read();
if (done) break;
const bmp = await createImageBitmap(frame);
if (latestScreenBitmap) latestScreenBitmap.close();
latestScreenBitmap = bmp;
frame.close();
}
})();
Keep the webcam active with a hidden video
const hiddenCam = document.createElement("video");
hiddenCam.muted = true;
hiddenCam.playsInline = true;
hiddenCam.autoplay = true;
hiddenCam.srcObject = camStream;
document.body.appendChild(hiddenCam);
await hiddenCam.play().catch(() => {});
Composite on a steady 30 fps timer
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d", { alpha: false });
function clamp1080p(w, h) {
const s = Math.min(1920 / w, 1080 / h, 1);
return { w: Math.round(w * s), h: Math.round(h * s) };
}
const TICK_MS = 33; // ~30 fps
setInterval(() => {
let w = 1280, h = 720; // default before first screen frame
if (latestScreenBitmap) {
const out = clamp1080p(latestScreenBitmap.width, latestScreenBitmap.height);
w = out.w; h = out.h;
}
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h;
}
if (latestScreenBitmap) {
ctx.drawImage(latestScreenBitmap, 0, 0, w, h);
} else {
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, w, h);
}
if (hiddenCam.readyState >= 2) {
const size = Math.round(Math.min(w, h) * 0.28);
const x = w - size - 28, y = h - size - 28;
ctx.save();
ctx.beginPath();
ctx.arc(x + size/2, y + size/2, size/2, 0, Math.PI*2);
ctx.clip();
const cw = hiddenCam.videoWidth || 16;
const ch = hiddenCam.videoHeight || 9;
const scale = Math.max(size / cw, size / ch);
const dw = cw * scale, dh = ch * scale;
const dx = x + (size - dw)/2, dy = y + (size - dh)/2;
ctx.drawImage(hiddenCam, dx, dy, dw, dh);
ctx.restore();
ctx.strokeStyle = "rgba(255,255,255,0.96)";
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(x + size/2, y + size/2, size/2 - 1.25, 0, Math.PI*2);
ctx.stroke();
}
}, TICK_MS);

Record & Save
const mixed = new MediaStream([
...canvas.captureStream(30).getVideoTracks(),
...new (window.AudioContext || window.webkitAudioContext)().createMediaStreamDestination().stream.getAudioTracks()
]);
const mime = MediaRecorder.isTypeSupported("video/webm;codecs=vp9")
? "video/webm;codecs=vp9"
: "video/webm;codecs=vp8";
const rec = new MediaRecorder(mixed, {
mimeType: mime,
videoBitsPerSecond: 9_000_000,
audioBitsPerSecond: 160_000
});
const chunks = [];
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
rec.onstop = async () => {
const blob = new Blob(chunks, { type: chunks[0]?.type || "video/webm" });
const name = `screencam-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`;
if (window.showSaveFilePicker) {
try {
const handle = await showSaveFilePicker({
suggestedName: name,
types: [{ description: "WebM", accept: { "video/webm": [".webm"] } }]
});
const w = await handle.createWritable();
await w.write(blob);
await w.close();
return;
} catch {}
}
// Fallback
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
};
rec.start(1000);
What I learned
Browser capture is sensitive to focus and motion. If the screen is static, do not rely on new frames to drive your overlay. Draw on a timer.
Keep the webcam stream actively playing in a video element. It prevents some cameras from stalling.
Cap the output. 1080p at 30 fps with a reasonable bitrate looks pretty good and is stable.
Whats Next
Moveable and resizable webcam bubble
Keyboard shortcut for start and stop
Quick trim after saving