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

@@ -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"
}
]
}

View File

@@ -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

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');
}

View File

@@ -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)

View File

@@ -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';
}

View File

@@ -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
View 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
View 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 };

View File

@@ -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
View 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
View 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');
});
});