'use strict'; const { spawn } = require('child_process'); 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) { let i = 2; // SOI (FF D8) überspringen while (i < buf.length - 8) { if (buf[i] !== 0xFF) break; const marker = buf[i + 1]; const segLen = buf.readUInt16BE(i + 2); if (marker === 0xC0 || marker === 0xC2) { return buf.readUInt16BE(i + 7); // Breite bei Offset +7 im SOF } i += 2 + segLen; } return null; } // ── Parser für FFmpeg `-f mpjpeg` ──────────────────────────────────────────── // FFmpeg schreibt pro Frame: --\r\nContent-Type: image/jpeg\r\n // Content-Length: \r\n\r\n\r\n // Wir keyen auf Content-Length → deterministisch, unabhängig vom Boundary-String. class MpjpegParser { constructor(onFrame) { this.onFrame = onFrame; this.buf = Buffer.alloc(0); this.need = -1; // -1 = Header-Modus, sonst erwartete Body-Bytes } push(chunk) { this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk; for (;;) { if (this.need < 0) { const headEnd = this.buf.indexOf('\r\n\r\n'); if (headEnd < 0) { // Schutz gegen unbegrenztes Puffern bei unerwartetem Müll if (this.buf.length > (1 << 20)) this.buf = this.buf.subarray(this.buf.length - 4096); return; } const header = this.buf.toString('latin1', 0, headEnd); const m = /content-length:\s*(\d+)/i.exec(header); if (!m) { this.buf = this.buf.subarray(headEnd + 4); continue; } this.need = parseInt(m[1], 10); this.buf = this.buf.subarray(headEnd + 4); } if (this.buf.length < this.need) return; const frame = this.buf.subarray(0, this.need); this.buf = this.buf.subarray(this.need); this.need = -1; try { this.onFrame(frame); } catch (_e) { /* Consumer-Fehler ignorieren */ } } } } // ── CameraSwitch ───────────────────────────────────────────────────────────── // Eine Instanz pro physischem Gerät. Der EINZIGE Öffner von /dev/videoN. // Hält IMMER nur EINEN FFmpeg-Prozess: entweder Live (640) oder HD-Grab (1280) — // NIE beide. Der Übergang wird über das `close`-Event des FFmpeg-Kindprozesses // synchronisiert: Prozess weg ⇒ Kernel hat den Device-FD geschlossen ⇒ Gerät frei. // Genau dieses Signal verweigert go2rtcs API — deshalb der Eigenbau. // // Events: 'frame' (Buffer) – je ein Live-JPEG class CameraSwitch extends EventEmitter { constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', hiresEncode, onDemand = true, idleGraceMs = 15000, stream = true }) { super(); this.setMaxListeners(0); // beliebig viele Stream-Clients this.id = id; this.device = device; this.liveSize = liveSize; this.liveFps = liveFps; this.streamEnabled = stream; // UI "Aus": Kamera darf NICHT live gehen (gated _spawnLive) this.hiresSize = hiresSize; this.hiresFps = hiresFps; this.encode = encode; // für Live: 'copybsf' (Default) | 'mjpeg' (Re-Encode) this.hiresEncode = hiresEncode ?? encode; // für Grab: fällt auf encode zurück wenn nicht gesetzt 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); 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.streamEnabled && 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.streamEnabled && 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() { if (!this.streamEnabled) return; // UI "Aus" → Kamera bleibt dunkel 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, ...videoOutArgs(this.encode), '-f', 'mpjpeg', '-flush_packets', '1', 'pipe:1', // jedes Frame sofort rausschreiben ]; let p; try { p = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { console.error(`[cam ${this.id}] spawn fehlgeschlagen: ${e.message} → Retry in 1.5s`); this._scheduleRestart(); return; } this.proc = p; this.state = 'live'; const parser = new MpjpegParser((frame) => { this.latest = frame; this.emit('frame', frame); }); p.stdout.on('data', (c) => parser.push(c)); p.stderr.on('data', (c) => { const s = c.toString(); if (/error|busy|invalid|no such|cannot|denied/i.test(s)) console.error(`[cam ${this.id}] ffmpeg: ${s.trim()}`); }); 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/idle) → kein Auto-Restart hier console.warn(`[cam ${this.id}] live ffmpeg unerwartet beendet (code=${code} sig=${sig}) → Restart`); this._scheduleRestart(); }); console.log(`[cam ${this.id}] live gestartet (${this.liveSize}@${this.liveFps}, ${this.device})`); } _scheduleRestart() { if (!this.streamEnabled) return; // UI "Aus" → kein Auto-Restart if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten if (this.restartTimer) return; this.restartTimer = setTimeout(() => { this.restartTimer = null; if (this.streamEnabled && this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive(); }, 1500); } // ── Hot-Reload (config.html / POST /api/config) ──────────────────────────── // Wendet eine neue Live-Auflösung bzw. Stream-An/Aus zur Laufzeit an, OHNE // Container-Restart. Nutzt die vorhandenen Bausteine (_killCurrentAndWait / // _spawnLive) und respektiert Lock (HD-Grab) sowie On-Demand. async reconfigure({ liveSize, stream } = {}) { if (typeof stream === 'boolean') this.streamEnabled = stream; // Während eines HD-Grabs nicht eingreifen – die neue liveSize gilt nach dem // Grab (grabHires startet Live über _spawnLive neu, das this.liveSize liest). if (this.lock) { if (liveSize) this.liveSize = liveSize; return; } const sizeChanged = !!liveSize && liveSize !== this.liveSize; if (liveSize) this.liveSize = liveSize; // Stream deaktiviert → laufenden Live-Prozess stoppen, NICHT neu starten. if (!this.streamEnabled) { if (this.proc && this.state === 'live') await this._killCurrentAndWait(); this.state = 'stopped'; return; } // Auflösung geändert → laufenden Prozess beenden (FD frei via close-Event). if (sizeChanged && this.proc && this.state === 'live') { await this._killCurrentAndWait(); this.state = 'stopped'; } // (Neu) starten, wenn nichts läuft und Verbraucher da sind (On-Demand) bzw. // Dauerbetrieb. _spawnLive ist zusätzlich durch streamEnabled gegated. if (this.state === 'stopped' && !this.proc && (!this.onDemand || this.subscribers > 0)) { this._spawnLive(); } } // ── HD-Grab ──────────────────────────────────────────────────────────────── // Wenn liveSize == hiresSize: kein Format-Wechsel nötig. Live-Frame direkt // zurückgeben (on-demand startet den Stream bei Bedarf). Schnell, kein Gerät- // Neustart, kein Format-Übergangs-Problem. // // Wenn liveSize ≠ hiresSize: Live stoppen → hires-FFmpeg → zurück. // minWidth (90 % der Soll-Breite) verhindert dass Übergangs-Frames (falsche // Auflösung aus dem v4l2-Buffer des vorigen Formats) akzeptiert werden. // Beispiel: hiresSize="1280x960" → minWidth=1152 → lehnt 640er-Frames ab. async grabHires(opts = {}) { // Shortcut: keine Format-Umschaltung wenn Live- und Hires-Auflösung identisch if (this.liveSize === this.hiresSize) { return this.grabSnapshot(); } const hiresW = parseInt(this.hiresSize.split('x')[0], 10); const { minSize = 15000, minWidth = Math.floor(hiresW * 0.9), // nur Frames nahe der Soll-Breite akzeptieren settleFrames = 6, maxWaitMs = 10000, } = opts; if (this.lock) throw new Error('HD-Grab läuft bereits'); this.lock = true; const t0 = Date.now(); if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; } try { // 1. Live-FFmpeg beenden, auf Prozess-Ende warten (= Device-FD frei). // Das close-Event ist der harte Beweis, dass der FD zu ist → kein zweiter // Öffner. KEIN warmup/sleep: auf dem Host bestätigt (A/B-Test 2026-06-06), // dass ein direkter Open auf die Zielauflösung sofort korrekte Frames liefert // (1920×1080 bzw. 1280×960, jedes Frame). Ein zweites ffmpeg dazwischen // erzeugt nur „Device or resource busy". await this._killCurrentAndWait(); this.state = 'grabbing'; console.log(`[cam ${this.id}] HD: Live gestoppt nach ${Date.now() - t0}ms, Gerät frei → ${this.hiresSize}-Grab (minWidth=${minWidth}, encode=${this.hiresEncode})`); // 2. hires-FFmpeg starten, warmlaufen lassen (settleFrames), besten Frame greifen. // minWidth lehnt etwaige Übergangs-Frames in falscher Auflösung ab. const jpeg = await this._captureAt(this.hiresSize, this.hiresFps, this.hiresEncode, { minSize, minWidth, settleFrames, maxWaitMs }); const gotW = readJpegWidth(jpeg) ?? '?'; console.log(`[cam ${this.id}] HD OK – ${jpeg.length} bytes, Breite=${gotW}px (Soll: ${hiresW}px, ${Date.now() - t0}ms)`); return jpeg; } finally { // 3. Zurück auf Live, sofern noch Verbraucher da sind (On-Demand). Live hat Priorität. this.state = 'stopped'; if (!this.onDemand || this.subscribers > 0) this._spawnLive(); this.lock = false; console.log(`[cam ${this.id}] HD beendet${(!this.onDemand || this.subscribers > 0) ? ', Live zurück' : ' (idle, kein Live-Restart)'} (gesamt ${Date.now() - t0}ms)`); } } // ── Einzelbild-Snapshot (für /api/snapshot, inkl. Snapshot-Modus) ────────── // Läuft Live → aktuelles Frame (schnell, kein Geräte-Neustart). Sonst: // • streamEnabled (Live an, aber Frame noch nicht da) → getFrame() (on-demand). // • stream:false (Snapshot-Modus) → one-shot open/grab/close an liveSize. Das // Gerät bleibt dazwischen geschlossen → spart CPU/USB; der Viewer pollt das // alle paar Sekunden. streamEnabled bleibt aus (kein Dauer-Stream). async grabSnapshot() { if (this.latest) return this.latest; // Live läuft → sofort if (this.streamEnabled) return this.getFrame(); // Live an, Frame kommt gleich if (this.lock) throw new Error('Gerät belegt (Grab läuft)'); this.lock = true; if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; } this.state = 'grabbing'; try { const w = parseInt(this.liveSize.split('x')[0], 10) || 0; return await this._captureAt(this.liveSize, this.liveFps, this.encode, { minSize: 1000, minWidth: w ? Math.floor(w * 0.8) : 0, settleFrames: 3, maxWaitMs: 6000, }); } finally { this.state = 'stopped'; this.lock = false; // Falls inzwischen wieder Live gewünscht ist (stream:true + Verbraucher): if (this.streamEnabled && (!this.onDemand || this.subscribers > 0)) this._spawnLive(); } } // Beendet den aktuellen Prozess und resolved erst nach dessen 'close' (FD frei). _killCurrentAndWait(timeoutMs = 4000) { return new Promise((resolve) => { const p = this.proc; if (!p) return resolve(); this.stopping = true; let done = false; const fin = () => { if (!done) { done = true; resolve(); } }; p.once('close', fin); try { p.kill('SIGTERM'); } catch (_e) { /* schon weg */ } setTimeout(() => { if (!done) { try { p.kill('SIGKILL'); } catch (_e) {} } }, Math.max(500, timeoutMs - 1000)); setTimeout(fin, timeoutMs); // Sicherheitsnetz }); } // Generischer one-shot Capture an beliebiger Auflösung (open → settle → grab → close). // Genutzt von grabHires (hiresSize) und grabSnapshot (liveSize, Snapshot-Modus). _captureAt(size, fps, encode, { minSize, minWidth, settleFrames, maxWaitMs }) { return new Promise((resolve, reject) => { const args = [ '-hide_banner', '-loglevel', 'warning', '-fflags', 'nobuffer', '-probesize', '5000000', '-analyzeduration', '1000000', '-f', 'v4l2', '-input_format', 'mjpeg', '-video_size', size, '-framerate', String(fps), '-i', this.device, ...videoOutArgs(encode), '-f', 'mpjpeg', 'pipe:1', ]; let p; try { p = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return reject(e); } this.proc = p; this.stopping = false; let count = 0; let best = null; let decided = false; let closed = false; let finalized = false; let storedResult = null; let storedErr = null; let timer = setTimeout(() => decide(best, best ? null : new Error('HD-Timeout')), maxWaitMs); let hardFin = null; function finalize(self) { if (finalized) return; finalized = true; if (hardFin) clearTimeout(hardFin); self.proc = null; if (storedResult) resolve(storedResult); else reject(storedErr || new Error('kein HD-Frame')); } const self = this; function decide(result, err) { if (decided) return; decided = true; storedResult = result; storedErr = err; if (timer) { clearTimeout(timer); timer = null; } if (closed) { finalize(self); return; } // Prozess beenden; finalize() erst nach 'close' (= FD frei) self.stopping = true; try { p.kill('SIGTERM'); } catch (_e) {} setTimeout(() => { if (!closed) { try { p.kill('SIGKILL'); } catch (_e) {} } }, 800); hardFin = setTimeout(() => finalize(self), 2500); // falls 'close' ausbleibt } const parser = new MpjpegParser((frame) => { count++; if (!best || frame.length > best.length) best = frame; const w = readJpegWidth(frame); if (count >= settleFrames) { if (frame.length >= minSize && (w === null || w >= minWidth)) { decide(Buffer.from(frame), null); // kopieren: subarray teilt den Parser-Puffer } else { console.warn(`[cam ${this.id}] hires frame ${count} verworfen: ${frame.length} bytes width=${w ?? '?'} (minSize=${minSize}, minWidth=${minWidth})`); } } }); p.stdout.on('data', (c) => parser.push(c)); p.stderr.on('data', (c) => { const s = c.toString().trim(); if (!s) return; if (/error|busy|invalid|no such|cannot|denied|input is truncated|Could not find codec parameters|Immediate exit requested|Output file is empty/i.test(s)) { console.warn(`[cam ${this.id}] hires ffmpeg: ${s}`); } else { console.log(`[cam ${this.id}] hires ffmpeg: ${s}`); } }); p.on('error', (e) => { if (!decided) decide(null, e); }); p.on('close', () => { closed = true; if (decided) finalize(self); else decide(best ? Buffer.from(best) : null, best ? null : new Error('HD-FFmpeg vorzeitig beendet')); }); }); } } module.exports = { CameraSwitch, MpjpegParser, readJpegWidth };