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

@@ -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,41 +73,35 @@ 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');
canvas.className = 'cam-freeze';
canvas.width = W;
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
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); }
}
// 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);
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); }
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);