From 1a712ed877b6fe30794b7f44b9665f1a70895a57 Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:15:13 +0200 Subject: [PATCH] Claude: WebRTC --- docker-compose.yaml | 44 +++++------------------ go2rtc.yaml | 2 +- public/viewer.js | 87 +++++++++++++++++++++++++++++++++------------ 3 files changed, 75 insertions(+), 58 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index b839dc8..eb12647 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,43 +4,19 @@ name: approbotwebcam # # Voraussetzungen: # 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 # # 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. +# network_mode: host → beide Container teilen den Host-Netzwerk-Stack. +# 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: # ── go2rtc: Kamera-Capture + H.264-Encoding + WebRTC ────────────────────── @@ -48,17 +24,15 @@ services: image: ghcr.io/alexxit/go2rtc container_name: AppRobotGo2RTC 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 devices: - /dev/video0:/dev/video0 - /dev/video2:/dev/video2 group_add: - video - configs: - - source: go2rtc_config - target: /config/go2rtc.yaml + volumes: + # go2rtc.yaml liegt im selben Verzeichnis wie docker-compose.yaml + - ${APP_PATH:-.}/go2rtc.yaml:/config/go2rtc.yaml:ro # ── webcam: Node.js (Viewer · Snapshot-Proxy · WebRTC-Signaling-Proxy) ─── webcam: @@ -71,7 +45,7 @@ services: image: approbotwebcam:latest container_name: AppRobotWebcam restart: unless-stopped - network_mode: host # Erreicht go2rtc über localhost:1984 + network_mode: host command: sh -c "npm install && node server.js" volumes: - ${APP_PATH:-.}:/usr/src/app diff --git a/go2rtc.yaml b/go2rtc.yaml index 53fb32d..e9b5e0c 100644 --- a/go2rtc.yaml +++ b/go2rtc.yaml @@ -20,4 +20,4 @@ api: origin: "*" log: - level: warn + level: info diff --git a/public/viewer.js b/public/viewer.js index 7be31af..d8ac5e3 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -5,66 +5,103 @@ const ICE_SERVERS = [ { 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) { 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); + setTimeout(() => { + pc.removeEventListener('icegatheringstatechange', check); + log('ice', `gathering timeout nach ${timeoutMs}ms – sende trotzdem`); + resolve(); + }, timeoutMs); }); } async function startWebRTC(camId, videoEl, statusEl) { - statusEl.textContent = 'Verbinde...'; + setStatus(statusEl, 'Verbinde...', '#888'); + log(camId, `WebRTC start → /api/webrtc?src=${camId}`); 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.onicecandidate = ({ candidate }) => { + if (candidate) log(camId, `ICE candidate: ${candidate.type} ${candidate.address ?? '?'}`); }; + pc.onicegatheringstatechange = () => + log(camId, `ICE gathering: ${pc.iceGatheringState}`); + pc.oniceconnectionstatechange = () => { 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') { pc.close(); 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(); await pc.setLocalDescription(offer); + + log(camId, `ICE gathering wartet (max 5s)...`); 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)}`, { method: 'POST', headers: { 'Content-Type': 'application/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) { - statusEl.textContent = `Fehler: ${err.message}`; - console.error(`[${camId}]`, err); + console.error(`[${camId}] Fehler:`, err); + setStatus(statusEl, `${err.message}`, '#c44'); pc?.close(); setTimeout(() => startWebRTC(camId, videoEl, statusEl), 5000); } } +function setStatus(el, text, color) { + el.textContent = text; + el.style.color = color ?? '#999'; +} + function createCameraView(camId, container) { + log(camId, 'View erstellt'); + const box = document.createElement('div'); box.className = 'cam-box'; @@ -72,7 +109,7 @@ function createCameraView(camId, container) { video.autoplay = true; video.playsInline = 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); const label = document.createElement('div'); @@ -101,23 +138,29 @@ function createCameraView(camId, container) { 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') - .then(r => r.json()) + .then(r => { + log('init', `/api/snapshot → HTTP ${r.status}`); + return r.json(); + }) .then(data => { + log('init', `Kameras: ${JSON.stringify(data.cameras)}`); const container = document.getElementById('cameras'); const cams = data.cameras ?? []; 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; } cams.forEach(c => createCameraView(c.id, container)); document.getElementById('statusText').textContent = `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`; }) - .catch(() => { - // Fallback wenn go2rtc noch nicht läuft + .catch(err => { + console.error('[init] /api/snapshot Fehler:', err); + document.getElementById('statusText').textContent = 'API-Fehler – Fallback'; const container = document.getElementById('cameras'); ['cam0', 'cam1'].forEach(id => createCameraView(id, container)); - document.getElementById('statusText').textContent = 'go2rtc nicht erreichbar – versuche trotzdem'; });