Claude: Button
This commit is contained in:
@@ -65,11 +65,11 @@ Encoding, ICE-Negotiation, robuster Client mit Auto-Fallback (WebRTC→MSE→MJP
|
|||||||
- [x] Stabile Snapshot-API `/api/snapshot/cam{n}`
|
- [x] Stabile Snapshot-API `/api/snapshot/cam{n}`
|
||||||
- [x] Auflösung fest 640×480 → Latenz „akzeptabel" (war vorher das Hauptproblem)
|
- [x] Auflösung fest 640×480 → Latenz „akzeptabel" (war vorher das Hauptproblem)
|
||||||
|
|
||||||
### Phase 3 – Latenz final tunen (offen)
|
### Phase 3 – Latenz final tunen ✅
|
||||||
- [ ] Messvergleich WebRTC ⟷ MJPEG durchführen → siehe `03_Protocoll_roadmap.md`
|
- [x] Messvergleich WebRTC ⟷ MJPEG: **WebRTC ~130 ms, MJPEG ~200 ms** → WebRTC gewinnt
|
||||||
- [ ] Falls nötig: Auflösung 320×240 testen (kleiner = weniger Browser-Last)
|
- [x] Entscheid: bei WebRTC bleiben (niedrigere Latenz + besser für Internet)
|
||||||
- [ ] Falls nötig: Keyframe-Intervall senken (`-g 15`), zerolatency-Tuning
|
- [ ] Optional: Prüfen ob Kamera natives H.264 liefert (`v4l2-ctl --list-formats`) → kein Re-Encode
|
||||||
- [ ] Prüfen ob Kamera natives H.264 liefert (`v4l2-ctl --list-formats`) → kein Re-Encode
|
- [ ] Optional: Keyframe-Intervall / Encoder-Preset tunen wenn <100 ms gefordert
|
||||||
|
|
||||||
### Phase 4 – Internet-Härtung (offen, vor Produktiv-Schaltung)
|
### Phase 4 – Internet-Härtung (offen, vor Produktiv-Schaltung)
|
||||||
- [ ] **TLS via Caddy** – empfohlen, weil Caddy WebSocket-Proxy nativ und zuverlässig kann.
|
- [ ] **TLS via Caddy** – empfohlen, weil Caddy WebSocket-Proxy nativ und zuverlässig kann.
|
||||||
|
|||||||
@@ -56,12 +56,15 @@ direkt im Browser öffnen (für cam1: `cam0` → `cam1` ersetzen):
|
|||||||
gleichzeitig zeigt (Handy + Monitor zusammen abfotografieren ist am einfachsten).
|
gleichzeitig zeigt (Handy + Monitor zusammen abfotografieren ist am einfachsten).
|
||||||
4. Differenz „echte Zeit ↔ Bild im Stream" ablesen = Gesamt-Latenz pro Protokoll.
|
4. Differenz „echte Zeit ↔ Bild im Stream" ablesen = Gesamt-Latenz pro Protokoll.
|
||||||
|
|
||||||
### Ergebnis-Tabelle (später ausfüllen)
|
### Ergebnis-Tabelle ✅ gemessen 2026-06-03
|
||||||
|
|
||||||
| Kamera | MJPEG roh | WebRTC | MSE | Sieger |
|
Methode: Handy-Stoppuhr (ms) vor Kamera, Foto von Monitor + Stoppuhr.
|
||||||
|--------|-----------|--------|-----|--------|
|
|
||||||
| cam0 | ? ms | ? ms | ? ms | ? |
|
| Kamera | MJPEG | WebRTC | MSE | Sieger |
|
||||||
| cam1 | ? ms | ? ms | ? ms | ? |
|
|--------|-------|--------|-----|--------|
|
||||||
|
| cam0+1 | ~200 ms | ~130 ms | — | **WebRTC** |
|
||||||
|
|
||||||
|
MSE nicht gemessen (erwartet schlechter als beide, für Live nicht relevant).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,21 @@
|
|||||||
padding: 10px 16px; background: #1a1a1a; border-bottom: 1px solid #333;
|
padding: 10px 16px; background: #1a1a1a; border-bottom: 1px solid #333;
|
||||||
}
|
}
|
||||||
h1 { font-size: 1rem; font-weight: normal; letter-spacing: 0.05em; }
|
h1 { font-size: 1rem; font-weight: normal; letter-spacing: 0.05em; }
|
||||||
#statusText { font-size: 0.8rem; color: #888; margin-left: auto; }
|
#statusText { font-size: 0.8rem; color: #888; }
|
||||||
|
|
||||||
|
#snapAllBtn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: #2a4a2a; color: #8f8; border: 1px solid #4a8;
|
||||||
|
padding: 5px 14px; font-family: monospace; font-size: 0.82rem;
|
||||||
|
cursor: pointer; border-radius: 3px; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
#snapAllBtn:hover:not(:disabled) { background: #3a6a3a; }
|
||||||
|
#snapAllBtn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
#cameras { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; }
|
#cameras { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; }
|
||||||
|
|
||||||
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; }
|
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; }
|
||||||
|
|
||||||
/* go2rtc Web-Component */
|
|
||||||
video-stream { display: block; width: 640px; height: 480px; background: #111; }
|
video-stream { display: block; width: 640px; height: 480px; background: #111; }
|
||||||
video-stream video { width: 100%; height: 100%; object-fit: contain; }
|
video-stream video { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
|
||||||
@@ -28,23 +36,17 @@
|
|||||||
background: rgba(0,0,0,.65); padding: 2px 7px; border-radius: 3px;
|
background: rgba(0,0,0,.65); padding: 2px 7px; border-radius: 3px;
|
||||||
font-size: 0.72rem; color: #ccc;
|
font-size: 0.72rem; color: #ccc;
|
||||||
}
|
}
|
||||||
.cam-actions { position: absolute; top: 5px; right: 8px; display: flex; gap: 4px; }
|
|
||||||
.cam-actions button {
|
|
||||||
background: rgba(0,0,0,.65); color: #ccc; border: 1px solid #444;
|
|
||||||
padding: 2px 8px; font-family: monospace; font-size: 0.7rem;
|
|
||||||
cursor: pointer; border-radius: 3px;
|
|
||||||
}
|
|
||||||
.cam-actions button:hover { background: rgba(60,60,60,.8); }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>AppRobotWebcam</h1>
|
<h1>AppRobotWebcam</h1>
|
||||||
<span id="statusText">Verbinde...</span>
|
<span id="statusText">Verbinde...</span>
|
||||||
|
<button id="snapAllBtn" disabled>⬇ Snapshot alle</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="cameras"></div>
|
<div id="cameras"></div>
|
||||||
|
|
||||||
<!-- go2rtc's offizieller Player (über Node-Proxy von go2rtc geladen) -->
|
|
||||||
<script type="module" src="/video-stream.js"></script>
|
<script type="module" src="/video-stream.js"></script>
|
||||||
<script src="viewer.js" defer></script>
|
<script src="viewer.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,16 +3,31 @@
|
|||||||
// go2rtc Player-Modi – Fallback-Reihenfolge: WebRTC → MSE → MJPEG
|
// go2rtc Player-Modi – Fallback-Reihenfolge: WebRTC → MSE → MJPEG
|
||||||
const MODE = 'webrtc,mse,mjpeg';
|
const MODE = 'webrtc,mse,mjpeg';
|
||||||
|
|
||||||
// ── Logging (sichtbar in Browser DevTools → Console → F12) ──────────────────
|
// ── Logging (Browser DevTools → Console → F12) ───────────────────────────────
|
||||||
const P = '[WebcamViewer]';
|
const P = '[WebcamViewer]';
|
||||||
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
|
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
|
||||||
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
|
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
|
||||||
const err = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
|
const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
|
||||||
|
|
||||||
|
// ── Snapshot aller Kameras gleichzeitig ──────────────────────────────────────
|
||||||
|
// Auflösung = was go2rtc im MJPEG-Stream hält (aktuell 640×480 gemäss Config).
|
||||||
|
// Für höhere Auflösung: in go2rtc.yaml einen separaten Hi-Res-Stream definieren
|
||||||
|
// und hier auf dessen /api/frame.jpeg?src=cam0_hires zeigen.
|
||||||
|
function snapshotAll(camIds) {
|
||||||
|
const ts = Date.now();
|
||||||
|
log('snap', `Snapshot alle Kameras: ${camIds.join(', ')}`);
|
||||||
|
camIds.forEach(id => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/api/snapshot/${id}`;
|
||||||
|
a.download = `${id}_${ts}.jpg`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Kamera-View aufbauen ─────────────────────────────────────────────────────
|
// ── Kamera-View aufbauen ─────────────────────────────────────────────────────
|
||||||
function buildCamera(camId, go2rtcPort, container) {
|
function buildCamera(camId, go2rtcPort, container) {
|
||||||
// WebSocket direkt zu go2rtc – kein Proxy-Zwischenschritt, garantiert stabil.
|
|
||||||
// Protokoll: ws:// auf LAN (http). Für Internet mit TLS wird aus ws: wss: (Caddy).
|
|
||||||
const wsUrl = `ws://${location.hostname}:${go2rtcPort}/api/ws?src=${encodeURIComponent(camId)}`;
|
const wsUrl = `ws://${location.hostname}:${go2rtcPort}/api/ws?src=${encodeURIComponent(camId)}`;
|
||||||
log(camId, `View erstellt mode="${MODE}" ws=${wsUrl}`);
|
log(camId, `View erstellt mode="${MODE}" ws=${wsUrl}`);
|
||||||
|
|
||||||
@@ -22,16 +37,15 @@ function buildCamera(camId, go2rtcPort, container) {
|
|||||||
const stream = document.createElement('video-stream');
|
const stream = document.createElement('video-stream');
|
||||||
stream.mode = MODE;
|
stream.mode = MODE;
|
||||||
|
|
||||||
// Events vom inneren <video>-Element (capture-Phase)
|
|
||||||
stream.addEventListener('play', () => log(camId, '▶ spielt'), true);
|
stream.addEventListener('play', () => log(camId, '▶ spielt'), true);
|
||||||
stream.addEventListener('playing', () => log(camId, '▶ Bild läuft'), true);
|
stream.addEventListener('playing', () => log(camId, '▶ Bild läuft'), true);
|
||||||
stream.addEventListener('pause', () => warn(camId, 'pausiert'), true);
|
stream.addEventListener('pause', () => warn(camId, 'pausiert'), true);
|
||||||
stream.addEventListener('stalled', () => warn(camId, 'stalled (keine Daten)'), true);
|
stream.addEventListener('stalled', () => warn(camId, 'stalled (keine Daten)'), true);
|
||||||
stream.addEventListener('waiting', () => warn(camId, 'waiting (Buffer leer)'), true);
|
stream.addEventListener('waiting', () => warn(camId, 'waiting (Buffer leer)'), true);
|
||||||
stream.addEventListener('error', (e) => err(camId, 'Video-Fehler', e), true);
|
stream.addEventListener('error', (e) => logErr(camId, 'Video-Fehler', e), true);
|
||||||
|
|
||||||
log(camId, `Verbinde WebSocket → ${wsUrl}`);
|
log(camId, `Verbinde WebSocket → ${wsUrl}`);
|
||||||
stream.src = wsUrl; // setzt den VideoRTC-src und startet die Verbindung
|
stream.src = wsUrl;
|
||||||
|
|
||||||
box.appendChild(stream);
|
box.appendChild(stream);
|
||||||
|
|
||||||
@@ -40,20 +54,6 @@ function buildCamera(camId, go2rtcPort, container) {
|
|||||||
label.textContent = camId;
|
label.textContent = camId;
|
||||||
box.appendChild(label);
|
box.appendChild(label);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'cam-actions';
|
|
||||||
const snapBtn = document.createElement('button');
|
|
||||||
snapBtn.textContent = 'Snapshot';
|
|
||||||
snapBtn.onclick = () => {
|
|
||||||
log(camId, `Snapshot → /api/snapshot/${camId}`);
|
|
||||||
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);
|
container.appendChild(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,30 +61,27 @@ function buildCamera(camId, go2rtcPort, container) {
|
|||||||
async function init() {
|
async function init() {
|
||||||
log('init', 'Starte...');
|
log('init', 'Starte...');
|
||||||
|
|
||||||
// go2rtc-Port vom Server holen (damit er nicht im Client hart kodiert ist)
|
|
||||||
let go2rtcPort = 1984;
|
let go2rtcPort = 1984;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/config.json');
|
const r = await fetch('/config.json');
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
go2rtcPort = d.go2rtcPort ?? 1984;
|
go2rtcPort = d.go2rtcPort ?? 1984;
|
||||||
log('init', `go2rtc WebSocket-Port: ${go2rtcPort}`);
|
log('init', `go2rtc WS-Port: ${go2rtcPort}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn('init', `Konnte /config.json nicht laden, nehme Port ${go2rtcPort}`);
|
warn('init', `Konnte /config.json nicht laden, nehme Port ${go2rtcPort}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// go2rtc Web-Component muss geladen sein bevor .src gesetzt wird
|
|
||||||
try {
|
try {
|
||||||
await customElements.whenDefined('video-stream');
|
await customElements.whenDefined('video-stream');
|
||||||
log('init', '<video-stream> definiert');
|
log('init', '<video-stream> definiert');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err('init', '<video-stream> nicht geladen – /video-stream.js erreichbar?', e);
|
logErr('init', '<video-stream> nicht geladen – /video-stream.js erreichbar?', e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.getElementById('cameras');
|
const container = document.getElementById('cameras');
|
||||||
const statusText = document.getElementById('statusText');
|
const statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
// Kamera-Liste von go2rtc via Node-Proxy
|
|
||||||
let cams = [];
|
let cams = [];
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/snapshot');
|
const r = await fetch('/api/snapshot');
|
||||||
@@ -95,7 +92,7 @@ async function init() {
|
|||||||
log('init', `Kameras: ${cams.join(', ') || '(keine)'}`);
|
log('init', `Kameras: ${cams.join(', ') || '(keine)'}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err('init', '/api/snapshot Fehler – Fallback', e);
|
logErr('init', '/api/snapshot Fehler – Fallback', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cams.length === 0) {
|
if (cams.length === 0) {
|
||||||
@@ -103,6 +100,13 @@ async function init() {
|
|||||||
cams = ['cam0', 'cam1'];
|
cams = ['cam0', 'cam1'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Globaler Snapshot-Button in der Header-Bar verdrahten
|
||||||
|
const snapAllBtn = document.getElementById('snapAllBtn');
|
||||||
|
if (snapAllBtn) {
|
||||||
|
snapAllBtn.onclick = () => snapshotAll(cams);
|
||||||
|
snapAllBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
cams.forEach(id => buildCamera(id, go2rtcPort, container));
|
cams.forEach(id => buildCamera(id, go2rtcPort, container));
|
||||||
statusText.textContent = `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
statusText.textContent = `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`;
|
||||||
log('init', 'Fertig');
|
log('init', 'Fertig');
|
||||||
|
|||||||
Reference in New Issue
Block a user