From d9cfa7e974439b9950ea9e943071e42277bfaf76 Mon Sep 17 00:00:00 2001
From: chk <79915315+ChKendel@users.noreply.github.com>
Date: Sun, 7 Jun 2026 17:00:43 +0200
Subject: [PATCH] Compress
---
cameras.json | 41 +++++-----
docker-compose.yaml | 17 ++++
public/config.html | 6 +-
public/config.js | 29 ++++++-
public/viewer.js | 170 +++++++++++++++++++++++++++++++++++++++-
server.js | 29 ++++++-
src/cameraSwitch.js | 88 +++++++++++++++------
src/configService.js | 27 +++++--
src/fmp4Parser.js | 73 +++++++++++++++++
src/hwencode.js | 118 ++++++++++++++++++++++++++++
src/snapshotService.js | 64 ++++++++++++++-
test/fmp4Parser.test.js | 73 +++++++++++++++++
test/hwencode.test.js | 71 +++++++++++++++++
13 files changed, 744 insertions(+), 62 deletions(-)
create mode 100644 src/fmp4Parser.js
create mode 100644 src/hwencode.js
create mode 100644 test/fmp4Parser.test.js
create mode 100644 test/hwencode.test.js
diff --git a/cameras.json b/cameras.json
index 8c7ffbe..f25ceb6 100644
--- a/cameras.json
+++ b/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"
}
]
}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 5c84fbe..096c6ae 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -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
diff --git a/public/config.html b/public/config.html
index 19c41bc..d5f337d 100644
--- a/public/config.html
+++ b/public/config.html
@@ -16,9 +16,9 @@
- | ID | Name | Live-Auflösung | aktuell |
+ | ID | Name | Live-Auflösung | Kompression | aktuell |
- | lädt… |
+ | lädt… |
@@ -30,6 +30,8 @@
Auflösungs-Änderung ist sofort aktiv (laufende Streams frieren kurz ein).
„Aus" schaltet in den Snapshot-Modus: kein Video, alle 15 s ein HD-Einzelbild im Viewer.
+ 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.
⚠ C920 (cam2) braucht bei kleinen 4:3-Auflösungen überdurchschnittlich Bandbreite (siehe doc/12).
diff --git a/public/config.js b/public/config.js
index 29ed64d..abec31a 100644
--- a/public/config.js
+++ b/public/config.js
@@ -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 } : {}) };
});
}
diff --git a/public/viewer.js b/public/viewer.js
index 95019bd..2a4dd71 100644
--- a/public/viewer.js
+++ b/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/
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 →