Umbau mit cameraSwitch

This commit is contained in:
chk
2026-06-05 06:36:48 +02:00
parent 0ea475d6b6
commit 8c8c769e22
10 changed files with 641 additions and 839 deletions

View File

@@ -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

View File

@@ -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 ~13 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).

View File

@@ -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).

View File

@@ -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"
# ──────────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────────

View File

@@ -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"

View File

@@ -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>

View File

@@ -1,404 +1,119 @@
'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 (~12 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; cam.active = true;
const wsUrl = `ws://${location.hostname}:${GO2RTC_PORT}/api/ws?src=${encodeURIComponent(cam.id)}`; // Cache-Buster erzwingt eine frische Verbindung (sonst hängt Reconnect manchmal)
log(cam.id, `Verbinde → ${wsUrl}`); cam.img.src = `/api/stream/${encodeURIComponent(cam.id)}?t=${Date.now()}`;
cam.active = true;
cam.startedAt = performance.now();
cam.playingSince = null; // wird erst beim 'playing'-Event gesetzt
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`;
document.body.appendChild(a); document.body.appendChild(a);
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,34 +146,17 @@ function buildCamera(camId, container) {
startStream(cam); startStream(cam);
} }
// ── Init ───────────────────────────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────────────────────────
async function init() { async function init() {
log('init', 'Starte...'); log('init', 'Starte...');
const container = document.getElementById('cameras');
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 statusText = document.getElementById('statusText'); const statusText = document.getElementById('statusText');
let camIds = []; let camIds = [];
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();

151
server.js
View File

@@ -1,140 +1,71 @@
'use strict'; 'use strict';
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
View 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 };

View File

@@ -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. ✓ // HD-Grab: delegiert an den Schalter. Der Schalter garantiert, dass Live
// sauber gestoppt ist (Prozess-close), bevor 1280 startet → kein Race.
router.get('/:id/hires', async (req, res) => { router.get('/:id/hires', async (req, res) => {
const { id } = req.params; const sw = switches[req.params.id];
const hiresId = `${id}_hires`; if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
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 { try {
// Schritt 1: Warten bis id keine Consumer mehr hat (Gerät frei, max 8 s) const jpeg = await sw.grabHires();
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)
// go2rtc öffnet /dev/videoN bei der ersten Anfrage → erste Frames können
// unterbelichtet sein → Größen-Check; Retry gibt Kamera Zeit zum Einschwingen.
const MAX_RETRIES = 4;
const RETRY_MS = 800;
let jpeg = null;
let lastWidth = null;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const fr = await fetch(
`${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 res.set({
// (sonst zwei Encoder auf einem Device = genau der 106%-Konflikt). 'Content-Type': 'image/jpeg',
// curl http://<host>:8444/api/snapshot/cam0/hires-probe 'Content-Length': frame.length,
// Nach der Messung diese Route + doc-Eintrag wieder entfernen. 'Cache-Control': 'no-store',
router.get('/:id/hires-probe', async (req, res) => { 'X-Camera-Id': req.params.id,
const { id } = req.params; 'X-Timestamp': new Date().toISOString(),
const hiresId = `${id}_hires`; });
if (hiresLocks[id]) return res.status(429).json({ error: `${id} belegt` }); res.end(frame);
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({
'Content-Type': 'image/jpeg',
'Content-Length': buf.length,
'Cache-Control': 'no-store',
'X-Camera-Id': id,
'X-Timestamp': new Date().toISOString(),
});
res.end(buf);
} 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 };