Building ScreenCam

Building ScreenCam, a minimal Chrome extension that records a screen or tab, overlays a webcam, mixes audio, and lets you save the file. No accounts, no locking videos.

Table of Contents

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:

  1. Chrome slows or stops rendering when the window is not in front.

  2. 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

If I ever need something truly fail-safe across machines, a native macOS app using ScreenCaptureKit and AVFoundation would be it. For my use, this extension is enough. It records clean tutorials and always gives me the file. Please feel free to view the GitHub link and contribute via PRs or any edits you see fit.