# 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: ```bash ls -la /dev/dri/ # renderD128 vorhanden? → Hardware-Encoding möglich ``` Wenn ja, Umsetzung: ```yaml # 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: 1. CPU fällt auf <10%? 2. Latenz stabil <200ms? 3. `go2rtc`-Log zeigt `h264_vaapi` statt `libx264`? ### 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). ```yaml 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: ```javascript 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) ```yaml 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: ```bash 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: ```bash 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-ctl` prüfen (s.o.) - Wenn 1280×720 als MJPEG nativ: `video_size=640x480` → `video_size=1280x720` in 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** ```yaml 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): ```javascript 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): ```javascript 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 frei - `PUT /api/streams?src={name}` mit Body = Stream-URL → startet Producer neu **3. Mutex (concurrent requests verhindern)** ```javascript 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-ctl` bestä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`.