// under the hood

No framework. No build step. No bullshit.

BeamCam is about a thousand lines of plain JavaScript on top of standard browser APIs. The browser already ships a camera stack, a video codec, an encrypted real-time transport and a congestion controller. The job is to wire them together and stay out of their way.

LayerWhatWhy
CapturegetUserMedia, getDisplayMediaCamera, mic, screen. Native, hardware-accelerated.
TransportWebRTC via PeerJS 1.5.4Peer-to-peer, DTLS-SRTP encrypted, built-in congestion control.
Signalingpeerjs-server (Node) behind CaddySwaps SDP and ICE candidates over a WebSocket. Never sees media.
NAT traversalcoturn on :3478 UDP/TCPSTUN to find a public address, TURN as a last-resort relay.
RecordingMediaRecorderWebM/VP9 in memory, exported as a Blob.
UIHTML + CSS, Lucide icons, qrcode.jsAll self-hosted. No CDN, no fonts from elsewhere.

Architecture

There are two planes. The signaling plane is tiny: a few kilobytes of JSON so two browsers can introduce themselves. The media plane carries the megabits, and it doesn't pass through our server.

beamcam.live Caddy :443 static files /peerjs → :9000 coturn :3478 STUN: public IP TURN: relay Camera phone getUserMedia → stream peer id: cam-xxxxxxxx answers calls Viewer random peer id calls cam-xxxxxxxx plays remote stream WSS: SDP + ICE WSS: SDP + ICE media: P2P, DTLS-SRTP video + audio → · ← talk-back audio

