Files
appRobotWebcam/public/viewer.js
2026-06-04 21:29:35 +02:00

517 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// go2rtc Player-Modi.
// 'mjpeg' → Passthrough: go2rtc reicht Kamera-MJPEG 1:1 durch.
// KEIN Transcode → ~0% CPU, keine Freezes, ~200ms Latenz.
// 'webrtc,mse,mjpeg' → WebRTC bevorzugt. ABER: WebRTC/MSE können kein MJPEG →
// go2rtc transcodiert MJPEG→H.264 in Software (libx264) →
// ~55% CPU pro Kamera + Keyframe-Freezes. ~130ms Latenz.
const MODE = 'mjpeg';
const IS_MJPEG = !MODE.includes('webrtc'); // MJPEG-Modus: kein WebRTC/getStats/Health
// ── Überwachungs-Parameter ───────────────────────────────────────────────────
const MONITOR_INTERVAL = 2500; // ms zwischen Health-Checks (getStats)
const CONNECT_GRACE_MS = 25000; // so lange darf der Verbindungsaufbau dauern (kein Alarm)
const WARMUP_MS = 15000; // Karenz nach 'playing', bis Überlast-Erkennung scharf wird
const OVERLOAD_TICKS = 3; // so viele kritische Checks in Folge → Auto-Abschaltung
// Schwellen (auf Basis der ZUVERLÄSSIGEN getStats-Werte, nicht Render-Drops):
const SERVER_LOW_FPS = 12; // recv < 12/s nach Aufwärmen → Server liefert wenig (nur Warnung)
const CLIENT_DECODE_RATIO = 0.6; // decoded < 60% von recv → Decoder kommt nicht nach (echte Client-Überlast)
const NET_LOST_PER_TICK = 5; // mehr verlorene Pakete/Intervall → Netz-Warnung
const NET_JITTER_MS = 60; // mehr Jitter → Netz-Warnung
// ── Logging (Browser DevTools → Console → F12) ───────────────────────────────
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 }
// ── Stream starten / stoppen ─────────────────────────────────────────────────
function startStream(cam) {
if (cam.box.querySelector('video-stream')) return;
const wsUrl = `ws://${location.hostname}:${GO2RTC_PORT}/api/ws?src=${encodeURIComponent(cam.id)}`;
log(cam.id, `Verbinde → ${wsUrl}`);
cam.active = true;
cam.startedAt = performance.now();
cam.playingSince = null; // wird erst beim 'playing'-Event gesetzt
cam.statsLast = null;
cam.badTicks = 0;
const stream = document.createElement('video-stream');
stream.mode = MODE;
stream.addEventListener('playing', () => {
cam.playingSince = performance.now();
cam.statsLast = null;
cam.badTicks = 0;
log(cam.id, '▶ Bild läuft (Aufwärmphase startet)');
}, true);
stream.addEventListener('error', (e) => logErr(cam.id, 'Video-Fehler', e), true);
stream.src = wsUrl;
cam.box.insertBefore(stream, cam.box.firstChild);
cam.toggleBtn.textContent = '⏸';
cam.toggleBtn.title = 'Stream ausschalten';
setInfo(cam, 'verbindet…', '');
}
function stopStream(cam, auto = false) {
const el = cam.box.querySelector('video-stream');
if (el) el.remove();
cam.active = false;
cam.autoOff = auto;
cam.playingSince = null;
cam.toggleBtn.textContent = '▶';
cam.toggleBtn.title = 'Stream einschalten';
setInfo(cam, auto ? 'auto-aus (Client überlastet)' : 'aus', auto ? 'crit' : '');
log(cam.id, auto ? 'AUTO-abgeschaltet (Decoder kam nicht nach)' : 'manuell aus');
if (auto) showNotice();
}
// ── 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.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, W, H);
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;
}
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, '── Release-Test (Phase 1 Diagnose) gestartet ──');
await showFreezeCanvas(cam, 'Release-Test…');
stopStream(cam);
setInfo(cam, 'Release-Test…', 'warn');
try {
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, `✓ frei nach ${data.msUntilFree}ms`);
setInfo(cam, `frei nach ${data.msUntilFree}ms`, 'ok');
} else {
warn(cam.id, 'freed=false');
setInfo(cam, 'nicht freigegeben', 'crit');
}
} catch (e) {
logErr(cam.id, 'release-test', e);
setInfo(cam, 'Release-Test Fehler', 'crit');
} finally {
removeFreezeCanvas(cam);
startStream(cam);
cam.testing = false;
cam.hdBtn.disabled = false;
log(cam.id, '── Release-Test beendet ──');
}
}
// ── Health-Anzeige ───────────────────────────────────────────────────────────
function setInfo(cam, text, cls) {
cam.infoEl.textContent = text;
cam.infoEl.className = 'cam-info ' + (cls ?? '');
}
function showConnecting(cam) {
const secs = Math.round((performance.now() - cam.startedAt) / 1000);
setInfo(cam, secs < CONNECT_GRACE_MS / 1000 ? `verbindet… ${secs}s` : 'kein Signal',
secs < CONNECT_GRACE_MS / 1000 ? '' : 'crit');
}
// ── Monitor: liest getStats (inbound-rtp) = die verlässliche Wahrheit ─────────
// recv = Frames über Netz → niedrig = Server liefert nicht (Encode-CPU)
// decoded = davon dekodiert → deutlich < recv = Client-Decoder überlastet
// lost/jitter → Netz/WiFi
async function monitor() {
const now = performance.now();
for (const cam of cameras) {
if (!cam.active) continue;
// MJPEG-Passthrough: kein PeerConnection, kein getStats, keine Decoder-Überlast.
// Das Bild läuft, sobald das Element da ist nur simple Status-Anzeige.
if (IS_MJPEG) {
const live = !!cam.box.querySelector('video-stream');
setInfo(cam, live ? 'MJPEG · live' : 'aus', live ? 'ok' : '');
continue;
}
const el = cam.box.querySelector('video-stream');
const pc = el && el.pc;
// Noch nicht verbunden oder 'playing' noch nicht gefeuert → Aufbauphase
if (!pc || typeof pc.getStats !== 'function' || cam.playingSince === null) {
showConnecting(cam);
continue;
}
let stats;
try { stats = await pc.getStats(); } catch { continue; }
let v = null;
stats.forEach(r => { if (r.type === 'inbound-rtp' && r.kind === 'video') v = r; });
if (!v) { showConnecting(cam); continue; }
const cur = {
t: v.timestamp,
recv: v.framesReceived ?? 0,
dec: v.framesDecoded ?? 0,
drop: v.framesDropped ?? 0,
lost: v.packetsLost ?? 0,
bytes: v.bytesReceived ?? 0,
};
const last = cam.statsLast;
cam.statsLast = cur;
// Erster Messpunkt oder Zähler-Reset → nur Baseline
if (!last || cur.t <= last.t || cur.recv < last.recv) {
setInfo(cam, 'misst…', '');
continue;
}
const dt = (cur.t - last.t) / 1000;
const recvPs = Math.round((cur.recv - last.recv) / dt);
const decPs = Math.round((cur.dec - last.dec) / dt);
const dropPs = Math.round((cur.drop - last.drop) / dt);
const lostD = Math.max(0, cur.lost - last.lost);
const mbps = (((cur.bytes - last.bytes) * 8) / dt / 1e6).toFixed(2);
const jitter = v.jitter != null ? Math.round(v.jitter * 1000) : 0;
const size = (v.frameWidth && v.frameHeight) ? `${v.frameWidth}x${v.frameHeight}` : '?';
log(cam.id, `[stats] recv=${recvPs}/s decoded=${decPs}/s dropped=${dropPs}/s ` +
`lost=+${lostD} jitter=${jitter}ms ${size} ${mbps}Mbps`);
// Während Aufwärmphase: anzeigen, aber NICHT bewerten
if (now - cam.playingSince < WARMUP_MS) {
const secs = Math.ceil((WARMUP_MS - (now - cam.playingSince)) / 1000);
setInfo(cam, `startet… ${decPs} fps (${secs}s)`, '');
cam.badTicks = 0;
continue;
}
// ── Bewertung nach Aufwärmphase ──
const clientOverload = recvPs >= SERVER_LOW_FPS && decPs < recvPs * CLIENT_DECODE_RATIO;
const serverLow = recvPs < SERVER_LOW_FPS;
const netBad = lostD > NET_LOST_PER_TICK || jitter > NET_JITTER_MS;
if (clientOverload) {
setInfo(cam, `Decoder hängt ${decPs}/${recvPs} fps`, 'crit');
cam.badTicks++;
} else {
cam.badTicks = 0;
if (serverLow) setInfo(cam, `Server liefert ${recvPs}/s`, 'warn');
else if (netBad) setInfo(cam, `${decPs} fps · ⚠ ${jitter}ms / lost+${lostD}`, 'warn');
else setInfo(cam, `${decPs} fps · ${mbps} Mbps`, 'ok');
}
// Auto-Schutz NUR bei echter Client-Überlast (Decoder kommt nicht nach)
if (cam.badTicks >= OVERLOAD_TICKS) {
warn(cam.id, `Decoder überlastet (${cam.badTicks}× kritisch) → Auto-Abschaltung`);
stopStream(cam, true);
}
}
}
// ── Hinweis-Banner bei Auto-Abschaltung ──────────────────────────────────────
function showNotice() {
const off = cameras.filter(c => c.autoOff && !c.active).map(c => c.id);
const bar = document.getElementById('notice');
if (off.length === 0) { bar.style.display = 'none'; return; }
bar.innerHTML = `⚠ Client überlastet automatisch deaktiviert: <b>${off.join(', ')}</b> `;
const btn = document.createElement('button');
btn.textContent = 'Wieder aktivieren';
btn.onclick = () => {
cameras.filter(c => c.autoOff && !c.active).forEach(c => { c.autoOff = false; startStream(c); });
showNotice();
};
bar.appendChild(btn);
bar.style.display = 'flex';
}
// ── HD-Snapshot aller Kameras (parallel) ─────────────────────────────────────
// cam0 und cam1 liegen auf getrennten Geräten → gleichzeitiger Grab sicher.
// Alle Live-Streams werden synchron eingefroren und losgelassen, dann beide
// /hires-Requests parallel gefeuert. finally stellt immer alle zurück.
async function snapshotAllHires() {
if (cameras.some(c => c.testing)) return;
const snapBtn = document.getElementById('snapAllBtn');
if (snapBtn) snapBtn.disabled = true;
cameras.forEach(c => { c.testing = true; c.hdBtn.disabled = true; });
log('snap', `HD-Grab alle: ${cameras.map(c => c.id).join(', ')}`);
try {
// 1. Alle Freeze-Canvases gleichzeitig aufbauen (je ein /api/snapshot-Fetch)
await Promise.all(cameras.map(c => showFreezeCanvas(c, 'Capturing HD…')));
// 2. Alle Live-Streams synchron loslassen → alle Consumer fallen gleichzeitig auf 0
cameras.forEach(c => stopStream(c));
const ts = Date.now();
// 3. Alle /hires-Grabs parallel Fehler einer Kamera blockieren die andere nicht
await Promise.allSettled(cameras.map(async c => {
try {
const r = await fetch(
`/api/snapshot/${encodeURIComponent(c.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();
const blobUrl = URL.createObjectURL(blob);
if (c.freezeCanvas) {
const ctx = c.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(c, 'HD ✓', '#8f8');
}
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${c.id}_hires_${ts}.jpg`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(blobUrl);
setInfo(c, 'HD gespeichert', 'ok');
log(c.id, `HD-Grab OK ${blob.size} bytes`);
} catch (e) {
logErr(c.id, 'HD-Grab fehlgeschlagen', e);
setInfo(c, `HD Fehler: ${e.message}`, 'crit');
}
}));
} finally {
// 4. Immer: alle zurück auf Live
await sleep(600);
cameras.forEach(c => {
removeFreezeCanvas(c);
startStream(c);
c.testing = false;
c.hdBtn.disabled = false;
});
if (snapBtn) snapBtn.disabled = false;
log('snap', '── HD-Grab alle beendet, alle zurück auf Live ──');
}
}
// ── Kamera-View aufbauen ─────────────────────────────────────────────────────
function buildCamera(camId, container) {
const box = document.createElement('div');
box.className = 'cam-box';
const label = document.createElement('div');
label.className = 'cam-label';
label.textContent = camId;
const info = document.createElement('div');
info.className = 'cam-info';
info.textContent = '…';
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-Snapshot (1280×960) cam loslassen, hires-Grab, Download';
const cam = {
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 = () => {
if (cam.testing) return; // während HD-Test gesperrt
cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice();
};
hd.onclick = () => runHiresGrab(cam);
box.appendChild(label);
box.appendChild(info);
box.appendChild(toggle);
box.appendChild(hd);
container.appendChild(box);
cameras.push(cam);
startStream(cam);
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function init() {
log('init', 'Starte...');
try {
const d = await (await fetch('/config.json')).json();
GO2RTC_PORT = d.go2rtcPort ?? 1984;
log('init', `go2rtc WS-Port: ${GO2RTC_PORT}`);
} catch (e) {
warn('init', `/config.json nicht ladbar, nehme Port ${GO2RTC_PORT}`);
}
try {
await customElements.whenDefined('video-stream');
log('init', '<video-stream> definiert');
} catch (e) {
logErr('init', '<video-stream> nicht geladen /video-stream.js erreichbar?', e);
return;
}
const container = document.getElementById('cameras');
const statusText = document.getElementById('statusText');
let camIds = [];
try {
const r = await fetch('/api/snapshot');
log('init', `/api/snapshot → HTTP ${r.status}`);
if (r.ok) camIds = ((await r.json()).cameras ?? []).map(c => c.id);
log('init', `Kameras: ${camIds.join(', ') || '(keine)'}`);
} catch (e) {
logErr('init', '/api/snapshot Fehler Fallback', e);
}
if (camIds.length === 0) { warn('init', 'Fallback cam0, cam1'); camIds = ['cam0', 'cam1']; }
const snapBtn = document.getElementById('snapAllBtn');
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
camIds.forEach(id => buildCamera(id, container));
statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · WebRTC`;
setInterval(monitor, MONITOR_INTERVAL); // getStats-basierte Überwachung + Diagnose
log('init', 'Fertig Überwachung (getStats) aktiv');
}
init();