# 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.
