diff --git a/cameras.json b/cameras.json index 26ddfcf..eaa7d27 100644 --- a/cameras.json +++ b/cameras.json @@ -28,7 +28,8 @@ "stream": true, "hires": true, "note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0", - "hiresSize": "1920x1080" + "hiresSize": "1920x1080", + "hiresEncode": "mjpeg" } ] } diff --git a/doc/05_screenShot_roadmap.md b/doc/05_screenShot_roadmap.md index 89186d7..06fbc3d 100644 --- a/doc/05_screenShot_roadmap.md +++ b/doc/05_screenShot_roadmap.md @@ -1,483 +1,204 @@ -> # ⛔ 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 → `` | -> | 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 – Snapshot & HD-Grab ---- - -# 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). +> Status: **Implementiert**. Architektur seit 2026-06-05 (Node-MJPEG-Schalter). +> Gemessene Werte: Latenz 139 ms, ~5 % idle, ~35 %/Kamera aktiv, HD-Grab ~2–3 s. --- ## 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 `` -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) +Eine `CameraSwitch`-Instanz pro physischem Gerät — der einzige Öffner von `/dev/videoN`. +Hält immer nur **einen** FFmpeg: entweder Live **oder** Grab. Nie beide. ``` -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 +cameras.json + └── server.js → CameraSwitch (besitzt /dev/videoN) + │ + ├── Live: ffmpeg → mpjpeg pipe → Fan-out an N Browser-Clients () + └── Grab: Live SIGTERM → close-Event = FD frei → hires-FFmpeg → zurück ``` -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. +Das `close`-Event des Kindprozesses ist der harte Beweis „Gerät frei" — kein Timing, +keine Polls. Das Race (zwei Encoder auf einem Gerät) ist konstruktiv ausgeschlossen. --- -## Der eine Dreh- und Angelpunkt (= warum Phase 1 zuerst kommt) +## Live-Stream -Der ganze Ansatz steht und fällt mit **einer** Annahme: +``` +ffmpeg -fflags nobuffer -f v4l2 -input_format mjpeg + -video_size -framerate + -i /dev/videoN + -c:v copy -bsf:v mjpeg2jpeg ← copybsf (Default) + -f mpjpeg -flush_packets 1 pipe:1 +``` -> **Gibt go2rtc das Gerät frei, wenn `cam0` den letzten Zuschauer verliert — und wie -> schnell?** +| Encode-Modus | CPU | Wann | +|---|---|---| +| `copybsf` (Default) | ~35 %/Kamera | Kamera liefert natives MJPEG | +| `mjpeg` (Fallback) | ~50 %/Kamera | Re-Encode, falls copybsf Probleme macht | -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.** +**`mjpeg2jpeg` ist Pflicht bei copybsf** — Kamera-MJPEG lässt JPEG-Huffman-Tabellen +weg; ohne den Filter verschluckt sich der mpjpeg-Muxer (107 % CPU + Hang). + +**Latenz-Tuning:** `-fflags nobuffer` + `-flush_packets 1` + `socket.setNoDelay(true)` + +`cork/uncork` (Header+JPEG+Trailer = ein TCP-Segment). Gemessen: **139 ms** Kamera→Browser. + +**On-Demand:** Live läuft nur wenn Clients verbunden sind. `acquire()`/`release()` zählen +Verbraucher. Nach dem letzten + `IDLE_GRACE_MS` (15 s): Stop → **0 % idle**. Abschaltbar +via `ON_DEMAND=false`. --- -## PHASE 1 — Freigabe verifizieren (kein Grab, voll reversibel) +## HD-Grab (`grabHires`) -**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. +### Fall A — liveSize == hiresSize -### Umzusetzen +Wenn die Live-Auflösung bereits der gewünschten Hires-Auflösung entspricht (z.B. C920 +mit `liveSize: "1920x1080"`): kein Format-Wechsel nötig. `grabHires` ruft direkt +`getFrame()` auf dem laufenden Live-Stream auf. Kein Neustart, kein Übergangs-Problem. -**A. Viewer (`public/viewer.js`)** – Button „Hi-Res-Test (Phase 1)": -1. Aktuellen `cam0`-Frame auf ein `` zeichnen, „HD Image Work" einblenden, - Canvas anstelle des `` zeigen. -2. `` 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: `` für cam0 wieder einsetzen (Live zurück), Canvas weg. +``` +Kamera ──live 1920×1080──► getFrame() → JPEG 1920×1080 +``` -**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. `state` ≠ - `running`) → 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.: - ```json - { "freed": true, "msUntilFree": 1700, "samples": [...] } - ``` -5. Kein Schreibzugriff auf go2rtc. Nur Lesen. +### Fall B — liveSize ≠ hiresSize (C270: 640×480 → 1280×960) -### 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 - `` („HD Image Work"), entfernt den `` (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. +``` +1. Live-FFmpeg SIGTERM → warte auf close (= FD frei) +2. sleep(300ms) ← v4l2-Buffer leeren, Kamera-Reset abwarten +3. hires-FFmpeg bei hiresSize/hiresFps starten +4. Frames warmlaufen lassen (settleFrames=6) + minWidth-Check (90 % der Soll-Breite) +5. Ersten validen Frame nehmen → hires-FFmpeg SIGTERM +6. finally: Live-FFmpeg neu starten (immer, auch bei Fehler) +``` -### 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). +**Blackout:** Der `` friert ~2–3 s ein und läuft danach weiter. Kein Client-Handling nötig. -### 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. +**minWidth** wird automatisch aus `hiresSize` abgeleitet (`floor(hiresW × 0.9)`). +Damit werden Frames abgelehnt, die noch auf der alten Live-Auflösung liegen +(v4l2-Buffer-Reste vom vorherigen Format). --- -## Erster Live-Test (2026-06-04) — INCONCLUSIVE (nicht widerlegt, ersetzt durch zweiten) +## Kamera-spezifische Konfiguration -Antwort: `{ freed: false, zeroConsumerAt: null, producerStoppedAt: null }`. +### C920 (HD Pro Webcam) — liveSize = hiresSize -**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.) +Die C920 hat ein v4l2-Treiber-Eigenheit: nach einem Live-Stream bei 640×480 kann sie +beim Neustart auf 1920×1080 nicht sauber wechseln — der Treiber gibt 1280×720-Frames +zurück (Übergangs-Artefakt, trotz korrekter Format-Anforderung). -**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. +**Lösung:** `liveSize` und `hiresSize` identisch setzen. `grabHires` wechselt dann kein +Format — es liest direkt aus dem laufenden 1920×1080-Stream (Fall A). -**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). +```json +{ + "id": "cam2", + "liveSize": "1920x1080", + "liveFps": 15, + "hiresSize": "1920x1080" +} +``` -**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_*`). +### C270 — Standard (Fall B) -## Zweiter Live-Test (2026-06-04) — ✅ Phase 1 abgeschlossen +```json +{ + "id": "cam0", + "hiresSize": "1280x960" +} +``` -Kamera: cam1. Alle anderen Consumer geschlossen (offener Tab an unerwartetem Ort gefunden -und geschlossen). Test mit einem einzigen Browser-Tab durchgeführt. +Live bei 640×480, Grab bei 1280×960. Format-Wechsel + 300ms-Pause funktioniert. + +### Auflösung prüfen + +Nur MJPG-native Auflösungen in `liveSize`/`hiresSize` verwenden (kein Software-Encode): + +```bash +v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG +``` + +--- + +## API-Endpunkte + +| Endpunkt | Beschreibung | +|---|---| +| `GET /api/snapshot/:id` | 640er JPEG aus dem Live-Puffer (on-demand, sofort) | +| `GET /api/snapshot/:id/hires` | HD-JPEG via `grabHires` (2–3 s, Blackout) | +| `GET /api/stream/:id` | MJPEG multipart/x-mixed-replace (Live, Browser ``) | +| `GET /api/cameras` | Metadaten aller Kameras aus cameras.json | +| `GET /health` | Zustand aller CameraSwitch-Instanzen | + +--- + +## Bekannte Restprobleme + +| Problem | Priorität | Zustand | +|---|---|---| +| Stream friert selten dauerhaft ein | niedrig | Einzelfall; clientseitiger Watchdog (Frame-Timeout → `img.src` neu) noch nicht implementiert | +| `unable to decode APP fields` im Log (C270) | kosmetisch | Einmalige Probe-Warnung von FFmpeg, kein Auswirkung | + +--- + +--- + +## Architektur-Entwicklung (Historie) + +Dieser Abschnitt dokumentiert den Weg zur aktuellen Lösung. +Für die maßgebliche Implementierung gelten ausschliesslich die Abschnitte oben. + +### go2rtc-Ansatz (2026-06-04) — abgelöst + +#### Grundidee: Consumer-Umhängen + +go2rtc verwaltete die Kameras. Für HD-Grabs wurde der Live-Consumer (`cam0`) losgelassen, +damit go2rtc das Gerät freigibt, dann ein separater `cam0_hires`-Stream bei 1280×960 geöffnet. + +#### Phase 1 — Freigabe messen (2026-06-04, ✅ erfolgreich) + +Endpunkt `GET /api/snapshot/:id/release-test` pollt go2rtcs `/api/streams` alle 200 ms +und misst, wann nach Consumer-Verlust der Producer stoppt (= Gerät frei). **Ergebnis:** ```json -{ "id": "cam1", "freed": true, "msUntilFree": 0, - "zeroConsumerAt": 4850, "producerStoppedAt": 4850 } +{ "freed": true, "msUntilFree": 0, "zeroConsumerAt": 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.) +go2rtc gibt das Gerät sofort frei wenn der letzte Consumer weg ist. Kein „warm halten". +Die ~5 s (`zeroConsumerAt`) sind die WS-Abmeldelatenz von go2rtc selbst. -**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. +#### Phase 2 — HD-Grab implementiert (2026-06-04, ✅ funktionierte) -**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.** +HD-Grab lieferte echte 1280×960-Frames (~76 KB). Einschränkungen: +- Nur bei einem einzigen Viewer-Tab zuverlässig (bei mehreren fiel Consumer-Count nie auf 0) +- Sequenz: Browser löst cam0 → 5 s warten → Grab → Browser hängt zurück +- Blackout ~8–10 s (5 s Pause + Grab-Zeit) -> ⚠ 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. +#### Race-Bug entdeckt (2026-06-05) → go2rtc entfernt ---- +go2rtcs API konnte nicht zuverlässig melden, wann FFmpeg `/dev/videoN` freigibt. +Folge: zwei FFmpeg gleichzeitig auf demselben Gerät → **106 % CPU + Hang** (reproduzierbar). +Details: `09_Bug_reports.md`. -## Phase 2 — Ergebnis (2026-06-04) +**Erkenntnis:** Das Problem ist strukturell — go2rtc gibt keine harten Garantien über +den Gerätezustand. Der einzige zuverlässige Beweis „Gerät frei" ist das `close`-Event +des FFmpeg-Prozesses selbst. -### ✅ 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 +→ **Entscheidung 2026-06-05:** go2rtc entfernen. Node startet FFmpeg direkt. -### 🐛 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). +### Node-MJPEG-Schalter (2026-06-05) — aktuelle Architektur -Fix in `snapshotService.js`: `.filter(id => !id.endsWith('_hires'))` auf die Kameraliste. -Danach: nur cam0/cam1 im Viewer, `_hires`-Streams bleiben dormant. ✓ +Mit `src/cameraSwitch.js` besitzt Node die Kameras direkt. Das `close`-Event des eigenen +FFmpeg-Kindprozesses ist der harte Beweis „FD geschlossen = Gerät frei". Keine go2rtc- +Abhängigkeit, kein Race. -### 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. +**Sofort gemessene Werte:** +- Latenz: 139 ms (vorher ~340 ms mit go2rtc) +- CPU idle: ~5 % (On-Demand) +- CPU aktiv: ~35 %/Kamera (copybsf) +- HD-Grab beide Kameras parallel: ~2,3 s, je 1280×960 -**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: -```yaml -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=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, `` 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 `