17 KiB
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: siehe04_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)
cam0wird nie verändert. KeinPATCH/PUT/DELETEauf 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 voncam0_hiresin der Config (per Redeploy, nicht zur Laufzeit). - Kleiner Schadensradius: Geht etwas schief, ist die Erholung „Browser wieder auf
cam0hängen" → go2rtc startetcam0neu. 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
cam0den 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)":
- Aktuellen
cam0-Frame auf ein<canvas>zeichnen, „HD Image Work" einblenden, Canvas anstelle des<video-stream>zeigen. <video-stream>für cam0 entfernen/stoppen (das ist das „Umhängen" – cam0 verliert seinen Consumer).GET /api/snapshot/cam0/release-testaufrufen (neuer Node-Endpunkt, s.u.).- 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:
- Startzeit loggen.
GET ${go2rtc}/api/streamsalle 200 ms pollen (max. ~10 s).- Loggen:
- Wann erreicht
cam00 Consumer? - Wann ist der
cam0-Producer gestoppt (Feldproducersleer bzw.state≠running) → das ist der Proxy für „Gerät frei". - Dauer von „0 Consumer" → „Producer gestoppt" in ms.
- Wann erreicht
- Ergebnis ins Log schreiben und als JSON zurückgeben, z. B.:
{ "freed": true, "msUntilFree": 1700, "samples": [...] } - Kein Schreibzugriff auf go2rtc. Nur Lesen.
Umgesetzt am 2026-06-04
- Node:
GET /api/snapshot/:id/release-testinsrc/snapshotService.js– pollt/api/streamsalle 200 ms (max. 10 s), misstzeroConsumerAt/producerStoppedAt, liefert{ freed, msUntilFree, samples }. Rein lesend. Parser an den bestehendenserver.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 imfinallyimmer wieder auf Live zurück. - Messung an der Live-Instanz steht noch aus (Docker/go2rtc auf dem Server) – erst
diese liefert das echte
msUntilFreefür Schritt 3/5.
Erfolgskriterium Phase 1
- Log/JSON zeigt
freed: trueund eine konkretemsUntilFree. - 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, siehe04_*). Trat nicht ein.
⚠ Die genaue JSON-Form von
/api/streams(Felderproducers/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
:1984zeigte cam0 (= persistenter Consumer). Frühes Monitor-Log zeigteconsumers → 2. - Verdacht B — Abmelde-Lag:
el.remove()schließt die WS (Browser-Log zeigtstream.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: geloggtesrelease-test JSONaufklappen).
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:
{ "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 nachel.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:
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=copyist laut04_*auf dieser Kamera tot.)- Präzondition prüfen: Nach Redeploy via
/api/streamsbestätigen, dasscam0_hiresdormant ist (kein laufender Producer, solange niemand es anfragt). Sonst würde es beim Start das Gerät greifen und mitcam0kollidieren.
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:
- Mutex setzen (kein paralleler Grab).
- (optional) via
/api/streamsverifizieren:cam0hat 0 Consumer → sonst abbrechen (Gerät noch belegt). sleep(msUntilFree)– Gerät freigeben lassen.- Grab mit Warmup (robuste Variante):
- Kurz
cam0_hiresals 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 pxund nicht „zu klein/ schwarz" ist (Warmup-Schutz, vgl. das frühere 1-KB-Schwarzbild). - Einfachere Alternative:
GET /api/frame.jpeg?src=cam0_hiresmit Retry (mehrfach, bis Breite ≥1000 px und plausible Größe).
- Kurz
- Consumer von
cam0_hiresbeenden → Gerät frei. - 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 aufcam0hängen. Dacam0nie 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 percanvasCtx.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.