From eeb47f58c006974737060425fb4da7e36347225e Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:42:52 +0200 Subject: [PATCH] Claude: Putzen --- public/viewer.js | 33 --------- src/snapshotService.js | 77 ------------------- src/videoStream.js | 163 ----------------------------------------- 3 files changed, 273 deletions(-) delete mode 100644 src/videoStream.js diff --git a/public/viewer.js b/public/viewer.js index 64828f4..61857dd 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -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; diff --git a/src/snapshotService.js b/src/snapshotService.js index 4d7a7a6..7e1c18d 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -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 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 bereits entfernt (Umhängen), // BEVOR er diesen Endpunkt ruft. cam0/cam1 werden NICHT verändert. diff --git a/src/videoStream.js b/src/videoStream.js deleted file mode 100644 index 3afb6ff..0000000 --- a/src/videoStream.js +++ /dev/null @@ -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 };