Files
appRobotWebcam/doc/05_screenShot_roadmap.md
2026-06-04 20:43:19 +02:00

310 lines
16 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.
# AppRobotWebcam Hi-Res-Snapshot via Consumer-Umhängen
> Status: **Phase 1 abgeschlossen** (2026-06-04):
> **Linchpin beantwortet: `freed: true`.** go2rtc gibt das Gerät frei, sobald der letzte
> Consumer weg ist. Zwei Folgeprobleme blockieren Phase 2: Stream erholt sich nicht
> (bleibt schwarz), und go2rtc läuft nach dem Test auf 106 % (erfordert Container-Recreate).
> 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 — Den Hi-Res-Grab ergänzen (Schritt 4 + 5)
Phase 1 hat `freed: true` geliefert. **Phase 2 kann gestartet werden.**
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.