429 lines
19 KiB
JavaScript
429 lines
19 KiB
JavaScript
'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: --<boundary>\r\nContent-Type: image/jpeg\r\n
|
||
// Content-Length: <n>\r\n\r\n<n bytes JPEG>\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 };
|