diff --git a/doc/05_screenShot_roadmap.md b/doc/05_screenShot_roadmap.md index 3d114af..25782b1 100644 --- a/doc/05_screenShot_roadmap.md +++ b/doc/05_screenShot_roadmap.md @@ -1,6 +1,7 @@ # AppRobotWebcam – Hi-Res-Snapshot via Consumer-Umhängen -> Status: **Konzept, phasenweise testbar.** Noch nicht umgesetzt. +> Status: **Phase 1 implementiert** (Code steht, Messung an der Live-Instanz steht +> noch aus). Phase 2 weiterhin Konzept. > Vorgeschichte & gescheiterte Ansätze: siehe `04_Delay_roadmap.md` (Abschnitt > „KONSOLIDIERT"). Diese Datei beschreibt den Ansatz, der die dort dokumentierten > Fehler **strukturell** umgeht. @@ -121,6 +122,17 @@ im schlimmsten Fall ist es ein Reconnect von cam0. ``` 5. Kein Schreibzugriff auf go2rtc. Nur Lesen. +### Umgesetzt am 2026-06-04 +- **Node:** `GET /api/snapshot/:id/release-test` in `src/snapshotService.js` – pollt + `/api/streams` alle 200 ms (max. 10 s), misst `zeroConsumerAt`/`producerStoppedAt`, + liefert `{ freed, msUntilFree, samples }`. Rein lesend. Parser an den bestehenden + `server.js`-Monitor angelehnt (`producers[].state === 'running'`, `consumers.length`). +- **Viewer:** Pro Kamera Button „HD?" in `public/viewer.js`. Friert den Frame auf ein + `` („HD Image Work"), entfernt den `` (Umhängen), ruft den + Endpunkt, hängt im `finally` **immer** wieder auf Live zurück. +- **Messung an der Live-Instanz steht noch aus** (Docker/go2rtc auf dem Server) – erst + diese liefert das echte `msUntilFree` für Schritt 3/5. + ### Erfolgskriterium Phase 1 - Log/JSON zeigt `freed: true` und eine **konkrete** `msUntilFree`. - Nach dem Test (Schritt 6) zeigt cam0 wieder normal Live (~50 % CPU, stabil). diff --git a/public/index.html b/public/index.html index 0958082..40ae43e 100644 --- a/public/index.html +++ b/public/index.html @@ -42,6 +42,9 @@ video-stream { display: block; width: 640px; height: 480px; background: #111; } video-stream video { width: 100%; height: 100%; object-fit: contain; } + /* Eingefrorener Frame während des Hi-Res-Tests (Phase 1) */ + .cam-freeze { display: block; width: 640px; height: 480px; background: #111; } + .cam-label { position: absolute; top: 5px; left: 8px; z-index: 2; background: rgba(0,0,0,.65); padding: 2px 7px; border-radius: 3px; @@ -65,6 +68,16 @@ cursor: pointer; border-radius: 3px; } .cam-toggle:hover { background: rgba(60,60,60,.85); } + + /* Hi-Res-Test-Button (Phase 1) – links neben dem Ein/Aus-Schalter */ + .cam-hdtest { + position: absolute; top: 5px; right: 40px; z-index: 2; + background: rgba(0,0,0,.65); color: #8cf; border: 1px solid #468; + height: 22px; padding: 0 7px; font-family: monospace; font-size: 0.7rem; + cursor: pointer; border-radius: 3px; + } + .cam-hdtest:hover:not(:disabled) { background: rgba(40,60,90,.85); } + .cam-hdtest:disabled { opacity: 0.4; cursor: default; } diff --git a/public/viewer.js b/public/viewer.js index edc6b6f..424b28a 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -72,6 +72,90 @@ 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 einfrieren + „HD Image Work" einblenden +// 2. 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, wieder einsetzen (Live zurück) +// cam selbst wird nie verändert; im schlimmsten Fall nur ein Reconnect. +function showFreezeCanvas(cam) { + removeFreezeCanvas(cam); + const W = 640, H = 480; + const canvas = document.createElement('canvas'); + canvas.className = 'cam-freeze'; + 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); + + cam.box.insertBefore(canvas, cam.box.firstChild); + cam.freezeCanvas = canvas; +} + +function removeFreezeCanvas(cam) { + if (cam.freezeCanvas) { cam.freezeCanvas.remove(); cam.freezeCanvas = null; } +} + +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); + stopStream(cam); + setInfo(cam, 'HD-Test: messe Freigabe…', '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)`); + 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'); + } + } catch (e) { + logErr(cam.id, 'release-test fehlgeschlagen', e); + setInfo(cam, 'HD-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 ──'); + } +} + // ── Health-Anzeige ─────────────────────────────────────────────────────────── function setInfo(cam, text, cls) { cam.infoEl.textContent = text; @@ -223,15 +307,26 @@ function buildCamera(camId, container) { const toggle = document.createElement('button'); toggle.className = 'cam-toggle'; + const hd = document.createElement('button'); + hd.className = 'cam-hdtest'; + hd.textContent = 'HD?'; + hd.title = 'Hi-Res-Test (Phase 1): Geräte-Freigabe messen'; + const cam = { - id: camId, box, infoEl: info, toggleBtn: toggle, + id: camId, box, infoEl: info, toggleBtn: toggle, hdBtn: hd, active: false, startedAt: 0, playingSince: null, statsLast: null, badTicks: 0, autoOff: false, + testing: false, freezeCanvas: null, }; - toggle.onclick = () => { cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice(); }; + toggle.onclick = () => { + if (cam.testing) return; // während HD-Test gesperrt + cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice(); + }; + hd.onclick = () => runReleaseTest(cam); box.appendChild(label); box.appendChild(info); box.appendChild(toggle); + box.appendChild(hd); container.appendChild(box); cameras.push(cam); diff --git a/src/snapshotService.js b/src/snapshotService.js index 4e16a5a..6c9f97e 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -2,14 +2,93 @@ const express = require('express'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + // 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.) function createSnapshotRouter(go2rtcUrl) { const router = express.Router(); + // ── PHASE 1: Geräte-Freigabe messen (rein LESEND, kein Grab) ──────────────── + // Linchpin-Test aus doc/05_screenShot_roadmap.md: Gibt go2rtc das Gerät frei, + // wenn cam0 den letzten Consumer verliert – und wie schnell? + // + // Voraussetzung: der Client hat seinen für :id bereits entfernt + // (das „Umhängen"), BEVOR er diesen Endpunkt ruft. cam0 wird hier NICHT verändert + // – wir pollen nur /api/streams und beobachten, wann der Producer stoppt. + // + // Antwort z.B.: { freed: true, msUntilFree: 1700, zeroConsumerAt, producerStoppedAt, samples } + router.get('/:id/release-test', async (req, res) => { + const { id } = req.params; + const POLL_MS = 200; + const MAX_MS = 10000; + const t0 = Date.now(); + const samples = []; + let zeroConsumerAt = null; // ms ab t0, sobald 0 Consumer beobachtet + let producerStoppedAt = null; // ms ab t0, sobald kein laufender Producer mehr + + console.log(`[release-test][${id}] Start – polle /api/streams alle ${POLL_MS}ms (max ${MAX_MS}ms)`); + + while (Date.now() - t0 < MAX_MS) { + const elapsed = Date.now() - t0; + let nConsumers = null; + let producerRunning = null; + + try { + // Per-Poll-Timeout: hängt go2rtc, darf das nicht den ganzen Endpunkt + // blockieren (sonst kommt der Client nie zurück auf Live → Regel 4). + const r = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) }); + if (r.ok) { + const streams = await r.json(); + const s = streams[id]; + if (s) { + // Shape vgl. server.js-Monitor: producers[].state ('running'|'stop'|…), consumers[] + const producers = s.producers ?? []; + const consumers = s.consumers ?? []; + nConsumers = consumers.length; + producerRunning = producers.some((p) => (p.state ?? '') === 'running'); + } else { + // Stream gar nicht (mehr) gelistet → kein Producer, keine Consumer + nConsumers = 0; + producerRunning = false; + } + } + } catch (e) { + // einzelner Poll-Fehler ist nicht fatal – weiter messen + console.warn(`[release-test][${id}] Poll @${elapsed}ms fehlgeschlagen: ${e.message}`); + } + + samples.push({ t: elapsed, consumers: nConsumers, producerRunning }); + + if (zeroConsumerAt === null && nConsumers === 0) { + zeroConsumerAt = elapsed; + console.log(`[release-test][${id}] 0 Consumer @${elapsed}ms`); + } + if (zeroConsumerAt !== null && producerRunning === false && producerStoppedAt === null) { + producerStoppedAt = elapsed; + console.log(`[release-test][${id}] Producer gestoppt @${elapsed}ms → Gerät frei`); + break; + } + + await sleep(POLL_MS); + } + + const freed = producerStoppedAt !== null; + const msUntilFree = + freed && zeroConsumerAt !== null ? producerStoppedAt - zeroConsumerAt : null; + + console.log( + `[release-test][${id}] Ergebnis: freed=${freed} msUntilFree=${msUntilFree} ` + + `(0-Consumer@${zeroConsumerAt}ms, Producer-Stop@${producerStoppedAt}ms)` + ); + + res.json({ id, freed, msUntilFree, zeroConsumerAt, producerStoppedAt, samples }); + }); + router.get('/', async (_req, res) => { try { const r = await fetch(`${go2rtcUrl}/api/streams`);