Files
appRobotWebcam/doc/05_screenShot_roadmap.md
2026-06-05 07:32:05 +02:00

26 KiB
Raw Blame History

ABGELÖST (2026-06-05) — dieser Ansatz war die Ursache des 106%-Bugs

Der unten beschriebene Consumer-Umhängen-Ansatz mit go2rtc (cam0 loslassen → go2rtc gibt Gerät frei → cam0_hires greifen) hat sich als prinzipiell racy erwiesen: go2rtcs API kann nicht zuverlässig melden, wann FFmpeg /dev/videoN freigibt → zwei Encoder auf einem Gerät → 106% CPU + Freeze (siehe 09_Bug_reports.md).

Aktuelle, maßgebliche Architektur: Node-MJPEG-Schalter, go2rtc entfernt. Node besitzt die Kameras selbst; das close-Event des eigenen FFmpeg ist der harte Beweis „Gerät frei". Das Race ist damit konstruktiv ausgeschlossen.

alt (unten, abgelöst) neu (maßgeblich)
Geräte-Öffner go2rtc Node src/cameraSwitch.js
Live go2rtc-WS + video-stream.js MJPEG multipart → <img>
HD-Grab 2. go2rtc-Stream cam_hires (Race) Schalter: Live stoppen (close=FD frei) → 1280 → zurück
Multi-User brach gelöst (ein FFmpeg → Fan-out)

