310 lines
16 KiB
Markdown
310 lines
16 KiB
Markdown
# 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** ~8–10 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 ~1–2 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 1–6 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.
|