diff --git a/src/snapshotService.js b/src/snapshotService.js index cf961e1..4e16a5a 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -2,123 +2,27 @@ const express = require('express'); -// ── Kamera-Konfiguration ────────────────────────────────────────────────────── -// 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', - 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', - 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. +// Entkoppelt den Consumer von go2rtc-Interna – proxied intern auf /api/frame.jpeg. // -// GET /api/snapshot → JSON-Liste der Kameras -// 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 (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. +// GET /api/snapshot → JSON-Liste der Kameras (aus go2rtc /api/streams) +// GET /api/snapshot/cam0 → aktuelles JPEG (aus go2rtc /api/frame.jpeg?src=cam0) function createSnapshotRouter(go2rtcUrl) { const router = express.Router(); - // ── Kamera-Liste ───────────────────────────────────────────────────────────── router.get('/', async (_req, res) => { try { const r = await fetch(`${go2rtcUrl}/api/streams`); if (!r.ok) throw new Error(`go2rtc HTTP ${r.status}`); const streams = await r.json(); res.json({ - cameras: Object.keys(streams).map(id => ({ - id, - url: `/api/snapshot/${id}`, - hiresUrl: `/api/snapshot/${id}/hires`, - })), + cameras: Object.keys(streams).map(id => ({ id, url: `/api/snapshot/${id}` })), }); } catch (err) { res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` }); } }); - // ── 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 { @@ -145,65 +49,4 @@ function createSnapshotRouter(go2rtcUrl) { return router; } -// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── - -function sleep(ms) { - return new Promise(r => setTimeout(r, ms)); -} - -// 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(srcUrl)}`; -} - -// 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)` - ); -} - -// 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 };