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

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