248 lines
9.3 KiB
Markdown
248 lines
9.3 KiB
Markdown
# 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.
|
||
|
||
---
|
||
|
||
## 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. 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
|
||
```
|
||
|
||
| 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
|
||
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
|
||
|
||
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.
|
||
|
||
```
|
||
Kamera ──live 1920×1080──► getFrame() → JPEG 1920×1080
|
||
```
|
||
|
||
### Fall B — liveSize ≠ hiresSize (C270: 640×480 → 1280×960)
|
||
|
||
```
|
||
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)
|
||
```
|
||
|
||
**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
|
||
|
||
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.
|
||
|
||
### Log- und Fehlerbild
|
||
|
||
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.
|
||
|
||
### 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.
|
||
|
||
---
|
||
|
||
## 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).
|
||
|
||
```json
|
||
{
|
||
"id": "cam2",
|
||
"liveSize": "1920x1080",
|
||
"liveFps": 15,
|
||
"hiresSize": "1920x1080"
|
||
}
|
||
```
|
||
|
||
### C270 — Standard (Fall B)
|
||
|
||
```json
|
||
{
|
||
"id": "cam0",
|
||
"hiresSize": "1280x960"
|
||
}
|
||
```
|
||
|
||
Live bei 640×480, Grab bei 1280×960. Format-Wechsel + 300ms-Pause funktioniert.
|
||
|
||
### 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
|
||
```
|
||
|
||
---
|
||
|
||
## 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 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 |
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## Architektur-Entwicklung (Historie)
|
||
|
||
Dieser Abschnitt dokumentiert den Weg zur aktuellen Lösung.
|
||
Für die maßgebliche Implementierung gelten ausschliesslich die Abschnitte oben.
|
||
|
||
### go2rtc-Ansatz (2026-06-04) — abgelöst
|
||
|
||
#### Grundidee: Consumer-Umhängen
|
||
|
||
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.
|
||
|
||
#### 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.
|
||
|
||
### 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 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
|
||
|
||
Alle weiteren Details: aktuelle Architektur-Abschnitte oben.
|