Claude: WebSocket
This commit is contained in:
@@ -132,6 +132,16 @@ go2rtc's `#hardware` ist für Re-Encoding von RTSP-H.264-Streams gebaut,
|
||||
**nicht** für MJPEG-Kamera-Input. Ohne eigenen FFmpeg-Befehl (den go2rtc nicht
|
||||
erlaubt) ist Hardware-Encoding für diesen Use-Case nicht erreichbar.
|
||||
|
||||
**Neue Hardware kaufen?**
|
||||
Nicht empfohlen — und keine Garantie möglich:
|
||||
- `renderD128` (Intel iGPU) ist bereits vorhanden und VAAPI-fähig. Das Problem liegt in
|
||||
go2rtc's Architektur, nicht in der Hardware. Bessere GPU würde nichts ändern.
|
||||
- Eine **Kamera mit nativem H.264-Output** (z.B. Logitech C920) würde das Encoding-
|
||||
Problem für den Live-Stream lösen — aber nicht das Hi-Res-Snapshot-Problem (Kamera
|
||||
bleibt bei einer Auflösung locked). Kein Mehrwert für diesen Use-Case.
|
||||
- **Empfehlung:** Kein Hardware-Kauf. MJPEG-Passthrough läuft stabil bei <5% CPU.
|
||||
Für H.264 (130 ms statt 200 ms) → MediaMTX-Weg (s.u.), keine neue Hardware nötig.
|
||||
|
||||
### Entscheid: MJPEG-Passthrough ✓ (umgesetzt)
|
||||
|
||||
```yaml
|
||||
@@ -151,6 +161,47 @@ Kamera liefert MJPEG nativ → go2rtc reicht es 1:1 durch → kein Encoding →
|
||||
70ms mehr Latenz ist für Roboter-Überwachung vertretbar.
|
||||
Snapshots haben native JPEG-Qualität (kein H.264-Artefakte).
|
||||
|
||||
---
|
||||
|
||||
## ⚠ KORREKTUR (2026-06-04): Passthrough war nie aktiv
|
||||
|
||||
Obiger Entscheid war **konfiguriert, aber nicht wirksam.** Quelle und Auslieferung
|
||||
sind zwei verschiedene Dinge — und nur die Quelle wurde umgestellt.
|
||||
|
||||
| | konfiguriert | tatsächlich geliefert |
|
||||
|-|-------------|----------------------|
|
||||
| go2rtc-Quelle | `#video=mjpeg` ✓ | MJPEG |
|
||||
| Viewer `viewer.js` | `MODE = 'webrtc,mse,mjpeg'` | **Browser zog WebRTC** |
|
||||
|
||||
**WebRTC und MSE können kein MJPEG transportieren** — die einzigen WebRTC-Video-Codecs
|
||||
sind H.264/VP8/VP9/AV1. Sobald der Browser WebRTC zog, **transcodierte go2rtc das
|
||||
Kamera-MJPEG nach H.264 in Software (libx264)** — ein Encoder pro Kamera.
|
||||
|
||||
**Beweis aus der Messung:** CPU skalierte 2× mit der Client-Zahl (53% → 127% bei
|
||||
2 Clients). Passthrough ist clientzahl-unabhängig ~0% — nur Transcoding skaliert so.
|
||||
|
||||
Das erklärt rückwirkend **alles**:
|
||||
- Hohe CPU trotz „MJPEG-Passthrough"-Config → es war nie Passthrough.
|
||||
- Auflösung war nie die Ursache — der libx264-Encoder war es (egal bei welcher Auflösung).
|
||||
- Freezes nur mit WebRTC, nie mit MJPEG → H.264-Keyframe-Abhängigkeit (`-g 50` =
|
||||
bis 1,67s Standbild nach Loss). MJPEG-Frames sind unabhängig → ein Loss = ein
|
||||
einzelner Ruckler, nie ein mehrsekündiges Standbild.
|
||||
|
||||
### Echter Fix (umgesetzt)
|
||||
|
||||
Die **Auslieferung** im Viewer auf MJPEG zwingen: `MODE = 'mjpeg'` in `public/viewer.js`.
|
||||
Damit ist die Kette durchgängig MJPEG: **Kamera → go2rtc (copy) → Browser.** Kein Encoder.
|
||||
|
||||
```
|
||||
CPU ~0% · keine Freezes · ~200ms Latenz · skaliert auf mehr Kameras
|
||||
```
|
||||
|
||||
go2rtc-Quelle bleibt 640×480 `#video=mjpeg`. **Hardware-Encoding ist damit
|
||||
gegenstandslos** — es wird gar nicht mehr encodiert. Der ganze VAAPI-Strang unten
|
||||
ist nur noch relevant, falls später doch WebRTC-Latenz (~130ms) zwingend gebraucht wird.
|
||||
|
||||
---
|
||||
|
||||
### Falls doch noch H.264 gewünscht (mit korrektem VAAPI)
|
||||
|
||||
Erfordert MediaMTX als Zwischenstufe:
|
||||
@@ -175,38 +226,150 @@ Aufwand: ~2h (zusätzlicher Container, RTSP-Verkabelung). Lohnt sich erst wenn
|
||||
|
||||
Eine USB-Kamera kann gleichzeitig nur **eine** Auflösung liefern.
|
||||
go2rtc hält die Kamera offen — Snapshot-Auflösung = Stream-Auflösung.
|
||||
`/api/snapshot/cam0` proxied go2rtc's `/api/frame.jpeg` → liefert immer Stream-Auflösung (640×480).
|
||||
|
||||
Versuch: `video_size=1280x960` im laufenden Stream → CPU sprang auf 112%.
|
||||
Ursache unklar (vermutlich MJPEG-Frames 4× grösser → mehr I/O-Last in go2rtc).
|
||||
**Zurückgesetzt auf stabilen Zustand: 640x480 @ 30fps, ~20% CPU.**
|
||||
**Wahrscheinliche Ursache:** Kamera unterstützt 1280×960 nicht als natives MJPEG →
|
||||
FFmpeg fällt auf YUYV zurück → Software-MJPEG-Encoding → CPU explodiert.
|
||||
(Nicht reines I/O-Problem, sondern fehlendes natives Format.)
|
||||
**Zurückgesetzt auf stabilen Zustand: 640×480 @ 30fps, ~20% CPU.**
|
||||
|
||||
### Drei Optionen (noch nicht umgesetzt)
|
||||
Zwingend vor jedem Auflösungstest:
|
||||
```bash
|
||||
v4l2-ctl --list-formats-ext -d /dev/video0 # prüft welche Auflösungen MJPEG-nativ sind
|
||||
v4l2-ctl --list-formats-ext -d /dev/video2
|
||||
```
|
||||
Nur wenn eine Auflösung dort unter "MJPEG" (nicht "YUYV") erscheint, bleibt CPU niedrig.
|
||||
|
||||
**Option 1 — Hi-Res-Stream + CSS-Skalierung im Browser**
|
||||
- Stream auf 1280x720 oder 1280x960 setzen
|
||||
- Browser zeigt 640x480 (CSS), Snapshot = volle Auflösung
|
||||
- Problem: CPU-Last beim Hochskalieren steigt (s.o.)
|
||||
- Lösung: erst `v4l2-ctl --list-formats-ext -d /dev/video0` prüfen welche
|
||||
MJPEG-Auflösungen die Kamera nativ unterstützt. Dann schrittweise testen:
|
||||
640x480 → 1280x720 → 1280x960. CPU nach jedem Schritt messen.
|
||||
- Zeitaufwand: 30 min
|
||||
---
|
||||
|
||||
**Option 2 — Stream stoppen, Snapshot, Stream starten**
|
||||
- Node.js orchestriert: go2rtc-Stream stoppen → FFmpeg einmalig
|
||||
`-frames:v 1` bei maximaler Auflösung → Bild speichern → Stream neu starten
|
||||
- Video-Blackout: ~1–2 Sekunden
|
||||
- CPU-Peak: kurz, dann zurück auf normal
|
||||
- Aufwand: ~2h (Node.js Orchestrierungslogik + go2rtc Stream-API)
|
||||
- Geeignet wenn Snapshots nur alle 10–30s gebraucht werden
|
||||
### Option 1 — Hi-Res-Stream + CSS-Skalierung (30 min, zuerst testen)
|
||||
|
||||
- `v4l2-ctl` prüfen (s.o.)
|
||||
- Wenn 1280×720 als MJPEG nativ: `video_size=640x480` → `video_size=1280x720` in docker-compose
|
||||
- Browser zeigt per CSS 640px breit, Snapshot = volle 1280×720
|
||||
- CPU erwartet: moderat (<30 %), da MJPEG-Passthrough ohne Encoding
|
||||
- Wenn 1280×720 nur als YUYV: Option 2 wählen
|
||||
|
||||
---
|
||||
|
||||
### Option 2 — Frame-Grab mit Blackout (2–3 h, konkreter Plan)
|
||||
|
||||
go2rtc hat eine Stream-Management-REST-API. Node.js stoppt den Stream kurz,
|
||||
greift mit FFmpeg direkt auf das Device zu, startet den Stream neu.
|
||||
|
||||
**Blackout:** ~1–2 Sekunden. Akzeptabel bei Snapshot-Intervall ≥ 40 s und Roboter-Pause.
|
||||
|
||||
#### Nötige Änderungen
|
||||
|
||||
**1. `docker-compose.yaml` — Devices + FFmpeg in Node-Container**
|
||||
|
||||
```yaml
|
||||
webcam:
|
||||
build:
|
||||
context: /tmp
|
||||
dockerfile_inline: |
|
||||
FROM node:lts-bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /usr/src/app
|
||||
EXPOSE 8444
|
||||
devices:
|
||||
- /dev/video0:/dev/video0
|
||||
- /dev/video2:/dev/video2
|
||||
group_add:
|
||||
- video
|
||||
```
|
||||
|
||||
**2. `snapshotService.js` — neuer `/hires`-Endpoint**
|
||||
|
||||
Konfiguration oben in der Datei (passend zu go2rtc-Config halten):
|
||||
```javascript
|
||||
const CAM_CONFIG = {
|
||||
cam0: { device: '/dev/video0', hiresSize: '1280x720',
|
||||
streamUrl: 'ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg' },
|
||||
cam1: { device: '/dev/video2', hiresSize: '1280x720',
|
||||
streamUrl: 'ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg' },
|
||||
};
|
||||
```
|
||||
|
||||
Endpoint-Logik (Pseudocode):
|
||||
```javascript
|
||||
router.get('/:id/hires', async (req, res) => {
|
||||
const cfg = CAM_CONFIG[req.params.id];
|
||||
if (!cfg) return res.status(404).json({ error: 'Unknown camera' });
|
||||
|
||||
// 1. go2rtc-Stream stoppen (gibt Device frei)
|
||||
await fetch(`${go2rtcUrl}/api/streams?src=${req.params.id}`, { method: 'DELETE' });
|
||||
await new Promise(r => setTimeout(r, 800)); // warten bis FFmpeg-Prozess beendet
|
||||
|
||||
// 2. Hi-Res-Frame via FFmpeg one-shot
|
||||
const jpeg = await captureOneFrame(cfg.device, cfg.hiresSize);
|
||||
|
||||
// 3. Stream in go2rtc wiederherstellen
|
||||
await fetch(`${go2rtcUrl}/api/streams?src=${req.params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: cfg.streamUrl,
|
||||
});
|
||||
|
||||
res.set({ 'Content-Type': 'image/jpeg', 'Cache-Control': 'no-store' });
|
||||
res.end(jpeg);
|
||||
});
|
||||
|
||||
function captureOneFrame(device, size) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-f', 'v4l2', '-input_format', 'mjpeg', '-video_size', size,
|
||||
'-frames:v', '1', '-q:v', '1', '-f', 'mjpeg', 'pipe:1',
|
||||
];
|
||||
// spawn('ffmpeg', ['-i', device, ...args]) → collect stdout → resolve(buffer)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
go2rtc-API-Endpunkte (verifiziert):
|
||||
- `DELETE /api/streams?src={name}` → stoppt Producer, gibt Device frei
|
||||
- `PUT /api/streams?src={name}` mit Body = Stream-URL → startet Producer neu
|
||||
|
||||
**3. Mutex (concurrent requests verhindern)**
|
||||
|
||||
```javascript
|
||||
let hiresLock = false;
|
||||
// Am Anfang des Endpoints:
|
||||
if (hiresLock) return res.status(429).json({ error: 'hi-res snapshot in progress' });
|
||||
hiresLock = true;
|
||||
try { /* ... */ } finally { hiresLock = false; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option 3 — Separate Kameras für Homing
|
||||
|
||||
**Option 3 — Separate Kameras für Homing**
|
||||
- Zwei zusätzliche USB-Kameras, nur für Homing (kein Live-Stream)
|
||||
- go2rtc öffnet sie nicht → kein Konflikt, volle Auflösung on-demand
|
||||
- Aufwand: Hardware-Kosten + Montage + FFmpeg one-shot in Node.js
|
||||
- Sauberste Lösung langfristig
|
||||
- Sauberste Lösung langfristig, aber Hardware-Investment
|
||||
|
||||
### Empfehlung
|
||||
---
|
||||
|
||||
Option 1 zuerst, aber schrittweise mit CPU-Messung pro Auflösungsstufe.
|
||||
Option 2 wenn Blackout akzeptabel und Option 1 zu viel CPU braucht.
|
||||
Option 3 wenn Homing-Qualität kritisch und Budget vorhanden.
|
||||
### Ergebnis der Tests
|
||||
|
||||
**Option 1 gescheitert (1280×960 @ 30fps MJPEG nativ):**
|
||||
- Kamera unterstützt 1280×960 nativ als MJPEG (per `v4l2-ctl` bestätigt)
|
||||
- CPU trotzdem 53% mit 1 Client / 127% mit 2 Clients
|
||||
- Ursache: **reines I/O** — go2rtc schiebt grosse Frames für jeden Client separat durch
|
||||
den Netzwerkstack. CPU skaliert 2× mit Clients → kein Encoding, nur Datenmenge.
|
||||
- Bei 2 Kameras × 1280×960 × 30fps × 2 Clients: ~30–40 Mbit/s — zu viel.
|
||||
|
||||
**Entscheid: Option 2 (Blackout-Snapshot) ✓ (implementiert)**
|
||||
|
||||
Live-Stream bleibt bei 640×480 @ 30fps (<5% CPU, stabil).
|
||||
Hi-Res on demand via `/api/snapshot/cam{n}/hires`:
|
||||
|
||||
```
|
||||
GET /api/snapshot/cam0/hires
|
||||
→ go2rtc-Stream löschen → 900ms warten → FFmpeg one-shot 1280×960 → Stream wiederherstellen
|
||||
→ Blackout: ~1–2 s. CPU-Peak: kurz, dann zurück auf <5%.
|
||||
```
|
||||
|
||||
Umgesetzt in `src/snapshotService.js` und `docker-compose.yaml`.
|
||||
|
||||
@@ -17,73 +17,69 @@ name: approbotwebcam
|
||||
# Zugriff:
|
||||
# Viewer: http://<host>:8444/
|
||||
# Snapshot (Homing) http://<host>:8444/api/snapshot/cam0
|
||||
# Hi-Res Snapshot http://<host>:8444/api/snapshot/cam0/hires
|
||||
# go2rtc-Debug-UI http://<host>:1984/ (nur intern/LAN)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
configs:
|
||||
go2rtc_yaml:
|
||||
# Komplette go2rtc-Config eingebettet – keine separate Datei nötig.
|
||||
content: |
|
||||
streams:
|
||||
# MJPEG-Passthrough: Kamera liefert MJPEG nativ → go2rtc reicht es 1:1 durch.
|
||||
# Kein Encoding, kein libx264, kein VAAPI → CPU <5%, keine Freezes.
|
||||
# Latenz ~200ms (vs. 130ms bei H.264) — für Roboter-Überwachung ausreichend.
|
||||
# Hinweis: go2rtc's #hardware funktioniert NICHT mit MJPEG-Kamera-Input
|
||||
# (hwupload benötigt VAAPI-Decoder auf Input-Seite, MJPEG läuft Software).
|
||||
# 640x480 @ 30fps – stabiler Live-Stream, <5% CPU, ~200ms Latenz.
|
||||
# Hi-Res-Snapshots über /api/snapshot/cam{n}/hires (Node.js Blackout-Methode).
|
||||
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"
|
||||
webrtc:
|
||||
listen: ":8555"
|
||||
candidates:
|
||||
# stun:8555 → go2rtc erkennt die öffentliche IP automatisch (für Internet).
|
||||
# Falls das nicht klappt: feste IP/Domain eintragen, z.B.
|
||||
# - robot.example.com:8555
|
||||
- stun:8555
|
||||
api:
|
||||
listen: ":1984"
|
||||
# origin "*" erlaubt das WebSocket-Signaling vom Viewer (Port 8444 = anderer Origin).
|
||||
# Ohne diese Zeile blockt go2rtc den WS mit "request origin not allowed".
|
||||
# LAN: unkritisch. Internet: Caddy davor schränkt den Zugriff wieder ein.
|
||||
origin: "*"
|
||||
log:
|
||||
level: info
|
||||
# On-demand bestätigt: go2rtc startet Encoder erst bei erstem Client (0% CPU ohne Client).
|
||||
|
||||
services:
|
||||
|
||||
# ── go2rtc: Kamera-Capture · H.264-Encoding · WebRTC ──────────────────────
|
||||
# ── go2rtc: Kamera-Capture · MJPEG-Passthrough · Streaming ────────────────
|
||||
go2rtc:
|
||||
image: ghcr.io/alexxit/go2rtc
|
||||
container_name: AppRobotGo2RTC
|
||||
restart: unless-stopped
|
||||
network_mode: host # echte Host-IP als WebRTC-ICE-Kandidat
|
||||
network_mode: host
|
||||
devices:
|
||||
- /dev/video0:/dev/video0
|
||||
- /dev/video2:/dev/video2
|
||||
# /dev/dri nicht mehr nötig: MJPEG-Passthrough braucht keine GPU
|
||||
group_add:
|
||||
- video
|
||||
# render-Gruppe NICHT hier setzen — existiert im Container-Image nicht → 500-Fehler.
|
||||
# /dev/dri-Zugriff funktioniert via devices: + Container läuft als root.
|
||||
configs:
|
||||
- source: go2rtc_yaml
|
||||
target: /config/go2rtc.yaml
|
||||
|
||||
# ── webcam: Node.js (Viewer · /api/ws-Proxy · Snapshot-API) ──────────────
|
||||
# ffmpeg ist im Image damit der /hires-Endpunkt direkt auf das Gerät zugreifen
|
||||
# kann, wenn go2rtc den Stream kurz freigibt (Blackout-Snapshot-Methode).
|
||||
webcam:
|
||||
build:
|
||||
context: /tmp # Leerer Build-Context – Code kommt per Bind-Mount
|
||||
context: /tmp
|
||||
dockerfile_inline: |
|
||||
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
|
||||
EXPOSE 8444
|
||||
image: approbotwebcam:latest
|
||||
container_name: AppRobotWebcam
|
||||
restart: unless-stopped
|
||||
network_mode: host # erreicht go2rtc via localhost:1984
|
||||
network_mode: host
|
||||
command: sh -c "npm install --omit=dev && node server.js"
|
||||
volumes:
|
||||
- ${APP_PATH:-.}:/usr/src/app
|
||||
devices:
|
||||
- /dev/video0:/dev/video0
|
||||
- /dev/video2:/dev/video2
|
||||
group_add:
|
||||
- video
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8444
|
||||
@@ -98,5 +94,5 @@ services:
|
||||
# - ${APP_PATH:-.}/go2rtc.yaml:/config/go2rtc.yaml:ro
|
||||
#
|
||||
# Bleibt eine Kamera schwarz? → in der Config oben die Quelle ersetzen durch die
|
||||
# simple, bestätigte Form (ohne Auflösung): "ffmpeg:/dev/video0#video=h264#video=mjpeg"
|
||||
# simple, bestätigte Form (ohne Auflösung): "ffmpeg:/dev/video0#video=mjpeg"
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
// go2rtc Player-Modi – Fallback-Reihenfolge: WebRTC → MSE → MJPEG
|
||||
const MODE = 'webrtc,mse,mjpeg';
|
||||
// go2rtc Player-Modi.
|
||||
// 'mjpeg' → Passthrough: go2rtc reicht Kamera-MJPEG 1:1 durch.
|
||||
// KEIN Transcode → ~0% CPU, keine Freezes, ~200ms Latenz.
|
||||
// 'webrtc,mse,mjpeg' → WebRTC bevorzugt. ABER: WebRTC/MSE können kein MJPEG →
|
||||
// go2rtc transcodiert MJPEG→H.264 in Software (libx264) →
|
||||
// ~55% CPU pro Kamera + Keyframe-Freezes. ~130ms Latenz.
|
||||
const MODE = 'mjpeg';
|
||||
const IS_MJPEG = !MODE.includes('webrtc'); // MJPEG-Modus: kein WebRTC/getStats/Health
|
||||
|
||||
// ── Überwachungs-Parameter ───────────────────────────────────────────────────
|
||||
const MONITOR_INTERVAL = 2500; // ms zwischen Health-Checks (getStats)
|
||||
@@ -88,6 +94,14 @@ async function monitor() {
|
||||
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
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const express = require('express');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// ── Kamera-Konfiguration ──────────────────────────────────────────────────────
|
||||
// Muss zur go2rtc-Config in docker-compose.yaml passen.
|
||||
const CAM_CONFIG = {
|
||||
cam0: {
|
||||
device: '/dev/video0',
|
||||
hiresSize: '1280x960',
|
||||
streamUrl: 'ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg',
|
||||
},
|
||||
cam1: {
|
||||
device: '/dev/video2',
|
||||
hiresSize: '1280x960',
|
||||
streamUrl: 'ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg',
|
||||
},
|
||||
};
|
||||
|
||||
// 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 (aus go2rtc /api/streams)
|
||||
// GET /api/snapshot/cam0 → aktuelles JPEG (aus go2rtc /api/frame.jpeg?src=cam0)
|
||||
// GET /api/snapshot → JSON-Liste der Kameras
|
||||
// GET /api/snapshot/cam0 → aktueller Frame (640×480, go2rtc passthrough)
|
||||
// GET /api/snapshot/cam0/hires → einmaliger Hi-Res-Frame (1280×960, Blackout ~1–2 s)
|
||||
//
|
||||
// Hi-Res-Ablauf:
|
||||
// 1. go2rtc-Stream temporär löschen → Gerät wird freigegeben
|
||||
// 2. FFmpeg one-shot direkt auf /dev/videoX → 1280×960 MJPEG
|
||||
// 3. go2rtc-Stream wiederherstellen → Live-Video läuft wieder
|
||||
//
|
||||
function createSnapshotRouter(go2rtcUrl) {
|
||||
const router = express.Router();
|
||||
|
||||
// ── Kamera-Liste ─────────────────────────────────────────────────────────────
|
||||
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).map(id => ({ id, url: `/api/snapshot/${id}` })),
|
||||
cameras: Object.keys(streams).map(id => ({
|
||||
id,
|
||||
url: `/api/snapshot/${id}`,
|
||||
hiresUrl: `/api/snapshot/${id}/hires`,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Standard-Snapshot (Stream-Auflösung, sofort) ─────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
@@ -46,7 +74,132 @@ function createSnapshotRouter(go2rtcUrl) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Hi-Res-Snapshot (Blackout ~1–2 s, 1280×960) ──────────────────────────────
|
||||
let hiresLock = false;
|
||||
|
||||
router.get('/:id/hires', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const cfg = CAM_CONFIG[id];
|
||||
if (!cfg) {
|
||||
return res.status(404).json({ error: `Kamera '${id}' nicht in CAM_CONFIG` });
|
||||
}
|
||||
if (hiresLock) {
|
||||
return res.status(429).json({ error: 'Hi-Res-Snapshot läuft bereits – bitte warten' });
|
||||
}
|
||||
|
||||
hiresLock = true;
|
||||
console.log(`[snapshot][${id}] hires-Start (${cfg.hiresSize})`);
|
||||
|
||||
try {
|
||||
// 1. go2rtc-Stream stoppen → gibt /dev/videoX frei
|
||||
const delRes = await fetch(
|
||||
`${go2rtcUrl}/api/streams?src=${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
console.log(`[snapshot][${id}] go2rtc DELETE stream → HTTP ${delRes.status}`);
|
||||
|
||||
// kurz warten bis FFmpeg-Prozess in go2rtc beendet und Gerät freigegeben ist
|
||||
await sleep(900);
|
||||
|
||||
// 2. Hi-Res-Frame via FFmpeg one-shot
|
||||
const jpeg = await captureOneFrame(cfg.device, cfg.hiresSize);
|
||||
console.log(`[snapshot][${id}] Frame captured (${jpeg.length} bytes)`);
|
||||
|
||||
// 3. go2rtc-Stream wiederherstellen
|
||||
const putRes = await fetch(
|
||||
`${go2rtcUrl}/api/streams?src=${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: cfg.streamUrl,
|
||||
}
|
||||
);
|
||||
console.log(`[snapshot][${id}] go2rtc PUT stream → HTTP ${putRes.status}`);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': jpeg.length,
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Camera-Id': id,
|
||||
'X-Resolution': cfg.hiresSize,
|
||||
'X-Timestamp': new Date().toISOString(),
|
||||
});
|
||||
res.end(jpeg);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[snapshot][${id}] hires-Fehler:`, err.message);
|
||||
|
||||
// Stream auf jeden Fall wiederherstellen, auch im Fehlerfall
|
||||
try {
|
||||
await fetch(`${go2rtcUrl}/api/streams?src=${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: cfg.streamUrl,
|
||||
});
|
||||
console.log(`[snapshot][${id}] Stream nach Fehler wiederhergestellt`);
|
||||
} catch (restoreErr) {
|
||||
console.error(`[snapshot][${id}] Stream-Wiederherstellung fehlgeschlagen:`, restoreErr.message);
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
} finally {
|
||||
hiresLock = false;
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// Startet FFmpeg, liest genau einen MJPEG-Frame aus stdout, gibt ihn als Buffer zurück.
|
||||
function captureOneFrame(device, size, timeoutMs = 8000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-f', 'v4l2',
|
||||
'-input_format', 'mjpeg',
|
||||
'-video_size', size,
|
||||
'-framerate', '10', // niedrige FPS → schnellerer erster Frame
|
||||
'-i', device,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '1', // beste JPEG-Qualität
|
||||
'-f', 'mjpeg',
|
||||
'pipe:1',
|
||||
];
|
||||
|
||||
const chunks = [];
|
||||
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
proc.stdout.on('data', chunk => chunks.push(chunk));
|
||||
proc.stderr.on('data', () => {}); // FFmpeg-Infos unterdrücken (loglevel error)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
reject(new Error(`FFmpeg timeout nach ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
proc.on('close', code => {
|
||||
clearTimeout(timer);
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (buf.length > 0) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error(`FFmpeg exit ${code}, kein Frame erhalten`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', err => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createSnapshotRouter };
|
||||
|
||||
Reference in New Issue
Block a user