claude: webcam
This commit is contained in:
@@ -2,106 +2,89 @@
|
|||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
Sauberer, fokussierter Webcam-Streaming-Service als Docker-Container.
|
Sauberer, fokussierter Webcam-Service als Docker-Container. Kein Robot-Control, kein
|
||||||
Kein Robot-Control, kein ArUco – nur zwei Verantwortlichkeiten:
|
ArUco – nur zwei Verantwortlichkeiten:
|
||||||
|
|
||||||
1. **Live-Video** mit minimaler Latenz (MJPEG über WebSocket)
|
1. **Live-Video** mit minimaler Latenz (WebRTC via go2rtc)
|
||||||
2. **Standbilder** auf Abruf via HTTP REST (`/api/snapshot/cam{n}`)
|
2. **Standbilder** auf Abruf via HTTP REST (`/api/snapshot/cam{n}`)
|
||||||
|
|
||||||
Das Homing-Projekt holt seine Standbilder über den Snapshot-Endpunkt – keine weitere Kopplung nötig.
|
Das Homing-Projekt holt seine Standbilder über den Snapshot-Endpunkt – keine weitere Kopplung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architektur
|
## Architektur (final)
|
||||||
|
|
||||||
```
|
```
|
||||||
USB Kameras
|
USB Kameras
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
FFmpeg (v4l2 → MJPEG)
|
go2rtc (Capture · H.264-Encode · WebRTC) ── intern, Port 1984 + UDP 8555
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Node.js Server (Express + ws)
|
Node.js / Express ── öffentlich, Port 8444
|
||||||
├── WebSocket /ws/cam0, /ws/cam1 → Browser (Live-Stream)
|
├── / eigener Viewer (go2rtc <video-stream>-Component)
|
||||||
└── HTTP GET /api/snapshot/cam0 → Homing-Projekt (JPEG)
|
├── /api/ws WebRTC-Signaling → proxied zu go2rtc
|
||||||
|
├── /api/snapshot/* Standbilder → proxied zu go2rtc /api/frame.jpeg
|
||||||
|
└── /health
|
||||||
```
|
```
|
||||||
|
|
||||||
**Stack-Entscheide (bereits umgesetzt):**
|
**Warum go2rtc statt eigenem FFmpeg-Stream:**
|
||||||
|
Erste Version (eigener Node-FFmpeg-MJPEG-Stream über WebSocket) hatte spürbare Latenz.
|
||||||
|
go2rtc ist ein spezialisierter Streaming-Server: WebRTC mit ~50–150 ms, automatisches
|
||||||
|
Encoding, ICE-Negotiation, robuster Client mit Auto-Fallback (WebRTC→MSE→MJPEG).
|
||||||
|
|
||||||
|
**Warum Node davor (statt go2rtc direkt):**
|
||||||
|
- Ein einziger öffentlicher Port (8444); go2rtc-Admin bleibt unerreichbar
|
||||||
|
- Stabile Snapshot-Schnittstelle, entkoppelt von go2rtc-Interna
|
||||||
|
- Eigener, schlanker Viewer
|
||||||
|
|
||||||
|
**Stack-Entscheide:**
|
||||||
|
|
||||||
| Komponente | Wahl | Begründung |
|
| Komponente | Wahl | Begründung |
|
||||||
|------------|------|------------|
|
|------------|------|------------|
|
||||||
| Webserver | Node.js + Express | Wartbar, grosses Ecosystem, user-präferiert |
|
| Streaming | go2rtc | WebRTC out-of-the-box, niedrige Latenz, internet-tauglich |
|
||||||
| WebSocket | `ws`-Library | Schlank, bewährt, kein Overhead |
|
| Webserver | Node.js + Express | wartbar, user-präferiert |
|
||||||
| Video-Capture | FFmpeg | Stabil, flexibel, MJPEG-passthrough möglich |
|
| Proxy | http-proxy-middleware | reicht HTTP + WebSocket transparent durch |
|
||||||
| Stream-Protokoll | MJPEG über WebSocket | Geringste Latenz, einfach im Browser |
|
| Live-Protokoll | WebRTC (Fallback MSE/MJPEG) | niedrigste Latenz, skaliert über Internet |
|
||||||
| Snapshot-API | HTTP GET → raw JPEG | Einfachste Schnittstelle für Consumer |
|
| Snapshot-API | HTTP GET → JPEG | einfachste Schnittstelle für Consumer |
|
||||||
| Container | `dockerfile_inline` in docker-compose | Kein separates Dockerfile, Portainer-tauglich |
|
| Container | docker-compose, `configs` inline | kein Dockerfile-File, Portainer-tauglich |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
### Phase 1 – Grundgerüst ✅ (erledigt)
|
### Phase 1 – Grundgerüst ✅
|
||||||
|
- [x] Projektstruktur, package.json, docker-compose mit inline-Config
|
||||||
|
- [x] Erste Version (Node-FFmpeg-MJPEG über WebSocket) – verworfen wegen Latenz
|
||||||
|
|
||||||
- [x] Projektstruktur anlegen
|
### Phase 2 – Umstieg auf go2rtc / WebRTC ✅
|
||||||
- [x] `package.json` mit Abhängigkeiten (express, ws)
|
- [x] go2rtc als Streaming-Backend (Kamera-Capture + WebRTC)
|
||||||
- [x] `docker-compose.yaml` mit `dockerfile_inline` (Node.js + FFmpeg, kein separates Dockerfile)
|
- [x] go2rtc-Config in docker-compose eingebettet (`configs.content`)
|
||||||
- [x] `server.js` – HTTP + WebSocket-Server, Graceful Shutdown
|
- [x] Node als Reverse-Proxy (`/api/ws`, `/api/frame.jpeg`, Player-Scripts)
|
||||||
- [x] `src/deviceDetect.js` – Kamera-Erkennung (env → by-id → /dev/video*)
|
- [x] Eigener Viewer mit go2rtc `<video-stream>`-Component (Auto-Fallback)
|
||||||
- [x] `src/videoStream.js` – FFmpegStreamer (MJPEG splitten, WebSocket broadcast, Auto-Restart mit Backoff)
|
- [x] Stabile Snapshot-API `/api/snapshot/cam{n}`
|
||||||
- [x] `src/snapshotService.js` – REST-Endpunkt: aktuellstes Frame aus laufendem Stream
|
- [x] Auflösung fest 640×480 → Latenz „akzeptabel" (war vorher das Hauptproblem)
|
||||||
- [x] `public/index.html` + `public/viewer.js` – Basis-Viewer
|
|
||||||
|
|
||||||
### Phase 2 – Deployment & Latenz-Baseline
|
### Phase 3 – Latenz final tunen (offen)
|
||||||
|
- [ ] Messvergleich WebRTC ⟷ MJPEG durchführen → siehe `03_Protocoll_roadmap.md`
|
||||||
|
- [ ] Falls nötig: Auflösung 320×240 testen (kleiner = weniger Browser-Last)
|
||||||
|
- [ ] Falls nötig: Keyframe-Intervall senken (`-g 15`), zerolatency-Tuning
|
||||||
|
- [ ] Prüfen ob Kamera natives H.264 liefert (`v4l2-ctl --list-formats`) → kein Re-Encode
|
||||||
|
|
||||||
- [ ] **Kamera-Zugriff im Container** verifizieren:
|
### Phase 4 – Internet-Härtung (offen, vor Produktiv-Schaltung)
|
||||||
- `/dev/video0` und `/dev/video2` im Container sichtbar
|
- [ ] **TLS**: Reverse Proxy (Caddy/nginx/traefik) mit HTTPS vor Port 8444
|
||||||
- `group_add: video` greift (Zugriffsrechte)
|
(WebRTC im Browser läuft über Internet zuverlässig nur im secure context)
|
||||||
- Fallback auf YUYV422 wenn MJPEG nicht unterstützt
|
- [ ] **WebRTC-Candidate**: `stun:8555` testen; falls NAT-Probleme → feste public IP/Domain
|
||||||
- [ ] **Latenz messen** (Baseline):
|
in der go2rtc-Config eintragen (`candidates: [robot.example.com:8555]`)
|
||||||
- Uhr auf Kamera richten, Screenshot → Differenz ablesen
|
- [ ] **TURN**: nur falls reines STUN + Port-Forward UDP 8555 nicht reicht → coturn
|
||||||
- Zielwert: <100 ms Ende-zu-Ende (Kamera → Browser-Pixel)
|
- [ ] **Zugriffsschutz**: Basic-Auth oder Token am Reverse Proxy (1–3 bekannte User)
|
||||||
- [ ] **Multi-Format-Fallback** implementieren: MJPEG → YUYV422 → RGB24
|
- [ ] **Firewall**: TCP 8444 + UDP 8555 forwarden; Port 1984 NICHT exponieren
|
||||||
- [ ] **Health-Endpunkt** `/health` erweitern: Kamera-Status, verbundene Clients, FPS
|
|
||||||
|
|
||||||
### Phase 3 – Latenz optimieren
|
### Phase 5 – Robustheit (optional)
|
||||||
|
- [ ] Kamera hot-plug: go2rtc-Verhalten bei Device-Verlust prüfen
|
||||||
- [ ] **FFmpeg-Flags tunen** für minimale Latenz:
|
- [ ] Resource Limits dokumentieren (`mem_limit`, `cpus`)
|
||||||
```
|
- [ ] JSON-Logging
|
||||||
-fflags nobuffer -flags low_delay -probesize 32 -analyzeduration 0
|
- [ ] Snapshot-Metadaten / optionaler Webhook nach Snapshot
|
||||||
```
|
|
||||||
- [ ] **Native MJPEG pass-through** testen:
|
|
||||||
Wenn Kamera MJPEG nativ bei Zielauflösung liefert → `-vcodec copy`
|
|
||||||
(kein Re-Encoding, minimale CPU-Last, minimale Latenz)
|
|
||||||
- [ ] **Canvas-Rendering** im Browser: `createImageBitmap()` statt Blob-URL-Overhead
|
|
||||||
- [ ] **WebRTC evaluieren**: <50 ms möglich, aber STUN/TURN-Komplexität in Docker –
|
|
||||||
sinnvoll erst wenn MJPEG-Latenz >150 ms bleibt
|
|
||||||
|
|
||||||
### Phase 4 – Hochauflösende Snapshots
|
|
||||||
|
|
||||||
**Aktuell**: Snapshot = letztes Frame aus dem Stream (Auflösung = Stream-Auflösung).
|
|
||||||
**Ziel**: Snapshot in originaler Kamera-Auflösung (z.B. 1280×960).
|
|
||||||
|
|
||||||
Drei Optionen (noch offen):
|
|
||||||
|
|
||||||
| Option | Vorgehen | Pro | Contra |
|
|
||||||
|--------|----------|-----|--------|
|
|
||||||
| A | Stream-Frame direkt nehmen | Sofort, kein Aufwand | Auflösung = Stream-Auflösung |
|
|
||||||
| B | Zweite FFmpeg-Pipeline 0.5 FPS High-Res | Immer verfügbar | CPU-Last, pipe:3 Komplexität |
|
|
||||||
| C | Einmaliger `ffmpeg -frames:v 1` on-demand | Hohe Qualität | ~500 ms Delay, Stream-Unterbrechung |
|
|
||||||
|
|
||||||
- [ ] Option wählen und implementieren
|
|
||||||
- [ ] Mit Homing-Projekt testen (Consumer von `/api/snapshot`)
|
|
||||||
- [ ] Snapshot-Metadaten in Response-Headern: Zeitstempel, Auflösung, Kamera-ID
|
|
||||||
- [ ] Optionaler Webhook: POST nach Snapshot an konfigurierbaren Endpunkt
|
|
||||||
|
|
||||||
### Phase 5 – Robustheit & Produktion
|
|
||||||
|
|
||||||
- [ ] Kamera hot-plug: Stream-Neustart wenn `/dev/videoX` verschwindet/wiederkommt
|
|
||||||
- [ ] Resource Limits: `--memory 512m --cpus 1.0` in docker-compose
|
|
||||||
- [ ] HTTPS: Reverse Proxy (nginx/traefik) vorschalten – kein TLS im App-Code
|
|
||||||
- [ ] JSON-Logging mit Level (info/warn/error)
|
|
||||||
- [ ] Kamera-Parameter per env var: `CAM0_WIDTH`, `CAM0_HEIGHT`, `CAM0_FPS`, `CAM0_QUALITY`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -109,26 +92,21 @@ Drei Optionen (noch offen):
|
|||||||
|
|
||||||
| Feature | appRobotVideoControls | appRobotWebcam |
|
| Feature | appRobotVideoControls | appRobotWebcam |
|
||||||
|---------|-----------------------|----------------|
|
|---------|-----------------------|----------------|
|
||||||
| Video-Streaming | ✅ | ✅ (verbessert) |
|
| Video-Streaming | eigener FFmpeg-MJPEG/WS | go2rtc / WebRTC |
|
||||||
| Snapshots | ✅ (komplex, dual-pipe) | ✅ (HTTP REST, einfach) |
|
| Snapshots | komplex (dual-pipe) | HTTP REST, einfach |
|
||||||
| Robot-Control (G-Code) | ✅ | ❌ anderes Projekt |
|
| Robot-Control (G-Code) | ✅ | ❌ anderes Projekt |
|
||||||
| ArUco / Homing | ✅ (Python+OpenCV) | ❌ anderes Projekt |
|
| ArUco / Homing | ✅ | ❌ anderes Projekt |
|
||||||
| Gamepad / Keyboard | ✅ | ❌ |
|
| Separates Dockerfile | ✅ (OpenCV-Build) | ❌ (inline in compose) |
|
||||||
| HTTPS (self-signed) | ✅ | ❌ (Reverse Proxy empfohlen) |
|
|
||||||
| Separates Dockerfile | ✅ (gross, OpenCV-Build) | ❌ (inline in compose) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ports & Netzwerk
|
## Ports
|
||||||
|
|
||||||
| Service | Container-Port | Host-Port |
|
| Dienst | Port | Exponiert? |
|
||||||
|---------|---------------|-----------|
|
|--------|------|-----------|
|
||||||
| HTTP + WS | 8080 | 8444 |
|
| Node Viewer + API + Signaling | TCP 8444 | ja (Firewall) |
|
||||||
|
| WebRTC Media | UDP 8555 | ja (Firewall) |
|
||||||
```bash
|
| go2rtc HTTP/Debug-UI | TCP 1984 | nein (nur intern/LAN) |
|
||||||
# Netzwerk einmalig erstellen (falls noch nicht vorhanden)
|
|
||||||
docker network create appRobotNet
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -137,16 +115,16 @@ docker network create appRobotNet
|
|||||||
```
|
```
|
||||||
appRobotWebcam/
|
appRobotWebcam/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── deviceDetect.js Kamera-Erkennung (env → by-id → /dev/video*)
|
│ └── snapshotService.js Snapshot-Router (proxied go2rtc /api/frame.jpeg)
|
||||||
│ ├── videoStream.js FFmpeg-MJPEG-Streamer + WebSocket-Broadcast
|
|
||||||
│ └── snapshotService.js REST-Router für /api/snapshot
|
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── index.html Basis-Viewer
|
│ ├── index.html Viewer (lädt go2rtc <video-stream>)
|
||||||
│ └── viewer.js WebSocket-Client, MJPEG-Rendering
|
│ └── viewer.js baut Kamera-Views, Auto-Fallback WebRTC→MSE→MJPEG
|
||||||
├── doc/
|
├── doc/
|
||||||
│ ├── 01_WebcamRoadmap.md (diese Datei)
|
│ ├── 01_WebcamRoadmap.md (diese Datei)
|
||||||
│ └── 05_OptionalToDo_roadmap.md Control-Optionen
|
│ ├── 03_Protocoll_roadmap.md WebRTC⟷MJPEG-Vergleich (nachzuholen)
|
||||||
├── docker-compose.yaml Einzige Docker-Datei (dockerfile_inline)
|
│ └── 05_OptionalToDo_roadmap.md Control-Integration (Optionen)
|
||||||
|
├── docker-compose.yaml einzige Deploy-Datei (go2rtc-Config eingebettet)
|
||||||
|
├── go2rtc.yaml nur Referenz/lokal (Config ist in compose eingebettet)
|
||||||
├── package.json
|
├── package.json
|
||||||
└── server.js Einstiegspunkt
|
└── server.js Node-Einstiegspunkt
|
||||||
```
|
```
|
||||||
|
|||||||
100
doc/03_Protocoll_roadmap.md
Normal file
100
doc/03_Protocoll_roadmap.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# AppRobotWebcam – Protokoll-Vergleich WebRTC ⟷ MJPEG
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- **Auflösung fest auf 640×480** → Latenz ist jetzt **„immer akzeptabel"** (vorher schwankend/träge).
|
||||||
|
Das bestätigt: ein Teil der gefühlten Latenz kam von zu großen Frames im Browser
|
||||||
|
(Decode + Render). Kleineres Bild = schnellerer Browser.
|
||||||
|
- **Entscheid vorläufig: WebRTC** – skaliert besser über echtes Internet (NAT, mehrere User).
|
||||||
|
- go2rtc bleibt die Basis → **http://thinkcentre.local:1984** bleibt jederzeit als
|
||||||
|
Vergleichs- und Debug-Oberfläche verfügbar.
|
||||||
|
|
||||||
|
> Der direkte Messvergleich WebRTC ⟷ MJPEG steht noch aus (Zeitgründen).
|
||||||
|
> Diese Datei hält fest, **wie** man ihn nachholt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warum überhaupt vergleichen?
|
||||||
|
|
||||||
|
| Protokoll | Latenz (LAN, 480p) | Mechanik | Bandbreite |
|
||||||
|
|-----------|--------------------|----------|------------|
|
||||||
|
| **MJPEG** | am niedrigsten | Jedes Frame ein eigenständiges JPEG, kein Buffer, `<img>` rendert sofort | hoch (jedes Frame voll) |
|
||||||
|
| **WebRTC** | niedrig | H.264-Encode + Jitter-Buffer im Browser; dafür effiziente Kompression | niedrig |
|
||||||
|
| **MSE** | mittel–hoch | Für VOD optimiert, größerer Puffer | niedrig |
|
||||||
|
|
||||||
|
- **MJPEG** = theoretische Latenz-Untergrenze, aber bei mehr Usern / über Internet
|
||||||
|
bandbreitenhungrig und ohne NAT-Traversal.
|
||||||
|
- **WebRTC** = minimal mehr Latenz durch Encode/Buffer, dafür internet-tauglich
|
||||||
|
(NAT-Traversal via STUN/TURN, geringe Bandbreite, mehrere User).
|
||||||
|
|
||||||
|
Für **1–3 User im LAN** kann MJPEG gewinnen. Über **Internet** gewinnt WebRTC fast immer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## So führst du den Vergleich durch
|
||||||
|
|
||||||
|
go2rtc stellt jeden Stream über **mehrere** Protokolle bereit. Jeweils einzeln und
|
||||||
|
direkt im Browser öffnen (für cam1: `cam0` → `cam1` ersetzen):
|
||||||
|
|
||||||
|
| Modus | URL | Was es testet |
|
||||||
|
|-------|-----|---------------|
|
||||||
|
| **MJPEG roh** | `http://thinkcentre.local:1984/api/stream.mjpeg?src=cam0` | Untergrenze: reines Bild, kein Player, kein Buffer |
|
||||||
|
| **WebRTC pur** | `http://thinkcentre.local:1984/webrtc.html?src=cam0` | WebRTC isoliert |
|
||||||
|
| **MSE pur** | `http://thinkcentre.local:1984/mse.html?src=cam0` | MSE isoliert (Referenz) |
|
||||||
|
| **Alle Links** | `http://thinkcentre.local:1984/links.html?src=cam0` | go2rtc listet selbst alle Endpunkte auf |
|
||||||
|
|
||||||
|
> Hinweis zur go2rtc-Startseite: Die Checkboxen (WebRTC/MSE/MJPEG) oben filtern nur,
|
||||||
|
> welche Technologien der **kombinierte** Player `stream.html` ausprobieren darf –
|
||||||
|
> sichtbar ändert sich dabei nichts, weil er automatisch die erste passende nimmt.
|
||||||
|
> Für einen echten Vergleich **die obigen Einzel-URLs** verwenden.
|
||||||
|
|
||||||
|
### Latenz messen (objektiv, in ms)
|
||||||
|
|
||||||
|
1. Handy-Stoppuhr mit Millisekunden-Anzeige vor die Kamera halten.
|
||||||
|
2. MJPEG-URL und WebRTC-URL in zwei Tabs/Fenstern nebeneinander öffnen.
|
||||||
|
3. Einen Screenshot machen, der **die echte Stoppuhr** und **beide Stream-Bilder**
|
||||||
|
gleichzeitig zeigt (Handy + Monitor zusammen abfotografieren ist am einfachsten).
|
||||||
|
4. Differenz „echte Zeit ↔ Bild im Stream" ablesen = Gesamt-Latenz pro Protokoll.
|
||||||
|
|
||||||
|
### Ergebnis-Tabelle (später ausfüllen)
|
||||||
|
|
||||||
|
| Kamera | MJPEG roh | WebRTC | MSE | Sieger |
|
||||||
|
|--------|-----------|--------|-----|--------|
|
||||||
|
| cam0 | ? ms | ? ms | ? ms | ? |
|
||||||
|
| cam1 | ? ms | ? ms | ? ms | ? |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidungslogik nach dem Test
|
||||||
|
|
||||||
|
- **WebRTC ≈ MJPEG (Differenz < ~50 ms):**
|
||||||
|
→ Bei **WebRTC** bleiben. Vorteil Internet-Skalierung überwiegt die paar ms.
|
||||||
|
|
||||||
|
- **MJPEG deutlich schneller (Differenz > ~100 ms) UND nur LAN-Nutzung:**
|
||||||
|
→ Optional **MJPEG-Viewer** zusätzlich anbieten (simple `<img>`-Seite).
|
||||||
|
go2rtc liefert MJPEG ohnehin schon unter `/api/stream.mjpeg?src=camN`.
|
||||||
|
|
||||||
|
- **Auflösung weiter drücken:**
|
||||||
|
→ In `docker-compose.yaml` unter `configs:` `video_size=640x480` → `320x240`.
|
||||||
|
Test wiederholen. Kleiner = weniger Browser-Last = weniger Latenz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weitere Latenz-Stellschrauben (falls WebRTC noch zu träge)
|
||||||
|
|
||||||
|
1. **Keyframe-Intervall senken** – H.264 startet erst beim nächsten Keyframe.
|
||||||
|
In der go2rtc-Quelle den Encoder mit `-g 15` (Keyframe alle 0.5 s @30fps) zwingen.
|
||||||
|
2. **Kamera-natives H.264** – falls die Webcam H.264 direkt liefert (UVC H.264),
|
||||||
|
kann go2rtc ohne Re-Encode durchreichen → minimale CPU + Latenz.
|
||||||
|
Prüfen mit: `v4l2-ctl -d /dev/video0 --list-formats`
|
||||||
|
3. **`zerolatency`-Tuning** im Encoder (ultrafast + tune zerolatency).
|
||||||
|
4. **WebRTC statt über Proxy direkt** – Jitter-Buffer des Browsers ist Fixkosten,
|
||||||
|
lässt sich nur begrenzt beeinflussen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtig: go2rtc bleibt erhalten
|
||||||
|
|
||||||
|
Da der finale Aufbau weiter auf go2rtc setzt, bleibt **http://thinkcentre.local:1984**
|
||||||
|
dauerhaft als Debug-/Vergleichs-UI nutzbar. Der Protokoll-Vergleich kann also
|
||||||
|
**jederzeit später** nachgeholt werden, ohne etwas umzubauen.
|
||||||
@@ -1,40 +1,62 @@
|
|||||||
name: approbotwebcam
|
name: approbotwebcam
|
||||||
|
|
||||||
# ── Portainer Web-Editor: dieses YAML einfügen, dann Deploy ─────────────────
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# FINALER WebRTC-AUFBAU – go2rtc (Streaming) + Node.js (Viewer/Proxy/API)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
#
|
#
|
||||||
# Voraussetzungen:
|
# Portainer: Stack → Web editor → dieses YAML einfügen → Deploy.
|
||||||
# 1. Code auf dem Server (git clone / Synology Drive sync)
|
# Vorher in Portainer → "Environment variables":
|
||||||
# 2. go2rtc.yaml muss im selben Verzeichnis liegen wie dieses File
|
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam (Code muss dort liegen)
|
||||||
# 3. In Portainer → "Environment variables":
|
|
||||||
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam
|
|
||||||
#
|
#
|
||||||
# Firewall: genau zwei Ports freigeben:
|
# WICHTIG: Vor jedem Redeploy sicherstellen, dass server.js / public/ / src/
|
||||||
# TCP 8444 → HTTP (Viewer · Snapshot-API · WebRTC-Signaling)
|
# auf dem Server unter APP_PATH aktuell sind (Synology-Sync abwarten).
|
||||||
# UDP 8555 → WebRTC Media (go2rtc direkt, kann nicht proxiert werden)
|
|
||||||
#
|
#
|
||||||
# network_mode: host → beide Container teilen den Host-Netzwerk-Stack.
|
# Firewall (Internet): TCP 8444 (Viewer+API+Signaling) · UDP 8555 (WebRTC-Media)
|
||||||
# Das ist für WebRTC entscheidend: go2rtc bekommt die echte Host-IP als
|
# Port 1984 (go2rtc) NICHT nach aussen – läuft nur intern via localhost.
|
||||||
# ICE-Kandidat, nicht eine Docker-interne 172.x-Adresse.
|
#
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# Zugriff:
|
||||||
|
# Viewer: http://<host>:8444/
|
||||||
|
# Snapshot (Homing) http://<host>:8444/api/snapshot/cam0
|
||||||
|
# go2rtc-Debug-UI http://<host>:1984/ (nur intern/LAN)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
configs:
|
||||||
|
go2rtc_yaml:
|
||||||
|
# Komplette go2rtc-Config eingebettet – keine separate Datei nötig.
|
||||||
|
content: |
|
||||||
|
streams:
|
||||||
|
cam0: "ffmpeg:device?video=/dev/video0&video_size=640x480#video=h264#video=mjpeg"
|
||||||
|
cam1: "ffmpeg:device?video=/dev/video2&video_size=640x480#video=h264#video=mjpeg"
|
||||||
|
webrtc:
|
||||||
|
listen: ":8555"
|
||||||
|
candidates:
|
||||||
|
# stun:8555 → go2rtc erkennt die öffentliche IP automatisch (für Internet).
|
||||||
|
# Falls das nicht klappt: feste IP/Domain eintragen, z.B.
|
||||||
|
# - robot.example.com:8555
|
||||||
|
- stun:8555
|
||||||
|
api:
|
||||||
|
listen: ":1984"
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
# ── go2rtc: Kamera-Capture + H.264-Encoding + WebRTC ──────────────────────
|
# ── go2rtc: Kamera-Capture · H.264-Encoding · WebRTC ──────────────────────
|
||||||
go2rtc:
|
go2rtc:
|
||||||
image: ghcr.io/alexxit/go2rtc
|
image: ghcr.io/alexxit/go2rtc
|
||||||
container_name: AppRobotGo2RTC
|
container_name: AppRobotGo2RTC
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host # echte Host-IP als WebRTC-ICE-Kandidat
|
||||||
devices:
|
devices:
|
||||||
- /dev/video0:/dev/video0
|
- /dev/video0:/dev/video0
|
||||||
- /dev/video2:/dev/video2
|
- /dev/video2:/dev/video2
|
||||||
group_add:
|
group_add:
|
||||||
- video
|
- video
|
||||||
volumes:
|
configs:
|
||||||
# go2rtc.yaml liegt im selben Verzeichnis wie docker-compose.yaml
|
- source: go2rtc_yaml
|
||||||
- ${APP_PATH:-.}/go2rtc.yaml:/config/go2rtc.yaml:ro
|
target: /config/go2rtc.yaml
|
||||||
|
|
||||||
# ── webcam: Node.js (Viewer · Snapshot-Proxy · WebRTC-Signaling-Proxy) ───
|
# ── webcam: Node.js (Viewer · /api/ws-Proxy · Snapshot-API) ──────────────
|
||||||
webcam:
|
webcam:
|
||||||
build:
|
build:
|
||||||
context: /tmp # Leerer Build-Context – Code kommt per Bind-Mount
|
context: /tmp # Leerer Build-Context – Code kommt per Bind-Mount
|
||||||
@@ -45,11 +67,23 @@ services:
|
|||||||
image: approbotwebcam:latest
|
image: approbotwebcam:latest
|
||||||
container_name: AppRobotWebcam
|
container_name: AppRobotWebcam
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host # erreicht go2rtc via localhost:1984
|
||||||
command: sh -c "npm install && node server.js"
|
command: sh -c "npm install --omit=dev && node server.js"
|
||||||
volumes:
|
volumes:
|
||||||
- ${APP_PATH:-.}:/usr/src/app
|
- ${APP_PATH:-.}:/usr/src/app
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=8444
|
- PORT=8444
|
||||||
- GO2RTC_URL=http://localhost:1984
|
- GO2RTC_URL=http://localhost:1984
|
||||||
|
depends_on:
|
||||||
|
- go2rtc
|
||||||
|
|
||||||
|
# ── FALLBACK ──────────────────────────────────────────────────────────────────
|
||||||
|
# Meckert Portainer beim Deploy über "configs content" (sehr alte Compose-Version)?
|
||||||
|
# → den configs-Block oben löschen und stattdessen beim go2rtc-Service mounten:
|
||||||
|
# volumes:
|
||||||
|
# - ${APP_PATH:-.}/go2rtc.yaml:/config/go2rtc.yaml:ro
|
||||||
|
#
|
||||||
|
# Bleibt eine Kamera schwarz? → in der Config oben die Quelle ersetzen durch die
|
||||||
|
# simple, bestätigte Form (ohne Auflösung): "ffmpeg:/dev/video0#video=h264#video=mjpeg"
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
27
go2rtc.yaml
27
go2rtc.yaml
@@ -1,23 +1,22 @@
|
|||||||
|
# Hinweis: Diese Datei wird für das Portainer-Deployment NICHT mehr gebraucht –
|
||||||
|
# die Config ist jetzt direkt in docker-compose.yaml eingebettet (configs.content).
|
||||||
|
# Sie bleibt hier nur als Referenz / für lokales go2rtc ohne Compose.
|
||||||
|
|
||||||
streams:
|
streams:
|
||||||
# FFmpeg öffnet die v4l2-Kamera und encodiert zu H.264 für WebRTC
|
# device?-Form: go2rtc öffnet die v4l2-Kamera mit fixer Auflösung.
|
||||||
# Falls die Kamera kein MJPEG liefert: "#video=h264" durch "#video=mjpeg" oder "#video=vp8" ersetzen
|
# #video=h264 → für WebRTC (transcodiert)
|
||||||
cam0:
|
# #video=mjpeg → für MJPEG-Endpoint (Passthrough, niedrigste Latenz)
|
||||||
- "ffmpeg:/dev/video0#video=h264"
|
cam0: "ffmpeg:device?video=/dev/video0&video_size=640x480#video=h264#video=mjpeg"
|
||||||
cam1:
|
cam1: "ffmpeg:device?video=/dev/video2&video_size=640x480#video=h264#video=mjpeg"
|
||||||
- "ffmpeg:/dev/video2#video=h264"
|
|
||||||
|
# Simple Fallback-Form (ohne Auflösungs-Vorgabe), falls device? Probleme macht:
|
||||||
|
# cam0: "ffmpeg:/dev/video0#video=h264#video=mjpeg"
|
||||||
|
|
||||||
webrtc:
|
webrtc:
|
||||||
ice_servers:
|
listen: ":8555"
|
||||||
- urls:
|
|
||||||
- stun:stun.l.google.com:19302
|
|
||||||
- stun:stun1.l.google.com:19302
|
|
||||||
# Fixer UDP-Port → einfache Firewall-Regel: UDP 8555 weiterleiten
|
|
||||||
listen: ":8555/udp"
|
|
||||||
|
|
||||||
api:
|
api:
|
||||||
listen: ":1984"
|
listen: ":1984"
|
||||||
# Erlaubt Requests vom Node.js-Proxy (gleicher Host, anderer Port)
|
|
||||||
origin: "*"
|
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: info
|
level: info
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,17 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "approbotwebcam",
|
"name": "approbotwebcam",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Low-latency webcam streaming service for robot vision",
|
"description": "Low-latency WebRTC webcam service (go2rtc + Node proxy) for robot vision",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.1"
|
"express": "^4.21.1",
|
||||||
},
|
"http-proxy-middleware": "^3.0.3"
|
||||||
"devDependencies": {
|
|
||||||
"nodemon": "^3.1.7"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -6,51 +6,32 @@
|
|||||||
<title>AppRobotWebcam</title>
|
<title>AppRobotWebcam</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
body { background: #0f0f0f; color: #e0e0e0; font-family: monospace; }
|
body { background: #0f0f0f; color: #e0e0e0; font-family: monospace; }
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px; background: #1a1a1a; border-bottom: 1px solid #333;
|
||||||
background: #1a1a1a;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
}
|
}
|
||||||
h1 { font-size: 1rem; font-weight: normal; letter-spacing: 0.05em; }
|
h1 { font-size: 1rem; font-weight: normal; letter-spacing: 0.05em; }
|
||||||
#statusText { font-size: 0.8rem; color: #888; margin-left: auto; }
|
#statusText { font-size: 0.8rem; color: #888; margin-left: auto; }
|
||||||
|
|
||||||
#cameras {
|
#cameras { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; }
|
||||||
display: flex; flex-wrap: wrap; gap: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cam-box {
|
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; }
|
||||||
position: relative;
|
|
||||||
background: #000;
|
/* go2rtc Web-Component */
|
||||||
border: 1px solid #2a2a2a;
|
video-stream { display: block; width: 640px; height: 480px; background: #111; }
|
||||||
}
|
video-stream video { width: 100%; height: 100%; object-fit: contain; }
|
||||||
.cam-box video { display: block; }
|
|
||||||
|
|
||||||
.cam-label {
|
.cam-label {
|
||||||
position: absolute; top: 5px; left: 8px;
|
position: absolute; top: 5px; left: 8px;
|
||||||
background: rgba(0,0,0,.65);
|
background: rgba(0,0,0,.65); padding: 2px 7px; border-radius: 3px;
|
||||||
padding: 2px 7px; border-radius: 3px;
|
|
||||||
font-size: 0.72rem; color: #ccc;
|
font-size: 0.72rem; color: #ccc;
|
||||||
}
|
}
|
||||||
.cam-info {
|
.cam-actions { position: absolute; top: 5px; right: 8px; display: flex; gap: 4px; }
|
||||||
position: absolute; bottom: 5px; right: 8px;
|
|
||||||
background: rgba(0,0,0,.65);
|
|
||||||
padding: 2px 7px; border-radius: 3px;
|
|
||||||
font-size: 0.68rem; color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cam-actions {
|
|
||||||
position: absolute; top: 5px; right: 8px;
|
|
||||||
display: flex; gap: 4px;
|
|
||||||
}
|
|
||||||
.cam-actions button {
|
.cam-actions button {
|
||||||
background: rgba(0,0,0,.65); color: #ccc;
|
background: rgba(0,0,0,.65); color: #ccc; border: 1px solid #444;
|
||||||
border: 1px solid #444; padding: 2px 8px;
|
padding: 2px 8px; font-family: monospace; font-size: 0.7rem;
|
||||||
font-family: monospace; font-size: 0.7rem;
|
|
||||||
cursor: pointer; border-radius: 3px;
|
cursor: pointer; border-radius: 3px;
|
||||||
}
|
}
|
||||||
.cam-actions button:hover { background: rgba(60,60,60,.8); }
|
.cam-actions button:hover { background: rgba(60,60,60,.8); }
|
||||||
@@ -62,6 +43,9 @@
|
|||||||
<span id="statusText">Verbinde...</span>
|
<span id="statusText">Verbinde...</span>
|
||||||
</header>
|
</header>
|
||||||
<div id="cameras"></div>
|
<div id="cameras"></div>
|
||||||
<script src="viewer.js"></script>
|
|
||||||
|
<!-- go2rtc's offizieller Player (über Node-Proxy von go2rtc geladen) -->
|
||||||
|
<script type="module" src="/video-stream.js"></script>
|
||||||
|
<script src="viewer.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
167
public/viewer.js
167
public/viewer.js
@@ -1,126 +1,25 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const ICE_SERVERS = [
|
// go2rtc-Player-Modi in Fallback-Reihenfolge.
|
||||||
{ urls: 'stun:stun.l.google.com:19302' },
|
// webrtc zuerst (niedrigste Latenz), dann MSE, dann MJPEG – das verhindert
|
||||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
// schwarze Seiten: schlägt WebRTC fehl, springt der Player automatisch weiter.
|
||||||
];
|
const MODE = 'webrtc,mse,mjpeg';
|
||||||
|
|
||||||
function log(camId, msg) {
|
|
||||||
console.log(`[${camId}] ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitIceComplete(pc, timeoutMs = 5000) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
if (pc.iceGatheringState === 'complete') { resolve(); return; }
|
|
||||||
const check = () => { if (pc.iceGatheringState === 'complete') resolve(); };
|
|
||||||
pc.addEventListener('icegatheringstatechange', check);
|
|
||||||
setTimeout(() => {
|
|
||||||
pc.removeEventListener('icegatheringstatechange', check);
|
|
||||||
log('ice', `gathering timeout nach ${timeoutMs}ms – sende trotzdem`);
|
|
||||||
resolve();
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startWebRTC(camId, videoEl, statusEl) {
|
|
||||||
setStatus(statusEl, 'Verbinde...', '#888');
|
|
||||||
log(camId, `WebRTC start → /api/webrtc?src=${camId}`);
|
|
||||||
|
|
||||||
let pc;
|
|
||||||
try {
|
|
||||||
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
|
||||||
|
|
||||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
||||||
|
|
||||||
pc.onicecandidate = ({ candidate }) => {
|
|
||||||
if (candidate) log(camId, `ICE candidate: ${candidate.type} ${candidate.address ?? '?'}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
pc.onicegatheringstatechange = () =>
|
|
||||||
log(camId, `ICE gathering: ${pc.iceGatheringState}`);
|
|
||||||
|
|
||||||
pc.oniceconnectionstatechange = () => {
|
|
||||||
const s = pc.iceConnectionState;
|
|
||||||
log(camId, `ICE connection: ${s}`);
|
|
||||||
const colors = { connected: '#4c4', completed: '#4c4', checking: '#fa0', failed: '#c44', disconnected: '#c44' };
|
|
||||||
setStatus(statusEl, s, colors[s] ?? '#888');
|
|
||||||
if (s === 'failed' || s === 'closed') {
|
|
||||||
pc.close();
|
|
||||||
setTimeout(() => startWebRTC(camId, videoEl, statusEl), 4000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pc.onconnectionstatechange = () =>
|
|
||||||
log(camId, `connection: ${pc.connectionState}`);
|
|
||||||
|
|
||||||
pc.ontrack = ({ streams }) => {
|
|
||||||
log(camId, `Track erhalten: ${streams.length} stream(s)`);
|
|
||||||
if (streams[0]) {
|
|
||||||
videoEl.srcObject = streams[0];
|
|
||||||
videoEl.play().catch(e => log(camId, `play() Fehler: ${e.message}`));
|
|
||||||
setStatus(statusEl, 'Live ✓', '#4c4');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
log(camId, 'Erstelle SDP Offer...');
|
|
||||||
const offer = await pc.createOffer();
|
|
||||||
await pc.setLocalDescription(offer);
|
|
||||||
|
|
||||||
log(camId, `ICE gathering wartet (max 5s)...`);
|
|
||||||
await waitIceComplete(pc);
|
|
||||||
|
|
||||||
log(camId, `Sende Offer (${pc.localDescription.sdp.length} Bytes) an Server...`);
|
|
||||||
const resp = await fetch(`/api/webrtc?src=${encodeURIComponent(camId)}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/sdp' },
|
|
||||||
body: pc.localDescription.sdp,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = await resp.text();
|
|
||||||
throw new Error(`Signaling HTTP ${resp.status}: ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sdpAnswer = await resp.text();
|
|
||||||
log(camId, `Answer erhalten (${sdpAnswer.length} Bytes)`);
|
|
||||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
|
||||||
log(camId, 'Remote description gesetzt – warte auf ICE...');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[${camId}] Fehler:`, err);
|
|
||||||
setStatus(statusEl, `${err.message}`, '#c44');
|
|
||||||
pc?.close();
|
|
||||||
setTimeout(() => startWebRTC(camId, videoEl, statusEl), 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(el, text, color) {
|
|
||||||
el.textContent = text;
|
|
||||||
el.style.color = color ?? '#999';
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCameraView(camId, container) {
|
|
||||||
log(camId, 'View erstellt');
|
|
||||||
|
|
||||||
|
function buildCamera(camId, container) {
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'cam-box';
|
box.className = 'cam-box';
|
||||||
|
|
||||||
const video = document.createElement('video');
|
// go2rtc Web-Component – verbindet sich relativ auf /api/ws (→ Node-Proxy)
|
||||||
video.autoplay = true;
|
const stream = document.createElement('video-stream');
|
||||||
video.playsInline = true;
|
stream.mode = MODE;
|
||||||
video.muted = true;
|
stream.src = `/api/ws?src=${encodeURIComponent(camId)}`;
|
||||||
video.style.cssText = 'display:block;width:640px;height:480px;background:#111';
|
box.appendChild(stream);
|
||||||
box.appendChild(video);
|
|
||||||
|
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
label.className = 'cam-label';
|
label.className = 'cam-label';
|
||||||
label.textContent = camId;
|
label.textContent = camId;
|
||||||
box.appendChild(label);
|
box.appendChild(label);
|
||||||
|
|
||||||
const status = document.createElement('div');
|
|
||||||
status.className = 'cam-info';
|
|
||||||
box.appendChild(status);
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'cam-actions';
|
actions.className = 'cam-actions';
|
||||||
const snapBtn = document.createElement('button');
|
const snapBtn = document.createElement('button');
|
||||||
@@ -135,32 +34,28 @@ function createCameraView(camId, container) {
|
|||||||
box.appendChild(actions);
|
box.appendChild(actions);
|
||||||
|
|
||||||
container.appendChild(box);
|
container.appendChild(box);
|
||||||
startWebRTC(camId, video, status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kamera-Liste via /api/snapshot (proxied go2rtc /api/streams)
|
async function init() {
|
||||||
log('init', 'Frage Kamera-Liste ab...');
|
// Warten bis die go2rtc-Web-Component definiert ist (sonst greift der .src-Setter nicht)
|
||||||
fetch('/api/snapshot')
|
await customElements.whenDefined('video-stream');
|
||||||
.then(r => {
|
|
||||||
log('init', `/api/snapshot → HTTP ${r.status}`);
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
log('init', `Kameras: ${JSON.stringify(data.cameras)}`);
|
|
||||||
const container = document.getElementById('cameras');
|
const container = document.getElementById('cameras');
|
||||||
const cams = data.cameras ?? [];
|
const statusText = document.getElementById('statusText');
|
||||||
if (cams.length === 0) {
|
|
||||||
document.getElementById('statusText').textContent = 'Keine Kameras (go2rtc läuft?)';
|
let cams = ['cam0', 'cam1']; // Fallback
|
||||||
console.warn('go2rtc meldet keine Streams. Prüfe http://server:1984');
|
try {
|
||||||
return;
|
const r = await fetch('/api/snapshot');
|
||||||
|
const d = await r.json();
|
||||||
|
if (Array.isArray(d.cameras) && d.cameras.length) {
|
||||||
|
cams = d.cameras.map(c => c.id);
|
||||||
}
|
}
|
||||||
cams.forEach(c => createCameraView(c.id, container));
|
} catch (err) {
|
||||||
document.getElementById('statusText').textContent =
|
console.warn('Kamera-Liste nicht abrufbar, nutze Fallback:', err.message);
|
||||||
`${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
}
|
||||||
})
|
|
||||||
.catch(err => {
|
cams.forEach(id => buildCamera(id, container));
|
||||||
console.error('[init] /api/snapshot Fehler:', err);
|
statusText.textContent = `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
||||||
document.getElementById('statusText').textContent = 'API-Fehler – Fallback';
|
}
|
||||||
const container = document.getElementById('cameras');
|
|
||||||
['cam0', 'cam1'].forEach(id => createCameraView(id, container));
|
init();
|
||||||
});
|
|
||||||
|
|||||||
70
server.js
70
server.js
@@ -3,6 +3,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||||
const { createSnapshotRouter } = require('./src/snapshotService');
|
const { createSnapshotRouter } = require('./src/snapshotService');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
||||||
@@ -10,58 +11,49 @@ const GO2RTC_URL = process.env.GO2RTC_URL ?? 'http://localhost:1984';
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
// ── Stabile Snapshot-API (vor dem Proxy registrieren!) ────────────────────────
|
||||||
|
// Für das Homing-Projekt: GET /api/snapshot/cam0 → JPEG
|
||||||
app.use('/api/snapshot', createSnapshotRouter(GO2RTC_URL));
|
app.use('/api/snapshot', createSnapshotRouter(GO2RTC_URL));
|
||||||
|
|
||||||
// ── WebRTC signaling proxy ────────────────────────────────────────────────────
|
|
||||||
// Browser postet SDP-Offer hierher; wir leiten es an go2rtc weiter und
|
|
||||||
// geben die SDP-Answer zurück. Nur ein HTTP-Port nach aussen nötig.
|
|
||||||
app.post(
|
|
||||||
'/api/webrtc',
|
|
||||||
express.text({ type: 'application/sdp', limit: '64kb' }),
|
|
||||||
async (req, res) => {
|
|
||||||
const src = req.query.src ?? '';
|
|
||||||
try {
|
|
||||||
const upstream = await fetch(
|
|
||||||
`${GO2RTC_URL}/api/webrtc?src=${encodeURIComponent(src)}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/sdp' },
|
|
||||||
body: req.body,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!upstream.ok) {
|
|
||||||
const msg = await upstream.text();
|
|
||||||
return res.status(upstream.status).send(msg);
|
|
||||||
}
|
|
||||||
const answer = await upstream.text();
|
|
||||||
res.set('Content-Type', 'application/sdp');
|
|
||||||
res.send(answer);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Health ────────────────────────────────────────────────────────────────────
|
// ── Health ────────────────────────────────────────────────────────────────────
|
||||||
app.get('/health', async (_req, res) => {
|
app.get('/health', async (_req, res) => {
|
||||||
let go2rtcOk = false;
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${GO2RTC_URL}/api/streams`);
|
const r = await fetch(`${GO2RTC_URL}/api/streams`);
|
||||||
go2rtcOk = r.ok;
|
const streams = r.ok ? await r.json() : {};
|
||||||
} catch { /* not reachable */ }
|
res.json({ status: r.ok ? 'ok' : 'degraded', cameras: Object.keys(streams) });
|
||||||
|
} catch (err) {
|
||||||
res.json({ status: go2rtcOk ? 'ok' : 'degraded', go2rtc: go2rtcOk });
|
res.status(503).json({ status: 'down', error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
// ── Reverse-Proxy zu go2rtc ───────────────────────────────────────────────────
|
||||||
|
// Reicht nur die nötigen Pfade durch (go2rtc-Admin bleibt damit unerreichbar):
|
||||||
|
// /api/ws WebRTC/MSE-Signaling (WebSocket)
|
||||||
|
// /api/frame.jpeg Snapshots
|
||||||
|
// /api/stream.* MJPEG/MP4-Fallback
|
||||||
|
// /api/streams Stream-Liste
|
||||||
|
// /video-rtc.js, /video-stream.js go2rtc's offizieller Player
|
||||||
|
const go2rtcProxy = createProxyMiddleware({
|
||||||
|
target: GO2RTC_URL,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
pathFilter: ['/api/**', '/video-rtc.js', '/video-stream.js'],
|
||||||
|
logger: console,
|
||||||
|
});
|
||||||
|
app.use(go2rtcProxy);
|
||||||
|
|
||||||
|
// ── Eigener Viewer ─────────────────────────────────────────────────────────────
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ── Start ───────────────────────────────────────────────────────────────────────
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
server.on('upgrade', go2rtcProxy.upgrade); // WebSocket-Signaling durchreichen
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`AppRobotWebcam on http://0.0.0.0:${PORT}`);
|
console.log(`AppRobotWebcam on http://0.0.0.0:${PORT}`);
|
||||||
console.log(` go2rtc backend: ${GO2RTC_URL}`);
|
console.log(` go2rtc backend: ${GO2RTC_URL}`);
|
||||||
console.log(` WebRTC signaling proxy: POST /api/webrtc?src=cam0`);
|
console.log(` Viewer: http://0.0.0.0:${PORT}/`);
|
||||||
console.log(` Snapshot API: GET /api/snapshot/cam0`);
|
console.log(` Snapshot (API): http://0.0.0.0:${PORT}/api/snapshot/cam0`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = (sig) => {
|
const shutdown = (sig) => {
|
||||||
|
|||||||
@@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
// Proxiert go2rtc-Frame-API als /api/snapshot/:id
|
// Stabile Snapshot-Schnittstelle für das Homing-Projekt.
|
||||||
// GET /api/snapshot → JSON mit Kamera-Liste (von go2rtc /api/streams)
|
// Entkoppelt den Consumer von go2rtc-Interna – proxied intern auf /api/frame.jpeg.
|
||||||
// GET /api/snapshot/cam0 → aktuelles JPEG-Frame (von go2rtc /api/frame?src=cam0)
|
//
|
||||||
|
// GET /api/snapshot → JSON-Liste der Kameras (aus go2rtc /api/streams)
|
||||||
|
// GET /api/snapshot/cam0 → aktuelles JPEG (aus go2rtc /api/frame.jpeg?src=cam0)
|
||||||
function createSnapshotRouter(go2rtcUrl) {
|
function createSnapshotRouter(go2rtcUrl) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${go2rtcUrl}/api/streams`);
|
const r = await fetch(`${go2rtcUrl}/api/streams`);
|
||||||
if (!r.ok) throw new Error(`go2rtc ${r.status}`);
|
if (!r.ok) throw new Error(`go2rtc HTTP ${r.status}`);
|
||||||
const streams = await r.json();
|
const streams = await r.json();
|
||||||
res.json({
|
res.json({
|
||||||
cameras: Object.keys(streams).map(id => ({
|
cameras: Object.keys(streams).map(id => ({ id, url: `/api/snapshot/${id}` })),
|
||||||
id,
|
|
||||||
url: `/api/snapshot/${id}`,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` });
|
res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` });
|
||||||
@@ -27,9 +26,11 @@ function createSnapshotRouter(go2rtcUrl) {
|
|||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(`${go2rtcUrl}/api/frame?src=${encodeURIComponent(id)}`);
|
const upstream = await fetch(
|
||||||
|
`${go2rtcUrl}/api/frame.jpeg?src=${encodeURIComponent(id)}`
|
||||||
|
);
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
return res.status(upstream.status).json({ error: 'kein Frame verfügbar' });
|
return res.status(upstream.status).json({ error: `kein Frame (${id})` });
|
||||||
}
|
}
|
||||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||||
res.set({
|
res.set({
|
||||||
|
|||||||
Reference in New Issue
Block a user