Umbau mit cameraSwitch Fix Delay
This commit is contained in:
@@ -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.
|
immer nur **einen** FFmpeg. Zustände `stopped | live | grabbing`, Mutex pro Kamera.
|
||||||
|
|
||||||
- **Live (Dauerbetrieb):** `ffmpeg -f v4l2 -input_format mjpeg -video_size 640x480
|
- **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
|
-framerate 30 -i /dev/videoN -c:v copy -bsf:v mjpeg2jpeg -f mpjpeg pipe:1` (Default
|
||||||
die mpjpeg-Frames (Content-Length-basiert), hält den letzten (für `/api/snapshot`),
|
`ENCODE_MODE=copybsf`). Node parst die mpjpeg-Frames (Content-Length-basiert), hält den
|
||||||
sendet sie an alle Stream-Clients. Crash → Auto-Restart nach 1,5 s.
|
letzten (für `/api/snapshot`), sendet sie an alle Stream-Clients. Crash → Restart 1,5 s.
|
||||||
> ⚠ **`-c:v mjpeg` (Re-Encode), NICHT `-c:v copy`.** `copy` ist auf dieser Kamera
|
> ⚠ **Der `mjpeg2jpeg`-Bitstream-Filter ist Pflicht.** Plain `-c:v copy` (ohne Filter)
|
||||||
> empirisch tot: 04/09 dokumentieren CPU **107%** + hängendes Bild (FFmpeg verschluckt
|
> ist auf dieser Kamera tot: **107% CPU + Hang** (04/09), weil das Kamera-MJPEG die
|
||||||
> sich an den APP-Feldern des Kamera-MJPEG). Re-Encode = der bewährte ~50%-Pfad
|
> JPEG-Tables weglässt und der mpjpeg-Muxer sich verschluckt. **Auf dem Host getestet
|
||||||
> (entspricht go2rtcs `#video=mjpeg`). **Dieser Fehler wurde am 2026-06-05 zunächst
|
> (2026-06-05):** `copy -bsf:v mjpeg2jpeg` läuft sauber (der „APP fields"-Hinweis ist
|
||||||
> wiederholt (copy) und dann korrigiert.**
|
> 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) →
|
- **HD-Grab (`grabHires`):** Live-FFmpeg `SIGTERM` → **auf `close` warten** (FD frei) →
|
||||||
1280-FFmpeg, warmlaufen (ab Frame ≥6, ≥15 KB, Breite ≥1000), besten Frame greifen →
|
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).
|
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:
|
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**.
|
`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
|
## Verifiziert vs. offen
|
||||||
|
|
||||||
- **Lokal verifiziert (ohne Kamera):** MJPEG-Parser (Unittest, Chunk-robust, `\r\n\r\n`
|
- **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.
|
im Body); HTTP-Routing (snapshot/stream/health, 404/503); On-Demand-Lebenszyklus
|
||||||
- **FFmpeg = der bewährte go2rtc-`#video=mjpeg`-Pfad** (Re-Encode mjpeg→mjpeg, ~50% für
|
(acquire/release/idle-Stop/Reconnect-Karenz/getFrame/always-on) per Mock-Unittest.
|
||||||
2 Kameras), Ausgabe `-c:v mjpeg -q:v 5 -f mpjpeg`. **Nicht** `copy` (= 107%, s.o.).
|
- **FFmpeg Default `copybsf`** = `-c:v copy -bsf:v mjpeg2jpeg` (Bitstream-Copy, kein
|
||||||
- **Auf der Hardware noch zu verifizieren:** CPU-Last, Latenz, HD-Blackout-Dauer, und der
|
Transcode; auf dem Host getestet). Fallback `ENCODE_MODE=mjpeg` (~50%, Re-Encode).
|
||||||
Bug-Reproweg unten.
|
- **Auf der Hardware:** CPU **69 % für 2 Kameras bestätigt** (User, copybsf). Latenz nach
|
||||||
|
den Flags oben + Bug-Reproweg noch gegenzumessen.
|
||||||
|
|
||||||
## Hardware-Testplan
|
## 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%,
|
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
|
APP-Feld-Fehler) → **`-c:v mjpeg` (Re-Encode)**. (Optional `HIRES_FPS=30` verkürzt den
|
||||||
Warmup leicht.)
|
Warmup leicht.)
|
||||||
- **CPU unter 50% drücken?** Re-Encode 2×640@30 ≈ 50% (wie go2rtc). Hebel, falls nötig
|
- **CPU:** `copybsf` (Bitstream-Copy) ist Default und liefert 69 % für 2 Kameras (gemessen).
|
||||||
(zuerst auf dem Host **messen**, nicht raten): `LIVE_FPS=15` (halbiert Encode-Last) oder
|
Weiter drücken nur falls nötig: `LIVE_FPS=15`. Fallback bei Problemen: `ENCODE_MODE=mjpeg`.
|
||||||
Test des Bitstream-Filters `-c:v copy -bsf:v mjpeg2jpeg` (fügt fehlende Tables ohne
|
- **On-Demand Live:** ✅ umgesetzt (s. o.) — Idle-CPU 0 statt ~60 %. Per `ON_DEMAND=false`
|
||||||
Decode hinzu — ungetestet, könnte echtes Low-CPU bringen oder auch scheitern).
|
abschaltbar, falls je ein dauerhaft warmer Stream gewünscht ist.
|
||||||
- **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).
|
|
||||||
|
|||||||
@@ -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,
|
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).
|
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
|
**FFmpeg (Default `ENCODE_MODE=copybsf`):** `-f v4l2 -input_format mjpeg -video_size
|
||||||
-video_size 640x480 -framerate 30 -i … -c:v mjpeg -q:v 5 -f mpjpeg pipe:1` (Re-Encode
|
640x480 -framerate 30 -i … -c:v copy -bsf:v mjpeg2jpeg -f mpjpeg pipe:1` — Bitstream-Copy,
|
||||||
mjpeg→mjpeg, ~50% für 2 Kameras).
|
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
|
1. **Fehler:** Erste Schalter-Fassung nutzte plain `-c:v copy` (ohne Bitstream-Filter)
|
||||||
hängendes Bild.** Ursache: `copy` ist auf dieser Kamera empirisch tot — 04/09 dokumentieren
|
→ **CPU 100%+ und hängendes Bild.** Das Kamera-MJPEG lässt JPEG-Tables weg, der
|
||||||
es (107%), FFmpeg verschluckt sich an den APP-Feldern des Kamera-MJPEG
|
mpjpeg-Muxer verschluckt sich (`[mjpeg] unable to decode APP fields`) → keine validen
|
||||||
(`[mjpeg] unable to decode APP fields: Invalid data`) → keine validen Frames → Freeze.
|
Frames. **Exakt der in 04/05 dokumentierte und von mir ignorierte Punkt.**
|
||||||
**Das war exakt der in 04/05 dokumentierte und ignorierte Punkt.** Fix: `-c:v copy` →
|
2. **Sofort-Fix:** `-c:v mjpeg` (Re-Encode, ~50%, wie go2rtc) — stabil, aber nicht besser
|
||||||
`-c:v mjpeg` (Re-Encode, wie go2rtc). Lehre erneut: **erst die Doku-Fakten anwenden,
|
als der alte Stand.
|
||||||
dann bauen.**
|
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.
|
||||||
@@ -61,6 +61,10 @@ services:
|
|||||||
# - LIVE_FPS=30
|
# - LIVE_FPS=30
|
||||||
# - HIRES_SIZE=1280x960
|
# - HIRES_SIZE=1280x960
|
||||||
# - HIRES_FPS=15
|
# - 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 ────────────────────────────────────────────────────────────────────
|
# ── Hinweise ────────────────────────────────────────────────────────────────────
|
||||||
# • Bleibt eine Kamera schwarz? Geräte prüfen: v4l2-ctl --list-devices
|
# • Bleibt eine Kamera schwarz? Geräte prüfen: v4l2-ctl --list-devices
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
|
|||||||
const LIVE_FPS = parseInt(process.env.LIVE_FPS ?? '30', 10);
|
const LIVE_FPS = parseInt(process.env.LIVE_FPS ?? '30', 10);
|
||||||
const HIRES_SIZE = process.env.HIRES_SIZE ?? '1280x960';
|
const HIRES_SIZE = process.env.HIRES_SIZE ?? '1280x960';
|
||||||
const HIRES_FPS = parseInt(process.env.HIRES_FPS ?? '15', 10);
|
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) ─
|
// ── Kameras: cam0 = erstes Gerät, cam1 = zweites … (DEV0/DEV1-Env überschreibt) ─
|
||||||
const devices = detectDevices();
|
const devices = detectDevices();
|
||||||
const switches = {};
|
const switches = {};
|
||||||
devices.forEach((device, i) => {
|
devices.forEach((device, i) => {
|
||||||
const id = `cam${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();
|
const app = express();
|
||||||
@@ -52,7 +55,7 @@ const server = http.createServer(app);
|
|||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
|
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
|
||||||
console.log(` Kameras: ${Object.entries(switches).map(([id, sw]) => `${id}=${sw.device}`).join(', ')}`);
|
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(` Viewer: http://0.0.0.0:${PORT}/`);
|
||||||
console.log(` Live-Stream: http://0.0.0.0:${PORT}/api/stream/cam0`);
|
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)`);
|
console.log(` Snapshot API: http://0.0.0.0:${PORT}/api/snapshot/cam0 (+ /hires)`);
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ const EventEmitter = require('events');
|
|||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
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.
|
// Liest Bildbreite aus JPEG-Header (SOF0/SOF2-Marker) ohne externe Bibliothek.
|
||||||
// Gibt null zurück wenn der Marker nicht gefunden wird.
|
// Gibt null zurück wenn der Marker nicht gefunden wird.
|
||||||
function readJpegWidth(buf) {
|
function readJpegWidth(buf) {
|
||||||
@@ -66,7 +77,7 @@ class MpjpegParser {
|
|||||||
//
|
//
|
||||||
// Events: 'frame' (Buffer) – je ein Live-JPEG
|
// Events: 'frame' (Buffer) – je ein Live-JPEG
|
||||||
class CameraSwitch extends EventEmitter {
|
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();
|
super();
|
||||||
this.setMaxListeners(0); // beliebig viele Stream-Clients
|
this.setMaxListeners(0); // beliebig viele Stream-Clients
|
||||||
this.id = id;
|
this.id = id;
|
||||||
@@ -75,28 +86,77 @@ class CameraSwitch extends EventEmitter {
|
|||||||
this.liveFps = liveFps;
|
this.liveFps = liveFps;
|
||||||
this.hiresSize = hiresSize;
|
this.hiresSize = hiresSize;
|
||||||
this.hiresFps = hiresFps;
|
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.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.state = 'stopped'; // stopped | live | grabbing
|
||||||
this.lock = false; // Mutex: nur ein Grab gleichzeitig
|
this.lock = false; // Mutex: nur ein Grab gleichzeitig
|
||||||
this.stopping = false; // unterscheidet absichtliches Kill von Crash
|
this.stopping = false; // unterscheidet absichtliches Kill von Crash
|
||||||
this.restartTimer = null;
|
this.restartTimer = null;
|
||||||
|
this.subscribers = 0; // aktive Verbraucher (Stream-Clients + laufende Snapshots)
|
||||||
|
this.idleTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
// On-Demand: lazy – Live startet erst beim ersten Verbraucher (acquire()).
|
||||||
|
if (this.onDemand) return;
|
||||||
if (this.state === 'stopped' && !this.proc) this._spawnLive();
|
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) ───────────────────
|
// ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ───────────────────
|
||||||
_spawnLive() {
|
_spawnLive() {
|
||||||
this.stopping = false;
|
this.stopping = false;
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
|
'-fflags', 'nobuffer', // Input nicht puffern → niedrige Latenz
|
||||||
'-f', 'v4l2', '-input_format', 'mjpeg',
|
'-f', 'v4l2', '-input_format', 'mjpeg',
|
||||||
'-video_size', this.liveSize, '-framerate', String(this.liveFps),
|
'-video_size', this.liveSize, '-framerate', String(this.liveFps),
|
||||||
'-i', this.device,
|
'-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;
|
let p;
|
||||||
try {
|
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('error', (e) => console.error(`[cam ${this.id}] live ffmpeg error: ${e.message}`));
|
||||||
p.on('close', (code, sig) => {
|
p.on('close', (code, sig) => {
|
||||||
this.proc = null;
|
this.proc = null;
|
||||||
|
this.latest = null; // Producer weg → gepuffertes Frame ist stale
|
||||||
const wasStopping = this.stopping;
|
const wasStopping = this.stopping;
|
||||||
if (this.state === 'live') this.state = 'stopped';
|
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`);
|
console.warn(`[cam ${this.id}] live ffmpeg unerwartet beendet (code=${code} sig=${sig}) → Restart`);
|
||||||
this._scheduleRestart();
|
this._scheduleRestart();
|
||||||
});
|
});
|
||||||
@@ -131,10 +192,11 @@ class CameraSwitch extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_scheduleRestart() {
|
_scheduleRestart() {
|
||||||
|
if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten
|
||||||
if (this.restartTimer) return;
|
if (this.restartTimer) return;
|
||||||
this.restartTimer = setTimeout(() => {
|
this.restartTimer = setTimeout(() => {
|
||||||
this.restartTimer = null;
|
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);
|
}, 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)`);
|
console.log(`[cam ${this.id}] HD OK – ${jpeg.length} bytes, Breite=${readJpegWidth(jpeg) ?? '?'} (${Date.now() - t0}ms)`);
|
||||||
return jpeg;
|
return jpeg;
|
||||||
} finally {
|
} 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.state = 'stopped';
|
||||||
this._spawnLive();
|
if (!this.onDemand || this.subscribers > 0) this._spawnLive();
|
||||||
this.lock = false;
|
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',
|
'-f', 'v4l2', '-input_format', 'mjpeg',
|
||||||
'-video_size', this.hiresSize, '-framerate', String(this.hiresFps),
|
'-video_size', this.hiresSize, '-framerate', String(this.hiresFps),
|
||||||
'-i', this.device,
|
'-i', this.device,
|
||||||
'-c:v', 'mjpeg', '-q:v', '5', '-f', 'mpjpeg', 'pipe:1',
|
...videoOutArgs(this.encode), '-f', 'mpjpeg', 'pipe:1',
|
||||||
];
|
];
|
||||||
let p;
|
let p;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -42,11 +42,12 @@ function createSnapshotRouter(switches) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
const sw = switches[req.params.id];
|
const sw = switches[req.params.id];
|
||||||
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
|
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
|
||||||
const frame = sw.latest;
|
try {
|
||||||
if (!frame) return res.status(503).json({ error: 'noch kein Frame verfügbar' });
|
// getFrame() startet die Kamera bei Bedarf on-demand und wartet auf ein frisches Bild
|
||||||
|
const frame = await sw.getFrame();
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': 'image/jpeg',
|
||||||
'Content-Length': frame.length,
|
'Content-Length': frame.length,
|
||||||
@@ -55,6 +56,9 @@ function createSnapshotRouter(switches) {
|
|||||||
'X-Timestamp': new Date().toISOString(),
|
'X-Timestamp': new Date().toISOString(),
|
||||||
});
|
});
|
||||||
res.end(frame);
|
res.end(frame);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(503).json({ error: `kein Frame: ${err.message}` });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
@@ -76,27 +80,36 @@ function createStreamRouter(switches) {
|
|||||||
'Connection': 'close',
|
'Connection': 'close',
|
||||||
'X-Camera-Id': req.params.id,
|
'X-Camera-Id': req.params.id,
|
||||||
});
|
});
|
||||||
|
if (res.socket) res.socket.setNoDelay(true); // Nagle aus → kein Sammel-Delay
|
||||||
|
|
||||||
let closed = false;
|
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) => {
|
const onFrame = (buf) => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
// Backpressure: langsamer Client bremst die anderen nicht – Frames droppen
|
// Backpressure: langsamer Client bremst die anderen nicht – Frames droppen
|
||||||
if (res.writableLength > (1 << 20)) return;
|
if (res.writableLength > (1 << 20)) return;
|
||||||
// try/catch: ein kaputter Client darf die anderen nicht aushungern
|
// cork/uncork: Header+JPEG+Trailer als EIN TCP-Segment → minimale Latenz.
|
||||||
// (ein werfender 'frame'-Listener würde sonst emit() abbrechen)
|
// try/catch: ein kaputter Client darf die anderen nicht aushungern.
|
||||||
try {
|
try {
|
||||||
|
res.cork();
|
||||||
res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${buf.length}\r\n\r\n`);
|
res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${buf.length}\r\n\r\n`);
|
||||||
res.write(buf);
|
res.write(buf);
|
||||||
res.write('\r\n');
|
res.write('\r\n');
|
||||||
|
res.uncork();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sw.acquire(); // On-Demand: Verbraucher anmelden (startet Live falls nötig)
|
||||||
sw.on('frame', onFrame);
|
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);
|
req.on('close', cleanup);
|
||||||
res.on('error', cleanup);
|
res.on('error', cleanup);
|
||||||
|
|||||||
Reference in New Issue
Block a user