11 KiB
AppRobotWebcam – Hi-Res-Snapshot via Consumer-Umhängen
Status: Phase 1 implementiert (Code steht, Messung an der Live-Instanz steht noch aus). Phase 2 weiterhin Konzept. 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)
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).- Wird der Producer nicht gestoppt (
freed: false): go2rtc hält das Gerät warm → Ansatz so nicht tragfähig → prüfen, ob ein go2rtc-Setting das Verhalten ändert, sonst zurück zu Weg A (separate Kamera, siehe04_*).
⚠ 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.
PHASE 2 — Den Hi-Res-Grab ergänzen (Schritt 4 + 5)
Nur starten, wenn Phase 1 freed: true geliefert hat.
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.