Claude: WebRTC Versuch

This commit is contained in:
chk
2026-06-02 23:10:13 +02:00
parent 9b1ae2ae14
commit 11811a2e03
7 changed files with 279 additions and 241 deletions

View File

@@ -1,66 +1,81 @@
name: approbotwebcam name: approbotwebcam
# ── Portainer Web-Editor: dieses YAML direkt einfügen ─────────────────────── # ── Portainer Web-Editor: dieses YAML einfügen, dann Deploy ─────────────────
# 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
# #
# Beim ersten Deploy baut Portainer das Image (Node.js + FFmpeg). # Voraussetzungen:
# Danach reicht "Redeploy" kein Rebuild nötig ausser bei System-Updates. # 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: services:
webcam:
build: # ── go2rtc: Kamera-Capture + H.264-Encoding + WebRTC ──────────────────────
context: /tmp # Leerer Build-Context: kein COPY nötig, Code kommt per Bind-Mount go2rtc:
dockerfile_inline: | image: ghcr.io/alexxit/go2rtc
FROM node:lts-bookworm-slim container_name: AppRobotGo2RTC
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
restart: unless-stopped restart: unless-stopped
# host-Netzwerk: go2rtc bekommt die echte Host-IP als ICE-Kandidat
# npm install läuft einmalig beim Start, danach gecacht in node_modules # → WebRTC funktioniert auf LAN und über die Firewall
command: sh -c "npm install && node server.js" network_mode: host
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
devices: devices:
- /dev/video0:/dev/video0 - /dev/video0:/dev/video0
- /dev/video2:/dev/video2 - /dev/video2:/dev/video2
# Kamera-Zugriffsrechte: Node-Prozess braucht Gruppe 'video'
group_add: group_add:
- video - video
configs:
- source: go2rtc_config
target: /config/go2rtc.yaml
networks: # ── webcam: Node.js (Viewer · Snapshot-Proxy · WebRTC-Signaling-Proxy) ───
- appRobotNet webcam:
build:
networks: context: /tmp # Leerer Build-Context Code kommt per Bind-Mount
appRobotNet: dockerfile_inline: |
external: true 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

23
go2rtc.yaml Normal file
View File

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

View File

@@ -8,8 +8,7 @@
"dev": "nodemon server.js" "dev": "nodemon server.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.21.1", "express": "^4.21.1"
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.7" "nodemon": "^3.1.7"

View File

