# AppRobotWebcam – Multiple Kameras > Status: **Implementiert** (Phase 1–4A). Phase 4B/C offen. > Aktueller Stand: 3 Kameras (2× C270, 1× C920), gemischte stream-Konfiguration. --- ## Architektur ``` cameras.json │ └── server.js → je Eintrag EINE CameraSwitch (besitzt /dev/videoN, On-Demand) │ ├── stream:true → Viewer zeigt Live-Box → GET /api/stream/:id └── stream:false → Viewer zeigt Platzhalter (kein Dauer-) Grab-Pfad identisch: getFrame() / grabHires() Node.js / Express :8444 ├── GET /api/cameras → Metadaten aller Kameras aus cameras.json ├── GET /api/snapshot → Liste mit Metadaten (id, name, position, stream …) ├── GET /api/snapshot/:id → 640er JPEG (on-demand, getFrame) ├── GET /api/snapshot/:id/hires → hires JPEG (grabHires – Live kurz pausieren) ├── GET /api/stream/:id → MJPEG multipart/x-mixed-replace (Live) └── GET /health → Status aller Switches ``` **Eine Kamera = eine `CameraSwitch`.** Es gibt keinen separaten Grab-Pfad für Live- vs. Snapshot-Kameras. Das Feld `stream` entscheidet nur, ob der Viewer eine Live-Box aufbaut — der Server behandelt alle Kameras gleich. **CPU-Verbrauch:** On-Demand — 0 % idle, ~35 %/Kamera nur solange jemand schaut oder ein Grab läuft. Limit ist USB-Bandbreite + Zahl gleichzeitiger Live-Views, nicht die Gesamtzahl der Kameras. --- ## `cameras.json` Einzige Konfigurationsquelle für Geräte, Namen und Auflösungen. Liegt im Projektverzeichnis, wird beim Start geladen und validiert. ```json { "cameras": [ { "id": "cam0", "device": "/dev/video0", "name": "Vorderseite", "position": "front", "stream": true, "hires": true, "note": "usb-046d_0825_3BB3FE20-video-index0" }, { "id": "cam1", "device": "/dev/video2", "name": "Links", "position": "left", "stream": false, "hires": true, "note": "usb-046d_081b_342D4F40-video-index0" }, { "id": "cam2", "device": "/dev/video4", "name": "Rechts", "position": "right", "stream": true, "hires": true, "hiresSize": "1920x1080", "note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0" } ] } ``` > **cam2 (C920):** Live läuft auf dem globalen Default 640×480 (USB-schonend, flüssig), > nur der HD-Grab geht auf 1920×1080. `encode`/`hiresEncode` sind **nicht** gesetzt → > beide nutzen `copybsf` (Kamera-JPEG ohne Re-Encode = beste Standbild-Qualität, s.u.). > Der Wechsel 640→1920 beim Grab funktioniert direkt; **kein** Warmup/Zwischenformat nötig > (auf dem Host verifiziert, 2026-06-06). Details: `05_screenShot_roadmap.md`. ### Felder | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `id` | string | ✓ | Stabiler Bezeichner; wird in Dateinamen und API-Pfaden verwendet | | `device` | string | ✓ | `/dev/videoN` — der Pfad **im Container** (siehe by-id-Mapping unten) | | `name` | string | | Anzeigename im Viewer (Fallback: `id`) | | `position` | string | | Frei; wird im Viewer-Label angezeigt (`name · position`) | | `stream` | bool | | `true` → Live-Box im Viewer; `false` → Platzhalter (Default: `true`) | | `hires` | bool | | `false` → kein hires-Grab verfügbar, z.B. bei schwacher Kamera (Default: `true`) | | `liveSize` | string | | z.B. `"1280x720"` — überschreibt globales `LIVE_SIZE`-Env | | `liveFps` | int | | Überschreibt globales `LIVE_FPS`-Env | | `hiresSize` | string | | Überschreibt globales `HIRES_SIZE`-Env | | `hiresFps` | int | | Überschreibt globales `HIRES_FPS`-Env | | `encode` | string | | `"copybsf"` oder `"mjpeg"` — überschreibt `ENCODE_MODE`-Env (gilt für Live; und für Grab, falls `hiresEncode` fehlt) | | `hiresEncode` | string | | Encode **nur** für den HD-Grab; überschreibt `encode`. Default = `encode`. Für Standbilder `copybsf` bevorzugen (s.u.) | | `note` | string | | Freitext; empfohlen: by-id-Name des Geräts (Dokumentation) | **Globale Env-Defaults** (gelten wenn das Feld in cameras.json fehlt): `LIVE_SIZE=640x480`, `LIVE_FPS=30`, `HIRES_SIZE=1280x960`, `HIRES_FPS=15`, `ENCODE_MODE=copybsf`. --- ## Stabile Gerätepfade (`docker-compose.yaml`) `/dev/videoN` kann nach einem Reboot eine andere Kamera bezeichnen. Lösung: by-id auf dem **Host** → festes `/dev/videoN` im **Container**. ```bash ls -la /dev/v4l/by-id/ # zeigt die stabilen Namen ``` In `docker-compose.yaml`: ```yaml devices: - /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0 # cam0 - /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2 # cam1 - /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4 # cam2 ``` Der by-id-Name gehört zusätzlich ins `note`-Feld von cameras.json (Dokumentation). `cameras.json` selbst verwendet `/dev/videoN` — den Pfad den der Container sieht. --- ## MJPEG-Auflösung prüfen Nicht jede Auflösung ist bei jeder Kamera nativ MJPEG. Nicht-native Auflösungen erzwingen Software-Re-Encode (~50 % CPU extra). ```bash v4l2-ctl --list-formats-ext -d /dev/video4 | grep -A 20 MJPG ``` Nur Auflösungen unter `'MJPG'` in `liveSize`/`hiresSize` eintragen. Falls eine Auflösung nur unter `'YUYV'` erscheint → andere Auflösung wählen. > **Lehrgeld (2026-06-06):** Beim Einbau der C920 schien 1920×1080 „nur 720 zu liefern". > Ursache war **nicht** die Kamera (1920×1080 MJPEG ist nativ, auf dem Host verifiziert), > sondern (a) veralteter Code im Container und (b) ein nachträglich eingebauter > „Warmup-Zwischenformat"-Schritt, der einen zweiten FFmpeg auf dem Gerät startete > (`Device or resource busy`). Beides entfernt. Lehre: erst auf dem **Host** mit > `ffmpeg`/`ffprobe` verifizieren, was die Kamera real liefert, bevor man Code-Theorien baut. --- ## Standbild-Qualität: Encode-Wahl Eine USB-Kamera liefert das Bild bei `input_format mjpeg` bereits **JPEG-komprimiert** (Hardware, unvermeidbar). Wie der HD-Grab das ausgibt, entscheidet `hiresEncode`/`encode`: | Modus | Was passiert | Qualität / Größe | Wann | |---|---|---|---| | `copybsf` (Default) | Kamera-JPEG **durchreichen** (`-c:v copy -bsf:v mjpeg2jpeg`), keine zweite Kompression | beste JPEG-Qualität, klein, niedrigste CPU | **Standardwahl, auch für Standbilder** | | `mjpeg` | Re-Encode (`-c:v mjpeg -q:v 5`) — **zweite** Kompression | sichtbare Artefakte bei Standbildern, ~50 % CPU | nur als Fallback, wenn copybsf bei einer Kamera zickt | → Für scharfe Standbilder **`copybsf`** verwenden (kein `hiresEncode` setzen). `mjpeg` war ein Workaround und erzeugte bei cam2 sichtbare Artefakte (131 kB vs. ~350 kB copybsf). **Wirklich verlustfrei** ginge nur über das rohe **YUYV**-Format → PNG (kein JPEG-Verlust überhaupt). Das ist ein eigener Grab-Pfad (große Dateien ~3–6 MB, langsamer) und noch **nicht implementiert** — bei Bedarf siehe „Offene Punkte". --- ## USB-Hardware: Bandbreite Mehrere aktive Live-Streams brauchen genug USB-Kapazität. | Auflösung | fps | MJPEG-Bitrate | Streams pro USB-2.0-Controller | |---|---|---|---| | 640×480 | 30 | ~5 MB/s | 8 (theoretisch), 3–4 (praktisch) | | 1280×720 | 30 | ~12 MB/s | 2–3 | | 1280×960 | 15 | ~15 MB/s | 2–3 | | 1920×1080 | 30 | ~25 MB/s | 1–2 | `lsusb -t` zeigt welche Kameras am selben Controller hängen. Streaming-Kameras (`stream:true`) möglichst auf **getrennten Controllern**. Snapshot-only-Kameras (`stream:false`) teilen einen Controller problemlos. --- ## Neue Kamera hinzufügen 1. `ls -la /dev/v4l/by-id/` → by-id-Namen identifizieren 2. `v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG` → Auflösung wählen 3. Eintrag in `cameras.json` ergänzen (id, device, name, position, stream, liveSize …) 4. `docker-compose.yaml` → neues Device (`by-id → /dev/videoN`) eintragen 5. Stack in Portainer redeployen --- ## Phase 4B/C — WebService-Push (offen) **Erst umsetzen wenn ein konkreter aufrufender Container existiert.** ### Option B — Push-Trigger mit Job-ID Ein fremder Container löst einen Grab aus; Bilder landen auf einem gemeinsamen Volume. ``` POST /api/snapshot/trigger Body (optional): { "cameras": ["cam0", "cam2"] } ← leer = alle Response: { "job": "abc123", "status": "grabbing" } GET /api/snapshot/job/abc123 Response: { "status": "done", "files": [ { "id": "cam0", "path": "/snapshots/cam0_hires_1234.jpg" }, { "id": "cam2", "path": "/snapshots/cam2_hires_1234.jpg" } ]} ``` `docker-compose.yaml` — gemeinsames Volume: ```yaml volumes: snapshots: webcam: volumes: - snapshots:/snapshots homing-service: volumes: - snapshots:/snapshots:ro ``` `src/snapshotService.js`: Bilder nach `/snapshots/` schreiben, In-Memory Job-Queue. **Aufwand:** ~3–4 h. ### Option C — Synchroner Einzel-Endpunkt ``` GET /api/snapshot/all/hires Response: JSON { "cam0": "", "cam2": "" } ``` Blockiert ~8–10 s. Nur sinnvoll wenn der Aufrufer synchron warten kann. **Aufwand:** ~2 h. --- ## Offene Punkte | Punkt | Priorität | Massnahme | |---|---|---| | Phase 4B/C WebService-Push | niedrig | erst wenn aufrufender Container konkret | | Verlustfreie Standbilder (YUYV→PNG) | niedrig | eigener Grab-Pfad: rohes YUYV lesen → PNG; nur wenn JPEG-Qualität nicht reicht | | USB-Bandbreite bei >4 aktiven Streams | mittel | `lsusb -t` prüfen, Kameras auf Controller verteilen | | Stream-Freeze (selten) | niedrig | bekannt; noch kein reproduzierbarer Fall |