Files
appRobotWebcam/public/viewer.js
2026-06-07 17:00:43 +02:00

402 lines
16 KiB
JavaScript
Raw Permalink 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';
// ── 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 (~12 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();