Files
appRobotWebcam/src/cameraSwitch.js
2026-06-05 06:48:40 +02:00

260 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };