Claude: WebRTC Versuch
This commit is contained in:
@@ -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
|
||||
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.1",
|
||||
"ws": "^8.18.0"
|
||||
"express": "^4.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.7"
|
||||
|
||||
@@ -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;
|
||||
|
||||
169
public/viewer.js
169
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';
|
||||
});
|
||||
|
||||
131
server.js
131
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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user