diff --git a/public/viewer.js b/public/viewer.js index b55c965..8ad275d 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -4,13 +4,16 @@ const MODE = 'webrtc,mse,mjpeg'; // ── Überwachungs-Parameter ─────────────────────────────────────────────────── -const MONITOR_INTERVAL = 2000; // ms zwischen Health-Checks -const DROP_WARN = 0.10; // >10% verworfene Frames → gelb -const DROP_CRIT = 0.30; // >30% verworfene Frames → rot -const OVERLOAD_TICKS = 3; // so viele kritische Checks in Folge → Auto-Abschaltung +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 STATS_INTERVAL = 3000; // ms zwischen WebRTC-Diagnose-Logs (getStats) +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]'; @@ -19,33 +22,32 @@ 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, last, badTicks, autoOff } +const cameras = []; // { id, box, infoEl, toggleBtn, active, startedAt, playingSince, statsLast, badTicks, autoOff } -// ── Stream starten / stoppen (durch Erzeugen/Entfernen des Elements) ───────── +// ── Stream starten / stoppen ───────────────────────────────────────────────── function startStream(cam) { - if (cam.box.querySelector('video-stream')) return; // läuft schon + 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.last = null; + cam.statsLast = null; cam.badTicks = 0; const stream = document.createElement('video-stream'); stream.mode = MODE; - // 'playing' = ab jetzt fließen wirklich Frames → Aufwärmphase startet stream.addEventListener('playing', () => { cam.playingSince = performance.now(); - cam.last = null; + 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); // Overlays bleiben oben + cam.box.insertBefore(stream, cam.box.firstChild); cam.toggleBtn.textContent = '⏸'; cam.toggleBtn.title = 'Stream ausschalten'; setInfo(cam, 'verbindet…', ''); @@ -53,14 +55,14 @@ function startStream(cam) { function stopStream(cam, auto = false) { const el = cam.box.querySelector('video-stream'); - if (el) el.remove(); // disconnectedCallback schließt WS/PeerConnection + 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 (überlastet)' : 'aus', auto ? 'crit' : ''); - log(cam.id, auto ? 'AUTO-abgeschaltet (Browser überlastet)' : 'manuell aus'); + 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(); } @@ -70,124 +72,93 @@ function setInfo(cam, text, cls) { cam.infoEl.className = 'cam-info ' + (cls ?? ''); } -// Stream existiert, aber 'playing' noch nicht gefeuert → Verbindungsaufbau function showConnecting(cam) { const secs = Math.round((performance.now() - cam.startedAt) / 1000); - if (secs < CONNECT_GRACE_MS / 1000) { - setInfo(cam, `verbindet… ${secs}s`, ''); // grau, kein Alarm - } else { - setInfo(cam, 'kein Signal', 'crit'); // dauert zu lang – aber KEINE Auto-Abschaltung - } - // Wichtig: badTicks NICHT erhöhen, solange nicht stabil gespielt wird + setInfo(cam, secs < CONNECT_GRACE_MS / 1000 ? `verbindet… ${secs}s` : 'kein Signal', + secs < CONNECT_GRACE_MS / 1000 ? '' : 'crit'); } -// ── Monitor-Schleife: misst fps + verworfene Frames pro Kamera ─────────────── -function monitorTick() { +// ── 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(); - cameras.forEach(cam => { - if (!cam.active) return; - - const video = cam.box.querySelector('video'); - - // (1) Noch kein Video / noch nicht "playing" → Verbindungsaufbau, nicht bewerten - if (!video || typeof video.getVideoPlaybackQuality !== 'function' || cam.playingSince === null) { - showConnecting(cam); - return; - } - - const warming = (now - cam.playingSince) < WARMUP_MS; - - const q = video.getVideoPlaybackQuality(); - const cur = { dropped: q.droppedVideoFrames, total: q.totalVideoFrames, t: now }; - - // Erster Messpunkt oder Zähler-Reset (Neustart) → nur Baseline merken - if (!cam.last || cur.total < cam.last.total) { - cam.last = cur; - setInfo(cam, warming ? 'startet…' : 'misst…', ''); - return; - } - - const dTotal = cur.total - cam.last.total; - const dDropped = cur.dropped - cam.last.dropped; - const dSec = (cur.t - cam.last.t) / 1000; - cam.last = cur; - - if (dTotal <= 0) { - // Keine neuen Frames im Intervall - if (warming) { - setInfo(cam, 'startet…', ''); // während Aufwärmen normal - } else { - setInfo(cam, '⏳ keine Frames', 'warn'); - cam.badTicks++; - } - } else { - const fps = Math.round(dTotal / dSec); - const ratio = dDropped / dTotal; - const pct = Math.round(ratio * 100); - if (warming) { - // Während Aufwärmphase: anzeigen, aber NICHT als Überlast werten - const secs = Math.ceil((WARMUP_MS - (now - cam.playingSince)) / 1000); - setInfo(cam, `startet… ${fps} fps (${secs}s)`, ''); - cam.badTicks = 0; - } else { - const cls = ratio >= DROP_CRIT ? 'crit' : ratio >= DROP_WARN ? 'warn' : 'ok'; - setInfo(cam, `${fps} fps · ${pct}% drop`, cls); - cam.badTicks = ratio >= DROP_CRIT ? cam.badTicks + 1 : 0; - } - } - - // Auto-Schutz greift NUR nach der Aufwärmphase - if (!warming && cam.badTicks >= OVERLOAD_TICKS) { - warn(cam.id, `überlastet (${cam.badTicks}× kritisch nach Aufwärmen) → Auto-Abschaltung`); - stopStream(cam, true); - } - }); -} - -// ── WebRTC-Diagnose: lokalisiert WO Frames verloren gehen ──────────────────── -// Liest die inbound-rtp-Statistik der PeerConnection und loggt sie in die Console. -// recv = Frames die ÜBER DAS NETZ ankommen → niedrig = Server liefert nicht (Encode-CPU) -// decoded = davon dekodiert -// dropped = beim Dekodieren verworfen → hoch = Client zu schwach (hier 10% CPU → unwahrscheinlich) -// lost = verlorene Pakete / jitter → hoch = Netz/WiFi -async function pollStats() { for (const cam of cameras) { if (!cam.active) continue; + const el = cam.box.querySelector('video-stream'); const pc = el && el.pc; - if (!pc || typeof pc.getStats !== 'function') continue; + // 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) continue; + if (!v) { showConnecting(cam); continue; } const cur = { t: v.timestamp, - frames: v.framesReceived ?? 0, - decoded: v.framesDecoded ?? 0, - dropped: v.framesDropped ?? 0, + 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; - if (!last || cur.t <= last.t) continue; + + // 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.frames - last.frames) / dt); - const decPs = Math.round((cur.decoded - last.decoded) / dt); - const dropPs = Math.round((cur.dropped - last.dropped) / dt); - const lostD = cur.lost - last.lost; + 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 jit = v.jitter != null ? Math.round(v.jitter * 1000) : '?'; + 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=${jit}ms ${size} ${mbps}Mbps`); + `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); + } } } @@ -196,7 +167,7 @@ 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 = `⚠ Browser überlastet – automatisch deaktiviert: ${off.join(', ')} `; + bar.innerHTML = `⚠ Client überlastet – automatisch deaktiviert: ${off.join(', ')} `; const btn = document.createElement('button'); btn.textContent = 'Wieder aktivieren'; btn.onclick = () => { @@ -207,7 +178,7 @@ function showNotice() { bar.style.display = 'flex'; } -// ── Snapshot aller (auch abgeschalteter) Kameras ───────────────────────────── +// ── Snapshot aller Kameras ─────────────────────────────────────────────────── function snapshotAll() { const ts = Date.now(); const ids = cameras.map(c => c.id); @@ -240,8 +211,7 @@ function buildCamera(camId, container) { const cam = { id: camId, box, infoEl: info, toggleBtn: toggle, - active: false, startedAt: 0, playingSince: null, last: null, badTicks: 0, autoOff: false, - statsLast: null, + active: false, startedAt: 0, playingSince: null, statsLast: null, badTicks: 0, autoOff: false, }; toggle.onclick = () => { cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice(); }; @@ -294,9 +264,8 @@ async function init() { camIds.forEach(id => buildCamera(id, container)); statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · WebRTC`; - setInterval(monitorTick, MONITOR_INTERVAL); // Health-Überwachung starten - setInterval(pollStats, STATS_INTERVAL); // WebRTC-Diagnose in die Console - log('init', 'Fertig – Überwachung + Diagnose aktiv'); + setInterval(monitor, MONITOR_INTERVAL); // getStats-basierte Überwachung + Diagnose + log('init', 'Fertig – Überwachung (getStats) aktiv'); } init();