213 lines
8.4 KiB
Markdown
213 lines
8.4 KiB
Markdown
# AppRobotWebcam – Snapshot & HD-Grab
|
||
|
||
> 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.
|
||
|
||
---
|
||
|
||
## Architektur
|
||
|
||
Eine `CameraSwitch`-Instanz pro physischem Gerät — der einzige Öffner von `/dev/videoN`.
|
||
Hält immer nur **einen** FFmpeg: entweder Live **oder** Grab. Nie beide.
|
||
|
||
```
|
||
cameras.json
|
||
└── server.js → CameraSwitch (besitzt /dev/videoN)
|
||
│
|
||
├── Live: ffmpeg → mpjpeg pipe → Fan-out an N Browser-Clients (<img>)
|
||
└── Grab: Live SIGTERM → close-Event = FD frei → hires-FFmpeg → zurück
|
||
```
|
||
|
||
Das `close`-Event des Kindprozesses ist der harte Beweis „Gerät frei" — kein Timing,
|
||
keine Polls, **kein zweiter Öffner**. Das Race (zwei Encoder auf einem Gerät) ist
|
||
konstruktiv ausgeschlossen.
|
||
|
||
---
|
||
|
||
## Live-Stream
|
||
|
||
```
|
||
ffmpeg -fflags nobuffer -f v4l2 -input_format mjpeg
|
||
-video_size <liveSize> -framerate <liveFps>
|
||
-i /dev/videoN
|
||
-c:v copy -bsf:v mjpeg2jpeg ← copybsf (Default)
|
||
-f mpjpeg -flush_packets 1 pipe:1
|
||
```
|
||
|
||
**`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
|
||
via `ON_DEMAND=false`.
|
||
|
||
---
|
||
|
||
## HD-Grab (`grabHires`)
|
||
|
||
### Fall A — `liveSize == hiresSize`
|
||
|
||
Kein Format-Wechsel nötig: `grabHires` gibt direkt den laufenden Live-Frame zurück
|
||
(`getFrame()`). Schnell, kein Geräte-Neustart.
|
||
|
||
### Fall B — `liveSize ≠ hiresSize` (der Normalfall)
|
||
|
||
```
|
||
1. Live-FFmpeg SIGTERM → warte auf close (= FD frei)
|
||
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.
|
||
|
||
---
|
||
|
||
## Standbild-Qualität: Encode-Wahl
|
||
|
||
Die Kamera liefert bei `input_format mjpeg` bereits **JPEG-komprimiert** (Hardware,
|
||
unvermeidbar). Entscheidend ist, ob wir eine **zweite** Kompression daraufsetzen:
|
||
|
||
| `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 |
|
||
|
||
- `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.
|
||
|
||
**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
|
||
|
||
### C270 (`cam0`, `cam1`)
|
||
|
||
```json
|
||
{ "hiresSize": "1280x960" }
|
||
```
|
||
Live 640×480 (Default), Grab 1280×960, `copybsf`. Bewährter Standardfall.
|
||
|
||
### C920 (`cam2`)
|
||
|
||
```json
|
||
{ "hiresSize": "1920x1080" }
|
||
```
|
||
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
|
||
|
||
```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.
|
||
|
||
---
|
||
|
||
## API-Endpunkte
|
||
|
||
| Endpunkt | Beschreibung |
|
||
|---|---|
|
||
| `GET /api/snapshot/:id` | 640er JPEG aus dem Live-Puffer (on-demand, sofort) |
|
||
| `GET /api/snapshot/:id/hires` | HD-JPEG via `grabHires` (2–3 s, Blackout) |
|
||
| `GET /api/stream/:id` | MJPEG multipart/x-mixed-replace (Live, Browser `<img>`) |
|
||
| `GET /api/cameras` | Metadaten aller Kameras aus cameras.json |
|
||
| `GET /health` | Zustand aller CameraSwitch-Instanzen |
|
||
|
||
---
|
||
|
||
## Bekannte Restprobleme
|
||
|
||
| Problem | Priorität | Zustand |
|
||
|---|---|---|
|
||
| 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" |
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## Architektur-Entwicklung (Historie)
|
||
|
||
Für die maßgebliche Implementierung gelten ausschliesslich die Abschnitte oben.
|
||
Dieser Teil dokumentiert den Weg dorthin.
|
||
|
||
### go2rtc-Ansatz (2026-06-04) — abgelöst
|
||
|
||
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:
|
||
|
||
**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.
|
||
|
||
→ **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
|
||
|
||
Sofort gemessen: Latenz 139 ms (vorher ~340 ms), Idle-CPU ~5 % (On-Demand), ~35 %/Kamera
|
||
aktiv, HD-Grab beider C270 parallel ~2,3 s.
|
||
|
||
### C920-Hires & der Warmup-Irrweg (2026-06-06)
|
||
|
||
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.
|