Claude: HighResolution mit Limit
This commit is contained in:
@@ -24,18 +24,47 @@
|
||||
#snapAllBtn:hover:not(:disabled) { background: #3a6a3a; }
|
||||
#snapAllBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* Überlast-Banner */
|
||||
#notice {
|
||||
display: none; align-items: center; gap: 10px;
|
||||
background: #4a2a2a; color: #fbb; border-bottom: 1px solid #a55;
|
||||
padding: 8px 16px; font-size: 0.82rem;
|
||||
}
|
||||
#notice button {
|
||||
background: #2a2a2a; color: #fcc; border: 1px solid #a55;
|
||||
padding: 3px 10px; font-family: monospace; cursor: pointer; border-radius: 3px;
|
||||
}
|
||||
|
||||
#cameras { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; }
|
||||
|
||||
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; }
|
||||
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; min-width: 320px; }
|
||||
|
||||
video-stream { display: block; width: 640px; height: 480px; background: #111; }
|
||||
video-stream video { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.cam-label {
|
||||
position: absolute; top: 5px; left: 8px;
|
||||
position: absolute; top: 5px; left: 8px; z-index: 2;
|
||||
background: rgba(0,0,0,.65); padding: 2px 7px; border-radius: 3px;
|
||||
font-size: 0.72rem; color: #ccc;
|
||||
}
|
||||
/* Health-Anzeige unten links: fps · drop% */
|
||||
.cam-info {
|
||||
position: absolute; bottom: 5px; left: 8px; z-index: 2;
|
||||
background: rgba(0,0,0,.7); padding: 2px 7px; border-radius: 3px;
|
||||
font-size: 0.7rem; color: #999;
|
||||
}
|
||||
.cam-info.ok { color: #6d6; }
|
||||
.cam-info.warn { color: #fb4; }
|
||||
.cam-info.crit { color: #f66; }
|
||||
|
||||
/* Ein/Aus-Schalter oben rechts */
|
||||
.cam-toggle {
|
||||
position: absolute; top: 5px; right: 8px; z-index: 2;
|
||||
background: rgba(0,0,0,.65); color: #ccc; border: 1px solid #444;
|
||||
width: 26px; height: 22px; font-size: 0.7rem;
|
||||
cursor: pointer; border-radius: 3px;
|
||||
}
|
||||
.cam-toggle:hover { background: rgba(60,60,60,.85); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -45,6 +74,7 @@
|
||||
<button id="snapAllBtn" disabled>⬇ Snapshot alle</button>
|
||||
</header>
|
||||
|
||||
<div id="notice"></div>
|
||||
<div id="cameras"></div>
|
||||
|
||||
<script type="module" src="/video-stream.js"></script>
|
||||
|
||||
208
public/viewer.js
208
public/viewer.js
@@ -3,72 +3,175 @@
|
||||
// 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
|
||||
|
||||
// ── 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 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 ?? '');
|
||||
|
||||
// ── Snapshot aller Kameras gleichzeitig ──────────────────────────────────────
|
||||
// Auflösung = was go2rtc im MJPEG-Stream hält (aktuell 640×480 gemäss Config).
|
||||
// Für höhere Auflösung: in go2rtc.yaml einen separaten Hi-Res-Stream definieren
|
||||
// und hier auf dessen /api/frame.jpeg?src=cam0_hires zeigen.
|
||||
function snapshotAll(camIds) {
|
||||
let GO2RTC_PORT = 1984;
|
||||
const cameras = []; // { id, box, infoEl, toggleBtn, active, 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}`);
|
||||
|
||||
const stream = document.createElement('video-stream');
|
||||
stream.mode = MODE;
|
||||
stream.addEventListener('playing', () => log(cam.id, '▶ Bild läuft'), true);
|
||||
stream.addEventListener('error', (e) => logErr(cam.id, 'Video-Fehler', e), true);
|
||||
stream.src = wsUrl;
|
||||
|
||||
// Vor das Label einfügen, damit Overlays oben liegen
|
||||
cam.box.insertBefore(stream, cam.box.firstChild);
|
||||
cam.active = true;
|
||||
cam.last = null;
|
||||
cam.badTicks = 0;
|
||||
cam.toggleBtn.textContent = '⏸';
|
||||
cam.toggleBtn.title = 'Stream ausschalten';
|
||||
}
|
||||
|
||||
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.toggleBtn.textContent = '▶';
|
||||
cam.toggleBtn.title = 'Stream einschalten';
|
||||
setInfo(cam, auto ? 'auto-aus (überlastet)' : 'aus', '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 ?? '');
|
||||
}
|
||||
|
||||
// ── Monitor-Schleife: misst fps + verworfene Frames pro Kamera ───────────────
|
||||
function monitorTick() {
|
||||
cameras.forEach(cam => {
|
||||
if (!cam.active) return;
|
||||
const video = cam.box.querySelector('video');
|
||||
if (!video || typeof video.getVideoPlaybackQuality !== 'function') {
|
||||
setInfo(cam, 'läuft', 'ok');
|
||||
return;
|
||||
}
|
||||
|
||||
const q = video.getVideoPlaybackQuality();
|
||||
const now = performance.now();
|
||||
const cur = { dropped: q.droppedVideoFrames, total: q.totalVideoFrames, t: now };
|
||||
|
||||
// Beim ersten Tick (oder nach Neustart = Zähler zurückgesetzt) nur Baseline merken
|
||||
if (!cam.last || cur.total < cam.last.total) {
|
||||
cam.last = cur;
|
||||
setInfo(cam, 'misst…', 'ok');
|
||||
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) { // kein neuer Frame → evtl. Freeze
|
||||
setInfo(cam, '⏳ keine Frames', 'warn');
|
||||
cam.badTicks++;
|
||||
} else {
|
||||
const fps = Math.round(dTotal / dSec);
|
||||
const ratio = dDropped / dTotal;
|
||||
const pct = Math.round(ratio * 100);
|
||||
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: anhaltende Überlast → diese Kamera abschalten
|
||||
if (cam.badTicks >= OVERLOAD_TICKS) {
|
||||
warn(cam.id, `ü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 = `⚠ 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();
|
||||
log('snap', `Snapshot alle Kameras: ${camIds.join(', ')}`);
|
||||
camIds.forEach(id => {
|
||||
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.href = `/api/snapshot/${id}`;
|
||||
a.download = `${id}_${ts}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
a.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Kamera-View aufbauen ─────────────────────────────────────────────────────
|
||||
function buildCamera(camId, go2rtcPort, container) {
|
||||
const wsUrl = `ws://${location.hostname}:${go2rtcPort}/api/ws?src=${encodeURIComponent(camId)}`;
|
||||
log(camId, `View erstellt mode="${MODE}" ws=${wsUrl}`);
|
||||
|
||||
function buildCamera(camId, container) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'cam-box';
|
||||
|
||||
const stream = document.createElement('video-stream');
|
||||
stream.mode = MODE;
|
||||
|
||||
stream.addEventListener('play', () => log(camId, '▶ spielt'), true);
|
||||
stream.addEventListener('playing', () => log(camId, '▶ Bild läuft'), true);
|
||||
stream.addEventListener('pause', () => warn(camId, 'pausiert'), true);
|
||||
stream.addEventListener('stalled', () => warn(camId, 'stalled (keine Daten)'), true);
|
||||
stream.addEventListener('waiting', () => warn(camId, 'waiting (Buffer leer)'), true);
|
||||
stream.addEventListener('error', (e) => logErr(camId, 'Video-Fehler', e), true);
|
||||
|
||||
log(camId, `Verbinde WebSocket → ${wsUrl}`);
|
||||
stream.src = wsUrl;
|
||||
|
||||
box.appendChild(stream);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'cam-label';
|
||||
label.className = 'cam-label';
|
||||
label.textContent = camId;
|
||||
box.appendChild(label);
|
||||
|
||||
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, last: 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...');
|
||||
|
||||
let go2rtcPort = 1984;
|
||||
try {
|
||||
const r = await fetch('/config.json');
|
||||
const d = await r.json();
|
||||
go2rtcPort = d.go2rtcPort ?? 1984;
|
||||
log('init', `go2rtc WS-Port: ${go2rtcPort}`);
|
||||
const d = await (await fetch('/config.json')).json();
|
||||
GO2RTC_PORT = d.go2rtcPort ?? 1984;
|
||||
log('init', `go2rtc WS-Port: ${GO2RTC_PORT}`);
|
||||
} catch (e) {
|
||||
warn('init', `Konnte /config.json nicht laden, nehme Port ${go2rtcPort}`);
|
||||
warn('init', `/config.json nicht ladbar, nehme Port ${GO2RTC_PORT}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -82,34 +185,25 @@ async function init() {
|
||||
const container = document.getElementById('cameras');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
let cams = [];
|
||||
let camIds = [];
|
||||
try {
|
||||
const r = await fetch('/api/snapshot');
|
||||
log('init', `/api/snapshot → HTTP ${r.status}`);
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
cams = (d.cameras ?? []).map(c => c.id);
|
||||
log('init', `Kameras: ${cams.join(', ') || '(keine)'}`);
|
||||
}
|
||||
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']; }
|
||||
|
||||
if (cams.length === 0) {
|
||||
warn('init', 'Fallback auf cam0, cam1');
|
||||
cams = ['cam0', 'cam1'];
|
||||
}
|
||||
const snapBtn = document.getElementById('snapAllBtn');
|
||||
if (snapBtn) { snapBtn.onclick = snapshotAll; snapBtn.disabled = false; }
|
||||
|
||||
// Globaler Snapshot-Button in der Header-Bar verdrahten
|
||||
const snapAllBtn = document.getElementById('snapAllBtn');
|
||||
if (snapAllBtn) {
|
||||
snapAllBtn.onclick = () => snapshotAll(cams);
|
||||
snapAllBtn.disabled = false;
|
||||
}
|
||||
camIds.forEach(id => buildCamera(id, container));
|
||||
statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · WebRTC`;
|
||||
|
||||
cams.forEach(id => buildCamera(id, go2rtcPort, container));
|
||||
statusText.textContent = `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
||||
log('init', 'Fertig');
|
||||
setInterval(monitorTick, MONITOR_INTERVAL); // Health-Überwachung starten
|
||||
log('init', 'Fertig – Überwachung aktiv');
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user