From 9b1ae2ae14d970fdcbe7d0449287370b432bc020 Mon Sep 17 00:00:00 2001
From: chk <79915315+ChKendel@users.noreply.github.com>
Date: Tue, 2 Jun 2026 22:19:08 +0200
Subject: [PATCH] WebCam als schlanke alternative zum appVideoControl
---
README.md | 0
doc/01_WebcamRoadmap.md | 152 ++++++++++++++++++++++++++++++
doc/05_OptionalToDo_roadmap.md | 87 ++++++++++++++++++
docker-compose.yaml | 66 +++++++++++++
package.json | 20 ++++
public/index.html | 67 ++++++++++++++
public/viewer.js | 110 ++++++++++++++++++++++
server.js | 85 +++++++++++++++++
src/deviceDetect.js | 42 +++++++++
src/snapshotService.js | 50 ++++++++++
src/videoStream.js | 163 +++++++++++++++++++++++++++++++++
11 files changed, 842 insertions(+)
create mode 100644 README.md
create mode 100644 doc/01_WebcamRoadmap.md
create mode 100644 doc/05_OptionalToDo_roadmap.md
create mode 100644 docker-compose.yaml
create mode 100644 package.json
create mode 100644 public/index.html
create mode 100644 public/viewer.js
create mode 100644 server.js
create mode 100644 src/deviceDetect.js
create mode 100644 src/snapshotService.js
create mode 100644 src/videoStream.js
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/doc/01_WebcamRoadmap.md b/doc/01_WebcamRoadmap.md
new file mode 100644
index 0000000..6cc2679
--- /dev/null
+++ b/doc/01_WebcamRoadmap.md
@@ -0,0 +1,152 @@
+# AppRobotWebcam – Roadmap
+
+## Ziel
+
+Sauberer, fokussierter Webcam-Streaming-Service als Docker-Container.
+Kein Robot-Control, kein ArUco – nur zwei Verantwortlichkeiten:
+
+1. **Live-Video** mit minimaler Latenz (MJPEG über WebSocket)
+2. **Standbilder** auf Abruf via HTTP REST (`/api/snapshot/cam{n}`)
+
+Das Homing-Projekt holt seine Standbilder über den Snapshot-Endpunkt – keine weitere Kopplung nötig.
+
+---
+
+## Architektur
+
+```
+USB Kameras
+ │
+ ▼
+FFmpeg (v4l2 → MJPEG)
+ │
+ ▼
+Node.js Server (Express + ws)
+ ├── WebSocket /ws/cam0, /ws/cam1 → Browser (Live-Stream)
+ └── HTTP GET /api/snapshot/cam0 → Homing-Projekt (JPEG)
+```
+
+**Stack-Entscheide (bereits umgesetzt):**
+
+| Komponente | Wahl | Begründung |
+|------------|------|------------|
+| Webserver | Node.js + Express | Wartbar, grosses Ecosystem, user-präferiert |
+| WebSocket | `ws`-Library | Schlank, bewährt, kein Overhead |
+| Video-Capture | FFmpeg | Stabil, flexibel, MJPEG-passthrough möglich |
+| Stream-Protokoll | MJPEG über WebSocket | Geringste Latenz, einfach im Browser |
+| Snapshot-API | HTTP GET → raw JPEG | Einfachste Schnittstelle für Consumer |
+| Container | `dockerfile_inline` in docker-compose | Kein separates Dockerfile, Portainer-tauglich |
+
+---
+
+## Tasks
+
+### Phase 1 – Grundgerüst ✅ (erledigt)
+
+- [x] Projektstruktur anlegen
+- [x] `package.json` mit Abhängigkeiten (express, ws)
+- [x] `docker-compose.yaml` mit `dockerfile_inline` (Node.js + FFmpeg, kein separates Dockerfile)
+- [x] `server.js` – HTTP + WebSocket-Server, Graceful Shutdown
+- [x] `src/deviceDetect.js` – Kamera-Erkennung (env → by-id → /dev/video*)
+- [x] `src/videoStream.js` – FFmpegStreamer (MJPEG splitten, WebSocket broadcast, Auto-Restart mit Backoff)
+- [x] `src/snapshotService.js` – REST-Endpunkt: aktuellstes Frame aus laufendem Stream
+- [x] `public/index.html` + `public/viewer.js` – Basis-Viewer
+
+### Phase 2 – Deployment & Latenz-Baseline
+
+- [ ] **Kamera-Zugriff im Container** verifizieren:
+ - `/dev/video0` und `/dev/video2` im Container sichtbar
+ - `group_add: video` greift (Zugriffsrechte)
+ - Fallback auf YUYV422 wenn MJPEG nicht unterstützt
+- [ ] **Latenz messen** (Baseline):
+ - Uhr auf Kamera richten, Screenshot → Differenz ablesen
+ - Zielwert: <100 ms Ende-zu-Ende (Kamera → Browser-Pixel)
+- [ ] **Multi-Format-Fallback** implementieren: MJPEG → YUYV422 → RGB24
+- [ ] **Health-Endpunkt** `/health` erweitern: Kamera-Status, verbundene Clients, FPS
+
+### Phase 3 – Latenz optimieren
+
+- [ ] **FFmpeg-Flags tunen** für minimale Latenz:
+ ```
+ -fflags nobuffer -flags low_delay -probesize 32 -analyzeduration 0
+ ```
+- [ ] **Native MJPEG pass-through** testen:
+ Wenn Kamera MJPEG nativ bei Zielauflösung liefert → `-vcodec copy`
+ (kein Re-Encoding, minimale CPU-Last, minimale Latenz)
+- [ ] **Canvas-Rendering** im Browser: `createImageBitmap()` statt Blob-URL-Overhead
+- [ ] **WebRTC evaluieren**: <50 ms möglich, aber STUN/TURN-Komplexität in Docker –
+ sinnvoll erst wenn MJPEG-Latenz >150 ms bleibt
+
+### Phase 4 – Hochauflösende Snapshots
+
+**Aktuell**: Snapshot = letztes Frame aus dem Stream (Auflösung = Stream-Auflösung).
+**Ziel**: Snapshot in originaler Kamera-Auflösung (z.B. 1280×960).
+
+Drei Optionen (noch offen):
+
+| Option | Vorgehen | Pro | Contra |
+|--------|----------|-----|--------|
+| A | Stream-Frame direkt nehmen | Sofort, kein Aufwand | Auflösung = Stream-Auflösung |
+| B | Zweite FFmpeg-Pipeline 0.5 FPS High-Res | Immer verfügbar | CPU-Last, pipe:3 Komplexität |
+| C | Einmaliger `ffmpeg -frames:v 1` on-demand | Hohe Qualität | ~500 ms Delay, Stream-Unterbrechung |
+
+- [ ] Option wählen und implementieren
+- [ ] Mit Homing-Projekt testen (Consumer von `/api/snapshot`)
+- [ ] Snapshot-Metadaten in Response-Headern: Zeitstempel, Auflösung, Kamera-ID
+- [ ] Optionaler Webhook: POST nach Snapshot an konfigurierbaren Endpunkt
+
+### Phase 5 – Robustheit & Produktion
+
+- [ ] Kamera hot-plug: Stream-Neustart wenn `/dev/videoX` verschwindet/wiederkommt
+- [ ] Resource Limits: `--memory 512m --cpus 1.0` in docker-compose
+- [ ] HTTPS: Reverse Proxy (nginx/traefik) vorschalten – kein TLS im App-Code
+- [ ] JSON-Logging mit Level (info/warn/error)
+- [ ] Kamera-Parameter per env var: `CAM0_WIDTH`, `CAM0_HEIGHT`, `CAM0_FPS`, `CAM0_QUALITY`
+
+---
+
+## Abgrenzung zu appRobotVideoControls
+
+| Feature | appRobotVideoControls | appRobotWebcam |
+|---------|-----------------------|----------------|
+| Video-Streaming | ✅ | ✅ (verbessert) |
+| Snapshots | ✅ (komplex, dual-pipe) | ✅ (HTTP REST, einfach) |
+| Robot-Control (G-Code) | ✅ | ❌ anderes Projekt |
+| ArUco / Homing | ✅ (Python+OpenCV) | ❌ anderes Projekt |
+| Gamepad / Keyboard | ✅ | ❌ |
+| HTTPS (self-signed) | ✅ | ❌ (Reverse Proxy empfohlen) |
+| Separates Dockerfile | ✅ (gross, OpenCV-Build) | ❌ (inline in compose) |
+
+---
+
+## Ports & Netzwerk
+
+| Service | Container-Port | Host-Port |
+|---------|---------------|-----------|
+| HTTP + WS | 8080 | 8444 |
+
+```bash
+# Netzwerk einmalig erstellen (falls noch nicht vorhanden)
+docker network create appRobotNet
+```
+
+---
+
+## Datei-Struktur
+
+```
+appRobotWebcam/
+├── src/
+│ ├── deviceDetect.js Kamera-Erkennung (env → by-id → /dev/video*)
+│ ├── videoStream.js FFmpeg-MJPEG-Streamer + WebSocket-Broadcast
+│ └── snapshotService.js REST-Router für /api/snapshot
+├── public/
+│ ├── index.html Basis-Viewer
+│ └── viewer.js WebSocket-Client, MJPEG-Rendering
+├── doc/
+│ ├── 01_WebcamRoadmap.md (diese Datei)
+│ └── 05_OptionalToDo_roadmap.md Control-Optionen
+├── docker-compose.yaml Einzige Docker-Datei (dockerfile_inline)
+├── package.json
+└── server.js Einstiegspunkt
+```
diff --git a/doc/05_OptionalToDo_roadmap.md b/doc/05_OptionalToDo_roadmap.md
new file mode 100644
index 0000000..a7f21b5
--- /dev/null
+++ b/doc/05_OptionalToDo_roadmap.md
@@ -0,0 +1,87 @@
+## Roadmap - Optionale Punkte ##
+
+---
+
+## Control integrieren
+
+Das aktuelle `appRobotVideoControls` koppelt zwei unabhängige Verantwortlichkeiten:
+
+1. **Video-Streaming** (USB-Kameras → Browser)
+2. **Robot-Control** (Browser → G-Code → Robot-Server)
+
+### Warum wir trennen
+
+| Problem | Auswirkung |
+|---------|------------|
+| Control-Code-Änderung → Video-Server-Restart | Stream unterbricht, Operator verliert Bild |
+| Grosser Docker-Container | OpenCV + Python + FFmpeg + Control-Libs |
+| Schwer testbar | Video-Stream nicht einfach simulierbar |
+| Latenz-Konflikte | Video-Tuning ≠ Control-Anforderungen |
+| Robot-Server offline | Video-Server wirft Fehler |
+
+### Vorteil der Kopplung
+
+Ein Browser-Tab für alles: Video + Steuerung zusammen – sehr bequem für den Operator.
+
+---
+
+### Optionen für Control
+
+**Option A: Getrennt bleiben (empfohlen für Phase 1–2)**
+
+```
+appRobotWebcam → Port 8444 (nur Video + Snapshots)
+appRobotControl → Port 8445 (nur G-Code, Gamepad, Keyboard → Robot)
+```
+
+- Browser öffnet beide als Tabs oder iframe-Dashboard
+- Nachteil: Zwei Services deployen und warten
+
+**Option B: Control als optionales Modul in appRobotWebcam**
+
+- Env var `ENABLE_CONTROL=true/false` schaltet Control-Code ein/aus
+- Control-Code ist in der Codebase, aber inaktiv wenn nicht konfiguriert
+- Nachteil: Code-Komplexität steigt, trotzdem ein Container
+
+**Option C: Vollintegration wie bisher**
+
+- Alles in einem Container – wie `appRobotVideoControls`
+- Einfachste UX, einfachstes Deployment
+- Nachteil: Monolith, schwer zu warten und zu testen
+
+### Empfehlung
+
+Für Phase 1–2: **Option A** – sauber trennen.
+Wenn UX zum Problem wird (Operator-Feedback): **Option B** evaluieren.
+Option C nur wenn Deployment-Einfachheit absolut dominiert.
+
+---
+
+## Homing-Projekt Anbindung
+
+Das Homing-Projekt braucht Standbilder der Kameras für ArUco-Erkennung.
+
+**Schnittstelle**: `GET /api/snapshot/cam{n}` → JPEG-Bild
+
+`appRobotWebcam` liefert genau das. Keine weitere Kopplung nötig.
+Das Homing-Projekt ist Consumer, `appRobotWebcam` ist Producer.
+
+```
+Homing-Projekt
+ └── HTTP GET http://approbotwebcam:8080/api/snapshot/cam0
+ └── HTTP GET http://approbotwebcam:8080/api/snapshot/cam1
+ ↓
+ ArUco-Erkennung → Kamera-Pose → Weltkoordinaten
+```
+
+---
+
+## Dashboard / UI Konsolidierung
+
+Wenn mehrere Services (Video, Control, Homing-Status) parallel laufen:
+
+- **Option**: Leichtes Dashboard als eigene statische Seite (nginx) mit iframes
+- **Option**: Portainer-UI zeigt alle Container-Status
+- **Option**: Control-Panel in `appRobotWebcam` einbetten (Option B oben)
+
+Dies ist bewusst aufgeschoben – erst wenn klar ist welche Services produktiv laufen.
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..509685b
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,66 @@
+name: approbotwebcam
+
+# ── Portainer Web-Editor: dieses YAML direkt einfügen ───────────────────────
+# Voraussetzungen auf dem Server:
+# 1. docker network create appRobotNet
+# 2. Code-Verzeichnis liegt auf dem Server (git clone / rsync / Synology Drive)
+# 3. In Portainer unter "Environment variables" setzen:
+# APP_PATH=/absoluter/pfad/zum/appRobotWebcam
+#
+# Beim ersten Deploy baut Portainer das Image (Node.js + FFmpeg).
+# Danach reicht "Redeploy" – kein Rebuild nötig ausser bei System-Updates.
+# ─────────────────────────────────────────────────────────────────────────────
+
+services:
+ webcam:
+ build:
+ context: /tmp # Leerer Build-Context: kein COPY nötig, Code kommt per Bind-Mount
+ dockerfile_inline: |
+ FROM node:lts-bookworm-slim
+ RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ffmpeg \
+ v4l-utils \
+ && rm -rf /var/lib/apt/lists/*
+ WORKDIR /usr/src/app
+ EXPOSE 8080
+ image: approbotwebcam:latest
+ container_name: AppRobotWebcam
+ restart: unless-stopped
+
+ # npm install läuft einmalig beim Start, danach gecacht in node_modules
+ command: sh -c "npm install && node server.js"
+
+ volumes:
+ # APP_PATH in Portainer setzen, z.B. /volume1/docker/appRobotWebcam
+ # Lokal (ohne Portainer): APP_PATH nicht setzen → Fallback auf ./
+ - ${APP_PATH:-.}:/usr/src/app
+
+ ports:
+ - "8444:8080"
+
+ environment:
+ - NODE_ENV=production
+ - PORT=8080
+ - DEV0=/dev/video0
+ - DEV1=/dev/video2
+ # Optional – Defaults: 640x480 @ 30fps, Qualität 5
+ # - CAM0_WIDTH=640
+ # - CAM0_HEIGHT=480
+ # - CAM0_FPS=30
+ # - CAM0_QUALITY=5
+
+ devices:
+ - /dev/video0:/dev/video0
+ - /dev/video2:/dev/video2
+
+ # Kamera-Zugriffsrechte: Node-Prozess braucht Gruppe 'video'
+ group_add:
+ - video
+
+ networks:
+ - appRobotNet
+
+networks:
+ appRobotNet:
+ external: true
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..62cecbd
--- /dev/null
+++ b/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "approbotwebcam",
+ "version": "0.1.0",
+ "description": "Low-latency webcam streaming service for robot vision",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "nodemon server.js"
+ },
+ "dependencies": {
+ "express": "^4.21.1",
+ "ws": "^8.18.0"
+ },
+ "devDependencies": {
+ "nodemon": "^3.1.7"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..6150d9d
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+ AppRobotWebcam
+
+
+
+
+ AppRobotWebcam
+ Verbinde...
+
+
+
+
+
diff --git a/public/viewer.js b/public/viewer.js
new file mode 100644
index 0000000..0a20f2d
--- /dev/null
+++ b/public/viewer.js
@@ -0,0 +1,110 @@
+'use strict';
+
+const WS_RECONNECT_MS = 2000;
+
+function createCameraView(idx, container) {
+ // DOM
+ const box = document.createElement('div');
+ box.className = 'cam-box';
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 640;
+ canvas.height = 480;
+ box.appendChild(canvas);
+
+ const label = document.createElement('div');
+ label.className = 'cam-label';
+ label.textContent = `cam${idx}`;
+ box.appendChild(label);
+
+ const info = document.createElement('div');
+ info.className = 'cam-info';
+ info.textContent = 'Verbinde...';
+ box.appendChild(info);
+
+ const actions = document.createElement('div');
+ actions.className = 'cam-actions';
+ const snapBtn = document.createElement('button');
+ snapBtn.textContent = 'Snapshot';
+ actions.appendChild(snapBtn);
+ box.appendChild(actions);
+
+ container.appendChild(box);
+
+ // Snapshot download
+ snapBtn.addEventListener('click', () => {
+ const a = document.createElement('a');
+ a.href = `/api/snapshot/cam${idx}`;
+ a.download = `cam${idx}_${Date.now()}.jpg`;
+ a.click();
+ });
+
+ // Rendering
+ const ctx = canvas.getContext('2d');
+ let frameCount = 0;
+ let lastFpsTs = Date.now();
+ let fps = 0;
+
+ function drawFrame(arrayBuffer) {
+ const blob = new Blob([arrayBuffer], { type: 'image/jpeg' });
+ createImageBitmap(blob)
+ .then((bmp) => {
+ if (canvas.width !== bmp.width || canvas.height !== bmp.height) {
+ canvas.width = bmp.width;
+ canvas.height = bmp.height;
+ }
+ ctx.drawImage(bmp, 0, 0);
+ bmp.close();
+
+ frameCount++;
+ const now = Date.now();
+ if (now - lastFpsTs >= 1000) {
+ fps = Math.round(frameCount * 1000 / (now - lastFpsTs));
+ frameCount = 0;
+ lastFpsTs = now;
+ info.textContent = `${fps} fps`;
+ }
+ })
+ .catch(() => {/* ignore decode errors */});
+ }
+
+ // WebSocket connection with auto-reconnect
+ function connect() {
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const ws = new WebSocket(`${proto}//${location.host}/ws/cam${idx}`);
+ ws.binaryType = 'arraybuffer';
+
+ ws.onopen = () => { info.textContent = 'Verbunden'; };
+ ws.onclose = () => {
+ info.textContent = `Getrennt – neu in ${WS_RECONNECT_MS / 1000}s`;
+ setTimeout(connect, WS_RECONNECT_MS);
+ };
+ ws.onerror = () => { info.textContent = 'Verbindungsfehler'; };
+ ws.onmessage = (evt) => drawFrame(evt.data);
+ }
+
+ connect();
+}
+
+// Fetch camera list from server, then build one view per camera
+fetch('/api/snapshot')
+ .then(r => r.json())
+ .then(data => {
+ const container = document.getElementById('cameras');
+ const count = data.cameras?.length ?? 0;
+
+ if (count === 0) {
+ document.getElementById('statusText').textContent = 'Keine Kameras erkannt';
+ return;
+ }
+
+ for (let i = 0; i < count; i++) createCameraView(i, container);
+ document.getElementById('statusText').textContent =
+ `${count} Kamera${count !== 1 ? 's' : ''} erkannt`;
+ })
+ .catch(() => {
+ // Fallback: show 2 cameras if API fails
+ const container = document.getElementById('cameras');
+ for (let i = 0; i < 2; i++) createCameraView(i, container);
+ document.getElementById('statusText').textContent = 'Kamera-API nicht erreichbar';
+ });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..cbcc961
--- /dev/null
+++ b/server.js
@@ -0,0 +1,85 @@
+'use strict';
+
+const express = require('express');
+const http = require('http');
+const { WebSocketServer } = require('ws');
+const path = require('path');
+const { detectDevices } = require('./src/deviceDetect');
+const { VideoStream } = require('./src/videoStream');
+const { createSnapshotRouter } = require('./src/snapshotService');
+
+const PORT = parseInt(process.env.PORT ?? '8080', 10);
+
+function buildStreams() {
+ const devices = detectDevices();
+ console.log(`Detected ${devices.length} camera(s):`, devices);
+
+ return devices.map((device, idx) => new VideoStream(device, {
+ name: `cam${idx}`,
+ width: parseInt(process.env[`CAM${idx}_WIDTH`] ?? '640', 10),
+ height: parseInt(process.env[`CAM${idx}_HEIGHT`] ?? '480', 10),
+ fps: parseInt(process.env[`CAM${idx}_FPS`] ?? '30', 10),
+ quality: parseInt(process.env[`CAM${idx}_QUALITY`] ?? '5', 10),
+ }));
+}
+
+function main() {
+ const streams = buildStreams();
+
+ // Start all streams eagerly – first client gets immediate frame
+ streams.forEach(s => s.start());
+
+ const app = express();
+
+ app.use(express.static(path.join(__dirname, 'public')));
+ app.use('/api/snapshot', createSnapshotRouter(streams));
+
+ app.get('/health', (_req, res) => {
+ res.json({
+ status: 'ok',
+ cameras: streams.map((s, i) => ({
+ id: `cam${i}`,
+ device: s.device,
+ running: s.isRunning,
+ clients: s.clientCount,
+ hasFrame: s.latestFrame !== null,
+ })),
+ });
+ });
+
+ const server = http.createServer(app);
+ const wss = new WebSocketServer({ noServer: true });
+
+ server.on('upgrade', (req, socket, head) => {
+ const match = req.url?.match(/^\/ws\/cam(\d+)$/);
+ if (!match) { socket.destroy(); return; }
+
+ const idx = parseInt(match[1], 10);
+ if (!streams[idx]) { socket.destroy(); return; }
+
+ wss.handleUpgrade(req, socket, head, (ws) => {
+ const stream = streams[idx];
+ stream.addClient(ws);
+ ws.on('close', () => stream.removeClient(ws));
+ ws.on('error', () => stream.removeClient(ws));
+ });
+ });
+
+ server.listen(PORT, '0.0.0.0', () => {
+ console.log(`AppRobotWebcam on http://0.0.0.0:${PORT}`);
+ streams.forEach((s, i) => {
+ console.log(` ws://0.0.0.0:${PORT}/ws/cam${i} → ${s.device}`);
+ console.log(` http://0.0.0.0:${PORT}/api/snapshot/cam${i}`);
+ });
+ });
+
+ const shutdown = (signal) => {
+ console.log(`\n${signal} received – shutting down`);
+ streams.forEach(s => s.stop());
+ server.close(() => process.exit(0));
+ };
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+}
+
+main();
diff --git a/src/deviceDetect.js b/src/deviceDetect.js
new file mode 100644
index 0000000..752a88f
--- /dev/null
+++ b/src/deviceDetect.js
@@ -0,0 +1,42 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+// Returns ordered list of camera device paths.
+// Priority: DEV0/DEV1/... env vars → /dev/v4l/by-id/ → /dev/video*
+function detectDevices() {
+ // Explicit env vars take priority
+ const envDevices = [];
+ for (let i = 0; i < 8; i++) {
+ const dev = process.env[`DEV${i}`];
+ if (dev) envDevices.push(dev);
+ }
+ if (envDevices.length > 0) return envDevices;
+
+ // Stable device IDs (survive USB re-plug without renumbering)
+ const byIdDir = '/dev/v4l/by-id';
+ if (fs.existsSync(byIdDir)) {
+ try {
+ const found = fs.readdirSync(byIdDir)
+ .filter(name => !name.endsWith('-index1')) // skip audio/metadata endpoints
+ .map(name => fs.realpathSync(path.join(byIdDir, name)))
+ .filter((v, i, arr) => arr.indexOf(v) === i) // deduplicate symlink targets
+ .sort();
+ if (found.length > 0) return found;
+ } catch { /* fall through */ }
+ }
+
+ // Simple enumeration fallback (Linux)
+ try {
+ const found = fs.readdirSync('/dev')
+ .filter(name => /^video\d+$/.test(name))
+ .sort((a, b) => parseInt(a.slice(5)) - parseInt(b.slice(5)))
+ .map(name => `/dev/${name}`);
+ if (found.length > 0) return found;
+ } catch { /* fall through */ }
+
+ return ['/dev/video0', '/dev/video2'];
+}
+
+module.exports = { detectDevices };
diff --git a/src/snapshotService.js b/src/snapshotService.js
new file mode 100644
index 0000000..7efadc0
--- /dev/null
+++ b/src/snapshotService.js
@@ -0,0 +1,50 @@
+'use strict';
+
+const express = require('express');
+
+// Returns an Express router mounted at /api/snapshot
+// GET /api/snapshot → JSON listing of cameras
+// GET /api/snapshot/cam0 → latest JPEG from cam0
+// GET /api/snapshot/cam1 → latest JPEG from cam1
+function createSnapshotRouter(streams) {
+ const router = express.Router();
+
+ router.get('/', (_req, res) => {
+ res.json({
+ cameras: streams.map((s, i) => ({
+ id: `cam${i}`,
+ device: s.device,
+ running: s.isRunning,
+ clients: s.clientCount,
+ hasFrame: s.latestFrame !== null,
+ url: `/api/snapshot/cam${i}`,
+ })),
+ });
+ });
+
+ router.get('/:id', (req, res) => {
+ const idx = parseInt(req.params.id.replace('cam', ''), 10);
+ if (isNaN(idx) || !streams[idx]) {
+ return res.status(404).json({ error: 'camera not found' });
+ }
+
+ const frame = streams[idx].latestFrame;
+ if (!frame) {
+ return res.status(503).json({ error: 'no frame available yet – stream may still be starting' });
+ }
+
+ res.set({
+ 'Content-Type': 'image/jpeg',
+ 'Content-Length': frame.length,
+ 'Cache-Control': 'no-store',
+ 'X-Camera-Id': `cam${idx}`,
+ 'X-Camera-Device': streams[idx].device,
+ 'X-Timestamp': new Date().toISOString(),
+ });
+ res.end(frame);
+ });
+
+ return router;
+}
+
+module.exports = { createSnapshotRouter };
diff --git a/src/videoStream.js b/src/videoStream.js
new file mode 100644
index 0000000..3afb6ff
--- /dev/null
+++ b/src/videoStream.js
@@ -0,0 +1,163 @@
+'use strict';
+
+const { spawn } = require('child_process');
+const { WebSocket } = require('ws');
+
+// JPEG frame boundaries
+const SOI = Buffer.from([0xff, 0xd8]);
+const EOI = Buffer.from([0xff, 0xd9]);
+
+// Max buffer before we assume stream corruption and discard
+const MAX_BUFFER = 2 * 1024 * 1024;
+
+// Drop frames to clients with a clogged send buffer (slow network/tab)
+const MAX_CLIENT_BUFFER = 512 * 1024;
+
+class VideoStream {
+ constructor(device, options = {}) {
+ this.device = device;
+ this.name = options.name ?? 'cam';
+ this.width = options.width ?? 640;
+ this.height = options.height ?? 480;
+ this.fps = options.fps ?? 30;
+ this.quality = options.quality ?? 5; // FFmpeg MJPEG quality: 2=best … 31=worst
+
+ this._clients = new Set();
+ this._process = null;
+ this._restartTimer = null;
+ this._restartDelay = 1000;
+ this._running = false;
+ this._latestFrame = null;
+ }
+
+ get latestFrame() { return this._latestFrame; }
+ get isRunning() { return this._running; }
+ get clientCount() { return this._clients.size; }
+
+ start() {
+ if (this._running) return;
+ this._running = true;
+ this._spawn();
+ }
+
+ stop() {
+ this._running = false;
+ clearTimeout(this._restartTimer);
+ if (this._process) {
+ this._process.kill('SIGKILL');
+ this._process = null;
+ }
+ }
+
+ addClient(ws) {
+ this._clients.add(ws);
+ // Send latest frame immediately – client sees picture right away
+ if (this._latestFrame) {
+ ws.send(this._latestFrame, { binary: true });
+ }
+ }
+
+ removeClient(ws) {
+ this._clients.delete(ws);
+ }
+
+ // ---------------------------------------------------------------------------
+ // FFmpeg pipeline
+ // ---------------------------------------------------------------------------
+
+ _buildArgs() {
+ return [
+ '-hide_banner', '-loglevel', 'warning',
+
+ // Minimize input buffering for low latency
+ '-fflags', 'nobuffer',
+ '-flags', 'low_delay',
+ '-probesize', '32',
+ '-analyzeduration', '0',
+
+ // Input: USB camera via Video4Linux2
+ '-f', 'v4l2',
+ '-input_format', 'mjpeg', // prefer hardware MJPEG (no re-decode if res matches)
+ '-video_size', `${this.width}x${this.height}`,
+ '-framerate', String(this.fps),
+ '-i', this.device,
+
+ // Output: scale to target size, encode as MJPEG to stdout
+ // If camera delivers native MJPEG at target resolution, consider -vcodec copy
+ '-vf', `scale=${this.width}:${this.height}`,
+ '-f', 'mjpeg',
+ '-q:v', String(this.quality),
+ 'pipe:1',
+ ];
+ }
+
+ _spawn() {
+ console.log(`[${this.name}] starting ffmpeg on ${this.device}`);
+ const startedAt = Date.now();
+ const proc = spawn('ffmpeg', this._buildArgs(), {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ this._process = proc;
+
+ let buf = Buffer.alloc(0);
+
+ proc.stdout.on('data', (chunk) => {
+ buf = Buffer.concat([buf, chunk]);
+
+ let offset = 0;
+ while (true) {
+ const soi = buf.indexOf(SOI, offset);
+ if (soi === -1) break;
+ const eoi = buf.indexOf(EOI, soi + 2);
+ if (eoi === -1) break;
+
+ const frame = buf.slice(soi, eoi + 2);
+ this._latestFrame = frame;
+ this._broadcast(frame);
+ this._restartDelay = 1000; // reset backoff on good frames
+ offset = eoi + 2;
+ }
+
+ buf = offset > 0 ? buf.slice(offset) : buf;
+ if (buf.length > MAX_BUFFER) {
+ console.warn(`[${this.name}] buffer overflow, discarding`);
+ buf = Buffer.alloc(0);
+ }
+ });
+
+ proc.stderr.on('data', (chunk) => {
+ const msg = chunk.toString().trimEnd();
+ if (msg) console.error(`[${this.name}] ffmpeg: ${msg}`);
+ });
+
+ proc.on('close', (code) => {
+ this._process = null;
+ const uptime = Date.now() - startedAt;
+ console.log(`[${this.name}] ffmpeg closed (code=${code}, uptime=${uptime}ms)`);
+
+ if (!this._running) return;
+
+ // Exponential backoff: 1s → 2s → 4s → 8s max
+ console.log(`[${this.name}] restart in ${this._restartDelay}ms`);
+ this._restartTimer = setTimeout(() => {
+ this._restartDelay = Math.min(this._restartDelay * 2, 8000);
+ this._spawn();
+ }, this._restartDelay);
+ });
+
+ proc.on('error', (err) => {
+ console.error(`[${this.name}] spawn error: ${err.message}`);
+ });
+ }
+
+ _broadcast(frame) {
+ for (const ws of this._clients) {
+ if (ws.readyState !== WebSocket.OPEN) continue;
+ // Drop frame for slow clients rather than queuing indefinitely
+ if (ws.bufferedAmount > MAX_CLIENT_BUFFER) continue;
+ ws.send(frame, { binary: true });
+ }
+ }
+}
+
+module.exports = { VideoStream };