8.4 KiB
AppRobotWebcam – Snapshot & HD-Grab
Status: Implementiert & verifiziert (3 Kameras: 2× C270, 1× C920). Architektur seit 2026-06-05 (Node-MJPEG-Schalter), C920-Hires + Qualität 2026-06-06. Werte: Live-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, kein zweiter Öffner. 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
mjpeg2jpeg ist Pflicht bei copybsf — Kamera-MJPEG lässt die 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
Kein Format-Wechsel nötig: grabHires gibt direkt den laufenden Live-Frame zurück
(getFrame()). Schnell, kein Geräte-Neustart.
Fall B — liveSize ≠ hiresSize (der Normalfall)
1. Live-FFmpeg SIGTERM → warte auf close (= FD frei)
2. hires-FFmpeg bei hiresSize/hiresFps starten
3. Frames warmlaufen lassen (settleFrames = 6) + minWidth-Check
4. ersten validen Frame nehmen → hires-FFmpeg beenden
5. finally: Live-FFmpeg neu starten (immer, auch bei Fehler)
Kein Warmup, kein sleep zwischen Stop und Grab. Auf dem Host verifiziert
(A/B-Test 2026-06-06): ein direkter Open auf die Zielauflösung liefert sofort korrekte
Frames — sowohl 1280×960 (C270) als auch 1920×1080 (C920), in beiden Encode-Modi.
Ein zweites FFmpeg dazwischen (früher als „Warmup-Zwischenformat" eingebaut) erzeugt
nur Device or resource busy und ist entfernt.
minWidth wird automatisch aus hiresSize abgeleitet (floor(hiresW × 0.9)).
Beispiel 1920x1080 → minWidth = 1728. Damit werden etwaige Übergangs-Frames in
falscher Auflösung (v4l2-Buffer-Reste) abgelehnt.
_captureHires setzt zusätzlich -probesize 5000000 -analyzeduration 1000000, damit
FFmpeg das MJPEG-Format sicher erkennt.
Blackout: Der <img> friert ~2–3 s ein und läuft danach weiter. Kein Client-Handling nötig.
Standbild-Qualität: Encode-Wahl
Die Kamera liefert bei input_format mjpeg bereits JPEG-komprimiert (Hardware,
unvermeidbar). Entscheidend ist, ob wir eine zweite Kompression daraufsetzen:
hiresEncode |
FFmpeg | Effekt | Empfehlung |
|---|---|---|---|
copybsf (Default) |
-c:v copy -bsf:v mjpeg2jpeg |
Kamera-JPEG pur durchgereicht, keine zweite Kompression | Standardwahl, auch für Standbilder |
mjpeg |
-c:v mjpeg -q:v 5 |
Re-Encode = zweite Kompression → sichtbare Artefakte | nur Fallback, falls copybsf zickt |
hiresEncodeüberschreibtencodenur für den Grab; fehlt es, giltencode.- cam2 lief kurzzeitig auf
mjpeg(Workaround) → sichtbare Artefakte, 131 kB. Mitcopybsfkommt das ungekürzte Kamera-JPEG (~300–450 kB) → keine Re-Encode-Artefakte.
Wirklich verlustfrei (kein JPEG-Verlust überhaupt) ginge nur über das rohe YUYV-Format → PNG. Eigener Grab-Pfad, große Dateien (~3–6 MB), langsamer, USB-intensiv. Noch nicht implementiert — nur bauen, wenn die JPEG-Qualität nicht reicht.
Kamera-spezifische Konfiguration
C270 (cam0, cam1)
{ "hiresSize": "1280x960" }
Live 640×480 (Default), Grab 1280×960, copybsf. Bewährter Standardfall.
C920 (cam2)
{ "hiresSize": "1920x1080" }
Live 640×480 (Default, USB-schonend), Grab 1920×1080, copybsf. Der Wechsel 640→1920
funktioniert direkt — kein liveSize-Override und kein Warmup nötig.
Auflösung prüfen
v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG
Nur unter 'MJPG' gelistete Auflösungen verwenden (sonst Software-Encode = teuer).
Harmlose Log-Meldungen
Diese erscheinen im Normalbetrieb und bedeuten keinen Fehler:
| Meldung | Bedeutung |
|---|---|
unable to decode APP fields: Invalid data found |
einmalige FFmpeg-Probe-Warnung beim Stream-Start (C270) |
Dequeued v4l2 buffer contains corrupted data |
erster Frame nach Geräte-Open ist oft unvollständig — wird verworfen |
input is truncated / Error applying bitstream filters |
copybsf droppt den ersten korrupten Frame; settleFrames nimmt einen späteren |
Entscheidend ist die HD OK-Zeile mit Breite=…px passend zur Soll-Breite.
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 gebaut |
| Verlustfreie Standbilder (YUYV→PNG) | niedrig | offen; siehe „Standbild-Qualität" |
Architektur-Entwicklung (Historie)
Für die maßgebliche Implementierung gelten ausschliesslich die Abschnitte oben. Dieser Teil dokumentiert den Weg dorthin.
go2rtc-Ansatz (2026-06-04) — abgelöst
go2rtc verwaltete die Kameras. Für HD-Grabs wurde der Live-Consumer losgelassen, damit
go2rtc das Gerät freigibt, dann ein separater *_hires-Stream geöffnet. Phase 1 (Freigabe
messen) und Phase 2 (Grab) funktionierten grundsätzlich, aber:
Race-Bug (2026-06-05): go2rtcs API konnte nicht zuverlässig melden, wann FFmpeg
/dev/videoN freigibt → zwei FFmpeg gleichzeitig auf demselben Gerät → 106 % CPU + Hang
(Details: 09_Bug_reports.md). Strukturell nicht lösbar, weil go2rtc keine harte Garantie
über den Gerätezustand gibt.
→ Entscheidung: go2rtc entfernen. Node startet FFmpeg selbst; dessen close-Event ist
der harte Beweis „Gerät frei". Die alte go2rtc.yaml wurde 2026-06-06 gelöscht (liegt in
der Git-Historie).
Node-MJPEG-Schalter (2026-06-05) — aktuelle Architektur
Sofort gemessen: Latenz 139 ms (vorher ~340 ms), Idle-CPU ~5 % (On-Demand), ~35 %/Kamera aktiv, HD-Grab beider C270 parallel ~2,3 s.
C920-Hires & der Warmup-Irrweg (2026-06-06)
Beim Einbau der dritten Kamera (C920, 1920×1080) schien der Grab „nur 720" zu liefern.
Mehrere falsche Theorien (Kamera kann kein 1080-MJPEG; Treiber stuft 1080→720 zurück)
wurden durch Host-Tests widerlegt: v4l2-ctl + direkte ffmpeg-Grabs zeigten
1920×1080-MJPEG auf jedem Frame, in beiden Encode-Modi.
Die zwei realen Ursachen:
- Veralteter Code im Container — eine fehlgeschlagene Git-Übertragung; der Container
lief alten Code (verifiziert via
docker exec … grep). Jede „Fix→Redeploy"-Runde testete Code, der gar nicht lief. - Warmup-Zwischenformat — ein nachträglich eingebauter Schritt öffnete ein zweites
FFmpeg (1280×720) vor dem Grab →
Device or resource busy→ alle Grabs brachen ab.
Lehre (festgehalten): Erst auf dem Host mit ffmpeg/ffprobe/v4l2-ctl
verifizieren, was die Kamera real liefert, und sicherstellen, dass der Container den
aktuellen Code fährt — bevor Code-Theorien gebaut werden. Diagnose-Tool dafür:
tools/hires-probe.js.
Nach Entfernen des Warmups und mit aktuellem Code: alle drei Kameras liefern korrekte
HD-Grabs (cam2 = 1920×1080). Encode auf copybsf für artefaktfreie Standbilder.