# AppRobotWebcam – Hi-Res-Snapshot via Consumer-Umhängen > 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 `` 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 `` zeichnen, „HD Image Work" einblenden, Canvas anstelle des `` zeigen. 2. `` 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: `` 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 `` („HD Image Work"), entfernt den `` (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 ~8–10 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 ~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, `` 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 `