PTZ is a privacy-preserving telepresence prototype for controllable cameras. It relays media through LiveKit and routes PTZ commands through a backend control plane, so viewer browsers can watch and control a camera without receiving the camera's network address.
- WebRTC media relay through LiveKit; media is never sent directly between viewer and camera networks.
- Express backend for LiveKit token issuance, short-lived control sessions, and PTZ command arbitration.
- WebSocket control relay with one active controller per room, short leases, rate limits, and command acknowledgements.
- Browser viewer and browser camera-uplink pages for local demos.
- Node camera agent for hardware PTZ control, with mock, VISCA, ONVIF, HTTP-template, and HTTP-form adapters.
- Optional headless examples for camera-side media/control processes.
[Camera Edge] --(WebRTC publish)--> [LiveKit SFU] --(WebRTC subscribe)--> [Viewer Browser]
| ^
| |
+--(WS control, outbound only)--> [Backend API/Control Relay] <--(WS control)--+
Viewer only sees backend/SFU public addresses, not camera LAN/WAN IP.
POST /api/tokenreturns:- LiveKit access token
- control websocket URL
- signed control session token, valid for 15 minutes
GET /healthzhealth endpoint/ws/controlwebsocket relay with:- signed control-session authentication
- shared viewer/camera token checks when configured
- single-controller lock (
request_control/release_control) - PTZ rate limit using a token bucket
- PTZ forwarding to the active camera endpoint
- command acknowledgements from the camera side
- Audit log for forwarded PTZ commands (
logs/ptz-audit.log) - Viewer app at
/ - Camera publisher app at
/camera - Headless camera control agent at
src/camera-agent.js - PTZ adapters for mock, VISCA over UDP/TCP, VISCA serial, ONVIF ContinuousMove, and generic HTTP-template/form control
- Viewer sessions receive relay URLs and signed session tokens, never camera LAN/WAN addresses.
- Camera-side processes initiate outbound connections to the backend and media relay, so the camera network does not need inbound ports.
- Control commands are accepted only from the current controller for a room, and the lease is extended only when valid commands are forwarded.
- Local development can run without shared access tokens. In
NODE_ENV=production, the backend refuses to start unless presentation, camera-agent, LiveKit, and control-signing secrets are configured. - This repo uses shared presentation/camera-agent tokens rather than end-user accounts. Account auth, durable audit storage, TURN, and camera-specific motion bounds are deployment concerns outside this prototype.
- Install dependencies:
npm install- Start a local LiveKit server:
docker compose up -d livekit- Configure local env:
cp .env.example .env- Start the backend:
npm run dev- Open two browser tabs:
- Camera/media tab:
http://localhost:3000/camera - Viewer tab:
http://localhost:3000/
- In the camera tab, start the camera session. In the viewer tab, connect, acquire control, and use the PTZ buttons.
npm run presentation-link builds a viewer URL that auto-connects and requests PTZ control. If PRESENTATION_ACCESS_TOKEN is configured on the backend, use the same value when generating the link.
PUBLIC_URL=https://your-backend.example.com \
PRESENTATION_ACCESS_TOKEN=replace-with-link-token \
npm run presentation-linkThe generated URL has this shape:
https://your-backend.example.com/?room=demo-room&identity=presenter&autoconnect=1&autocontrol=1&access=...
When opened, the viewer page:
- requests a viewer token from the backend
- connects to the LiveKit media relay
- connects to the backend control relay
- requests PTZ control
- displays a privacy panel showing the media relay, control relay, and that no camera edge address was issued to the browser
For media, the simplest path is the browser camera page with an HDMI capture device, USB/UVC camera, OBS virtual camera, or another browser-visible source.
For PTZ control, run the Node camera agent on the camera-side machine:
PTZ_DRIVER=visca-udp PTZ_VISCA_HOST=192.0.2.10 npm run camera-agentSupported driver values:
mockvisca-udpvisca-tcpvisca-serialonvifhttp-templatehttp-form
The browser camera page can publish video while the camera agent owns the PTZ control socket. Leave "Register this browser as the PTZ receiver" unchecked for that split setup.
Browser-free camera video can also run from a headless process on the camera-side machine. This path requires ffmpeg on the system path.
python3 -m venv .venv-headless
. .venv-headless/bin/activate
pip install -r requirements-headless.txt
BACKEND_HTTP_URL=https://ptz-backend.example.com \
CAMERA_AGENT_TOKEN=replace-with-camera-agent-token \
RTSP_URL=rtsp://viewer:replace-with-password@192.0.2.10:554/stream1 \
python scripts/headless-video-agent.pyThis joins LiveKit as the camera publisher and pushes frames from the camera RTSP stream without using the browser camera-uplink page.
The backend needs these variables for a shared-token deployment:
LIVEKIT_URL=wss://...
LIVEKIT_API_KEY=...
LIVEKIT_API_SECRET=...
CONTROL_SIGNING_SECRET=...
PRESENTATION_ACCESS_TOKEN=...
CAMERA_AGENT_TOKEN=...
Reference env files and systemd templates live in deploy/:
deploy/pi/camera-agent.env.exampledeploy/pi/ptz-camera-agent.servicedeploy/pi/video-rtmp.env.exampledeploy/pi/ptz-video-rtmp.servicedeploy/pc/headless-video.env.example
These are templates for camera-side machines. Paths, Linux users, video devices, RTMP ingress values, and camera driver settings should be adapted to the target hardware.
npm run check
npm audit --audit-level=moderatenpm run check syntax-checks the backend, camera agent, helper scripts, and browser scripts.