Claude: Phase 2 experimental

This commit is contained in:
chk
2026-06-04 21:11:31 +02:00
parent c5198b70bd
commit e9f1ce73eb
3 changed files with 250 additions and 49 deletions

View File

@@ -4,14 +4,32 @@ const express = require('express');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Liest Bildbreite aus JPEG-Header (SOF0/SOF2-Marker) ohne externe Bibliothek.
// Gibt null zurück wenn der Marker nicht gefunden wird.
function readJpegWidth(buf) {
let i = 2; // SOI (FF D8) überspringen
while (i < buf.length - 8) {
if (buf[i] !== 0xFF) break;
const marker = buf[i + 1];
const segLen = buf.readUInt16BE(i + 2);
if (marker === 0xC0 || marker === 0xC2) {
return buf.readUInt16BE(i + 7); // Breite bei Offset +7 im SOF
}
i += 2 + segLen;
}
return null;
}
// 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 (aus go2rtc /api/streams)
// GET /api/snapshot/cam0 → aktuelles JPEG (aus go2rtc /api/frame.jpeg?src=cam0)
// GET /api/snapshot/cam0/release-test → Phase-1-Messung (nur lesend, s.u.)
// 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();
let hiresLock = false; // Mutex: nie zwei Hi-Res-Grabs gleichzeitig
// ── PHASE 1: Geräte-Freigabe messen (rein LESEND, kein Grab) ────────────────
// Linchpin-Test aus doc/05_screenShot_roadmap.md: Gibt go2rtc das Gerät frei,
@@ -89,6 +107,109 @@ function createSnapshotRouter(go2rtcUrl) {
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.
// cam{id}_hires muss in der go2rtc-Config definiert sein (docker-compose.yaml).
//
// Ablauf: Warten bis id 0 Consumer hat → cam_hires-Frame per frame.jpeg holen.
// Eiserne Regeln (04_Delay_roadmap.md): nur GET, kein PUT/PATCH/DELETE. ✓
router.get('/:id/hires', async (req, res) => {
const { id } = req.params;
const hiresId = `${id}_hires`;
if (hiresLock) {
return res.status(429).json({ error: 'Hi-Res-Grab läuft bereits bitte warten' });
}
hiresLock = true;
const t0 = Date.now();
try {
// Schritt 1: Warten bis id keine Consumer mehr hat (Gerät frei, max 8 s)
const POLL_MS = 200;
const MAX_WAIT = 8000;
const MIN_SIZE = 15000; // <15KB → Warmup-Schwarzbild, retry
let deviceFree = false;
while (Date.now() - t0 < MAX_WAIT) {
try {
const r = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) });
if (r.ok) {
const streams = await r.json();
const s = streams[id];
const nC = s ? (s.consumers ?? []).length : 0;
const pRunning = s ? (s.producers ?? []).some(p => (p.state ?? '') === 'running') : false;
if (nC === 0 && !pRunning) {
deviceFree = true;
console.log(`[hires][${id}] Gerät frei nach ${Date.now() - t0}ms`);
break;
}
}
} catch (e) {
console.warn(`[hires][${id}] Poll fehlgeschlagen: ${e.message}`);
}
await sleep(POLL_MS);
}
if (!deviceFree) {
return res.status(503).json({
error: `Gerät nicht frei nach ${MAX_WAIT}ms noch ${id}-Consumer aktiv?`,
});
}
// Schritt 2: Frame greifen (cam_hires on-demand, mit Warmup-Retry)
// go2rtc öffnet /dev/videoN bei der ersten Anfrage → erste Frames können
// unterbelichtet sein → Größen-Check; Retry gibt Kamera Zeit zum Einschwingen.
const MAX_RETRIES = 4;
const RETRY_MS = 800;
let jpeg = null;
let lastWidth = null;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const fr = await fetch(
`${go2rtcUrl}/api/frame.jpeg?src=${encodeURIComponent(hiresId)}`,
{ signal: AbortSignal.timeout(5000) }
);
if (fr.ok) {
const buf = Buffer.from(await fr.arrayBuffer());
const w = readJpegWidth(buf);
console.log(`[hires][${id}] Versuch ${attempt + 1}: ${buf.length} bytes, Breite=${w ?? '?'}`);
if (buf.length >= MIN_SIZE && (w === null || w >= 1000)) {
jpeg = buf;
lastWidth = w;
break;
}
}
} catch (e) {
console.warn(`[hires][${id}] frame.jpeg Versuch ${attempt + 1}: ${e.message}`);
}
if (attempt < MAX_RETRIES - 1) await sleep(RETRY_MS);
}
if (!jpeg) {
return res.status(503).json({ error: 'kein verwertbarer Hi-Res-Frame (Warmup-Timeout)' });
}
console.log(`[hires][${id}] OK ${jpeg.length} bytes, Breite=${lastWidth}, Dauer=${Date.now() - t0}ms`);
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': jpeg.length,
'Cache-Control': 'no-store',
'X-Camera-Id': id,
'X-Hires-Id': hiresId,
'X-Frame-Width': String(lastWidth ?? ''),
'X-Timestamp': new Date().toISOString(),
});
res.end(jpeg);
} catch (err) {
if (!res.headersSent) res.status(503).json({ error: `hires: ${err.message}` });
} finally {
hiresLock = false;
}
});
router.get('/', async (_req, res) => {
try {
const r = await fetch(`${go2rtcUrl}/api/streams`);