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
# ── 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:
# ── Portainer Web-Editor: dieses YAML einfügen, dann Deploy ─────────────────
#
# Voraussetzungen:
# 1. Code auf dem Server (git clone / Synology Drive sync)
# 2. In Portainer "Environment variables":
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam
#
# Beim ersten Deploy baut Portainer das Image (Node.js + FFmpeg).
# Danach reicht "Redeploy" kein Rebuild nötig ausser bei System-Updates.
# 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

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"
},
"dependencies": {
"express": "^4.21.1",
"ws": "^8.18.0"
"express": "^4.21.1"
},
"devDependencies": {
"nodemon": "^3.1.7"

View File

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

View File

@@ -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
// 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 */});
startWebRTC(camId, video, status);
}
// 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')
.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';
});

109
server.js
View File

@@ -2,84 +2,71 @@
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 { createSnapshotRouter } = require('./src/snapshotService');
const PORT = parseInt(process.env.PORT ?? '8080', 10);
function buildStreams() {
const devices = detectDevices();
console.log(`Detected ${devices.length} camera(s):`, devices);
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),
}));
}
function main() {
const streams = buildStreams();
// Start all streams eagerly first client gets immediate frame
streams.forEach(s => s.start());
const PORT = parseInt(process.env.PORT ?? '8444', 10);
const GO2RTC_URL = process.env.GO2RTC_URL ?? 'http://localhost:1984';
const app = express();
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api/snapshot', createSnapshotRouter(streams));
app.use('/api/snapshot', createSnapshotRouter(GO2RTC_URL));
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,
})),
});
// ── 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}` });
}
}
);
// ── 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 */ }
res.json({ status: go2rtcOk ? 'ok' : 'degraded', go2rtc: go2rtcOk });
});
// ── Start ─────────────────────────────────────────────────────────────────────
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}`);
});
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 shutdown = (signal) => {
console.log(`\n${signal} received shutting down`);
streams.forEach(s => s.stop());
const shutdown = (sig) => {
console.log(`\n${sig} shutting down`);
server.close(() => process.exit(0));
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main();

View File

@@ -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) => {
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: 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}`,
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 frame = streams[idx].latestFrame;
if (!frame) {
return res.status(503).json({ error: 'no frame available yet stream may still be starting' });
}
const buf = Buffer.from(await upstream.arrayBuffer());
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': frame.length,
'Content-Length': buf.length,
'Cache-Control': 'no-store',
'X-Camera-Id': `cam${idx}`,
'X-Camera-Device': streams[idx].device,
'X-Camera-Id': id,
'X-Timestamp': new Date().toISOString(),
});
res.end(frame);
res.end(buf);
} catch (err) {
res.status(503).json({ error: `go2rtc: ${err.message}` });
}
});
return router;