→ Neue Architektur + Hardware-Testplan stehen weiter unten in diesem Dokument (Abschnitt „## Node-MJPEG-Schalter"). Alles ab hier bis dorthin ist Historie.


AppRobotWebcam Hi-Res-Snapshot via Consumer-Umhängen (historisch)

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 <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 — 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 ~810 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 ~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.



Node-MJPEG-Schalter (2026-06-05) — maßgebliche Architektur

Ersetzt den gesamten go2rtc-Ansatz oben. Bei Widerspruch gilt dieser Abschnitt.

Kernidee: Node besitzt die Kamera selbst

Das 106%-Race entstand, weil zwei FFmpeg (Live 640 + HD 1280) gleichzeitig auf demselben /dev/videoN liefen, und go2rtcs API nicht zuverlässig melden konnte, wann ein FFmpeg das Gerät freigibt. Lösung: Node startet die FFmpeg-Prozesse selbst → das close-Event des Kindprozesses ist der harte Beweis „Prozess weg ⇒ Kernel-FD geschlossen ⇒ Gerät frei". Race konstruktiv ausgeschlossen, nicht über Timing entschärft.

go2rtc  ── ENTFERNT
Node (server.js)
  ├─ CameraSwitch cam0  ── besitzt /dev/video0 ── EIN FFmpeg (Live ODER HD)
  ├─ CameraSwitch cam1  ── besitzt /dev/video2 ── EIN FFmpeg (Live ODER HD)
  ├─ /api/stream/<id>   ── MJPEG multipart/x-mixed-replace → Browser <img>
  └─ /api/snapshot/<id> ── 640 aus RAM · /<id>/hires → HD-Grab über den Schalter

Der Schalter (src/cameraSwitch.js)

Eine CameraSwitch-Instanz pro Gerät — der einzige Öffner von /dev/videoN. Hält immer nur einen FFmpeg. Zustände stopped | live | grabbing, Mutex pro Kamera.

  • Live (Dauerbetrieb): ffmpeg -f v4l2 -input_format mjpeg -video_size 640x480 -framerate 30 -i /dev/videoN -c:v copy -bsf:v mjpeg2jpeg -f mpjpeg pipe:1 (Default ENCODE_MODE=copybsf). Node parst die mpjpeg-Frames (Content-Length-basiert), hält den letzten (für /api/snapshot), sendet sie an alle Stream-Clients. Crash → Restart 1,5 s.

    Der mjpeg2jpeg-Bitstream-Filter ist Pflicht. Plain -c:v copy (ohne Filter) ist auf dieser Kamera tot: 107% CPU + Hang (04/09), weil das Kamera-MJPEG die JPEG-Tables weglässt und der mpjpeg-Muxer sich verschluckt. Auf dem Host getestet (2026-06-05): copy -bsf:v mjpeg2jpeg läuft sauber (der „APP fields"-Hinweis ist eine einmalige Probe-Warnung) → kein Transcode → CPU < Re-Encode, browser-valide JPEGs. Fallback ENCODE_MODE=mjpeg = Re-Encode ~50% (go2rtcs #video=mjpeg). Lehrgeld 2026-06-05: erst copy ohne Filter ausgeliefert (107%), dann via Host- Messung auf copy+mjpeg2jpeg korrigiert.

  • HD-Grab (grabHires): Live-FFmpeg SIGTERMauf close warten (FD frei) → 1280-FFmpeg, warmlaufen (ab Frame ≥6, ≥15 KB, Breite ≥1000), besten Frame greifen → beenden, auf close warten → finally: immer Live zurück (Live hat Priorität).
  • Blackout: <img> friert ~13 s ein, läuft dann weiter. Kein Client-Handling nötig (das war früher die Fehlerquelle).

Auslieferung / Multi-User

/api/stream/<id> = multipart/x-mixed-replace; ein FFmpeg → Fan-out an N Clients. Backpressure: voller Socket-Puffer (>1 MB) eines langsamen Clients → Frames für ihn droppen, andere bleiben flüssig. Clients halten kein Gerät → Multi-User gelöst.

Konfiguration (docker-compose.yaml)

Ein Node-Container mit FFmpeg, Geräte durchgereicht, group_add: video. Env-Overrides: DEV0/DEV1, LIVE_SIZE/LIVE_FPS, HIRES_SIZE/HIRES_FPS. Firewall: nur noch TCP 8444.

Latenz-Tuning (2026-06-05)

Gemessen ~340 ms Kamera→Browser. Gegenmaßnahmen (verlustarm, Lost Frames erlaubt):

  • FFmpeg Live: -fflags nobuffer (Input nicht puffern) + -flush_packets 1 (jedes Frame sofort aus dem Muxer in die Pipe).
  • Node-Stream: socket.setNoDelay(true) (Nagle aus) + cork/uncork um Header+JPEG+ Trailer → ein TCP-Segment pro Frame, sofort gesendet.
  • Backpressure droppt Frames für langsame Clients statt zu puffern → Latenz steigt nicht.
  • Weitere Hebel, falls nötig: LIVE_FPS runter ändert die Latenz NICHT (nur Buffering), aber HIRES_FPS etc. egal hier. Browser-<img> fügt ~1 Frame Anzeige-Latenz dazu.

On-Demand (2026-06-05, umgesetzt)

Live-FFmpeg läuft nur, solange Verbraucher da sind (Stream-Clients oder ein laufender Snapshot). acquire()/release() zählen Verbraucher; nach dem letzten + IDLE_GRACE_MS (15 s) Stop → 0 % idle. /api/snapshot (getFrame()) startet die Kamera bei Bedarf und wartet auf ein frisches Bild (latest wird beim Stop genullt → kein stale Frame). Reconnect innerhalb der Karenz hält Live (kein Thrashing). Abschaltbar: ON_DEMAND=false.

Verifiziert vs. offen

  • Lokal verifiziert (ohne Kamera): MJPEG-Parser (Unittest, Chunk-robust, \r\n\r\n im Body); HTTP-Routing (snapshot/stream/health, 404/503); On-Demand-Lebenszyklus (acquire/release/idle-Stop/Reconnect-Karenz/getFrame/always-on) per Mock-Unittest.
  • FFmpeg Default copybsf = -c:v copy -bsf:v mjpeg2jpeg (Bitstream-Copy, kein Transcode; auf dem Host getestet). Fallback ENCODE_MODE=mjpeg (~50%, Re-Encode).
  • Auf der Hardware bestätigt (User, 2026-06-05):
    • Latenz 139 ms Kamera→Browser (vorher 340 ms) — nach nobuffer/flush_packets/setNoDelay/cork.
    • CPU ~5 % idle (On-Demand, keine Clients), ~35 %/Kamera beim aktiven Streamen (copybsf).
    • HD-Grab beider Kameras parallel: je echtes 1280×960-JPEG (~133 KB) in ~2,3 s. Live kehrt sauber zurück.
    • Login/Logout + Screenshot+Reconnect: kein 106%-Race mehr.
  • Bekanntes Restproblem (niedrige Prio): ein Live-Stream ist einmal eingefroren (Einzelfall, akzeptiert). Verdacht: gedroppte/abgebrochene multipart-Verbindung, die nicht von selbst reconnectet. Später prüfen: clientseitiger Watchdog (Frame-Timeout → img.src neu setzen) bzw. ein abgebrochener onFrame-Write, der cleanup() auslöst, ohne dass der Browser neu verbindet.

Hardware-Testplan

  1. Code syncen, Stack neu deployen (Image baut FFmpeg ein — erster Build dauert länger).
  2. Viewer öffnen → beide Kameras Live (MJPEG · live). CPU messen (Erwartung < 50 %).
  3. Bug-Reproweg: Anmelden → „HD" → Download → Stream nach kurzem Freeze weiter → neu anmelden / Tab neu laden. Erwartung: keine 106%, kein Dauer-Freeze.
  4. Zwei Browser gleichzeitig → „HD" während beide verbunden → kein 503 (Multi-User).
  5. HD-Bild: 1280×960, nicht schwarz. Blackout-Dauer notieren.
  6. Eine Kamera abziehen → Log rate-limitierter Restart, andere Kamera + Node unberührt.

docker logs AppRobotWebcam zeigt jeden Zustandswechsel des Schalters.

Rollback

git checkout <commit-vor-umbau> -- docker-compose.yaml server.js package.json public/ src/ (der go2rtc-Stand liegt vollständig in der Git-Historie).

Mögliche Folgeschritte

  • Auflösungen nativ MJPG? Bestätigt (2026-06-05) via v4l2-ctl --list-formats-ext -d /dev/video0: Kamera liefert MJPG in 640×480 @ 30 fps (Live) UND 1280×960 @ 30 fps (HD). ABER: trotz nativem MJPG ist -c:v copy auf dieser Kamera tot (107%, APP-Feld-Fehler) → -c:v mjpeg (Re-Encode). (Optional HIRES_FPS=30 verkürzt den Warmup leicht.)
  • CPU: copybsf (Bitstream-Copy) ist Default und liefert 69 % für 2 Kameras (gemessen). Weiter drücken nur falls nötig: LIVE_FPS=15. Fallback bei Problemen: ENCODE_MODE=mjpeg.
  • On-Demand Live: umgesetzt (s. o.) — Idle-CPU 0 statt ~60 %. Per ON_DEMAND=false abschaltbar, falls je ein dauerhaft warmer Stream gewünscht ist.