Files
appRobotWebcam/doc/05_screenShot_roadmap.md
2026-06-05 07:32:05 +02:00

484 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> # ⛔ ABGELÖST (2026-06-05) — dieser Ansatz war die Ursache des 106%-Bugs
>
> Der unten beschriebene **Consumer-Umhängen-Ansatz mit go2rtc** (`cam0` loslassen →
> go2rtc gibt Gerät frei → `cam0_hires` greifen) hat sich als **prinzipiell racy**
> erwiesen: go2rtcs API kann nicht zuverlässig melden, wann FFmpeg `/dev/videoN`
> freigibt → zwei Encoder auf einem Gerät → **106% CPU + Freeze** (siehe `09_Bug_reports.md`).
>
> **Aktuelle, maßgebliche Architektur:** **Node-MJPEG-Schalter, go2rtc entfernt.**
> Node besitzt die Kameras selbst; das `close`-Event des eigenen FFmpeg ist der harte
> Beweis „Gerät frei". Das Race ist damit konstruktiv ausgeschlossen.
>
> | | alt (unten, abgelöst) | **neu (maßgeblich)** |
> |-|----------------------|----------------------|
> | Geräte-Öffner | go2rtc | **Node** `src/cameraSwitch.js` |
> | Live | go2rtc-WS + `video-stream.js` | MJPEG multipart → `<img>` |
> | HD-Grab | 2. go2rtc-Stream `cam_hires` (Race) | Schalter: Live stoppen (`close`=FD frei) → 1280 → zurück |
> | Multi-User | brach | gelöst (ein FFmpeg → Fan-out) |
>
> **→ Neue Architektur + Hardware-Testplan stehen weiter unten in diesem Dokument
> (Abschnitt „## Node-MJPEG-Schalter").** Alles ab hier bis dorthin ist **Historie**.
---
# AppRobotWebcam Hi-Res-Snapshot via Consumer-Umhängen ⛔ (historisch)
> Status: **Phase 2 implementiert und funktional** (2026-06-04):
> HD-Grab liefert echten 1280×960-Frame (76071 bytes bestätigt). Bekanntes Problem
> behoben: `_hires`-Streams aus Kameraliste gefiltert. CPU ~35 % stabil.
> Vorgeschichte & gescheiterte Ansätze: siehe `04_Delay_roadmap.md` (Abschnitt
> „KONSOLIDIERT"). Diese Datei beschreibt den Ansatz, der die dort dokumentierten
> Fehler **strukturell** umgeht.
---
## Grundidee
Das Kernproblem aller bisherigen Versuche: **Eine USB-Kamera lässt sich nur einmal
öffnen**, und der Live-Viewer zwingt go2rtc, das Gerät zu halten (per on-demand-
Reconnect). Jeder Versuch, go2rtc das Gerät zu *entreißen* oder die Live-Quelle zur
Laufzeit *umzuschalten*, ist gescheitert (device-busy, bzw. `PATCH` hängt an → 107 %).
**Neuer Ansatz das Problem umdrehen:** Nicht das Gerät dem Stream entreißen, sondern
**die Zuschauer vom Live-Stream wegziehen.** Hat `cam0` keine Zuschauer mehr, stoppt
go2rtc den Producer von selbst (on-demand) und gibt das Gerät frei. Dann kann ein
separater Hi-Res-Stream es kurz für sich haben.
### Warum das sicher ist (im Gegensatz zu allem vorher)
- **`cam0` wird nie verändert.** Kein `PATCH`/`PUT`/`DELETE` auf den Live-Stream.
Das Append-Problem (107 %) kann nicht auftreten.
- **Zur Laufzeit nur LESENDE go2rtc-Aufrufe**: `GET /api/streams`, `GET /api/frame.jpeg`.
Die einzige „schreibende" Änderung ist das Hinzufügen von `cam0_hires` in der Config
(per Redeploy, nicht zur Laufzeit).
- **Kleiner Schadensradius**: Geht etwas schief, ist die Erholung „Browser wieder auf
`cam0` hängen" → go2rtc startet `cam0` neu. Keine kaputte Stream-Definition, die bis
zum Neustart hängt.
Damit respektiert der Ansatz die eisernen Regeln aus `04_*` (Snapshot-Pfad read-only,
keine Laufzeit-Mutation von cam0/cam1).
---
## Architektur
| Stream | Quelle | on-demand | Zweck |
|--------|--------|-----------|-------|
| `cam0` | Kamera @640 | ja | **unverändert** Live-Stream |
| `cam1` | Kamera @640 | ja | **unverändert** Live-Stream |
| `cam0_hires` | Kamera @1280×960 | ja | **nur** für den Hi-Res-Grab (Phase 2) |
| `cam1_hires` | Kamera @1280×960 | ja | dito für cam1 |
**Platzhalter = rein clientseitig**, kein go2rtc-Stream nötig:
Der Browser friert beim Umhängen den zuletzt gezeigten Live-Frame auf einem `<canvas>`
ein und blendet „HD Image Work" ein (ca. 30 % der Bildgröße, unten rechts). Das ist
das, was während des Grabs zu sehen ist. Vorteil: keine zusätzliche go2rtc-Last, kein
Nachschieben von Bildern in go2rtc.
> Alternative (nur falls *mehrere* Zuschauer gleichzeitig denselben Platzhalter sehen
> sollen): ein echter go2rtc-`standbild0`-Stream aus einer statischen Bilddatei. Mehr
> Aufwand, hier zunächst nicht nötig.
---
## Ziel-Ablauf (vollständig, Phase 1 + 2)
```
1. Browser hängt um: cam0 → Canvas-Standbild ("HD Image Work") [clientseitig]
2. cam0 hat 0 Zuschauer → go2rtc stoppt cam0-Producer → Gerät frei
3. [Pause ~4s] ← Wert ist FREI WÄHLBAR, wird aus Phase-1-Messung gesetzt
4. Node holt 1 Frame von cam0_hires → go2rtc öffnet Gerät @1280 → frame.jpeg
(Breite ≥1000px prüfen, sonst retry; Warmup beachten s.u.)
5. cam0_hires-Consumer endet → Gerät frei [Pause ~4s]
6. Browser hängt zurück: Canvas → cam0 → Live @640 wieder da
```
Blackout auf **cam0** ~810 s (mit 4s-Pausen), cam1 unberührt. Für „alle 30 s,
Button-getriggert, Blackout ok" passt das. Die Pausen sind großzügig gewählt; sobald
Phase 1 die echte Freigabe-Zeit liefert, können sie gekürzt werden.
> **Die 4s sind ein Startwert, keine feste Größe.** Phase 1 misst, wie schnell go2rtc
> das Gerät wirklich freigibt → daraus wird der reale Pausenwert.
---
## Der eine Dreh- und Angelpunkt (= warum Phase 1 zuerst kommt)
Der ganze Ansatz steht und fällt mit **einer** Annahme:
> **Gibt go2rtc das Gerät frei, wenn `cam0` den letzten Zuschauer verliert — und wie
> schnell?**
go2rtc *kann* einen Producer nach dem letzten Consumer „warm" halten statt ihn sofort
zu stoppen. Tut es das, bleibt das Gerät belegt und `cam0_hires` läuft auf „device
busy". **Diese Zahl wird in Phase 1 gemessen, bevor irgendein Hi-Res-Grab gebaut wird.**
---
## PHASE 1 — Freigabe verifizieren (kein Grab, voll reversibel)
**Ziel:** Beweisen, dass Schritt 1 → 2 → 6 funktioniert und das Gerät tatsächlich
(und wie schnell) frei wird. Kein `cam0_hires`, kein 1280-Zugriff. Risiko ~null:
im schlimmsten Fall ist es ein Reconnect von cam0.
### Umzusetzen
**A. Viewer (`public/viewer.js`)** Button „Hi-Res-Test (Phase 1)":
1. Aktuellen `cam0`-Frame auf ein `<canvas>` zeichnen, „HD Image Work" einblenden,
Canvas anstelle des `<video-stream>` zeigen.
2. `<video-stream>` für cam0 **entfernen/stoppen** (das ist das „Umhängen" cam0
verliert seinen Consumer).
3. `GET /api/snapshot/cam0/release-test` aufrufen (neuer Node-Endpunkt, s.u.).
4. Auf Antwort: `<video-stream>` für cam0 wieder einsetzen (Live zurück), Canvas weg.
**B. Node (`src/snapshotService.js`)** neuer read-only Endpunkt
`GET /api/snapshot/:id/release-test`:
1. Startzeit loggen.
2. `GET ${go2rtc}/api/streams` alle 200 ms pollen (max. ~10 s).
3. Loggen:
- Wann erreicht `cam0` **0 Consumer**?
- Wann ist der `cam0`-**Producer gestoppt** (Feld `producers` leer bzw. `state`
`running`) → das ist der Proxy für „Gerät frei".
- Dauer von „0 Consumer" → „Producer gestoppt" in ms.
4. Ergebnis ins Log schreiben **und** als JSON zurückgeben, z. B.:
```json
{ "freed": true, "msUntilFree": 1700, "samples": [...] }
```
5. Kein Schreibzugriff auf go2rtc. Nur Lesen.
### Umgesetzt am 2026-06-04
- **Node:** `GET /api/snapshot/:id/release-test` in `src/snapshotService.js` pollt
`/api/streams` alle 200 ms (max. 10 s), misst `zeroConsumerAt`/`producerStoppedAt`,
liefert `{ freed, msUntilFree, samples }`. Rein lesend. Parser an den bestehenden
`server.js`-Monitor angelehnt (`producers[].state === 'running'`, `consumers.length`).
- **Viewer:** Pro Kamera Button „HD?" in `public/viewer.js`. Friert den Frame auf ein
`<canvas>` („HD Image Work"), entfernt den `<video-stream>` (Umhängen), ruft den
Endpunkt, hängt im `finally` **immer** wieder auf Live zurück.
- **Messung an der Live-Instanz steht noch aus** (Docker/go2rtc auf dem Server) erst
diese liefert das echte `msUntilFree` für Schritt 3/5.
### Erfolgskriterium Phase 1
- Log/JSON zeigt `freed: true` und eine **konkrete** `msUntilFree`.
- Nach dem Test (Schritt 6) zeigt cam0 wieder normal Live (~50 % CPU, stabil).
### Was wir daraus lernen
- `msUntilFree` → der reale Pausenwert für Schritt 3/5 (statt der geratenen 4 s).
- **Gemessen (2026-06-04):** `freed: true`, `msUntilFree: 0`, `zeroConsumerAt: 4850 ms`.
go2rtc hält das Gerät **nicht** warm — es stoppt den Producer sofort wenn der letzte
Consumer weg ist. Der Ansatz ist bestätigt.
- Würde der Producer nicht gestoppt (`freed: false`): go2rtc hält das Gerät warm →
Ansatz so nicht tragfähig → zurück zu Weg A (separate Kamera, siehe `04_*`). Trat
**nicht** ein.
> ⚠ Die genaue JSON-Form von `/api/streams` (Felder `producers`/`consumers`/`state`)
> vor dem Bauen kurz an der echten Instanz ansehen (`curl -s localhost:1984/api/streams`)
> und den Parser danach ausrichten — nicht annehmen.
---
## Erster Live-Test (2026-06-04) — INCONCLUSIVE (nicht widerlegt, ersetzt durch zweiten)
Antwort: `{ freed: false, zeroConsumerAt: null, producerStoppedAt: null }`.
**Richtig gelesen:** `zeroConsumerAt: null` = cam0 hatte während der vollen 10 s Messung
**nie 0 Consumer**. Damit wurde die eigentliche Linchpin-Frage (*stoppt der Producer bei
0 Consumern?*) **gar nicht getestet** — der Vorzustand „0 Consumer" wurde nie erreicht.
Der Test ist **ergebnislos, nicht widerlegend.** (Frühere Notiz „Producer warm gehalten →
Ansatz tot" war voreilig und ist zurückgezogen.)
**CPU-Spike 106 % war transient.** Direkt nach Disconnect→Reconnect kurz 106 %, danach
von selbst auf **23 %** zurück (`docker stats AppRobotGo2RTC`). `curl /api/streams` direkt
danach zeigte einen **sauberen Zustand**: je **1** mjpeg-Producer (`-c:v mjpeg`, 640×480)
+ **1** Consumer pro Kamera — **kein** doppelter Producer, **kein** H.264. Der Test hat
**keinen kaputten State hinterlassen**; cam0/cam1 unangetastet. Read-only-Pfad bestätigt.
**Warum nie 0 Consumer? — vor dem Weiterbauen klären:**
- **Verdacht A — zweiter Consumer:** weiterer Browser-Tab, oder die go2rtc-Debug-UI auf
`:1984` zeigte cam0 (= persistenter Consumer). Frühes Monitor-Log zeigte `consumers → 2`.
- **Verdacht B — Abmelde-Lag:** `el.remove()` schließt die WS (Browser-Log zeigt
`stream.onclose`), go2rtc meldet den Consumer aber nicht (rechtzeitig) als entfernt.
- **Datenquelle:** das `samples`-Array der Antwort (Consumer-Zahl alle 200 ms) zeigt es
exakt — beim nächsten Lauf auswerten (F12-Console: geloggtes `release-test JSON` aufklappen).
**Nächster Schritt (tragfähig):** sicherstellen, dass **nur ein** Consumer total existiert
(alle anderen Tabs + go2rtc-UI schließen) und der Feed auf Klick **vollständig** beendet
wird, dann neu messen. Erst wenn die Consumer-Zahl nachweislich auf 0 fällt (`samples`
zeigt `consumers: 0`), ist die Linchpin-Frage beantwortbar. Fällt sie dann auf 0 und der
Producer stoppt → `freed: true` → Phase 2. Stoppt der Producer trotz 0 Consumern nicht →
*dann erst* ist der Ansatz widerlegt → Weg A (separate Kamera, `04_*`).
## Zweiter Live-Test (2026-06-04) — ✅ Phase 1 abgeschlossen
Kamera: cam1. Alle anderen Consumer geschlossen (offener Tab an unerwartetem Ort gefunden
und geschlossen). Test mit einem einzigen Browser-Tab durchgeführt.
**Ergebnis:**
```json
{ "id": "cam1", "freed": true, "msUntilFree": 0,
"zeroConsumerAt": 4850, "producerStoppedAt": 4850 }
```
**Was das bedeutet:**
- **`freed: true` → Linchpin hält.** go2rtc gibt das Gerät frei, sobald der letzte
Consumer weg ist. Der Grundansatz ist tragfähig.
- **`msUntilFree: 0`** — Producer stoppt **gleichzeitig** mit dem letzten Consumer-Abgang
(kein „warm halten"). Die Pause für Phase 2 kann sehr kurz gewählt werden.
- **`zeroConsumerAt: 4850ms`** — es dauert ~5 s, bis go2rtc den WS-Consumer nach
`el.remove()` als entfernt registriert. Das ist der Wert, der in Schritt 3 als Pause
gebraucht wird: **Browser muss cam0 loslassen und dann ~5 s warten**, bevor der
Hi-Res-Grab starten kann. (Nicht 4 s wie geraten — real ~5 s, vermutlich ein go2rtc
internen Timeout.)
**Bug 1 — Stream blieb schwarz (behoben, Ursache: zweiter Tab):**
`startStream()` im `finally` feuerte `stream.onopen`, danach sofort `ondisconnect →
Video-Fehler: MEDIA_ELEMENT_ERROR: Empty src attribute → onclose`. Ursache war ein
**zweiter offener Browser-Tab** an unerwartetem Ort. Mit genau einem Tab **kommt der
Stream korrekt in der 640er-Auflösung zurück.** Kein Code-Fix nötig.
**Bug 2 — CPU 106 % nach Test (behoben, Ursache: zweiter Tab):**
Direkt nach dem ersten Test 106 %, erforderte Container-Recreate. Nach Schließen des
zweiten Tabs und erneutem Test: CPU geht danach auf ~40 % zurück, stabil. Ursache war
der zweite Consumer, der go2rtc in einen unklaren Zustand beim Reconnect trieb.
**Mit einem Tab: kein CPU-Problem. Kein Code-Fix nötig für Single-Operator-Betrieb.**
> ⚠ Einschränkung bleibt: **Immer nur ein Tab/Client pro Kamera**, wenn der HD-Test
> läuft — sonst fällt der Consumer-Count nie auf 0 und der Test schlägt fehl. Für
> Single-Operator-Betrieb (Button auf Anforderung) ist das akzeptiert.
---
## Phase 2 — Ergebnis (2026-06-04)
### ✅ Funktioniert
- HD-Grab pro Kamera via HD-Button: **76071 bytes, echter 1280×960-Frame** bestätigt
- Freeze-Canvas zeigt echten 640er-Frame (via `/api/snapshot/:id`, robust im MJPEG-Modus)
- Stream erholt sich nach Grab korrekt (Live zurück, ~35 % CPU stabil)
- Mutex verhindert parallele Grabs
### 🐛 Bug gefunden + behoben: `_hires`-Filter
`/api/snapshot`-Liste enthielt alle go2rtc-Streams inkl. `cam0_hires`/`cam1_hires`.
Folge: Viewer baute Live-Boxen für Hires-Streams → go2rtc versuchte Geräte zu öffnen
→ „Resource busy" (cam0/cam1 hielten sie bereits).
Fix in `snapshotService.js`: `.filter(id => !id.endsWith('_hires'))` auf die Kameraliste.
Danach: nur cam0/cam1 im Viewer, `_hires`-Streams bleiben dormant. ✓
### Offener Punkt: „Snapshot alle" mit HD
Aktuell: Button lädt 640er-Frames aller Kameras herunter.
Gewünscht: HD-Grabs für alle Kameras synchron auslösen.
**Machbarkeit:** sicher und möglich. Cam0 und cam1 liegen auf getrennten Geräten
(`/dev/video0` ≠ `/dev/video2`) → parallele Grabs ohne Geräte-Konflikt.
Nötige Änderung: globalen `hiresLock` durch per-Kamera-Locks ersetzen (`{ cam0: false, cam1: false }`).
Blackout: beide Kameras ~810 s gleichzeitig (für Homing besser als versetzt).
**Noch nicht implementiert — wartet auf Entscheid.**
---
## PHASE 2 — Den Hi-Res-Grab ergänzen (Schritt 4 + 5)
Phase 1 hat `freed: true` geliefert. **Phase 2 implementiert (s.o.).**
Realer Pausenwert aus der Messung: `zeroConsumerAt: 4850 ms` → Schritt 3/5 mit **5 s**
planen (statt der geratenen 4 s).
### Vorbereitung (Config, per Redeploy nicht zur Laufzeit)
`docker-compose.yaml`, go2rtc-`streams` ergänzen:
```yaml
cam0_hires: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
cam1_hires: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
```
- `#video=mjpeg` (Re-Encode) ist ok läuft nur ~12 s pro Grab. (`#video=copy` ist
laut `04_*` auf dieser Kamera tot.)
- **Präzondition prüfen:** Nach Redeploy via `/api/streams` bestätigen, dass
`cam0_hires` **dormant** ist (kein laufender Producer, solange niemand es anfragt).
Sonst würde es beim Start das Gerät greifen und mit `cam0` kollidieren.
### Node-Endpunkt `GET /api/snapshot/:id/hires` (Phase-2-Variante)
Voraussetzung: der **Client hat cam0 bereits losgelassen** (Browser-Dance wie Phase 1).
Ablauf im Endpunkt:
1. Mutex setzen (kein paralleler Grab).
2. (optional) via `/api/streams` verifizieren: `cam0` hat 0 Consumer → sonst abbrechen
(Gerät noch belegt).
3. `sleep(msUntilFree)` Gerät freigeben lassen.
4. **Grab mit Warmup** (robuste Variante):
- Kurz `cam0_hires` als Stream konsumieren (z. B. `GET /api/stream.mjpeg?src=cam0_hires`)
für ~1,5 s, damit die Kamera-Belichtung einschwingt und der Producer warm bleibt.
- Den **letzten** Frame behalten, der `Breite ≥ 1000 px` **und** nicht „zu klein/
schwarz" ist (Warmup-Schutz, vgl. das frühere 1-KB-Schwarzbild).
- Einfachere Alternative: `GET /api/frame.jpeg?src=cam0_hires` mit Retry (mehrfach,
bis Breite ≥1000 px und plausible Größe).
5. Consumer von `cam0_hires` beenden → Gerät frei.
6. Mutex lösen. JPEG (1280×960) zurückgeben.
**Client** nach Antwort: Canvas weg, `<video-stream>` cam0 wieder einsetzen (Schritt 6).
### Robustheit (Pflicht)
- **`finally`/Recovery:** Egal was schiefgeht der Client MUSS am Ende wieder auf `cam0`
hängen. Da `cam0` nie verändert wurde, reicht „wieder anhängen" zur vollen Erholung.
- **Timeout** auf den Grab (z. B. 8 s) → sonst Fehler + Recovery.
- **Mutex**: nie zwei Grabs gleichzeitig (würde zwei 1280-Producer = Gerätekonflikt
provozieren).
---
## Platzhalter-Detail (Canvas „HD Image Work")
- Beim Umhängen den aktuellen Frame des `<video>`-Elements per
`canvasCtx.drawImage(video, …)` einfrieren.
- Text „HD Image Work" unten rechts, ca. 30 % der Bildbreite, mit halbtransparentem
Hintergrund (Lesbarkeit).
- Canvas über/anstelle des gestoppten `<video-stream>` zeigen.
- Nach Schritt 6 Canvas entfernen.
- Rein clientseitig go2rtc sieht davon nichts.
---
## Offene Punkte / Risiken (ehrlich)
| Punkt | Status | Umgang |
|-------|--------|--------|
| **Gibt go2rtc das Gerät frei + wie schnell?** | **ungeklärt Linchpin** | **Phase 1 misst es.** Erst danach Phase 2. |
| Warmup-Schwarzbild bei 1280 | bekannt | kurz konsumieren + Breiten/Größen-Check + Retry |
| Mehrere gleichzeitige Zuschauer | Einschränkung | Gerät wird nur frei, wenn **alle** cam0 loslassen. Für 1 Operator + Button ok; Multi-Client bräuchte ein Broadcast-Signal „alle auf Platzhalter". |
| `cam0_hires` greift Gerät schon beim Start? | zu prüfen | nach Redeploy via `/api/streams` bestätigen, dass es dormant ist |
| Fehler mitten in der Sequenz | beherrschbar | `finally` → Client immer zurück auf cam0; Worst case go2rtc-Restart, cam0-Definition bleibt heil |
| Orchestrierung Client↔Server | Komplexität | klare Reihenfolge: Client löst cam0 → ruft Endpunkt → Endpunkt wartet+grabt → Client hängt zurück |
---
## Käme das so hin? — kurz
**Ja.** Der Ablauf 16 mit anpassbaren Pausen ist tragfähig, **wenn** der Linchpin
(Geräte-Freigabe nach Consumer-Verlust) hält — und genau das beweist Phase 1, ohne
cam0 anzufassen und ohne einen einzigen schreibenden go2rtc-Aufruf. Zwei Vereinfachungen
gegenüber der ersten Skizze: der Platzhalter ist clientseitig (kein eigener Stream),
und zur Laufzeit wird go2rtc nur **gelesen**, nie verändert.
**Reihenfolge:** Phase 1 (messen, ~null Risiko) → Pausen aus der Messung setzen →
Phase 2 (Grab). Fällt Phase 1, bleibt Weg A (separate Kamera) aus `04_*` der sichere
Fallback.
---
---
# ✅ Node-MJPEG-Schalter (2026-06-05) — maßgebliche Architektur
> Ersetzt den gesamten go2rtc-Ansatz oben. Bei Widerspruch gilt dieser Abschnitt.
## Kernidee: Node besitzt die Kamera selbst
Das 106%-Race entstand, weil **zwei** FFmpeg (Live 640 + HD 1280) gleichzeitig auf
**demselben** `/dev/videoN` liefen, und go2rtcs API nicht zuverlässig melden konnte, wann
ein FFmpeg das Gerät freigibt. **Lösung:** Node startet die FFmpeg-Prozesse selbst → das
`close`-Event des Kindprozesses ist der harte Beweis „Prozess weg ⇒ Kernel-FD geschlossen
⇒ Gerät frei". Race konstruktiv ausgeschlossen, nicht über Timing entschärft.
```
go2rtc ── ENTFERNT
Node (server.js)
├─ CameraSwitch cam0 ── besitzt /dev/video0 ── EIN FFmpeg (Live ODER HD)
├─ CameraSwitch cam1 ── besitzt /dev/video2 ── EIN FFmpeg (Live ODER HD)
├─ /api/stream/<id> ── MJPEG multipart/x-mixed-replace → Browser <img>
└─ /api/snapshot/<id> ── 640 aus RAM · /<id>/hires → HD-Grab über den Schalter
```
## Der Schalter (`src/cameraSwitch.js`)
Eine `CameraSwitch`-Instanz pro Gerät — der **einzige** Öffner von `/dev/videoN`. Hält
immer nur **einen** FFmpeg. Zustände `stopped | live | grabbing`, Mutex pro Kamera.
- **Live (Dauerbetrieb):** `ffmpeg -f v4l2 -input_format mjpeg -video_size 640x480
-framerate 30 -i /dev/videoN -c:v copy -bsf:v mjpeg2jpeg -f mpjpeg pipe:1` (Default
`ENCODE_MODE=copybsf`). Node parst die mpjpeg-Frames (Content-Length-basiert), hält den
letzten (für `/api/snapshot`), sendet sie an alle Stream-Clients. Crash → Restart 1,5 s.
> ⚠ **Der `mjpeg2jpeg`-Bitstream-Filter ist Pflicht.** Plain `-c:v copy` (ohne Filter)
> ist auf dieser Kamera tot: **107% CPU + Hang** (04/09), weil das Kamera-MJPEG die
> JPEG-Tables weglässt und der mpjpeg-Muxer sich verschluckt. **Auf dem Host getestet
> (2026-06-05):** `copy -bsf:v mjpeg2jpeg` läuft sauber (der „APP fields"-Hinweis ist
> eine einmalige Probe-Warnung) → kein Transcode → CPU < Re-Encode, browser-valide JPEGs.
> Fallback `ENCODE_MODE=mjpeg` = Re-Encode ~50% (go2rtcs `#video=mjpeg`).
> **Lehrgeld 2026-06-05:** erst `copy` ohne Filter ausgeliefert (107%), dann via Host-
> Messung auf `copy+mjpeg2jpeg` korrigiert.
- **HD-Grab (`grabHires`):** Live-FFmpeg `SIGTERM` → **auf `close` warten** (FD frei) →
1280-FFmpeg, warmlaufen (ab Frame ≥6, ≥15 KB, Breite ≥1000), besten Frame greifen →
beenden, auf `close` warten → `finally`: **immer** Live zurück (Live hat Priorität).
- **Blackout:** `<img>` friert ~13 s ein, läuft dann weiter. **Kein Client-Handling
nötig** (das war früher die Fehlerquelle).
## Auslieferung / Multi-User
`/api/stream/<id>` = `multipart/x-mixed-replace`; ein FFmpeg → Fan-out an N Clients.
Backpressure: voller Socket-Puffer (>1 MB) eines langsamen Clients → Frames für ihn
droppen, andere bleiben flüssig. Clients halten **kein** Gerät → **Multi-User gelöst.**
## Konfiguration (`docker-compose.yaml`)
Ein Node-Container mit FFmpeg, Geräte durchgereicht, `group_add: video`. Env-Overrides:
`DEV0/DEV1`, `LIVE_SIZE/LIVE_FPS`, `HIRES_SIZE/HIRES_FPS`. Firewall: nur noch **TCP 8444**.
## Latenz-Tuning (2026-06-05)
Gemessen ~340 ms Kamera→Browser. Gegenmaßnahmen (verlustarm, Lost Frames erlaubt):
- **FFmpeg Live:** `-fflags nobuffer` (Input nicht puffern) + `-flush_packets 1` (jedes
Frame sofort aus dem Muxer in die Pipe).
- **Node-Stream:** `socket.setNoDelay(true)` (Nagle aus) + `cork/uncork` um Header+JPEG+
Trailer → ein TCP-Segment pro Frame, sofort gesendet.
- Backpressure droppt Frames für langsame Clients statt zu puffern → Latenz steigt nicht.
- Weitere Hebel, falls nötig: `LIVE_FPS` runter ändert die Latenz NICHT (nur Buffering),
aber `HIRES_FPS` etc. egal hier. Browser-`<img>` fügt ~1 Frame Anzeige-Latenz dazu.
## On-Demand (2026-06-05, umgesetzt)
Live-FFmpeg läuft nur, solange Verbraucher da sind (Stream-Clients oder ein laufender
Snapshot). `acquire()`/`release()` zählen Verbraucher; nach dem letzten + `IDLE_GRACE_MS`
(15 s) Stop → **0 % idle**. `/api/snapshot` (`getFrame()`) startet die Kamera bei Bedarf
und wartet auf ein frisches Bild (`latest` wird beim Stop genullt → kein stale Frame).
Reconnect innerhalb der Karenz hält Live (kein Thrashing). Abschaltbar: `ON_DEMAND=false`.
## Verifiziert vs. offen
- **Lokal verifiziert (ohne Kamera):** MJPEG-Parser (Unittest, Chunk-robust, `\r\n\r\n`
im Body); HTTP-Routing (snapshot/stream/health, 404/503); On-Demand-Lebenszyklus
(acquire/release/idle-Stop/Reconnect-Karenz/getFrame/always-on) per Mock-Unittest.
- **FFmpeg Default `copybsf`** = `-c:v copy -bsf:v mjpeg2jpeg` (Bitstream-Copy, kein
Transcode; auf dem Host getestet). Fallback `ENCODE_MODE=mjpeg` (~50%, Re-Encode).
- **✅ Auf der Hardware bestätigt (User, 2026-06-05):**
- **Latenz 139 ms** Kamera→Browser (vorher 340 ms) — nach `nobuffer`/`flush_packets`/`setNoDelay`/`cork`.
- **CPU ~5 % idle** (On-Demand, keine Clients), ~35 %/Kamera beim aktiven Streamen (copybsf).
- **HD-Grab beider Kameras parallel:** je echtes 1280×960-JPEG (~133 KB) in ~2,3 s. Live kehrt sauber zurück.
- **Login/Logout + Screenshot+Reconnect:** kein 106%-Race mehr.
- **Bekanntes Restproblem (niedrige Prio):** ein Live-Stream ist einmal eingefroren
(Einzelfall, akzeptiert). Verdacht: gedroppte/abgebrochene multipart-Verbindung, die
nicht von selbst reconnectet. Später prüfen: clientseitiger Watchdog (Frame-Timeout →
`img.src` neu setzen) bzw. ein abgebrochener `onFrame`-Write, der `cleanup()` auslöst,
ohne dass der Browser neu verbindet.
## Hardware-Testplan
1. Code syncen, Stack neu deployen (Image baut FFmpeg ein — erster Build dauert länger).
2. Viewer öffnen → beide Kameras Live (`MJPEG · live`). **CPU messen** (Erwartung < 50 %).
3. **Bug-Reproweg:** Anmelden → „HD" → Download → Stream nach kurzem Freeze weiter →
**neu anmelden / Tab neu laden.** Erwartung: **keine 106%, kein Dauer-Freeze.**
4. Zwei Browser gleichzeitig → „HD" während beide verbunden → **kein 503** (Multi-User).
5. HD-Bild: 1280×960, nicht schwarz. Blackout-Dauer notieren.
6. Eine Kamera abziehen → Log rate-limitierter Restart, andere Kamera + Node unberührt.
`docker logs AppRobotWebcam` zeigt jeden Zustandswechsel des Schalters.
## Rollback
`git checkout <commit-vor-umbau> -- docker-compose.yaml server.js package.json public/ src/`
(der go2rtc-Stand liegt vollständig in der Git-Historie).
## Mögliche Folgeschritte
- **Auflösungen nativ MJPG?** ✅ **Bestätigt (2026-06-05)** via `v4l2-ctl --list-formats-ext
-d /dev/video0`: Kamera liefert `MJPG` in **640×480 @ 30 fps** (Live) UND **1280×960 @
30 fps** (HD). ABER: trotz nativem MJPG ist `-c:v copy` auf dieser Kamera tot (107%,
APP-Feld-Fehler) → **`-c:v mjpeg` (Re-Encode)**. (Optional `HIRES_FPS=30` verkürzt den
Warmup leicht.)
- **CPU:** `copybsf` (Bitstream-Copy) ist Default und liefert 69 % für 2 Kameras (gemessen).
Weiter drücken nur falls nötig: `LIVE_FPS=15`. Fallback bei Problemen: `ENCODE_MODE=mjpeg`.
- **On-Demand Live:** ✅ umgesetzt (s. o.) — Idle-CPU 0 statt ~60 %. Per `ON_DEMAND=false`
abschaltbar, falls je ein dauerhaft warmer Stream gewünscht ist.