25 KiB
⛔ ABGELÖST (2026-06-05) — dieser Ansatz war die Ursache des 106%-Bugs
Der unten beschriebene Consumer-Umhängen-Ansatz mit go2rtc (
cam0loslassen → go2rtc gibt Gerät frei →cam0_hiresgreifen) hat sich als prinzipiell racy erwiesen: go2rtcs API kann nicht zuverlässig melden, wann FFmpeg/dev/videoNfreigibt → zwei Encoder auf einem Gerät → 106% CPU + Freeze (siehe09_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.jsLive go2rtc-WS + video-stream.jsMJPEG multipart → <img>HD-Grab 2. go2rtc-Stream cam_hires(Race)Schalter: Live stoppen ( close=FD frei) → 1280 → zurückMulti-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: 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.
✅ 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(DefaultENCODE_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 mjpeg2jpegläuft sauber (der „APP fields"-Hinweis ist eine einmalige Probe-Warnung) → kein Transcode → CPU < Re-Encode, browser-valide JPEGs. FallbackENCODE_MODE=mjpeg= Re-Encode ~50% (go2rtcs#video=mjpeg). Lehrgeld 2026-06-05: erstcopyohne Filter ausgeliefert (107%), dann via Host- Messung aufcopy+mjpeg2jpegkorrigiert. - HD-Grab (
grabHires): Live-FFmpegSIGTERM→ aufclosewarten (FD frei) → 1280-FFmpeg, warmlaufen (ab Frame ≥6, ≥15 KB, Breite ≥1000), besten Frame greifen → beenden, aufclosewarten →finally: immer Live zurück (Live hat Priorität). - Blackout:
<img>friert ~1–3 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/uncorkum 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_FPSrunter ändert die Latenz NICHT (nur Buffering), aberHIRES_FPSetc. 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\nim 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). FallbackENCODE_MODE=mjpeg(~50%, Re-Encode). - Auf der Hardware: CPU 69 % für 2 Kameras bestätigt (User, copybsf). Latenz nach den Flags oben + Bug-Reproweg noch gegenzumessen.
Hardware-Testplan
- Code syncen, Stack neu deployen (Image baut FFmpeg ein — erster Build dauert länger).
- Viewer öffnen → beide Kameras Live (
MJPEG · live). CPU messen (Erwartung < 50 %). - Bug-Reproweg: Anmelden → „HD" → Download → Stream nach kurzem Freeze weiter → neu anmelden / Tab neu laden. Erwartung: keine 106%, kein Dauer-Freeze.
- Zwei Browser gleichzeitig → „HD" während beide verbunden → kein 503 (Multi-User).
- HD-Bild: 1280×960, nicht schwarz. Blackout-Dauer notieren.
- 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 liefertMJPGin 640×480 @ 30 fps (Live) UND 1280×960 @ 30 fps (HD). ABER: trotz nativem MJPG ist-c:v copyauf dieser Kamera tot (107%, APP-Feld-Fehler) →-c:v mjpeg(Re-Encode). (OptionalHIRES_FPS=30verkü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=falseabschaltbar, falls je ein dauerhaft warmer Stream gewünscht ist.