This commit is contained in:
chk
2026-06-07 17:00:43 +02:00
parent 39fa6d07f5
commit d9cfa7e974
13 changed files with 744 additions and 62 deletions

View File

@@ -16,9 +16,9 @@
<div class="card">
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Live-Auflösung</th><th>aktuell</th></tr>
<tr><th>ID</th><th>Name</th><th>Live-Auflösung</th><th>Kompression</th><th>aktuell</th></tr>
</thead>
<tbody id="rows"><tr><td colspan="4">lädt…</td></tr></tbody>
<tbody id="rows"><tr><td colspan="5">lädt…</td></tr></tbody>
</table>
<div class="actions">
@@ -30,6 +30,8 @@
<p class="hint">
Auflösungs-Änderung ist sofort aktiv (laufende Streams frieren kurz ein).<br>
„Aus" schaltet in den Snapshot-Modus: kein Video, alle 15 s ein HD-Einzelbild im Viewer.<br>
Kompression „H.264" spart Bandbreite (GPU), braucht aber einen MSE-fähigen Browser;
„MJPEG" ist der latenzärmste Standard. Umschalten erst nach Viewer-Reload sichtbar.<br>
⚠ C920 (cam2) braucht bei kleinen 4:3-Auflösungen überdurchschnittlich Bandbreite (siehe doc/12).
</p>
</main>

View File

@@ -7,6 +7,12 @@
const OFF = '__off__';
let liveSizes = [];
// UI bietet nur diese zwei Modi an (mjpeg-Re-Encode bleibt API/Env vorbehalten).
const ENCODE_OPTIONS = [
{ value: 'copybsf', label: 'MJPEG' },
{ value: 'h264', label: 'H.264 (GPU)' },
];
async function load() {
const r = await fetch('/api/config');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
@@ -46,20 +52,35 @@ function render(cameras) {
}
tdSel.appendChild(sel);
const tdEnc = document.createElement('td');
const encSel = document.createElement('select');
encSel.dataset.encId = cam.id;
// copybsf und (unsichtbar) mjpeg gelten beide als „MJPEG" in der UI.
const curEnc = cam.encode === 'h264' ? 'h264' : 'copybsf';
for (const o of ENCODE_OPTIONS) {
encSel.add(new Option(o.label, o.value, false, curEnc === o.value));
}
tdEnc.appendChild(encSel);
const tdCur = document.createElement('td');
tdCur.className = 'cur';
tdCur.textContent = isOff ? 'aus' : (cam.liveSize || '?');
tdCur.textContent = isOff ? 'aus' : `${cam.liveSize || '?'} · ${curEnc === 'h264' ? 'H.264' : 'MJPEG'}`;
tr.append(tdId, tdName, tdSel, tdCur);
tr.append(tdId, tdName, tdSel, tdEnc, tdCur);
tbody.appendChild(tr);
}
}
function collect() {
const encOf = (id) => {
const e = document.querySelector(`select[data-enc-id="${id}"]`);
return e ? e.value : undefined;
};
return Array.from(document.querySelectorAll('select[data-id]')).map((sel) => {
const id = sel.dataset.id;
if (sel.value === OFF) return { id, stream: false };
return { id, stream: true, liveSize: sel.value };
const encode = encOf(id);
if (sel.value === OFF) return { id, stream: false, ...(encode ? { encode } : {}) };
return { id, stream: true, liveSize: sel.value, ...(encode ? { encode } : {}) };
});
}

View File

@@ -38,6 +38,150 @@ function stopStream(cam) {
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,
@@ -151,11 +295,29 @@ function buildCamera(camMeta, container) {
hd.className = 'cam-hdtest';
hd.textContent = 'HD';
const cam = { id: camMeta.id, stream: camMeta.stream, box, infoEl: info, hdBtn: hd, active: false, busy: false };
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) {
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;
@@ -230,7 +392,9 @@ async function init() {
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
camList.forEach((c) => buildCamera(c, container));
statusText.textContent = `${camList.length} Kamera${camList.length !== 1 ? 's' : ''} · MJPEG`;
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');
}