WebCam als schlanke alternative zum appVideoControl
This commit is contained in:
152
doc/01_WebcamRoadmap.md
Normal file
152
doc/01_WebcamRoadmap.md
Normal 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
|
||||
```
|
||||
87
doc/05_OptionalToDo_roadmap.md
Normal file
87
doc/05_OptionalToDo_roadmap.md
Normal 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 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.
|
||||
66
docker-compose.yaml
Normal file
66
docker-compose.yaml
Normal 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
20
package.json
Normal 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
67
public/index.html
Normal 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
110
public/viewer.js
Normal 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
85
server.js
Normal 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
42
src/deviceDetect.js
Normal 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
50
src/snapshotService.js
Normal 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
163
src/videoStream.js
Normal 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 };
|
||||
Reference in New Issue
Block a user