WebCam als schlanke alternative zum appVideoControl

This commit is contained in:
chk
2026-06-02 22:19:08 +02:00
commit 9b1ae2ae14
11 changed files with 842 additions and 0 deletions

0
README.md Normal file
View File

152
doc/01_WebcamRoadmap.md Normal file
View File

@@ -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
```

View File

@@ -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 12)**
```
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 12: **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.

66
docker-compose.yaml Normal file
View File

@@ -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

20
package.json Normal file
View File

@@ -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"
}
}

67
public/index.html Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AppRobotWebcam</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f0f0f; color: #e0e0e0; font-family: monospace; }
header {
display: flex; align-items: center; gap: 12px;
padding: 10px 16px;
background: #1a1a1a;
border-bottom: 1px solid #333;
}
h1 { font-size: 1rem; font-weight: normal; letter-spacing: 0.05em; }
#statusText { font-size: 0.8rem; color: #888; margin-left: auto; }
#cameras {
display: flex; flex-wrap: wrap; gap: 12px;
padding: 12px;
}
.cam-box {
position: relative;
background: #000;
border: 1px solid #2a2a2a;
}
.cam-box canvas { display: block; }
.cam-label {
position: absolute; top: 5px; left: 8px;
background: rgba(0,0,0,.65);
padding: 2px 7px; border-radius: 3px;
font-size: 0.72rem; color: #ccc;
}
.cam-info {
position: absolute; bottom: 5px; right: 8px;
background: rgba(0,0,0,.65);
padding: 2px 7px; border-radius: 3px;
font-size: 0.68rem; color: #999;
}
.cam-actions {
position: absolute; top: 5px; right: 8px;
display: flex; gap: 4px;
}
.cam-actions button {
background: rgba(0,0,0,.65); color: #ccc;
border: 1px solid #444; padding: 2px 8px;
font-family: monospace; font-size: 0.7rem;
cursor: pointer; border-radius: 3px;
}
.cam-actions button:hover { background: rgba(60,60,60,.8); }
</style>
</head>
<body>
<header>
<h1>AppRobotWebcam</h1>
<span id="statusText">Verbinde...</span>
</header>
<div id="cameras"></div>
<script src="viewer.js"></script>
</body>
</html>

110
public/viewer.js Normal file
View File

@@ -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';
});

85
server.js Normal file
View File

@@ -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();

42
src/deviceDetect.js Normal file
View File

@@ -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 };

50
src/snapshotService.js Normal file
View File

@@ -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 };

163
src/videoStream.js Normal file
View File

@@ -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 };