Claude: WebRTC
This commit is contained in:
@@ -4,43 +4,19 @@ name: approbotwebcam
|
|||||||
#
|
#
|
||||||
# Voraussetzungen:
|
# Voraussetzungen:
|
||||||
# 1. Code auf dem Server (git clone / Synology Drive sync)
|
# 1. Code auf dem Server (git clone / Synology Drive sync)
|
||||||
# 2. In Portainer → "Environment variables":
|
# 2. go2rtc.yaml muss im selben Verzeichnis liegen wie dieses File
|
||||||
|
# 3. In Portainer → "Environment variables":
|
||||||
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam
|
# APP_PATH = /absoluter/pfad/zum/appRobotWebcam
|
||||||
#
|
#
|
||||||
# Firewall: genau zwei Ports freigeben:
|
# Firewall: genau zwei Ports freigeben:
|
||||||
# TCP 8444 → HTTP (Viewer · Snapshot-API · WebRTC-Signaling)
|
# TCP 8444 → HTTP (Viewer · Snapshot-API · WebRTC-Signaling)
|
||||||
# UDP 8555 → WebRTC Media (go2rtc direkt, kann nicht proxiert werden)
|
# UDP 8555 → WebRTC Media (go2rtc direkt, kann nicht proxiert werden)
|
||||||
#
|
#
|
||||||
# go2rtc-Konfiguration steht unten im "configs"-Block.
|
# network_mode: host → beide Container teilen den Host-Netzwerk-Stack.
|
||||||
# Kameras, Codec oder Ports ändern? → configs.go2rtc_config.content anpassen.
|
# Das ist für WebRTC entscheidend: go2rtc bekommt die echte Host-IP als
|
||||||
|
# ICE-Kandidat, nicht eine Docker-interne 172.x-Adresse.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── 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:
|
||||||
|
|
||||||
# ── go2rtc: Kamera-Capture + H.264-Encoding + WebRTC ──────────────────────
|
# ── go2rtc: Kamera-Capture + H.264-Encoding + WebRTC ──────────────────────
|
||||||
@@ -48,17 +24,15 @@ services:
|
|||||||
image: ghcr.io/alexxit/go2rtc
|
image: ghcr.io/alexxit/go2rtc
|
||||||
container_name: AppRobotGo2RTC
|
container_name: AppRobotGo2RTC
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# host-Netzwerk: go2rtc bekommt die echte Host-IP als ICE-Kandidat
|
|
||||||
# → WebRTC funktioniert auf LAN und über die Firewall
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
devices:
|
devices:
|
||||||
- /dev/video0:/dev/video0
|
- /dev/video0:/dev/video0
|
||||||
- /dev/video2:/dev/video2
|
- /dev/video2:/dev/video2
|
||||||
group_add:
|
group_add:
|
||||||
- video
|
- video
|
||||||
configs:
|
volumes:
|
||||||
- source: go2rtc_config
|
# go2rtc.yaml liegt im selben Verzeichnis wie docker-compose.yaml
|
||||||
target: /config/go2rtc.yaml
|
- ${APP_PATH:-.}/go2rtc.yaml:/config/go2rtc.yaml:ro
|
||||||
|
|
||||||
# ── webcam: Node.js (Viewer · Snapshot-Proxy · WebRTC-Signaling-Proxy) ───
|
# ── webcam: Node.js (Viewer · Snapshot-Proxy · WebRTC-Signaling-Proxy) ───
|
||||||
webcam:
|
webcam:
|
||||||
@@ -71,7 +45,7 @@ services:
|
|||||||
image: approbotwebcam:latest
|
image: approbotwebcam:latest
|
||||||
container_name: AppRobotWebcam
|
container_name: AppRobotWebcam
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host # Erreicht go2rtc über localhost:1984
|
network_mode: host
|
||||||
command: sh -c "npm install && node server.js"
|
command: sh -c "npm install && node server.js"
|
||||||
volumes:
|
volumes:
|
||||||
- ${APP_PATH:-.}:/usr/src/app
|
- ${APP_PATH:-.}:/usr/src/app
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ api:
|
|||||||
origin: "*"
|
origin: "*"
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: warn
|
level: info
|
||||||
|
|||||||
@@ -5,66 +5,103 @@ const ICE_SERVERS = [
|
|||||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Wartet bis ICE-Gathering fertig ist (max timeoutMs)
|
function log(camId, msg) {
|
||||||
|
console.log(`[${camId}] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
function waitIceComplete(pc, timeoutMs = 5000) {
|
function waitIceComplete(pc, timeoutMs = 5000) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
if (pc.iceGatheringState === 'complete') { resolve(); return; }
|
if (pc.iceGatheringState === 'complete') { resolve(); return; }
|
||||||
const check = () => { if (pc.iceGatheringState === 'complete') resolve(); };
|
const check = () => { if (pc.iceGatheringState === 'complete') resolve(); };
|
||||||
pc.addEventListener('icegatheringstatechange', check);
|
pc.addEventListener('icegatheringstatechange', check);
|
||||||
setTimeout(() => { pc.removeEventListener('icegatheringstatechange', check); resolve(); }, timeoutMs);
|
setTimeout(() => {
|
||||||
|
pc.removeEventListener('icegatheringstatechange', check);
|
||||||
|
log('ice', `gathering timeout nach ${timeoutMs}ms – sende trotzdem`);
|
||||||
|
resolve();
|
||||||
|
}, timeoutMs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWebRTC(camId, videoEl, statusEl) {
|
async function startWebRTC(camId, videoEl, statusEl) {
|
||||||
statusEl.textContent = 'Verbinde...';
|
setStatus(statusEl, 'Verbinde...', '#888');
|
||||||
|
log(camId, `WebRTC start → /api/webrtc?src=${camId}`);
|
||||||
|
|
||||||
let pc;
|
let pc;
|
||||||
try {
|
try {
|
||||||
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||||
|
|
||||||
// Nur Video empfangen, kein Audio
|
|
||||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
|
||||||
pc.ontrack = ({ streams }) => {
|
pc.onicecandidate = ({ candidate }) => {
|
||||||
if (!streams[0]) return;
|
if (candidate) log(camId, `ICE candidate: ${candidate.type} ${candidate.address ?? '?'}`);
|
||||||
videoEl.srcObject = streams[0];
|
|
||||||
videoEl.play().catch(() => {});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pc.onicegatheringstatechange = () =>
|
||||||
|
log(camId, `ICE gathering: ${pc.iceGatheringState}`);
|
||||||
|
|
||||||
pc.oniceconnectionstatechange = () => {
|
pc.oniceconnectionstatechange = () => {
|
||||||
const s = pc.iceConnectionState;
|
const s = pc.iceConnectionState;
|
||||||
statusEl.textContent = { connected: 'Live ✓', completed: 'Live ✓' }[s] ?? s;
|
log(camId, `ICE connection: ${s}`);
|
||||||
|
const colors = { connected: '#4c4', completed: '#4c4', checking: '#fa0', failed: '#c44', disconnected: '#c44' };
|
||||||
|
setStatus(statusEl, s, colors[s] ?? '#888');
|
||||||
if (s === 'failed' || s === 'closed') {
|
if (s === 'failed' || s === 'closed') {
|
||||||
pc.close();
|
pc.close();
|
||||||
setTimeout(() => startWebRTC(camId, videoEl, statusEl), 4000);
|
setTimeout(() => startWebRTC(camId, videoEl, statusEl), 4000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// SDP Offer erstellen und warten bis alle ICE-Kandidaten gesammelt sind
|
pc.onconnectionstatechange = () =>
|
||||||
|
log(camId, `connection: ${pc.connectionState}`);
|
||||||
|
|
||||||
|
pc.ontrack = ({ streams }) => {
|
||||||
|
log(camId, `Track erhalten: ${streams.length} stream(s)`);
|
||||||
|
if (streams[0]) {
|
||||||
|
videoEl.srcObject = streams[0];
|
||||||
|
videoEl.play().catch(e => log(camId, `play() Fehler: ${e.message}`));
|
||||||
|
setStatus(statusEl, 'Live ✓', '#4c4');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log(camId, 'Erstelle SDP Offer...');
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
log(camId, `ICE gathering wartet (max 5s)...`);
|
||||||
await waitIceComplete(pc);
|
await waitIceComplete(pc);
|
||||||
|
|
||||||
// Signaling über Node.js-Proxy (kein separater go2rtc-Port nach aussen nötig)
|
log(camId, `Sende Offer (${pc.localDescription.sdp.length} Bytes) an Server...`);
|
||||||
const resp = await fetch(`/api/webrtc?src=${encodeURIComponent(camId)}`, {
|
const resp = await fetch(`/api/webrtc?src=${encodeURIComponent(camId)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/sdp' },
|
headers: { 'Content-Type': 'application/sdp' },
|
||||||
body: pc.localDescription.sdp,
|
body: pc.localDescription.sdp,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) throw new Error(`Signaling HTTP ${resp.status}: ${await resp.text()}`);
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`Signaling HTTP ${resp.status}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() });
|
const sdpAnswer = await resp.text();
|
||||||
|
log(camId, `Answer erhalten (${sdpAnswer.length} Bytes)`);
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||||
|
log(camId, 'Remote description gesetzt – warte auf ICE...');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = `Fehler: ${err.message}`;
|
console.error(`[${camId}] Fehler:`, err);
|
||||||
console.error(`[${camId}]`, err);
|
setStatus(statusEl, `${err.message}`, '#c44');
|
||||||
pc?.close();
|
pc?.close();
|
||||||
setTimeout(() => startWebRTC(camId, videoEl, statusEl), 5000);
|
setTimeout(() => startWebRTC(camId, videoEl, statusEl), 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStatus(el, text, color) {
|
||||||
|
el.textContent = text;
|
||||||
|
el.style.color = color ?? '#999';
|
||||||
|
}
|
||||||
|
|
||||||
function createCameraView(camId, container) {
|
function createCameraView(camId, container) {
|
||||||
|
log(camId, 'View erstellt');
|
||||||
|
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'cam-box';
|
box.className = 'cam-box';
|
||||||
|
|
||||||
@@ -72,7 +109,7 @@ function createCameraView(camId, container) {
|
|||||||
video.autoplay = true;
|
video.autoplay = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.style.cssText = 'display:block;width:640px;height:480px;background:#000';
|
video.style.cssText = 'display:block;width:640px;height:480px;background:#111';
|
||||||
box.appendChild(video);
|
box.appendChild(video);
|
||||||
|
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
@@ -101,23 +138,29 @@ function createCameraView(camId, container) {
|
|||||||
startWebRTC(camId, video, status);
|
startWebRTC(camId, video, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kamera-Liste von go2rtc (via Node.js-Proxy), dann Views aufbauen
|
// Kamera-Liste via /api/snapshot (proxied go2rtc /api/streams)
|
||||||
|
log('init', 'Frage Kamera-Liste ab...');
|
||||||
fetch('/api/snapshot')
|
fetch('/api/snapshot')
|
||||||
.then(r => r.json())
|
.then(r => {
|
||||||
|
log('init', `/api/snapshot → HTTP ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
log('init', `Kameras: ${JSON.stringify(data.cameras)}`);
|
||||||
const container = document.getElementById('cameras');
|
const container = document.getElementById('cameras');
|
||||||
const cams = data.cameras ?? [];
|
const cams = data.cameras ?? [];
|
||||||
if (cams.length === 0) {
|
if (cams.length === 0) {
|
||||||
document.getElementById('statusText').textContent = 'Keine Kameras in go2rtc';
|
document.getElementById('statusText').textContent = 'Keine Kameras (go2rtc läuft?)';
|
||||||
|
console.warn('go2rtc meldet keine Streams. Prüfe http://server:1984');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cams.forEach(c => createCameraView(c.id, container));
|
cams.forEach(c => createCameraView(c.id, container));
|
||||||
document.getElementById('statusText').textContent =
|
document.getElementById('statusText').textContent =
|
||||||
`${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
`${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(err => {
|
||||||
// Fallback wenn go2rtc noch nicht läuft
|
console.error('[init] /api/snapshot Fehler:', err);
|
||||||
|
document.getElementById('statusText').textContent = 'API-Fehler – Fallback';
|
||||||
const container = document.getElementById('cameras');
|
const container = document.getElementById('cameras');
|
||||||
['cam0', 'cam1'].forEach(id => createCameraView(id, container));
|
['cam0', 'cam1'].forEach(id => createCameraView(id, container));
|
||||||
document.getElementById('statusText').textContent = 'go2rtc nicht erreichbar – versuche trotzdem';
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user