commit 9b1ae2ae14d970fdcbe7d0449287370b432bc020 Author: chk <79915315+ChKendel@users.noreply.github.com> Date: Tue Jun 2 22:19:08 2026 +0200 WebCam als schlanke alternative zum appVideoControl 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 };