18 KiB
AppRobotWebcam – Delay / Ruckler-Analyse
Symptom
Nach Umstieg auf WebRTC/H.264: Bild ruckelt, friert teils 1–2 s ein, manchmal bleibt ein Einzelbild ganz stehen. Im reinen MJPEG-Modus trat das nicht auf.
Diagnose-Verlauf
Phase 1 — Messung und Eingrenzung
| Quelle | CPU |
|---|---|
| AppRobotGo2RTC, 1 Client | ~35–103 % |
| AppRobotGo2RTC, 2 Clients | 65–114 % |
| AppRobotWebcam (Node.js) | 0 % |
| Browser-Client (Laptop) | ~10 % |
getStats() im Browser lieferte konstant recv=30/s decoded=30/s dropped=0/s →
Browser und Netz sind nicht das Problem.
Zwei Browser (Laptop + Handy) zeigen exakt identische Latenz für cam0 bzw. cam1. Ändert sich die Latenz, ändert sie sich auf beiden Clients synchron → Problem sitzt in go2rtc/FFmpeg, nicht in Netz oder Browser.
Phase 2 — Root-Cause-Analyse
go2rtc's generierter FFmpeg-Befehl (simple URL-Form):
-readrate_initial_burst 0.001 -re -i /dev/videoX
-c:v libx264 -g 50 -preset:v superfast -tune:v zerolatency
-re = Rate-Emulation für Datei-Wiedergabe — puffert Live-Frames künstlich.
-g 50 = Keyframe alle 1,67 s → bis zu 1,67 s Standbild nach Loss/Reconnect.
libx264 = Software-Encoding → CPU-intensiv, skaliert schlecht mit mehreren Clients.
Phase 3 — Source-Format-Experimente (alle versucht, Ergebnis unbefriedigend)
| Source-Format | Ergebnis | Warum nicht ausreichend |
|---|---|---|
ffmpeg:/dev/video0#video=h264 |
~35% CPU mit 1 Client, Bild funktioniert | -re erzeugt variable Latenz |
ffmpeg:/dev/video0#video=h264#video=mjpeg |
~95% CPU | Doppeltes Encoding |
v4l2:/dev/video0#video=h264 |
0% CPU ohne Client (on-demand ✓), kein Bild | v4l2: Source unterstützt #video=h264 nicht |
ffmpeg:-f v4l2 ...#video=h264 |
FFmpeg-Parsing-Fehler: -f wird als Dateiname interpretiert |
go2rtc splittet den String nicht in Args |
ffmpeg:device?video=/dev/video0&input_format=mjpeg...#video=h264 |
~103% CPU, Bild funktioniert | Kein -re (gut), aber libx264 läuft trotzdem durch |
Kern-Erkenntnis (nach Phase 3)
Das Source-Format ist nicht das Problem. libx264 Software-Encoding ist es. Egal wie die Frames reinkommen — der Encoder frisst denselben CPU. Alle Source-Experimente haben daran nichts geändert.
On-Demand-Verhalten ist ein Nebeneffekt: go2rtc startet den Encoder erst bei erstem Client, stoppt bei letztem. Das ist Standard-go2rtc-Verhalten, unabhängig vom Source-Format.
Schlussfolgerung: Zwei echte Lösungen
Lösung 1 — Hardware-Encoding (Intel QuickSync / VAAPI) ← bevorzugt
H.264-Encoding auf der Intel-iGPU statt auf der CPU. CPU-Last: ~35% → ~5%. Latenz unverändert (~130ms WebRTC).
Voraussetzung prüfen:
ls -la /dev/dri/
# renderD128 vorhanden? → Hardware-Encoding möglich
Wenn ja, Umsetzung:
# docker-compose.yaml — go2rtc service:
devices:
- /dev/video0:/dev/video0
- /dev/video2:/dev/video2
- /dev/dri:/dev/dri # ← GPU durchreichen
# go2rtc-Config:
streams:
cam0: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=h264#hardware"
cam1: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=h264#hardware"
#hardware weist go2rtc an, h264_vaapi zu verwenden. go2rtc baut den FFmpeg-Befehl
mit VAAPI-Flags — ohne -re, mit GPU-Encoding.
Zu verifizieren nach Aktivierung:
- CPU fällt auf <10%?
- Latenz stabil <200ms?
go2rtc-Log zeigth264_vaapistattlibx264?
Lösung 2 — MJPEG (Fallback, sofort umsetzbar)
Kein Encoding, kein GOP, keine CPU-Last. War nachweislich stabil und flüssig. Latenz ~200ms (70ms mehr als WebRTC — für Roboter-Überwachung vertretbar).
streams:
cam0: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg"
cam1: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg"
Im Browser-Viewer MODE anpassen:
const MODE = 'mjpeg'; // statt 'webrtc,mse,mjpeg'
CPU erwartet: <5%. Kein -g 50, keine Freezes, kein Encoding-Jitter.
Ergebnis aller Versuche — Entscheid
Hardware-Encoding: gescheitert (go2rtc-Limitation)
renderD128 ist vorhanden (ls -la /dev/dri/ bestätigt). go2rtc's #hardware
verwendet -hwaccel vaapi -hwaccel_output_format vaapi auf Input-Seite. Das setzt
voraus, dass der Decoder VAAPI nutzt. MJPEG von v4l2 wird aber per Software
dekodiert — hwupload findet keine VAAPI-Device-Referenz → Filterchain-Fehler.
[hwupload] A hardware device reference is required to upload frames to.
[AVFilterGraph] Error initializing filters
go2rtc's #hardware ist für Re-Encoding von RTSP-H.264-Streams gebaut,
nicht für MJPEG-Kamera-Input. Ohne eigenen FFmpeg-Befehl (den go2rtc nicht
erlaubt) ist Hardware-Encoding für diesen Use-Case nicht erreichbar.
Neue Hardware kaufen? Nicht empfohlen — und keine Garantie möglich:
renderD128(Intel iGPU) ist bereits vorhanden und VAAPI-fähig. Das Problem liegt in go2rtc's Architektur, nicht in der Hardware. Bessere GPU würde nichts ändern.- Eine Kamera mit nativem H.264-Output (z.B. Logitech C920) würde das Encoding- Problem für den Live-Stream lösen — aber nicht das Hi-Res-Snapshot-Problem (Kamera bleibt bei einer Auflösung locked). Kein Mehrwert für diesen Use-Case.
- Empfehlung: Kein Hardware-Kauf. MJPEG-Passthrough läuft stabil bei <5% CPU. Für H.264 (130 ms statt 200 ms) → MediaMTX-Weg (s.u.), keine neue Hardware nötig.
Entscheid: MJPEG-Passthrough ✓ (umgesetzt)
cam0: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg"
cam1: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg"
Kamera liefert MJPEG nativ → go2rtc reicht es 1:1 durch → kein Encoding → CPU <5%.
| H.264 Software | H.264 Hardware | MJPEG Passthrough | |
|---|---|---|---|
| CPU | ~100% | gescheitert | <5% |
| Latenz | ~130ms | — | ~200ms |
| Freezes | gelegentlich | — | keine |
| Stabilität | mittel | — | hoch |
70ms mehr Latenz ist für Roboter-Überwachung vertretbar. Snapshots haben native JPEG-Qualität (kein H.264-Artefakte).
⚠ KORREKTUR (2026-06-04): Passthrough war nie aktiv
Obiger Entscheid war konfiguriert, aber nicht wirksam. Quelle und Auslieferung sind zwei verschiedene Dinge — und nur die Quelle wurde umgestellt.
| konfiguriert | tatsächlich geliefert | |
|---|---|---|
| go2rtc-Quelle | #video=mjpeg ✓ |
MJPEG |
Viewer viewer.js |
MODE = 'webrtc,mse,mjpeg' |
Browser zog WebRTC |
WebRTC und MSE können kein MJPEG transportieren — die einzigen WebRTC-Video-Codecs sind H.264/VP8/VP9/AV1. Sobald der Browser WebRTC zog, transcodierte go2rtc das Kamera-MJPEG nach H.264 in Software (libx264) — ein Encoder pro Kamera.
Beweis aus der Messung: CPU skalierte 2× mit der Client-Zahl (53% → 127% bei 2 Clients). Passthrough ist clientzahl-unabhängig ~0% — nur Transcoding skaliert so.
Das erklärt rückwirkend alles:
- Hohe CPU trotz „MJPEG-Passthrough"-Config → es war nie Passthrough.
- Auflösung war nie die Ursache — der libx264-Encoder war es (egal bei welcher Auflösung).
- Freezes nur mit WebRTC, nie mit MJPEG → H.264-Keyframe-Abhängigkeit (
-g 50= bis 1,67s Standbild nach Loss). MJPEG-Frames sind unabhängig → ein Loss = ein einzelner Ruckler, nie ein mehrsekündiges Standbild.
Echter Fix (umgesetzt)
Die Auslieferung im Viewer auf MJPEG zwingen: MODE = 'mjpeg' in public/viewer.js.
Damit ist die Kette durchgängig MJPEG: Kamera → go2rtc (copy) → Browser. Kein Encoder.
CPU ~0% · keine Freezes · ~200ms Latenz · skaliert auf mehr Kameras
go2rtc-Quelle bleibt 640×480 #video=mjpeg. Hardware-Encoding ist damit
gegenstandslos — es wird gar nicht mehr encodiert. Der ganze VAAPI-Strang unten
ist nur noch relevant, falls später doch WebRTC-Latenz (~130ms) zwingend gebraucht wird.
Falls doch noch H.264 gewünscht (mit korrektem VAAPI)
Erfordert MediaMTX als Zwischenstufe:
v4l2 → FFmpeg (vaapi_device + eigene Flags) → RTSP (MediaMTX) → go2rtc WebRTC
FFmpeg-Befehl der funktionieren würde:
ffmpeg -vaapi_device /dev/dri/renderD128 \
-f v4l2 -input_format mjpeg -video_size 640x480 -framerate 30 -i /dev/video0 \
-vf "format=nv12,hwupload" -c:v h264_vaapi -g 15 -bf 0 \
-f rtsp rtsp://mediamtx:8554/cam0
Aufwand: ~2h (zusätzlicher Container, RTSP-Verkabelung). Lohnt sich erst wenn 200ms Latenz nachweislich ein Problem für den Anwendungsfall ist.
Hi-Res-Snapshots — offenes Problem
Warum es nicht trivial ist
Eine USB-Kamera kann gleichzeitig nur eine Auflösung liefern.
go2rtc hält die Kamera offen — Snapshot-Auflösung = Stream-Auflösung.
/api/snapshot/cam0 proxied go2rtc's /api/frame.jpeg → liefert immer Stream-Auflösung (640×480).
Versuch: video_size=1280x960 im laufenden Stream → CPU sprang auf 112%.
Wahrscheinliche Ursache: Kamera unterstützt 1280×960 nicht als natives MJPEG →
FFmpeg fällt auf YUYV zurück → Software-MJPEG-Encoding → CPU explodiert.
(Nicht reines I/O-Problem, sondern fehlendes natives Format.)
Zurückgesetzt auf stabilen Zustand: 640×480 @ 30fps, ~20% CPU.
Zwingend vor jedem Auflösungstest:
v4l2-ctl --list-formats-ext -d /dev/video0 # prüft welche Auflösungen MJPEG-nativ sind
v4l2-ctl --list-formats-ext -d /dev/video2
Nur wenn eine Auflösung dort unter "MJPEG" (nicht "YUYV") erscheint, bleibt CPU niedrig.
Option 1 — Hi-Res-Stream + CSS-Skalierung (30 min, zuerst testen)
v4l2-ctlprüfen (s.o.)- Wenn 1280×720 als MJPEG nativ:
video_size=640x480→video_size=1280x720in docker-compose - Browser zeigt per CSS 640px breit, Snapshot = volle 1280×720
- CPU erwartet: moderat (<30 %), da MJPEG-Passthrough ohne Encoding
- Wenn 1280×720 nur als YUYV: Option 2 wählen
Option 2 — Frame-Grab mit Blackout (2–3 h, konkreter Plan)
go2rtc hat eine Stream-Management-REST-API. Node.js stoppt den Stream kurz, greift mit FFmpeg direkt auf das Device zu, startet den Stream neu.
Blackout: ~1–2 Sekunden. Akzeptabel bei Snapshot-Intervall ≥ 40 s und Roboter-Pause.
Nötige Änderungen
1. docker-compose.yaml — Devices + FFmpeg in Node-Container
webcam:
build:
context: /tmp
dockerfile_inline: |
FROM node:lts-bookworm-slim
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
EXPOSE 8444
devices:
- /dev/video0:/dev/video0
- /dev/video2:/dev/video2
group_add:
- video
2. snapshotService.js — neuer /hires-Endpoint
Konfiguration oben in der Datei (passend zu go2rtc-Config halten):
const CAM_CONFIG = {
cam0: { device: '/dev/video0', hiresSize: '1280x720',
streamUrl: 'ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg' },
cam1: { device: '/dev/video2', hiresSize: '1280x720',
streamUrl: 'ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg' },
};
Endpoint-Logik (Pseudocode):
router.get('/:id/hires', async (req, res) => {
const cfg = CAM_CONFIG[req.params.id];
if (!cfg) return res.status(404).json({ error: 'Unknown camera' });
// 1. go2rtc-Stream stoppen (gibt Device frei)
await fetch(`${go2rtcUrl}/api/streams?src=${req.params.id}`, { method: 'DELETE' });
await new Promise(r => setTimeout(r, 800)); // warten bis FFmpeg-Prozess beendet
// 2. Hi-Res-Frame via FFmpeg one-shot
const jpeg = await captureOneFrame(cfg.device, cfg.hiresSize);
// 3. Stream in go2rtc wiederherstellen
await fetch(`${go2rtcUrl}/api/streams?src=${req.params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: cfg.streamUrl,
});
res.set({ 'Content-Type': 'image/jpeg', 'Cache-Control': 'no-store' });
res.end(jpeg);
});
function captureOneFrame(device, size) {
return new Promise((resolve, reject) => {
const args = [
'-f', 'v4l2', '-input_format', 'mjpeg', '-video_size', size,
'-frames:v', '1', '-q:v', '1', '-f', 'mjpeg', 'pipe:1',
];
// spawn('ffmpeg', ['-i', device, ...args]) → collect stdout → resolve(buffer)
});
}
go2rtc-API-Endpunkte (verifiziert):
DELETE /api/streams?src={name}→ stoppt Producer, gibt Device freiPUT /api/streams?src={name}mit Body = Stream-URL → startet Producer neu
3. Mutex (concurrent requests verhindern)
let hiresLock = false;
// Am Anfang des Endpoints:
if (hiresLock) return res.status(429).json({ error: 'hi-res snapshot in progress' });
hiresLock = true;
try { /* ... */ } finally { hiresLock = false; }
Option 3 — Separate Kameras für Homing
- Zwei zusätzliche USB-Kameras, nur für Homing (kein Live-Stream)
- go2rtc öffnet sie nicht → kein Konflikt, volle Auflösung on-demand
- Aufwand: Hardware-Kosten + Montage + FFmpeg one-shot in Node.js
- Sauberste Lösung langfristig, aber Hardware-Investment
Ergebnis der Tests
Option 1 gescheitert (1280×960 @ 30fps MJPEG nativ):
- Kamera unterstützt 1280×960 nativ als MJPEG (per
v4l2-ctlbestätigt) - CPU trotzdem 53% mit 1 Client / 127% mit 2 Clients
- Ursache: reines I/O — go2rtc schiebt grosse Frames für jeden Client separat durch den Netzwerkstack. CPU skaliert 2× mit Clients → kein Encoding, nur Datenmenge.
- Bei 2 Kameras × 1280×960 × 30fps × 2 Clients: ~30–40 Mbit/s — zu viel.
Entscheid: Option 2 (Blackout-Snapshot) ✓ (implementiert)
Live-Stream bleibt bei 640×480 @ 30fps (<5% CPU, stabil).
Hi-Res on demand via /api/snapshot/cam{n}/hires:
GET /api/snapshot/cam0/hires
→ go2rtc-Stream löschen → 900ms warten → FFmpeg one-shot 1280×960 → Stream wiederherstellen
→ Blackout: ~1–2 s. CPU-Peak: kurz, dann zurück auf <5%.
Umgesetzt in src/snapshotService.js und docker-compose.yaml.
Erster Live-Test (2026-06-04): erfolgreich + 2 Bugs behoben
Live-Stream nahezu Echtzeit, stabil. Hi-Res-Bild 1280×960 über /hires da.
Zwei Bugs gefunden und sofort behoben:
-
Schwarzer Player nach Reload ✓ behoben Ursache: Stream-Restore rief die go2rtc-API falsch auf. Verifiziert gegen die go2rtc-OpenAPI-Spec:
PUT /api/streamserwartetsrc= Quelle (URI) undname= Stream-Name, beide als Query-Param. Der Code schickte abersrc=cam0(den Namen) und die Quelle im Body (den go2rtc ignoriert). Folge:cam0wurde mit Quelle „cam0" = Selbstreferenz neu angelegt → kaputt → beim nächsten Verbindungsaufbau (Reload) schwarz. Fix:buildPutUrl()→PUT /api/streams?name=cam0&src=<url-encoded-quelle>, kein Body. (DELETE?src=cam0war korrekt — DELETE nutztsrcals Namen, API-Asymmetrie.) -
Hi-Res-Bild manchmal leer (~1KB schwarz) ✓ behoben Ursache: USB-Kamera liefert direkt nach Geräte-Öffnen unbelichtete Frames (Auto-Belichtung/Weissabgleich brauchen einen Moment).
-frames:v 1griff den ersten, schwarzen Frame. Fix: erste 15 Frames verwerfen (-vf select=gte(n,15)), dann einen greifen. Kostet ~1 s mehr Blackout.
Hinweis: Der hier beschriebene externe-FFmpeg-Grab (DELETE → eigener FFmpeg → PUT) wurde im zweiten Test verworfen — siehe nächster Abschnitt. Der PUT-Param-Fix (Bug 1) bleibt gültig (gleiche
name+src-Konvention nutzt jetzt PATCH).
Zweiter Test (2026-06-04): externer Grab scheitert → Architektur-Pivot
Befund: Live-Video stabil ✓. Aber /hires liefert FFmpeg exit 1, kein Frame erhalten (curl: leeres 1KB-Bild). Video bleibt dabei durchgehend stabil.
Diagnose (belegt): Das Ausbleiben des Blackouts ist der Beweis. Der externe-Grab-
Ansatz müsste das Video kurz schwarz schalten (DELETE stoppt den go2rtc-Producer).
Es bleibt aber stabil → go2rtc gibt das Gerät nie frei: Der offene Live-Viewer
reconnectet nach dem DELETE sofort, go2rtc startet den Producer per on-demand neu und
greift /dev/video0 zurück, bevor der externe FFmpeg es öffnen kann → „device busy"
→ exit 1. Eine USB-Kamera lässt sich nur einmal öffnen — zwei Prozesse (go2rtc +
eigener FFmpeg) können nicht gleichzeitig zugreifen, und der Live-Viewer lässt go2rtc
immer gewinnen. Der Zwei-Prozess-Ansatz ist damit grundsätzlich falsch.
Lösung (umgesetzt): go2rtc-interner Hi-Res-Grab — kein zweiter Prozess. go2rtc behält die Geräte-Hoheit. Node schaltet nur kurz dessen Quelle um:
1. PATCH /api/streams?name=cam0&src=<1280×960-Quelle> → go2rtc-Producer auf Hi-Res
2. ~1,2s warten (Producer-Start + Kamera-Belichtung)
3. GET /api/frame.jpeg?src=cam0 → Frame holen; nur akzeptieren wenn JPEG ≥1000px
breit (sonst ist es noch der alte 640er); bis zu 6× alle 500ms retryen
4. PATCH /api/streams?name=cam0&src=<640×480-Quelle> → zurück auf Live (immer, finally)
Nur ein Prozess (go2rtc) öffnet je das Gerät → keine Konkurrenz mehr möglich.
Der Live-Viewer dieser einen Kamera glitcht ~3–4s (Producer-Restart + kurz 1280er
Bild, vom Browser per CSS skaliert) — der vom Nutzer ausdrücklich akzeptierte „Blackout".
Die zweite Kamera ist nicht betroffen. Umgesetzt in src/snapshotService.js
(externer FFmpeg + captureOneFrame entfernt).
Offene Punkte (ToDo)
- go2rtc-CPU ~50% bei 2 aktiven Live-Streams. Besser als H.264-Transcode (~127%),
aber kein echtes Null. go2rtc re-encodiert MJPEG→MJPEG (kein
-c:v copy) statt reinem Durchreichen. ~0,5 CPU-Kerne für 2 Kameras → stabil und unkritisch auf dieser Maschine. Optionaler Hebel falls je nötig: prüfen ob go2rtc-Quelle auf echtes Copy/Passthrough umstellbar ist. Risiko: Stabilität des laufenden Streams — nur anfassen wenn CPU real zum Problem wird. - Cleanup (unkritisch): Der webcam-Container braucht jetzt kein
ffmpegund keinedevices/group_add: videomehr (kein externer Grab). Kann beim nächsten bewussten Aufräumen ausdocker-compose.yamlraus — aktuell harmlos (nur ungenutzt). - Falls der PATCH-Restart je hakt (frame.jpeg bleibt zu klein/640): Warmup-Zeit
oder Retry-Anzahl in
snapshotService.jserhöhen (HIRES_WARMUP_MS,HIRES_TRIES).