Claude: Dokumentation

This commit is contained in:
chk
2026-06-06 13:12:18 +02:00
parent 2997fb67f7
commit 92b17b18be
6 changed files with 351 additions and 287 deletions

View File

@@ -0,0 +1,65 @@
# AppRobotWebcam
Webcam-Service für den AppRobot. Liefert Live-MJPEG-Streams und HD-Standbilder
über einen einzelnen HTTP-Port — als Docker-Container, ohne externe Streaming-Server.
## Was es tut
| | |
|---|---|
| **Live-Stream** | MJPEG multipart im Browser `<img>`, ~139 ms Latenz |
| **HD-Snapshot** | Ein JPEG pro Kamera auf Knopfdruck oder per HTTP GET |
| **Snapshot alle** | Alle Kameras parallel in einem Schritt |
| **REST-API** | Kameraliste, Snapshots, Streams — für andere Container nutzbar |
## Kameras (aktuell)
| ID | Modell | Live | HD-Grab |
|---|---|---|---|
| cam0 | Logitech C270 | 640×480 | 1280×960 |
| cam1 | Logitech C270 | 640×480 | 1280×960 |
| cam2 | Logitech C920 | 640×480 | 1920×1080 |
Konfiguration ausschliesslich über `cameras.json` — kein Redeploy bei Kamera-Änderungen.
## Zugriff
```
http://<host>:8444/ Viewer
http://<host>:8444/api/stream/cam0 Live-MJPEG
http://<host>:8444/api/snapshot/cam0 640er JPEG
http://<host>:8444/api/snapshot/cam0/hires HD-JPEG
http://<host>:8444/api/cameras Kamera-Metadaten (JSON)
http://<host>:8444/health Status
```
## Deploy (Portainer)
1. Portainer → Stacks → Web editor → `docker-compose.yaml` einfügen
2. `APP_PATH` auf den absoluten Pfad des Projektverzeichnisses setzen
3. Deploy — der Container baut sich selbst (Node + FFmpeg)
```yaml
# Minimal-Konfiguration:
APP_PATH=/home/user/appRobotWebcam
```
## Architektur
```
cameras.json → server.js → CameraSwitch (/dev/videoN)
├── Live: ffmpeg → MJPEG → Browser
└── Grab: Live stoppen → hires → zurück
```
Ein FFmpeg pro Kamera, nie zwei gleichzeitig. Das `close`-Event ist der harte Beweis
„Gerät frei" — kein Race, kein 106%-CPU-Bug (der mit go2rtc aufgetreten war).
## Dokumentation
| Datei | Inhalt |
|---|---|
| `doc/01_WebcamRoadmap.md` | Ziel, Architektur, Entwicklungsgeschichte |
| `doc/05_screenShot_roadmap.md` | HD-Grab, Encode-Qualität, Kamera-Eigenheiten |
| `doc/07_multipleCam_roadmap.md` | cameras.json-Referenz, Multi-Kamera-Setup |
| `doc/09_Bug_reports.md` | Bug-Dokumentation |

View File

