Claude: Dokumentation
This commit is contained in:
@@ -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
|
||||
│
|
||||
▼
|
||||
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
|
||||
cameras.json → server.js → CameraSwitch (eine Instanz pro /dev/videoN)
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ 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, 2–3 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 ~50–150 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 (1–3 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 | **~2–3 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 |
|
||||
|
||||
@@ -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 ~2–3 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 ~2–3 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -29,137 +31,113 @@ keine Polls. Das Race (zwei Encoder auf einem Gerät) ist konstruktiv ausgeschlo
|
||||
ffmpeg -fflags nobuffer -f v4l2 -input_format mjpeg
|
||||
-video_size <liveSize> -framerate <liveFps>
|
||||
-i /dev/videoN
|
||||
-c:v copy -bsf:v mjpeg2jpeg ← copybsf (Default)
|
||||
-c:v copy -bsf:v mjpeg2jpeg ← copybsf (Default)
|
||||
-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 ~2–3 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 (~300–450 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 (~3–6 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 ~8–10 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.
|
||||
|
||||
@@ -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 ~3–6 MB, langsamer) und noch
|
||||
**nicht implementiert** — bei Bedarf siehe „Offene Punkte".
|
||||
|
||||
---
|
||||
|
||||
@@ -222,5 +248,6 @@ Blockiert ~8–10 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 |
|
||||
|
||||
Reference in New Issue
Block a user