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

@@ -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
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';
});