402 lines
16 KiB
JavaScript
402 lines
16 KiB
JavaScript
'use strict';
|
||
|
||
// ── Architektur ───────────────────────────────────────────────────────────────
|
||
// Der Server (Node) besitzt die Kameras und liefert den Live-Stream als
|
||
// MJPEG multipart/x-mixed-replace unter /api/stream/<id>. Der Browser rendert
|
||
// das nativ in einem <img>. KEIN WebRTC, KEIN go2rtc, kein Transcode.
|
||
//
|
||
// HD-Snapshot: GET /api/snapshot/<id>/hires. Der Server-Schalter pausiert dafür
|
||
// den Live-FFmpeg kurz (~1–2 s), greift 1280×960, schaltet zurück. Der <img>-
|
||
// 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');
|
||
}
|
||
|
||
// ── H.264-Live über MSE ─────────────────────────────────────────────────────
|
||
// Der Server liefert unter /api/stream/<id> ein fortlaufendes fragmentiertes MP4
|
||
// (Init-Segment zuerst, dann Fragmente). Wir lesen den Body als ReadableStream
|
||
// und speisen die Bytes der Reihe nach in einen SourceBuffer → <video>.
|
||
// Kein MSE / Codec nicht unterstützt → automatischer Snapshot-Fallback (kein
|
||
// schwarzes Bild). Latenz wird durch „an die Live-Kante springen" klein gehalten.
|
||
const H264_KEEP_S = 6; // so viel Puffer hinter currentTime behalten
|
||
const H264_MAX_LAG_S = 2.0; // mehr Rückstand → an die Kante springen
|
||
|
||
function startH264Stream(cam) {
|
||
cam.active = true;
|
||
cam.toggleBtn.textContent = '⏸';
|
||
cam.toggleBtn.title = 'Stream ausschalten';
|
||
|
||
const mime = `video/mp4; codecs="${cam.mseCodec || 'avc1.4D401F'}"`;
|
||
if (!('MediaSource' in window) || !window.MediaSource.isTypeSupported(mime)) {
|
||
warn(cam.id, `MSE/${mime} nicht unterstützt → Snapshot-Fallback`);
|
||
startH264Fallback(cam);
|
||
return;
|
||
}
|
||
|
||
teardownH264(cam); // evtl. alte Session abräumen
|
||
setInfo(cam, 'verbindet… (H.264)', '');
|
||
|
||
const ms = new MediaSource();
|
||
cam.mediaSource = ms;
|
||
cam.h264Queue = [];
|
||
const objUrl = URL.createObjectURL(ms);
|
||
cam.video.src = objUrl;
|
||
|
||
ms.addEventListener('sourceopen', () => {
|
||
URL.revokeObjectURL(objUrl);
|
||
let sb;
|
||
try { sb = ms.addSourceBuffer(mime); }
|
||
catch (e) { logErr(cam.id, 'addSourceBuffer', e); startH264Fallback(cam); return; }
|
||
sb.mode = 'segments';
|
||
cam.sourceBuffer = sb;
|
||
sb.addEventListener('updateend', () => { keepLiveEdge(cam); appendNext(cam); });
|
||
pumpH264(cam, mime);
|
||
});
|
||
}
|
||
|
||
async function pumpH264(cam, mime) {
|
||
const ctrl = new AbortController();
|
||
cam.h264Abort = ctrl;
|
||
try {
|
||
const resp = await fetch(`/api/stream/${encodeURIComponent(cam.id)}?t=${Date.now()}`, { signal: ctrl.signal });
|
||
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
|
||
const reader = resp.body.getReader();
|
||
cam.video.play().catch(() => {}); // muted → Autoplay erlaubt
|
||
log(cam.id, 'H.264 verbunden');
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done || !cam.active) break;
|
||
if (value && value.length) { cam.h264Queue.push(value); appendNext(cam); }
|
||
}
|
||
} catch (e) {
|
||
if (!cam.active) return;
|
||
if (e && e.name === 'AbortError') return;
|
||
setInfo(cam, 'Verbindungsfehler – neu…', 'crit');
|
||
logErr(cam.id, 'H.264-Stream abgebrochen', e);
|
||
setTimeout(() => { if (cam.active && !cam.busy) startH264Stream(cam); }, 2000);
|
||
}
|
||
}
|
||
|
||
function appendNext(cam) {
|
||
const sb = cam.sourceBuffer, q = cam.h264Queue;
|
||
if (!sb || sb.updating || !q) return;
|
||
if (q.length) {
|
||
const chunk = q.shift();
|
||
try {
|
||
sb.appendBuffer(chunk);
|
||
if (cam.active && !cam.busy) setInfo(cam, 'H.264 · live', 'ok');
|
||
} catch (e) {
|
||
if (e && e.name === 'QuotaExceededError') { q.unshift(chunk); trimBuffer(cam, true); }
|
||
else logErr(cam.id, 'appendBuffer', e);
|
||
}
|
||
} else {
|
||
trimBuffer(cam, false); // Leerlauf nutzen, um alten Puffer zu kappen
|
||
}
|
||
}
|
||
|
||
function trimBuffer(cam, force) {
|
||
const sb = cam.sourceBuffer, v = cam.video;
|
||
if (!sb || sb.updating || !sb.buffered.length) return;
|
||
const start = sb.buffered.start(0);
|
||
const cutoff = (v.currentTime || 0) - (force ? 2 : H264_KEEP_S);
|
||
if (cutoff > start + 0.5) { try { sb.remove(start, cutoff); } catch (_e) {} }
|
||
}
|
||
|
||
function keepLiveEdge(cam) {
|
||
const sb = cam.sourceBuffer, v = cam.video;
|
||
if (!sb || !sb.buffered.length) return;
|
||
const end = sb.buffered.end(sb.buffered.length - 1);
|
||
if (end - (v.currentTime || 0) > H264_MAX_LAG_S) v.currentTime = end - 0.3; // aufholen
|
||
}
|
||
|
||
function teardownH264(cam) {
|
||
if (cam.h264Abort) { try { cam.h264Abort.abort(); } catch (_e) {} cam.h264Abort = null; }
|
||
if (cam.mediaSource && cam.mediaSource.readyState === 'open') { try { cam.mediaSource.endOfStream(); } catch (_e) {} }
|
||
cam.mediaSource = null; cam.sourceBuffer = null; cam.h264Queue = null;
|
||
if (cam.video) { try { cam.video.pause(); cam.video.removeAttribute('src'); cam.video.load(); } catch (_e) {} }
|
||
}
|
||
|
||
function stopH264Stream(cam) {
|
||
cam.active = false;
|
||
teardownH264(cam);
|
||
if (cam.snapTimer) { clearInterval(cam.snapTimer); cam.snapTimer = null; cam.snapshotActive = false; }
|
||
cam.toggleBtn.textContent = '▶';
|
||
cam.toggleBtn.title = 'Stream einschalten';
|
||
setInfo(cam, 'aus', '');
|
||
log(cam.id, 'Live aus (H.264)');
|
||
}
|
||
|
||
// MSE nicht verfügbar: <video> gegen <img> tauschen und das Live-JPEG pollen
|
||
// (der Server hält dafür einen gedrosselten MJPEG-Nebenausgang vor).
|
||
function startH264Fallback(cam) {
|
||
if (cam.video && !cam.fallbackImg) {
|
||
const img = document.createElement('img');
|
||
img.className = 'cam-img';
|
||
img.alt = cam.video.alt || cam.id;
|
||
cam.video.replaceWith(img);
|
||
cam.fallbackImg = img;
|
||
cam.img = img;
|
||
}
|
||
cam.snapshotActive = true;
|
||
fetchLiveSnapshot(cam);
|
||
cam.snapTimer = setInterval(() => fetchLiveSnapshot(cam), 1000);
|
||
setInfo(cam, 'H.264 ohne MSE – Snapshot-Fallback', 'warn');
|
||
}
|
||
|
||
async function fetchLiveSnapshot(cam) {
|
||
if (!cam.snapshotActive) return;
|
||
try {
|
||
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}?t=${Date.now()}`, { signal: AbortSignal.timeout(8000) });
|
||
if (!r.ok) return;
|
||
const blob = await r.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
cam.img.src = url;
|
||
if (cam.lastBlobUrl) URL.revokeObjectURL(cam.lastBlobUrl);
|
||
cam.lastBlobUrl = url;
|
||
} catch (_e) { /* nächstes Intervall versucht es erneut */ }
|
||
}
|
||
|
||
// ── 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, encode: camMeta.encode, mseCodec: camMeta.mseCodec, box, infoEl: info, hdBtn: hd, active: false, busy: false };
|
||
|
||
hd.onclick = () => runHiresGrab(cam);
|
||
|
||
if (camMeta.stream && camMeta.encode === 'h264') {
|
||
// H.264-Kamera: <video> + MSE statt <img>.
|
||
const video = document.createElement('video');
|
||
video.className = 'cam-img';
|
||
video.muted = true; video.autoplay = true; video.playsInline = true;
|
||
video.setAttribute('playsinline', ''); video.setAttribute('muted', '');
|
||
|
||
const toggle = document.createElement('button');
|
||
toggle.className = 'cam-toggle';
|
||
toggle.onclick = () => { if (!cam.busy) (cam.active ? stopH264Stream(cam) : startH264Stream(cam)); };
|
||
|
||
cam.video = video;
|
||
cam.toggleBtn = toggle;
|
||
hd.title = 'Hi-Res-Snapshot – Live friert kurz ein, dann Download';
|
||
|
||
box.appendChild(video);
|
||
box.appendChild(toggle);
|
||
startH264Stream(cam);
|
||
} else 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));
|
||
const modes = new Set(camList.filter((c) => c.stream !== false).map((c) => (c.encode === 'h264' ? 'H.264' : 'MJPEG')));
|
||
const modeLabel = modes.size ? [...modes].join('+') : '—';
|
||
statusText.textContent = `${camList.length} Kamera${camList.length !== 1 ? 's' : ''} · ${modeLabel}`;
|
||
log('init', 'Fertig');
|
||
}
|
||
|
||
init();
|