Files
appRobotWebcam/doc/04_Delay_roadmap.md
2026-06-04 15:48:32 +02:00

16 KiB
Raw Blame History

AppRobotWebcam Delay / Ruckler-Analyse

Symptom

Nach Umstieg auf WebRTC/H.264: Bild ruckelt, friert teils 12 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 ~35103 %
AppRobotGo2RTC, 2 Clients 65114 %
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:

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

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-ctl prüfen (s.o.)
  • Wenn 1280×720 als MJPEG nativ: video_size=640x480video_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 (23 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: ~12 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 frei
  • PUT /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-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: ~3040 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: ~12 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:

  1. Schwarzer Player nach Reload ✓ behoben Ursache: Stream-Restore rief die go2rtc-API falsch auf. Verifiziert gegen die go2rtc-OpenAPI-Spec: PUT /api/streams erwartet src = Quelle (URI) und name = Stream-Name, beide als Query-Param. Der Code schickte aber src=cam0 (den Namen) und die Quelle im Body (den go2rtc ignoriert). Folge: cam0 wurde 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=cam0 war korrekt — DELETE nutzt src als Namen, API-Asymmetrie.)

  2. 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 1 griff den ersten, schwarzen Frame. Fix: erste 15 Frames verwerfen (-vf select=gte(n,15)), dann einen greifen. Kostet ~1 s mehr Blackout.

Offene Punkte (ToDo)

  • go2rtc-CPU ~53% 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. Das sind ~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, funktionierenden Streams — daher nur anfassen wenn CPU real zum Problem wird.
  • Geräte-Race bei Hi-Res mit gleichzeitig offenem Live-Tab. Ist ein Live-Consumer aktiv, kann go2rtc das Gerät nach dem DELETE per on-demand-Reconnect sofort wieder greifen und mit dem Hi-Res-Grab kollidieren. Warmup + Frame-Verwerfen fängt das meist ab. Falls doch leere Bilder auftreten: kurzer Retry im Grab, oder Live-Tab vor dem Hi-Res-Klick kurz pausieren.