'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(); } // ── 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: ${off.join(', ')} `; 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 cam = { id: camId, box, infoEl: info, toggleBtn: toggle, active: false, startedAt: 0, playingSince: null, statsLast: null, badTicks: 0, autoOff: false, }; toggle.onclick = () => { cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice(); }; box.appendChild(label); box.appendChild(info); box.appendChild(toggle); 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', ' definiert'); } catch (e) { logErr('init', ' 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();