Claude: Screenshot Phase 1
This commit is contained in:
@@ -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
|
||||
`<canvas>` („HD Image Work"), entfernt den `<video-stream>` (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).
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 <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) {
|
||||
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);
|
||||
|
||||
@@ -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 <video-stream> 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`);
|
||||
|
||||
Reference in New Issue
Block a user