Claude: Putzen
This commit is contained in:
@@ -198,39 +198,6 @@ async function runHiresGrab(cam) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase-1-Diagnose-Tool (nicht mehr im UI, für Console-Aufruf) ─────────────
|
||||
async function runReleaseTest(cam) {
|
||||
if (cam.testing) return;
|
||||
cam.testing = true;
|
||||
cam.hdBtn.disabled = true;
|
||||
log(cam.id, '── Release-Test (Phase 1 Diagnose) gestartet ──');
|
||||
await showFreezeCanvas(cam, 'Release-Test…');
|
||||
stopStream(cam);
|
||||
setInfo(cam, 'Release-Test…', 'warn');
|
||||
try {
|
||||
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/release-test`,
|
||||
{ signal: AbortSignal.timeout(15000) });
|
||||
const data = await r.json();
|
||||
console.log(`${P}[${cam.id}] release-test JSON:`, data);
|
||||
if (data.freed) {
|
||||
log(cam.id, `✓ frei nach ${data.msUntilFree}ms`);
|
||||
setInfo(cam, `frei nach ${data.msUntilFree}ms`, 'ok');
|
||||
} else {
|
||||
warn(cam.id, 'freed=false');
|
||||
setInfo(cam, 'nicht freigegeben', 'crit');
|
||||
}
|
||||
} catch (e) {
|
||||
logErr(cam.id, 'release-test', e);
|
||||
setInfo(cam, 'Release-Test Fehler', 'crit');
|
||||
} finally {
|
||||
removeFreezeCanvas(cam);
|
||||
startStream(cam);
|
||||
cam.testing = false;
|
||||
cam.hdBtn.disabled = false;
|
||||
log(cam.id, '── Release-Test beendet ──');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Health-Anzeige ───────────────────────────────────────────────────────────
|
||||
function setInfo(cam, text, cls) {
|
||||
cam.infoEl.textContent = text;
|
||||
|
||||
@@ -25,88 +25,11 @@ function readJpegWidth(buf) {
|
||||
//
|
||||
// GET /api/snapshot → JSON-Liste der Kameras
|
||||
// GET /api/snapshot/cam0 → 640er JPEG (live)
|
||||
// GET /api/snapshot/cam0/release-test → Phase-1-Freigabe-Messung (nur lesend)
|
||||
// GET /api/snapshot/cam0/hires → 1280×960 JPEG via cam0_hires (Phase 2)
|
||||
function createSnapshotRouter(go2rtcUrl) {
|
||||
const router = express.Router();
|
||||
const hiresLocks = {}; // Mutex pro Kamera: { cam0: false, cam1: false, … }
|
||||
|
||||
// ── PHASE 1: Geräte-Freigabe messen (rein LESEND, kein Grab) ────────────────
|
||||
// Linchpin-Test aus doc/05_screenShot_roadmap.md: Gibt go2rtc das Gerät frei,
|
||||
// wenn cam0 den letzten Consumer verliert – und wie schnell?
|
||||
//
|
||||
// Voraussetzung: der Client hat seinen <video-stream> für :id bereits entfernt
|
||||
// (das „Umhängen"), BEVOR er diesen Endpunkt ruft. cam0 wird hier NICHT verändert
|
||||
// – wir pollen nur /api/streams und beobachten, wann der Producer stoppt.
|
||||
//
|
||||
// Antwort z.B.: { freed: true, msUntilFree: 1700, zeroConsumerAt, producerStoppedAt, samples }
|
||||
router.get('/:id/release-test', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const POLL_MS = 200;
|
||||
const MAX_MS = 10000;
|
||||
const t0 = Date.now();
|
||||
const samples = [];
|
||||
let zeroConsumerAt = null; // ms ab t0, sobald 0 Consumer beobachtet
|
||||
let producerStoppedAt = null; // ms ab t0, sobald kein laufender Producer mehr
|
||||
|
||||
console.log(`[release-test][${id}] Start – polle /api/streams alle ${POLL_MS}ms (max ${MAX_MS}ms)`);
|
||||
|
||||
while (Date.now() - t0 < MAX_MS) {
|
||||
const elapsed = Date.now() - t0;
|
||||
let nConsumers = null;
|
||||
let producerRunning = null;
|
||||
|
||||
try {
|
||||
// Per-Poll-Timeout: hängt go2rtc, darf das nicht den ganzen Endpunkt
|
||||
// blockieren (sonst kommt der Client nie zurück auf Live → Regel 4).
|
||||
const r = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) });
|
||||
if (r.ok) {
|
||||
const streams = await r.json();
|
||||
const s = streams[id];
|
||||
if (s) {
|
||||
// Shape vgl. server.js-Monitor: producers[].state ('running'|'stop'|…), consumers[]
|
||||
const producers = s.producers ?? [];
|
||||
const consumers = s.consumers ?? [];
|
||||
nConsumers = consumers.length;
|
||||
producerRunning = producers.some((p) => (p.state ?? '') === 'running');
|
||||
} else {
|
||||
// Stream gar nicht (mehr) gelistet → kein Producer, keine Consumer
|
||||
nConsumers = 0;
|
||||
producerRunning = false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// einzelner Poll-Fehler ist nicht fatal – weiter messen
|
||||
console.warn(`[release-test][${id}] Poll @${elapsed}ms fehlgeschlagen: ${e.message}`);
|
||||
}
|
||||
|
||||
samples.push({ t: elapsed, consumers: nConsumers, producerRunning });
|
||||
|
||||
if (zeroConsumerAt === null && nConsumers === 0) {
|
||||
zeroConsumerAt = elapsed;
|
||||
console.log(`[release-test][${id}] 0 Consumer @${elapsed}ms`);
|
||||
}
|
||||
if (zeroConsumerAt !== null && producerRunning === false && producerStoppedAt === null) {
|
||||
producerStoppedAt = elapsed;
|
||||
console.log(`[release-test][${id}] Producer gestoppt @${elapsed}ms → Gerät frei`);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
|
||||
const freed = producerStoppedAt !== null;
|
||||
const msUntilFree =
|
||||
freed && zeroConsumerAt !== null ? producerStoppedAt - zeroConsumerAt : null;
|
||||
|
||||
console.log(
|
||||
`[release-test][${id}] Ergebnis: freed=${freed} msUntilFree=${msUntilFree} ` +
|
||||
`(0-Consumer@${zeroConsumerAt}ms, Producer-Stop@${producerStoppedAt}ms)`
|
||||
);
|
||||
|
||||
res.json({ id, freed, msUntilFree, zeroConsumerAt, producerStoppedAt, samples });
|
||||
});
|
||||
|
||||
// ── PHASE 2: Hi-Res-Grab via cam0_hires (rein LESEND gegenüber cam0/cam1) ────
|
||||
// Voraussetzung: Client hat seinen <video-stream> bereits entfernt (Umhängen),
|
||||
// BEVOR er diesen Endpunkt ruft. cam0/cam1 werden NICHT verändert.
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const { WebSocket } = require('ws');
|
||||
|
||||
// JPEG frame boundaries
|
||||
const SOI = Buffer.from([0xff, 0xd8]);
|
||||
const EOI = Buffer.from([0xff, 0xd9]);
|
||||
|
||||
// Max buffer before we assume stream corruption and discard
|
||||
const MAX_BUFFER = 2 * 1024 * 1024;
|
||||
|
||||
// Drop frames to clients with a clogged send buffer (slow network/tab)
|
||||
const MAX_CLIENT_BUFFER = 512 * 1024;
|
||||
|
||||
class VideoStream {
|
||||
constructor(device, options = {}) {
|
||||
this.device = device;
|
||||
this.name = options.name ?? 'cam';
|
||||
this.width = options.width ?? 640;
|
||||
this.height = options.height ?? 480;
|
||||
this.fps = options.fps ?? 30;
|
||||
this.quality = options.quality ?? 5; // FFmpeg MJPEG quality: 2=best … 31=worst
|
||||
|
||||
this._clients = new Set();
|
||||
this._process = null;
|
||||
this._restartTimer = null;
|
||||
this._restartDelay = 1000;
|
||||
this._running = false;
|
||||
this._latestFrame = null;
|
||||
}
|
||||
|
||||
get latestFrame() { return this._latestFrame; }
|
||||
get isRunning() { return this._running; }
|
||||
get clientCount() { return this._clients.size; }
|
||||
|
||||
start() {
|
||||
if (this._running) return;
|
||||
this._running = true;
|
||||
this._spawn();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._running = false;
|
||||
clearTimeout(this._restartTimer);
|
||||
if (this._process) {
|
||||
this._process.kill('SIGKILL');
|
||||
this._process = null;
|
||||
}
|
||||
}
|
||||
|
||||
addClient(ws) {
|
||||
this._clients.add(ws);
|
||||
// Send latest frame immediately – client sees picture right away
|
||||
if (this._latestFrame) {
|
||||
ws.send(this._latestFrame, { binary: true });
|
||||
}
|
||||
}
|
||||
|
||||
removeClient(ws) {
|
||||
this._clients.delete(ws);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFmpeg pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_buildArgs() {
|
||||
return [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
|
||||
// Minimize input buffering for low latency
|
||||
'-fflags', 'nobuffer',
|
||||
'-flags', 'low_delay',
|
||||
'-probesize', '32',
|
||||
'-analyzeduration', '0',
|
||||
|
||||
// Input: USB camera via Video4Linux2
|
||||
'-f', 'v4l2',
|
||||
'-input_format', 'mjpeg', // prefer hardware MJPEG (no re-decode if res matches)
|
||||
'-video_size', `${this.width}x${this.height}`,
|
||||
'-framerate', String(this.fps),
|
||||
'-i', this.device,
|
||||
|
||||
// Output: scale to target size, encode as MJPEG to stdout
|
||||
// If camera delivers native MJPEG at target resolution, consider -vcodec copy
|
||||
'-vf', `scale=${this.width}:${this.height}`,
|
||||
'-f', 'mjpeg',
|
||||
'-q:v', String(this.quality),
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
_spawn() {
|
||||
console.log(`[${this.name}] starting ffmpeg on ${this.device}`);
|
||||
const startedAt = Date.now();
|
||||
const proc = spawn('ffmpeg', this._buildArgs(), {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this._process = proc;
|
||||
|
||||
let buf = Buffer.alloc(0);
|
||||
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const soi = buf.indexOf(SOI, offset);
|
||||
if (soi === -1) break;
|
||||
const eoi = buf.indexOf(EOI, soi + 2);
|
||||
if (eoi === -1) break;
|
||||
|
||||
const frame = buf.slice(soi, eoi + 2);
|
||||
this._latestFrame = frame;
|
||||
this._broadcast(frame);
|
||||
this._restartDelay = 1000; // reset backoff on good frames
|
||||
offset = eoi + 2;
|
||||
}
|
||||
|
||||
buf = offset > 0 ? buf.slice(offset) : buf;
|
||||
if (buf.length > MAX_BUFFER) {
|
||||
console.warn(`[${this.name}] buffer overflow, discarding`);
|
||||
buf = Buffer.alloc(0);
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
const msg = chunk.toString().trimEnd();
|
||||
if (msg) console.error(`[${this.name}] ffmpeg: ${msg}`);
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
this._process = null;
|
||||
const uptime = Date.now() - startedAt;
|
||||
console.log(`[${this.name}] ffmpeg closed (code=${code}, uptime=${uptime}ms)`);
|
||||
|
||||
if (!this._running) return;
|
||||
|
||||
// Exponential backoff: 1s → 2s → 4s → 8s max
|
||||
console.log(`[${this.name}] restart in ${this._restartDelay}ms`);
|
||||
this._restartTimer = setTimeout(() => {
|
||||
this._restartDelay = Math.min(this._restartDelay * 2, 8000);
|
||||
this._spawn();
|
||||
}, this._restartDelay);
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error(`[${this.name}] spawn error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
_broadcast(frame) {
|
||||
for (const ws of this._clients) {
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
// Drop frame for slow clients rather than queuing indefinitely
|
||||
if (ws.bufferedAmount > MAX_CLIENT_BUFFER) continue;
|
||||
ws.send(frame, { binary: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { VideoStream };
|
||||
Reference in New Issue
Block a user