From 306aacac807a2721be5d9e0abb4ff8f4e6586a85 Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:06:45 +0200 Subject: [PATCH] Claude: WebSocket --- doc/04_Delay_roadmap.md | 211 +++++++++++++++++++++++++++++++++++----- docker-compose.yaml | 38 ++++---- public/viewer.js | 18 +++- src/snapshotService.js | 163 ++++++++++++++++++++++++++++++- 4 files changed, 378 insertions(+), 52 deletions(-) diff --git a/doc/04_Delay_roadmap.md b/doc/04_Delay_roadmap.md index 5d54dba..7daabf4 100644 --- a/doc/04_Delay_roadmap.md +++ b/doc/04_Delay_roadmap.md @@ -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`. diff --git a/docker-compose.yaml b/docker-compose.yaml index fdf239a..b2d3ab0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,73 +17,69 @@ name: approbotwebcam # Zugriff: # Viewer: http://:8444/ # Snapshot (Homing) http://:8444/api/snapshot/cam0 +# Hi-Res Snapshot http://:8444/api/snapshot/cam0/hires # go2rtc-Debug-UI http://: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" # ──────────────────────────────────────────────────────────────────────────────── diff --git a/public/viewer.js b/public/viewer.js index 8ad275d..edc6b6f 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -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 diff --git a/src/snapshotService.js b/src/snapshotService.js index 4e16a5a..01a03fe 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -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 };