'use strict'; const { spawn } = require('child_process'); const EventEmitter = require('events'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 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 }) { super(); this.setMaxListeners(0); // beliebig viele Stream-Clients this.id = id; this.device = device; this.liveSize = liveSize; this.liveFps = liveFps; this.hiresSize = hiresSize; this.hiresFps = hiresFps; this.proc = null; // aktueller FFmpeg-Prozess (Live ODER Grab) this.latest = null; // letztes Live-JPEG (für /api/snapshot) 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; } start() { if (this.state === 'stopped' && !this.proc) this._spawnLive(); } // ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ─────────────────── _spawnLive() { this.stopping = false; const args = [ '-hide_banner', '-loglevel', 'warning', '-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', ]; 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; const wasStopping = this.stopping; if (this.state === 'live') this.state = 'stopped'; if (wasStopping) return; // beabsichtigt (HD-Grab) → grabHires startet Live neu 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.restartTimer) return; this.restartTimer = setTimeout(() => { this.restartTimer = null; if (this.state === 'stopped' && !this.lock) this._spawnLive(); }, 1500); } // ── HD-Grab: Live sauber stoppen → 1280 greifen → Live zurück ────────────── // Garantie: zwischen Stop und 1280-Start liegt das `close`-Event des Live- // FFmpeg → /dev/videoN ist frei. Niemals zwei Encoder gleichzeitig. async grabHires(opts = {}) { const { minSize = 15000, minWidth = 1000, settleFrames = 6, maxWaitMs = 6000 } = 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) await this._killCurrentAndWait(); this.state = 'grabbing'; console.log(`[cam ${this.id}] HD: Live gestoppt nach ${Date.now() - t0}ms, Gerät frei → 1280-Grab`); // 2. 1280-FFmpeg starten, warmlaufen lassen, besten Frame greifen const jpeg = await this._captureHires({ minSize, minWidth, settleFrames, maxWaitMs }); 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 this.state = 'stopped'; this._spawnLive(); this.lock = false; console.log(`[cam ${this.id}] HD beendet, Live zurück (gesamt ${Date.now() - t0}ms)`); } } // 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 }); } _captureHires({ minSize, minWidth, settleFrames, maxWaitMs }) { return new Promise((resolve, reject) => { const args = [ '-hide_banner', '-loglevel', 'warning', '-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', ]; 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 && frame.length >= minSize && (w === null || w >= minWidth)) { decide(Buffer.from(frame), null); // kopieren: subarray teilt den Parser-Puffer } }); 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}] hires ffmpeg: ${s.trim()}`); }); 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 };