The site itself is static: index.html (the app), about/ (the landing page), tech.html, one script and two stylesheets. Caddy serves the files and proxies /peerjs/* (HTTP + WebSocket) to the PeerJS server on 127.0.0.1:9000. The PeerJS server runs with allow_discovery: false, so nobody can list the cameras that are online.

The handshake, step by step

  1. Camera phone generates a peer id from crypto.getRandomValues (8 random bytes, rendered as cam- + 12 base-36 characters), registers it with the signaling server and shows it as text and as a QR code of https://beamcam.live/#cam-….
  2. Viewer opens with an anonymous id. A #cam-… hash in the URL (from the QR code) is queued and dialled as soon as the peer is open, so you don't race a timer.
  3. The viewer calls the camera with a placeholder stream: a 1 fps black canvas plus a silent AudioContext track. This is on purpose. The SDP offer then contains both an audio and a video m-line, so the answer can carry real media in both directions without renegotiation.
  4. The camera auto-answers with its real MediaStream (720p ideal, chosen fps, mic on).
  5. ICE runs: host candidates (LAN), server-reflexive (STUN), relay (TURN). The best working pair wins. The camera polls iceConnectionState to report "Connected" in the UI.
  6. From here, the server is out of the loop until someone hangs up.
// viewer: offer audio + video even though we have nothing to send yet
const canvasStream = canvas.captureStream(1);
const silence = new AudioContext().createMediaStreamDestination();
const dummy = new MediaStream([
  ...canvasStream.getVideoTracks(), ...silence.stream.getAudioTracks()
]);
currentCall = peer.call(targetId, dummy);

NAT traversal: same Wi-Fi, mesh, 4G

On the same network (including Wi-Fi mesh systems, which are a single LAN behind several access points), the host candidates connect directly. Your video travels over your local network and never reaches the internet. Only the initial handshake does.

Across networks, each device asks the STUN server for its public address and the two punch through their NATs. When that fails (symmetric NAT, corporate firewall), TURN relays the encrypted packets. Both peers use iceTransportPolicy: "all"; forcing "relay" was tried and broke connections that would have worked directly.

Why not fully offline on a LAN? Two browsers can't find each other without a rendezvous point. Browsers have no mDNS/Bonjour API for web pages. A local signaling server would do it; see self-hosting.

Push-to-talk

Because of the placeholder stream, the viewer already has a negotiated audio sender carrying silence. Talking back is one call:

const sender = pc.getSenders().find(s => s.track?.kind === "audio");
const mic = (await getUserMedia({ audio: true })).getAudioTracks()[0];
await sender.replaceTrack(mic);   // once; no renegotiation
mic.enabled = pressed;              // on every press / release

The mic is requested on the first press and kept. After that, press and release just flip track.enabled, so there's no delay and no permission prompt mid-sentence. On the camera side, the incoming stream plays through a hidden <audio> element. The browser's acoustic echo cancellation stops the phone's own speaker from feeding back into its mic. The button uses pointer capture (works with mouse, touch and pen), and Space works too on a keyboard.

1-to-1 calls

Same signaling and the same pairing flow, but both ends send. The host registers a call-… id; the joiner gets a lobby ("with video" / "audio only"). That tap is also the user gesture iOS wants before it plays sound.

  • Both m-lines are always negotiated. With the camera off, a 1 fps black canvas track stands in for the offer/answer, then the sender gets replaceTrack(null): nothing is encoded, nothing is sent.
  • Camera off really is off. The track is stopped, the hardware is released and the camera light goes out. Turning it back on is getUserMedia() + replaceTrack(), with no renegotiation.
  • A data channel carries state ({cam, mic}, and a bye on hang-up). The other side shows an avatar or a "Muted" badge instead of guessing from a black frame. ICE state is watched too, because PeerJS doesn't always fire close on the far end.
  • Full duplex audio with the browser's echo cancellation, noise suppression and auto gain. Headphones still beat a phone speaker held at arm's length.
  • Strictly 1-to-1. A second caller is turned away while the line is busy. When the call ends, the host keeps its code and waits for the next caller.
  • Adaptive bitrate and the wake lock run on both ends.

Camera ⇄ screen share

In the camera mode and in calls, the camera button opens a menu: Camera, Share screen, Turn video off. Each choice is one replaceTrack() on the existing video sender, so the call never drops.

  • Screen tracks come from getDisplayMedia() (1080p ideal, 15 fps) and get contentHint = "detail": under pressure the encoder drops frames, not resolution, so text stays readable.
  • The adaptive bitrate never applies scaleResolutionDownBy to a screen.
  • The receiver is told ({screen: true} on the data channel) and switches to object-fit: contain: a demo is shown whole, never cropped. The camera viewer always uses contain.
  • The browser's own "Stop sharing" button fires ended on the track: BeamCam goes back to whatever was on before (camera, or nothing).
  • Mobile browsers don't implement getDisplayMedia(); the option is shown disabled there.

Adaptive bitrate: measured, not guessed

WebRTC's own congestion control (GCC) already reacts within seconds. It's good at reacting and bad at committing: on a flaky link it climbs, gets burned and drops, over and over. BeamCam's Auto mode sits on top and picks a ceiling the link has sustained, computed locally on the camera phone:

  • Every 2 s, read getStats(): availableOutgoingBitrate from the nominated candidate pair, fractionLost and RTT from remote-inbound-rtp. On Firefox, which has no bandwidth estimate, the actual send rate from outbound-rtp.bytesSent is used instead.
  • Keep a sliding window of 90 samples = 3 minutes.
  • Ignore the first 10 s: the estimator starts low and ramps up, and those samples would drag the budget down for three minutes.
  • Budget = 20th percentile of measured bandwidth. One good second doesn't raise it; the link has to hold that speed most of the time.
  • Loss over the last 30 s above 2% / 5% cuts the budget by 15% / 30%.
  • Move along a ladder of levels, applied with RTCRtpSender.setParameters():
LevelmaxBitratescaleResolutionDownBy≈ Output from 720p
0300 kbps2360p
1600 kbps2360p
21 Mbps1.5480p
31.5 Mbps1720p (start level)
42.5 Mbps1720p
54 Mbps1720p, high detail

Hysteresis band: the bandwidth estimate tends to sit close to what's already being sent, so "budget below the cap" alone would ratchet quality down forever. Instead it drops only when the sustained budget falls under 90% of the current cap, going straight to the highest level that fits in 85% of it. It climbs one level at a time, only when the budget clears the next level by 25% and loss stays under 2%. Bad news is acted on immediately; good news has to last before it counts. Pick a fixed bitrate in the menu to turn all of this off.

What it does not touch: colour depth. Real-time video codecs (VP8/VP9/H.264) encode 8-bit 4:2:0. There is no "fewer colours" knob. The closest real lever is the grayscale filter, which empties the chroma planes.

Image filters: applied to what's sent

Brightness, contrast and grayscale change the stream the viewer receives, not just the local preview. When any slider is off neutral, the camera track goes through a small pipeline:

camera track → hidden <video> → <canvas> → canvas.captureStream() → RTCRtpSender.replaceTrack()
  • Canvas 2D ctx.filter (grayscale() contrast() brightness()) where supported: Chrome, Edge, Firefox. It's GPU-backed.
  • WebGL fragment shader fallback for browsers without ctx.filter: luma mix, then contrast around mid-grey, then gain.
  • Drawn once per camera frame with requestVideoFrameCallback, falling back to requestAnimationFrame.
  • Back at neutral values, the pipeline is torn down and the raw camera track is swapped back in, so it costs nothing when unused.

Grayscale also saves bandwidth: flat chroma planes compress to almost nothing, so at the same bitrate the encoder spends everything on luminance detail. Useful for a dark nursery at 500 kbps.

Local recorder

Camera (getUserMedia, 1080p ideal) or screen (getDisplayMedia) with audio goes into a MediaRecorder. VP9 is used when available, otherwise the browser's default. Chunks are collected every 100 ms in memory, then assembled into a Blob for playback and download. No network involved. Keep long recordings in mind: everything lives in RAM until you download it.

Privacy model

  • Media is end-to-end encrypted by WebRTC (DTLS-SRTP, not optional). A TURN relay only forwards ciphertext.
  • No long-lived secrets in the browser. TURN credentials are minted per session by the signaling server (/peerjs/turn): a username carrying an expiry timestamp and an HMAC of it, valid 24 h. The coturn secret stays on the server.
  • No accounts, no database. Peer ids are ephemeral and disappear when the tab closes.
  • No third-party requests. PeerJS, Lucide and qrcode.js are served from beamcam.live. No CDN, analytics, fonts or pixels.
  • Client logs off in production. Debug logging only runs on localhost.
  • The pairing code is a bearer token. It's random enough to be unguessable in practice, and with discovery disabled the codes can't be listed. But whoever holds the code can watch, so treat it like a key.

Code map

PathRole
src/app.htmlApp markup (fragment; wrapped into dist/index.html by the build)
src/script.jsEverything: views, broadcaster, viewer, calls, push-to-talk, filters, adaptive bitrate, recorder, routing
src/style.cssApp styles
src/pages/Landing page and this page, plus src/site.css
bin/build.shAssembles dist/: HTML wrapper, manifest, copies. That's the whole build.
server/PeerJS server (server.js), systemd and coturn install scripts
config-samples/Caddy and nginx reverse-proxy examples

The app is a single page with four views toggled by an active class. URL hashes drive entry points: #camera, #viewer, #record, #videocall, #audiocall, or #cam-… / #call-… to connect straight away.

Self-hosting

Three pieces, all boring on purpose. Everything deployment-specific lives in one .env file: name, domain, which apps are shown, TURN secret.

# settings
cp .env.example .env              # APP_NAME, APP_DOMAIN, APP_LIVE / APP_VIDEOCALL / APP_AUDIOCALL / APP_EDITOR

# 1. signaling server + STUN/TURN
cd server
./install.sh                      # npm dependencies
sudo ./install-coturn.sh          # coturn; writes TURN_SECRET into ../.env
sudo ./install-service.sh         # systemd unit for server.js

# 2. static files: .env -> dist/config.js (public values only)
bash bin/build.sh

# 3. proxy (Caddy does TLS for you)
your.domain {
    root * /path/to/dist
    file_server
    reverse_proxy /peerjs/* 127.0.0.1:9000
}

The build copies only a whitelist of public values into the page: the TURN secret never reaches dist/. Run it on a box inside your house and you get a LAN-only setup: signaling, media and TURN never leave your network.

Known limits

  • Foreground only on mobile. iOS and Android suspend camera capture in background tabs. The Screen Wake Lock API keeps the display on while streaming and is re-acquired when the tab becomes visible again.
  • No virtual camera. A web page can't register a system camera device. Use OBS's Virtual Camera.
  • Service Worker disabled. An earlier one intercepted the signaling WebSocket. It's unregistered on load. No offline install for now.
  • One to one by design. One viewer per camera, two people per call. No group calls: that would need an SFU, which means a media server. That's a different product.

Source

MIT licensed. Read it, fork it, send a patch. Short, clear patches get merged.

beamcam/beamcamgithub.com/beamcam/beamcam · MIT · © 2026 L3DLP
→