@@ -28,7 +28,7 @@
background: #000; background: #000;
border: 1px solid #2a2a2a; border: 1px solid #2a2a2a;
} }
.cam-box canvas { display: block; } .cam-box video { display: block; }
.cam-label { .cam-label {
position: absolute; top: 5px; left: 8px; position: absolute; top: 5px; left: 8px;

View File

@@ -1,110 +1,123 @@
'use strict'; '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) { // Wartet bis ICE-Gathering fertig ist (max timeoutMs)
// DOM 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'); const box = document.createElement('div');
box.className = 'cam-box'; box.className = 'cam-box';
const canvas = document.createElement('canvas'); const video = document.createElement('video');
canvas.width = 640; video.autoplay = true;
canvas.height = 480; video.playsInline = true;
box.appendChild(canvas); video.muted = true;
video.style.cssText = 'display:block;width:640px;height:480px;background:#000';
box.appendChild(video);
const label = document.createElement('div'); const label = document.createElement('div');
label.className = 'cam-label'; label.className = 'cam-label';
label.textContent = `cam${idx}`; label.textContent = camId;
box.appendChild(label); box.appendChild(label);
const info = document.createElement('div'); const status = document.createElement('div');
info.className = 'cam-info'; status.className = 'cam-info';
info.textContent = 'Verbinde...'; box.appendChild(status);
box.appendChild(info);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'cam-actions'; actions.className = 'cam-actions';
const snapBtn = document.createElement('button'); const snapBtn = document.createElement('button');
snapBtn.textContent = 'Snapshot'; 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); actions.appendChild(snapBtn);
box.appendChild(actions); box.appendChild(actions);
container.appendChild(box); container.appendChild(box);
startWebRTC(camId, video, status);
// 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();
} }
// 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') fetch('/api/snapshot')
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
const container = document.getElementById('cameras'); const container = document.getElementById('cameras');
const count = data.cameras?.length ?? 0; const cams = data.cameras ?? [];
if (cams.length === 0) {
if (count === 0) { document.getElementById('statusText').textContent = 'Keine Kameras in go2rtc';
document.getElementById('statusText').textContent = 'Keine Kameras erkannt';
return; return;
} }
cams.forEach(c => createCameraView(c.id, container));
for (let i = 0; i < count; i++) createCameraView(i, container);
document.getElementById('statusText').textContent = document.getElementById('statusText').textContent =
`${count} Kamera${count !== 1 ? 's' : ''} erkannt`; `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
}) })
.catch(() => { .catch(() => {
// Fallback: show 2 cameras if API fails // Fallback wenn go2rtc noch nicht läuft
const container = document.getElementById('cameras'); const container = document.getElementById('cameras');
for (let i = 0; i < 2; i++) createCameraView(i, container); ['cam0', 'cam1'].forEach(id => createCameraView(id, container));
document.getElementById('statusText').textContent = 'Kamera-API nicht erreichbar'; document.getElementById('statusText').textContent = 'go2rtc nicht erreichbar versuche trotzdem';
}); });

123
server.js
View File

@@ -2,84 +2,71 @@
const express = require('express'); const express = require('express');
const http = require('http'); const http = require('http');
const { WebSocketServer } = require('ws');
const path = require('path'); const path = require('path');
const { detectDevices } = require('./src/deviceDetect');
const { VideoStream } = require('./src/videoStream');
const { createSnapshotRouter } = require('./src/snapshotService'); 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 app = express();
const devices = detectDevices();
console.log(`Detected ${devices.length} camera(s):`, devices);
return devices.map((device, idx) => new VideoStream(device, { app.use(express.static(path.join(__dirname, 'public')));
name: `cam${idx}`, app.use('/api/snapshot', createSnapshotRouter(GO2RTC_URL));
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),
}));
}
function main() { // ── WebRTC signaling proxy ────────────────────────────────────────────────────
const streams = buildStreams(); // 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 // ── Health ────────────────────────────────────────────────────────────────────
streams.forEach(s => s.start()); 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'))); // ── Start ─────────────────────────────────────────────────────────────────────
app.use('/api/snapshot', createSnapshotRouter(streams)); const server = http.createServer(app);
app.get('/health', (_req, res) => { server.listen(PORT, '0.0.0.0', () => {
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,
})),
});
});
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}`); console.log(`AppRobotWebcam on http://0.0.0.0:${PORT}`);
streams.forEach((s, i) => { console.log(` go2rtc backend: ${GO2RTC_URL}`);
console.log(` ws://0.0.0.0:${PORT}/ws/cam${i}${s.device}`); console.log(` WebRTC signaling proxy: POST /api/webrtc?src=cam0`);
console.log(` http://0.0.0.0:${PORT}/api/snapshot/cam${i}`); console.log(` Snapshot API: GET /api/snapshot/cam0`);
}); });
});
const shutdown = (signal) => { const shutdown = (sig) => {
console.log(`\n${signal} received shutting down`); console.log(`\n${sig} shutting down`);
streams.forEach(s => s.stop());
server.close(() => process.exit(0)); server.close(() => process.exit(0));
}; };
process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main();

View File

@@ -2,46 +2,47 @@
const express = require('express'); const express = require('express');
// Returns an Express router mounted at /api/snapshot // Proxiert go2rtc-Frame-API als /api/snapshot/:id
// GET /api/snapshot → JSON listing of cameras // GET /api/snapshot → JSON mit Kamera-Liste (von go2rtc /api/streams)
// GET /api/snapshot/cam0 latest JPEG from cam0 // GET /api/snapshot/cam0 → aktuelles JPEG-Frame (von go2rtc /api/frame?src=cam0)
// GET /api/snapshot/cam1 → latest JPEG from cam1 function createSnapshotRouter(go2rtcUrl) {
function createSnapshotRouter(streams) {
const router = express.Router(); const router = express.Router();
router.get('/', (_req, res) => { 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({ res.json({
cameras: streams.map((s, i) => ({ cameras: Object.keys(streams).map(id => ({
id: `cam${i}`, id,
device: s.device, url: `/api/snapshot/${id}`,
running: s.isRunning,
clients: s.clientCount,
hasFrame: s.latestFrame !== null,
url: `/api/snapshot/cam${i}`,
})), })),
}); });
} catch (err) {
res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` });
}
}); });
router.get('/:id', (req, res) => { router.get('/:id', async (req, res) => {
const idx = parseInt(req.params.id.replace('cam', ''), 10); const { id } = req.params;
if (isNaN(idx) || !streams[idx]) { try {
return res.status(404).json({ error: 'camera not found' }); 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());
const frame = streams[idx].latestFrame;
if (!frame) {
return res.status(503).json({ error: 'no frame available yet stream may still be starting' });
}
res.set({ res.set({
'Content-Type': 'image/jpeg', 'Content-Type': 'image/jpeg',
'Content-Length': frame.length, 'Content-Length': buf.length,
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'X-Camera-Id': `cam${idx}`, 'X-Camera-Id': id,
'X-Camera-Device': streams[idx].device,
'X-Timestamp': new Date().toISOString(), 'X-Timestamp': new Date().toISOString(),
}); });
res.end(frame); res.end(buf);
} catch (err) {
res.status(503).json({ error: `go2rtc: ${err.message}` });
}
}); });
return router; return router;