// 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.
| Layer | What | Why |
|---|---|---|
| Capture | getUserMedia, getDisplayMedia | Camera, mic, screen. Native, hardware-accelerated. |
| Transport | WebRTC via PeerJS 1.5.4 | Peer-to-peer, DTLS-SRTP encrypted, built-in congestion control. |
| Signaling | peerjs-server (Node) behind Caddy | Swaps SDP and ICE candidates over a WebSocket. Never sees media. |
| NAT traversal | coturn on :3478 UDP/TCP | STUN to find a public address, TURN as a last-resort relay. |
| Recording | MediaRecorder | WebM/VP9 in memory, exported as a Blob. |
| UI | HTML + CSS, Lucide icons, qrcode.js | All 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.
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
- Camera phone generates a peer id from
crypto.getRandomValues(8 random bytes, rendered ascam-+ 12 base-36 characters), registers it with the signaling server and shows it as text and as a QR code ofhttps://beamcam.live/#cam-…. - 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. - The viewer calls the camera with a placeholder stream: a 1 fps black
canvas plus a silent
AudioContexttrack. 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. - The camera auto-answers with its real
MediaStream(720p ideal, chosen fps, mic on). - ICE runs: host candidates (LAN), server-reflexive (STUN), relay (TURN).
The best working pair wins. The camera polls
iceConnectionStateto report "Connected" in the UI. - 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 abyeon 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 firecloseon 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 getcontentHint = "detail": under pressure the encoder drops frames, not resolution, so text stays readable. - The adaptive bitrate never applies
scaleResolutionDownByto a screen. - The receiver is told (
{screen: true}on the data channel) and switches toobject-fit: contain: a demo is shown whole, never cropped. The camera viewer always usescontain. - The browser's own "Stop sharing" button fires
endedon 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():availableOutgoingBitratefrom the nominated candidate pair,fractionLostand RTT fromremote-inbound-rtp. On Firefox, which has no bandwidth estimate, the actual send rate fromoutbound-rtp.bytesSentis 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():
| Level | maxBitrate | scaleResolutionDownBy | ≈ Output from 720p |
|---|---|---|---|
| 0 | 300 kbps | 2 | 360p |
| 1 | 600 kbps | 2 | 360p |
| 2 | 1 Mbps | 1.5 | 480p |
| 3 | 1.5 Mbps | 1 | 720p (start level) |
| 4 | 2.5 Mbps | 1 | 720p |
| 5 | 4 Mbps | 1 | 720p, 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 torequestAnimationFrame. - 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
| Path | Role |
|---|---|
src/app.html | App markup (fragment; wrapped into dist/index.html by the build) |
src/script.js | Everything: views, broadcaster, viewer, calls, push-to-talk, filters, adaptive bitrate, recorder, routing |
src/style.css | App styles |
src/pages/ | Landing page and this page, plus src/site.css |
bin/build.sh | Assembles 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.