@@ -2,121 +2,99 @@
## Ziel
Sauberer, fokussierter Webcam-Service als Docker-Container. Kein Robot-Control, kein
ArUco nur zwei Verantwortlichkeiten:
Fokussierter Webcam-Service als Docker-Container. Zwei Verantwortlichkeiten:
1. **Live-Video** mit minimaler Latenz (WebRTC via go2rtc)
2. **Standbilder** auf Abruf via HTTP REST (`/api/snapshot/cam{n}`)
1. **Live-Video** mit minimaler Latenz (MJPEG via Browser `<img>`)
2. **HD-Standbilder** auf Abruf via HTTP REST (`/api/snapshot/:id/hires`)
Das Homing-Projekt holt seine Standbilder über den Snapshot-Endpunkt keine weitere Kopplung.
Das Homing-Projekt und andere Container holen Standbilder über den HTTP-Endpunkt
keine weitere Kopplung.
---
## Architektur (final)
## Architektur (aktuell)
```
USB Kameras
cameras.json → server.js → CameraSwitch (eine Instanz pro /dev/videoN)
go2rtc (Capture · H.264-Encode · WebRTC) ── intern, Port 1984 + UDP 8555
Node.js / Express ── öffentlich, Port 8444
├── / eigener Viewer (go2rtc <video-stream>-Component)
├── /api/ws WebRTC-Signaling → proxied zu go2rtc
├── /api/snapshot/* Standbilder → proxied zu go2rtc /api/frame.jpeg
── /health
┌──────────────┴──────────────┐
│ Live (On-Demand) │ HD-Grab
│ ffmpeg MJPEG passthrough │ Live stoppen → hires → zurück
│ → multipart/x-mixed-replace │ close-Event = FD frei (kein Race)
▼ ▼
Browser <img> JPEG via HTTP
Node.js / Express :8444
── GET / Viewer (index.html + viewer.js)
├── GET /api/cameras Metadaten aller Kameras (aus cameras.json)
├── GET /api/snapshot Liste der Kameras mit Metadaten
├── GET /api/snapshot/:id 640er JPEG (aus Live-Puffer, on-demand)
├── GET /api/snapshot/:id/hires HD-JPEG (grabHires, 23 s)
├── GET /api/stream/:id MJPEG multipart/x-mixed-replace (Live)
└── GET /health Zustand aller CameraSwitch-Instanzen
```
**Warum go2rtc statt eigenem FFmpeg-Stream:**
Erste Version (eigener Node-FFmpeg-MJPEG-Stream über WebSocket) hatte spürbare Latenz.
go2rtc ist ein spezialisierter Streaming-Server: WebRTC mit ~50150 ms, automatisches
Encoding, ICE-Negotiation, robuster Client mit Auto-Fallback (WebRTC→MSE→MJPEG).
**Warum kein go2rtc mehr:** go2rtc konnte nicht zuverlässig melden, wann FFmpeg das
Gerät freigibt → Race: zwei Encoder auf `/dev/videoN` → 106 % CPU + Hang. Mit eigenem
FFmpeg-Start ist das `close`-Event des Kindprozesses der harte Beweis „Gerät frei".
→ Details: `doc/09_Bug_reports.md`, Entwicklungsgeschichte: `doc/05_screenShot_roadmap.md`.
**Warum Node davor (statt go2rtc direkt):**
- Ein einziger öffentlicher Port (8444); go2rtc-Admin bleibt unerreichbar
- Stabile Snapshot-Schnittstelle, entkoppelt von go2rtc-Interna
- Eigener, schlanker Viewer
**Stack-Entscheide:**
**Stack:**
| Komponente | Wahl | Begründung |
|------------|------|------------|
| Streaming | go2rtc | WebRTC out-of-the-box, niedrige Latenz, internet-tauglich |
|---|---|---|
| Streaming | Node-eigener FFmpeg → MJPEG | kein Race, geringer Overhead |
| Webserver | Node.js + Express | wartbar, user-präferiert |
| Proxy | http-proxy-middleware | reicht HTTP + WebSocket transparent durch |
| Live-Protokoll | WebRTC (Fallback MSE/MJPEG) | niedrigste Latenz, skaliert über Internet |
| Snapshot-API | HTTP GET → JPEG | einfachste Schnittstelle für Consumer |
| Container | docker-compose, `configs` inline | kein Dockerfile-File, Portainer-tauglich |
| Live-Protokoll | MJPEG multipart (`<img>`) | native Browser-Unterstützung, ~139 ms |
| HD-Grab | MJPEG copybsf (Kamera-JPEG pur) | kein Re-Encode, keine zweite Kompression |
| Container | docker-compose, inline Dockerfile | Portainer-tauglich, kein externes Image |
---
## Tasks
## Gemessene Werte (Hardware, 2026-06-05/06)
### Phase 1 Grundgerüst ✅
- [x] Projektstruktur, package.json, docker-compose mit inline-Config
- [x] Erste Version (Node-FFmpeg-MJPEG über WebSocket) verworfen wegen Latenz
### Phase 2 Umstieg auf go2rtc / WebRTC ✅
- [x] go2rtc als Streaming-Backend (Kamera-Capture + WebRTC)
- [x] go2rtc-Config in docker-compose eingebettet (`configs.content`)
- [x] Node als Reverse-Proxy (`/api/ws`, `/api/frame.jpeg`, Player-Scripts)
- [x] Eigener Viewer mit go2rtc `<video-stream>`-Component (Auto-Fallback)
- [x] Stabile Snapshot-API `/api/snapshot/cam{n}`
- [x] Auflösung fest 640×480 → Latenz „akzeptabel" (war vorher das Hauptproblem)
### Phase 3 Latenz final tunen ✅
- [x] Messvergleich WebRTC ⟷ MJPEG: **WebRTC ~130 ms, MJPEG ~200 ms** → WebRTC gewinnt
- [x] Entscheid: bei WebRTC bleiben (niedrigere Latenz + besser für Internet)
- [ ] Optional: Prüfen ob Kamera natives H.264 liefert (`v4l2-ctl --list-formats`) → kein Re-Encode
- [ ] Optional: Keyframe-Intervall / Encoder-Preset tunen wenn <100 ms gefordert
### Phase 4 Internet-Härtung (offen, vor Produktiv-Schaltung)
- [ ] **TLS via Caddy** empfohlen, weil Caddy WebSocket-Proxy nativ und zuverlässig kann.
Aktuell verbindet Browser `/api/ws` direkt zu go2rtc Port 1984.
Caddy bündelt beides hinter einer Domain:
```
robot.example.com {
handle /api/ws* { reverse_proxy localhost:1984 }
handle /api/stream* { reverse_proxy localhost:1984 }
handle /video-*.js { reverse_proxy localhost:1984 }
handle { reverse_proxy localhost:8444 }
}
```
Danach: viewer.js `go2rtcPort` auf 443 (wss://) setzen, Node GO2RTC_PORT=443.
- [ ] **WebRTC-Candidate**: `stun:8555` testen; bei NAT-Problemen → feste IP/Domain:
`candidates: [robot.example.com:8555]` in go2rtc-Config
- [ ] **TURN**: nur wenn STUN + UDP 8555 nicht reicht (sehr restriktive NATs)
- [ ] **Zugriffsschutz**: Basic-Auth am Caddy (13 bekannte User)
- [ ] **Firewall**: TCP 443 (Caddy) + UDP 8555 (WebRTC) forwarden; 1984 + 8444 intern
### Phase 5 Robustheit (optional)
- [ ] Kamera hot-plug: go2rtc-Verhalten bei Device-Verlust prüfen
- [ ] Resource Limits dokumentieren (`mem_limit`, `cpus`)
- [ ] JSON-Logging
- [ ] Snapshot-Metadaten / optionaler Webhook nach Snapshot
| Kenngrösse | Wert |
|---|---|
| Live-Latenz Kamera→Browser | **139 ms** |
| CPU idle (niemand schaut) | **~5 %** (On-Demand) |
| CPU aktiv | **~35 %/Kamera** (copybsf) |
| HD-Grab Dauer | **~23 s** (settleFrames + Format-Switch) |
| HD-Auflösung C270 | 1280×960 JPEG |
| HD-Auflösung C920 | 1920×1080 JPEG |
---
## Abgrenzung zu appRobotVideoControls
## Kamera-Konfiguration (`cameras.json`)
| Feature | appRobotVideoControls | appRobotWebcam |
|---------|-----------------------|----------------|
| Video-Streaming | eigener FFmpeg-MJPEG/WS | go2rtc / WebRTC |
| Snapshots | komplex (dual-pipe) | HTTP REST, einfach |
| Robot-Control (G-Code) | ✅ | ❌ anderes Projekt |
| ArUco / Homing | ✅ | ❌ anderes Projekt |
| Separates Dockerfile | ✅ (OpenCV-Build) | ❌ (inline in compose) |
Einzige Konfigurationsquelle — kein Hardcode im Code, kein Redeploy für neue Kameras.
---
```json
{
"cameras": [
{ "id": "cam0", "device": "/dev/video0", "name": "Kamera 0",
"position": "front", "stream": true, "hires": true,
"hiresSize": "1280x960",
"note": "usb-046d_0825_3BB3FE20-video-index0" },
{ "id": "cam1", "device": "/dev/video2", "name": "Kamera 1",
"position": "left", "stream": true, "hires": true,
"hiresSize": "1280x960",
"note": "usb-046d_081b_342D4F40-video-index0" },
{ "id": "cam2", "device": "/dev/video4", "name": "Kamera 2",
"position": "right", "stream": true, "hires": true,
"hiresSize": "1920x1080",
"note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0" }
]
}
```
## Ports
Per-Kamera-Felder: `liveSize`, `liveFps`, `hiresSize`, `hiresFps`, `encode`,
`hiresEncode` (überschreibt globale Env-Defaults). Details: `doc/07_multipleCam_roadmap.md`.
| Dienst | Port | Exponiert? |
|--------|------|-----------|
| Node Viewer + API + Signaling | TCP 8444 | ja (Firewall) |
| WebRTC Media | UDP 8555 | ja (Firewall) |
| go2rtc HTTP/Debug-UI | TCP 1984 | nein (nur intern/LAN) |
Geräte in `docker-compose.yaml` über stabile `by-id`-Pfade einbinden:
```yaml
devices:
- /dev/v4l/by-id/usb-...-video-index0:/dev/videoN
```
---
@@ -124,17 +102,55 @@ Encoding, ICE-Negotiation, robuster Client mit Auto-Fallback (WebRTC→MSE→MJP
```
appRobotWebcam/
├── cameras.json Kamera-Konfiguration (Geräte, Namen, Auflösungen)
├── server.js Einstiegspunkt; lädt cameras.json, erzeugt CameraSwitch
├── src/
── snapshotService.js Snapshot-Router (proxied go2rtc /api/frame.jpeg)
── cameraSwitch.js CameraSwitch-Klasse (ein FFmpeg pro Gerät, Live + Grab)
│ └── snapshotService.js Express-Router für /api/snapshot, /api/stream, /api/cameras
├── public/
│ ├── index.html Viewer (lädt go2rtc <video-stream>)
│ └── viewer.js baut Kamera-Views, Auto-Fallback WebRTC→MSE→MJPEG
├── doc/
── 01_WebcamRoadmap.md (diese Datei)
│ ├── 03_Protocoll_roadmap.md WebRTC⟷MJPEG-Vergleich (nachzuholen)
│ └── 05_OptionalToDo_roadmap.md Control-Integration (Optionen)
├── docker-compose.yaml einzige Deploy-Datei (go2rtc-Config eingebettet)
├── go2rtc.yaml nur Referenz/lokal (Config ist in compose eingebettet)
│ ├── index.html Viewer-HTML + CSS
│ └── viewer.js Kamera-Boxen, Live-Start/Stop, HD-Grab, Snapshot-alle
├── tools/
── hires-probe.js Diagnose-Skript: Hires-Grab direkt auf dem Host testen
├── docker-compose.yaml Einzige Deploy-Datei (inline Dockerfile, by-id devices)
├── package.json
└── server.js Node-Einstiegspunkt
└── doc/
├── 01_WebcamRoadmap.md (diese Datei)
├── 04_Delay_roadmap.md Latenz-Geschichte + Messwerte
├── 05_screenShot_roadmap.md HD-Grab Architektur + Encode-Qualität
├── 06_portForwarding_roadmap.md Port-Forwarding / Internet-Zugang
├── 07_multipleCam_roadmap.md Multi-Kamera-Konfiguration, cameras.json-Referenz
└── 09_Bug_reports.md Bug-Dokumentation (go2rtc-Race, Warmup-Irrweg, …)
```
---
## Entwicklungsgeschichte (Phasen)
### Phase 1 — Grundgerüst ✅
Projektstruktur, Node + Express, erster eigener FFmpeg-MJPEG-Stream über WebSocket.
Verworfen wegen Latenz.
### Phase 2 — go2rtc / WebRTC ✅ (abgelöst)
go2rtc als Streaming-Backend (Kamera-Capture, H.264/WebRTC). WebRTC ~130 ms, MJPEG
~200 ms. Stabiler Snapshot-Endpunkt `/api/snapshot/cam{n}` via Node-Proxy.
### Phase 3 — go2rtc-Race-Bug → Node-MJPEG-Schalter ✅ (2026-06-05)
go2rtc konnte den Gerätezustand beim HD-Grab nicht zuverlässig signalisieren → 106 %-CPU-
Race. go2rtc entfernt. Node besitzt die Kameras direkt; `close`-Event = FD frei.
Latenz: **139 ms** (besser als go2rtc). CPU idle: **0 %** (On-Demand).
### Phase 4 — Multi-Kamera + cameras.json ✅ (2026-06-06)
`cameras.json` als Konfigurationsquelle. Dritte Kamera (C920, 1920×1080) in Betrieb.
`hiresEncode` pro Kamera. Stabile by-id-Gerätepfade.
---
## Offene Punkte
| Punkt | Priorität |
|---|---|
| Internet-Zugang (TLS via Caddy / Reverse-Proxy) | mittel — `doc/06_portForwarding_roadmap.md` |
| WebService-Push (POST /api/snapshot/trigger + Volume) | niedrig — erst bei konkretem Aufrufer |
| Verlustfreie Standbilder (YUYV→PNG) | niedrig — nur falls JPEG-Qualität nicht reicht |
| Stream-Freeze (selten, Einzelfall) | niedrig — clientseitiger Watchdog noch offen |

View File

@@ -1,7 +1,8 @@
# AppRobotWebcam Snapshot & HD-Grab
> Status: **Implementiert**. Architektur seit 2026-06-05 (Node-MJPEG-Schalter).
> Gemessene Werte: Latenz 139 ms, ~5 % idle, ~35 %/Kamera aktiv, HD-Grab ~23 s.
> Status: **Implementiert & verifiziert** (3 Kameras: 2× C270, 1× C920).
> Architektur seit 2026-06-05 (Node-MJPEG-Schalter), C920-Hires + Qualität 2026-06-06.
> Werte: Live-Latenz 139 ms, ~5 % idle, ~35 %/Kamera aktiv, HD-Grab ~23 s.
---
@@ -19,7 +20,8 @@ cameras.json
```
Das `close`-Event des Kindprozesses ist der harte Beweis „Gerät frei" — kein Timing,
keine Polls. Das Race (zwei Encoder auf einem Gerät) ist konstruktiv ausgeschlossen.
keine Polls, **kein zweiter Öffner**. Das Race (zwei Encoder auf einem Gerät) ist
konstruktiv ausgeschlossen.
---
@@ -33,133 +35,109 @@ ffmpeg -fflags nobuffer -f v4l2 -input_format mjpeg
-f mpjpeg -flush_packets 1 pipe:1
```
| Encode-Modus | CPU | Wann |
|---|---|---|
| `copybsf` (Default) | ~35 %/Kamera | Kamera liefert natives MJPEG |
| `mjpeg` (Fallback) | ~50 %/Kamera | Re-Encode, falls copybsf Probleme macht |
**`mjpeg2jpeg` ist Pflicht bei copybsf** — Kamera-MJPEG lässt JPEG-Huffman-Tabellen
**`mjpeg2jpeg` ist Pflicht bei copybsf** — Kamera-MJPEG lässt die JPEG-Huffman-Tabellen
weg; ohne den Filter verschluckt sich der mpjpeg-Muxer (107 % CPU + Hang).
**Latenz-Tuning:** `-fflags nobuffer` + `-flush_packets 1` + `socket.setNoDelay(true)` +
`cork/uncork` (Header+JPEG+Trailer = ein TCP-Segment). Gemessen: **139 ms** Kamera→Browser.
**On-Demand:** Live läuft nur wenn Clients verbunden sind. `acquire()`/`release()` zählen
Verbraucher. Nach dem letzten + `IDLE_GRACE_MS` (15 s): Stop → **0 % idle**. Abschaltbar
**On-Demand:** Live läuft nur, wenn Clients verbunden sind. `acquire()`/`release()` zählen
Verbraucher; nach dem letzten + `IDLE_GRACE_MS` (15 s) Stop → **0 % idle**. Abschaltbar
via `ON_DEMAND=false`.
---
## HD-Grab (`grabHires`)
### Fall A — liveSize == hiresSize
### Fall A — `liveSize == hiresSize`
Wenn die Live-Auflösung bereits der gewünschten Hires-Auflösung entspricht (z.B. C920
mit `liveSize: "1920x1080"`): kein Format-Wechsel nötig. `grabHires` ruft direkt
`getFrame()` auf dem laufenden Live-Stream auf. Kein Neustart, kein Übergangs-Problem.
Kein Format-Wechsel nötig: `grabHires` gibt direkt den laufenden Live-Frame zurück
(`getFrame()`). Schnell, kein Geräte-Neustart.
```
Kamera ──live 1920×1080──► getFrame() → JPEG 1920×1080
```
### Fall B — liveSize ≠ hiresSize (C270: 640×480 → 1280×960)
### Fall B — `liveSize ≠ hiresSize` (der Normalfall)
```
1. Live-FFmpeg SIGTERM → warte auf close (= FD frei)
2. sleep(800ms) ← Kamera-/Treiberreset, Puffer auslaufen lassen
3. optional: 1280x720-Warmup-Format öffnen
4. sleep(500ms) ← Warmup/Formatwechsel stabilisieren
5. hires-FFmpeg bei hiresSize/hiresFps starten
6. Frames warmlaufen lassen (settleFrames=6) + minWidth-Check (90 % der Soll-Breite)
7. Ersten validen Frame nehmen → hires-FFmpeg SIGTERM
8. finally: Live-FFmpeg neu starten (immer, auch bei Fehler)
2. hires-FFmpeg bei hiresSize/hiresFps starten
3. Frames warmlaufen lassen (settleFrames = 6) + minWidth-Check
4. ersten validen Frame nehmen → hires-FFmpeg beenden
5. finally: Live-FFmpeg neu starten (immer, auch bei Fehler)
```
**Kein Warmup, kein `sleep` zwischen Stop und Grab.** Auf dem Host verifiziert
(A/B-Test 2026-06-06): ein direkter Open auf die Zielauflösung liefert sofort korrekte
Frames — sowohl 1280×960 (C270) als auch 1920×1080 (C920), in beiden Encode-Modi.
Ein zweites FFmpeg dazwischen (früher als „Warmup-Zwischenformat" eingebaut) erzeugt
nur `Device or resource busy` und ist entfernt.
**`minWidth`** wird automatisch aus `hiresSize` abgeleitet (`floor(hiresW × 0.9)`).
Beispiel `1920x1080``minWidth = 1728`. Damit werden etwaige Übergangs-Frames in
falscher Auflösung (v4l2-Buffer-Reste) abgelehnt.
**`_captureHires`** setzt zusätzlich `-probesize 5000000 -analyzeduration 1000000`, damit
FFmpeg das MJPEG-Format sicher erkennt.
**Blackout:** Der `<img>` friert ~23 s ein und läuft danach weiter. Kein Client-Handling nötig.
**minWidth** wird automatisch aus `hiresSize` abgeleitet (`floor(hiresW × 0.9)`).
Damit werden Frames abgelehnt, die noch auf der alten Live-Auflösung liegen
(v4l2-Buffer-Reste vom vorherigen Format).
**FFmpeg-Probe:** Für das finale hires-Open werden jetzt zusätzliche Probe-Parameter
gesetzt (`-probesize 5000000`, `-analyzeduration 1000000`), um das MJPEG-Format
und die Kameraparameter sicherer zu erkennen.
---
## Aktueller Ablauf des HD-Grabs
## Standbild-Qualität: Encode-Wahl
Der aktuelle Ablauf ist bewusst defensiv:
- Live-Stream beenden und auf FFmpeg-`close` warten.
- 800 ms Pause zum Zurücksetzen des Kameratreibers.
- Wenn liveSize deutlich kleiner als hiresSize ist, zunächst ein Zwischenformat
`1280x720` starten und kurz einlaufen lassen.
- 500 ms warten, damit das Gerät in den neuen Auflösungsmodus umschaltet.
- Hires-Stream starten, mehrere Frames puffern und den ersten validen Frame
mit genügend Bytes und Breite auswählen.
- Hires-Stream beenden, Live-Stream neu starten.
Die Kamera liefert bei `input_format mjpeg` bereits **JPEG-komprimiert** (Hardware,
unvermeidbar). Entscheidend ist, ob wir eine **zweite** Kompression daraufsetzen:
### Log- und Fehlerbild
| `hiresEncode` | FFmpeg | Effekt | Empfehlung |
|---|---|---|---|
| `copybsf` (Default) | `-c:v copy -bsf:v mjpeg2jpeg` | Kamera-JPEG **pur durchgereicht**, keine zweite Kompression | **Standardwahl, auch für Standbilder** |
| `mjpeg` | `-c:v mjpeg -q:v 5` | Re-Encode = **zweite** Kompression → sichtbare Artefakte | nur Fallback, falls copybsf zickt |
Aktuelle Log-Meldungen zeigen typische MJPEG/Treiber-Phänomene:
- `unable to decode APP fields`: meist kosmetisch beim MJPEG-Parser, häufig bei
UVC-Webcams. Das bedeutet nicht zwingend einen fehlgeschlagenen Grab.
- `input is truncated` / `Error applying bitstream filters`: kann auftreten, wenn
FFmpeg beim Beenden gerade ein MJPEG-Paket verarbeitet. Das ist ein Hinweis auf
einen abrupten Stream-Abbruch, nicht zwingend auf ein ungültiges Bild.
- `Could not find codec parameters ... unspecified pixel format`: deutet darauf hin,
dass der neue HD-Stream noch nicht sauber genug erkannt wurde. Deshalb nutzen
wir jetzt größere Probe-Parameter.
- `hiresEncode` überschreibt `encode` nur für den Grab; fehlt es, gilt `encode`.
- cam2 lief kurzzeitig auf `mjpeg` (Workaround) → sichtbare Artefakte, **131 kB**. Mit
`copybsf` kommt das ungekürzte Kamera-JPEG (~300450 kB) → keine Re-Encode-Artefakte.
### Mögliche Ursachen
- Treiberzustand der Kamera nach einem schnellen Formatwechsel: der C920 kann
noch Frames aus dem alten Modus liefern oder zwischen `640×480` und `1920×1080`
in einen inkonsistenten Zustand kommen.
- MJPEG-Frames ohne vollständige JPEG-Header oder Huffman-Tabellen, die erst durch
`mjpeg2jpeg` ergänzt werden.
- Abrupte Beendigung des `ffmpeg`-Prozesses während eines laufenden MJPEG-Pakets.
**Wirklich verlustfrei** (kein JPEG-Verlust überhaupt) ginge nur über das rohe
**YUYV**-Format → PNG. Eigener Grab-Pfad, große Dateien (~36 MB), langsamer, USB-intensiv.
**Noch nicht implementiert** — nur bauen, wenn die JPEG-Qualität nicht reicht.
---
## Kamera-spezifische Konfiguration
### C920 (HD Pro Webcam) — liveSize = hiresSize
Die C920 hat ein v4l2-Treiber-Eigenheit: nach einem Live-Stream bei 640×480 kann sie
beim Neustart auf 1920×1080 nicht sauber wechseln — der Treiber gibt 1280×720-Frames
zurück (Übergangs-Artefakt, trotz korrekter Format-Anforderung).
**Lösung:** `liveSize` und `hiresSize` identisch setzen. `grabHires` wechselt dann kein
Format — es liest direkt aus dem laufenden 1920×1080-Stream (Fall A).
### C270 (`cam0`, `cam1`)
```json
{
"id": "cam2",
"liveSize": "1920x1080",
"liveFps": 15,
"hiresSize": "1920x1080"
}
{ "hiresSize": "1280x960" }
```
Live 640×480 (Default), Grab 1280×960, `copybsf`. Bewährter Standardfall.
### C270 — Standard (Fall B)
### C920 (`cam2`)
```json
{
"id": "cam0",
"hiresSize": "1280x960"
}
{ "hiresSize": "1920x1080" }
```
Live bei 640×480, Grab bei 1280×960. Format-Wechsel + 300ms-Pause funktioniert.
Live 640×480 (Default, USB-schonend), Grab 1920×1080, `copybsf`. Der Wechsel 640→1920
funktioniert direkt — **kein** `liveSize`-Override und **kein** Warmup nötig.
### Auflösung prüfen
Nur MJPG-native Auflösungen in `liveSize`/`hiresSize` verwenden (kein Software-Encode):
```bash
v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG
```
Nur unter `'MJPG'` gelistete Auflösungen verwenden (sonst Software-Encode = teuer).
---
## Harmlose Log-Meldungen
Diese erscheinen im Normalbetrieb und bedeuten **keinen** Fehler:
| Meldung | Bedeutung |
|---|---|
| `unable to decode APP fields: Invalid data found` | einmalige FFmpeg-Probe-Warnung beim Stream-Start (C270) |
| `Dequeued v4l2 buffer contains corrupted data` | erster Frame nach Geräte-Open ist oft unvollständig — wird verworfen |
| `input is truncated` / `Error applying bitstream filters` | copybsf droppt den ersten korrupten Frame; `settleFrames` nimmt einen späteren |
Entscheidend ist die `HD OK`-Zeile mit `Breite=…px` passend zur `Soll`-Breite.
---
@@ -179,10 +157,8 @@ v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG
| Problem | Priorität | Zustand |
|---|---|---|
| Stream friert selten dauerhaft ein | niedrig | Einzelfall; clientseitiger Watchdog (Frame-Timeout → `img.src` neu) noch nicht implementiert |
| `unable to decode APP fields` im Log (C270) | kosmetisch | Einmalige Probe-Warnung von FFmpeg, kein Auswirkung |
| `input is truncated` / `Error applying bitstream filters` beim HD-Grab | mittel | Hinweis auf abrupten Stream-Abbruch beim Formatwechsel; Bild kann dennoch gültig sein |
| `Could not find codec parameters ... unspecified pixel format` | mittel | Zeichen für unvollständige FFmpeg-Probe nach Formatwechsel; größere probe-Parameter helfen |
| Stream friert selten dauerhaft ein | niedrig | Einzelfall; clientseitiger Watchdog (Frame-Timeout → `img.src` neu) noch nicht gebaut |
| Verlustfreie Standbilder (YUYV→PNG) | niedrig | offen; siehe „Standbild-Qualität" |
---
@@ -190,58 +166,47 @@ v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG
## Architektur-Entwicklung (Historie)
Dieser Abschnitt dokumentiert den Weg zur aktuellen Lösung.
Für die maßgebliche Implementierung gelten ausschliesslich die Abschnitte oben.
Dieser Teil dokumentiert den Weg dorthin.
### go2rtc-Ansatz (2026-06-04) — abgelöst
#### Grundidee: Consumer-Umhängen
go2rtc verwaltete die Kameras. Für HD-Grabs wurde der Live-Consumer losgelassen, damit
go2rtc das Gerät freigibt, dann ein separater `*_hires`-Stream geöffnet. Phase 1 (Freigabe
messen) und Phase 2 (Grab) funktionierten grundsätzlich, aber:
go2rtc verwaltete die Kameras. Für HD-Grabs wurde der Live-Consumer (`cam0`) losgelassen,
damit go2rtc das Gerät freigibt, dann ein separater `cam0_hires`-Stream bei 1280×960 geöffnet.
**Race-Bug (2026-06-05):** go2rtcs API konnte nicht zuverlässig melden, wann FFmpeg
`/dev/videoN` freigibt → zwei FFmpeg gleichzeitig auf demselben Gerät → **106 % CPU + Hang**
(Details: `09_Bug_reports.md`). Strukturell nicht lösbar, weil go2rtc keine harte Garantie
über den Gerätezustand gibt.
#### Phase 1 — Freigabe messen (2026-06-04, ✅ erfolgreich)
Endpunkt `GET /api/snapshot/:id/release-test` pollt go2rtcs `/api/streams` alle 200 ms
und misst, wann nach Consumer-Verlust der Producer stoppt (= Gerät frei).
**Ergebnis:**
```json
{ "freed": true, "msUntilFree": 0, "zeroConsumerAt": 4850 }
```
go2rtc gibt das Gerät sofort frei wenn der letzte Consumer weg ist. Kein „warm halten".
Die ~5 s (`zeroConsumerAt`) sind die WS-Abmeldelatenz von go2rtc selbst.
#### Phase 2 — HD-Grab implementiert (2026-06-04, ✅ funktionierte)
HD-Grab lieferte echte 1280×960-Frames (~76 KB). Einschränkungen:
- Nur bei einem einzigen Viewer-Tab zuverlässig (bei mehreren fiel Consumer-Count nie auf 0)
- Sequenz: Browser löst cam0 → 5 s warten → Grab → Browser hängt zurück
- Blackout ~810 s (5 s Pause + Grab-Zeit)
#### Race-Bug entdeckt (2026-06-05) → go2rtc entfernt
go2rtcs API konnte nicht zuverlässig melden, wann FFmpeg `/dev/videoN` freigibt.
Folge: zwei FFmpeg gleichzeitig auf demselben Gerät → **106 % CPU + Hang** (reproduzierbar).
Details: `09_Bug_reports.md`.
**Erkenntnis:** Das Problem ist strukturell — go2rtc gibt keine harten Garantien über
den Gerätezustand. Der einzige zuverlässige Beweis „Gerät frei" ist das `close`-Event
des FFmpeg-Prozesses selbst.
**Entscheidung 2026-06-05:** go2rtc entfernen. Node startet FFmpeg direkt.
**Entscheidung:** go2rtc entfernen. Node startet FFmpeg selbst; dessen `close`-Event ist
der harte Beweis „Gerät frei". Die alte `go2rtc.yaml` wurde 2026-06-06 gelöscht (liegt in
der Git-Historie).
### Node-MJPEG-Schalter (2026-06-05) — aktuelle Architektur
Mit `src/cameraSwitch.js` besitzt Node die Kameras direkt. Das `close`-Event des eigenen
FFmpeg-Kindprozesses ist der harte Beweis „FD geschlossen = Gerät frei". Keine go2rtc-
Abhängigkeit, kein Race.
Sofort gemessen: Latenz 139 ms (vorher ~340 ms), Idle-CPU ~5 % (On-Demand), ~35 %/Kamera
aktiv, HD-Grab beider C270 parallel ~2,3 s.
**Sofort gemessene Werte:**
- Latenz: 139 ms (vorher ~340 ms mit go2rtc)
- CPU idle: ~5 % (On-Demand)
- CPU aktiv: ~35 %/Kamera (copybsf)
- HD-Grab beide Kameras parallel: ~2,3 s, je 1280×960
### C920-Hires & der Warmup-Irrweg (2026-06-06)
Alle weiteren Details: aktuelle Architektur-Abschnitte oben.
Beim Einbau der dritten Kamera (C920, 1920×1080) schien der Grab „nur 720" zu liefern.
Mehrere falsche Theorien (Kamera kann kein 1080-MJPEG; Treiber stuft 1080→720 zurück)
wurden **durch Host-Tests widerlegt**: `v4l2-ctl` + direkte `ffmpeg`-Grabs zeigten
1920×1080-MJPEG auf jedem Frame, in beiden Encode-Modi.
Die zwei realen Ursachen:
1. **Veralteter Code im Container** — eine fehlgeschlagene Git-Übertragung; der Container
lief alten Code (verifiziert via `docker exec … grep`). Jede „Fix→Redeploy"-Runde
testete Code, der gar nicht lief.
2. **Warmup-Zwischenformat** — ein nachträglich eingebauter Schritt öffnete ein zweites
FFmpeg (1280×720) vor dem Grab → `Device or resource busy` → alle Grabs brachen ab.
**Lehre (festgehalten):** Erst auf dem **Host** mit `ffmpeg`/`ffprobe`/`v4l2-ctl`
verifizieren, was die Kamera real liefert, und sicherstellen, dass der Container den
aktuellen Code fährt — **bevor** Code-Theorien gebaut werden. Diagnose-Tool dafür:
`tools/hires-probe.js`.
Nach Entfernen des Warmups und mit aktuellem Code: alle drei Kameras liefern korrekte
HD-Grabs (cam2 = 1920×1080). Encode auf `copybsf` für artefaktfreie Standbilder.

View File

@@ -68,16 +68,19 @@ Liegt im Projektverzeichnis, wird beim Start geladen und validiert.
"position": "right",
"stream": true,
"hires": true,
"liveSize": "1280x720",
"liveFps": 30,
"hiresSize": "1920x1080",
"hiresFps": 30,
"note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0"
}
]
}
```
> **cam2 (C920):** Live läuft auf dem globalen Default 640×480 (USB-schonend, flüssig),
> nur der HD-Grab geht auf 1920×1080. `encode`/`hiresEncode` sind **nicht** gesetzt →
> beide nutzen `copybsf` (Kamera-JPEG ohne Re-Encode = beste Standbild-Qualität, s.u.).
> Der Wechsel 640→1920 beim Grab funktioniert direkt; **kein** Warmup/Zwischenformat nötig
> (auf dem Host verifiziert, 2026-06-06). Details: `05_screenShot_roadmap.md`.
### Felder
| Feld | Typ | Pflicht | Bedeutung |
@@ -92,7 +95,8 @@ Liegt im Projektverzeichnis, wird beim Start geladen und validiert.
| `liveFps` | int | | Überschreibt globales `LIVE_FPS`-Env |
| `hiresSize` | string | | Überschreibt globales `HIRES_SIZE`-Env |
| `hiresFps` | int | | Überschreibt globales `HIRES_FPS`-Env |
| `encode` | string | | `"copybsf"` oder `"mjpeg"` — überschreibt globales `ENCODE_MODE`-Env |
| `encode` | string | | `"copybsf"` oder `"mjpeg"` — überschreibt `ENCODE_MODE`-Env (gilt für Live; und für Grab, falls `hiresEncode` fehlt) |
| `hiresEncode` | string | | Encode **nur** für den HD-Grab; überschreibt `encode`. Default = `encode`. Für Standbilder `copybsf` bevorzugen (s.u.) |
| `note` | string | | Freitext; empfohlen: by-id-Name des Geräts (Dokumentation) |
**Globale Env-Defaults** (gelten wenn das Feld in cameras.json fehlt):
@@ -136,9 +140,31 @@ v4l2-ctl --list-formats-ext -d /dev/video4 | grep -A 20 MJPG
Nur Auflösungen unter `'MJPG'` in `liveSize`/`hiresSize` eintragen.
Falls eine Auflösung nur unter `'YUYV'` erscheint → andere Auflösung wählen.
Falls der Stream schwarz bleibt obwohl die Auflösung als MJPG gelistet ist:
`"encode": "mjpeg"` in cameras.json für diese Kamera erzwingt Re-Encode
(kompatibel mit jedem Kamera-MJPEG, aber höhere CPU-Last).
> **Lehrgeld (2026-06-06):** Beim Einbau der C920 schien 1920×1080 „nur 720 zu liefern".
> Ursache war **nicht** die Kamera (1920×1080 MJPEG ist nativ, auf dem Host verifiziert),
> sondern (a) veralteter Code im Container und (b) ein nachträglich eingebauter
> „Warmup-Zwischenformat"-Schritt, der einen zweiten FFmpeg auf dem Gerät startete
> (`Device or resource busy`). Beides entfernt. Lehre: erst auf dem **Host** mit
> `ffmpeg`/`ffprobe` verifizieren, was die Kamera real liefert, bevor man Code-Theorien baut.
---
## Standbild-Qualität: Encode-Wahl
Eine USB-Kamera liefert das Bild bei `input_format mjpeg` bereits **JPEG-komprimiert**
(Hardware, unvermeidbar). Wie der HD-Grab das ausgibt, entscheidet `hiresEncode`/`encode`:
| Modus | Was passiert | Qualität / Größe | Wann |
|---|---|---|---|
| `copybsf` (Default) | Kamera-JPEG **durchreichen** (`-c:v copy -bsf:v mjpeg2jpeg`), keine zweite Kompression | beste JPEG-Qualität, klein, niedrigste CPU | **Standardwahl, auch für Standbilder** |
| `mjpeg` | Re-Encode (`-c:v mjpeg -q:v 5`) — **zweite** Kompression | sichtbare Artefakte bei Standbildern, ~50 % CPU | nur als Fallback, wenn copybsf bei einer Kamera zickt |
→ Für scharfe Standbilder **`copybsf`** verwenden (kein `hiresEncode` setzen). `mjpeg`
war ein Workaround und erzeugte bei cam2 sichtbare Artefakte (131 kB vs. ~350 kB copybsf).
**Wirklich verlustfrei** ginge nur über das rohe **YUYV**-Format → PNG (kein JPEG-Verlust
überhaupt). Das ist ein eigener Grab-Pfad (große Dateien ~36 MB, langsamer) und noch
**nicht implementiert** — bei Bedarf siehe „Offene Punkte".
---
@@ -222,5 +248,6 @@ Blockiert ~810 s. Nur sinnvoll wenn der Aufrufer synchron warten kann.
| Punkt | Priorität | Massnahme |
|---|---|---|
| Phase 4B/C WebService-Push | niedrig | erst wenn aufrufender Container konkret |
| Verlustfreie Standbilder (YUYV→PNG) | niedrig | eigener Grab-Pfad: rohes YUYV lesen → PNG; nur wenn JPEG-Qualität nicht reicht |
| USB-Bandbreite bei >4 aktiven Streams | mittel | `lsusb -t` prüfen, Kameras auf Controller verteilen |
| Stream-Freeze (selten) | niedrig | bekannt; noch kein reproduzierbarer Fall |

View File

@@ -1,31 +1,31 @@
name: approbotwebcam
# ════════════════════════════════════════════════════════════════════════════
# NODE-MJPEG-SCHALTER ein Node-Container besitzt die Kameras selbst
# AppRobotWebcam Node-MJPEG-Schalter
# ════════════════════════════════════════════════════════════════════════════
#
# Node startet pro Kamera EINEN FFmpeg (640 MJPEG passthrough) und verteilt den
# Stream als multipart/x-mixed-replace an die Browser (<img>). Für HD-Snapshots
# stoppt der Schalter den Live-FFmpeg sauber (Prozess-close = Gerät frei),
# greift 1280×960, schaltet zurück. go2rtc wird NICHT mehr gebraucht.
# Node besitzt jede Kamera direkt (eine CameraSwitch-Instanz pro /dev/videoN).
# Live: FFmpeg → MJPEG multipart → Browser <img>. Latenz: ~139 ms.
# HD-Grab: Live-FFmpeg stoppen (close-Event = FD frei) → hires-FFmpeg →
# JPEG an Client → Live zurück. Auflösungen in cameras.json konfiguriert.
#
# Warum so: go2rtcs API konnte nicht zuverlässig melden, wann FFmpeg das Gerät
# freigibt → Race (zwei Encoder auf /dev/videoN = ~108% CPU). Wenn Node FFmpeg
# selbst startet, ist dessen 'close'-Event der harte Beweis „Gerät frei".
# Siehe doc/09_Bug_reports.md.
# Kameras (aktuell, by-id = stabil über Reboots):
# cam0 C270 /dev/video0 Live 640×480, Hires 1280×960
# cam1 C270 /dev/video2 Live 640×480, Hires 1280×960
# cam2 C920 /dev/video4 Live 640×480, Hires 1920×1080
#
# Portainer: Stack → Web editor → dieses YAML → Deploy.
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam (Code muss dort liegen)
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam
#
# Firewall (Internet): TCP 8444 (Viewer + Stream + API). Sonst nichts mehr.
# Firewall: TCP 8444 (Viewer + Stream + API)
#
# Zugriff:
# Viewer: http://<host>:8444/
# Live-Stream: http://<host>:8444/api/stream/cam0
# Snapshot (Homing): http://<host>:8444/api/snapshot/cam0 (+ /hires)
#
# ROLLBACK auf den alten go2rtc-Aufbau: git checkout <commit> -- docker-compose.yaml
# server.js public/ src/ (der go2rtc-Stand liegt in der Git-Historie).
# Snapshot: http://<host>:8444/api/snapshot/cam0
# HD-Snapshot: http://<host>:8444/api/snapshot/cam0/hires
# Kamera-Liste: http://<host>:8444/api/cameras
# Status: http://<host>:8444/health
# ════════════════════════════════════════════════════════════════════════════
services:
@@ -69,10 +69,24 @@ services:
# - IDLE_GRACE_MS=15000 # Karenz nach letztem Zuschauer vor dem Stop
# ── Hinweise ────────────────────────────────────────────────────────────────────
# • Bleibt eine Kamera schwarz? Geräte prüfen: v4l2-ctl --list-devices
# und ob 640x480 bzw. 1280x960 als MJPEG nativ angeboten werden:
# v4l2-ctl --list-formats-ext -d /dev/video0
# Nur MJPEG-native Auflösungen bleiben CPU-arm (YUYV → Software-Encode = teuer).
# • Meckert Portainer über sehr alte Compose-Syntax (dockerfile_inline)? Dann
# Compose/Docker-Engine aktualisieren dieser Aufbau braucht Compose v2.
# • Neue oder geänderte Kamera: cameras.json anpassen + Redeploy (kein Code-Änderung).
# by-id-Namen ermitteln: ls -la /dev/v4l/by-id/
# Neues Device hier eintragen (by-id → /dev/videoN), dann cameras.json-Eintrag.
#
# • Bleibt eine Kamera schwarz oder liefert falsche Auflösung?
# Direkt auf dem Host testen (ohne Docker, beweist was die Kamera real kann):
# node tools/hires-probe.js /dev/video4 1920x1080 copybsf
# Alternativ manuell:
# v4l2-ctl --list-formats-ext -d /dev/video4 # MJPG-Auflösungen anzeigen
# Nur MJPEG-native Auflösungen in cameras.json verwenden (YUYV = Software-Encode = ~50% CPU).
#
# • HD-Grab liefert schlechte Qualität?
# Standard ist copybsf (Kamera-JPEG pur, keine zweite Kompression).
# "hiresEncode": "mjpeg" in cameras.json nur als Fallback, erzeugt Re-Encode-Artefakte.
#
# • Code-Stand im Container prüfen:
# docker exec AppRobotWebcam grep -n "Grab (minWidth" src/cameraSwitch.js
# Zeigt die neue Log-Zeile → aktueller Code läuft. "1280-Grab" → staler Code.
#
# • Compose v2 ist Pflicht (dockerfile_inline). Bei Portainer-Warnung: Docker-Engine updaten.
# ────────────────────────────────────────────────────────────────────────────────

View File

@@ -1,23 +0,0 @@
# Hinweis: Diese Datei wird für das Portainer-Deployment NICHT mehr gebraucht
# die Config ist jetzt direkt in docker-compose.yaml eingebettet (configs.content).
# Sie bleibt hier nur als Referenz / für lokales go2rtc ohne Compose.
streams:
# Einfache Form: bestätigt funktionsfähig für beide Kameras.
# #video=h264 → für WebRTC (transcodiert)
# #video=mjpeg → für MJPEG-Fallback + /api/frame.jpeg (Snapshot)
cam0: "ffmpeg:/dev/video0#video=h264#video=mjpeg"
cam1: "ffmpeg:/dev/video2#video=h264#video=mjpeg"
webrtc:
listen: ":8555"
candidates:
- stun:8555
api:
listen: ":1984"
# erlaubt WebSocket-Signaling vom Viewer auf Port 8444 (anderer Origin)
origin: "*"
log:
level: info