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

446 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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 (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**
```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: ~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.
> **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 ~34s (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** `ffmpeg` und
**keine** `devices`/`group_add: video` mehr (kein externer Grab). Kann beim nächsten
bewussten Aufräumen aus `docker-compose.yaml` raus — aktuell harmlos (nur ungenutzt).
- **Falls der PATCH-Restart je hakt** (frame.jpeg bleibt zu klein/640): Warmup-Zeit
oder Retry-Anzahl in `snapshotService.js` erhöhen (`HIRES_WARMUP_MS`, `HIRES_TRIES`).