Claude: ScreenShot v2

This commit is contained in:
chk
2026-06-04 16:22:05 +02:00
parent 9a6ff7df7c
commit 3d943d1ded

View File

@@ -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=<stream>&src=<quelle-uri-url-encoded>
// `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 };