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

16 KiB
Raw Blame History

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. staterunning) → 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.:
    { "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:

{ "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:

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.