Files
appRobotWebcam/doc/05_screenShot_roadmap.md
2026-06-06 13:12:18 +02:00

8.4 KiB
Raw Blame History

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 ~23 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 1920x1080minWidth = 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 ~23 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 überschreibt encode nur für den Grab; fehlt es, gilt encode.
  • cam2 lief kurzzeitig auf mjpeg (Workaround) → sichtbare Artefakte, 131 kB. Mit copybsf kommt das ungekürzte Kamera-JPEG (~300450 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 (~36 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 (23 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:

  1. 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.
  2. 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.