Claude: WebRTC Versuch
This commit is contained in:
@@ -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
23
go2rtc.yaml
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
167
public/viewer.js
167
public/viewer.js
@@ -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
123
server.js
@@ -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();
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user