9.3 KiB
AppRobotWebcam – Snapshot & HD-Grab
Status: Implementiert. Architektur seit 2026-06-05 (Node-MJPEG-Schalter). Gemessene Werte: Latenz 139 ms, ~5 % idle, ~35 %/Kamera aktiv, HD-Grab ~2–3 s.
Architektur
Eine CameraSwitch-Instanz pro physischem Gerät — der einzige Öffner von /dev/videoN.
Hält immer nur einen FFmpeg: entweder Live oder Grab. Nie beide.
cameras.json
└── server.js → CameraSwitch (besitzt /dev/videoN)
│
├── Live: ffmpeg → mpjpeg pipe → Fan-out an N Browser-Clients (<img>)
└── Grab: Live SIGTERM → close-Event = FD frei → hires-FFmpeg → zurück
Das close-Event des Kindprozesses ist der harte Beweis „Gerät frei" — kein Timing,
keine Polls. Das Race (zwei Encoder auf einem Gerät) ist konstruktiv ausgeschlossen.
Live-Stream
ffmpeg -fflags nobuffer -f v4l2 -input_format mjpeg
-video_size <liveSize> -framerate <liveFps>
-i /dev/videoN
-c:v copy -bsf:v mjpeg2jpeg ← copybsf (Default)
-f mpjpeg -flush_packets 1 pipe:1
| Encode-Modus | CPU | Wann |
|---|---|---|
copybsf (Default) |
~35 %/Kamera | Kamera liefert natives MJPEG |
mjpeg (Fallback) |
~50 %/Kamera | Re-Encode, falls copybsf Probleme macht |
mjpeg2jpeg ist Pflicht bei copybsf — Kamera-MJPEG lässt JPEG-Huffman-Tabellen
weg; ohne den Filter verschluckt sich der mpjpeg-Muxer (107 % CPU + Hang).
Latenz-Tuning: -fflags nobuffer + -flush_packets 1 + socket.setNoDelay(true) +
cork/uncork (Header+JPEG+Trailer = ein TCP-Segment). Gemessen: 139 ms Kamera→Browser.
On-Demand: Live läuft nur wenn Clients verbunden sind. acquire()/release() zählen
Verbraucher. Nach dem letzten + IDLE_GRACE_MS (15 s): Stop → 0 % idle. Abschaltbar
via ON_DEMAND=false.
HD-Grab (grabHires)
Fall A — liveSize == hiresSize
Wenn die Live-Auflösung bereits der gewünschten Hires-Auflösung entspricht (z.B. C920
mit liveSize: "1920x1080"): kein Format-Wechsel nötig. grabHires ruft direkt
getFrame() auf dem laufenden Live-Stream auf. Kein Neustart, kein Übergangs-Problem.
Kamera ──live 1920×1080──► getFrame() → JPEG 1920×1080
Fall B — liveSize ≠ hiresSize (C270: 640×480 → 1280×960)
1. Live-FFmpeg SIGTERM → warte auf close (= FD frei)
2. sleep(800ms) ← Kamera-/Treiberreset, Puffer auslaufen lassen
3. optional: 1280x720-Warmup-Format öffnen
4. sleep(500ms) ← Warmup/Formatwechsel stabilisieren
5. hires-FFmpeg bei hiresSize/hiresFps starten
6. Frames warmlaufen lassen (settleFrames=6) + minWidth-Check (90 % der Soll-Breite)
7. Ersten validen Frame nehmen → hires-FFmpeg SIGTERM
8. finally: Live-FFmpeg neu starten (immer, auch bei Fehler)
Blackout: Der <img> friert ~2–3 s ein und läuft danach weiter. Kein Client-Handling nötig.
minWidth wird automatisch aus hiresSize abgeleitet (floor(hiresW × 0.9)).
Damit werden Frames abgelehnt, die noch auf der alten Live-Auflösung liegen
(v4l2-Buffer-Reste vom vorherigen Format).
FFmpeg-Probe: Für das finale hires-Open werden jetzt zusätzliche Probe-Parameter
gesetzt (-probesize 5000000, -analyzeduration 1000000), um das MJPEG-Format
und die Kameraparameter sicherer zu erkennen.
Aktueller Ablauf des HD-Grabs
Der aktuelle Ablauf ist bewusst defensiv:
- Live-Stream beenden und auf FFmpeg-
closewarten. - 800 ms Pause zum Zurücksetzen des Kameratreibers.
- Wenn liveSize deutlich kleiner als hiresSize ist, zunächst ein Zwischenformat
1280x720starten und kurz einlaufen lassen. - 500 ms warten, damit das Gerät in den neuen Auflösungsmodus umschaltet.
- Hires-Stream starten, mehrere Frames puffern und den ersten validen Frame mit genügend Bytes und Breite auswählen.
- Hires-Stream beenden, Live-Stream neu starten.
Log- und Fehlerbild
Aktuelle Log-Meldungen zeigen typische MJPEG/Treiber-Phänomene:
unable to decode APP fields: meist kosmetisch beim MJPEG-Parser, häufig bei UVC-Webcams. Das bedeutet nicht zwingend einen fehlgeschlagenen Grab.input is truncated/Error applying bitstream filters: kann auftreten, wenn FFmpeg beim Beenden gerade ein MJPEG-Paket verarbeitet. Das ist ein Hinweis auf einen abrupten Stream-Abbruch, nicht zwingend auf ein ungültiges Bild.Could not find codec parameters ... unspecified pixel format: deutet darauf hin, dass der neue HD-Stream noch nicht sauber genug erkannt wurde. Deshalb nutzen wir jetzt größere Probe-Parameter.
Mögliche Ursachen
- Treiberzustand der Kamera nach einem schnellen Formatwechsel: der C920 kann
noch Frames aus dem alten Modus liefern oder zwischen
640×480und1920×1080in einen inkonsistenten Zustand kommen. - MJPEG-Frames ohne vollständige JPEG-Header oder Huffman-Tabellen, die erst durch
mjpeg2jpegergänzt werden. - Abrupte Beendigung des
ffmpeg-Prozesses während eines laufenden MJPEG-Pakets.
Kamera-spezifische Konfiguration
C920 (HD Pro Webcam) — liveSize = hiresSize
Die C920 hat ein v4l2-Treiber-Eigenheit: nach einem Live-Stream bei 640×480 kann sie beim Neustart auf 1920×1080 nicht sauber wechseln — der Treiber gibt 1280×720-Frames zurück (Übergangs-Artefakt, trotz korrekter Format-Anforderung).
Lösung: liveSize und hiresSize identisch setzen. grabHires wechselt dann kein
Format — es liest direkt aus dem laufenden 1920×1080-Stream (Fall A).
{
"id": "cam2",
"liveSize": "1920x1080",
"liveFps": 15,
"hiresSize": "1920x1080"
}
C270 — Standard (Fall B)
{
"id": "cam0",
"hiresSize": "1280x960"
}
Live bei 640×480, Grab bei 1280×960. Format-Wechsel + 300ms-Pause funktioniert.
Auflösung prüfen
Nur MJPG-native Auflösungen in liveSize/hiresSize verwenden (kein Software-Encode):
v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG
API-Endpunkte
| Endpunkt | Beschreibung |
|---|---|
GET /api/snapshot/:id |
640er JPEG aus dem Live-Puffer (on-demand, sofort) |
GET /api/snapshot/:id/hires |
HD-JPEG via grabHires (2–3 s, Blackout) |
GET /api/stream/:id |
MJPEG multipart/x-mixed-replace (Live, Browser <img>) |
GET /api/cameras |
Metadaten aller Kameras aus cameras.json |
GET /health |
Zustand aller CameraSwitch-Instanzen |
Bekannte Restprobleme
| Problem | Priorität | Zustand |
|---|---|---|
| Stream friert selten dauerhaft ein | niedrig | Einzelfall; clientseitiger Watchdog (Frame-Timeout → img.src neu) noch nicht implementiert |
unable to decode APP fields im Log (C270) |
kosmetisch | Einmalige Probe-Warnung von FFmpeg, kein Auswirkung |
input is truncated / Error applying bitstream filters beim HD-Grab |
mittel | Hinweis auf abrupten Stream-Abbruch beim Formatwechsel; Bild kann dennoch gültig sein |
Could not find codec parameters ... unspecified pixel format |
mittel | Zeichen für unvollständige FFmpeg-Probe nach Formatwechsel; größere probe-Parameter helfen |
Architektur-Entwicklung (Historie)
Dieser Abschnitt dokumentiert den Weg zur aktuellen Lösung. Für die maßgebliche Implementierung gelten ausschliesslich die Abschnitte oben.
go2rtc-Ansatz (2026-06-04) — abgelöst
Grundidee: Consumer-Umhängen
go2rtc verwaltete die Kameras. Für HD-Grabs wurde der Live-Consumer (cam0) losgelassen,
damit go2rtc das Gerät freigibt, dann ein separater cam0_hires-Stream bei 1280×960 geöffnet.
Phase 1 — Freigabe messen (2026-06-04, ✅ erfolgreich)
Endpunkt GET /api/snapshot/:id/release-test pollt go2rtcs /api/streams alle 200 ms
und misst, wann nach Consumer-Verlust der Producer stoppt (= Gerät frei).
Ergebnis:
{ "freed": true, "msUntilFree": 0, "zeroConsumerAt": 4850 }
go2rtc gibt das Gerät sofort frei wenn der letzte Consumer weg ist. Kein „warm halten".
Die ~5 s (zeroConsumerAt) sind die WS-Abmeldelatenz von go2rtc selbst.
Phase 2 — HD-Grab implementiert (2026-06-04, ✅ funktionierte)
HD-Grab lieferte echte 1280×960-Frames (~76 KB). Einschränkungen:
- Nur bei einem einzigen Viewer-Tab zuverlässig (bei mehreren fiel Consumer-Count nie auf 0)
- Sequenz: Browser löst cam0 → 5 s warten → Grab → Browser hängt zurück
- Blackout ~8–10 s (5 s Pause + Grab-Zeit)
Race-Bug entdeckt (2026-06-05) → go2rtc entfernt
go2rtcs API konnte nicht zuverlässig melden, wann FFmpeg /dev/videoN freigibt.
Folge: zwei FFmpeg gleichzeitig auf demselben Gerät → 106 % CPU + Hang (reproduzierbar).
Details: 09_Bug_reports.md.
Erkenntnis: Das Problem ist strukturell — go2rtc gibt keine harten Garantien über
den Gerätezustand. Der einzige zuverlässige Beweis „Gerät frei" ist das close-Event
des FFmpeg-Prozesses selbst.
→ Entscheidung 2026-06-05: go2rtc entfernen. Node startet FFmpeg direkt.
Node-MJPEG-Schalter (2026-06-05) — aktuelle Architektur
Mit src/cameraSwitch.js besitzt Node die Kameras direkt. Das close-Event des eigenen
FFmpeg-Kindprozesses ist der harte Beweis „FD geschlossen = Gerät frei". Keine go2rtc-
Abhängigkeit, kein Race.
Sofort gemessene Werte:
- Latenz: 139 ms (vorher ~340 ms mit go2rtc)
- CPU idle: ~5 % (On-Demand)
- CPU aktiv: ~35 %/Kamera (copybsf)
- HD-Grab beide Kameras parallel: ~2,3 s, je 1280×960
Alle weiteren Details: aktuelle Architektur-Abschnitte oben.