Files
appRobotWebcam/public/viewer.js
2026-06-04 19:53:04 +02:00

381 lines
16 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 ?? '');
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-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;
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';
}
// ── Snapshot aller Kameras ───────────────────────────────────────────────────
function snapshotAll() {
const ts = Date.now();
const ids = cameras.map(c => c.id);
log('snap', `Snapshot alle: ${ids.join(', ')}`);
ids.forEach(id => {
const a = document.createElement('a');
a.href = `/api/snapshot/${id}`;
a.download = `${id}_${ts}.jpg`;
document.body.appendChild(a);
a.click();
a.remove();
});
}
// ── 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-Test (Phase 1): Geräte-Freigabe messen';
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 = () => runReleaseTest(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 = snapshotAll; 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();