286 lines
12 KiB
JavaScript
286 lines
12 KiB
JavaScript
'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: <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 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', '<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();
|