Files
appRobotWebcam/public/viewer.js
2026-06-03 22:59:06 +02:00

303 lines
12 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 Fallback-Reihenfolge: WebRTC → MSE → MJPEG
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 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)
// ── 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, last, badTicks, autoOff }
// ── Stream starten / stoppen (durch Erzeugen/Entfernen des Elements) ─────────
function startStream(cam) {
if (cam.box.querySelector('video-stream')) return; // läuft schon
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.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.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.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(); // disconnectedCallback schließt WS/PeerConnection
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');
if (auto) showNotice();
}
// ── Health-Anzeige ───────────────────────────────────────────────────────────
function setInfo(cam, text, cls) {
cam.infoEl.textContent = text;
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
}
// ── Monitor-Schleife: misst fps + verworfene Frames pro Kamera ───────────────
function monitorTick() {
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;
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;
const cur = {
t: v.timestamp,
frames: v.framesReceived ?? 0,
decoded: v.framesDecoded ?? 0,
dropped: 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;
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 mbps = (((cur.bytes - last.bytes) * 8) / dt / 1e6).toFixed(2);
const jit = v.jitter != null ? Math.round(v.jitter * 1000) : '?';
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`);
}
}
// ── 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 = `⚠ Browser ü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 (auch abgeschalteter) 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, last: null, badTicks: 0, autoOff: false,
statsLast: null,
};
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(monitorTick, MONITOR_INTERVAL); // Health-Überwachung starten
setInterval(pollStats, STATS_INTERVAL); // WebRTC-Diagnose in die Console
log('init', 'Fertig Überwachung + Diagnose aktiv');
}
init();