Compress
This commit is contained in:
41
cameras.json
41
cameras.json
@@ -1,37 +1,38 @@
|
||||
{
|
||||
"cameras": [
|
||||
{
|
||||
"id": "cam0",
|
||||
"device": "/dev/video0",
|
||||
"name": "Kamera 0",
|
||||
"id": "cam0",
|
||||
"device": "/dev/video0",
|
||||
"name": "Kamera 0",
|
||||
"position": "front",
|
||||
"stream": true,
|
||||
"hires": true,
|
||||
"note": "usb-046d_0825_3BB3FE20-video-index0",
|
||||
"stream": true,
|
||||
"hires": true,
|
||||
"note": "usb-046d_0825_3BB3FE20-video-index0",
|
||||
"hiresSize": "1280x960",
|
||||
"liveSize": "320x240"
|
||||
},
|
||||
{
|
||||
"id": "cam1",
|
||||
"device": "/dev/video2",
|
||||
"name": "Kamera 1",
|
||||
"id": "cam1",
|
||||
"device": "/dev/video2",
|
||||
"name": "Kamera 1",
|
||||
"position": "left",
|
||||
"stream": true,
|
||||
"hires": true,
|
||||
"note": "usb-046d_081b_342D4F40-video-index0",
|
||||
"stream": true,
|
||||
"hires": true,
|
||||
"note": "usb-046d_081b_342D4F40-video-index0",
|
||||
"hiresSize": "1280x960",
|
||||
"liveSize": "320x240"
|
||||
},
|
||||
{
|
||||
"id": "cam2",
|
||||
"device": "/dev/video4",
|
||||
"name": "Kamera 2",
|
||||
"id": "cam2",
|
||||
"device": "/dev/video4",
|
||||
"name": "Kamera 2",
|
||||
"position": "right",
|
||||
"stream": true,
|
||||
"hires": true,
|
||||
"note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0",
|
||||
"hiresSize": "1920x1080",
|
||||
"liveSize": "320x240"
|
||||
"stream": true,
|
||||
"hires": true,
|
||||
"note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0",
|
||||
"hiresSize": "1920x1080",
|
||||
"liveSize": "640x480",
|
||||
"encode": "h264"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -62,8 +62,12 @@ services:
|
||||
- /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0 # cam0 – C270 (046d:0825)
|
||||
- /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2 # cam1 – C270 (046d:081b)
|
||||
- /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4 # cam2 – C920
|
||||
# GPU-Renderknoten für H.264-Encoding (VAAPI Intel/AMD). Nur nötig, wenn eine
|
||||
# Kamera encode='h264' nutzt. Auf der Intel-Box bestätigt: /dev/dri/renderD128.
|
||||
- /dev/dri:/dev/dri
|
||||
group_add:
|
||||
- video
|
||||
- render # Zugriff auf /dev/dri/renderD128 (VAAPI). GID via `getent group render`.
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8444
|
||||
@@ -75,8 +79,21 @@ services:
|
||||
# - HIRES_FPS=15
|
||||
# - ENCODE_MODE=copybsf # copybsf = Bitstream-Copy, niedrige CPU (Default)
|
||||
# # mjpeg = Re-Encode (~50%, Fallback falls copybsf zickt)
|
||||
# # h264 = GPU-H.264 → MSE (Bandbreite sparen, braucht GPU)
|
||||
# - ON_DEMAND=true # Live nur bei Zuschauern (Default); 'false' = dauerhaft an
|
||||
# - IDLE_GRACE_MS=15000 # Karenz nach letztem Zuschauer vor dem Stop
|
||||
#
|
||||
# ── H.264-Hardware-Encoding (nur relevant für encode='h264') ──────────────
|
||||
# - GPU=intel # intel|amd → VAAPI (gemeinsamer Pfad) · none → libx264 (CPU-Test)
|
||||
# # → HIER die Maschine wählen: 'intel' (UHD 630) oder 'amd' (680M)
|
||||
# - HWENC=vaapi # vaapi|qsv|libx264 – Encoder erzwingen (überschreibt GPU)
|
||||
# - HWENC_DEVICE=/dev/dri/renderD128 # VAAPI/QSV-Renderknoten
|
||||
# - H264_BITRATE=3M # Zielbitrate
|
||||
# - H264_GOP= # Keyframe-Abstand (Default ~2×fps); kleiner = schnellerer Einstieg, mehr Bitrate
|
||||
# - H264_PROFILE=main # constrained_baseline|main|high (muss zum Treiber passen)
|
||||
# - H264_FRAG_MS=200 # fMP4-Fragmentlänge in ms
|
||||
# - H264_JPEG_FPS=2 # Bildrate des MJPEG-Nebenausgangs (für /api/snapshot)
|
||||
# - H264_MSE_CODEC= # MSE-Codec-String überschreiben, falls der Browser meckert (z.B. avc1.640020)
|
||||
|
||||
# ── Netzwerk ────────────────────────────────────────────────────────────────────
|
||||
# Externes, bereits existierendes Bridge-Netz (vom Stack "approbot"). Wird hier nur
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
29
server.js
29
server.js
@@ -7,16 +7,35 @@ const path = require('path');
|
||||
const { CameraSwitch } = require('./src/cameraSwitch');
|
||||
const { createSnapshotRouter, createStreamRouter, createCamerasRouter } = require('./src/snapshotService');
|
||||
const { createConfigRouter } = require('./src/configService');
|
||||
const { resolveHwenc, mseCodecString } = require('./src/hwencode');
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
||||
const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
|
||||
const LIVE_FPS = parseInt(process.env.LIVE_FPS ?? '30', 10);
|
||||
const HIRES_SIZE = process.env.HIRES_SIZE ?? '1280x960';
|
||||
const HIRES_FPS = parseInt(process.env.HIRES_FPS ?? '15', 10);
|
||||
const ENCODE_MODE = process.env.ENCODE_MODE ?? 'copybsf'; // 'copybsf' (niedrige CPU) | 'mjpeg' (Re-Encode-Fallback)
|
||||
const ENCODE_MODE = process.env.ENCODE_MODE ?? 'copybsf'; // 'copybsf' (niedrige CPU) | 'mjpeg' (Re-Encode) | 'h264' (GPU)
|
||||
const ON_DEMAND = (process.env.ON_DEMAND ?? 'true') !== 'false'; // Live nur bei Verbrauchern (spart idle-CPU)
|
||||
const IDLE_GRACE_MS = parseInt(process.env.IDLE_GRACE_MS ?? '15000', 10);
|
||||
|
||||
// ── Hardware-Encoding (nur relevant für Kameras mit encode='h264') ────────────
|
||||
// GPU=intel|amd|auto|none – Maschinen-GPU (intel/amd → VAAPI, none → libx264)
|
||||
// HWENC=vaapi|qsv|libx264 – Encoder erzwingen (überschreibt GPU)
|
||||
// HWENC_DEVICE=/dev/dri/renderD128 – VAAPI/QSV-Renderknoten
|
||||
const HWENC = resolveHwenc({
|
||||
vendor: process.env.GPU ?? 'auto',
|
||||
encoder: process.env.HWENC,
|
||||
device: process.env.HWENC_DEVICE,
|
||||
});
|
||||
const H264 = {
|
||||
bitrate: process.env.H264_BITRATE ?? '3M',
|
||||
gop: process.env.H264_GOP ? parseInt(process.env.H264_GOP, 10) : undefined, // default in hwencode: ~2×fps
|
||||
profile: process.env.H264_PROFILE ?? 'main',
|
||||
fragMs: parseInt(process.env.H264_FRAG_MS ?? '200', 10),
|
||||
jpegFps: parseInt(process.env.H264_JPEG_FPS ?? '2', 10), // Snapshot-Nebenausgang
|
||||
};
|
||||
const MSE_CODEC = process.env.H264_MSE_CODEC ?? mseCodecString(H264.profile, process.env.H264_LEVEL ?? '1F');
|
||||
|
||||
// ── cameras.json → CameraSwitch-Instanzen ─────────────────────────────────────
|
||||
const CAMERAS_PATH = path.join(__dirname, 'cameras.json');
|
||||
let camerasJson;
|
||||
@@ -43,6 +62,7 @@ for (const cam of camsConfig) {
|
||||
if (!cam.id || !cam.device) {
|
||||
console.error(`cameras.json: Eintrag ohne id/device: ${JSON.stringify(cam)}`); process.exit(1);
|
||||
}
|
||||
const camEncode = cam.encode ?? ENCODE_MODE;
|
||||
// Per-Kamera-Felder in cameras.json überschreiben die globalen Env-Werte
|
||||
switches[cam.id] = new CameraSwitch({
|
||||
id: cam.id, device: cam.device,
|
||||
@@ -50,10 +70,11 @@ for (const cam of camsConfig) {
|
||||
liveFps: cam.liveFps ?? LIVE_FPS,
|
||||
hiresSize: cam.hiresSize ?? HIRES_SIZE,
|
||||
hiresFps: cam.hiresFps ?? HIRES_FPS,
|
||||
encode: cam.encode ?? ENCODE_MODE,
|
||||
encode: camEncode,
|
||||
hiresEncode: cam.hiresEncode, // undefined → fällt im Konstruktor auf encode zurück
|
||||
stream: cam.stream !== false, // UI "Aus" → Kamera startet nicht live
|
||||
onDemand: ON_DEMAND, idleGraceMs: IDLE_GRACE_MS,
|
||||
hwenc: HWENC, h264: H264, mseCodec: MSE_CODEC, // nur für encode='h264' relevant
|
||||
});
|
||||
camsMeta.push({
|
||||
id: cam.id,
|
||||
@@ -62,6 +83,8 @@ for (const cam of camsConfig) {
|
||||
position: cam.position ?? '',
|
||||
stream: cam.stream !== false,
|
||||
hires: cam.hires !== false,
|
||||
encode: camEncode,
|
||||
mseCodec: camEncode === 'h264' ? MSE_CODEC : null,
|
||||
note: cam.note ?? '',
|
||||
});
|
||||
}
|
||||
@@ -78,6 +101,7 @@ app.use('/api/config', createConfigRouter({
|
||||
getCamerasJson: () => camerasJson,
|
||||
setCamerasJson: (v) => { camerasJson = v; },
|
||||
persist: persistCameras,
|
||||
mseCodec: MSE_CODEC,
|
||||
}));
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
@@ -107,6 +131,7 @@ server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
|
||||
console.log(` Kameras: ${camsMeta.map((c) => `${c.id}=${c.device} "${c.name}" stream=${c.stream}`).join(', ')}`);
|
||||
console.log(` Live: ${LIVE_SIZE}@${LIVE_FPS} · HD-Grab: ${HIRES_SIZE}@${HIRES_FPS} · Encode: ${ENCODE_MODE} · On-Demand: ${ON_DEMAND}`);
|
||||
console.log(` H.264-GPU: ${HWENC.encoder} @ ${HWENC.device} · ${H264.bitrate}, profile=${H264.profile}, MSE=${MSE_CODEC} (nur für encode=h264)`);
|
||||
console.log(` Viewer: http://0.0.0.0:${PORT}/`);
|
||||
|
||||
// Live-Producer starten (Dauerbetrieb)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const { h264LiveArgs } = require('./hwencode');
|
||||
const { Fmp4Parser } = require('./fmp4Parser');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -77,7 +79,7 @@ class MpjpegParser {
|
||||
//
|
||||
// Events: 'frame' (Buffer) – je ein Live-JPEG
|
||||
class CameraSwitch extends EventEmitter {
|
||||
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', hiresEncode, onDemand = true, idleGraceMs = 15000, stream = true }) {
|
||||
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15, encode = 'copybsf', hiresEncode, onDemand = true, idleGraceMs = 15000, stream = true, hwenc = null, h264 = {}, mseCodec = null }) {
|
||||
super();
|
||||
this.setMaxListeners(0); // beliebig viele Stream-Clients
|
||||
this.id = id;
|
||||
@@ -87,13 +89,17 @@ class CameraSwitch extends EventEmitter {
|
||||
this.streamEnabled = stream; // UI "Aus": Kamera darf NICHT live gehen (gated _spawnLive)
|
||||
this.hiresSize = hiresSize;
|
||||
this.hiresFps = hiresFps;
|
||||
this.encode = encode; // für Live: 'copybsf' (Default) | 'mjpeg' (Re-Encode)
|
||||
this.hiresEncode = hiresEncode ?? encode; // für Grab: fällt auf encode zurück wenn nicht gesetzt
|
||||
this.encode = encode; // Live: 'copybsf' (Default) | 'mjpeg' (Re-Encode) | 'h264' (GPU)
|
||||
this.hiresEncode = hiresEncode ?? (encode === 'h264' ? 'copybsf' : encode); // Grab bleibt JPEG (h264 → copybsf)
|
||||
this.hwenc = hwenc; // { encoder, device } – GPU-Backend (global, von server.js); nur für encode='h264'
|
||||
this.h264 = h264; // Tuning { bitrate, gop, profile, fragMs, jpegFps }
|
||||
this.mseCodec = mseCodec; // MSE-Codec-String für den Browser (z. B. 'avc1.4D401F')
|
||||
this.onDemand = onDemand; // Live nur laufen lassen, solange Verbraucher da sind
|
||||
this.idleGraceMs = idleGraceMs; // Karenz nach letztem Verbraucher vor dem Stop
|
||||
|
||||
this.proc = null; // aktueller FFmpeg-Prozess (Live ODER Grab)
|
||||
this.latest = null; // letztes Live-JPEG (für /api/snapshot); null wenn Live aus
|
||||
this.initSegment = null; // fMP4 Init (ftyp+moov), nur im h264-Modus gesetzt
|
||||
this.state = 'stopped'; // stopped | live | grabbing
|
||||
this.lock = false; // Mutex: nur ein Grab gleichzeitig
|
||||
this.stopping = false; // unterscheidet absichtliches Kill von Crash
|
||||
@@ -152,18 +158,34 @@ class CameraSwitch extends EventEmitter {
|
||||
_spawnLive() {
|
||||
if (!this.streamEnabled) return; // UI "Aus" → Kamera bleibt dunkel
|
||||
this.stopping = false;
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
'-fflags', 'nobuffer', // Input nicht puffern → niedrige Latenz
|
||||
'-f', 'v4l2', '-input_format', 'mjpeg',
|
||||
'-video_size', this.liveSize, '-framerate', String(this.liveFps),
|
||||
'-i', this.device,
|
||||
...videoOutArgs(this.encode),
|
||||
'-f', 'mpjpeg', '-flush_packets', '1', 'pipe:1', // jedes Frame sofort rausschreiben
|
||||
];
|
||||
const isH264 = this.encode === 'h264';
|
||||
|
||||
// Args + stdio je nach Modus. h264 nutzt fd 3 für den MJPEG-Nebenausgang
|
||||
// (hält `latest`/Snapshots am Leben), der MJPEG-Pfad bleibt unverändert.
|
||||
let args;
|
||||
let useFd3 = false;
|
||||
if (isH264) {
|
||||
if (!this.hwenc) { console.error(`[cam ${this.id}] encode=h264 ohne hwenc-Konfig → kein Start`); return; }
|
||||
({ args, useFd3 } = h264LiveArgs({
|
||||
device: this.device, size: this.liveSize, fps: this.liveFps, hwenc: this.hwenc,
|
||||
bitrate: this.h264.bitrate, gop: this.h264.gop, profile: this.h264.profile,
|
||||
fragMs: this.h264.fragMs, jpegFps: this.h264.jpegFps, jpegSnapshots: true,
|
||||
}));
|
||||
} else {
|
||||
args = [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
'-fflags', 'nobuffer', // Input nicht puffern → niedrige Latenz
|
||||
'-f', 'v4l2', '-input_format', 'mjpeg',
|
||||
'-video_size', this.liveSize, '-framerate', String(this.liveFps),
|
||||
'-i', this.device,
|
||||
...videoOutArgs(this.encode),
|
||||
'-f', 'mpjpeg', '-flush_packets', '1', 'pipe:1', // jedes Frame sofort rausschreiben
|
||||
];
|
||||
}
|
||||
|
||||
let p;
|
||||
try {
|
||||
p = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
p = spawn('ffmpeg', args, { stdio: useFd3 ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'] });
|
||||
} catch (e) {
|
||||
console.error(`[cam ${this.id}] spawn fehlgeschlagen: ${e.message} → Retry in 1.5s`);
|
||||
this._scheduleRestart();
|
||||
@@ -172,11 +194,26 @@ class CameraSwitch extends EventEmitter {
|
||||
this.proc = p;
|
||||
this.state = 'live';
|
||||
|
||||
const parser = new MpjpegParser((frame) => {
|
||||
this.latest = frame;
|
||||
this.emit('frame', frame);
|
||||
});
|
||||
p.stdout.on('data', (c) => parser.push(c));
|
||||
if (isH264) {
|
||||
// pipe:1 = fMP4 → Init-Segment cachen + Fragmente an die MSE-Clients fan-outen.
|
||||
const fmp4 = new Fmp4Parser({
|
||||
onInit: (init) => { this.initSegment = init; this.emit('init', init); },
|
||||
onSegment: (seg) => this.emit('segment', seg),
|
||||
});
|
||||
p.stdout.on('data', (c) => fmp4.push(c));
|
||||
// pipe:3 = gedrosseltes MJPEG → `latest` für /api/snapshot + <img>-Fallback.
|
||||
if (useFd3 && p.stdio[3]) {
|
||||
const jpg = new MpjpegParser((frame) => { this.latest = frame; this.emit('frame', frame); });
|
||||
p.stdio[3].on('data', (c) => jpg.push(c));
|
||||
}
|
||||
} else {
|
||||
const parser = new MpjpegParser((frame) => {
|
||||
this.latest = frame;
|
||||
this.emit('frame', frame);
|
||||
});
|
||||
p.stdout.on('data', (c) => parser.push(c));
|
||||
}
|
||||
|
||||
p.stderr.on('data', (c) => {
|
||||
const s = c.toString();
|
||||
if (/error|busy|invalid|no such|cannot|denied/i.test(s)) console.error(`[cam ${this.id}] ffmpeg: ${s.trim()}`);
|
||||
@@ -185,13 +222,14 @@ class CameraSwitch extends EventEmitter {
|
||||
p.on('close', (code, sig) => {
|
||||
this.proc = null;
|
||||
this.latest = null; // Producer weg → gepuffertes Frame ist stale
|
||||
this.initSegment = null; // neuer Producer ⇒ neues Init-Segment
|
||||
const wasStopping = this.stopping;
|
||||
if (this.state === 'live') this.state = 'stopped';
|
||||
if (wasStopping) return; // beabsichtigt (HD-Grab/idle) → kein Auto-Restart hier
|
||||
console.warn(`[cam ${this.id}] live ffmpeg unerwartet beendet (code=${code} sig=${sig}) → Restart`);
|
||||
this._scheduleRestart();
|
||||
});
|
||||
console.log(`[cam ${this.id}] live gestartet (${this.liveSize}@${this.liveFps}, ${this.device})`);
|
||||
console.log(`[cam ${this.id}] live gestartet (${this.liveSize}@${this.liveFps}, ${this.device}, encode=${this.encode}${isH264 ? `/${this.hwenc.encoder}` : ''})`);
|
||||
}
|
||||
|
||||
_scheduleRestart() {
|
||||
@@ -208,11 +246,13 @@ class CameraSwitch extends EventEmitter {
|
||||
// Wendet eine neue Live-Auflösung bzw. Stream-An/Aus zur Laufzeit an, OHNE
|
||||
// Container-Restart. Nutzt die vorhandenen Bausteine (_killCurrentAndWait /
|
||||
// _spawnLive) und respektiert Lock (HD-Grab) sowie On-Demand.
|
||||
async reconfigure({ liveSize, stream } = {}) {
|
||||
async reconfigure({ liveSize, stream, encode } = {}) {
|
||||
if (typeof stream === 'boolean') this.streamEnabled = stream;
|
||||
const encodeChanged = !!encode && encode !== this.encode;
|
||||
if (encode) this.encode = encode;
|
||||
|
||||
// Während eines HD-Grabs nicht eingreifen – die neue liveSize gilt nach dem
|
||||
// Grab (grabHires startet Live über _spawnLive neu, das this.liveSize liest).
|
||||
// Während eines HD-Grabs nicht eingreifen – die neue liveSize/encode gilt nach
|
||||
// dem Grab (grabHires startet Live über _spawnLive neu, das beide liest).
|
||||
if (this.lock) { if (liveSize) this.liveSize = liveSize; return; }
|
||||
|
||||
const sizeChanged = !!liveSize && liveSize !== this.liveSize;
|
||||
@@ -225,8 +265,8 @@ class CameraSwitch extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auflösung geändert → laufenden Prozess beenden (FD frei via close-Event).
|
||||
if (sizeChanged && this.proc && this.state === 'live') {
|
||||
// Auflösung ODER Encoder geändert → laufenden Prozess beenden (FD frei via close).
|
||||
if ((sizeChanged || encodeChanged) && this.proc && this.state === 'live') {
|
||||
await this._killCurrentAndWait();
|
||||
this.state = 'stopped';
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ const { LIVE_SIZES } = require('./liveSizes');
|
||||
|
||||
// ── Reine Logik (kein Express, kein fs) – Jest-testbar ───────────────────────
|
||||
|
||||
// Validiert den POST-Body. reqCameras: [{ id, liveSize?, stream? }].
|
||||
// Erlaubte Encode-Modi (Live). copybsf = MJPEG-Copy (Default), mjpeg = Re-Encode,
|
||||
// h264 = GPU-H.264 (MSE). UI bietet nur copybsf/h264 an, mjpeg bleibt per API/Env.
|
||||
const ENCODES = ['copybsf', 'mjpeg', 'h264'];
|
||||
|
||||
// Validiert den POST-Body. reqCameras: [{ id, liveSize?, stream?, encode? }].
|
||||
// Liefert { ok, errors[] }. Ein einziger Fehler kippt das Ergebnis → kein
|
||||
// Teil-Apply (der Aufrufer wendet nur bei ok:true etwas an).
|
||||
function validateConfig(reqCameras, knownIds, liveSizes = LIVE_SIZES) {
|
||||
@@ -30,6 +34,9 @@ function validateConfig(reqCameras, knownIds, liveSizes = LIVE_SIZES) {
|
||||
if (c.liveSize != null && !liveSizes.includes(c.liveSize)) {
|
||||
errors.push(`${c.id}: ungültige Auflösung "${c.liveSize}" (erlaubt: ${liveSizes.join(', ')})`);
|
||||
}
|
||||
if (c.encode != null && !ENCODES.includes(c.encode)) {
|
||||
errors.push(`${c.id}: ungültiger Encode "${c.encode}" (erlaubt: ${ENCODES.join(', ')})`);
|
||||
}
|
||||
}
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
@@ -47,6 +54,7 @@ function mergeConfig(camerasJson, reqCameras) {
|
||||
const next = { ...cam };
|
||||
if (p.liveSize != null) next.liveSize = p.liveSize;
|
||||
if ('stream' in p) next.stream = p.stream;
|
||||
if (p.encode != null) next.encode = p.encode;
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
@@ -57,11 +65,13 @@ function mergeConfig(camerasJson, reqCameras) {
|
||||
function currentConfig(switches, camsMeta) {
|
||||
return {
|
||||
liveSizes: LIVE_SIZES,
|
||||
encodes: ENCODES,
|
||||
cameras: camsMeta.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
liveSize: switches[m.id]?.liveSize ?? null,
|
||||
stream: m.stream,
|
||||
encode: switches[m.id]?.encode ?? m.encode ?? 'copybsf',
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -70,7 +80,7 @@ function currentConfig(switches, camsMeta) {
|
||||
// GET /api/config → { liveSizes, cameras:[{id,name,liveSize,stream}] }
|
||||
// POST /api/config → Body { cameras:[{id,liveSize?,stream}] }
|
||||
// validiert → cameras.json atomar schreiben → Hot-Reload
|
||||
function createConfigRouter({ switches, camsMeta, getCamerasJson, setCamerasJson, persist }) {
|
||||
function createConfigRouter({ switches, camsMeta, getCamerasJson, setCamerasJson, persist, mseCodec = null }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
@@ -91,16 +101,21 @@ function createConfigRouter({ switches, camsMeta, getCamerasJson, setCamerasJson
|
||||
}
|
||||
setCamerasJson(merged);
|
||||
|
||||
// 2. camsMeta.stream nachziehen (Viewer respektiert das beim nächsten Laden).
|
||||
// 2. camsMeta nachziehen (Viewer respektiert das beim nächsten Laden).
|
||||
for (const c of reqCameras) {
|
||||
const meta = camsMeta.find((m) => m.id === c.id);
|
||||
if (meta && 'stream' in c) meta.stream = c.stream;
|
||||
if (!meta) continue;
|
||||
if ('stream' in c) meta.stream = c.stream;
|
||||
if (c.encode != null) {
|
||||
meta.encode = c.encode;
|
||||
meta.mseCodec = c.encode === 'h264' ? mseCodec : null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Hot-Reload pro genannter Kamera (Auflösung sofort aktiv, Aus/An n. Reload).
|
||||
// 3. Hot-Reload pro genannter Kamera (Auflösung/Encoder sofort aktiv, Aus/An n. Reload).
|
||||
await Promise.allSettled(reqCameras.map((c) => {
|
||||
const sw = switches[c.id];
|
||||
return sw ? sw.reconfigure({ liveSize: c.liveSize, stream: c.stream }) : Promise.resolve();
|
||||
return sw ? sw.reconfigure({ liveSize: c.liveSize, stream: c.stream, encode: c.encode }) : Promise.resolve();
|
||||
}));
|
||||
|
||||
res.json(currentConfig(switches, camsMeta));
|
||||
|
||||
73
src/fmp4Parser.js
Normal file
73
src/fmp4Parser.js
Normal file
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
// ── Parser für fragmentiertes MP4 (fMP4) aus FFmpegs `-f mp4 +empty_moov` ─────
|
||||
// FFmpeg schreibt auf pipe:1:
|
||||
// ftyp moov ← Init-Segment (einmal, am Anfang)
|
||||
// moof mdat moof mdat … ← je ein Media-Fragment (moof+mdat)
|
||||
//
|
||||
// MSE im Browser braucht das Init-Segment ZUERST, dann komplette Fragmente.
|
||||
// Ein spät verbundener Client bekommt das gecachte Init-Segment und steigt am
|
||||
// nächsten Fragment ein – deshalb trennen wir hier sauber auf Box-Grenzen.
|
||||
//
|
||||
// Box-Layout (ISO-BMFF): [4B size][4B type][payload]. size deckt size+type ab.
|
||||
// size == 1 → 64-Bit largesize (8B nach dem type). size == 0 → bis EOF (im
|
||||
// Live-Stream nicht zu erwarten; wir warten dann auf mehr Daten).
|
||||
class Fmp4Parser {
|
||||
// onInit(buf) – einmal, sobald ftyp+moov vollständig sind
|
||||
// onSegment(buf) – je ein vollständiges Fragment (moof+mdat)
|
||||
constructor({ onInit, onSegment } = {}) {
|
||||
this.onInit = onInit || (() => {});
|
||||
this.onSegment = onSegment || (() => {});
|
||||
this.buf = Buffer.alloc(0);
|
||||
this.gotInit = false;
|
||||
this.initParts = []; // sammelt ftyp…moov
|
||||
this.fragParts = []; // sammelt moof…mdat des aktuellen Fragments
|
||||
}
|
||||
|
||||
push(chunk) {
|
||||
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
|
||||
for (;;) {
|
||||
if (this.buf.length < 8) return; // Header unvollständig
|
||||
let size = this.buf.readUInt32BE(0);
|
||||
let headerLen = 8;
|
||||
if (size === 1) {
|
||||
if (this.buf.length < 16) return;
|
||||
// 64-Bit-Größe: high32 muss 0 sein (Boxen hier sind klein)
|
||||
size = this.buf.readUInt32BE(12);
|
||||
headerLen = 16;
|
||||
} else if (size === 0) {
|
||||
return; // Box bis EOF – im Live-Stream nicht erwartet, auf mehr warten
|
||||
}
|
||||
if (size < headerLen) { // defekt → diese Box-Länge überspringen, resynchronisieren
|
||||
this.buf = this.buf.subarray(headerLen);
|
||||
continue;
|
||||
}
|
||||
if (this.buf.length < size) return; // Box-Body noch nicht komplett
|
||||
|
||||
const type = this.buf.toString('latin1', 4, 8);
|
||||
const box = this.buf.subarray(0, size);
|
||||
this.buf = this.buf.subarray(size);
|
||||
|
||||
if (!this.gotInit) {
|
||||
this.initParts.push(box);
|
||||
if (type === 'moov') {
|
||||
const init = Buffer.concat(this.initParts);
|
||||
this.initParts = [];
|
||||
this.gotInit = true;
|
||||
try { this.onInit(init); } catch (_e) { /* Consumer-Fehler ignorieren */ }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Media-Phase: Fragment = moof … mdat. Bei mdat ist es vollständig.
|
||||
this.fragParts.push(box);
|
||||
if (type === 'mdat') {
|
||||
const seg = Buffer.concat(this.fragParts);
|
||||
this.fragParts = [];
|
||||
try { this.onSegment(seg); } catch (_e) { /* Consumer-Fehler ignorieren */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Fmp4Parser };
|
||||
118
src/hwencode.js
Normal file
118
src/hwencode.js
Normal file
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
// ── Hardware-Encoding-Konfiguration (Intel / AMD / Software) ──────────────────
|
||||
// Eine Stelle, die entscheidet WIE H.264 erzeugt wird. Der Rest des Codes kennt
|
||||
// nur `encode: 'h264'` (pro Kamera) – WELCHER Encoder (GPU-Vendor) genutzt wird,
|
||||
// ist eine Maschinen-Eigenschaft und kommt global aus dem Env (server.js).
|
||||
//
|
||||
// GPU=intel → VAAPI (h264_vaapi) ← Default-Encoder, läuft auf Intel UHD 630
|
||||
// GPU=amd → VAAPI (h264_vaapi) ← läuft auf AMD 680M (VCN), gleiche API
|
||||
// GPU=auto → VAAPI (Annahme: /dev/dri vorhanden)
|
||||
// GPU=none → libx264 (Software-Fallback, zum Testen ohne GPU; hohe CPU)
|
||||
//
|
||||
// VAAPI ist die gemeinsame Linux-Schnittstelle für Intel UND AMD → ein Codepfad
|
||||
// für beide. Optional erzwingbar: HWENC=vaapi|qsv|libx264 (qsv = nur Intel).
|
||||
|
||||
const DEFAULT_DEVICE = '/dev/dri/renderD128';
|
||||
|
||||
// Normalisiert die Env-Wünsche zu einem konkreten Encoder-Backend.
|
||||
// vendor: 'intel' | 'amd' | 'auto' | 'none' (GPU)
|
||||
// encoder: 'vaapi' | 'qsv' | 'libx264' | undefined (HWENC, überschreibt vendor)
|
||||
// device: VAAPI-Renderknoten (HWENC_DEVICE)
|
||||
function resolveHwenc({ vendor = 'auto', encoder, device } = {}) {
|
||||
const v = String(vendor).toLowerCase();
|
||||
let enc = encoder ? String(encoder).toLowerCase() : null;
|
||||
|
||||
if (!enc) {
|
||||
if (v === 'none' || v === 'cpu' || v === 'software') enc = 'libx264';
|
||||
else enc = 'vaapi'; // intel, amd, auto → VAAPI (gemeinsamer Pfad)
|
||||
}
|
||||
if (!['vaapi', 'qsv', 'libx264'].includes(enc)) {
|
||||
throw new Error(`Unbekannter HWENC '${enc}' (erlaubt: vaapi, qsv, libx264)`);
|
||||
}
|
||||
return { encoder: enc, device: device || DEFAULT_DEVICE, vendor: v };
|
||||
}
|
||||
|
||||
// profile ('constrained_baseline'|'baseline'|'main'|'high') + level-Hex →
|
||||
// MSE-Codec-String (z. B. 'avc1.4D401F'). Der Browser braucht das exakt für
|
||||
// addSourceBuffer(); der Stream-Decode selbst ist toleranter.
|
||||
const PROFILE_IDC = {
|
||||
constrained_baseline: '42E0',
|
||||
baseline: '4200',
|
||||
main: '4D40',
|
||||
high: '6400',
|
||||
};
|
||||
function mseCodecString(profile = 'main', levelHex = '1F') {
|
||||
const idc = PROFILE_IDC[String(profile).toLowerCase()] ?? PROFILE_IDC.main;
|
||||
const lvl = String(levelHex).toUpperCase().padStart(2, '0');
|
||||
return `avc1.${idc}${lvl}`;
|
||||
}
|
||||
|
||||
// Baut die FFmpeg-Args für den Live-H.264-Pfad:
|
||||
// Ausgang 1 (pipe:1): fragmentiertes MP4 (fMP4) → MSE im Browser.
|
||||
// Ausgang 2 (pipe:3): optional gedrosseltes MJPEG → hält `latest` für
|
||||
// /api/snapshot am Leben (Homing-Projekt) und ermöglicht <img>-Fallback.
|
||||
//
|
||||
// Rückgabe: { args, useFd3 } – useFd3=true ⇒ spawn mit stdio …,'pipe' (fd 3).
|
||||
function h264LiveArgs({
|
||||
device, size, fps, hwenc,
|
||||
bitrate = '3M', gop, profile = 'main',
|
||||
fragMs = 200,
|
||||
jpegSnapshots = true, jpegFps = 2, jpegQ = 7,
|
||||
}) {
|
||||
const g = gop || Math.max(1, Math.round((parseInt(fps, 10) || 30) * 2)); // ~2 s
|
||||
const { encoder, device: vaapiDev } = hwenc;
|
||||
|
||||
const input = [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
'-fflags', 'nobuffer',
|
||||
'-f', 'v4l2', '-input_format', 'mjpeg',
|
||||
'-video_size', size, '-framerate', String(fps),
|
||||
'-i', device,
|
||||
];
|
||||
|
||||
// Encoder-spezifische Geräte-Init + Upload-/Format-Filter.
|
||||
let hwInit = [];
|
||||
let encFilter; // Filter, der einen encode-fähigen Frame erzeugt
|
||||
let encCodec; // -c:v …
|
||||
if (encoder === 'vaapi') {
|
||||
hwInit = ['-vaapi_device', vaapiDev];
|
||||
encFilter = 'format=nv12,hwupload';
|
||||
encCodec = ['-c:v', 'h264_vaapi'];
|
||||
} else if (encoder === 'qsv') {
|
||||
hwInit = ['-init_hw_device', `qsv=hw:${vaapiDev}`, '-filter_hw_device', 'hw'];
|
||||
encFilter = 'format=nv12,hwupload=extra_hw_frames=16';
|
||||
encCodec = ['-c:v', 'h264_qsv'];
|
||||
} else { // libx264 (Software-Fallback)
|
||||
encFilter = 'format=yuv420p';
|
||||
encCodec = ['-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency'];
|
||||
}
|
||||
|
||||
const mp4Out = [
|
||||
...encCodec, '-profile:v', profile, '-b:v', String(bitrate),
|
||||
'-g', String(g), '-bf', '0',
|
||||
'-f', 'mp4',
|
||||
'-movflags', '+frag_keyframe+empty_moov+default_base_moof',
|
||||
'-frag_duration', String(Math.max(1, Math.round(fragMs)) * 1000),
|
||||
'pipe:1',
|
||||
];
|
||||
|
||||
if (!jpegSnapshots) {
|
||||
return { args: [...input, ...hwInit, '-vf', encFilter, ...mp4Out], useFd3: false };
|
||||
}
|
||||
|
||||
// Mit Snapshot-Nebenausgang: Eingang splitten – ein Zweig encodet H.264,
|
||||
// der andere liefert gedrosseltes MJPEG für /api/snapshot + Fallback.
|
||||
const filterComplex = `[0:v]split=2[enc][jpg];[enc]${encFilter}[venc]`;
|
||||
const args = [
|
||||
...input,
|
||||
...hwInit,
|
||||
'-filter_complex', filterComplex,
|
||||
'-map', '[venc]', ...mp4Out,
|
||||
'-map', '[jpg]', '-r', String(jpegFps), '-c:v', 'mjpeg', '-q:v', String(jpegQ),
|
||||
'-f', 'mpjpeg', 'pipe:3',
|
||||
];
|
||||
return { args, useFd3: true };
|
||||
}
|
||||
|
||||
module.exports = { resolveHwenc, mseCodecString, h264LiveArgs, DEFAULT_DEVICE };
|
||||
@@ -15,7 +15,7 @@ const { readJpegWidth } = require('./cameraSwitch');
|
||||
function createSnapshotRouter(switches, cameras) {
|
||||
const router = express.Router();
|
||||
|
||||
// cameras = camsMeta aus server.js: [{id, name, position, stream, hires, note}]
|
||||
// cameras = camsMeta aus server.js: [{id, name, position, stream, hires, encode, mseCodec, note}]
|
||||
router.get('/', (_req, res) => {
|
||||
res.json({
|
||||
cameras: cameras.map((c) => ({
|
||||
@@ -24,6 +24,8 @@ function createSnapshotRouter(switches, cameras) {
|
||||
position: c.position,
|
||||
stream: c.stream,
|
||||
hires: c.hires,
|
||||
encode: c.encode, // 'copybsf' | 'mjpeg' | 'h264' → Viewer wählt Player
|
||||
mseCodec: c.mseCodec, // nur bei h264: Codec-String für MSE-addSourceBuffer
|
||||
url: `/api/snapshot/${c.id}`,
|
||||
})),
|
||||
});
|
||||
@@ -74,6 +76,63 @@ function createSnapshotRouter(switches, cameras) {
|
||||
}
|
||||
|
||||
|
||||
// H.264-Live-Stream als fortlaufendes fragmentiertes MP4 (video/mp4). Ein FFmpeg
|
||||
// (im Schalter) → Fan-out an beliebig viele Browser. Der Client speist die Bytes
|
||||
// per MSE in ein <video>. Jeder neue Client bekommt ZUERST das gecachte
|
||||
// Init-Segment (ftyp+moov), dann ab dem nächsten Fragment den Live-Stream.
|
||||
function streamH264(sw, req, res) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'video/mp4',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Connection': 'close',
|
||||
'X-Camera-Id': req.params.id,
|
||||
});
|
||||
if (res.socket) res.socket.setNoDelay(true);
|
||||
|
||||
let closed = false;
|
||||
const cleanup = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
sw.removeListener('segment', onSegment);
|
||||
sw.removeListener('init', onReinit);
|
||||
sw.release();
|
||||
};
|
||||
|
||||
const onSegment = (seg) => {
|
||||
if (closed) return;
|
||||
// Video-Fragmente dürfen NICHT einzeln gedroppt werden (Decode-Lücke) – ist ein
|
||||
// Client zu langsam, lieber die Verbindung kappen; der Browser verbindet neu.
|
||||
if (res.writableLength > (4 << 20)) { cleanup(); try { res.end(); } catch (_e) {} return; }
|
||||
try { res.write(seg); } catch (_e) { cleanup(); }
|
||||
};
|
||||
|
||||
// Neues Init-Segment ⇒ der Producer wurde neu gestartet (Auflösung/Encoder/Crash).
|
||||
// Das alte moov passt nicht mehr → Verbindung beenden, Client holt sich frisch ab.
|
||||
const onReinit = () => { if (!closed) { cleanup(); try { res.end(); } catch (_e) {} } };
|
||||
|
||||
sw.acquire(); // On-Demand: startet den Producer falls nötig
|
||||
|
||||
const start = (init) => {
|
||||
if (closed) return;
|
||||
try { res.write(init); } catch (_e) { cleanup(); return; }
|
||||
sw.on('segment', onSegment);
|
||||
sw.on('init', onReinit);
|
||||
};
|
||||
|
||||
if (sw.initSegment) {
|
||||
start(sw.initSegment);
|
||||
} else {
|
||||
// Auf das erste Init-Segment warten (Producer läuft warm).
|
||||
const t = setTimeout(() => { sw.removeListener('init', onFirst); if (!closed) { sw.release(); try { res.end(); } catch (_e) {} } }, 10000);
|
||||
const onFirst = (init) => { clearTimeout(t); start(init); };
|
||||
sw.once('init', onFirst);
|
||||
}
|
||||
|
||||
req.on('close', cleanup);
|
||||
res.on('error', cleanup);
|
||||
}
|
||||
|
||||
// MJPEG-Live-Stream als multipart/x-mixed-replace. Ein FFmpeg (im Schalter) →
|
||||
// Fan-out an beliebig viele Browser. Browser rendert das nativ im <img>.
|
||||
function createStreamRouter(switches) {
|
||||
@@ -83,6 +142,9 @@ function createStreamRouter(switches) {
|
||||
const sw = switches[req.params.id];
|
||||
if (!sw) return res.status(404).end();
|
||||
|
||||
// H.264-Kameras liefern fMP4 (MSE) statt MJPEG-multipart (<img>).
|
||||
if (sw.encode === 'h264') return streamH264(sw, req, res);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'multipart/x-mixed-replace; boundary=frame',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
|
||||
73
test/fmp4Parser.test.js
Normal file
73
test/fmp4Parser.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const { Fmp4Parser } = require('../src/fmp4Parser');
|
||||
|
||||
// Baut eine ISO-BMFF-Box: [4B size][4B type][payload]. size deckt size+type ab.
|
||||
function box(type, payload = Buffer.alloc(0)) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(8 + payload.length, 0);
|
||||
head.write(type, 4, 'latin1');
|
||||
return Buffer.concat([head, payload]);
|
||||
}
|
||||
|
||||
// Typischer FFmpeg-fMP4-Stream: ftyp moov | (moof mdat)…
|
||||
function ftyp() { return box('ftyp', Buffer.from('isom')); }
|
||||
function moov() { return box('moov', Buffer.from([1, 2, 3, 4])); }
|
||||
function frag(n) { return Buffer.concat([box('moof', Buffer.from([n])), box('mdat', Buffer.from([n, n, n]))]); }
|
||||
|
||||
describe('Fmp4Parser', () => {
|
||||
test('Init-Segment = ftyp + moov, einmalig', () => {
|
||||
const inits = []; const segs = [];
|
||||
const p = new Fmp4Parser({ onInit: (b) => inits.push(b), onSegment: (b) => segs.push(b) });
|
||||
p.push(Buffer.concat([ftyp(), moov()]));
|
||||
expect(inits).toHaveLength(1);
|
||||
expect(inits[0].equals(Buffer.concat([ftyp(), moov()]))).toBe(true);
|
||||
expect(segs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Fragmente = moof + mdat', () => {
|
||||
const inits = []; const segs = [];
|
||||
const p = new Fmp4Parser({ onInit: (b) => inits.push(b), onSegment: (b) => segs.push(b) });
|
||||
p.push(Buffer.concat([ftyp(), moov(), frag(1), frag(2)]));
|
||||
expect(inits).toHaveLength(1);
|
||||
expect(segs).toHaveLength(2);
|
||||
expect(segs[0].equals(frag(1))).toBe(true);
|
||||
expect(segs[1].equals(frag(2))).toBe(true);
|
||||
});
|
||||
|
||||
test('über beliebige Chunk-Grenzen gesplittet', () => {
|
||||
const inits = []; const segs = [];
|
||||
const p = new Fmp4Parser({ onInit: (b) => inits.push(b), onSegment: (b) => segs.push(b) });
|
||||
const stream = Buffer.concat([ftyp(), moov(), frag(1), frag(2), frag(3)]);
|
||||
for (let i = 0; i < stream.length; i += 3) p.push(stream.subarray(i, i + 3)); // 3-Byte-Häppchen
|
||||
expect(inits).toHaveLength(1);
|
||||
expect(segs).toHaveLength(3);
|
||||
expect(segs[2].equals(frag(3))).toBe(true);
|
||||
});
|
||||
|
||||
test('Byte-für-Byte (Extremfall)', () => {
|
||||
const inits = []; const segs = [];
|
||||
const p = new Fmp4Parser({ onInit: (b) => inits.push(b), onSegment: (b) => segs.push(b) });
|
||||
const stream = Buffer.concat([ftyp(), moov(), frag(7)]);
|
||||
for (const byte of stream) p.push(Buffer.from([byte]));
|
||||
expect(inits).toHaveLength(1);
|
||||
expect(segs).toHaveLength(1);
|
||||
expect(segs[0].equals(frag(7))).toBe(true);
|
||||
});
|
||||
|
||||
test('64-Bit largesize (size==1) wird verstanden', () => {
|
||||
const inits = [];
|
||||
const p = new Fmp4Parser({ onInit: (b) => inits.push(b) });
|
||||
// ftyp normal, moov als 64-Bit-Box
|
||||
const payload = Buffer.from([9, 9]);
|
||||
const head = Buffer.alloc(16);
|
||||
head.writeUInt32BE(1, 0); // size == 1 → largesize folgt
|
||||
head.write('moov', 4, 'latin1');
|
||||
head.writeUInt32BE(0, 8); // largesize high
|
||||
head.writeUInt32BE(16 + payload.length, 12); // largesize low
|
||||
const moov64 = Buffer.concat([head, payload]);
|
||||
p.push(Buffer.concat([ftyp(), moov64]));
|
||||
expect(inits).toHaveLength(1);
|
||||
expect(inits[0].equals(Buffer.concat([ftyp(), moov64]))).toBe(true);
|
||||
});
|
||||
});
|
||||
71
test/hwencode.test.js
Normal file
71
test/hwencode.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
'use strict';
|
||||
|
||||
const { resolveHwenc, mseCodecString, h264LiveArgs } = require('../src/hwencode');
|
||||
|
||||
describe('resolveHwenc', () => {
|
||||
test('intel → vaapi am Default-Renderknoten', () => {
|
||||
expect(resolveHwenc({ vendor: 'intel' })).toMatchObject({ encoder: 'vaapi', device: '/dev/dri/renderD128' });
|
||||
});
|
||||
test('amd → vaapi (gleicher Codepfad)', () => {
|
||||
expect(resolveHwenc({ vendor: 'amd' }).encoder).toBe('vaapi');
|
||||
});
|
||||
test('none → libx264 (Software-Fallback)', () => {
|
||||
expect(resolveHwenc({ vendor: 'none' }).encoder).toBe('libx264');
|
||||
});
|
||||
test('HWENC überschreibt Vendor', () => {
|
||||
expect(resolveHwenc({ vendor: 'amd', encoder: 'qsv' }).encoder).toBe('qsv');
|
||||
});
|
||||
test('eigener Device-Pfad', () => {
|
||||
expect(resolveHwenc({ vendor: 'intel', device: '/dev/dri/renderD129' }).device).toBe('/dev/dri/renderD129');
|
||||
});
|
||||
test('unbekannter Encoder wirft', () => {
|
||||
expect(() => resolveHwenc({ encoder: 'nvenc' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mseCodecString', () => {
|
||||
test('main/3.1', () => expect(mseCodecString('main', '1F')).toBe('avc1.4D401F'));
|
||||
test('high', () => expect(mseCodecString('high', '1F')).toBe('avc1.64001F'));
|
||||
test('constrained_baseline', () => expect(mseCodecString('constrained_baseline', '1F')).toBe('avc1.42E01F'));
|
||||
test('unbekannt → main', () => expect(mseCodecString('xyz', '1F')).toBe('avc1.4D401F'));
|
||||
});
|
||||
|
||||
describe('h264LiveArgs', () => {
|
||||
const base = { device: '/dev/video0', size: '640x480', fps: 30 };
|
||||
|
||||
test('VAAPI: hwupload + h264_vaapi + fragmentiertes mp4 auf pipe:1', () => {
|
||||
const { args, useFd3 } = h264LiveArgs({ ...base, hwenc: resolveHwenc({ vendor: 'intel' }), jpegSnapshots: false });
|
||||
const s = args.join(' ');
|
||||
expect(s).toContain('-vaapi_device /dev/dri/renderD128');
|
||||
expect(s).toContain('format=nv12,hwupload');
|
||||
expect(s).toContain('-c:v h264_vaapi');
|
||||
expect(s).toContain('-movflags +frag_keyframe+empty_moov+default_base_moof');
|
||||
expect(args[args.length - 1]).toBe('pipe:1');
|
||||
expect(useFd3).toBe(false);
|
||||
});
|
||||
|
||||
test('mit Snapshot-Nebenausgang: split + pipe:3 + useFd3', () => {
|
||||
const { args, useFd3 } = h264LiveArgs({ ...base, hwenc: resolveHwenc({ vendor: 'amd' }), jpegSnapshots: true });
|
||||
const s = args.join(' ');
|
||||
expect(s).toContain('-filter_complex');
|
||||
expect(s).toContain('split=2');
|
||||
expect(s).toContain('-c:v mjpeg');
|
||||
expect(args[args.length - 1]).toBe('pipe:3');
|
||||
expect(useFd3).toBe(true);
|
||||
});
|
||||
|
||||
test('libx264-Software-Fallback', () => {
|
||||
const { args } = h264LiveArgs({ ...base, hwenc: resolveHwenc({ vendor: 'none' }), jpegSnapshots: false });
|
||||
const s = args.join(' ');
|
||||
expect(s).toContain('-c:v libx264');
|
||||
expect(s).toContain('-tune zerolatency');
|
||||
expect(s).not.toContain('hwupload');
|
||||
});
|
||||
|
||||
test('GOP default ~2×fps, überschreibbar', () => {
|
||||
const def = h264LiveArgs({ ...base, hwenc: resolveHwenc({}), jpegSnapshots: false });
|
||||
expect(def.args[def.args.indexOf('-g') + 1]).toBe('60');
|
||||
const custom = h264LiveArgs({ ...base, gop: 15, hwenc: resolveHwenc({}), jpegSnapshots: false });
|
||||
expect(custom.args[custom.args.indexOf('-g') + 1]).toBe('15');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user