260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
'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: --<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 }) {
|
||
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 };
|