From 39898e3a15ab25b9ba3e89b5a477f51ae2505e85 Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:07:29 +0200 Subject: [PATCH] Umbau mit cameraSwitch Fix Delay --- doc/05_screenShot_roadmap.md | 59 +++++++++++++++++--------- doc/09_Bug_reports.md | 28 ++++++++----- docker-compose.yaml | 4 ++ server.js | 7 +++- src/cameraSwitch.js | 80 ++++++++++++++++++++++++++++++++---- src/snapshotService.js | 43 ++++++++++++------- 6 files changed, 165 insertions(+), 56 deletions(-) diff --git a/doc/05_screenShot_roadmap.md b/doc/05_screenShot_roadmap.md index 59d227c..00235f5 100644 --- a/doc/05_screenShot_roadmap.md +++ b/doc/05_screenShot_roadmap.md @@ -388,14 +388,17 @@ Eine `CameraSwitch`-Instanz pro Gerät — der **einzige** Öffner von `/dev/vid 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 mjpeg -q:v 5 -f mpjpeg pipe:1`. Node parst - die mpjpeg-Frames (Content-Length-basiert), hält den letzten (für `/api/snapshot`), - sendet sie an alle Stream-Clients. Crash → Auto-Restart nach 1,5 s. - > ⚠ **`-c:v mjpeg` (Re-Encode), NICHT `-c:v copy`.** `copy` ist auf dieser Kamera - > empirisch tot: 04/09 dokumentieren CPU **107%** + hängendes Bild (FFmpeg verschluckt - > sich an den APP-Feldern des Kamera-MJPEG). Re-Encode = der bewährte ~50%-Pfad - > (entspricht go2rtcs `#video=mjpeg`). **Dieser Fehler wurde am 2026-06-05 zunächst - > wiederholt (copy) und dann korrigiert.** + -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 `SIGTERM` → **auf `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). @@ -413,14 +416,34 @@ droppen, andere bleiben flüssig. Clients halten **kein** Gerät → **Multi-Use 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-`` 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), Crash-Auto-Restart rate-limitiert. -- **FFmpeg = der bewährte go2rtc-`#video=mjpeg`-Pfad** (Re-Encode mjpeg→mjpeg, ~50% für - 2 Kameras), Ausgabe `-c:v mjpeg -q:v 5 -f mpjpeg`. **Nicht** `copy` (= 107%, s.o.). -- **Auf der Hardware noch zu verifizieren:** CPU-Last, Latenz, HD-Blackout-Dauer, und der - Bug-Reproweg unten. + 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:** CPU **69 % für 2 Kameras bestätigt** (User, copybsf). Latenz nach + den Flags oben + Bug-Reproweg noch gegenzumessen. ## Hardware-Testplan @@ -446,9 +469,7 @@ Ein Node-Container mit FFmpeg, Geräte durchgereicht, `group_add: video`. Env-Ov 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 unter 50% drücken?** Re-Encode 2×640@30 ≈ 50% (wie go2rtc). Hebel, falls nötig - (zuerst auf dem Host **messen**, nicht raten): `LIVE_FPS=15` (halbiert Encode-Last) oder - Test des Bitstream-Filters `-c:v copy -bsf:v mjpeg2jpeg` (fügt fehlende Tables ohne - Decode hinzu — ungetestet, könnte echtes Low-CPU bringen oder auch scheitern). -- **On-Demand Live** (FFmpeg erst bei erstem Client) wäre stromsparender, ist aber bewusst - weggelassen — Dauerbetrieb hält die Übergabe-Logik simpel (weniger Race-Fläche). +- **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. diff --git a/doc/09_Bug_reports.md b/doc/09_Bug_reports.md index f2fc456..7f0c466 100644 --- a/doc/09_Bug_reports.md +++ b/doc/09_Bug_reports.md @@ -106,16 +106,22 @@ Encoder auf einem `/dev/videoN` sind konstruktiv ausgeschlossen. Crash-Auto-Restart rate-limitiert. **Auf der Hardware noch zu verifizieren:** CPU-Last, Latenz, HD-Blackout-Dauer, kein 106% nach Screenshot+Reconnect (Testplan in 05). -**FFmpeg = der bewährte go2rtc-`#video=mjpeg`-Pfad:** `-f v4l2 -input_format mjpeg --video_size 640x480 -framerate 30 -i … -c:v mjpeg -q:v 5 -f mpjpeg pipe:1` (Re-Encode -mjpeg→mjpeg, ~50% für 2 Kameras). +**FFmpeg (Default `ENCODE_MODE=copybsf`):** `-f v4l2 -input_format mjpeg -video_size +640x480 -framerate 30 -i … -c:v copy -bsf:v mjpeg2jpeg -f mpjpeg pipe:1` — Bitstream-Copy, +kein Transcode. Fallback `mjpeg` = Re-Encode mjpeg→mjpeg (~50%, wie go2rtc). -### ⚠ Regression am 2026-06-05 (eingebaut **und** korrigiert): `-c:v copy` +### ⚠ Regression am 2026-06-05 (eingebaut → korrigiert → optimiert) -Erste Fassung des Schalters nutzte `-c:v copy` (Passthrough) → **CPU 100%+ und wieder -hängendes Bild.** Ursache: `copy` ist auf dieser Kamera empirisch tot — 04/09 dokumentieren -es (107%), FFmpeg verschluckt sich an den APP-Feldern des Kamera-MJPEG -(`[mjpeg] unable to decode APP fields: Invalid data`) → keine validen Frames → Freeze. -**Das war exakt der in 04/05 dokumentierte und ignorierte Punkt.** Fix: `-c:v copy` → -`-c:v mjpeg` (Re-Encode, wie go2rtc). Lehre erneut: **erst die Doku-Fakten anwenden, -dann bauen.** \ No newline at end of file +1. **Fehler:** Erste Schalter-Fassung nutzte plain `-c:v copy` (ohne Bitstream-Filter) + → **CPU 100%+ und hängendes Bild.** Das Kamera-MJPEG lässt JPEG-Tables weg, der + mpjpeg-Muxer verschluckt sich (`[mjpeg] unable to decode APP fields`) → keine validen + Frames. **Exakt der in 04/05 dokumentierte und von mir ignorierte Punkt.** +2. **Sofort-Fix:** `-c:v mjpeg` (Re-Encode, ~50%, wie go2rtc) — stabil, aber nicht besser + als der alte Stand. +3. **Host-Messung (User, 2026-06-05):** `-c:v copy -bsf:v mjpeg2jpeg` läuft 10 s sauber + durch (Copy bestätigt, nur einmalige Probe-Warnung). Der `mjpeg2jpeg`-Filter ergänzt + die fehlenden Tables ohne Decode → **kein Transcode, CPU < Re-Encode, valide JPEGs.** + → als Default `copybsf` verdrahtet, `mjpeg` bleibt als Fallback (`ENCODE_MODE`). + +Lehre erneut: **erst die Doku-Fakten anwenden, dann bauen** — und Optimierungen **messen** +(Punkt 3), nicht vorhersagen. \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index c95869c..b014c5d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -61,6 +61,10 @@ services: # - LIVE_FPS=30 # - HIRES_SIZE=1280x960 # - HIRES_FPS=15 + # - ENCODE_MODE=copybsf # copybsf = Bitstream-Copy, niedrige CPU (Default) + # # mjpeg = Re-Encode (~50%, Fallback falls copybsf zickt) + # - ON_DEMAND=true # Live nur bei Zuschauern (Default); 'false' = dauerhaft an + # - IDLE_GRACE_MS=15000 # Karenz nach letztem Zuschauer vor dem Stop # ── Hinweise ──────────────────────────────────────────────────────────────────── # • Bleibt eine Kamera schwarz? Geräte prüfen: v4l2-ctl --list-devices diff --git a/server.js b/server.js index 37c5044..2eaab25 100644 --- a/server.js +++ b/server.js @@ -12,13 +12,16 @@ const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480'; const LIVE_FPS = parseInt(process.env.LIVE_FPS ?? '30', 10); const HIRES_SIZE = process.env.HIRES_SIZE ?? '1280x960'; const HIRES_FPS = parseInt(process.env.HIRES_FPS ?? '15', 10); +const ENCODE_MODE = process.env.ENCODE_MODE ?? 'copybsf'; // 'copybsf' (niedrige CPU) | 'mjpeg' (Re-Encode-Fallback) +const ON_DEMAND = (process.env.ON_DEMAND ?? 'true') !== 'false'; // Live nur bei Verbrauchern (spart idle-CPU) +const IDLE_GRACE_MS = parseInt(process.env.IDLE_GRACE_MS ?? '15000', 10); // ── Kameras: cam0 = erstes Gerät, cam1 = zweites … (DEV0/DEV1-Env überschreibt) ─ const devices = detectDevices(); const switches = {}; devices.forEach((device, i) => { const id = `cam${i}`; - switches[id] = new CameraSwitch({ id, device, liveSize: LIVE_SIZE, liveFps: LIVE_FPS, hiresSize: HIRES_SIZE, hiresFps: HIRES_FPS }); + switches[id] = new CameraSwitch({ id, device, liveSize: LIVE_SIZE, liveFps: LIVE_FPS, hiresSize: HIRES_SIZE, hiresFps: HIRES_FPS, encode: ENCODE_MODE, onDemand: ON_DEMAND, idleGraceMs: IDLE_GRACE_MS }); }); const app = express(); @@ -52,7 +55,7 @@ const server = http.createServer(app); server.listen(PORT, '0.0.0.0', () => { console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`); console.log(` Kameras: ${Object.entries(switches).map(([id, sw]) => `${id}=${sw.device}`).join(', ')}`); - console.log(` Live: ${LIVE_SIZE}@${LIVE_FPS} · HD-Grab: ${HIRES_SIZE}@${HIRES_FPS}`); + console.log(` Live: ${LIVE_SIZE}@${LIVE_FPS} · HD-Grab: ${HIRES_SIZE}@${HIRES_FPS} · Encode: ${ENCODE_MODE} · On-Demand: ${ON_DEMAND}`); console.log(` Viewer: http://0.0.0.0:${PORT}/`); console.log(` Live-Stream: http://0.0.0.0:${PORT}/api/stream/cam0`); console.log(` Snapshot API: http://0.0.0.0:${PORT}/api/snapshot/cam0 (+ /hires)`); diff --git a/src/cameraSwitch.js b/src/cameraSwitch.js index c3c73a1..ab31bbf 100644 --- a/src/cameraSwitch.js +++ b/src/cameraSwitch.js @@ -5,6 +5,17 @@ const EventEmitter = require('events'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +// Video-Ausgabe-Args, umschaltbar via ENCODE_MODE (server.js): +// 'copybsf' (Default) → Bitstream-Passthrough. KEIN De-/Encode → niedrigste CPU. +// `mjpeg2jpeg` ergänzt die JPEG-Tables, die das Kamera-MJPEG weglässt → ohne den +// Filter verschluckt sich der mpjpeg-Muxer (107% + Hang, siehe 09). MIT Filter: +// sauberer Copy, browser-valide JPEGs. Auf der Hardware getestet (2026-06-05). +// 'mjpeg' → Re-Encode mjpeg→mjpeg (~50%, wie go2rtc). Sicherer Fallback. +function videoOutArgs(mode) { + if (mode === 'mjpeg') return ['-c:v', 'mjpeg', '-q:v', '5']; + return ['-c:v', 'copy', '-bsf:v', 'mjpeg2jpeg']; +} + // Liest Bildbreite aus JPEG-Header (SOF0/SOF2-Marker) ohne externe Bibliothek. // Gibt null zurück wenn der Marker nicht gefunden wird. function readJpegWidth(buf) { @@ -66,7 +77,7 @@ class MpjpegParser { // // Events: 'frame' (Buffer) – je ein Live-JPEG class CameraSwitch extends EventEmitter { - constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15 }) { + constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', onDemand = true, idleGraceMs = 15000 }) { super(); this.setMaxListeners(0); // beliebig viele Stream-Clients this.id = id; @@ -75,28 +86,77 @@ class CameraSwitch extends EventEmitter { this.liveFps = liveFps; this.hiresSize = hiresSize; this.hiresFps = hiresFps; + this.encode = encode; // 'copybsf' (Default, niedrige CPU) | 'mjpeg' (Re-Encode) + this.onDemand = onDemand; // Live nur laufen lassen, solange Verbraucher da sind + this.idleGraceMs = idleGraceMs; // Karenz nach letztem Verbraucher vor dem Stop this.proc = null; // aktueller FFmpeg-Prozess (Live ODER Grab) - this.latest = null; // letztes Live-JPEG (für /api/snapshot) + this.latest = null; // letztes Live-JPEG (für /api/snapshot); null wenn Live aus this.state = 'stopped'; // stopped | live | grabbing this.lock = false; // Mutex: nur ein Grab gleichzeitig this.stopping = false; // unterscheidet absichtliches Kill von Crash this.restartTimer = null; + this.subscribers = 0; // aktive Verbraucher (Stream-Clients + laufende Snapshots) + this.idleTimer = null; } start() { + // On-Demand: lazy – Live startet erst beim ersten Verbraucher (acquire()). + if (this.onDemand) return; if (this.state === 'stopped' && !this.proc) this._spawnLive(); } + // ── Verbraucher-Zählung / On-Demand ──────────────────────────────────────── + acquire() { + this.subscribers++; + if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; } + if (this.onDemand && this.state === 'stopped' && !this.lock && !this.proc) this._spawnLive(); + } + + release() { + this.subscribers = Math.max(0, this.subscribers - 1); + if (this.subscribers === 0 && this.onDemand) this._scheduleIdleStop(); + } + + _scheduleIdleStop() { + if (this.idleTimer) return; + this.idleTimer = setTimeout(() => { + this.idleTimer = null; + if (this.subscribers === 0 && this.state === 'live' && !this.lock && this.proc) { + console.log(`[cam ${this.id}] idle (keine Clients) → Live gestoppt`); + this.stopping = true; // → 'close'-Handler startet NICHT neu + try { this.proc.kill('SIGTERM'); } catch (_e) {} + } + }, this.idleGraceMs); + } + + // Aktuelles Frame für /api/snapshot. Startet bei Bedarf on-demand und wartet + // auf das erste frische Frame (latest ist null, solange Live aus ist). + async getFrame(timeoutMs = 4000) { + this.acquire(); + try { + if (this.latest) return this.latest; // frisch (nur gesetzt solange Live läuft) + return await new Promise((resolve, reject) => { + const t = setTimeout(() => { this.removeListener('frame', onF); reject(new Error('Frame-Timeout')); }, timeoutMs); + const onF = (f) => { clearTimeout(t); this.removeListener('frame', onF); resolve(f); }; + this.on('frame', onF); + }); + } finally { + this.release(); + } + } + // ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ─────────────────── _spawnLive() { this.stopping = false; const args = [ '-hide_banner', '-loglevel', 'warning', + '-fflags', 'nobuffer', // Input nicht puffern → niedrige Latenz '-f', 'v4l2', '-input_format', 'mjpeg', '-video_size', this.liveSize, '-framerate', String(this.liveFps), '-i', this.device, - '-c:v', 'mjpeg', '-q:v', '5', '-f', 'mpjpeg', 'pipe:1', + ...videoOutArgs(this.encode), + '-f', 'mpjpeg', '-flush_packets', '1', 'pipe:1', // jedes Frame sofort rausschreiben ]; let p; try { @@ -121,9 +181,10 @@ class CameraSwitch extends EventEmitter { p.on('error', (e) => console.error(`[cam ${this.id}] live ffmpeg error: ${e.message}`)); p.on('close', (code, sig) => { this.proc = null; + this.latest = null; // Producer weg → gepuffertes Frame ist stale const wasStopping = this.stopping; if (this.state === 'live') this.state = 'stopped'; - if (wasStopping) return; // beabsichtigt (HD-Grab) → grabHires startet Live neu + if (wasStopping) return; // beabsichtigt (HD-Grab/idle) → kein Auto-Restart hier console.warn(`[cam ${this.id}] live ffmpeg unerwartet beendet (code=${code} sig=${sig}) → Restart`); this._scheduleRestart(); }); @@ -131,10 +192,11 @@ class CameraSwitch extends EventEmitter { } _scheduleRestart() { + if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten if (this.restartTimer) return; this.restartTimer = setTimeout(() => { this.restartTimer = null; - if (this.state === 'stopped' && !this.lock) this._spawnLive(); + if (this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive(); }, 1500); } @@ -159,11 +221,11 @@ class CameraSwitch extends EventEmitter { console.log(`[cam ${this.id}] HD OK – ${jpeg.length} bytes, Breite=${readJpegWidth(jpeg) ?? '?'} (${Date.now() - t0}ms)`); return jpeg; } finally { - // 3. IMMER zurück auf Live (auch bei Fehler) – Live hat Priorität + // 3. Zurück auf Live, sofern noch Verbraucher da sind (On-Demand). Live hat Priorität. this.state = 'stopped'; - this._spawnLive(); + if (!this.onDemand || this.subscribers > 0) this._spawnLive(); this.lock = false; - console.log(`[cam ${this.id}] HD beendet, Live zurück (gesamt ${Date.now() - t0}ms)`); + console.log(`[cam ${this.id}] HD beendet${(!this.onDemand || this.subscribers > 0) ? ', Live zurück' : ' (idle, kein Live-Restart)'} (gesamt ${Date.now() - t0}ms)`); } } @@ -189,7 +251,7 @@ class CameraSwitch extends EventEmitter { '-f', 'v4l2', '-input_format', 'mjpeg', '-video_size', this.hiresSize, '-framerate', String(this.hiresFps), '-i', this.device, - '-c:v', 'mjpeg', '-q:v', '5', '-f', 'mpjpeg', 'pipe:1', + ...videoOutArgs(this.encode), '-f', 'mpjpeg', 'pipe:1', ]; let p; try { diff --git a/src/snapshotService.js b/src/snapshotService.js index d94ab87..497af49 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -42,19 +42,23 @@ function createSnapshotRouter(switches) { } }); - router.get('/:id', (req, res) => { + router.get('/:id', async (req, res) => { const sw = switches[req.params.id]; if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` }); - const frame = sw.latest; - if (!frame) return res.status(503).json({ error: 'noch kein Frame verfügbar' }); - res.set({ - 'Content-Type': 'image/jpeg', - 'Content-Length': frame.length, - 'Cache-Control': 'no-store', - 'X-Camera-Id': req.params.id, - 'X-Timestamp': new Date().toISOString(), - }); - res.end(frame); + try { + // getFrame() startet die Kamera bei Bedarf on-demand und wartet auf ein frisches Bild + const frame = await sw.getFrame(); + res.set({ + 'Content-Type': 'image/jpeg', + 'Content-Length': frame.length, + 'Cache-Control': 'no-store', + 'X-Camera-Id': req.params.id, + 'X-Timestamp': new Date().toISOString(), + }); + res.end(frame); + } catch (err) { + res.status(503).json({ error: `kein Frame: ${err.message}` }); + } }); return router; @@ -76,27 +80,36 @@ function createStreamRouter(switches) { 'Connection': 'close', 'X-Camera-Id': req.params.id, }); + if (res.socket) res.socket.setNoDelay(true); // Nagle aus → kein Sammel-Delay let closed = false; - const cleanup = () => { if (!closed) { closed = true; sw.removeListener('frame', onFrame); } }; + const cleanup = () => { + if (closed) return; + closed = true; + sw.removeListener('frame', onFrame); + sw.release(); // On-Demand: Verbraucher abmelden + }; const onFrame = (buf) => { if (closed) return; // Backpressure: langsamer Client bremst die anderen nicht – Frames droppen if (res.writableLength > (1 << 20)) return; - // try/catch: ein kaputter Client darf die anderen nicht aushungern - // (ein werfender 'frame'-Listener würde sonst emit() abbrechen) + // cork/uncork: Header+JPEG+Trailer als EIN TCP-Segment → minimale Latenz. + // try/catch: ein kaputter Client darf die anderen nicht aushungern. try { + res.cork(); res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${buf.length}\r\n\r\n`); res.write(buf); res.write('\r\n'); + res.uncork(); } catch (_e) { cleanup(); } }; + sw.acquire(); // On-Demand: Verbraucher anmelden (startet Live falls nötig) sw.on('frame', onFrame); - if (sw.latest) onFrame(sw.latest); // sofort erstes Bild + if (sw.latest) onFrame(sw.latest); // sofort erstes Bild, falls schon eins da req.on('close', cleanup); res.on('error', cleanup);