Umbau mit cameraSwitch Fix Delay
This commit is contained in:
@@ -5,6 +5,17 @@ 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) {
|
||||
@@ -66,7 +77,7 @@ class MpjpegParser {
|
||||
//
|
||||
// Events: 'frame' (Buffer) – je ein Live-JPEG
|
||||
class CameraSwitch extends EventEmitter {
|
||||
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15 }) {
|
||||
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', onDemand = true, idleGraceMs = 15000 }) {
|
||||
super();
|
||||
this.setMaxListeners(0); // beliebig viele Stream-Clients
|
||||
this.id = id;
|
||||
@@ -75,28 +86,77 @@ class CameraSwitch extends EventEmitter {
|
||||
this.liveFps = liveFps;
|
||||
this.hiresSize = hiresSize;
|
||||
this.hiresFps = hiresFps;
|
||||
this.encode = encode; // 'copybsf' (Default, niedrige CPU) | 'mjpeg' (Re-Encode)
|
||||
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)
|
||||
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.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.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() {
|
||||
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,
|
||||
'-c:v', 'mjpeg', '-q:v', '5', '-f', 'mpjpeg', 'pipe:1',
|
||||
...videoOutArgs(this.encode),
|
||||
'-f', 'mpjpeg', '-flush_packets', '1', 'pipe:1', // jedes Frame sofort rausschreiben
|
||||
];
|
||||
let p;
|
||||
try {
|
||||
@@ -121,9 +181,10 @@ class CameraSwitch extends EventEmitter {
|
||||
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) → grabHires startet Live neu
|
||||
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();
|
||||
});
|
||||
@@ -131,10 +192,11 @@ class CameraSwitch extends EventEmitter {
|
||||
}
|
||||
|
||||
_scheduleRestart() {
|
||||
if (this.onDemand && this.subscribers === 0) return; // niemand schaut → nicht neu starten
|
||||
if (this.restartTimer) return;
|
||||
this.restartTimer = setTimeout(() => {
|
||||
this.restartTimer = null;
|
||||
if (this.state === 'stopped' && !this.lock) this._spawnLive();
|
||||
if (this.state === 'stopped' && !this.lock && (!this.onDemand || this.subscribers > 0)) this._spawnLive();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
@@ -159,11 +221,11 @@ class CameraSwitch extends EventEmitter {
|
||||
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
|
||||
// 3. Zurück auf Live, sofern noch Verbraucher da sind (On-Demand). Live hat Priorität.
|
||||
this.state = 'stopped';
|
||||
this._spawnLive();
|
||||
if (!this.onDemand || this.subscribers > 0) this._spawnLive();
|
||||
this.lock = false;
|
||||
console.log(`[cam ${this.id}] HD beendet, Live zurück (gesamt ${Date.now() - t0}ms)`);
|
||||
console.log(`[cam ${this.id}] HD beendet${(!this.onDemand || this.subscribers > 0) ? ', Live zurück' : ' (idle, kein Live-Restart)'} (gesamt ${Date.now() - t0}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +251,7 @@ class CameraSwitch extends EventEmitter {
|
||||
'-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',
|
||||
...videoOutArgs(this.encode), '-f', 'mpjpeg', 'pipe:1',
|
||||
];
|
||||
let p;
|
||||
try {
|
||||
|
||||
@@ -42,19 +42,23 @@ function createSnapshotRouter(switches) {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
router.get('/:id', async (req, res) => {
|
||||
const sw = switches[req.params.id];
|
||||
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
|
||||
const frame = sw.latest;
|
||||
if (!frame) return res.status(503).json({ error: 'noch kein Frame verfügbar' });
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': frame.length,
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Camera-Id': req.params.id,
|
||||
'X-Timestamp': new Date().toISOString(),
|
||||
});
|
||||
res.end(frame);
|
||||
try {
|
||||
// getFrame() startet die Kamera bei Bedarf on-demand und wartet auf ein frisches Bild
|
||||
const frame = await sw.getFrame();
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': frame.length,
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Camera-Id': req.params.id,
|
||||
'X-Timestamp': new Date().toISOString(),
|
||||
});
|
||||
res.end(frame);
|
||||
} catch (err) {
|
||||
res.status(503).json({ error: `kein Frame: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
@@ -76,27 +80,36 @@ function createStreamRouter(switches) {
|
||||
'Connection': 'close',
|
||||
'X-Camera-Id': req.params.id,
|
||||
});
|
||||
if (res.socket) res.socket.setNoDelay(true); // Nagle aus → kein Sammel-Delay
|
||||
|
||||
let closed = false;
|
||||
const cleanup = () => { if (!closed) { closed = true; sw.removeListener('frame', onFrame); } };
|
||||
const cleanup = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
sw.removeListener('frame', onFrame);
|
||||
sw.release(); // On-Demand: Verbraucher abmelden
|
||||
};
|
||||
|
||||
const onFrame = (buf) => {
|
||||
if (closed) return;
|
||||
// Backpressure: langsamer Client bremst die anderen nicht – Frames droppen
|
||||
if (res.writableLength > (1 << 20)) return;
|
||||
// try/catch: ein kaputter Client darf die anderen nicht aushungern
|
||||
// (ein werfender 'frame'-Listener würde sonst emit() abbrechen)
|
||||
// cork/uncork: Header+JPEG+Trailer als EIN TCP-Segment → minimale Latenz.
|
||||
// try/catch: ein kaputter Client darf die anderen nicht aushungern.
|
||||
try {
|
||||
res.cork();
|
||||
res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${buf.length}\r\n\r\n`);
|
||||
res.write(buf);
|
||||
res.write('\r\n');
|
||||
res.uncork();
|
||||
} catch (_e) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
sw.acquire(); // On-Demand: Verbraucher anmelden (startet Live falls nötig)
|
||||
sw.on('frame', onFrame);
|
||||
if (sw.latest) onFrame(sw.latest); // sofort erstes Bild
|
||||
if (sw.latest) onFrame(sw.latest); // sofort erstes Bild, falls schon eins da
|
||||
|
||||
req.on('close', cleanup);
|
||||
res.on('error', cleanup);
|
||||
|
||||
Reference in New Issue
Block a user