Claude: Phase 2 experimental
This commit is contained in:
@@ -30,6 +30,11 @@ configs:
|
||||
# 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"
|
||||
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:
|
||||
listen: ":8555"
|
||||
candidates:
|
||||
|
||||
163
public/viewer.js
163
public/viewer.js
@@ -26,6 +26,7 @@ const P = '[WebcamViewer]';
|
||||
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
|
||||
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
|
||||
const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
let GO2RTC_PORT = 1984;
|
||||
const cameras = []; // { id, box, infoEl, toggleBtn, active, startedAt, playingSince, statsLast, badTicks, autoOff }
|
||||
@@ -72,14 +73,11 @@ function stopStream(cam, auto = false) {
|
||||
if (auto) showNotice();
|
||||
}
|
||||
|
||||
// ── Hi-Res-Test (Phase 1): Geräte-Freigabe messen ─────────────────────────────
|
||||
// Ablauf (doc/05_screenShot_roadmap.md, Phase 1):
|
||||
// 1. aktuellen Live-Frame auf <canvas> einfrieren + „HD Image Work" einblenden
|
||||
// 2. <video-stream> entfernen → cam verliert seinen Consumer (das „Umhängen")
|
||||
// 3. GET /api/snapshot/:id/release-test → Server misst, wann das Gerät frei wird
|
||||
// 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) {
|
||||
// ── Hi-Res Canvas-Freeze + Grab (Phase 2) ───────────────────────────────────
|
||||
|
||||
// Holt letzten 640er-Frame von /api/snapshot und zeichnet ihn auf canvas.
|
||||
// Robuster als drawImage(video-stream): go2rtc MJPEG-Modus hat kein <video>-Element.
|
||||
async function showFreezeCanvas(cam, badgeText = 'Capturing HD…') {
|
||||
removeFreezeCanvas(cam);
|
||||
const W = 640, H = 480;
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -90,23 +88,20 @@ function showFreezeCanvas(cam) {
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// letzten gezeigten Frame einfrieren (go2rtc rendert je nach Modus video/img/canvas)
|
||||
const src = cam.box.querySelector('video-stream video, video-stream img, video-stream canvas');
|
||||
if (src) {
|
||||
try { ctx.drawImage(src, 0, 0, W, H); } catch (e) { logErr(cam.id, 'drawImage (Freeze)', e); }
|
||||
try {
|
||||
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}`, { cache: 'no-store' });
|
||||
if (r.ok) {
|
||||
const url = URL.createObjectURL(await r.blob());
|
||||
await new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => { ctx.drawImage(img, 0, 0, W, H); URL.revokeObjectURL(url); resolve(); };
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
} catch (e) { logErr(cam.id, 'Freeze-Frame holen', e); }
|
||||
|
||||
// Badge „HD Image Work" unten rechts, ~30 % der Bildbreite, halbtransparent
|
||||
const bw = W * 0.30, bh = 34, m = 12;
|
||||
const bx = W - bw - m, by = H - bh - m;
|
||||
ctx.fillStyle = 'rgba(0,0,0,.6)';
|
||||
ctx.fillRect(bx, by, bw, bh);
|
||||
ctx.fillStyle = '#8f8';
|
||||
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.freezeCanvas = canvas;
|
||||
}
|
||||
@@ -115,44 +110,124 @@ function removeFreezeCanvas(cam) {
|
||||
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) {
|
||||
if (cam.testing) return;
|
||||
cam.testing = true;
|
||||
cam.hdBtn.disabled = true;
|
||||
log(cam.id, '── Hi-Res-Test (Phase 1) gestartet ──');
|
||||
|
||||
// 1. + 2. Frame einfrieren, dann cam loslassen (verliert seinen Consumer)
|
||||
showFreezeCanvas(cam);
|
||||
log(cam.id, '── Release-Test (Phase 1 Diagnose) gestartet ──');
|
||||
await showFreezeCanvas(cam, 'Release-Test…');
|
||||
stopStream(cam);
|
||||
setInfo(cam, 'HD-Test: messe Freigabe…', 'warn');
|
||||
|
||||
setInfo(cam, 'Release-Test…', 'warn');
|
||||
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`,
|
||||
{ signal: AbortSignal.timeout(15000) });
|
||||
const data = await r.json();
|
||||
console.log(`${P}[${cam.id}] release-test JSON:`, data);
|
||||
if (data.freed) {
|
||||
log(cam.id, `✓ Gerät frei nach ${data.msUntilFree} ms ` +
|
||||
`(0-Consumer@${data.zeroConsumerAt}ms → Producer-Stop@${data.producerStoppedAt}ms)`);
|
||||
log(cam.id, `✓ frei nach ${data.msUntilFree}ms`);
|
||||
setInfo(cam, `frei nach ${data.msUntilFree}ms`, 'ok');
|
||||
} else {
|
||||
warn(cam.id, 'Gerät NICHT freigegeben (freed=false) – go2rtc hält den Producer warm. ' +
|
||||
'Ansatz so nicht tragfähig (siehe Roadmap Phase 1).');
|
||||
setInfo(cam, 'nicht freigegeben (warm)', 'crit');
|
||||
warn(cam.id, 'freed=false');
|
||||
setInfo(cam, 'nicht freigegeben', 'crit');
|
||||
}
|
||||
} catch (e) {
|
||||
logErr(cam.id, 'release-test fehlgeschlagen', e);
|
||||
setInfo(cam, 'HD-Test Fehler', 'crit');
|
||||
logErr(cam.id, 'release-test', e);
|
||||
setInfo(cam, 'Release-Test Fehler', 'crit');
|
||||
} finally {
|
||||
// 4. Recovery: was auch passiert, zurück auf Live
|
||||
removeFreezeCanvas(cam);
|
||||
startStream(cam);
|
||||
cam.testing = 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');
|
||||
hd.className = 'cam-hdtest';
|
||||
hd.textContent = 'HD?';
|
||||
hd.title = 'Hi-Res-Test (Phase 1): Geräte-Freigabe messen';
|
||||
hd.textContent = 'HD';
|
||||
hd.title = 'Hi-Res-Snapshot (1280×960) – cam loslassen, hires-Grab, Download';
|
||||
|
||||
const cam = {
|
||||
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
|
||||
cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice();
|
||||
};
|
||||
hd.onclick = () => runReleaseTest(cam);
|
||||
hd.onclick = () => runHiresGrab(cam);
|
||||
|
||||
box.appendChild(label);
|
||||
box.appendChild(info);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user