Compress
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
170
public/viewer.js
170
public/viewer.js
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user