diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..a995fc9 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,16 @@ +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=devsecretdevsecretdevsecretdevsecret + +PUBLIC_LIVEKIT_URL=ws://localhost:7880 +PUBLIC_API_URL=http://localhost:8080 + +SESSION_SECRET=change-me-to-a-long-random-string +SESSION_TTL_HOURS=12 + +BOOTSTRAP_ADMIN_EMAIL=admin@pistation.local +BOOTSTRAP_ADMIN_PASSWORD=changeme + +PIN_ROTATION_SECONDS=45 +PIN_GRACE_SECONDS=15 + +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..6d14310 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,58 @@ +name: pistation + +services: + livekit: + image: livekit/livekit-server:latest + restart: unless-stopped + command: --config /etc/livekit.yaml + environment: + LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" + volumes: + - ./livekit.yaml:/etc/livekit.yaml:ro + network_mode: host + + server: + build: + context: .. + dockerfile: server/Dockerfile + restart: unless-stopped + depends_on: + - livekit + environment: + BIND_ADDRESS: 0.0.0.0:8080 + DATABASE_URL: sqlite:///data/pistation.db?mode=rwc + MEDIA_DIR: /data/media + PACKAGE_DIR: /data/packages + PUBLIC_API_URL: ${PUBLIC_API_URL} + LIVEKIT_URL: ${PUBLIC_LIVEKIT_URL} + LIVEKIT_API_KEY: ${LIVEKIT_API_KEY} + LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET} + SESSION_SECRET: ${SESSION_SECRET} + SESSION_TTL_HOURS: ${SESSION_TTL_HOURS} + BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD} + PIN_ROTATION_SECONDS: ${PIN_ROTATION_SECONDS} + PIN_GRACE_SECONDS: ${PIN_GRACE_SECONDS} + CORS_ORIGINS: ${CORS_ORIGINS} + volumes: + - server-data:/data + ports: + - "8080:8080" + + web: + build: + context: .. + dockerfile: apps/web-client/Dockerfile + restart: unless-stopped + depends_on: + - server + environment: + PORT: 3000 + HOST: 0.0.0.0 + PUBLIC_API_URL: ${PUBLIC_API_URL} + PUBLIC_LIVEKIT_URL: ${PUBLIC_LIVEKIT_URL} + ports: + - "3000:3000" + +volumes: + server-data: diff --git a/infra/livekit.yaml b/infra/livekit.yaml new file mode 100644 index 0000000..fa679c2 --- /dev/null +++ b/infra/livekit.yaml @@ -0,0 +1,22 @@ +port: 7880 +bind_addresses: + - 0.0.0.0 + +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 50100 + use_external_ip: false + +turn: + enabled: true + udp_port: 3478 + domain: localhost + +room: + auto_create: true + empty_timeout: 300 + max_participants: 50 + +logging: + level: info diff --git a/infra/pi/pistation.json.example b/infra/pi/pistation.json.example new file mode 100644 index 0000000..c9d9197 --- /dev/null +++ b/infra/pi/pistation.json.example @@ -0,0 +1,5 @@ +{ + "serverUrl": "http://pistation.local:8080", + "enrollmentToken": "paste-the-token-from-the-admin-panel", + "joinUrl": "pistation.local:3000" +} diff --git a/infra/pi/spike-h264.md b/infra/pi/spike-h264.md new file mode 100644 index 0000000..5e929df --- /dev/null +++ b/infra/pi/spike-h264.md @@ -0,0 +1,86 @@ +# Spike: video performance on the Pi Zero 2W + +The original question was whether WebKitGTK could reach hardware H.264 decode. That turned +out to be moot: WebKitGTK ships without WebRTC at all, so the kiosk cannot receive media in +the webview. + +The kiosk now joins the room from Rust with the native LiveKit SDK. Decoding happens there, +each frame is scaled and JPEG encoded, and the result is pushed to the webview to be drawn +on a canvas. The question this spike answers has therefore changed: can a Zero 2W decode the +incoming stream **and** re-encode it fast enough to look live. + +The decode half is still worth measuring with the commands below, because libwebrtc uses the +same V4L2 path. The encode half is measured from the app itself, by lowering +`PISTATION_VIDEO_MAX_WIDTH`, `PISTATION_VIDEO_FPS` and `PISTATION_VIDEO_QUALITY` until the +picture keeps up. + +## Baseline + +The Zero 2W has a VideoCore IV class block exposed through V4L2 stateful decode at +`/dev/video10`, reached through GStreamer's `v4l2h264dec`. + +## Steps + +1. Confirm the kernel exposes the decoder. + + ``` + ls -l /dev/video10 /dev/video11 /dev/video12 + v4l2-ctl -d /dev/video10 --all | head -40 + ``` + + `/dev/video10` must report `Codec Decoder` capabilities. + +2. Confirm GStreamer picks the hardware element over the software one. + + ``` + gst-inspect-1.0 v4l2h264dec + gst-launch-1.0 videotestsrc num-buffers=600 ! video/x-raw,width=1280,height=720 \ + ! x264enc tune=zerolatency ! h264parse ! v4l2h264dec ! fpsdisplaysink text-overlay=false + ``` + + Record the reported average FPS. Under 25 means the decode path is not viable. + +3. Run the same clip through the software decoder for comparison. + + ``` + gst-launch-1.0 videotestsrc num-buffers=600 ! video/x-raw,width=1280,height=720 \ + ! x264enc tune=zerolatency ! h264parse ! avdec_h264 ! fpsdisplaysink text-overlay=false + ``` + + If step 2 and step 3 report similar numbers, WebKit is almost certainly using + software decode and the hardware element is not being selected. + +4. Measure the real path. Start the kiosk, join from a laptop, share a 1280x720 + screen, and sample CPU while the stream is live. + + ``` + systemctl start pistation-kiosk + top -b -n 30 -d 1 | grep -E "WebKitWebProcess|pistation-kiosk" + ``` + + Sustained WebKitWebProcess CPU above roughly 85 percent of one core means the + decode is running in software. + +5. Force the issue if step 4 is inconclusive. + + ``` + GST_DEBUG=v4l2*:5 systemctl start pistation-kiosk + journalctl -u pistation-kiosk | grep -i v4l2h264dec + ``` + +## Recording the result + +| Measurement | Value | Pass threshold | +| --- | --- | --- | +| `v4l2h264dec` 720p30 FPS | | 25 or higher | +| WebKitWebProcess CPU, 720p live share | | under 85 percent of one core | +| Frames dropped over 5 minutes | | under 1 percent | +| Annotation input to on-screen latency | | under 250 ms | + +## If the spike fails + +1. Try 960x540 at 20 fps by capping the publisher in `apps/web-client` before + declaring failure. The Zero 2W often passes at that resolution. +2. If it still fails, build `services/compositor-bot`. GStreamer composites the + screen share and the annotation canvas server side and sends the Pi one stream, + which drops the Pi's job to plain video playback.