Multicam (c) resolution
This commit is contained in:
11
cameras.json
11
cameras.json
@@ -7,16 +7,18 @@
|
|||||||
"position": "front",
|
"position": "front",
|
||||||
"stream": true,
|
"stream": true,
|
||||||
"hires": true,
|
"hires": true,
|
||||||
"note": ""
|
"note": "usb-046d_0825_3BB3FE20-video-index0",
|
||||||
|
"hiresSize": "1280x960"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cam1",
|
"id": "cam1",
|
||||||
"device": "/dev/video2",
|
"device": "/dev/video2",
|
||||||
"name": "Kamera 1",
|
"name": "Kamera 1",
|
||||||
"position": "left",
|
"position": "left",
|
||||||
"stream": false,
|
"stream": true,
|
||||||
"hires": true,
|
"hires": true,
|
||||||
"note": ""
|
"note": "usb-046d_081b_342D4F40-video-index0",
|
||||||
|
"hiresSize": "1280x960"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cam2",
|
"id": "cam2",
|
||||||
@@ -25,7 +27,8 @@
|
|||||||
"position": "right",
|
"position": "right",
|
||||||
"stream": true,
|
"stream": true,
|
||||||
"hires": true,
|
"hires": true,
|
||||||
"note": ""
|
"note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0",
|
||||||
|
"hiresSize": "1920x1080"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,78 @@
|
|||||||
> # ⚙ ARCHITEKTUR-UPDATE (2026-06-05) — der Plan wird durch den Node-MJPEG-Schalter EINFACHER
|
|
||||||
>
|
|
||||||
> Diese Roadmap wurde für den **go2rtc**-Aufbau geschrieben. Der ist seit 2026-06-05 ersetzt:
|
|
||||||
> **Node besitzt alle Kameras selbst** (`src/cameraSwitch.js`, eine `CameraSwitch` pro Gerät),
|
|
||||||
> go2rtc ist weg. Das ändert mehrere Grundannahmen — überwiegend zugunsten weniger Aufwand:
|
|
||||||
>
|
|
||||||
> | Roadmap-Annahme (alt) | Neue Realität | Folge für den Plan |
|
|
||||||
> |-----------------------|---------------|--------------------|
|
|
||||||
> | Zwei Kamera-Klassen (`stream:true` via go2rtc, `stream:false` via eigenem FFmpeg) | **Eine** Klasse: jede Kamera ist eine `CameraSwitch` mit On-Demand | **Phase 2 (separater one-shot-Pfad) entfällt** — `getFrame()`/`grabHires()` gelten für alle |
|
|
||||||
> | go2rtc-Config parallel pflegen (Redeploy je Kamera) | nur `cameras.json` → erzeugt `CameraSwitch`-Instanzen | Phase 1 wird einfacher, **keine Doppelpflege** |
|
|
||||||
> | „~25 % CPU pro Live-Stream, dauerhaft" | **On-Demand: 0 % idle**, ~35 %/Kamera **nur während aktiv beobachtet** | „2–3 live" kostet nur was, wenn wirklich jemand zuschaut |
|
|
||||||
> | HD-Grab = „Phase-2-Dance" (Consumer release → cam_hires) | `grabHires()` (Live stoppen → 1280 → zurück), **fertig** | Phase-2-Dance-Beschreibungen sind überholt |
|
|
||||||
> | ffmpeg im Node-Container „noch zu ergänzen" | **bereits drin** (+ Geräte durchgereicht) | Phase-2-Voraussetzung schon erfüllt |
|
|
||||||
> | `stream: false` = „kein Live möglich" | jede Kamera *kann* live (On-Demand); `stream:false` = **nur Viewer zeigt keine Live-Box** | reine Anzeige-Entscheidung, kein anderer Grab-Pfad |
|
|
||||||
>
|
|
||||||
> **Netto:** Phasen 1, 3, 4 bleiben sinnvoll (cameras.json/Metadaten, „Snapshot alle", WebService).
|
|
||||||
> **Phase 2 ist großteils erledigt/obsolet.** USB-Bandbreite (unten) gilt unverändert.
|
|
||||||
> Details der Architektur: `05_screenShot_roadmap.md` (Abschnitt „Node-MJPEG-Schalter").
|
|
||||||
> Die folgenden Abschnitte sind als Konzept erhalten; go2rtc-spezifische Stellen sind
|
|
||||||
> inline mit ⚠ markiert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# AppRobotWebcam – Multiple Kameras
|
# AppRobotWebcam – Multiple Kameras
|
||||||
|
|
||||||
> Status: **Konzept** (teils überholt, s. Banner oben). Aktuelle Implementierung: Node-MJPEG-
|
> Status: **Implementiert** (Phase 1–4A). Phase 4B/C offen.
|
||||||
> Schalter, alle Kameras On-Demand, HD-Grab via `grabHires()` (`05_screenShot_roadmap.md`).
|
> Aktueller Stand: 3 Kameras (2× C270, 1× C920), gemischte stream-Konfiguration.
|
||||||
> Diese Datei beschreibt den Ausbau auf bis zu 10 Kameras.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ziel
|
## Architektur
|
||||||
|
|
||||||
| Anforderung | Detail |
|
```
|
||||||
|---|---|
|
cameras.json
|
||||||
| Bis zu **10 USB-Kameras** anschliessen | Nur ein Teil streamt live; alle liefern Snapshots |
|
│
|
||||||
| **2–3 Live-Streams** im Viewer | CPU-Budget: ~25 % pro MJPEG-Stream (gemessen) |
|
└── server.js → je Eintrag EINE CameraSwitch (besitzt /dev/videoN, On-Demand)
|
||||||
| **Alle** Kameras bei „Snapshot alle" | Streaming- und Nur-Snapshot-Kameras |
|
│
|
||||||
| **Identifizierbare Bilder** | Jede Kamera hat Namen, Position, Rolle – im Dateinamen sichtbar |
|
├── stream:true → Viewer zeigt Live-Box → GET /api/stream/:id
|
||||||
| **Download im Browser** | wie bisher |
|
└── stream:false → Viewer zeigt Platzhalter (kein Dauer-<img>)
|
||||||
| **Später: WebService-Weiterleitung** | Konzept ausarbeiten, noch nicht implementieren |
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Grundproblem: ~~Zwei Kamera-Klassen~~ → EINE Klasse (aktualisiert 2026-06-05)
|
## `cameras.json`
|
||||||
|
|
||||||
Eine USB-Kamera kann gleichzeitig nur **einmal** geöffnet werden — das bleibt der
|
Einzige Konfigurationsquelle für Geräte, Namen und Auflösungen.
|
||||||
harte Constraint. ⚠ **Die frühere Zwei-Klassen-Aufteilung ist mit dem Node-Schalter
|
Liegt im Projektverzeichnis, wird beim Start geladen und validiert.
|
||||||
hinfällig:** Jede Kamera ist eine `CameraSwitch`, die das Gerät **on-demand** öffnet.
|
|
||||||
Es gibt **einen** Grab-Pfad für alle:
|
|
||||||
|
|
||||||
| | `stream: true` | `stream: false` |
|
|
||||||
|---|---|---|
|
|
||||||
| Was ist es | `CameraSwitch` (wie alle) | `CameraSwitch` (wie alle) |
|
|
||||||
| Live-View im Viewer | ja (Live-Box) | nein (nur Snapshot-Symbol) — **reine Anzeige-Wahl** |
|
|
||||||
| Snapshot 640 | `getFrame()` (startet Gerät on-demand) | identisch `getFrame()` |
|
|
||||||
| Hires 1280 | `grabHires()` | identisch `grabHires()` |
|
|
||||||
| CPU im Idle | **0 %** (On-Demand, niemand schaut) | **0 %** |
|
|
||||||
| CPU aktiv | ~35 %/Kamera nur solange beobachtet/gegrabbt | nur während eines Grabs (~2 s) |
|
|
||||||
|
|
||||||
**`stream` steuert jetzt nur, ob der Viewer eine Live-Box aufmacht** — nicht mehr den
|
|
||||||
Grab-Mechanismus. Das alte „Phase-2-Dance" und der separate FFmpeg-one-shot-Pfad
|
|
||||||
entfallen; der Schalter macht das einheitlich.
|
|
||||||
|
|
||||||
**Faustregel (unverändert sinnvoll):** dauernd zu beobachtende Kameras → `stream: true`
|
|
||||||
(Live-Box); nur beim Homing/Trigger relevante → `stream: false` (spart Viewer-Last und
|
|
||||||
Bandbreite, da kein Dauer-`<img>`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Kamera-Konfigurationsdatei (`cameras.json`)
|
|
||||||
|
|
||||||
Statt hardcodierter Gerätenamen eine maschinenlesbare Liste:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"cameras": [
|
"cameras": [
|
||||||
{
|
{
|
||||||
"id": "cam_front",
|
"id": "cam0",
|
||||||
"device": "/dev/video0",
|
"device": "/dev/video0",
|
||||||
"name": "Vorderseite",
|
"name": "Vorderseite",
|
||||||
"position": "front",
|
"position": "front",
|
||||||
"stream": true,
|
"stream": true,
|
||||||
"hires": true,
|
"hires": true,
|
||||||
"note": "Logitech C270, Arm-Spitze"
|
"note": "usb-046d_0825_3BB3FE20-video-index0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cam_left",
|
"id": "cam1",
|
||||||
"device": "/dev/video2",
|
"device": "/dev/video2",
|
||||||
"name": "Links",
|
"name": "Links",
|
||||||
"position": "left",
|
"position": "left",
|
||||||
"stream": true,
|
|
||||||
"hires": true,
|
|
||||||
"note": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cam_top",
|
|
||||||
"device": "/dev/video4",
|
|
||||||
"name": "Draufsicht",
|
|
||||||
"position": "top",
|
|
||||||
"stream": false,
|
"stream": false,
|
||||||
"hires": true,
|
"hires": true,
|
||||||
"note": "Nur Snapshot, kein Live-Stream"
|
"note": "usb-046d_081b_342D4F40-video-index0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cam2",
|
||||||
|
"device": "/dev/video4",
|
||||||
|
"name": "Rechts",
|
||||||
|
"position": "right",
|
||||||
|
"stream": true,
|
||||||
|
"hires": true,
|
||||||
|
"liveSize": "1280x720",
|
||||||
|
"liveFps": 30,
|
||||||
|
"hiresSize": "1920x1080",
|
||||||
|
"hiresFps": 30,
|
||||||
|
"note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -108,288 +80,147 @@ Statt hardcodierter Gerätenamen eine maschinenlesbare Liste:
|
|||||||
|
|
||||||
### Felder
|
### Felder
|
||||||
|
|
||||||
| Feld | Typ | Bedeutung |
|
| Feld | Typ | Pflicht | Bedeutung |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `id` | string | stabiler Bezeichner, wird im Dateinamen verwendet |
|
| `id` | string | ✓ | Stabiler Bezeichner; wird in Dateinamen und API-Pfaden verwendet |
|
||||||
| `device` | string | `/dev/videoN` — oder besser persistenter Pfad (s.u.) |
|
| `device` | string | ✓ | `/dev/videoN` — der Pfad **im Container** (siehe by-id-Mapping unten) |
|
||||||
| `name` | string | Anzeigename im Viewer |
|
| `name` | string | | Anzeigename im Viewer (Fallback: `id`) |
|
||||||
| `position` | string | frei; hilfreich für Homing-Auswertung |
|
| `position` | string | | Frei; wird im Viewer-Label angezeigt (`name · position`) |
|
||||||
| `stream` | bool | `true` → Viewer zeigt Live-Box; `false` → nur Snapshot-Symbol (Grab-Pfad identisch, On-Demand) |
|
| `stream` | bool | | `true` → Live-Box im Viewer; `false` → Platzhalter (Default: `true`) |
|
||||||
| `hires` | bool | `false` → nur 640er-Snapshot verfügbar (z.B. bei alter Kamera) |
|
| `hires` | bool | | `false` → kein hires-Grab verfügbar, z.B. bei schwacher Kamera (Default: `true`) |
|
||||||
| `note` | string | Freitext, erscheint nicht im Viewer |
|
| `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 globales `ENCODE_MODE`-Env |
|
||||||
|
| `note` | string | | Freitext; empfohlen: by-id-Name des Geräts (Dokumentation) |
|
||||||
|
|
||||||
### Persistente Gerätenamen (empfohlen)
|
**Globale Env-Defaults** (gelten wenn das Feld in cameras.json fehlt):
|
||||||
|
`LIVE_SIZE=640x480`, `LIVE_FPS=30`, `HIRES_SIZE=1280x960`, `HIRES_FPS=15`,
|
||||||
`/dev/video0` kann nach Reboot wechseln. Stabiler:
|
`ENCODE_MODE=copybsf`.
|
||||||
|
|
||||||
```
|
|
||||||
/dev/v4l/by-id/usb-Logitech_HD_Webcam_C270_<serial>-video-index0
|
|
||||||
```
|
|
||||||
|
|
||||||
Ausgabe: `ls /dev/v4l/by-id/` auf dem Server.
|
|
||||||
Symlinks auf `/dev/videoN` — einmal prüfen, dann in `cameras.json` eintragen.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architektur-Überblick (aktualisiert 2026-06-05)
|
## Stabile Gerätepfade (`docker-compose.yaml`)
|
||||||
|
|
||||||
```
|
`/dev/videoN` kann nach einem Reboot eine andere Kamera bezeichnen.
|
||||||
cameras.json
|
Lösung: by-id auf dem **Host** → festes `/dev/videoN` im **Container**.
|
||||||
│
|
|
||||||
└── server.js erzeugt je Eintrag EINE CameraSwitch (besitzt /dev/videoN, On-Demand)
|
|
||||||
│
|
|
||||||
├── stream:true → Viewer zeigt Live-Box → GET /api/stream/:id (MJPEG multipart)
|
|
||||||
└── stream:false → Viewer zeigt nur Snapshot-Symbol (kein Dauer-<img>)
|
|
||||||
(Grab-Pfad für beide identisch — getFrame / grabHires)
|
|
||||||
|
|
||||||
Node.js / Express :8444 (go2rtc ENTFERNT)
|
```bash
|
||||||
├── GET /api/cameras → Liste aus cameras.json (mit Metadaten) [Phase 4A]
|
ls -la /dev/v4l/by-id/ # zeigt die stabilen Namen
|
||||||
├── GET /api/stream/:id → MJPEG multipart/x-mixed-replace (Live, On-Demand)
|
|
||||||
├── GET /api/snapshot/:id → 640er JPEG (getFrame – startet Gerät bei Bedarf)
|
|
||||||
├── GET /api/snapshot/:id/hires → 1280er JPEG (grabHires – Live kurz pausieren)
|
|
||||||
└── POST /api/snapshot/all → alle Kameras grabben, JSON mit Metadaten [Phase 4B]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Falls der Stream schwarz bleibt obwohl die Auflösung als MJPG gelistet ist:
|
||||||
|
`"encode": "mjpeg"` in cameras.json für diese Kamera erzwingt Re-Encode
|
||||||
|
(kompatibel mit jedem Kamera-MJPEG, aber höhere CPU-Last).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## USB-Hardware: Bandbreite
|
## USB-Hardware: Bandbreite
|
||||||
|
|
||||||
**10 Kameras brauchen mehrere USB-Controller.** Faustregel:
|
Mehrere aktive Live-Streams brauchen genug USB-Kapazität.
|
||||||
|
|
||||||
| Auflösung | fps | MJPEG-Bitrate (ca.) | Kameras pro USB-2.0-Controller |
|
| Auflösung | fps | MJPEG-Bitrate | Streams pro USB-2.0-Controller |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 640×480 | 30 | ~5 MB/s | 8 (theoretisch) |
|
| 640×480 | 30 | ~5 MB/s | 8 (theoretisch), 3–4 (praktisch) |
|
||||||
| 1280×960 | 15 | ~15 MB/s | 3 |
|
| 1280×720 | 30 | ~12 MB/s | 2–3 |
|
||||||
|
| 1280×960 | 15 | ~15 MB/s | 2–3 |
|
||||||
|
| 1920×1080 | 30 | ~25 MB/s | 1–2 |
|
||||||
|
|
||||||
Praktisch: 3–4 Kameras pro Controller ratsam (Headroom für Bursts).
|
`lsusb -t` zeigt welche Kameras am selben Controller hängen.
|
||||||
`lsusb -t` zeigt die Controller-Topologie.
|
Streaming-Kameras (`stream:true`) möglichst auf **getrennten Controllern**.
|
||||||
|
Snapshot-only-Kameras (`stream:false`) teilen einen Controller problemlos.
|
||||||
Streaming-Kameras (dauernd aktiv) auf **getrennten Controllern** halten.
|
|
||||||
Snapshot-only-Kameras teilen sich einen Controller ohne Probleme (selten aktiv).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phasenplan
|
## Neue Kamera hinzufügen
|
||||||
|
|
||||||
### Phase 1 — `cameras.json` + Viewer-Anpassung
|
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
|
||||||
**Ziel:** bestehende 2 Kameras aus `cameras.json` lesen statt hardcodern.
|
3. Eintrag in `cameras.json` ergänzen (id, device, name, position, stream, liveSize …)
|
||||||
Kein funktionaler Unterschied, aber Grundlage für alles Folgende.
|
4. `docker-compose.yaml` → neues Device (`by-id → /dev/videoN`) eintragen
|
||||||
|
5. Stack in Portainer redeployen
|
||||||
**Änderungen:**
|
|
||||||
|
|
||||||
**`cameras.json`** (neu, im Projektverzeichnis)
|
|
||||||
- Startet mit den bestehenden cam0/cam1-Einträgen
|
|
||||||
|
|
||||||
**`server.js`**
|
|
||||||
- `cameras.json` beim Start laden, validieren
|
|
||||||
- Je Eintrag eine `CameraSwitch` erzeugen (ersetzt das heutige `detectDevices()`-Mapping).
|
|
||||||
⚠ **Keine go2rtc-Config mehr** — die Kamera-Definition lebt nur noch in `cameras.json`.
|
|
||||||
Geräte müssen weiterhin in `docker-compose.yaml` durchgereicht werden (`devices:`).
|
|
||||||
|
|
||||||
**`src/snapshotService.js`**
|
|
||||||
- `GET /api/snapshot` → liest Kamera-Metadaten aus `cameras.json` (Felder `name`,
|
|
||||||
`position`, `stream` mitliefern). Die Switch-Map kommt aus `server.js`.
|
|
||||||
|
|
||||||
**`public/viewer.js`**
|
|
||||||
- Kamera-Box zeigt `name` + `position` statt rohem `id`
|
|
||||||
- Nicht-Streaming-Kameras (`stream: false`): keine Live-Box, nur Snapshot-Symbol
|
|
||||||
(Platzhalter-Box mit Kamera-Name und einem Snapshot-Button)
|
|
||||||
|
|
||||||
**Dateinamen:** `${cam.id}_hires_${timestamp}.jpg` → bereits korrekt wenn `id` sprechend ist
|
|
||||||
|
|
||||||
**Erfolgskriterium:** Viewer zeigt `name`/`position`, Dateinamen enthalten `id`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2 — Snapshot-only-Kameras (FFmpeg one-shot) — ⚠ GRÖSSTENTEILS OBSOLET
|
## Phase 4B/C — WebService-Push (offen)
|
||||||
|
|
||||||
> **Hinweis (2026-06-05):** Mit dem Node-Schalter ist dieser separate Pfad **nicht mehr
|
**Erst umsetzen wenn ein konkreter aufrufender Container existiert.**
|
||||||
> nötig.** Jede `CameraSwitch` macht Snapshots on-demand (`getFrame`/`grabHires`) — egal ob
|
|
||||||
> `stream:true` oder `false`. ffmpeg im Container + Geräte-Durchreichung sind bereits erledigt.
|
|
||||||
> Der einzige offene Teil aus dieser Phase: bei `stream:false` im Viewer **keine** Live-Box
|
|
||||||
> rendern. Der untenstehende one-shot-Plan ist nur noch historischer Kontext.
|
|
||||||
|
|
||||||
**Ziel (alt):** Kameras mit `stream: false` können Snapshots liefern, ohne go2rtc zu berühren.
|
### Option B — Push-Trigger mit Job-ID
|
||||||
|
|
||||||
**Voraussetzung:** `ffmpeg` im Node-Container verfügbar.
|
Ein fremder Container löst einen Grab aus; Bilder landen auf einem gemeinsamen Volume.
|
||||||
|
|
||||||
Aktuelles Dockerfile (`dockerfile_inline` in `docker-compose.yaml`) installiert kein ffmpeg.
|
|
||||||
Erweiterung:
|
|
||||||
```dockerfile
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
**`docker-compose.yaml`** — Node-Container braucht Gerätezugang:
|
|
||||||
```yaml
|
|
||||||
webcam:
|
|
||||||
devices:
|
|
||||||
- /dev/video4:/dev/video4 # je Snapshot-only-Kamera
|
|
||||||
group_add:
|
|
||||||
- video
|
|
||||||
```
|
|
||||||
|
|
||||||
**`src/snapshotService.js`** — neuer Grab-Pfad für `stream: false`:
|
|
||||||
```
|
|
||||||
GET /api/snapshot/:id → ffmpeg one-shot @ 640×480, -frames:v 1
|
|
||||||
GET /api/snapshot/:id/hires → ffmpeg one-shot @ 1280×960, -vf select=gte(n,15)
|
|
||||||
(ersten 15 Frames verwerfen = Warmup, vgl. 04_*)
|
|
||||||
```
|
|
||||||
|
|
||||||
FFmpeg-Befehl (bewährt, vgl. `04_Delay_roadmap.md`):
|
|
||||||
```bash
|
|
||||||
ffmpeg -f v4l2 -input_format mjpeg -video_size 1280x960 -framerate 15 \
|
|
||||||
-i /dev/video4 -vf select=gte(n\\,15) -frames:v 1 -q:v 2 -f image2 pipe:1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mutex:** pro `device` (nicht pro `id`), damit parallele Aufrufe auf demselben Gerät
|
|
||||||
nicht kollidieren.
|
|
||||||
|
|
||||||
**Erfolgskriterium:**
|
|
||||||
- `GET /api/snapshot/cam_top/hires` liefert 1280er-JPEG
|
|
||||||
- go2rtc-CPU unverändert (~25 % für Streaming-Kameras)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3 — „Snapshot alle" inkl. Snapshot-only-Kameras
|
|
||||||
|
|
||||||
**Ziel:** Ein Button-Klick erzeugt Bilder **aller** Kameras gleichzeitig.
|
|
||||||
|
|
||||||
⚠ **Vereinfacht (2026-06-05):** kein Unterschied mehr zwischen den Klassen. **Alle** Kameras
|
|
||||||
nutzen `grabHires()` (Live kurz pausieren → 1280 → zurück). Da jede `CameraSwitch` ihr
|
|
||||||
eigenes Gerät besitzt, laufen die Grabs gefahrlos parallel — genau das macht
|
|
||||||
`snapshotAllHires()` im Viewer heute schon.
|
|
||||||
|
|
||||||
**`public/viewer.js` — `snapshotAllHires()` erweitern:**
|
|
||||||
```
|
|
||||||
Promise.allSettled([
|
|
||||||
...streamingCams.map(c => hiresGrab(c)), // Phase-2-Dance
|
|
||||||
...snapshotOnlyCams.map(c => oneshotGrab(c)) // direkter Fetch
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
**Zeitbudget:**
|
|
||||||
- Streaming-Kameras: ~8–10 s (release + warmup)
|
|
||||||
- Snapshot-only-Kameras: ~2–3 s (FFmpeg one-shot + warmup)
|
|
||||||
- Gesamtdauer: ~8–10 s (parallel, limitiert durch Streaming-Kameras)
|
|
||||||
|
|
||||||
**Erfolgskriterium:** Alle Kameras liefern Bilder; Dateinamen enthalten `id`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4 (Option) — WebService-Schnittstelle
|
|
||||||
|
|
||||||
#### Option A — Pull: Bestehende REST-Endpunkte (fast fertig)
|
|
||||||
|
|
||||||
Andere Container (gleicher Host, `network_mode: host`) rufen bereits:
|
|
||||||
```
|
|
||||||
GET http://localhost:8444/api/snapshot/cam_front → 640er JPEG
|
|
||||||
GET http://localhost:8444/api/snapshot/cam_front/hires → 1280er JPEG
|
|
||||||
```
|
|
||||||
|
|
||||||
Das funktioniert **jetzt schon**. Einzige Ergänzung nötig:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/cameras → JSON-Liste aller Kameras mit Metadaten
|
|
||||||
```
|
|
||||||
|
|
||||||
Damit kann ein fremder Container die verfügbaren Kameras abfragen und gezielt
|
|
||||||
einzelne Snapshots holen.
|
|
||||||
|
|
||||||
**Aufwand:** ~1 h (neuer Endpunkt, Metadaten aus `cameras.json`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Option B — Push-Trigger: „Mache Screenshot" für andere Container
|
|
||||||
|
|
||||||
Ein fremder Container ruft einen Endpunkt auf; AppRobotWebcam grabbt alle Kameras
|
|
||||||
und legt die Bilder auf ein **gemeinsames Volume**:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/snapshot/trigger
|
POST /api/snapshot/trigger
|
||||||
Body (optional): { "cameras": ["cam_front", "cam_top"] } ← leer = alle
|
Body (optional): { "cameras": ["cam0", "cam2"] } ← leer = alle
|
||||||
Response: { "job": "abc123", "status": "grabbing" }
|
Response: { "job": "abc123", "status": "grabbing" }
|
||||||
|
|
||||||
GET /api/snapshot/job/abc123
|
GET /api/snapshot/job/abc123
|
||||||
Response: { "status": "done", "files": [
|
Response: { "status": "done", "files": [
|
||||||
{ "id": "cam_front", "name": "Vorderseite", "path": "/snapshots/cam_front_hires_1234.jpg" },
|
{ "id": "cam0", "path": "/snapshots/cam0_hires_1234.jpg" },
|
||||||
{ "id": "cam_top", "name": "Draufsicht", "path": "/snapshots/cam_top_hires_1234.jpg" }
|
{ "id": "cam2", "path": "/snapshots/cam2_hires_1234.jpg" }
|
||||||
]}
|
]}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Nötige Änderungen:**
|
|
||||||
|
|
||||||
`docker-compose.yaml` — gemeinsames Volume:
|
`docker-compose.yaml` — gemeinsames Volume:
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
snapshots: # benanntes Volume
|
snapshots:
|
||||||
|
|
||||||
webcam:
|
webcam:
|
||||||
volumes:
|
volumes:
|
||||||
- snapshots:/snapshots
|
- snapshots:/snapshots
|
||||||
homing-service: # der aufrufende Container
|
homing-service:
|
||||||
volumes:
|
volumes:
|
||||||
- snapshots:/snapshots:ro
|
- snapshots:/snapshots:ro
|
||||||
```
|
```
|
||||||
|
|
||||||
`src/snapshotService.js`:
|
`src/snapshotService.js`: Bilder nach `/snapshots/` schreiben, In-Memory Job-Queue.
|
||||||
- Bilder nicht nur als HTTP-Response, sondern auch nach `/snapshots/` schreiben
|
|
||||||
- Job-Queue (In-Memory reicht für Single-Operator-Betrieb): Map von `jobId → status`
|
|
||||||
- Dateiname: `${cam.id}_hires_${timestamp}.jpg`
|
|
||||||
|
|
||||||
**Aufwand:** ~3–4 h.
|
**Aufwand:** ~3–4 h.
|
||||||
|
|
||||||
**Wann sinnvoll:** wenn der aufrufende Container die Bilder weiterverarbeiten soll
|
### Option C — Synchroner Einzel-Endpunkt
|
||||||
(OCR, ArUco-Erkennung, ML) ohne Browser-Interaktion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Option C — Synchroner All-in-One-Endpunkt (einfachste WebService-Form)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/snapshot/all/hires
|
GET /api/snapshot/all/hires
|
||||||
Response: multipart/form-data mit je einem JPEG pro Kamera
|
Response: JSON { "cam0": "<base64>", "cam2": "<base64>" }
|
||||||
ODER: JSON { "cam_front": "<base64>", "cam_top": "<base64>" }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Nachteil: Antwort blockiert ~8–10 s (Grab-Dauer). Nur sinnvoll wenn der Aufrufer
|
Blockiert ~8–10 s. Nur sinnvoll wenn der Aufrufer synchron warten kann.
|
||||||
synchron warten kann und keine grossen Bilder überträgt.
|
|
||||||
|
|
||||||
**Aufwand:** ~2 h.
|
**Aufwand:** ~2 h.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Offene Punkte / Risiken
|
## Offene Punkte
|
||||||
|
|
||||||
| Punkt | Risiko | Umgang |
|
| Punkt | Priorität | Massnahme |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Gerätenamen `/dev/videoN` wechseln nach Reboot | mittel | persistente by-id-Pfade in `cameras.json` |
|
| Phase 4B/C WebService-Push | niedrig | erst wenn aufrufender Container konkret |
|
||||||
| USB-Bandbreite bei >4 Kameras gleichzeitig | mittel | separate USB-Controller; `lsusb -t` prüfen |
|
| USB-Bandbreite bei >4 aktiven Streams | mittel | `lsusb -t` prüfen, Kameras auf Controller verteilen |
|
||||||
| ffmpeg im Node-Container (Phase 2) | niedrig | einmalige Dockerfile-Änderung; bewährt in `04_*` |
|
| Stream-Freeze (selten) | niedrig | bekannt; noch kein reproduzierbarer Fall |
|
||||||
| CPU bei vielen Kameras | niedriger als gedacht | On-Demand: nur **gleichzeitig beobachtete** Streams kosten CPU (~35 %/Kam). Idle = 0 %. Grenze ist die Zahl der parallel offenen Live-Views + USB-Bandbreite, nicht die Gesamtzahl der Kameras |
|
|
||||||
| Warmup-Schwarzbild bei Hi-Res | bekannt, gelöst | `CameraSwitch.grabHires` verwirft die ersten Frames (`settleFrames`/`minSize`) |
|
|
||||||
| Parallele Grabs auf gleichem Gerät | beherrschbar | Mutex pro Device (nicht pro ID) |
|
|
||||||
| Job-Queue Phase 4B bei mehreren Clients | gering | für Single-Operator akzeptiert; sonst persistente Queue |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Empfohlene Reihenfolge
|
|
||||||
|
|
||||||
```
|
|
||||||
Phase 1 (cameras.json + Switch-Erzeugung) ~2 h Grundlage, kein Risiko
|
|
||||||
↓
|
|
||||||
Phase 2 (Snapshot-only, ffmpeg) ~0 h ⚠ erledigt durch Schalter; nur noch:
|
|
||||||
Viewer rendert bei stream:false keine Live-Box
|
|
||||||
↓
|
|
||||||
Phase 3 (Snapshot alle erweitert) ~1 h Logik existiert (snapshotAllHires), nur Liste erweitern
|
|
||||||
↓
|
|
||||||
Phase 4A (GET /api/cameras) ~1 h sofort nützlich für andere Container
|
|
||||||
↓
|
|
||||||
Phase 4B oder 4C ~3–4 h nur wenn Push-Trigger gebraucht wird
|
|
||||||
```
|
|
||||||
|
|
||||||
Phase 4B/C **erst wenn** ein konkreter aufrufender Container existiert —
|
|
||||||
nicht auf Vorrat bauen.
|
|
||||||
|
|||||||
@@ -47,12 +47,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${APP_PATH:-.}:/usr/src/app
|
- ${APP_PATH:-.}:/usr/src/app
|
||||||
devices:
|
devices:
|
||||||
# Jede Kamera aus cameras.json muss hier aufgeführt sein.
|
# by-id (Host) → /dev/videoN (Container) – stabil über Reboots und USB-Re-Plugs.
|
||||||
# Empfehlung: statt /dev/videoN → persistente by-id-Pfade verwenden
|
# Rechte Seite = Pfad den cameras.json + FFmpeg im Container sehen.
|
||||||
# (ls -la /dev/v4l/by-id/ auf dem Server zeigt die Namen)
|
- /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0 # cam0 – C270 (046d:0825)
|
||||||
- /dev/video0:/dev/video0 # C270 (046d:0825) → cam0 in cameras.json
|
- /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2 # cam1 – C270 (046d:081b)
|
||||||
- /dev/video2:/dev/video2 # C270 (046d:081b) → cam1 in cameras.json
|
- /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4 # cam2 – C920
|
||||||
- /dev/video4:/dev/video4 # C920 HD Pro → cam2 in cameras.json
|
|
||||||
group_add:
|
group_add:
|
||||||
- video
|
- video
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Reference in New Issue
Block a user