# 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 (
)
└── 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 -framerate
-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 `
` 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 `
`) |
| `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.