Umbau mit cameraSwitch
This commit is contained in:
@@ -1,3 +1,10 @@
|
|||||||
|
> # ℹ️ Architektur abgelöst (2026-06-05): go2rtc → Node-MJPEG-Schalter
|
||||||
|
> Dieses Dokument beschreibt den **go2rtc**-Aufbau (historisch wertvoll: Messungen,
|
||||||
|
> Fehler-Log, eiserne Regeln gelten weiter sinngemäß). Der Live-Stream läuft seit
|
||||||
|
> 2026-06-05 **nicht mehr über go2rtc**, sondern über einen Node-eigenen MJPEG-Schalter
|
||||||
|
> (`src/cameraSwitch.js`). Grund: das 106%-Race beim HD-Snapshot. Maßgeblich:
|
||||||
|
> `05_screenshot_roadmap.md` (Abschnitt „Node-MJPEG-Schalter") und `09_Bug_reports.md`.
|
||||||
|
|
||||||
# AppRobotWebcam – Delay / Ruckler-Analyse
|
# AppRobotWebcam – Delay / Ruckler-Analyse
|
||||||
|
|
||||||
## Symptom
|
## Symptom
|
||||||
|
|||||||
@@ -1,4 +1,27 @@
|
|||||||
# AppRobotWebcam – Hi-Res-Snapshot via Consumer-Umhängen
|
> # ⛔ ABGELÖST (2026-06-05) — dieser Ansatz war die Ursache des 106%-Bugs
|
||||||
|
>
|
||||||
|
> Der unten beschriebene **Consumer-Umhängen-Ansatz mit go2rtc** (`cam0` loslassen →
|
||||||
|
> go2rtc gibt Gerät frei → `cam0_hires` greifen) hat sich als **prinzipiell racy**
|
||||||
|
> erwiesen: go2rtcs API kann nicht zuverlässig melden, wann FFmpeg `/dev/videoN`
|
||||||
|
> freigibt → zwei Encoder auf einem Gerät → **106% CPU + Freeze** (siehe `09_Bug_reports.md`).
|
||||||
|
>
|
||||||
|
> **Aktuelle, maßgebliche Architektur:** **Node-MJPEG-Schalter, go2rtc entfernt.**
|
||||||
|
> Node besitzt die Kameras selbst; das `close`-Event des eigenen FFmpeg ist der harte
|
||||||
|
> Beweis „Gerät frei". Das Race ist damit konstruktiv ausgeschlossen.
|
||||||
|
>
|
||||||
|
> | | alt (unten, abgelöst) | **neu (maßgeblich)** |
|
||||||
|
> |-|----------------------|----------------------|
|
||||||
|
> | Geräte-Öffner | go2rtc | **Node** `src/cameraSwitch.js` |
|
||||||
|
> | Live | go2rtc-WS + `video-stream.js` | MJPEG multipart → `<img>` |
|
||||||
|
> | HD-Grab | 2. go2rtc-Stream `cam_hires` (Race) | Schalter: Live stoppen (`close`=FD frei) → 1280 → zurück |
|
||||||
|
> | Multi-User | brach | gelöst (ein FFmpeg → Fan-out) |
|
||||||
|
>
|
||||||
|
> **→ Neue Architektur + Hardware-Testplan stehen weiter unten in diesem Dokument
|
||||||
|
> (Abschnitt „## Node-MJPEG-Schalter").** Alles ab hier bis dorthin ist **Historie**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# AppRobotWebcam – Hi-Res-Snapshot via Consumer-Umhängen ⛔ (historisch)
|
||||||
|
|
||||||
> Status: **Phase 2 implementiert und funktional** (2026-06-04):
|
> Status: **Phase 2 implementiert und funktional** (2026-06-04):
|
||||||
> HD-Grab liefert echten 1280×960-Frame (76071 bytes bestätigt). Bekanntes Problem
|
> HD-Grab liefert echten 1280×960-Frame (76071 bytes bestätigt). Bekanntes Problem
|
||||||
@@ -334,3 +357,87 @@ und zur Laufzeit wird go2rtc nur **gelesen**, nie verändert.
|
|||||||
**Reihenfolge:** Phase 1 (messen, ~null Risiko) → Pausen aus der Messung setzen →
|
**Reihenfolge:** Phase 1 (messen, ~null Risiko) → Pausen aus der Messung setzen →
|
||||||
Phase 2 (Grab). Fällt Phase 1, bleibt Weg A (separate Kamera) aus `04_*` der sichere
|
Phase 2 (Grab). Fällt Phase 1, bleibt Weg A (separate Kamera) aus `04_*` der sichere
|
||||||
Fallback.
|
Fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# ✅ Node-MJPEG-Schalter (2026-06-05) — maßgebliche Architektur
|
||||||
|
|
||||||
|
> Ersetzt den gesamten go2rtc-Ansatz oben. Bei Widerspruch gilt dieser Abschnitt.
|
||||||
|
|
||||||
|
## Kernidee: Node besitzt die Kamera selbst
|
||||||
|
|
||||||
|
Das 106%-Race entstand, weil **zwei** FFmpeg (Live 640 + HD 1280) gleichzeitig auf
|
||||||
|
**demselben** `/dev/videoN` liefen, und go2rtcs API nicht zuverlässig melden konnte, wann
|
||||||
|
ein FFmpeg das Gerät freigibt. **Lösung:** Node startet die FFmpeg-Prozesse selbst → das
|
||||||
|
`close`-Event des Kindprozesses ist der harte Beweis „Prozess weg ⇒ Kernel-FD geschlossen
|
||||||
|
⇒ Gerät frei". Race konstruktiv ausgeschlossen, nicht über Timing entschärft.
|
||||||
|
|
||||||
|
```
|
||||||
|
go2rtc ── ENTFERNT
|
||||||
|
Node (server.js)
|
||||||
|
├─ CameraSwitch cam0 ── besitzt /dev/video0 ── EIN FFmpeg (Live ODER HD)
|
||||||
|
├─ CameraSwitch cam1 ── besitzt /dev/video2 ── EIN FFmpeg (Live ODER HD)
|
||||||
|
├─ /api/stream/<id> ── MJPEG multipart/x-mixed-replace → Browser <img>
|
||||||
|
└─ /api/snapshot/<id> ── 640 aus RAM · /<id>/hires → HD-Grab über den Schalter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Der Schalter (`src/cameraSwitch.js`)
|
||||||
|
|
||||||
|
Eine `CameraSwitch`-Instanz pro Gerät — der **einzige** Öffner von `/dev/videoN`. Hält
|
||||||
|
immer nur **einen** FFmpeg. Zustände `stopped | live | grabbing`, Mutex pro Kamera.
|
||||||
|
|
||||||
|
- **Live (Dauerbetrieb):** `ffmpeg -f v4l2 -input_format mjpeg -video_size 640x480
|
||||||
|
-framerate 30 -i /dev/videoN -c:v copy -f mpjpeg pipe:1` → kein Re-Encode. Node parst
|
||||||
|
die mpjpeg-Frames (Content-Length-basiert), hält den letzten (für `/api/snapshot`),
|
||||||
|
sendet sie an alle Stream-Clients. Crash → Auto-Restart nach 1,5 s.
|
||||||
|
- **HD-Grab (`grabHires`):** Live-FFmpeg `SIGTERM` → **auf `close` warten** (FD frei) →
|
||||||
|
1280-FFmpeg, warmlaufen (ab Frame ≥6, ≥15 KB, Breite ≥1000), besten Frame greifen →
|
||||||
|
beenden, auf `close` warten → `finally`: **immer** Live zurück (Live hat Priorität).
|
||||||
|
- **Blackout:** `<img>` friert ~1–3 s ein, läuft dann weiter. **Kein Client-Handling
|
||||||
|
nötig** (das war früher die Fehlerquelle).
|
||||||
|
|
||||||
|
## Auslieferung / Multi-User
|
||||||
|
|
||||||
|
`/api/stream/<id>` = `multipart/x-mixed-replace`; ein FFmpeg → Fan-out an N Clients.
|
||||||
|
Backpressure: voller Socket-Puffer (>1 MB) eines langsamen Clients → Frames für ihn
|
||||||
|
droppen, andere bleiben flüssig. Clients halten **kein** Gerät → **Multi-User gelöst.**
|
||||||
|
|
||||||
|
## Konfiguration (`docker-compose.yaml`)
|
||||||
|
|
||||||
|
Ein Node-Container mit FFmpeg, Geräte durchgereicht, `group_add: video`. Env-Overrides:
|
||||||
|
`DEV0/DEV1`, `LIVE_SIZE/LIVE_FPS`, `HIRES_SIZE/HIRES_FPS`. Firewall: nur noch **TCP 8444**.
|
||||||
|
|
||||||
|
## Verifiziert vs. offen
|
||||||
|
|
||||||
|
- **Lokal verifiziert (ohne Kamera):** MJPEG-Parser (Unittest, Chunk-robust, `\r\n\r\n`
|
||||||
|
im Body), HTTP-Routing (snapshot/stream/health, 404/503), Crash-Auto-Restart rate-limitiert.
|
||||||
|
- **FFmpeg-Args = die der bisher funktionierenden go2rtc-Quelle** (`-f v4l2 -input_format
|
||||||
|
mjpeg -video_size … -framerate …`), nur Ausgabe `-c:v copy -f mpjpeg`.
|
||||||
|
- **Auf der Hardware noch zu verifizieren:** CPU-Last, Latenz, HD-Blackout-Dauer, und der
|
||||||
|
Bug-Reproweg unten.
|
||||||
|
|
||||||
|
## Hardware-Testplan
|
||||||
|
|
||||||
|
1. Code syncen, Stack neu deployen (Image baut FFmpeg ein — erster Build dauert länger).
|
||||||
|
2. Viewer öffnen → beide Kameras Live (`MJPEG · live`). **CPU messen** (Erwartung < 50 %).
|
||||||
|
3. **Bug-Reproweg:** Anmelden → „HD" → Download → Stream nach kurzem Freeze weiter →
|
||||||
|
**neu anmelden / Tab neu laden.** Erwartung: **keine 106%, kein Dauer-Freeze.**
|
||||||
|
4. Zwei Browser gleichzeitig → „HD" während beide verbunden → **kein 503** (Multi-User).
|
||||||
|
5. HD-Bild: 1280×960, nicht schwarz. Blackout-Dauer notieren.
|
||||||
|
6. Eine Kamera abziehen → Log rate-limitierter Restart, andere Kamera + Node unberührt.
|
||||||
|
|
||||||
|
`docker logs AppRobotWebcam` zeigt jeden Zustandswechsel des Schalters.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git checkout <commit-vor-umbau> -- docker-compose.yaml server.js package.json public/ src/`
|
||||||
|
(der go2rtc-Stand liegt vollständig in der Git-Historie).
|
||||||
|
|
||||||
|
## Mögliche Folgeschritte
|
||||||
|
|
||||||
|
- **Hi-Res nativ?** `v4l2-ctl --list-formats-ext -d /dev/video0` — liefert die Kamera
|
||||||
|
1280×960 als **MJPEG**? Falls nur YUYV → `-c:v copy` scheitert → andere native Auflösung
|
||||||
|
oder bewusst Re-Encode (teurer).
|
||||||
|
- **On-Demand Live** (FFmpeg erst bei erstem Client) wäre stromsparender, ist aber bewusst
|
||||||
|
weggelassen — Dauerbetrieb hält die Übergabe-Logik simpel (weniger Race-Fläche).
|
||||||
|
|||||||
@@ -80,52 +80,32 @@ Der **Hinweg** (Schritt 1: warten bis `cam` frei, bevor `cam_hires` startet) fun
|
|||||||
— er wartet echt (4870ms / 5069ms im Log). Kaputt ist der **Rückweg** (`cam_hires` → `cam`):
|
— er wartet echt (4870ms / 5069ms im Log). Kaputt ist der **Rückweg** (`cam_hires` → `cam`):
|
||||||
dort wird nicht zuverlässig gewartet.
|
dort wird nicht zuverlässig gewartet.
|
||||||
|
|
||||||
### Lösungsvorschläge (geordnet nach Robustheit)
|
### ✅ GELÖST (2026-06-05) — Node-MJPEG-Schalter, go2rtc entfernt
|
||||||
|
|
||||||
**A — Separate Hi-Res-Kamera (Weg A aus 04). GARANTIERT.**
|
Statt go2rtc zu orchestrieren (blind, racet weiter) **besitzt Node die Kameras jetzt
|
||||||
Zusätzliche USB-Kamera, die go2rtc nicht öffnet; Node grabbt sie on-demand per one-shot
|
selbst**. Damit liefert das `close`-Event des selbst gestarteten FFmpeg den harten Beweis
|
||||||
FFmpeg. Anderes Device → kein Race möglich. Kostet Hardware. Einzige 100%-Lösung.
|
„Gerät frei" — genau das Signal, das go2rtcs API verweigerte. Das Race ist **eliminiert,
|
||||||
|
nicht getimt**. Details der finalen Architektur: `doc/05_screenshot_roadmap.md`.
|
||||||
|
|
||||||
**B — Feature streichen, zurück auf KONSOLIDIERT (04). GARANTIERT.**
|
**Was sich änderte:**
|
||||||
`cam0_hires`/`cam1_hires` aus docker-compose, `/hires` aus snapshotService, `HD`-Button
|
|
||||||
aus dem Viewer. Nur noch 640er-Snapshot (read-only `frame.jpeg`). Stabil, kein Hi-Res.
|
|
||||||
|
|
||||||
**C — No-Hardware-Versuch: Rückweg robust machen. MITTLERE Sicherheit, MUSS gemessen werden.**
|
| Komponente | vorher (go2rtc) | jetzt (Node-Schalter) |
|
||||||
Statt auf `state` zu pollen: warten bis go2rtc das **Producer-Objekt entfernt hat**
|
|-----------|-----------------|----------------------|
|
||||||
(`producers`-Array leer für `cam_hires`) + großzügiger Settle. Vorher zwingend
|
| Geräte-Öffner | go2rtc | **Node** (`src/cameraSwitch.js`, eine Instanz pro Gerät) |
|
||||||
**empirisch messen** (rein lesend, erlaubt): bei abgeschaltetem Live-Stream einmal
|
| Live-Auslieferung | go2rtc WS + `video-stream.js` | MJPEG `multipart/x-mixed-replace` → `<img>` |
|
||||||
`frame.jpeg?src=cam0_hires` holen, dann `GET /api/streams` alle 100ms für ~10s loggen —
|
| HD-Snapshot | 2. go2rtc-Stream `cam_hires` (Race!) | Schalter stoppt Live (Prozess-`close` = FD frei), greift 1280, zurück |
|
||||||
zeigt, ob/wann der Producer wirklich verschwindet. Erst wenn die Daten das belegen,
|
| Multi-User | brach (Consumer ≠ 0) | **gelöst**: ein FFmpeg → Fan-out an alle, Clients halten kein Gerät |
|
||||||
ausliefern. Bleibt prinzipiell ein Race auf geteiltem Device → kein Versprechen.
|
| go2rtc | nötig | **entfernt** |
|
||||||
|
|
||||||
**Empfehlung:** Wenn Hi-Res verzichtbar → **B**. Wenn Hi-Res zwingend → **A**.
|
**Warum 106% jetzt nicht mehr auftritt:** Pro Gerät hält der Schalter immer nur **einen**
|
||||||
**C** nur, wenn kein Hardware-Budget UND Hi-Res nötig — und nur nach Messung (nie wieder
|
FFmpeg. Übergang Live→HD und HD→Live wird über das `close`-Event synchronisiert — zwei
|
||||||
„verifiziert" ohne Messung auf `cam`, 04 eiserne Regel 3).
|
Encoder auf einem `/dev/videoN` sind konstruktiv ausgeschlossen.
|
||||||
|
|
||||||
### Messung Weg C (Probe) — Anleitung & Ergebnis
|
**Verifiziert (lokal, ohne Kamera):** MJPEG-Parser (Content-Length-basiert, Chunk-robust,
|
||||||
|
`\r\n\r\n` im Body) per Unittest; HTTP-Routing (snapshot/stream/health, 404/503-Pfade);
|
||||||
|
Crash-Auto-Restart rate-limitiert. **Auf der Hardware noch zu verifizieren:** CPU-Last,
|
||||||
|
Latenz, HD-Blackout-Dauer, kein 106% nach Screenshot+Reconnect (Testplan in 05).
|
||||||
|
|
||||||
Temporäre, rein lesende Diagnose-Route in `snapshotService.js`: `GET /:id/hires-probe`.
|
**FFmpeg-Argumente** sind identisch zu denen, die die *bisher funktionierende* go2rtc-
|
||||||
Sie wartet bis `cam` frei ist, holt **einen** `cam_hires`-Frame und schreibt dann 12s lang
|
Quelle erzeugte (`-f v4l2 -input_format mjpeg -video_size 640x480 -framerate 30 -i …`),
|
||||||
alle 100ms den `cam_hires`-Zustand mit (`cons`, `prods`, `states`).
|
nur `-c:v copy -f mpjpeg pipe:1` als Ausgabe → kein Re-Encode.
|
||||||
|
|
||||||
Ablauf:
|
|
||||||
1. Code auf Server syncen, **`AppRobotWebcam` neu starten** (lädt `server.js`; go2rtc unberührt).
|
|
||||||
2. Im Viewer die zu messende Kamera **ausschalten** (⏸) → `cam` hat 0 Consumer.
|
|
||||||
3. `curl http://<host>:8444/api/snapshot/cam0/hires-probe` (oder im Browser öffnen).
|
|
||||||
4. JSON-Antwort + Container-Log (`[probe]…`) hierher.
|
|
||||||
|
|
||||||
Entscheidend: **`producerGoneAtMs`** (wann `prods` auf 0 fällt) und wie sich `states`
|
|
||||||
entwickelt. Daraus wird der robuste Rückweg gebaut (warten bis `prods===0` + Settle).
|
|
||||||
Wenn `prods` **nie** 0 wird → go2rtc baut den Producer gar nicht ab → Weg C ist tot,
|
|
||||||
dann bleibt nur A oder B.
|
|
||||||
|
|
||||||
**Ergebnis:** _(hier eintragen nach der Messung)_
|
|
||||||
|
|
||||||
Danach die `hires-probe`-Route wieder entfernen.
|
|
||||||
|
|
||||||
### Noch offen: Multi-User (siehe Abschnitt oben)
|
|
||||||
|
|
||||||
Unabhängig vom 106%-Race: bei ≥2 aktiven Clients kann `/hires` nicht starten, weil
|
|
||||||
Schritt 1 wartet bis `cam` 0 Consumer hat (max 8s), ein zweiter Browser die Consumer-Zahl
|
|
||||||
aber nie auf 0 fallen lässt → Timeout → 503. Variante A löst das mit (separates Device,
|
|
||||||
kein Warten auf 0 Consumer). Sonst: „Schalter"-Idee oben (ein Producer, Server verteilt).
|
|
||||||
@@ -1,73 +1,42 @@
|
|||||||
name: approbotwebcam
|
name: approbotwebcam
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
# MJPEG-AUFBAU – go2rtc (Streaming) + Node.js (Viewer/Proxy/API)
|
# NODE-MJPEG-SCHALTER – ein Node-Container besitzt die Kameras selbst
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
#
|
#
|
||||||
# Portainer: Stack → Web editor → dieses YAML einfügen → Deploy.
|
# Node startet pro Kamera EINEN FFmpeg (640 MJPEG passthrough) und verteilt den
|
||||||
# Vorher in Portainer → "Environment variables":
|
# Stream als multipart/x-mixed-replace an die Browser (<img>). Für HD-Snapshots
|
||||||
|
# stoppt der Schalter den Live-FFmpeg sauber (Prozess-close = Gerät frei),
|
||||||
|
# greift 1280×960, schaltet zurück. go2rtc wird NICHT mehr gebraucht.
|
||||||
|
#
|
||||||
|
# Warum so: go2rtcs API konnte nicht zuverlässig melden, wann FFmpeg das Gerät
|
||||||
|
# freigibt → Race (zwei Encoder auf /dev/videoN = ~108% CPU). Wenn Node FFmpeg
|
||||||
|
# selbst startet, ist dessen 'close'-Event der harte Beweis „Gerät frei".
|
||||||
|
# Siehe doc/09_Bug_reports.md.
|
||||||
|
#
|
||||||
|
# Portainer: Stack → Web editor → dieses YAML → Deploy.
|
||||||
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam (Code muss dort liegen)
|
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam (Code muss dort liegen)
|
||||||
#
|
#
|
||||||
# WICHTIG: Vor jedem Redeploy sicherstellen, dass server.js / public/ / src/
|
# Firewall (Internet): TCP 8444 (Viewer + Stream + API). Sonst nichts mehr.
|
||||||
# auf dem Server unter APP_PATH aktuell sind (Synology-Sync abwarten).
|
|
||||||
#
|
|
||||||
# Firewall (Internet): TCP 8444 (Viewer+API)
|
|
||||||
# Port 1984 (go2rtc) NICHT nach aussen – läuft nur intern via localhost.
|
|
||||||
# UDP 8555 (WebRTC) wird NICHT verwendet – Viewer läuft im MJPEG-Modus.
|
|
||||||
#
|
#
|
||||||
# Zugriff:
|
# Zugriff:
|
||||||
# Viewer: http://<host>:8444/
|
# Viewer: http://<host>:8444/
|
||||||
# Snapshot (Homing) http://<host>:8444/api/snapshot/cam0
|
# Live-Stream: http://<host>:8444/api/stream/cam0
|
||||||
# go2rtc-Debug-UI http://<host>:1984/ (nur intern/LAN)
|
# Snapshot (Homing): http://<host>:8444/api/snapshot/cam0 (+ /hires)
|
||||||
|
#
|
||||||
|
# ROLLBACK auf den alten go2rtc-Aufbau: git checkout <commit> -- docker-compose.yaml
|
||||||
|
# server.js public/ src/ (der go2rtc-Stand liegt in der Git-Historie).
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
configs:
|
|
||||||
go2rtc_yaml:
|
|
||||||
content: |
|
|
||||||
streams:
|
|
||||||
# 640x480 MJPEG, Re-Encode in go2rtc (~50% CPU für 2 Kameras mit Clients).
|
|
||||||
# Viewer läuft im MJPEG-Modus (MODE='mjpeg' in viewer.js) → keine Freezes, ~200ms.
|
|
||||||
# NICHT #video=copy: am 2026-06-04 getestet → CPU 50% → 107% (schlechter). Verworfen.
|
|
||||||
cam0: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg"
|
|
||||||
cam1: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg"
|
|
||||||
# Phase-2 Hi-Res: on-demand (dormant bis erster Consumer). #video=copy auf dieser
|
|
||||||
# Kamera defekt (04_*), daher #video=mjpeg. Nur ~1-2s aktiv pro Grab.
|
|
||||||
# Rollback: diese beiden Zeilen entfernen + Redeploy.
|
|
||||||
cam0_hires: "ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
|
|
||||||
cam1_hires: "ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=1280x960&framerate=15#video=mjpeg"
|
|
||||||
webrtc:
|
|
||||||
listen: ":8555"
|
|
||||||
candidates:
|
|
||||||
- stun:8555
|
|
||||||
api:
|
|
||||||
listen: ":1984"
|
|
||||||
origin: "*"
|
|
||||||
log:
|
|
||||||
level: info
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
# ── go2rtc: Kamera-Capture · MJPEG Re-Encode · Streaming ──────────────────
|
|
||||||
go2rtc:
|
|
||||||
image: ghcr.io/alexxit/go2rtc
|
|
||||||
container_name: AppRobotGo2RTC
|
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: host
|
|
||||||
devices:
|
|
||||||
- /dev/video0:/dev/video0
|
|
||||||
- /dev/video2:/dev/video2
|
|
||||||
group_add:
|
|
||||||
- video
|
|
||||||
configs:
|
|
||||||
- source: go2rtc_yaml
|
|
||||||
target: /config/go2rtc.yaml
|
|
||||||
|
|
||||||
# ── webcam: Node.js (Viewer · /api/ws-Proxy · Snapshot-API) ──────────────
|
|
||||||
webcam:
|
webcam:
|
||||||
build:
|
build:
|
||||||
context: /tmp
|
context: /tmp
|
||||||
dockerfile_inline: |
|
dockerfile_inline: |
|
||||||
FROM node:lts-bookworm-slim
|
FROM node:lts-bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
EXPOSE 8444
|
EXPOSE 8444
|
||||||
image: approbotwebcam:latest
|
image: approbotwebcam:latest
|
||||||
@@ -77,19 +46,27 @@ services:
|
|||||||
command: sh -c "npm install --omit=dev && 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
|
||||||
|
devices:
|
||||||
|
- /dev/video0:/dev/video0
|
||||||
|
- /dev/video2:/dev/video2
|
||||||
|
group_add:
|
||||||
|
- video
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=8444
|
- PORT=8444
|
||||||
- GO2RTC_URL=http://localhost:1984
|
# Optional: Geräte/Auflösung überschreiben (sonst Auto-Detect + Defaults)
|
||||||
depends_on:
|
# - DEV0=/dev/video0
|
||||||
- go2rtc
|
# - DEV1=/dev/video2
|
||||||
|
# - LIVE_SIZE=640x480
|
||||||
|
# - LIVE_FPS=30
|
||||||
|
# - HIRES_SIZE=1280x960
|
||||||
|
# - HIRES_FPS=15
|
||||||
|
|
||||||
# ── FALLBACK ──────────────────────────────────────────────────────────────────
|
# ── Hinweise ────────────────────────────────────────────────────────────────────
|
||||||
# Meckert Portainer beim Deploy über "configs content" (sehr alte Compose-Version)?
|
# • Bleibt eine Kamera schwarz? Geräte prüfen: v4l2-ctl --list-devices
|
||||||
# → den configs-Block oben löschen und stattdessen beim go2rtc-Service mounten:
|
# und ob 640x480 bzw. 1280x960 als MJPEG nativ angeboten werden:
|
||||||
# volumes:
|
# v4l2-ctl --list-formats-ext -d /dev/video0
|
||||||
# - ${APP_PATH:-.}/go2rtc.yaml:/config/go2rtc.yaml:ro
|
# Nur MJPEG-native Auflösungen bleiben CPU-arm (YUYV → Software-Encode = teuer).
|
||||||
#
|
# • Meckert Portainer über sehr alte Compose-Syntax (dockerfile_inline)? Dann
|
||||||
# Bleibt eine Kamera schwarz? → in der Config oben die Quelle ersetzen durch die
|
# Compose/Docker-Engine aktualisieren – dieser Aufbau braucht Compose v2.
|
||||||
# simple, bestätigte Form (ohne Auflösung): "ffmpeg:/dev/video0#video=mjpeg"
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "approbotwebcam",
|
"name": "approbotwebcam",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Low-latency WebRTC webcam service (go2rtc + Node proxy) for robot vision",
|
"description": "Low-latency MJPEG webcam service (Node owns cameras via FFmpeg) for robot vision",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js"
|
"dev": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1"
|
||||||
"http-proxy-middleware": "^3.0.3"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -39,11 +39,8 @@
|
|||||||
|
|
||||||
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; min-width: 320px; }
|
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; min-width: 320px; }
|
||||||
|
|
||||||
video-stream { display: block; width: 640px; height: 480px; background: #111; }
|
/* Live-MJPEG (multipart/x-mixed-replace) – nativ im <img> */
|
||||||
video-stream video { width: 100%; height: 100%; object-fit: contain; }
|
.cam-img { display: block; width: 640px; height: 480px; background: #111; object-fit: contain; }
|
||||||
|
|
||||||
/* Eingefrorener Frame während des Hi-Res-Tests (Phase 1) */
|
|
||||||
.cam-freeze { display: block; width: 640px; height: 480px; background: #111; }
|
|
||||||
|
|
||||||
.cam-label {
|
.cam-label {
|
||||||
position: absolute; top: 5px; left: 8px; z-index: 2;
|
position: absolute; top: 5px; left: 8px; z-index: 2;
|
||||||
@@ -90,7 +87,6 @@
|
|||||||
<div id="notice"></div>
|
<div id="notice"></div>
|
||||||
<div id="cameras"></div>
|
<div id="cameras"></div>
|
||||||
|
|
||||||
<script type="module" src="/video-stream.js"></script>
|
|
||||||
<script src="viewer.js" defer></script>
|
<script src="viewer.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
445
public/viewer.js
445
public/viewer.js
@@ -1,178 +1,62 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// go2rtc Player-Modi.
|
// ── Architektur ───────────────────────────────────────────────────────────────
|
||||||
// 'mjpeg' → Passthrough: go2rtc reicht Kamera-MJPEG 1:1 durch.
|
// Der Server (Node) besitzt die Kameras und liefert den Live-Stream als
|
||||||
// KEIN Transcode → ~0% CPU, keine Freezes, ~200ms Latenz.
|
// MJPEG multipart/x-mixed-replace unter /api/stream/<id>. Der Browser rendert
|
||||||
// 'webrtc,mse,mjpeg' → WebRTC bevorzugt. ABER: WebRTC/MSE können kein MJPEG →
|
// das nativ in einem <img>. KEIN WebRTC, KEIN go2rtc, kein Transcode.
|
||||||
// go2rtc transcodiert MJPEG→H.264 in Software (libx264) →
|
//
|
||||||
// ~55% CPU pro Kamera + Keyframe-Freezes. ~130ms Latenz.
|
// HD-Snapshot: GET /api/snapshot/<id>/hires. Der Server-Schalter pausiert dafür
|
||||||
const MODE = 'mjpeg';
|
// den Live-FFmpeg kurz (~1–2 s), greift 1280×960, schaltet zurück. Der <img>-
|
||||||
const IS_MJPEG = !MODE.includes('webrtc'); // MJPEG-Modus: kein WebRTC/getStats/Health
|
// Stream friert in dieser Zeit ein und läuft danach weiter – kein Client-Handling
|
||||||
|
// nötig (das war früher die Fehlerquelle).
|
||||||
|
|
||||||
// ── Überwachungs-Parameter ───────────────────────────────────────────────────
|
|
||||||
const MONITOR_INTERVAL = 2500; // ms zwischen Health-Checks (getStats)
|
|
||||||
const CONNECT_GRACE_MS = 25000; // so lange darf der Verbindungsaufbau dauern (kein Alarm)
|
|
||||||
const WARMUP_MS = 15000; // Karenz nach 'playing', bis Überlast-Erkennung scharf wird
|
|
||||||
const OVERLOAD_TICKS = 3; // so viele kritische Checks in Folge → Auto-Abschaltung
|
|
||||||
|
|
||||||
// Schwellen (auf Basis der ZUVERLÄSSIGEN getStats-Werte, nicht Render-Drops):
|
|
||||||
const SERVER_LOW_FPS = 12; // recv < 12/s nach Aufwärmen → Server liefert wenig (nur Warnung)
|
|
||||||
const CLIENT_DECODE_RATIO = 0.6; // decoded < 60% von recv → Decoder kommt nicht nach (echte Client-Überlast)
|
|
||||||
const NET_LOST_PER_TICK = 5; // mehr verlorene Pakete/Intervall → Netz-Warnung
|
|
||||||
const NET_JITTER_MS = 60; // mehr Jitter → Netz-Warnung
|
|
||||||
|
|
||||||
// ── Logging (Browser DevTools → Console → F12) ───────────────────────────────
|
|
||||||
const P = '[WebcamViewer]';
|
const P = '[WebcamViewer]';
|
||||||
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
|
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
|
||||||
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
|
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
|
||||||
const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
|
const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
|
||||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
let GO2RTC_PORT = 1984;
|
const cameras = []; // { id, box, img, infoEl, toggleBtn, hdBtn, active, busy }
|
||||||
const cameras = []; // { id, box, infoEl, toggleBtn, active, startedAt, playingSince, statsLast, badTicks, autoOff }
|
|
||||||
|
|
||||||
// ── Stream starten / stoppen ─────────────────────────────────────────────────
|
// ── Live-Stream an/aus ────────────────────────────────────────────────────────
|
||||||
function startStream(cam) {
|
function startStream(cam) {
|
||||||
if (cam.box.querySelector('video-stream')) return;
|
|
||||||
const wsUrl = `ws://${location.hostname}:${GO2RTC_PORT}/api/ws?src=${encodeURIComponent(cam.id)}`;
|
|
||||||
log(cam.id, `Verbinde → ${wsUrl}`);
|
|
||||||
|
|
||||||
cam.active = true;
|
cam.active = true;
|
||||||
cam.startedAt = performance.now();
|
// Cache-Buster erzwingt eine frische Verbindung (sonst hängt Reconnect manchmal)
|
||||||
cam.playingSince = null; // wird erst beim 'playing'-Event gesetzt
|
cam.img.src = `/api/stream/${encodeURIComponent(cam.id)}?t=${Date.now()}`;
|
||||||
cam.statsLast = null;
|
|
||||||
cam.badTicks = 0;
|
|
||||||
|
|
||||||
const stream = document.createElement('video-stream');
|
|
||||||
stream.mode = MODE;
|
|
||||||
stream.addEventListener('playing', () => {
|
|
||||||
cam.playingSince = performance.now();
|
|
||||||
cam.statsLast = null;
|
|
||||||
cam.badTicks = 0;
|
|
||||||
log(cam.id, '▶ Bild läuft (Aufwärmphase startet)');
|
|
||||||
}, true);
|
|
||||||
stream.addEventListener('error', (e) => logErr(cam.id, 'Video-Fehler', e), true);
|
|
||||||
stream.src = wsUrl;
|
|
||||||
|
|
||||||
cam.box.insertBefore(stream, cam.box.firstChild);
|
|
||||||
cam.toggleBtn.textContent = '⏸';
|
cam.toggleBtn.textContent = '⏸';
|
||||||
cam.toggleBtn.title = 'Stream ausschalten';
|
cam.toggleBtn.title = 'Stream ausschalten';
|
||||||
setInfo(cam, 'verbindet…', '');
|
setInfo(cam, 'verbindet…', '');
|
||||||
|
log(cam.id, 'Live an');
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopStream(cam, auto = false) {
|
function stopStream(cam) {
|
||||||
const el = cam.box.querySelector('video-stream');
|
|
||||||
if (el) el.remove();
|
|
||||||
cam.active = false;
|
cam.active = false;
|
||||||
cam.autoOff = auto;
|
cam.img.removeAttribute('src'); // schließt die multipart-Verbindung
|
||||||
cam.playingSince = null;
|
|
||||||
cam.toggleBtn.textContent = '▶';
|
cam.toggleBtn.textContent = '▶';
|
||||||
cam.toggleBtn.title = 'Stream einschalten';
|
cam.toggleBtn.title = 'Stream einschalten';
|
||||||
setInfo(cam, auto ? 'auto-aus (Client überlastet)' : 'aus', auto ? 'crit' : '');
|
setInfo(cam, 'aus', '');
|
||||||
log(cam.id, auto ? 'AUTO-abgeschaltet (Decoder kam nicht nach)' : 'manuell aus');
|
log(cam.id, 'Live aus');
|
||||||
if (auto) showNotice();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hi-Res Canvas-Freeze + Grab (Phase 2) ───────────────────────────────────
|
// ── HD-Snapshot ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Holt letzten 640er-Frame von /api/snapshot und zeichnet ihn auf canvas.
|
|
||||||
// Robuster als drawImage(video-stream): go2rtc MJPEG-Modus hat kein <video>-Element.
|
|
||||||
async function showFreezeCanvas(cam, badgeText = 'Capturing HD…') {
|
|
||||||
removeFreezeCanvas(cam);
|
|
||||||
const W = 640, H = 480;
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.className = 'cam-freeze';
|
|
||||||
canvas.width = W;
|
|
||||||
canvas.height = H;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.fillStyle = '#111';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}`, { cache: 'no-store' });
|
|
||||||
if (r.ok) {
|
|
||||||
const url = URL.createObjectURL(await r.blob());
|
|
||||||
await new Promise(resolve => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => { ctx.drawImage(img, 0, 0, W, H); URL.revokeObjectURL(url); resolve(); };
|
|
||||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(); };
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) { logErr(cam.id, 'Freeze-Frame holen', e); }
|
|
||||||
|
|
||||||
drawBadge(ctx, W, H, badgeText, '#8cf');
|
|
||||||
cam.box.insertBefore(canvas, cam.box.firstChild);
|
|
||||||
cam.freezeCanvas = canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFreezeCanvas(cam) {
|
|
||||||
if (cam.freezeCanvas) { cam.freezeCanvas.remove(); cam.freezeCanvas = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawBadge(ctx, W, H, text, color = '#8cf') {
|
|
||||||
const bw = W * 0.38, bh = 34, m = 12;
|
|
||||||
const bx = W - bw - m, by = H - bh - m;
|
|
||||||
ctx.fillStyle = 'rgba(0,0,0,.75)';
|
|
||||||
ctx.fillRect(bx, by, bw, bh);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.font = '13px monospace';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(text, bx + bw / 2, by + bh / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateBadge(cam, text, color) {
|
|
||||||
if (!cam.freezeCanvas) return;
|
|
||||||
drawBadge(cam.freezeCanvas.getContext('2d'), 640, 480, text, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 2: Hi-Res-Grab ─────────────────────────────────────────────────────
|
|
||||||
// Ablauf (doc/05_screenShot_roadmap.md, Phase 2):
|
|
||||||
// 1. Live-Frame einfrieren + cam loslassen (Consumer → 0)
|
|
||||||
// 2. Server wartet auf Freigabe (cam0 Producer stoppt), greift dann cam0_hires
|
|
||||||
// 3. HD-JPEG im Canvas zeigen + Download auslösen
|
|
||||||
// 4. finally: immer zurück auf Live (cam0 bleibt unberührt → sauberer Reconnect)
|
|
||||||
async function runHiresGrab(cam) {
|
async function runHiresGrab(cam) {
|
||||||
if (cam.testing) return;
|
if (cam.busy) return;
|
||||||
cam.testing = true;
|
cam.busy = true;
|
||||||
cam.hdBtn.disabled = true;
|
cam.hdBtn.disabled = true;
|
||||||
|
setInfo(cam, 'HD: erfasse… (Stream friert kurz)', 'warn');
|
||||||
log(cam.id, '── HD-Grab gestartet ──');
|
log(cam.id, '── HD-Grab gestartet ──');
|
||||||
|
|
||||||
let blobUrl = null;
|
let blobUrl = null;
|
||||||
try {
|
try {
|
||||||
// 1. Freeze-Frame zeigen (echter 640er-Frame, kein grauer Kasten)
|
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/hires`, { signal: AbortSignal.timeout(20000) });
|
||||||
await showFreezeCanvas(cam, 'Capturing HD…');
|
|
||||||
stopStream(cam);
|
|
||||||
setInfo(cam, 'HD: warte auf Freigabe…', 'warn');
|
|
||||||
|
|
||||||
// 2. HD-Grab – Server pollt Freigabe, holt dann cam_hires-Frame.
|
|
||||||
// Client-Timeout (20s) > Server-Maximum (~12s: 8s Warten + 4×0.8s Retries)
|
|
||||||
const r = await fetch(
|
|
||||||
`/api/snapshot/${encodeURIComponent(cam.id)}/hires`,
|
|
||||||
{ signal: AbortSignal.timeout(20000) }
|
|
||||||
);
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const body = await r.json().catch(() => ({}));
|
const body = await r.json().catch(() => ({}));
|
||||||
throw new Error(body.error ?? `HTTP ${r.status}`);
|
throw new Error(body.error ?? `HTTP ${r.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await r.blob();
|
const blob = await r.blob();
|
||||||
|
const width = r.headers.get('X-Frame-Width') || '?';
|
||||||
blobUrl = URL.createObjectURL(blob);
|
blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
// 3a. HD-Frame im Canvas zeigen (skaliert auf 640px, volle Qualität)
|
|
||||||
if (cam.freezeCanvas) {
|
|
||||||
const ctx = cam.freezeCanvas.getContext('2d');
|
|
||||||
await new Promise(resolve => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => { ctx.drawImage(img, 0, 0, 640, 480); resolve(); };
|
|
||||||
img.onerror = resolve;
|
|
||||||
img.src = blobUrl;
|
|
||||||
});
|
|
||||||
updateBadge(cam, 'HD ✓ speichere…', '#8f8');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3b. Download auslösen
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = blobUrl;
|
a.href = blobUrl;
|
||||||
a.download = `${cam.id}_hires_${Date.now()}.jpg`;
|
a.download = `${cam.id}_hires_${Date.now()}.jpg`;
|
||||||
@@ -180,225 +64,56 @@ async function runHiresGrab(cam) {
|
|||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
|
|
||||||
setInfo(cam, 'HD gespeichert', 'ok');
|
setInfo(cam, `HD gespeichert (${width}px)`, 'ok');
|
||||||
log(cam.id, `HD-Grab OK – ${blob.size} bytes`);
|
log(cam.id, `HD-Grab OK – ${blob.size} bytes, ${width}px`);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logErr(cam.id, 'HD-Grab fehlgeschlagen', e);
|
logErr(cam.id, 'HD-Grab fehlgeschlagen', e);
|
||||||
setInfo(cam, `HD Fehler: ${e.message}`, 'crit');
|
setInfo(cam, `HD Fehler: ${e.message}`, 'crit');
|
||||||
} finally {
|
} finally {
|
||||||
// 4. Immer: kurz warten (go2rtc cam_hires freigeben), dann Live zurück
|
if (blobUrl) setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
|
||||||
await sleep(600);
|
cam.busy = false;
|
||||||
removeFreezeCanvas(cam);
|
|
||||||
if (blobUrl) { URL.revokeObjectURL(blobUrl); blobUrl = null; }
|
|
||||||
startStream(cam);
|
|
||||||
cam.testing = false;
|
|
||||||
cam.hdBtn.disabled = false;
|
cam.hdBtn.disabled = false;
|
||||||
log(cam.id, '── HD-Grab beendet, zurück auf Live ──');
|
log(cam.id, '── HD-Grab beendet ──');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Health-Anzeige ───────────────────────────────────────────────────────────
|
// ── HD-Snapshot aller Kameras (parallel) ──────────────────────────────────────
|
||||||
|
// cam0/cam1 liegen auf getrennten Geräten → der Schalter grabbt beide parallel
|
||||||
|
// gefahrlos (jeder Schalter steuert nur sein eigenes Gerät).
|
||||||
|
async function snapshotAllHires() {
|
||||||
|
const snapBtn = document.getElementById('snapAllBtn');
|
||||||
|
if (snapBtn) snapBtn.disabled = true;
|
||||||
|
log('snap', `HD-Grab alle: ${cameras.map((c) => c.id).join(', ')}`);
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(cameras.map((c) => runHiresGrab(c)));
|
||||||
|
} finally {
|
||||||
|
if (snapBtn) snapBtn.disabled = false;
|
||||||
|
log('snap', '── HD-Grab alle beendet ──');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status-Anzeige ────────────────────────────────────────────────────────────
|
||||||
function setInfo(cam, text, cls) {
|
function setInfo(cam, text, cls) {
|
||||||
cam.infoEl.textContent = text;
|
cam.infoEl.textContent = text;
|
||||||
cam.infoEl.className = 'cam-info ' + (cls ?? '');
|
cam.infoEl.className = 'cam-info ' + (cls ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showConnecting(cam) {
|
// ── Kamera-View aufbauen ──────────────────────────────────────────────────────
|
||||||
const secs = Math.round((performance.now() - cam.startedAt) / 1000);
|
|
||||||
setInfo(cam, secs < CONNECT_GRACE_MS / 1000 ? `verbindet… ${secs}s` : 'kein Signal',
|
|
||||||
secs < CONNECT_GRACE_MS / 1000 ? '' : 'crit');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Monitor: liest getStats (inbound-rtp) = die verlässliche Wahrheit ─────────
|
|
||||||
// recv = Frames über Netz → niedrig = Server liefert nicht (Encode-CPU)
|
|
||||||
// decoded = davon dekodiert → deutlich < recv = Client-Decoder überlastet
|
|
||||||
// lost/jitter → Netz/WiFi
|
|
||||||
async function monitor() {
|
|
||||||
const now = performance.now();
|
|
||||||
|
|
||||||
for (const cam of cameras) {
|
|
||||||
if (!cam.active) continue;
|
|
||||||
|
|
||||||
// MJPEG-Passthrough: kein PeerConnection, kein getStats, keine Decoder-Überlast.
|
|
||||||
// Das Bild läuft, sobald das Element da ist – nur simple Status-Anzeige.
|
|
||||||
if (IS_MJPEG) {
|
|
||||||
const live = !!cam.box.querySelector('video-stream');
|
|
||||||
setInfo(cam, live ? 'MJPEG · live' : 'aus', live ? 'ok' : '');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const el = cam.box.querySelector('video-stream');
|
|
||||||
const pc = el && el.pc;
|
|
||||||
// Noch nicht verbunden oder 'playing' noch nicht gefeuert → Aufbauphase
|
|
||||||
if (!pc || typeof pc.getStats !== 'function' || cam.playingSince === null) {
|
|
||||||
showConnecting(cam);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let stats;
|
|
||||||
try { stats = await pc.getStats(); } catch { continue; }
|
|
||||||
let v = null;
|
|
||||||
stats.forEach(r => { if (r.type === 'inbound-rtp' && r.kind === 'video') v = r; });
|
|
||||||
if (!v) { showConnecting(cam); continue; }
|
|
||||||
|
|
||||||
const cur = {
|
|
||||||
t: v.timestamp,
|
|
||||||
recv: v.framesReceived ?? 0,
|
|
||||||
dec: v.framesDecoded ?? 0,
|
|
||||||
drop: v.framesDropped ?? 0,
|
|
||||||
lost: v.packetsLost ?? 0,
|
|
||||||
bytes: v.bytesReceived ?? 0,
|
|
||||||
};
|
|
||||||
const last = cam.statsLast;
|
|
||||||
cam.statsLast = cur;
|
|
||||||
|
|
||||||
// Erster Messpunkt oder Zähler-Reset → nur Baseline
|
|
||||||
if (!last || cur.t <= last.t || cur.recv < last.recv) {
|
|
||||||
setInfo(cam, 'misst…', '');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dt = (cur.t - last.t) / 1000;
|
|
||||||
const recvPs = Math.round((cur.recv - last.recv) / dt);
|
|
||||||
const decPs = Math.round((cur.dec - last.dec) / dt);
|
|
||||||
const dropPs = Math.round((cur.drop - last.drop) / dt);
|
|
||||||
const lostD = Math.max(0, cur.lost - last.lost);
|
|
||||||
const mbps = (((cur.bytes - last.bytes) * 8) / dt / 1e6).toFixed(2);
|
|
||||||
const jitter = v.jitter != null ? Math.round(v.jitter * 1000) : 0;
|
|
||||||
const size = (v.frameWidth && v.frameHeight) ? `${v.frameWidth}x${v.frameHeight}` : '?';
|
|
||||||
|
|
||||||
log(cam.id, `[stats] recv=${recvPs}/s decoded=${decPs}/s dropped=${dropPs}/s ` +
|
|
||||||
`lost=+${lostD} jitter=${jitter}ms ${size} ${mbps}Mbps`);
|
|
||||||
|
|
||||||
// Während Aufwärmphase: anzeigen, aber NICHT bewerten
|
|
||||||
if (now - cam.playingSince < WARMUP_MS) {
|
|
||||||
const secs = Math.ceil((WARMUP_MS - (now - cam.playingSince)) / 1000);
|
|
||||||
setInfo(cam, `startet… ${decPs} fps (${secs}s)`, '');
|
|
||||||
cam.badTicks = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bewertung nach Aufwärmphase ──
|
|
||||||
const clientOverload = recvPs >= SERVER_LOW_FPS && decPs < recvPs * CLIENT_DECODE_RATIO;
|
|
||||||
const serverLow = recvPs < SERVER_LOW_FPS;
|
|
||||||
const netBad = lostD > NET_LOST_PER_TICK || jitter > NET_JITTER_MS;
|
|
||||||
|
|
||||||
if (clientOverload) {
|
|
||||||
setInfo(cam, `Decoder hängt ${decPs}/${recvPs} fps`, 'crit');
|
|
||||||
cam.badTicks++;
|
|
||||||
} else {
|
|
||||||
cam.badTicks = 0;
|
|
||||||
if (serverLow) setInfo(cam, `Server liefert ${recvPs}/s`, 'warn');
|
|
||||||
else if (netBad) setInfo(cam, `${decPs} fps · ⚠ ${jitter}ms / lost+${lostD}`, 'warn');
|
|
||||||
else setInfo(cam, `${decPs} fps · ${mbps} Mbps`, 'ok');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-Schutz NUR bei echter Client-Überlast (Decoder kommt nicht nach)
|
|
||||||
if (cam.badTicks >= OVERLOAD_TICKS) {
|
|
||||||
warn(cam.id, `Decoder überlastet (${cam.badTicks}× kritisch) → Auto-Abschaltung`);
|
|
||||||
stopStream(cam, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hinweis-Banner bei Auto-Abschaltung ──────────────────────────────────────
|
|
||||||
function showNotice() {
|
|
||||||
const off = cameras.filter(c => c.autoOff && !c.active).map(c => c.id);
|
|
||||||
const bar = document.getElementById('notice');
|
|
||||||
if (off.length === 0) { bar.style.display = 'none'; return; }
|
|
||||||
bar.innerHTML = `⚠ Client überlastet – automatisch deaktiviert: <b>${off.join(', ')}</b> `;
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.textContent = 'Wieder aktivieren';
|
|
||||||
btn.onclick = () => {
|
|
||||||
cameras.filter(c => c.autoOff && !c.active).forEach(c => { c.autoOff = false; startStream(c); });
|
|
||||||
showNotice();
|
|
||||||
};
|
|
||||||
bar.appendChild(btn);
|
|
||||||
bar.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HD-Snapshot aller Kameras (parallel) ─────────────────────────────────────
|
|
||||||
// cam0 und cam1 liegen auf getrennten Geräten → gleichzeitiger Grab sicher.
|
|
||||||
// Alle Live-Streams werden synchron eingefroren und losgelassen, dann beide
|
|
||||||
// /hires-Requests parallel gefeuert. finally stellt immer alle zurück.
|
|
||||||
async function snapshotAllHires() {
|
|
||||||
if (cameras.some(c => c.testing)) return;
|
|
||||||
|
|
||||||
const snapBtn = document.getElementById('snapAllBtn');
|
|
||||||
if (snapBtn) snapBtn.disabled = true;
|
|
||||||
cameras.forEach(c => { c.testing = true; c.hdBtn.disabled = true; });
|
|
||||||
log('snap', `HD-Grab alle: ${cameras.map(c => c.id).join(', ')}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Alle Freeze-Canvases gleichzeitig aufbauen (je ein /api/snapshot-Fetch)
|
|
||||||
await Promise.all(cameras.map(c => showFreezeCanvas(c, 'Capturing HD…')));
|
|
||||||
|
|
||||||
// 2. Alle Live-Streams synchron loslassen → alle Consumer fallen gleichzeitig auf 0
|
|
||||||
cameras.forEach(c => stopStream(c));
|
|
||||||
|
|
||||||
const ts = Date.now();
|
|
||||||
|
|
||||||
// 3. Alle /hires-Grabs parallel – Fehler einer Kamera blockieren die andere nicht
|
|
||||||
await Promise.allSettled(cameras.map(async c => {
|
|
||||||
try {
|
|
||||||
const r = await fetch(
|
|
||||||
`/api/snapshot/${encodeURIComponent(c.id)}/hires`,
|
|
||||||
{ signal: AbortSignal.timeout(20000) }
|
|
||||||
);
|
|
||||||
if (!r.ok) {
|
|
||||||
const body = await r.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error ?? `HTTP ${r.status}`);
|
|
||||||
}
|
|
||||||
const blob = await r.blob();
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
if (c.freezeCanvas) {
|
|
||||||
const ctx = c.freezeCanvas.getContext('2d');
|
|
||||||
await new Promise(resolve => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => { ctx.drawImage(img, 0, 0, 640, 480); resolve(); };
|
|
||||||
img.onerror = resolve;
|
|
||||||
img.src = blobUrl;
|
|
||||||
});
|
|
||||||
updateBadge(c, 'HD ✓', '#8f8');
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = blobUrl;
|
|
||||||
a.download = `${c.id}_hires_${ts}.jpg`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
URL.revokeObjectURL(blobUrl);
|
|
||||||
|
|
||||||
setInfo(c, 'HD gespeichert', 'ok');
|
|
||||||
log(c.id, `HD-Grab OK – ${blob.size} bytes`);
|
|
||||||
} catch (e) {
|
|
||||||
logErr(c.id, 'HD-Grab fehlgeschlagen', e);
|
|
||||||
setInfo(c, `HD Fehler: ${e.message}`, 'crit');
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
// 4. Immer: alle zurück auf Live
|
|
||||||
await sleep(600);
|
|
||||||
cameras.forEach(c => {
|
|
||||||
removeFreezeCanvas(c);
|
|
||||||
startStream(c);
|
|
||||||
c.testing = false;
|
|
||||||
c.hdBtn.disabled = false;
|
|
||||||
});
|
|
||||||
if (snapBtn) snapBtn.disabled = false;
|
|
||||||
log('snap', '── HD-Grab alle beendet, alle zurück auf Live ──');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Kamera-View aufbauen ─────────────────────────────────────────────────────
|
|
||||||
function buildCamera(camId, container) {
|
function buildCamera(camId, container) {
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'cam-box';
|
box.className = 'cam-box';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'cam-img';
|
||||||
|
img.alt = camId;
|
||||||
|
img.addEventListener('load', () => { if (cam.active && !cam.busy) setInfo(cam, 'MJPEG · live', 'ok'); });
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
if (!cam.active) return;
|
||||||
|
setInfo(cam, 'Verbindungsfehler – neu…', 'crit');
|
||||||
|
// Auto-Reconnect nach kurzer Pause (nicht während HD-Grab)
|
||||||
|
setTimeout(() => { if (cam.active && !cam.busy) startStream(cam); }, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
label.className = 'cam-label';
|
label.className = 'cam-label';
|
||||||
label.textContent = camId;
|
label.textContent = camId;
|
||||||
@@ -413,19 +128,14 @@ function buildCamera(camId, container) {
|
|||||||
const hd = document.createElement('button');
|
const hd = document.createElement('button');
|
||||||
hd.className = 'cam-hdtest';
|
hd.className = 'cam-hdtest';
|
||||||
hd.textContent = 'HD';
|
hd.textContent = 'HD';
|
||||||
hd.title = 'Hi-Res-Snapshot (1280×960) – cam loslassen, hires-Grab, Download';
|
hd.title = 'Hi-Res-Snapshot (1280×960) – Live friert kurz ein, dann Download';
|
||||||
|
|
||||||
const cam = {
|
const cam = { id: camId, box, img, infoEl: info, toggleBtn: toggle, hdBtn: hd, active: false, busy: false };
|
||||||
id: camId, box, infoEl: info, toggleBtn: toggle, hdBtn: hd,
|
|
||||||
active: false, startedAt: 0, playingSince: null, statsLast: null, badTicks: 0, autoOff: false,
|
toggle.onclick = () => { if (!cam.busy) (cam.active ? stopStream(cam) : startStream(cam)); };
|
||||||
testing: false, freezeCanvas: null,
|
|
||||||
};
|
|
||||||
toggle.onclick = () => {
|
|
||||||
if (cam.testing) return; // während HD-Test gesperrt
|
|
||||||
cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice();
|
|
||||||
};
|
|
||||||
hd.onclick = () => runHiresGrab(cam);
|
hd.onclick = () => runHiresGrab(cam);
|
||||||
|
|
||||||
|
box.appendChild(img);
|
||||||
box.appendChild(label);
|
box.appendChild(label);
|
||||||
box.appendChild(info);
|
box.appendChild(info);
|
||||||
box.appendChild(toggle);
|
box.appendChild(toggle);
|
||||||
@@ -436,26 +146,9 @@ function buildCamera(camId, container) {
|
|||||||
startStream(cam);
|
startStream(cam);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
async function init() {
|
async function init() {
|
||||||
log('init', 'Starte...');
|
log('init', 'Starte...');
|
||||||
|
|
||||||
try {
|
|
||||||
const d = await (await fetch('/config.json')).json();
|
|
||||||
GO2RTC_PORT = d.go2rtcPort ?? 1984;
|
|
||||||
log('init', `go2rtc WS-Port: ${GO2RTC_PORT}`);
|
|
||||||
} catch (e) {
|
|
||||||
warn('init', `/config.json nicht ladbar, nehme Port ${GO2RTC_PORT}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await customElements.whenDefined('video-stream');
|
|
||||||
log('init', '<video-stream> definiert');
|
|
||||||
} catch (e) {
|
|
||||||
logErr('init', '<video-stream> nicht geladen – /video-stream.js erreichbar?', e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.getElementById('cameras');
|
const container = document.getElementById('cameras');
|
||||||
const statusText = document.getElementById('statusText');
|
const statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
@@ -463,7 +156,7 @@ async function init() {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch('/api/snapshot');
|
const r = await fetch('/api/snapshot');
|
||||||
log('init', `/api/snapshot → HTTP ${r.status}`);
|
log('init', `/api/snapshot → HTTP ${r.status}`);
|
||||||
if (r.ok) camIds = ((await r.json()).cameras ?? []).map(c => c.id);
|
if (r.ok) camIds = ((await r.json()).cameras ?? []).map((c) => c.id);
|
||||||
log('init', `Kameras: ${camIds.join(', ') || '(keine)'}`);
|
log('init', `Kameras: ${camIds.join(', ') || '(keine)'}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logErr('init', '/api/snapshot Fehler – Fallback', e);
|
logErr('init', '/api/snapshot Fehler – Fallback', e);
|
||||||
@@ -473,11 +166,9 @@ async function init() {
|
|||||||
const snapBtn = document.getElementById('snapAllBtn');
|
const snapBtn = document.getElementById('snapAllBtn');
|
||||||
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
|
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
|
||||||
|
|
||||||
camIds.forEach(id => buildCamera(id, container));
|
camIds.forEach((id) => buildCamera(id, container));
|
||||||
statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · WebRTC`;
|
statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · MJPEG`;
|
||||||
|
log('init', 'Fertig');
|
||||||
setInterval(monitor, MONITOR_INTERVAL); // getStats-basierte Überwachung + Diagnose
|
|
||||||
log('init', 'Fertig – Überwachung (getStats) aktiv');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
143
server.js
143
server.js
@@ -3,138 +3,69 @@
|
|||||||
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 { CameraSwitch } = require('./src/cameraSwitch');
|
||||||
const { createSnapshotRouter } = require('./src/snapshotService');
|
const { detectDevices } = require('./src/deviceDetect');
|
||||||
|
const { createSnapshotRouter, createStreamRouter } = require('./src/snapshotService');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
||||||
const GO2RTC_URL = process.env.GO2RTC_URL ?? 'http://localhost:1984';
|
const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
|
||||||
const GO2RTC_PORT = parseInt(process.env.GO2RTC_PORT ?? '1984', 10);
|
const LIVE_FPS = parseInt(process.env.LIVE_FPS ?? '30', 10);
|
||||||
|
const HIRES_SIZE = process.env.HIRES_SIZE ?? '1280x960';
|
||||||
|
const HIRES_FPS = parseInt(process.env.HIRES_FPS ?? '15', 10);
|
||||||
|
|
||||||
|
// ── Kameras: cam0 = erstes Gerät, cam1 = zweites … (DEV0/DEV1-Env überschreibt) ─
|
||||||
|
const devices = detectDevices();
|
||||||
|
const switches = {};
|
||||||
|
devices.forEach((device, i) => {
|
||||||
|
const id = `cam${i}`;
|
||||||
|
switches[id] = new CameraSwitch({ id, device, liveSize: LIVE_SIZE, liveFps: LIVE_FPS, hiresSize: HIRES_SIZE, hiresFps: HIRES_FPS });
|
||||||
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// ── 1. Eigene Endpunkte (vor dem Proxy registrieren) ─────────────────────────
|
// ── 1. Eigene Endpunkte ───────────────────────────────────────────────────────
|
||||||
|
app.use('/api/snapshot', createSnapshotRouter(switches));
|
||||||
|
app.use('/api/stream', createStreamRouter(switches));
|
||||||
|
|
||||||
app.use('/api/snapshot', createSnapshotRouter(GO2RTC_URL));
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
app.get('/health', async (_req, res) => {
|
status: 'ok',
|
||||||
try {
|
cameras: Object.values(switches).map((sw) => ({
|
||||||
const r = await fetch(`${GO2RTC_URL}/api/streams`);
|
id: sw.id, device: sw.device, state: sw.state, hasFrame: !!sw.latest,
|
||||||
const streams = r.ok ? await r.json() : {};
|
})),
|
||||||
res.json({ status: r.ok ? 'ok' : 'degraded', cameras: Object.keys(streams) });
|
});
|
||||||
} catch (err) {
|
|
||||||
res.status(503).json({ status: 'down', error: err.message });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/config.json', (_req, res) => {
|
app.get('/config.json', (_req, res) => {
|
||||||
res.json({ go2rtcPort: GO2RTC_PORT });
|
res.json({ cameras: Object.keys(switches) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 2. HTTP-Proxy zu go2rtc ───────────────────────────────────────────────────
|
// ── 2. Statische Dateien ──────────────────────────────────────────────────────
|
||||||
const go2rtcProxy = createProxyMiddleware({
|
// no-cache: Browser MUSS index.html/viewer.js vor Nutzung revalidieren.
|
||||||
target: GO2RTC_URL,
|
|
||||||
changeOrigin: true,
|
|
||||||
pathFilter: ['/api', '/video-rtc.js', '/video-stream.js'],
|
|
||||||
logger: console,
|
|
||||||
on: {
|
|
||||||
error: (err, _req, res) => {
|
|
||||||
console.error('[HPM] proxy error:', err.message);
|
|
||||||
if (!res.headersSent) res.status(502).json({ error: 'go2rtc nicht erreichbar' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
app.use(go2rtcProxy);
|
|
||||||
|
|
||||||
// ── 3. Statische Dateien ──────────────────────────────────────────────────────
|
|
||||||
// no-cache: Browser MUSS viewer.js/index.html vor Nutzung revalidieren. Verhindert,
|
|
||||||
// dass eine alte gecachte viewer.js (z.B. mit WebRTC-Modus) weiterläuft → sonst
|
|
||||||
// transcodiert go2rtc nach H.264 = ~108% CPU statt ~50% (MJPEG).
|
|
||||||
app.use(express.static(path.join(__dirname, 'public'), {
|
app.use(express.static(path.join(__dirname, 'public'), {
|
||||||
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
|
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── 4. go2rtc Stream-Monitor (server-seitiges Logging) ───────────────────────
|
// ── 3. Start ──────────────────────────────────────────────────────────────────
|
||||||
// Pollt alle 5 s go2rtc /api/streams und loggt Änderungen.
|
|
||||||
// Sichtbar im Portainer-Log von AppRobotWebcam.
|
|
||||||
// Logt: Producer-Starts/-Stops, Consumer-Anzahl, Timeouts/Restarts.
|
|
||||||
//
|
|
||||||
// go2rtc /api/streams liefert z.B.:
|
|
||||||
// { "cam0": { "producers": [{"url":"...","state":"running"}], "consumers": [...] } }
|
|
||||||
//
|
|
||||||
const STREAM_POLL_MS = 5000;
|
|
||||||
let prevStreamState = {};
|
|
||||||
|
|
||||||
async function pollGo2rtcStreams() {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${GO2RTC_URL}/api/streams`);
|
|
||||||
if (!r.ok) { console.warn(`[monitor] /api/streams → HTTP ${r.status}`); return; }
|
|
||||||
const streams = await r.json();
|
|
||||||
|
|
||||||
for (const [name, data] of Object.entries(streams)) {
|
|
||||||
const producers = data.producers ?? [];
|
|
||||||
const consumers = data.consumers ?? [];
|
|
||||||
const nConsumers = consumers.length;
|
|
||||||
const prev = prevStreamState[name] ?? {};
|
|
||||||
|
|
||||||
// Producer-Status
|
|
||||||
for (let i = 0; i < producers.length; i++) {
|
|
||||||
const p = producers[i];
|
|
||||||
const state = p.state ?? 'unknown';
|
|
||||||
const key = `${name}.p${i}`;
|
|
||||||
const pPrev = prevStreamState[key];
|
|
||||||
|
|
||||||
if (pPrev !== state) {
|
|
||||||
if (state === 'running') console.log(`[monitor][${name}] producer #${i} LÄUFT (${p.url ?? ''})`);
|
|
||||||
if (state === 'error') console.error(`[monitor][${name}] producer #${i} FEHLER (${p.url ?? ''})`);
|
|
||||||
if (state === 'stop') console.warn(`[monitor][${name}] producer #${i} GESTOPPT`);
|
|
||||||
if (!['running','error','stop'].includes(state))
|
|
||||||
console.log(`[monitor][${name}] producer #${i} state="${state}"`);
|
|
||||||
prevStreamState[key] = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consumer-Anzahl — nur loggen wenn sie sich ändert
|
|
||||||
if (prev.nConsumers !== nConsumers) {
|
|
||||||
console.log(`[monitor][${name}] consumers: ${prev.nConsumers ?? '?'} → ${nConsumers}`);
|
|
||||||
prevStreamState[name] = { ...prev, nConsumers };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streams die verschwunden sind (Timeout/Restart)
|
|
||||||
for (const name of Object.keys(prevStreamState)) {
|
|
||||||
if (name.includes('.')) continue; // skip producer-state keys
|
|
||||||
if (!streams[name]) {
|
|
||||||
console.warn(`[monitor][${name}] Stream verschwunden aus go2rtc`);
|
|
||||||
delete prevStreamState[name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[monitor] go2rtc nicht erreichbar:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
|
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
|
||||||
console.log(` go2rtc HTTP: ${GO2RTC_URL}`);
|
console.log(` Kameras: ${Object.entries(switches).map(([id, sw]) => `${id}=${sw.device}`).join(', ')}`);
|
||||||
console.log(` go2rtc WS: ws://[host]:${GO2RTC_PORT} (Browser verbindet direkt)`);
|
console.log(` Live: ${LIVE_SIZE}@${LIVE_FPS} · HD-Grab: ${HIRES_SIZE}@${HIRES_FPS}`);
|
||||||
console.log(` Viewer: http://0.0.0.0:${PORT}/`);
|
console.log(` Viewer: http://0.0.0.0:${PORT}/`);
|
||||||
console.log(` Snapshot API: http://0.0.0.0:${PORT}/api/snapshot/cam0`);
|
console.log(` Live-Stream: http://0.0.0.0:${PORT}/api/stream/cam0`);
|
||||||
console.log(` Stream-Monitor: alle ${STREAM_POLL_MS / 1000}s → Portainer-Log`);
|
console.log(` Snapshot API: http://0.0.0.0:${PORT}/api/snapshot/cam0 (+ /hires)`);
|
||||||
|
|
||||||
// Ersten Poll nach 3 s (go2rtc braucht einen Moment zum Starten)
|
// Live-Producer starten (Dauerbetrieb)
|
||||||
setTimeout(() => {
|
Object.values(switches).forEach((sw) => sw.start());
|
||||||
pollGo2rtcStreams();
|
|
||||||
setInterval(pollGo2rtcStreams, STREAM_POLL_MS);
|
|
||||||
}, 3000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = (sig) => {
|
const shutdown = (sig) => {
|
||||||
console.log(`\n${sig} – shutting down`);
|
console.log(`\n${sig} – shutting down`);
|
||||||
|
Object.values(switches).forEach((sw) => { sw.stopping = true; if (sw.proc) { try { sw.proc.kill('SIGKILL'); } catch (_e) {} } });
|
||||||
server.close(() => process.exit(0));
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 3000); // Sicherheitsnetz
|
||||||
};
|
};
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
|||||||
259
src/cameraSwitch.js
Normal file
259
src/cameraSwitch.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Liest Bildbreite aus JPEG-Header (SOF0/SOF2-Marker) ohne externe Bibliothek.
|
||||||
|
// Gibt null zurück wenn der Marker nicht gefunden wird.
|
||||||
|
function readJpegWidth(buf) {
|
||||||
|
let i = 2; // SOI (FF D8) überspringen
|
||||||
|
while (i < buf.length - 8) {
|
||||||
|
if (buf[i] !== 0xFF) break;
|
||||||
|
const marker = buf[i + 1];
|
||||||
|
const segLen = buf.readUInt16BE(i + 2);
|
||||||
|
if (marker === 0xC0 || marker === 0xC2) {
|
||||||
|
return buf.readUInt16BE(i + 7); // Breite bei Offset +7 im SOF
|
||||||
|
}
|
||||||
|
i += 2 + segLen;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parser für FFmpeg `-f mpjpeg` ────────────────────────────────────────────
|
||||||
|
// FFmpeg schreibt pro Frame: --<boundary>\r\nContent-Type: image/jpeg\r\n
|
||||||
|
// Content-Length: <n>\r\n\r\n<n bytes JPEG>\r\n
|
||||||
|
// Wir keyen auf Content-Length → deterministisch, unabhängig vom Boundary-String.
|
||||||
|
class MpjpegParser {
|
||||||
|
constructor(onFrame) {
|
||||||
|
this.onFrame = onFrame;
|
||||||
|
this.buf = Buffer.alloc(0);
|
||||||
|
this.need = -1; // -1 = Header-Modus, sonst erwartete Body-Bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
push(chunk) {
|
||||||
|
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
|
||||||
|
for (;;) {
|
||||||
|
if (this.need < 0) {
|
||||||
|
const headEnd = this.buf.indexOf('\r\n\r\n');
|
||||||
|
if (headEnd < 0) {
|
||||||
|
// Schutz gegen unbegrenztes Puffern bei unerwartetem Müll
|
||||||
|
if (this.buf.length > (1 << 20)) this.buf = this.buf.subarray(this.buf.length - 4096);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const header = this.buf.toString('latin1', 0, headEnd);
|
||||||
|
const m = /content-length:\s*(\d+)/i.exec(header);
|
||||||
|
if (!m) { this.buf = this.buf.subarray(headEnd + 4); continue; }
|
||||||
|
this.need = parseInt(m[1], 10);
|
||||||
|
this.buf = this.buf.subarray(headEnd + 4);
|
||||||
|
}
|
||||||
|
if (this.buf.length < this.need) return;
|
||||||
|
const frame = this.buf.subarray(0, this.need);
|
||||||
|
this.buf = this.buf.subarray(this.need);
|
||||||
|
this.need = -1;
|
||||||
|
try { this.onFrame(frame); } catch (_e) { /* Consumer-Fehler ignorieren */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CameraSwitch ─────────────────────────────────────────────────────────────
|
||||||
|
// Eine Instanz pro physischem Gerät. Der EINZIGE Öffner von /dev/videoN.
|
||||||
|
// Hält IMMER nur EINEN FFmpeg-Prozess: entweder Live (640) oder HD-Grab (1280) —
|
||||||
|
// NIE beide. Der Übergang wird über das `close`-Event des FFmpeg-Kindprozesses
|
||||||
|
// synchronisiert: Prozess weg ⇒ Kernel hat den Device-FD geschlossen ⇒ Gerät frei.
|
||||||
|
// Genau dieses Signal verweigert go2rtcs API — deshalb der Eigenbau.
|
||||||
|
//
|
||||||
|
// Events: 'frame' (Buffer) – je ein Live-JPEG
|
||||||
|
class CameraSwitch extends EventEmitter {
|
||||||
|
constructor({ id, device, liveSize = '640x480', liveFps = 30, hiresSize = '1280x960', hiresFps = 15 }) {
|
||||||
|
super();
|
||||||
|
this.setMaxListeners(0); // beliebig viele Stream-Clients
|
||||||
|
this.id = id;
|
||||||
|
this.device = device;
|
||||||
|
this.liveSize = liveSize;
|
||||||
|
this.liveFps = liveFps;
|
||||||
|
this.hiresSize = hiresSize;
|
||||||
|
this.hiresFps = hiresFps;
|
||||||
|
|
||||||
|
this.proc = null; // aktueller FFmpeg-Prozess (Live ODER Grab)
|
||||||
|
this.latest = null; // letztes Live-JPEG (für /api/snapshot)
|
||||||
|
this.state = 'stopped'; // stopped | live | grabbing
|
||||||
|
this.lock = false; // Mutex: nur ein Grab gleichzeitig
|
||||||
|
this.stopping = false; // unterscheidet absichtliches Kill von Crash
|
||||||
|
this.restartTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.state === 'stopped' && !this.proc) this._spawnLive();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live-Producer (Dauerbetrieb, Auto-Restart bei Crash) ───────────────────
|
||||||
|
_spawnLive() {
|
||||||
|
this.stopping = false;
|
||||||
|
const args = [
|
||||||
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
|
'-f', 'v4l2', '-input_format', 'mjpeg',
|
||||||
|
'-video_size', this.liveSize, '-framerate', String(this.liveFps),
|
||||||
|
'-i', this.device,
|
||||||
|
'-c:v', 'copy', '-f', 'mpjpeg', 'pipe:1',
|
||||||
|
];
|
||||||
|
let p;
|
||||||
|
try {
|
||||||
|
p = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[cam ${this.id}] spawn fehlgeschlagen: ${e.message} → Retry in 1.5s`);
|
||||||
|
this._scheduleRestart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.proc = p;
|
||||||
|
this.state = 'live';
|
||||||
|
|
||||||
|
const parser = new MpjpegParser((frame) => {
|
||||||
|
this.latest = frame;
|
||||||
|
this.emit('frame', frame);
|
||||||
|
});
|
||||||
|
p.stdout.on('data', (c) => parser.push(c));
|
||||||
|
p.stderr.on('data', (c) => {
|
||||||
|
const s = c.toString();
|
||||||
|
if (/error|busy|invalid|no such|cannot|denied/i.test(s)) console.error(`[cam ${this.id}] ffmpeg: ${s.trim()}`);
|
||||||
|
});
|
||||||
|
p.on('error', (e) => console.error(`[cam ${this.id}] live ffmpeg error: ${e.message}`));
|
||||||
|
p.on('close', (code, sig) => {
|
||||||
|
this.proc = null;
|
||||||
|
const wasStopping = this.stopping;
|
||||||
|
if (this.state === 'live') this.state = 'stopped';
|
||||||
|
if (wasStopping) return; // beabsichtigt (HD-Grab) → grabHires startet Live neu
|
||||||
|
console.warn(`[cam ${this.id}] live ffmpeg unerwartet beendet (code=${code} sig=${sig}) → Restart`);
|
||||||
|
this._scheduleRestart();
|
||||||
|
});
|
||||||
|
console.log(`[cam ${this.id}] live gestartet (${this.liveSize}@${this.liveFps}, ${this.device})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleRestart() {
|
||||||
|
if (this.restartTimer) return;
|
||||||
|
this.restartTimer = setTimeout(() => {
|
||||||
|
this.restartTimer = null;
|
||||||
|
if (this.state === 'stopped' && !this.lock) this._spawnLive();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HD-Grab: Live sauber stoppen → 1280 greifen → Live zurück ──────────────
|
||||||
|
// Garantie: zwischen Stop und 1280-Start liegt das `close`-Event des Live-
|
||||||
|
// FFmpeg → /dev/videoN ist frei. Niemals zwei Encoder gleichzeitig.
|
||||||
|
async grabHires(opts = {}) {
|
||||||
|
const { minSize = 15000, minWidth = 1000, settleFrames = 6, maxWaitMs = 6000 } = opts;
|
||||||
|
if (this.lock) throw new Error('HD-Grab läuft bereits');
|
||||||
|
this.lock = true;
|
||||||
|
const t0 = Date.now();
|
||||||
|
if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Live-FFmpeg beenden, auf Prozess-Ende warten (= Device-FD frei)
|
||||||
|
await this._killCurrentAndWait();
|
||||||
|
this.state = 'grabbing';
|
||||||
|
console.log(`[cam ${this.id}] HD: Live gestoppt nach ${Date.now() - t0}ms, Gerät frei → 1280-Grab`);
|
||||||
|
|
||||||
|
// 2. 1280-FFmpeg starten, warmlaufen lassen, besten Frame greifen
|
||||||
|
const jpeg = await this._captureHires({ minSize, minWidth, settleFrames, maxWaitMs });
|
||||||
|
console.log(`[cam ${this.id}] HD OK – ${jpeg.length} bytes, Breite=${readJpegWidth(jpeg) ?? '?'} (${Date.now() - t0}ms)`);
|
||||||
|
return jpeg;
|
||||||
|
} finally {
|
||||||
|
// 3. IMMER zurück auf Live (auch bei Fehler) – Live hat Priorität
|
||||||
|
this.state = 'stopped';
|
||||||
|
this._spawnLive();
|
||||||
|
this.lock = false;
|
||||||
|
console.log(`[cam ${this.id}] HD beendet, Live zurück (gesamt ${Date.now() - t0}ms)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beendet den aktuellen Prozess und resolved erst nach dessen 'close' (FD frei).
|
||||||
|
_killCurrentAndWait(timeoutMs = 4000) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const p = this.proc;
|
||||||
|
if (!p) return resolve();
|
||||||
|
this.stopping = true;
|
||||||
|
let done = false;
|
||||||
|
const fin = () => { if (!done) { done = true; resolve(); } };
|
||||||
|
p.once('close', fin);
|
||||||
|
try { p.kill('SIGTERM'); } catch (_e) { /* schon weg */ }
|
||||||
|
setTimeout(() => { if (!done) { try { p.kill('SIGKILL'); } catch (_e) {} } }, Math.max(500, timeoutMs - 1000));
|
||||||
|
setTimeout(fin, timeoutMs); // Sicherheitsnetz
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_captureHires({ minSize, minWidth, settleFrames, maxWaitMs }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
|
'-f', 'v4l2', '-input_format', 'mjpeg',
|
||||||
|
'-video_size', this.hiresSize, '-framerate', String(this.hiresFps),
|
||||||
|
'-i', this.device,
|
||||||
|
'-c:v', 'copy', '-f', 'mpjpeg', 'pipe:1',
|
||||||
|
];
|
||||||
|
let p;
|
||||||
|
try {
|
||||||
|
p = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
} catch (e) { return reject(e); }
|
||||||
|
this.proc = p;
|
||||||
|
this.stopping = false;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let best = null;
|
||||||
|
let decided = false;
|
||||||
|
let closed = false;
|
||||||
|
let finalized = false;
|
||||||
|
let storedResult = null;
|
||||||
|
let storedErr = null;
|
||||||
|
let timer = setTimeout(() => decide(best, best ? null : new Error('HD-Timeout')), maxWaitMs);
|
||||||
|
let hardFin = null;
|
||||||
|
|
||||||
|
function finalize(self) {
|
||||||
|
if (finalized) return;
|
||||||
|
finalized = true;
|
||||||
|
if (hardFin) clearTimeout(hardFin);
|
||||||
|
self.proc = null;
|
||||||
|
if (storedResult) resolve(storedResult);
|
||||||
|
else reject(storedErr || new Error('kein HD-Frame'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
function decide(result, err) {
|
||||||
|
if (decided) return;
|
||||||
|
decided = true;
|
||||||
|
storedResult = result;
|
||||||
|
storedErr = err;
|
||||||
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
|
if (closed) { finalize(self); return; }
|
||||||
|
// Prozess beenden; finalize() erst nach 'close' (= FD frei)
|
||||||
|
self.stopping = true;
|
||||||
|
try { p.kill('SIGTERM'); } catch (_e) {}
|
||||||
|
setTimeout(() => { if (!closed) { try { p.kill('SIGKILL'); } catch (_e) {} } }, 800);
|
||||||
|
hardFin = setTimeout(() => finalize(self), 2500); // falls 'close' ausbleibt
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new MpjpegParser((frame) => {
|
||||||
|
count++;
|
||||||
|
if (!best || frame.length > best.length) best = frame;
|
||||||
|
const w = readJpegWidth(frame);
|
||||||
|
if (count >= settleFrames && frame.length >= minSize && (w === null || w >= minWidth)) {
|
||||||
|
decide(Buffer.from(frame), null); // kopieren: subarray teilt den Parser-Puffer
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
p.stdout.on('data', (c) => parser.push(c));
|
||||||
|
p.stderr.on('data', (c) => {
|
||||||
|
const s = c.toString();
|
||||||
|
if (/error|busy|invalid|no such|cannot|denied/i.test(s)) console.error(`[cam ${this.id}] hires ffmpeg: ${s.trim()}`);
|
||||||
|
});
|
||||||
|
p.on('error', (e) => { if (!decided) decide(null, e); });
|
||||||
|
p.on('close', () => {
|
||||||
|
closed = true;
|
||||||
|
if (decided) finalize(self);
|
||||||
|
else decide(best ? Buffer.from(best) : null, best ? null : new Error('HD-FFmpeg vorzeitig beendet'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CameraSwitch, MpjpegParser, readJpegWidth };
|
||||||
@@ -1,253 +1,108 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { readJpegWidth } = require('./cameraSwitch');
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
// Stabile Schnittstellen für Viewer und Homing-Projekt – lesen NUR aus den
|
||||||
|
// CameraSwitch-Instanzen (RAM-Puffer + Event-Stream). Kein Gerätezugriff hier,
|
||||||
// Liest Bildbreite aus JPEG-Header (SOF0/SOF2-Marker) ohne externe Bibliothek.
|
// keine go2rtc-Abhängigkeit mehr.
|
||||||
// Gibt null zurück wenn der Marker nicht gefunden wird.
|
|
||||||
function readJpegWidth(buf) {
|
|
||||||
let i = 2; // SOI (FF D8) überspringen
|
|
||||||
while (i < buf.length - 8) {
|
|
||||||
if (buf[i] !== 0xFF) break;
|
|
||||||
const marker = buf[i + 1];
|
|
||||||
const segLen = buf.readUInt16BE(i + 2);
|
|
||||||
if (marker === 0xC0 || marker === 0xC2) {
|
|
||||||
return buf.readUInt16BE(i + 7); // Breite bei Offset +7 im SOF
|
|
||||||
}
|
|
||||||
i += 2 + segLen;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stabile Snapshot-Schnittstelle für das Homing-Projekt.
|
|
||||||
// Entkoppelt den Consumer von go2rtc-Interna – proxied intern auf /api/frame.jpeg.
|
|
||||||
//
|
//
|
||||||
// GET /api/snapshot → JSON-Liste der Kameras
|
// GET /api/snapshot → JSON-Liste der Kameras
|
||||||
// GET /api/snapshot/cam0 → 640er JPEG (live)
|
// GET /api/snapshot/cam0 → letztes Live-JPEG (640) aus dem Puffer
|
||||||
// GET /api/snapshot/cam0/hires → 1280×960 JPEG via cam0_hires (Phase 2)
|
// GET /api/snapshot/cam0/hires → HD-JPEG (1280): Schalter pausiert Live kurz
|
||||||
function createSnapshotRouter(go2rtcUrl) {
|
// GET /api/stream/cam0 → MJPEG multipart/x-mixed-replace (Live)
|
||||||
|
|
||||||
|
function createSnapshotRouter(switches) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const hiresLocks = {}; // Mutex pro Kamera: { cam0: false, cam1: false, … }
|
|
||||||
|
|
||||||
// ── PHASE 2: Hi-Res-Grab via cam0_hires (rein LESEND gegenüber cam0/cam1) ────
|
router.get('/', (_req, res) => {
|
||||||
// Voraussetzung: Client hat seinen <video-stream> bereits entfernt (Umhängen),
|
res.json({
|
||||||
// BEVOR er diesen Endpunkt ruft. cam0/cam1 werden NICHT verändert.
|
cameras: Object.keys(switches).map((id) => ({ id, url: `/api/snapshot/${id}` })),
|
||||||
// cam{id}_hires muss in der go2rtc-Config definiert sein (docker-compose.yaml).
|
});
|
||||||
//
|
|
||||||
// Ablauf: Warten bis id 0 Consumer hat → cam_hires-Frame per frame.jpeg holen.
|
|
||||||
// Eiserne Regeln (04_Delay_roadmap.md): nur GET, kein PUT/PATCH/DELETE. ✓
|
|
||||||
router.get('/:id/hires', async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const hiresId = `${id}_hires`;
|
|
||||||
|
|
||||||
if (hiresLocks[id]) {
|
|
||||||
return res.status(429).json({ error: `Hi-Res-Grab für ${id} läuft bereits – bitte warten` });
|
|
||||||
}
|
|
||||||
hiresLocks[id] = true;
|
|
||||||
const t0 = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Schritt 1: Warten bis id keine Consumer mehr hat (Gerät frei, max 8 s)
|
|
||||||
const POLL_MS = 200;
|
|
||||||
const MAX_WAIT = 8000;
|
|
||||||
const MIN_SIZE = 15000; // <15KB → Warmup-Schwarzbild, retry
|
|
||||||
let deviceFree = false;
|
|
||||||
|
|
||||||
while (Date.now() - t0 < MAX_WAIT) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) });
|
|
||||||
if (r.ok) {
|
|
||||||
const streams = await r.json();
|
|
||||||
const s = streams[id];
|
|
||||||
const nC = s ? (s.consumers ?? []).length : 0;
|
|
||||||
const pRunning = s ? (s.producers ?? []).some(p => (p.state ?? '') === 'running') : false;
|
|
||||||
if (nC === 0 && !pRunning) {
|
|
||||||
deviceFree = true;
|
|
||||||
console.log(`[hires][${id}] Gerät frei nach ${Date.now() - t0}ms`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[hires][${id}] Poll fehlgeschlagen: ${e.message}`);
|
|
||||||
}
|
|
||||||
await sleep(POLL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!deviceFree) {
|
|
||||||
return res.status(503).json({
|
|
||||||
error: `Gerät nicht frei nach ${MAX_WAIT}ms – noch ${id}-Consumer aktiv?`,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Schritt 2: Frame greifen (cam_hires on-demand, mit Warmup-Retry)
|
// HD-Grab: delegiert an den Schalter. Der Schalter garantiert, dass Live
|
||||||
// go2rtc öffnet /dev/videoN bei der ersten Anfrage → erste Frames können
|
// sauber gestoppt ist (Prozess-close), bevor 1280 startet → kein Race.
|
||||||
// unterbelichtet sein → Größen-Check; Retry gibt Kamera Zeit zum Einschwingen.
|
router.get('/:id/hires', async (req, res) => {
|
||||||
const MAX_RETRIES = 4;
|
const sw = switches[req.params.id];
|
||||||
const RETRY_MS = 800;
|
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
|
||||||
let jpeg = null;
|
|
||||||
let lastWidth = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
||||||
try {
|
try {
|
||||||
const fr = await fetch(
|
const jpeg = await sw.grabHires();
|
||||||
`${go2rtcUrl}/api/frame.jpeg?src=${encodeURIComponent(hiresId)}`,
|
|
||||||
{ signal: AbortSignal.timeout(5000) }
|
|
||||||
);
|
|
||||||
if (fr.ok) {
|
|
||||||
const buf = Buffer.from(await fr.arrayBuffer());
|
|
||||||
const w = readJpegWidth(buf);
|
|
||||||
console.log(`[hires][${id}] Versuch ${attempt + 1}: ${buf.length} bytes, Breite=${w ?? '?'}`);
|
|
||||||
if (buf.length >= MIN_SIZE && (w === null || w >= 1000)) {
|
|
||||||
jpeg = buf;
|
|
||||||
lastWidth = w;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[hires][${id}] frame.jpeg Versuch ${attempt + 1}: ${e.message}`);
|
|
||||||
}
|
|
||||||
if (attempt < MAX_RETRIES - 1) await sleep(RETRY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jpeg) {
|
|
||||||
return res.status(503).json({ error: 'kein verwertbarer Hi-Res-Frame (Warmup-Timeout)' });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[hires][${id}] OK – ${jpeg.length} bytes, Breite=${lastWidth}, Dauer=${Date.now() - t0}ms`);
|
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': 'image/jpeg',
|
||||||
'Content-Length': jpeg.length,
|
'Content-Length': jpeg.length,
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'X-Camera-Id': id,
|
'X-Camera-Id': req.params.id,
|
||||||
'X-Hires-Id': hiresId,
|
'X-Frame-Width': String(readJpegWidth(jpeg) ?? ''),
|
||||||
'X-Frame-Width': String(lastWidth ?? ''),
|
|
||||||
'X-Timestamp': new Date().toISOString(),
|
'X-Timestamp': new Date().toISOString(),
|
||||||
});
|
});
|
||||||
res.end(jpeg);
|
res.end(jpeg);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!res.headersSent) res.status(503).json({ error: `hires: ${err.message}` });
|
res.status(503).json({ error: `hires: ${err.message}` });
|
||||||
} finally {
|
|
||||||
hiresLocks[id] = false;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 🔬 TEMPORÄR: Diagnose-Probe für den cam_hires-Teardown (Bug 106%) ─────────
|
router.get('/:id', (req, res) => {
|
||||||
// Misst REIN LESEND, wann go2rtc den cam_hires-Producer nach einem frame.jpeg
|
const sw = switches[req.params.id];
|
||||||
// wirklich abbaut. Beantwortet: Leert sich das producers-Array? Wann? Geht
|
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
|
||||||
// consumers sofort auf 0? Daraus wird der robuste Rückweg gebaut (Weg C).
|
const frame = sw.latest;
|
||||||
//
|
if (!frame) return res.status(503).json({ error: 'noch kein Frame verfügbar' });
|
||||||
// VORHER im Viewer die betreffende Kamera AUSschalten (⏸), damit cam frei ist
|
|
||||||
// (sonst zwei Encoder auf einem Device = genau der 106%-Konflikt).
|
|
||||||
// curl http://<host>:8444/api/snapshot/cam0/hires-probe
|
|
||||||
// Nach der Messung diese Route + doc-Eintrag wieder entfernen.
|
|
||||||
router.get('/:id/hires-probe', async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const hiresId = `${id}_hires`;
|
|
||||||
if (hiresLocks[id]) return res.status(429).json({ error: `${id} belegt` });
|
|
||||||
hiresLocks[id] = true;
|
|
||||||
const t0 = Date.now();
|
|
||||||
|
|
||||||
const snapHires = (streams) => {
|
|
||||||
const s = streams[hiresId];
|
|
||||||
const prods = s ? (s.producers ?? []) : [];
|
|
||||||
return {
|
|
||||||
cons: s ? (s.consumers ?? []).length : 0,
|
|
||||||
prods: prods.length,
|
|
||||||
states: prods.map(p => p.state ?? '?').join(',') || '-',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Schritt 1: warten bis cam frei (max 8s) – sonst messen wir den Konflikt mit
|
|
||||||
while (Date.now() - t0 < 8000) {
|
|
||||||
const r = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) }).catch(() => null);
|
|
||||||
if (r && r.ok) {
|
|
||||||
const s = (await r.json())[id];
|
|
||||||
const nC = s ? (s.consumers ?? []).length : 0;
|
|
||||||
const pR = s ? (s.producers ?? []).some(p => (p.state ?? '') === 'running') : false;
|
|
||||||
if (nC === 0 && !pR) break;
|
|
||||||
}
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schritt 2: einen cam_hires-Frame holen (startet den Producer)
|
|
||||||
const fr = await fetch(`${go2rtcUrl}/api/frame.jpeg?src=${encodeURIComponent(hiresId)}`,
|
|
||||||
{ signal: AbortSignal.timeout(6000) });
|
|
||||||
const buf = fr.ok ? Buffer.from(await fr.arrayBuffer()) : null;
|
|
||||||
const tFrame = Date.now();
|
|
||||||
const frameBytes = buf ? buf.length : 0;
|
|
||||||
const frameWidth = buf ? readJpegWidth(buf) : null;
|
|
||||||
console.log(`[probe][${id}] frame: ${frameBytes} bytes, Breite=${frameWidth ?? '?'} → poll teardown…`);
|
|
||||||
|
|
||||||
// Schritt 3: 12s lang alle 100ms den cam_hires-Zustand mitschreiben
|
|
||||||
const timeline = [];
|
|
||||||
let producerGoneAtMs = null;
|
|
||||||
let consumersZeroAtMs = null;
|
|
||||||
while (Date.now() - tFrame < 12000) {
|
|
||||||
const r = await fetch(`${go2rtcUrl}/api/streams`, { signal: AbortSignal.timeout(1000) }).catch(() => null);
|
|
||||||
const t = Date.now() - tFrame;
|
|
||||||
if (r && r.ok) {
|
|
||||||
const snap = snapHires(await r.json());
|
|
||||||
timeline.push({ t, ...snap });
|
|
||||||
if (producerGoneAtMs === null && snap.prods === 0) producerGoneAtMs = t;
|
|
||||||
if (consumersZeroAtMs === null && snap.cons === 0) consumersZeroAtMs = t;
|
|
||||||
} else {
|
|
||||||
timeline.push({ t, err: true });
|
|
||||||
}
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[probe][${id}] producerGoneAtMs=${producerGoneAtMs} consumersZeroAtMs=${consumersZeroAtMs}`);
|
|
||||||
console.log(`[probe][${id}] timeline:`, JSON.stringify(timeline));
|
|
||||||
res.json({ hiresId, frameBytes, frameWidth, producerGoneAtMs, consumersZeroAtMs, timeline });
|
|
||||||
} catch (err) {
|
|
||||||
if (!res.headersSent) res.status(503).json({ error: `probe: ${err.message}` });
|
|
||||||
} finally {
|
|
||||||
hiresLocks[id] = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/', async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${go2rtcUrl}/api/streams`);
|
|
||||||
if (!r.ok) throw new Error(`go2rtc HTTP ${r.status}`);
|
|
||||||
const streams = await r.json();
|
|
||||||
res.json({
|
|
||||||
cameras: Object.keys(streams)
|
|
||||||
.filter(id => !id.endsWith('_hires'))
|
|
||||||
.map(id => ({ id, url: `/api/snapshot/${id}` })),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
try {
|
|
||||||
const upstream = await fetch(
|
|
||||||
`${go2rtcUrl}/api/frame.jpeg?src=${encodeURIComponent(id)}`
|
|
||||||
);
|
|
||||||
if (!upstream.ok) {
|
|
||||||
return res.status(upstream.status).json({ error: `kein Frame (${id})` });
|
|
||||||
}
|
|
||||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': 'image/jpeg',
|
||||||
'Content-Length': buf.length,
|
'Content-Length': frame.length,
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'X-Camera-Id': id,
|
'X-Camera-Id': req.params.id,
|
||||||
'X-Timestamp': new Date().toISOString(),
|
'X-Timestamp': new Date().toISOString(),
|
||||||
});
|
});
|
||||||
res.end(buf);
|
res.end(frame);
|
||||||
} catch (err) {
|
|
||||||
res.status(503).json({ error: `go2rtc: ${err.message}` });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSnapshotRouter };
|
// MJPEG-Live-Stream als multipart/x-mixed-replace. Ein FFmpeg (im Schalter) →
|
||||||
|
// Fan-out an beliebig viele Browser. Browser rendert das nativ im <img>.
|
||||||
|
function createStreamRouter(switches) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
const sw = switches[req.params.id];
|
||||||
|
if (!sw) return res.status(404).end();
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'multipart/x-mixed-replace; boundary=frame',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Connection': 'close',
|
||||||
|
'X-Camera-Id': req.params.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
const cleanup = () => { if (!closed) { closed = true; sw.removeListener('frame', onFrame); } };
|
||||||
|
|
||||||
|
const onFrame = (buf) => {
|
||||||
|
if (closed) return;
|
||||||
|
// Backpressure: langsamer Client bremst die anderen nicht – Frames droppen
|
||||||
|
if (res.writableLength > (1 << 20)) return;
|
||||||
|
// try/catch: ein kaputter Client darf die anderen nicht aushungern
|
||||||
|
// (ein werfender 'frame'-Listener würde sonst emit() abbrechen)
|
||||||
|
try {
|
||||||
|
res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${buf.length}\r\n\r\n`);
|
||||||
|
res.write(buf);
|
||||||
|
res.write('\r\n');
|
||||||
|
} catch (_e) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sw.on('frame', onFrame);
|
||||||
|
if (sw.latest) onFrame(sw.latest); // sofort erstes Bild
|
||||||
|
|
||||||
|
req.on('close', cleanup);
|
||||||
|
res.on('error', cleanup);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSnapshotRouter, createStreamRouter };
|
||||||
|
|||||||
Reference in New Issue
Block a user