'use strict'; // ── Architektur ─────────────────────────────────────────────────────────────── // Der Server (Node) besitzt die Kameras und liefert den Live-Stream als // MJPEG multipart/x-mixed-replace unter /api/stream/. Der Browser rendert // das nativ in einem . KEIN WebRTC, KEIN go2rtc, kein Transcode. // // HD-Snapshot: GET /api/snapshot//hires. Der Server-Schalter pausiert dafür // den Live-FFmpeg kurz (~1–2 s), greift 1280×960, schaltet zurück. Der - // Stream friert in dieser Zeit ein und läuft danach weiter – kein Client-Handling // nötig (das war früher die Fehlerquelle). 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 ?? ''); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const cameras = []; // { id, box, img, infoEl, toggleBtn, hdBtn, active, busy } // ── Live-Stream an/aus ──────────────────────────────────────────────────────── function startStream(cam) { cam.active = true; // Cache-Buster erzwingt eine frische Verbindung (sonst hängt Reconnect manchmal) cam.img.src = `/api/stream/${encodeURIComponent(cam.id)}?t=${Date.now()}`; cam.toggleBtn.textContent = '⏸'; cam.toggleBtn.title = 'Stream ausschalten'; setInfo(cam, 'verbindet…', ''); log(cam.id, 'Live an'); } function stopStream(cam) { cam.active = false; cam.img.removeAttribute('src'); // schließt die multipart-Verbindung cam.toggleBtn.textContent = '▶'; cam.toggleBtn.title = 'Stream einschalten'; setInfo(cam, 'aus', ''); log(cam.id, 'Live aus'); } // ── Snapshot-Modus (stream:false): alle 15 s ein HD-Einzelbild ────────────── // Kein Video – pro Snapshot holt der Viewer ein Bild in HD-Auflösung (/hires, // pro Kamera in cameras.json konfiguriert). Da nur 1 Bild / 15 s übertragen wird, // ist HD hier bandbreiten-unkritisch und liefert das beste Standbild. Der Server // öffnet das Gerät pro Grab kurz und schliesst es wieder. Fehler (z. B. Gerät // gerade belegt) werden still übersprungen, das letzte gute Bild bleibt stehen. const SNAPSHOT_INTERVAL_MS = 15000; async function fetchSnapshot(cam) { if (!cam.snapshotActive) return; try { const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/hires?t=${Date.now()}`, { signal: AbortSignal.timeout(20000) }); if (!r.ok) { setInfo(cam, `Snapshot-Fehler (HTTP ${r.status})`, 'warn'); return; } const blob = await r.blob(); const w = r.headers.get('X-Frame-Width') || '?'; const url = URL.createObjectURL(blob); cam.img.src = url; if (cam.lastBlobUrl) URL.revokeObjectURL(cam.lastBlobUrl); cam.lastBlobUrl = url; setInfo(cam, `Einzelbild ${w}px · ${new Date().toLocaleTimeString()}`, 'ok'); } catch (_e) { setInfo(cam, 'Snapshot-Timeout', 'warn'); } } function startSnapshotMode(cam) { cam.snapshotActive = true; setInfo(cam, 'Einzelbild lädt…', ''); fetchSnapshot(cam); // sofort das erste Bild cam.snapTimer = setInterval(() => fetchSnapshot(cam), SNAPSHOT_INTERVAL_MS); log(cam.id, `Snapshot-Modus (alle ${SNAPSHOT_INTERVAL_MS / 1000} s)`); } // ── HD-Snapshot ─────────────────────────────────────────────────────────────── async function runHiresGrab(cam) { if (cam.busy) return; cam.busy = true; cam.hdBtn.disabled = true; setInfo(cam, cam.stream ? 'HD: erfasse… (Stream friert kurz)' : 'HD: erfasse…', 'warn'); log(cam.id, '── HD-Grab gestartet ──'); let blobUrl = null; try { const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/hires`, { signal: AbortSignal.timeout(20000) }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error ?? `HTTP ${r.status}`); } const blob = await r.blob(); const width = r.headers.get('X-Frame-Width') || '?'; blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = `${cam.id}_hires_${Date.now()}.jpg`; document.body.appendChild(a); a.click(); a.remove(); setInfo(cam, `HD gespeichert (${width}px)`, 'ok'); log(cam.id, `HD-Grab OK – ${blob.size} bytes, ${width}px`); } catch (e) { logErr(cam.id, 'HD-Grab fehlgeschlagen', e); setInfo(cam, `HD Fehler: ${e.message}`, 'crit'); } finally { if (blobUrl) setTimeout(() => URL.revokeObjectURL(blobUrl), 10000); cam.busy = false; cam.hdBtn.disabled = false; log(cam.id, '── HD-Grab beendet ──'); } } // ── HD-Snapshot aller Kameras (parallel) ────────────────────────────────────── // cam0/cam1 liegen auf getrennten Geräten → der Schalter grabbt beide parallel // gefahrlos (jeder Schalter steuert nur sein eigenes Gerät). async function snapshotAllHires() { const snapBtn = document.getElementById('snapAllBtn'); if (snapBtn) snapBtn.disabled = true; log('snap', `HD-Grab alle: ${cameras.map((c) => c.id).join(', ')}`); try { await Promise.allSettled(cameras.map((c) => runHiresGrab(c))); } finally { if (snapBtn) snapBtn.disabled = false; log('snap', '── HD-Grab alle beendet ──'); } } // ── Status-Anzeige ──────────────────────────────────────────────────────────── function setInfo(cam, text, cls) { cam.infoEl.textContent = text; cam.infoEl.className = 'cam-info ' + (cls ?? ''); } // ── Kamera-View aufbauen ────────────────────────────────────────────────────── // camMeta = { id, name, position, stream, hires } function buildCamera(camMeta, container) { const box = document.createElement('div'); box.className = 'cam-box'; const labelText = camMeta.name + (camMeta.position ? ` · ${camMeta.position}` : ''); const label = document.createElement('div'); label.className = 'cam-label'; label.textContent = labelText; const info = document.createElement('div'); info.className = 'cam-info'; info.textContent = camMeta.stream ? '…' : 'Nur Snapshot'; const hd = document.createElement('button'); hd.className = 'cam-hdtest'; hd.textContent = 'HD'; const cam = { id: camMeta.id, stream: camMeta.stream, box, infoEl: info, hdBtn: hd, active: false, busy: false }; hd.onclick = () => runHiresGrab(cam); if (camMeta.stream) { const img = document.createElement('img'); img.className = 'cam-img'; img.alt = labelText; img.addEventListener('load', () => { if (cam.active && !cam.busy) setInfo(cam, 'MJPEG · live', 'ok'); }); img.addEventListener('error', () => { if (!cam.active) return; setInfo(cam, 'Verbindungsfehler – neu…', 'crit'); setTimeout(() => { if (cam.active && !cam.busy) startStream(cam); }, 2000); }); const toggle = document.createElement('button'); toggle.className = 'cam-toggle'; toggle.onclick = () => { if (!cam.busy) (cam.active ? stopStream(cam) : startStream(cam)); }; cam.img = img; cam.toggleBtn = toggle; hd.title = 'Hi-Res-Snapshot (1280×960) – Live friert kurz ein, dann Download'; box.appendChild(img); box.appendChild(toggle); startStream(cam); } else { // Snapshot-Modus: grosser Banner + Einzelbild, das alle 5 s aktualisiert wird. const banner = document.createElement('div'); banner.className = 'single-pic-banner'; banner.textContent = 'Single Picture no Video'; const img = document.createElement('img'); img.className = 'cam-img'; img.alt = labelText; cam.img = img; hd.title = 'Hi-Res-Snapshot – Download'; box.appendChild(banner); box.appendChild(img); startSnapshotMode(cam); } box.appendChild(label); box.appendChild(info); box.appendChild(hd); container.appendChild(box); cameras.push(cam); } // ── Init ────────────────────────────────────────────────────────────────────── async function init() { log('init', 'Starte...'); const container = document.getElementById('cameras'); const statusText = document.getElementById('statusText'); let camList = []; try { const r = await fetch('/api/snapshot'); log('init', `/api/snapshot → HTTP ${r.status}`); if (r.ok) camList = (await r.json()).cameras ?? []; log('init', `Kameras: ${camList.map((c) => c.id).join(', ') || '(keine)'}`); } catch (e) { logErr('init', '/api/snapshot Fehler – Fallback', e); } if (camList.length === 0) { warn('init', 'Fallback cam0, cam1'); camList = [ { id: 'cam0', name: 'cam0', position: '', stream: true, hires: true }, { id: 'cam1', name: 'cam1', position: '', stream: true, hires: true }, ]; } const snapBtn = document.getElementById('snapAllBtn'); if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; } camList.forEach((c) => buildCamera(c, container)); statusText.textContent = `${camList.length} Kamera${camList.length !== 1 ? 's' : ''} · MJPEG`; log('init', 'Fertig'); } init();