diff --git a/doc/04_Delay_roadmap.md b/doc/04_Delay_roadmap.md index f763830..aef958a 100644 --- a/doc/04_Delay_roadmap.md +++ b/doc/04_Delay_roadmap.md @@ -395,16 +395,51 @@ Zwei Bugs gefunden und sofort behoben: ersten, schwarzen Frame. Fix: erste 15 Frames verwerfen (`-vf select=gte(n,15)`), dann einen greifen. Kostet ~1 s mehr Blackout. +> **Hinweis:** Der hier beschriebene externe-FFmpeg-Grab (DELETE → eigener FFmpeg → +> PUT) wurde im zweiten Test verworfen — siehe nächster Abschnitt. Der PUT-Param-Fix +> (Bug 1) bleibt gültig (gleiche `name`+`src`-Konvention nutzt jetzt PATCH). + +### Zweiter Test (2026-06-04): externer Grab scheitert → Architektur-Pivot + +**Befund:** Live-Video stabil ✓. Aber `/hires` liefert `FFmpeg exit 1, kein Frame +erhalten` (curl: leeres 1KB-Bild). Video bleibt dabei durchgehend stabil. + +**Diagnose (belegt):** Das *Ausbleiben des Blackouts* ist der Beweis. Der externe-Grab- +Ansatz müsste das Video kurz schwarz schalten (DELETE stoppt den go2rtc-Producer). +Es bleibt aber stabil → go2rtc gibt das Gerät **nie frei**: Der offene Live-Viewer +reconnectet nach dem DELETE sofort, go2rtc startet den Producer per on-demand neu und +greift `/dev/video0` zurück, bevor der externe FFmpeg es öffnen kann → „device busy" +→ exit 1. **Eine USB-Kamera lässt sich nur einmal öffnen** — zwei Prozesse (go2rtc + +eigener FFmpeg) können nicht gleichzeitig zugreifen, und der Live-Viewer lässt go2rtc +immer gewinnen. Der Zwei-Prozess-Ansatz ist damit grundsätzlich falsch. + +**Lösung (umgesetzt): go2rtc-interner Hi-Res-Grab — kein zweiter Prozess.** +go2rtc behält die Geräte-Hoheit. Node schaltet nur kurz dessen Quelle um: + +``` +1. PATCH /api/streams?name=cam0&src=<1280×960-Quelle> → go2rtc-Producer auf Hi-Res +2. ~1,2s warten (Producer-Start + Kamera-Belichtung) +3. GET /api/frame.jpeg?src=cam0 → Frame holen; nur akzeptieren wenn JPEG ≥1000px + breit (sonst ist es noch der alte 640er); bis zu 6× alle 500ms retryen +4. PATCH /api/streams?name=cam0&src=<640×480-Quelle> → zurück auf Live (immer, finally) +``` + +Nur **ein** Prozess (go2rtc) öffnet je das Gerät → keine Konkurrenz mehr möglich. +Der Live-Viewer dieser einen Kamera glitcht ~3–4s (Producer-Restart + kurz 1280er +Bild, vom Browser per CSS skaliert) — der vom Nutzer ausdrücklich akzeptierte „Blackout". +Die zweite Kamera ist nicht betroffen. Umgesetzt in `src/snapshotService.js` +(externer FFmpeg + `captureOneFrame` entfernt). + ### Offene Punkte (ToDo) -- **go2rtc-CPU ~53% bei 2 aktiven Live-Streams.** Besser als H.264-Transcode (~127%), +- **go2rtc-CPU ~50% bei 2 aktiven Live-Streams.** Besser als H.264-Transcode (~127%), aber kein echtes Null. go2rtc re-encodiert MJPEG→MJPEG (kein `-c:v copy`) statt - reinem Durchreichen. Das sind ~0,5 CPU-Kerne für 2 Kameras → stabil und unkritisch - auf dieser Maschine. **Optionaler Hebel falls je nötig:** prüfen ob go2rtc-Quelle - auf echtes Copy/Passthrough umstellbar ist. Risiko: Stabilität des laufenden, - funktionierenden Streams — daher nur anfassen wenn CPU real zum Problem wird. -- **Geräte-Race bei Hi-Res mit gleichzeitig offenem Live-Tab.** Ist ein Live-Consumer - aktiv, kann go2rtc das Gerät nach dem DELETE per on-demand-Reconnect sofort wieder - greifen und mit dem Hi-Res-Grab kollidieren. Warmup + Frame-Verwerfen fängt das - meist ab. Falls doch leere Bilder auftreten: kurzer Retry im Grab, oder Live-Tab - vor dem Hi-Res-Klick kurz pausieren. + reinem Durchreichen. ~0,5 CPU-Kerne für 2 Kameras → stabil und unkritisch auf dieser + Maschine. **Optionaler Hebel falls je nötig:** prüfen ob go2rtc-Quelle auf echtes + Copy/Passthrough umstellbar ist. Risiko: Stabilität des laufenden Streams — nur + anfassen wenn CPU real zum Problem wird. +- **Cleanup (unkritisch):** Der webcam-Container braucht jetzt **kein** `ffmpeg` und + **keine** `devices`/`group_add: video` mehr (kein externer Grab). Kann beim nächsten + bewussten Aufräumen aus `docker-compose.yaml` raus — aktuell harmlos (nur ungenutzt). +- **Falls der PATCH-Restart je hakt** (frame.jpeg bleibt zu klein/640): Warmup-Zeit + oder Retry-Anzahl in `snapshotService.js` erhöhen (`HIRES_WARMUP_MS`, `HIRES_TRIES`). diff --git a/src/snapshotService.js b/src/snapshotService.js index db7436d..cf961e1 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -1,34 +1,46 @@ 'use strict'; -const express = require('express'); -const { spawn } = require('child_process'); +const express = require('express'); // ── Kamera-Konfiguration ────────────────────────────────────────────────────── -// Muss zur go2rtc-Config in docker-compose.yaml passen. +// liveUrl MUSS exakt der go2rtc-Config (docker-compose.yaml) entsprechen, damit +// nach dem Hi-Res-Grab der Live-Stream identisch wiederhergestellt wird. +// hiresUrl gleiche Kamera, nur höhere Auflösung – wird NUR kurz für den Snapshot +// aktiviert. const CAM_CONFIG = { cam0: { - device: '/dev/video0', - hiresSize: '1280x960', - streamUrl: 'ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg', + device: '/dev/video0', + liveUrl: 'ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg', + hiresUrl: 'ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg', }, cam1: { - device: '/dev/video2', - hiresSize: '1280x960', - streamUrl: 'ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg', + device: '/dev/video2', + liveUrl: 'ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg', + hiresUrl: 'ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg', }, }; +// Hi-Res-Grab-Parameter +const HIRES_MIN_WIDTH = 1000; // akzeptiere nur Frames ≥1000px breit (sonst noch der 640er) +const HIRES_WARMUP_MS = 1200; // Producer-Start + Kamera-Belichtung abwarten +const HIRES_TRIES = 6; // so oft frame.jpeg pollen +const HIRES_GAP_MS = 500; // Pause zwischen den Versuchen + // Stabile Snapshot-Schnittstelle für das Homing-Projekt. // // GET /api/snapshot → JSON-Liste der Kameras -// GET /api/snapshot/cam0 → aktueller Frame (640×480, go2rtc passthrough) -// GET /api/snapshot/cam0/hires → einmaliger Hi-Res-Frame (1280×960, Blackout ~1–2 s) +// GET /api/snapshot/cam0 → aktueller Frame in Live-Auflösung (640×480) +// GET /api/snapshot/cam0/hires → einmaliger Hi-Res-Frame (1280×960) // -// Hi-Res-Ablauf: -// 1. go2rtc-Stream temporär löschen → Gerät wird freigegeben -// 2. FFmpeg one-shot direkt auf /dev/videoX → 1280×960 MJPEG -// 3. go2rtc-Stream wiederherstellen → Live-Video läuft wieder +// Hi-Res-Ablauf (go2rtc-intern, KEIN zweiter Prozess auf dem Gerät): +// 1. go2rtc-Quelle per PATCH kurz auf 1280×960 umschalten +// 2. Producer-Start + Belichtung abwarten +// 3. Frame über go2rtc /api/frame.jpeg holen (nur akzeptieren wenn wirklich 1280) +// 4. Quelle per PATCH zurück auf 640×480 (immer, auch im Fehlerfall) // +// Warum so: Eine USB-Kamera kann nur EINMAL geöffnet werden. Ein externer FFmpeg +// würde mit go2rtc um das Gerät konkurrieren (go2rtc gewinnt durch den Live-Viewer +// → "device busy"). Indem nur go2rtc das Gerät hält, gibt es keine Konkurrenz. function createSnapshotRouter(go2rtcUrl) { const router = express.Router(); @@ -50,7 +62,63 @@ function createSnapshotRouter(go2rtcUrl) { } }); - // ── Standard-Snapshot (Stream-Auflösung, sofort) ───────────────────────────── + // ── Hi-Res-Snapshot (1280×960, go2rtc-intern) ──────────────────────────────── + // Vor /:id registrieren ist nicht nötig (andere Pfadtiefe), aber explizit sauber. + let hiresLock = false; + + router.get('/:id/hires', async (req, res) => { + const { id } = req.params; + const cfg = CAM_CONFIG[id]; + if (!cfg) { + return res.status(404).json({ error: `Kamera '${id}' nicht in CAM_CONFIG` }); + } + if (hiresLock) { + return res.status(429).json({ error: 'Hi-Res-Snapshot läuft bereits – bitte warten' }); + } + + hiresLock = true; + console.log(`[snapshot][${id}] hires-Start → 1280×960`); + + try { + // 1. go2rtc-Quelle auf Hi-Res umschalten (go2rtc behält die Geräte-Hoheit) + const p1 = await fetch(streamApiUrl(go2rtcUrl, id, cfg.hiresUrl), { method: 'PATCH' }); + console.log(`[snapshot][${id}] PATCH → hires: HTTP ${p1.status}`); + + // 2. Producer-Start + Kamera-Belichtung abwarten + await sleep(HIRES_WARMUP_MS); + + // 3. Frame holen, bis er wirklich in Hi-Res ankommt + const jpeg = await grabHiresFrame(go2rtcUrl, id); + const width = jpegWidth(jpeg); + console.log(`[snapshot][${id}] Frame ${jpeg.length} bytes, ${width}px breit`); + + res.set({ + 'Content-Type': 'image/jpeg', + 'Content-Length': jpeg.length, + 'Cache-Control': 'no-store', + 'X-Camera-Id': id, + 'X-Resolution': `${width}px`, + 'X-Timestamp': new Date().toISOString(), + }); + res.end(jpeg); + + } catch (err) { + console.error(`[snapshot][${id}] hires-Fehler:`, err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + + } finally { + // 4. IMMER zurück auf Live-Auflösung – auch bei Fehler + try { + const p2 = await fetch(streamApiUrl(go2rtcUrl, id, cfg.liveUrl), { method: 'PATCH' }); + console.log(`[snapshot][${id}] PATCH → live zurück: HTTP ${p2.status}`); + } catch (restoreErr) { + console.error(`[snapshot][${id}] Live-Wiederherstellung fehlgeschlagen:`, restoreErr.message); + } + hiresLock = false; + } + }); + + // ── Standard-Snapshot (Live-Auflösung, sofort) ─────────────────────────────── router.get('/:id', async (req, res) => { const { id } = req.params; try { @@ -74,72 +142,6 @@ function createSnapshotRouter(go2rtcUrl) { } }); - // ── Hi-Res-Snapshot (Blackout ~1–2 s, 1280×960) ────────────────────────────── - let hiresLock = false; - - router.get('/:id/hires', async (req, res) => { - const { id } = req.params; - const cfg = CAM_CONFIG[id]; - if (!cfg) { - return res.status(404).json({ error: `Kamera '${id}' nicht in CAM_CONFIG` }); - } - if (hiresLock) { - return res.status(429).json({ error: 'Hi-Res-Snapshot läuft bereits – bitte warten' }); - } - - hiresLock = true; - console.log(`[snapshot][${id}] hires-Start (${cfg.hiresSize})`); - - try { - // 1. go2rtc-Stream stoppen → gibt /dev/videoX frei - const delRes = await fetch( - `${go2rtcUrl}/api/streams?src=${encodeURIComponent(id)}`, - { method: 'DELETE' } - ); - console.log(`[snapshot][${id}] go2rtc DELETE stream → HTTP ${delRes.status}`); - - // kurz warten bis FFmpeg-Prozess in go2rtc beendet und Gerät freigegeben ist - await sleep(900); - - // 2. Hi-Res-Frame via FFmpeg one-shot - const jpeg = await captureOneFrame(cfg.device, cfg.hiresSize); - console.log(`[snapshot][${id}] Frame captured (${jpeg.length} bytes)`); - - // 3. go2rtc-Stream wiederherstellen. - // go2rtc-API: PUT /api/streams?name=&src= - // Quelle steht im `src`-Query-Param (URL-encoded), NICHT im Body. - const putRes = await fetch(buildPutUrl(go2rtcUrl, id, cfg.streamUrl), { method: 'PUT' }); - console.log(`[snapshot][${id}] go2rtc PUT stream → HTTP ${putRes.status}`); - - res.set({ - 'Content-Type': 'image/jpeg', - 'Content-Length': jpeg.length, - 'Cache-Control': 'no-store', - 'X-Camera-Id': id, - 'X-Resolution': cfg.hiresSize, - 'X-Timestamp': new Date().toISOString(), - }); - res.end(jpeg); - - } catch (err) { - console.error(`[snapshot][${id}] hires-Fehler:`, err.message); - - // Stream auf jeden Fall wiederherstellen, auch im Fehlerfall - try { - await fetch(buildPutUrl(go2rtcUrl, id, cfg.streamUrl), { method: 'PUT' }); - console.log(`[snapshot][${id}] Stream nach Fehler wiederhergestellt`); - } catch (restoreErr) { - console.error(`[snapshot][${id}] Stream-Wiederherstellung fehlgeschlagen:`, restoreErr.message); - } - - if (!res.headersSent) { - res.status(500).json({ error: err.message }); - } - } finally { - hiresLock = false; - } - }); - return router; } @@ -149,62 +151,59 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } -// go2rtc-API zum (Wieder-)Anlegen eines Streams: -// PUT /api/streams?name=&src= -// Beide Werte als Query-Param. `src` ist die QUELLE (nicht der Name) — go2rtc -// liest sie NICHT aus dem Body. -function buildPutUrl(go2rtcUrl, name, streamUrl) { +// go2rtc-API zum Ändern der Quelle eines bestehenden Streams: +// PATCH /api/streams?name=&src= +// `src` ist die QUELLE (URL-encoded), `name` der Stream-Name. Kein Body. +function streamApiUrl(go2rtcUrl, name, srcUrl) { return `${go2rtcUrl}/api/streams` + `?name=${encodeURIComponent(name)}` - + `&src=${encodeURIComponent(streamUrl)}`; + + `&src=${encodeURIComponent(srcUrl)}`; } -// Startet FFmpeg, liest genau einen MJPEG-Frame aus stdout, gibt ihn als Buffer zurück. -function captureOneFrame(device, size, timeoutMs = 8000) { - return new Promise((resolve, reject) => { - const args = [ - '-hide_banner', '-loglevel', 'error', - '-f', 'v4l2', - '-input_format', 'mjpeg', - '-video_size', size, - '-framerate', '15', - '-i', device, - // Erste ~15 Frames verwerfen: die USB-Kamera liefert direkt nach dem Öffnen - // noch unbelichtete (schwarze) Frames – Auto-Belichtung/Weissabgleich brauchen - // einen Moment. Ohne das kommt das "1KB leer/schwarz"-Bild. - '-vf', 'select=gte(n\\,15)', - '-frames:v', '1', - '-q:v', '1', // beste JPEG-Qualität - '-f', 'mjpeg', - 'pipe:1', - ]; - - const chunks = []; - const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); - - proc.stdout.on('data', chunk => chunks.push(chunk)); - proc.stderr.on('data', () => {}); // FFmpeg-Infos unterdrücken (loglevel error) - - const timer = setTimeout(() => { - proc.kill('SIGKILL'); - reject(new Error(`FFmpeg timeout nach ${timeoutMs}ms`)); - }, timeoutMs); - - proc.on('close', code => { - clearTimeout(timer); - const buf = Buffer.concat(chunks); - if (buf.length > 0) { - resolve(buf); - } else { - reject(new Error(`FFmpeg exit ${code}, kein Frame erhalten`)); +// Holt von go2rtc so lange frame.jpeg, bis ein echter Hi-Res-Frame (≥HIRES_MIN_WIDTH +// breit) ankommt. Verhindert, dass noch der zwischengespeicherte 640er-Frame oder +// ein schwarzer Warmup-Frame zurückgegeben wird. +async function grabHiresFrame(go2rtcUrl, id) { + let best = null; + for (let i = 0; i < HIRES_TRIES; i++) { + try { + const r = await fetch(`${go2rtcUrl}/api/frame.jpeg?src=${encodeURIComponent(id)}`); + if (r.ok) { + const buf = Buffer.from(await r.arrayBuffer()); + if (jpegWidth(buf) >= HIRES_MIN_WIDTH) return buf; // echter Hi-Res-Frame + if (!best || buf.length > best.length) best = buf; // bestes Fallback merken } - }); + } catch { /* Netzfehler → retry */ } + await sleep(HIRES_GAP_MS); + } + throw new Error( + `kein ${HIRES_MIN_WIDTH}px-Frame nach ${HIRES_TRIES} Versuchen ` + + `(PATCH→hires erfolgreich? Producer-Restart langsam? Warmup erhöhen)` + ); +} - proc.on('error', err => { - clearTimeout(timer); - reject(err); - }); - }); +// Liest die Bildbreite aus den JPEG-Headern (SOF-Marker) – ohne externe Library. +// Gibt 0 zurück, wenn nichts gefunden wird. +function jpegWidth(buf) { + if (!buf || buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return 0; + let i = 2; + while (i + 9 < buf.length) { + if (buf[i] !== 0xff) { i++; continue; } + let marker = buf[i + 1]; + while (marker === 0xff && i + 1 < buf.length) { i++; marker = buf[i + 1]; } // Füllbytes + // Standalone-Marker ohne Längenfeld + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { + i += 2; continue; + } + // SOF-Marker tragen die Dimensionen (DHT=C4, JPG=C8, DAC=CC ausgenommen) + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return buf.readUInt16BE(i + 7); // FF Cx, len(2), precision(1), height(2), width(2) + } + const len = buf.readUInt16BE(i + 2); + if (len < 2) return 0; + i += 2 + len; + } + return 0; } module.exports = { createSnapshotRouter };