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

@@ -30,6 +30,11 @@ configs:
# NICHT #video=copy: am 2026-06-04 getestet → CPU 50% → 107% (schlechter). Verworfen. # NICHT #video=copy: am 2026-06-04 getestet → CPU 50% → 107% (schlechter). Verworfen.
cam0: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg" cam0: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg"
cam1: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg" cam1: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg"
# Phase-2 Hi-Res: on-demand (dormant bis erster Consumer). #video=copy auf dieser
# Kamera defekt (04_*), daher #video=mjpeg. Nur ~1-2s aktiv pro Grab.
# Rollback: diese beiden Zeilen entfernen + Redeploy.
cam0_hires: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
cam1_hires: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
webrtc: webrtc:
listen: ":8555" listen: ":8555"
candidates: candidates:

View File

@@ -26,6 +26,7 @@ const P = '[WebcamViewer]';
const log = (c, m) => console.log(`${P}[${c}] ${m}`); const log = (c, m) => console.log(`${P}[${c}] ${m}`);
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`); const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? ''); const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
const sleep = ms => new Promise(r => setTimeout(r, ms));
let GO2RTC_PORT = 1984; let GO2RTC_PORT = 1984;
const cameras = []; // { id, box, infoEl, toggleBtn, active, startedAt, playingSince, statsLast, badTicks, autoOff } const cameras = []; // { id, box, infoEl, toggleBtn, active, startedAt, playingSince, statsLast, badTicks, autoOff }
@@ -72,41 +73,35 @@ function stopStream(cam, auto = false) {
if (auto) showNotice(); if (auto) showNotice();
} }
// ── Hi-Res-Test (Phase 1): Geräte-Freigabe messen ───────────────────────────── // ── Hi-Res Canvas-Freeze + Grab (Phase 2) ───────────────────────────────────
// Ablauf (doc/05_screenShot_roadmap.md, Phase 1):
// 1. aktuellen Live-Frame auf <canvas> einfrieren + „HD Image Work" einblenden // Holt letzten 640er-Frame von /api/snapshot und zeichnet ihn auf canvas.
// 2. <video-stream> entfernen → cam verliert seinen Consumer (das „Umhängen") // Robuster als drawImage(video-stream): go2rtc MJPEG-Modus hat kein <video>-Element.
// 3. GET /api/snapshot/:id/release-test → Server misst, wann das Gerät frei wird async function showFreezeCanvas(cam, badgeText = 'Capturing HD…') {
// 4. egal wie es ausgeht: Canvas weg, <video-stream> wieder einsetzen (Live zurück)
// cam selbst wird nie verändert; im schlimmsten Fall nur ein Reconnect.
function showFreezeCanvas(cam) {
removeFreezeCanvas(cam); removeFreezeCanvas(cam);
const W = 640, H = 480; const W = 640, H = 480;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.className = 'cam-freeze'; canvas.className = 'cam-freeze';
canvas.width = W; canvas.width = W;
canvas.height = H; canvas.height = H;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.fillStyle = '#111'; ctx.fillStyle = '#111';
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
// letzten gezeigten Frame einfrieren (go2rtc rendert je nach Modus video/img/canvas) try {
const src = cam.box.querySelector('video-stream video, video-stream img, video-stream canvas'); const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}`, { cache: 'no-store' });
if (src) { if (r.ok) {
try { ctx.drawImage(src, 0, 0, W, H); } catch (e) { logErr(cam.id, 'drawImage (Freeze)', e); } const url = URL.createObjectURL(await r.blob());
} await new Promise(resolve => {
const img = new Image();
// Badge „HD Image Work" unten rechts, ~30 % der Bildbreite, halbtransparent img.onload = () => { ctx.drawImage(img, 0, 0, W, H); URL.revokeObjectURL(url); resolve(); };
const bw = W * 0.30, bh = 34, m = 12; img.onerror = () => { URL.revokeObjectURL(url); resolve(); };
const bx = W - bw - m, by = H - bh - m; img.src = url;
ctx.fillStyle = 'rgba(0,0,0,.6)'; });
ctx.fillRect(bx, by, bw, bh); }
ctx.fillStyle = '#8f8'; } catch (e) { logErr(cam.id, 'Freeze-Frame holen', e); }
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('HD Image Work', bx + bw / 2, by + bh / 2);
drawBadge(ctx, W, H, badgeText, '#8cf');
cam.box.insertBefore(canvas, cam.box.firstChild); cam.box.insertBefore(canvas, cam.box.firstChild);
cam.freezeCanvas = canvas; cam.freezeCanvas = canvas;
} }
@@ -115,44 +110,124 @@ function removeFreezeCanvas(cam) {
if (cam.freezeCanvas) { cam.freezeCanvas.remove(); cam.freezeCanvas = null; } if (cam.freezeCanvas) { cam.freezeCanvas.remove(); cam.freezeCanvas = null; }
} }
function drawBadge(ctx, W, H, text, color = '#8cf') {
const bw = W * 0.38, bh = 34, m = 12;
const bx = W - bw - m, by = H - bh - m;
ctx.fillStyle = 'rgba(0,0,0,.75)';
ctx.fillRect(bx, by, bw, bh);
ctx.fillStyle = color;
ctx.font = '13px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + bw / 2, by + bh / 2);
}
function updateBadge(cam, text, color) {
if (!cam.freezeCanvas) return;
drawBadge(cam.freezeCanvas.getContext('2d'), 640, 480, text, color);
}
// ── Phase 2: Hi-Res-Grab ─────────────────────────────────────────────────────
// Ablauf (doc/05_screenShot_roadmap.md, Phase 2):
// 1. Live-Frame einfrieren + cam loslassen (Consumer → 0)
// 2. Server wartet auf Freigabe (cam0 Producer stoppt), greift dann cam0_hires
// 3. HD-JPEG im Canvas zeigen + Download auslösen
// 4. finally: immer zurück auf Live (cam0 bleibt unberührt → sauberer Reconnect)
async function runHiresGrab(cam) {
if (cam.testing) return;
cam.testing = true;
cam.hdBtn.disabled = true;
log(cam.id, '── HD-Grab gestartet ──');
let blobUrl = null;
try {
// 1. Freeze-Frame zeigen (echter 640er-Frame, kein grauer Kasten)
await showFreezeCanvas(cam, 'Capturing HD…');
stopStream(cam);
setInfo(cam, 'HD: warte auf Freigabe…', 'warn');
// 2. HD-Grab Server pollt Freigabe, holt dann cam_hires-Frame.
// Client-Timeout (20s) > Server-Maximum (~12s: 8s Warten + 4×0.8s Retries)
const r = await fetch(
`/api/snapshot/${encodeURIComponent(cam.id)}/hires`,
{ signal: AbortSignal.timeout(20000) }
);
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.error ?? `HTTP ${r.status}`);
}
const blob = await r.blob();
blobUrl = URL.createObjectURL(blob);
// 3a. HD-Frame im Canvas zeigen (skaliert auf 640px, volle Qualität)
if (cam.freezeCanvas) {
const ctx = cam.freezeCanvas.getContext('2d');
await new Promise(resolve => {
const img = new Image();
img.onload = () => { ctx.drawImage(img, 0, 0, 640, 480); resolve(); };
img.onerror = resolve;
img.src = blobUrl;
});
updateBadge(cam, 'HD ✓ speichere…', '#8f8');
}
// 3b. Download auslösen
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${cam.id}_hires_${Date.now()}.jpg`;
document.body.appendChild(a);
a.click();
a.remove();
setInfo(cam, 'HD gespeichert', 'ok');
log(cam.id, `HD-Grab OK ${blob.size} bytes`);
} catch (e) {
logErr(cam.id, 'HD-Grab fehlgeschlagen', e);
setInfo(cam, `HD Fehler: ${e.message}`, 'crit');
} finally {
// 4. Immer: kurz warten (go2rtc cam_hires freigeben), dann Live zurück
await sleep(600);
removeFreezeCanvas(cam);
if (blobUrl) { URL.revokeObjectURL(blobUrl); blobUrl = null; }
startStream(cam);
cam.testing = false;
cam.hdBtn.disabled = false;
log(cam.id, '── HD-Grab beendet, zurück auf Live ──');
}
}
// ── Phase-1-Diagnose-Tool (nicht mehr im UI, für Console-Aufruf) ─────────────
async function runReleaseTest(cam) { async function runReleaseTest(cam) {
if (cam.testing) return; if (cam.testing) return;
cam.testing = true; cam.testing = true;
cam.hdBtn.disabled = true; cam.hdBtn.disabled = true;
log(cam.id, '── Hi-Res-Test (Phase 1) gestartet ──'); log(cam.id, '── Release-Test (Phase 1 Diagnose) gestartet ──');
await showFreezeCanvas(cam, 'Release-Test…');
// 1. + 2. Frame einfrieren, dann cam loslassen (verliert seinen Consumer)
showFreezeCanvas(cam);
stopStream(cam); stopStream(cam);
setInfo(cam, 'HD-Test: messe Freigabe…', 'warn'); setInfo(cam, 'Release-Test…', 'warn');
try { try {
// 3. Server pollt /api/streams und misst die Freigabezeit (rein lesend).
// Client-Timeout (15s) > Server-Maximum (10s): hängt der Request, läuft
// trotzdem der finally-Recovery → cam kommt immer auf Live zurück.
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/release-test`, const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/release-test`,
{ signal: AbortSignal.timeout(15000) }); { signal: AbortSignal.timeout(15000) });
const data = await r.json(); const data = await r.json();
console.log(`${P}[${cam.id}] release-test JSON:`, data); console.log(`${P}[${cam.id}] release-test JSON:`, data);
if (data.freed) { if (data.freed) {
log(cam.id, ` Gerät frei nach ${data.msUntilFree} ms ` + log(cam.id, `✓ frei nach ${data.msUntilFree}ms`);
`(0-Consumer@${data.zeroConsumerAt}ms → Producer-Stop@${data.producerStoppedAt}ms)`);
setInfo(cam, `frei nach ${data.msUntilFree}ms`, 'ok'); setInfo(cam, `frei nach ${data.msUntilFree}ms`, 'ok');
} else { } else {
warn(cam.id, 'Gerät NICHT freigegeben (freed=false) go2rtc hält den Producer warm. ' + warn(cam.id, 'freed=false');
'Ansatz so nicht tragfähig (siehe Roadmap Phase 1).'); setInfo(cam, 'nicht freigegeben', 'crit');
setInfo(cam, 'nicht freigegeben (warm)', 'crit');
} }
} catch (e) { } catch (e) {
logErr(cam.id, 'release-test fehlgeschlagen', e); logErr(cam.id, 'release-test', e);
setInfo(cam, 'HD-Test Fehler', 'crit'); setInfo(cam, 'Release-Test Fehler', 'crit');
} finally { } finally {
// 4. Recovery: was auch passiert, zurück auf Live
removeFreezeCanvas(cam); removeFreezeCanvas(cam);
startStream(cam); startStream(cam);
cam.testing = false; cam.testing = false;
cam.hdBtn.disabled = false; cam.hdBtn.disabled = false;
log(cam.id, '── Hi-Res-Test beendet, zurück auf Live ──'); log(cam.id, '── Release-Test beendet ──');
} }
} }
@@ -309,8 +384,8 @@ function buildCamera(camId, container) {
const hd = document.createElement('button'); const hd = document.createElement('button');
hd.className = 'cam-hdtest'; hd.className = 'cam-hdtest';
hd.textContent = 'HD?'; hd.textContent = 'HD';
hd.title = 'Hi-Res-Test (Phase 1): Geräte-Freigabe messen'; hd.title = 'Hi-Res-Snapshot (1280×960) cam loslassen, hires-Grab, Download';
const cam = { const cam = {
id: camId, box, infoEl: info, toggleBtn: toggle, hdBtn: hd, id: camId, box, infoEl: info, toggleBtn: toggle, hdBtn: hd,
@@ -321,7 +396,7 @@ function buildCamera(camId, container) {
if (cam.testing) return; // während HD-Test gesperrt if (cam.testing) return; // während HD-Test gesperrt
cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice(); cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice();
}; };
hd.onclick = () => runReleaseTest(cam); hd.onclick = () => runHiresGrab(cam);
box.appendChild(label); box.appendChild(label);
box.appendChild(info); box.appendChild(info);

View File

@@ -4,14 +4,32 @@ const express = require('express');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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. // Stabile Snapshot-Schnittstelle für das Homing-Projekt.
// Entkoppelt den Consumer von go2rtc-Interna proxied intern auf /api/frame.jpeg. // 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 → JSON-Liste der Kameras
// GET /api/snapshot/cam0 → aktuelles JPEG (aus go2rtc /api/frame.jpeg?src=cam0) // GET /api/snapshot/cam0 → 640er JPEG (live)
// GET /api/snapshot/cam0/release-test → Phase-1-Messung (nur lesend, s.u.) // 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) { function createSnapshotRouter(go2rtcUrl) {
const router = express.Router(); const router = express.Router();
let hiresLock = false; // Mutex: nie zwei Hi-Res-Grabs gleichzeitig
// ── PHASE 1: Geräte-Freigabe messen (rein LESEND, kein Grab) ──────────────── // ── PHASE 1: Geräte-Freigabe messen (rein LESEND, kein Grab) ────────────────
// Linchpin-Test aus doc/05_screenShot_roadmap.md: Gibt go2rtc das Gerät frei, // 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 }); 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) => { router.get('/', async (_req, res) => {
try { try {
const r = await fetch(`${go2rtcUrl}/api/streams`); const r = await fetch(`${go2rtcUrl}/api/streams`);