Files
appRobotWebcam/src/cameraSwitch.js
2026-06-07 10:42:28 +02:00

429 lines
19 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));
// 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 };