diff --git a/docker-compose.yaml b/docker-compose.yaml index 509685b..b839dc8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,66 +1,81 @@ name: approbotwebcam -# ── Portainer Web-Editor: dieses YAML direkt einfügen ─────────────────────── -# Voraussetzungen auf dem Server: -# 1. docker network create appRobotNet -# 2. Code-Verzeichnis liegt auf dem Server (git clone / rsync / Synology Drive) -# 3. In Portainer unter "Environment variables" setzen: -# APP_PATH=/absoluter/pfad/zum/appRobotWebcam +# ── Portainer Web-Editor: dieses YAML einfügen, dann Deploy ───────────────── # -# Beim ersten Deploy baut Portainer das Image (Node.js + FFmpeg). -# Danach reicht "Redeploy" – kein Rebuild nötig ausser bei System-Updates. +# Voraussetzungen: +# 1. Code auf dem Server (git clone / Synology Drive sync) +# 2. In Portainer → "Environment variables": +# APP_PATH = /absoluter/pfad/zum/appRobotWebcam +# +# Firewall: genau zwei Ports freigeben: +# TCP 8444 → HTTP (Viewer · Snapshot-API · WebRTC-Signaling) +# UDP 8555 → WebRTC Media (go2rtc direkt, kann nicht proxiert werden) +# +# go2rtc-Konfiguration steht unten im "configs"-Block. +# Kameras, Codec oder Ports ändern? → configs.go2rtc_config.content anpassen. # ───────────────────────────────────────────────────────────────────────────── +# ── go2rtc-Konfiguration (eingebettet, kein separates File nötig) ───────────── +configs: + go2rtc_config: + content: | + streams: + cam0: + - "ffmpeg:/dev/video0#video=h264" + cam1: + - "ffmpeg:/dev/video2#video=h264" + + webrtc: + ice_servers: + - urls: + - stun:stun.l.google.com:19302 + - stun:stun1.l.google.com:19302 + # Fixer UDP-Port → eine Firewall-Regel reicht + listen: ":8555/udp" + + api: + listen: ":1984" + origin: "*" + + log: + level: warn + +# ───────────────────────────────────────────────────────────────────────────── services: - webcam: - build: - context: /tmp # Leerer Build-Context: kein COPY nötig, Code kommt per Bind-Mount - dockerfile_inline: | - FROM node:lts-bookworm-slim - RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ffmpeg \ - v4l-utils \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /usr/src/app - EXPOSE 8080 - image: approbotwebcam:latest - container_name: AppRobotWebcam + + # ── go2rtc: Kamera-Capture + H.264-Encoding + WebRTC ────────────────────── + go2rtc: + image: ghcr.io/alexxit/go2rtc + container_name: AppRobotGo2RTC restart: unless-stopped - - # npm install läuft einmalig beim Start, danach gecacht in node_modules - command: sh -c "npm install && node server.js" - - volumes: - # APP_PATH in Portainer setzen, z.B. /volume1/docker/appRobotWebcam - # Lokal (ohne Portainer): APP_PATH nicht setzen → Fallback auf ./ - - ${APP_PATH:-.}:/usr/src/app - - ports: - - "8444:8080" - - environment: - - NODE_ENV=production - - PORT=8080 - - DEV0=/dev/video0 - - DEV1=/dev/video2 - # Optional – Defaults: 640x480 @ 30fps, Qualität 5 - # - CAM0_WIDTH=640 - # - CAM0_HEIGHT=480 - # - CAM0_FPS=30 - # - CAM0_QUALITY=5 - + # host-Netzwerk: go2rtc bekommt die echte Host-IP als ICE-Kandidat + # → WebRTC funktioniert auf LAN und über die Firewall + network_mode: host devices: - /dev/video0:/dev/video0 - /dev/video2:/dev/video2 - - # Kamera-Zugriffsrechte: Node-Prozess braucht Gruppe 'video' group_add: - video + configs: + - source: go2rtc_config + target: /config/go2rtc.yaml - networks: - - appRobotNet - -networks: - appRobotNet: - external: true + # ── webcam: Node.js (Viewer · Snapshot-Proxy · WebRTC-Signaling-Proxy) ─── + webcam: + build: + context: /tmp # Leerer Build-Context – Code kommt per Bind-Mount + dockerfile_inline: | + FROM node:lts-bookworm-slim + WORKDIR /usr/src/app + EXPOSE 8444 + image: approbotwebcam:latest + container_name: AppRobotWebcam + restart: unless-stopped + network_mode: host # Erreicht go2rtc über localhost:1984 + command: sh -c "npm install && node server.js" + volumes: + - ${APP_PATH:-.}:/usr/src/app + environment: + - NODE_ENV=production + - PORT=8444 + - GO2RTC_URL=http://localhost:1984 diff --git a/go2rtc.yaml b/go2rtc.yaml new file mode 100644 index 0000000..53fb32d --- /dev/null +++ b/go2rtc.yaml @@ -0,0 +1,23 @@ +streams: + # FFmpeg öffnet die v4l2-Kamera und encodiert zu H.264 für WebRTC + # Falls die Kamera kein MJPEG liefert: "#video=h264" durch "#video=mjpeg" oder "#video=vp8" ersetzen + cam0: + - "ffmpeg:/dev/video0#video=h264" + cam1: + - "ffmpeg:/dev/video2#video=h264" + +webrtc: + ice_servers: + - urls: + - stun:stun.l.google.com:19302 + - stun:stun1.l.google.com:19302 + # Fixer UDP-Port → einfache Firewall-Regel: UDP 8555 weiterleiten + listen: ":8555/udp" + +api: + listen: ":1984" + # Erlaubt Requests vom Node.js-Proxy (gleicher Host, anderer Port) + origin: "*" + +log: + level: warn diff --git a/package.json b/package.json index 62cecbd..e6fdfac 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "dev": "nodemon server.js" }, "dependencies": { - "express": "^4.21.1", - "ws": "^8.18.0" + "express": "^4.21.1" }, "devDependencies": { "nodemon": "^3.1.7" diff --git a/public/index.html b/public/index.html index 6150d9d..20b8834 100644 --- a/public/index.html +++ b/public/index.html @@ -28,7 +28,7 @@ background: #000; border: 1px solid #2a2a2a; } - .cam-box canvas { display: block; } + .cam-box video { display: block; } .cam-label { position: absolute; top: 5px; left: 8px; diff --git a/public/viewer.js b/public/viewer.js index 0a20f2d..7be31af 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -1,110 +1,123 @@ 'use strict'; -const WS_RECONNECT_MS = 2000; +const ICE_SERVERS = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, +]; -function createCameraView(idx, container) { - // DOM - const box = document.createElement('div'); +// Wartet bis ICE-Gathering fertig ist (max timeoutMs) +function waitIceComplete(pc, timeoutMs = 5000) { + return new Promise(resolve => { + if (pc.iceGatheringState === 'complete') { resolve(); return; } + const check = () => { if (pc.iceGatheringState === 'complete') resolve(); }; + pc.addEventListener('icegatheringstatechange', check); + setTimeout(() => { pc.removeEventListener('icegatheringstatechange', check); resolve(); }, timeoutMs); + }); +} + +async function startWebRTC(camId, videoEl, statusEl) { + statusEl.textContent = 'Verbinde...'; + + let pc; + try { + pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + + // Nur Video empfangen, kein Audio + pc.addTransceiver('video', { direction: 'recvonly' }); + + pc.ontrack = ({ streams }) => { + if (!streams[0]) return; + videoEl.srcObject = streams[0]; + videoEl.play().catch(() => {}); + }; + + pc.oniceconnectionstatechange = () => { + const s = pc.iceConnectionState; + statusEl.textContent = { connected: 'Live ✓', completed: 'Live ✓' }[s] ?? s; + if (s === 'failed' || s === 'closed') { + pc.close(); + setTimeout(() => startWebRTC(camId, videoEl, statusEl), 4000); + } + }; + + // SDP Offer erstellen und warten bis alle ICE-Kandidaten gesammelt sind + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await waitIceComplete(pc); + + // Signaling über Node.js-Proxy (kein separater go2rtc-Port nach aussen nötig) + const resp = await fetch(`/api/webrtc?src=${encodeURIComponent(camId)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/sdp' }, + body: pc.localDescription.sdp, + }); + + if (!resp.ok) throw new Error(`Signaling HTTP ${resp.status}: ${await resp.text()}`); + + await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() }); + + } catch (err) { + statusEl.textContent = `Fehler: ${err.message}`; + console.error(`[${camId}]`, err); + pc?.close(); + setTimeout(() => startWebRTC(camId, videoEl, statusEl), 5000); + } +} + +function createCameraView(camId, container) { + const box = document.createElement('div'); box.className = 'cam-box'; - const canvas = document.createElement('canvas'); - canvas.width = 640; - canvas.height = 480; - box.appendChild(canvas); + const video = document.createElement('video'); + video.autoplay = true; + video.playsInline = true; + video.muted = true; + video.style.cssText = 'display:block;width:640px;height:480px;background:#000'; + box.appendChild(video); const label = document.createElement('div'); label.className = 'cam-label'; - label.textContent = `cam${idx}`; + label.textContent = camId; box.appendChild(label); - const info = document.createElement('div'); - info.className = 'cam-info'; - info.textContent = 'Verbinde...'; - box.appendChild(info); + const status = document.createElement('div'); + status.className = 'cam-info'; + box.appendChild(status); const actions = document.createElement('div'); actions.className = 'cam-actions'; const snapBtn = document.createElement('button'); snapBtn.textContent = 'Snapshot'; + snapBtn.onclick = () => { + const a = document.createElement('a'); + a.href = `/api/snapshot/${camId}`; + a.download = `${camId}_${Date.now()}.jpg`; + a.click(); + }; actions.appendChild(snapBtn); box.appendChild(actions); container.appendChild(box); - - // Snapshot download - snapBtn.addEventListener('click', () => { - const a = document.createElement('a'); - a.href = `/api/snapshot/cam${idx}`; - a.download = `cam${idx}_${Date.now()}.jpg`; - a.click(); - }); - - // Rendering - const ctx = canvas.getContext('2d'); - let frameCount = 0; - let lastFpsTs = Date.now(); - let fps = 0; - - function drawFrame(arrayBuffer) { - const blob = new Blob([arrayBuffer], { type: 'image/jpeg' }); - createImageBitmap(blob) - .then((bmp) => { - if (canvas.width !== bmp.width || canvas.height !== bmp.height) { - canvas.width = bmp.width; - canvas.height = bmp.height; - } - ctx.drawImage(bmp, 0, 0); - bmp.close(); - - frameCount++; - const now = Date.now(); - if (now - lastFpsTs >= 1000) { - fps = Math.round(frameCount * 1000 / (now - lastFpsTs)); - frameCount = 0; - lastFpsTs = now; - info.textContent = `${fps} fps`; - } - }) - .catch(() => {/* ignore decode errors */}); - } - - // WebSocket connection with auto-reconnect - function connect() { - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${proto}//${location.host}/ws/cam${idx}`); - ws.binaryType = 'arraybuffer'; - - ws.onopen = () => { info.textContent = 'Verbunden'; }; - ws.onclose = () => { - info.textContent = `Getrennt – neu in ${WS_RECONNECT_MS / 1000}s`; - setTimeout(connect, WS_RECONNECT_MS); - }; - ws.onerror = () => { info.textContent = 'Verbindungsfehler'; }; - ws.onmessage = (evt) => drawFrame(evt.data); - } - - connect(); + startWebRTC(camId, video, status); } -// Fetch camera list from server, then build one view per camera +// Kamera-Liste von go2rtc (via Node.js-Proxy), dann Views aufbauen fetch('/api/snapshot') .then(r => r.json()) .then(data => { const container = document.getElementById('cameras'); - const count = data.cameras?.length ?? 0; - - if (count === 0) { - document.getElementById('statusText').textContent = 'Keine Kameras erkannt'; + const cams = data.cameras ?? []; + if (cams.length === 0) { + document.getElementById('statusText').textContent = 'Keine Kameras in go2rtc'; return; } - - for (let i = 0; i < count; i++) createCameraView(i, container); + cams.forEach(c => createCameraView(c.id, container)); document.getElementById('statusText').textContent = - `${count} Kamera${count !== 1 ? 's' : ''} erkannt`; + `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`; }) .catch(() => { - // Fallback: show 2 cameras if API fails + // Fallback wenn go2rtc noch nicht läuft const container = document.getElementById('cameras'); - for (let i = 0; i < 2; i++) createCameraView(i, container); - document.getElementById('statusText').textContent = 'Kamera-API nicht erreichbar'; + ['cam0', 'cam1'].forEach(id => createCameraView(id, container)); + document.getElementById('statusText').textContent = 'go2rtc nicht erreichbar – versuche trotzdem'; }); diff --git a/server.js b/server.js index cbcc961..a571226 100644 --- a/server.js +++ b/server.js @@ -1,85 +1,72 @@ 'use strict'; const express = require('express'); -const http = require('http'); -const { WebSocketServer } = require('ws'); -const path = require('path'); -const { detectDevices } = require('./src/deviceDetect'); -const { VideoStream } = require('./src/videoStream'); +const http = require('http'); +const path = require('path'); const { createSnapshotRouter } = require('./src/snapshotService'); -const PORT = parseInt(process.env.PORT ?? '8080', 10); +const PORT = parseInt(process.env.PORT ?? '8444', 10); +const GO2RTC_URL = process.env.GO2RTC_URL ?? 'http://localhost:1984'; -function buildStreams() { - const devices = detectDevices(); - console.log(`Detected ${devices.length} camera(s):`, devices); +const app = express(); - return devices.map((device, idx) => new VideoStream(device, { - name: `cam${idx}`, - width: parseInt(process.env[`CAM${idx}_WIDTH`] ?? '640', 10), - height: parseInt(process.env[`CAM${idx}_HEIGHT`] ?? '480', 10), - fps: parseInt(process.env[`CAM${idx}_FPS`] ?? '30', 10), - quality: parseInt(process.env[`CAM${idx}_QUALITY`] ?? '5', 10), - })); -} +app.use(express.static(path.join(__dirname, 'public'))); +app.use('/api/snapshot', createSnapshotRouter(GO2RTC_URL)); -function main() { - const streams = buildStreams(); +// ── WebRTC signaling proxy ──────────────────────────────────────────────────── +// Browser postet SDP-Offer hierher; wir leiten es an go2rtc weiter und +// geben die SDP-Answer zurück. Nur ein HTTP-Port nach aussen nötig. +app.post( + '/api/webrtc', + express.text({ type: 'application/sdp', limit: '64kb' }), + async (req, res) => { + const src = req.query.src ?? ''; + try { + const upstream = await fetch( + `${GO2RTC_URL}/api/webrtc?src=${encodeURIComponent(src)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/sdp' }, + body: req.body, + } + ); + if (!upstream.ok) { + const msg = await upstream.text(); + return res.status(upstream.status).send(msg); + } + const answer = await upstream.text(); + res.set('Content-Type', 'application/sdp'); + res.send(answer); + } catch (err) { + res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` }); + } + } +); - // Start all streams eagerly – first client gets immediate frame - streams.forEach(s => s.start()); +// ── Health ──────────────────────────────────────────────────────────────────── +app.get('/health', async (_req, res) => { + let go2rtcOk = false; + try { + const r = await fetch(`${GO2RTC_URL}/api/streams`); + go2rtcOk = r.ok; + } catch { /* not reachable */ } - const app = express(); + res.json({ status: go2rtcOk ? 'ok' : 'degraded', go2rtc: go2rtcOk }); +}); - app.use(express.static(path.join(__dirname, 'public'))); - app.use('/api/snapshot', createSnapshotRouter(streams)); +// ── Start ───────────────────────────────────────────────────────────────────── +const server = http.createServer(app); - app.get('/health', (_req, res) => { - res.json({ - status: 'ok', - cameras: streams.map((s, i) => ({ - id: `cam${i}`, - device: s.device, - running: s.isRunning, - clients: s.clientCount, - hasFrame: s.latestFrame !== null, - })), - }); - }); +server.listen(PORT, '0.0.0.0', () => { + console.log(`AppRobotWebcam on http://0.0.0.0:${PORT}`); + console.log(` go2rtc backend: ${GO2RTC_URL}`); + console.log(` WebRTC signaling proxy: POST /api/webrtc?src=cam0`); + console.log(` Snapshot API: GET /api/snapshot/cam0`); +}); - const server = http.createServer(app); - const wss = new WebSocketServer({ noServer: true }); - - server.on('upgrade', (req, socket, head) => { - const match = req.url?.match(/^\/ws\/cam(\d+)$/); - if (!match) { socket.destroy(); return; } - - const idx = parseInt(match[1], 10); - if (!streams[idx]) { socket.destroy(); return; } - - wss.handleUpgrade(req, socket, head, (ws) => { - const stream = streams[idx]; - stream.addClient(ws); - ws.on('close', () => stream.removeClient(ws)); - ws.on('error', () => stream.removeClient(ws)); - }); - }); - - server.listen(PORT, '0.0.0.0', () => { - console.log(`AppRobotWebcam on http://0.0.0.0:${PORT}`); - streams.forEach((s, i) => { - console.log(` ws://0.0.0.0:${PORT}/ws/cam${i} → ${s.device}`); - console.log(` http://0.0.0.0:${PORT}/api/snapshot/cam${i}`); - }); - }); - - const shutdown = (signal) => { - console.log(`\n${signal} received – shutting down`); - streams.forEach(s => s.stop()); - server.close(() => process.exit(0)); - }; - process.on('SIGINT', () => shutdown('SIGINT')); - process.on('SIGTERM', () => shutdown('SIGTERM')); -} - -main(); +const shutdown = (sig) => { + console.log(`\n${sig} – shutting down`); + server.close(() => process.exit(0)); +}; +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/src/snapshotService.js b/src/snapshotService.js index 7efadc0..0662b29 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -2,46 +2,47 @@ const express = require('express'); -// Returns an Express router mounted at /api/snapshot -// GET /api/snapshot → JSON listing of cameras -// GET /api/snapshot/cam0 → latest JPEG from cam0 -// GET /api/snapshot/cam1 → latest JPEG from cam1 -function createSnapshotRouter(streams) { +// Proxiert go2rtc-Frame-API als /api/snapshot/:id +// GET /api/snapshot → JSON mit Kamera-Liste (von go2rtc /api/streams) +// GET /api/snapshot/cam0 → aktuelles JPEG-Frame (von go2rtc /api/frame?src=cam0) +function createSnapshotRouter(go2rtcUrl) { const router = express.Router(); - router.get('/', (_req, res) => { - res.json({ - cameras: streams.map((s, i) => ({ - id: `cam${i}`, - device: s.device, - running: s.isRunning, - clients: s.clientCount, - hasFrame: s.latestFrame !== null, - url: `/api/snapshot/cam${i}`, - })), - }); + router.get('/', async (_req, res) => { + try { + const r = await fetch(`${go2rtcUrl}/api/streams`); + if (!r.ok) throw new Error(`go2rtc ${r.status}`); + const streams = await r.json(); + res.json({ + cameras: Object.keys(streams).map(id => ({ + id, + url: `/api/snapshot/${id}`, + })), + }); + } catch (err) { + res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` }); + } }); - router.get('/:id', (req, res) => { - const idx = parseInt(req.params.id.replace('cam', ''), 10); - if (isNaN(idx) || !streams[idx]) { - return res.status(404).json({ error: 'camera not found' }); + router.get('/:id', async (req, res) => { + const { id } = req.params; + try { + const upstream = await fetch(`${go2rtcUrl}/api/frame?src=${encodeURIComponent(id)}`); + if (!upstream.ok) { + return res.status(upstream.status).json({ error: 'kein Frame verfügbar' }); + } + 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}` }); } - - const frame = streams[idx].latestFrame; - if (!frame) { - return res.status(503).json({ error: 'no frame available yet – stream may still be starting' }); - } - - res.set({ - 'Content-Type': 'image/jpeg', - 'Content-Length': frame.length, - 'Cache-Control': 'no-store', - 'X-Camera-Id': `cam${idx}`, - 'X-Camera-Device': streams[idx].device, - 'X-Timestamp': new Date().toISOString(), - }); - res.end(frame); }); return router;