Umbau mit cameraSwitch
This commit is contained in:
@@ -39,11 +39,8 @@
|
||||
|
||||
.cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; min-width: 320px; }
|
||||
|
||||
video-stream { display: block; width: 640px; height: 480px; background: #111; }
|
||||
video-stream video { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* Eingefrorener Frame während des Hi-Res-Tests (Phase 1) */
|
||||
.cam-freeze { display: block; width: 640px; height: 480px; background: #111; }
|
||||
/* Live-MJPEG (multipart/x-mixed-replace) – nativ im <img> */
|
||||
.cam-img { display: block; width: 640px; height: 480px; background: #111; object-fit: contain; }
|
||||
|
||||
.cam-label {
|
||||
position: absolute; top: 5px; left: 8px; z-index: 2;
|
||||
@@ -90,7 +87,6 @@
|
||||
<div id="notice"></div>
|
||||
<div id="cameras"></div>
|
||||
|
||||
<script type="module" src="/video-stream.js"></script>
|
||||
<script src="viewer.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
455
public/viewer.js
455
public/viewer.js
@@ -1,404 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
// go2rtc Player-Modi.
|
||||
// 'mjpeg' → Passthrough: go2rtc reicht Kamera-MJPEG 1:1 durch.
|
||||
// KEIN Transcode → ~0% CPU, keine Freezes, ~200ms Latenz.
|
||||
// 'webrtc,mse,mjpeg' → WebRTC bevorzugt. ABER: WebRTC/MSE können kein MJPEG →
|
||||
// go2rtc transcodiert MJPEG→H.264 in Software (libx264) →
|
||||
// ~55% CPU pro Kamera + Keyframe-Freezes. ~130ms Latenz.
|
||||
const MODE = 'mjpeg';
|
||||
const IS_MJPEG = !MODE.includes('webrtc'); // MJPEG-Modus: kein WebRTC/getStats/Health
|
||||
// ── Architektur ───────────────────────────────────────────────────────────────
|
||||
// Der Server (Node) besitzt die Kameras und liefert den Live-Stream als
|
||||
// MJPEG multipart/x-mixed-replace unter /api/stream/<id>. Der Browser rendert
|
||||
// das nativ in einem <img>. KEIN WebRTC, KEIN go2rtc, kein Transcode.
|
||||
//
|
||||
// HD-Snapshot: GET /api/snapshot/<id>/hires. Der Server-Schalter pausiert dafür
|
||||
// den Live-FFmpeg kurz (~1–2 s), greift 1280×960, schaltet zurück. Der <img>-
|
||||
// Stream friert in dieser Zeit ein und läuft danach weiter – kein Client-Handling
|
||||
// nötig (das war früher die Fehlerquelle).
|
||||
|
||||
// ── Überwachungs-Parameter ───────────────────────────────────────────────────
|
||||
const MONITOR_INTERVAL = 2500; // ms zwischen Health-Checks (getStats)
|
||||
const CONNECT_GRACE_MS = 25000; // so lange darf der Verbindungsaufbau dauern (kein Alarm)
|
||||
const WARMUP_MS = 15000; // Karenz nach 'playing', bis Überlast-Erkennung scharf wird
|
||||
const OVERLOAD_TICKS = 3; // so viele kritische Checks in Folge → Auto-Abschaltung
|
||||
|
||||
// Schwellen (auf Basis der ZUVERLÄSSIGEN getStats-Werte, nicht Render-Drops):
|
||||
const SERVER_LOW_FPS = 12; // recv < 12/s nach Aufwärmen → Server liefert wenig (nur Warnung)
|
||||
const CLIENT_DECODE_RATIO = 0.6; // decoded < 60% von recv → Decoder kommt nicht nach (echte Client-Überlast)
|
||||
const NET_LOST_PER_TICK = 5; // mehr verlorene Pakete/Intervall → Netz-Warnung
|
||||
const NET_JITTER_MS = 60; // mehr Jitter → Netz-Warnung
|
||||
|
||||
// ── Logging (Browser DevTools → Console → F12) ───────────────────────────────
|
||||
const P = '[WebcamViewer]';
|
||||
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
|
||||
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
|
||||
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
|
||||
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
|
||||
const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let GO2RTC_PORT = 1984;
|
||||
const cameras = []; // { id, box, infoEl, toggleBtn, active, startedAt, playingSince, statsLast, badTicks, autoOff }
|
||||
const cameras = []; // { id, box, img, infoEl, toggleBtn, hdBtn, active, busy }
|
||||
|
||||
// ── Stream starten / stoppen ─────────────────────────────────────────────────
|
||||
// ── Live-Stream an/aus ────────────────────────────────────────────────────────
|
||||
function startStream(cam) {
|
||||
if (cam.box.querySelector('video-stream')) return;
|
||||
const wsUrl = `ws://${location.hostname}:${GO2RTC_PORT}/api/ws?src=${encodeURIComponent(cam.id)}`;
|
||||
log(cam.id, `Verbinde → ${wsUrl}`);
|
||||
|
||||
cam.active = true;
|
||||
cam.startedAt = performance.now();
|
||||
cam.playingSince = null; // wird erst beim 'playing'-Event gesetzt
|
||||
cam.statsLast = null;
|
||||
cam.badTicks = 0;
|
||||
|
||||
const stream = document.createElement('video-stream');
|
||||
stream.mode = MODE;
|
||||
stream.addEventListener('playing', () => {
|
||||
cam.playingSince = performance.now();
|
||||
cam.statsLast = null;
|
||||
cam.badTicks = 0;
|
||||
log(cam.id, '▶ Bild läuft (Aufwärmphase startet)');
|
||||
}, true);
|
||||
stream.addEventListener('error', (e) => logErr(cam.id, 'Video-Fehler', e), true);
|
||||
stream.src = wsUrl;
|
||||
|
||||
cam.box.insertBefore(stream, cam.box.firstChild);
|
||||
cam.active = true;
|
||||
// Cache-Buster erzwingt eine frische Verbindung (sonst hängt Reconnect manchmal)
|
||||
cam.img.src = `/api/stream/${encodeURIComponent(cam.id)}?t=${Date.now()}`;
|
||||
cam.toggleBtn.textContent = '⏸';
|
||||
cam.toggleBtn.title = 'Stream ausschalten';
|
||||
setInfo(cam, 'verbindet…', '');
|
||||
log(cam.id, 'Live an');
|
||||
}
|
||||
|
||||
function stopStream(cam, auto = false) {
|
||||
const el = cam.box.querySelector('video-stream');
|
||||
if (el) el.remove();
|
||||
function stopStream(cam) {
|
||||
cam.active = false;
|
||||
cam.autoOff = auto;
|
||||
cam.playingSince = null;
|
||||
cam.img.removeAttribute('src'); // schließt die multipart-Verbindung
|
||||
cam.toggleBtn.textContent = '▶';
|
||||
cam.toggleBtn.title = 'Stream einschalten';
|
||||
setInfo(cam, auto ? 'auto-aus (Client überlastet)' : 'aus', auto ? 'crit' : '');
|
||||
log(cam.id, auto ? 'AUTO-abgeschaltet (Decoder kam nicht nach)' : 'manuell aus');
|
||||
if (auto) showNotice();
|
||||
setInfo(cam, 'aus', '');
|
||||
log(cam.id, 'Live aus');
|
||||
}
|
||||
|
||||
// ── Hi-Res Canvas-Freeze + Grab (Phase 2) ───────────────────────────────────
|
||||
|
||||
// Holt letzten 640er-Frame von /api/snapshot und zeichnet ihn auf canvas.
|
||||
// Robuster als drawImage(video-stream): go2rtc MJPEG-Modus hat kein <video>-Element.
|
||||
async function showFreezeCanvas(cam, badgeText = 'Capturing HD…') {
|
||||
removeFreezeCanvas(cam);
|
||||
const W = 640, H = 480;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'cam-freeze';
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}`, { cache: 'no-store' });
|
||||
if (r.ok) {
|
||||
const url = URL.createObjectURL(await r.blob());
|
||||
await new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => { ctx.drawImage(img, 0, 0, W, H); URL.revokeObjectURL(url); resolve(); };
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
} catch (e) { logErr(cam.id, 'Freeze-Frame holen', e); }
|
||||
|
||||
drawBadge(ctx, W, H, badgeText, '#8cf');
|
||||
cam.box.insertBefore(canvas, cam.box.firstChild);
|
||||
cam.freezeCanvas = canvas;
|
||||
}
|
||||
|
||||
function removeFreezeCanvas(cam) {
|
||||
if (cam.freezeCanvas) { cam.freezeCanvas.remove(); cam.freezeCanvas = null; }
|
||||
}
|
||||
|
||||
function drawBadge(ctx, W, H, text, color = '#8cf') {
|
||||
const bw = W * 0.38, bh = 34, m = 12;
|
||||
const bx = W - bw - m, by = H - bh - m;
|
||||
ctx.fillStyle = 'rgba(0,0,0,.75)';
|
||||
ctx.fillRect(bx, by, bw, bh);
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '13px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, bx + bw / 2, by + bh / 2);
|
||||
}
|
||||
|
||||
function updateBadge(cam, text, color) {
|
||||
if (!cam.freezeCanvas) return;
|
||||
drawBadge(cam.freezeCanvas.getContext('2d'), 640, 480, text, color);
|
||||
}
|
||||
|
||||
// ── Phase 2: Hi-Res-Grab ─────────────────────────────────────────────────────
|
||||
// Ablauf (doc/05_screenShot_roadmap.md, Phase 2):
|
||||
// 1. Live-Frame einfrieren + cam loslassen (Consumer → 0)
|
||||
// 2. Server wartet auf Freigabe (cam0 Producer stoppt), greift dann cam0_hires
|
||||
// 3. HD-JPEG im Canvas zeigen + Download auslösen
|
||||
// 4. finally: immer zurück auf Live (cam0 bleibt unberührt → sauberer Reconnect)
|
||||
// ── HD-Snapshot ───────────────────────────────────────────────────────────────
|
||||
async function runHiresGrab(cam) {
|
||||
if (cam.testing) return;
|
||||
cam.testing = true;
|
||||
if (cam.busy) return;
|
||||
cam.busy = true;
|
||||
cam.hdBtn.disabled = true;
|
||||
setInfo(cam, 'HD: erfasse… (Stream friert kurz)', 'warn');
|
||||
log(cam.id, '── HD-Grab gestartet ──');
|
||||
|
||||
let blobUrl = null;
|
||||
try {
|
||||
// 1. Freeze-Frame zeigen (echter 640er-Frame, kein grauer Kasten)
|
||||
await showFreezeCanvas(cam, 'Capturing HD…');
|
||||
stopStream(cam);
|
||||
setInfo(cam, 'HD: warte auf Freigabe…', 'warn');
|
||||
|
||||
// 2. HD-Grab – Server pollt Freigabe, holt dann cam_hires-Frame.
|
||||
// Client-Timeout (20s) > Server-Maximum (~12s: 8s Warten + 4×0.8s Retries)
|
||||
const r = await fetch(
|
||||
`/api/snapshot/${encodeURIComponent(cam.id)}/hires`,
|
||||
{ signal: AbortSignal.timeout(20000) }
|
||||
);
|
||||
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/hires`, { signal: AbortSignal.timeout(20000) });
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `HTTP ${r.status}`);
|
||||
}
|
||||
|
||||
const blob = await r.blob();
|
||||
const width = r.headers.get('X-Frame-Width') || '?';
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 3a. HD-Frame im Canvas zeigen (skaliert auf 640px, volle Qualität)
|
||||
if (cam.freezeCanvas) {
|
||||
const ctx = cam.freezeCanvas.getContext('2d');
|
||||
await new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => { ctx.drawImage(img, 0, 0, 640, 480); resolve(); };
|
||||
img.onerror = resolve;
|
||||
img.src = blobUrl;
|
||||
});
|
||||
updateBadge(cam, 'HD ✓ speichere…', '#8f8');
|
||||
}
|
||||
|
||||
// 3b. Download auslösen
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.href = blobUrl;
|
||||
a.download = `${cam.id}_hires_${Date.now()}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
|
||||
setInfo(cam, 'HD gespeichert', 'ok');
|
||||
log(cam.id, `HD-Grab OK – ${blob.size} bytes`);
|
||||
|
||||
setInfo(cam, `HD gespeichert (${width}px)`, 'ok');
|
||||
log(cam.id, `HD-Grab OK – ${blob.size} bytes, ${width}px`);
|
||||
} catch (e) {
|
||||
logErr(cam.id, 'HD-Grab fehlgeschlagen', e);
|
||||
setInfo(cam, `HD Fehler: ${e.message}`, 'crit');
|
||||
} finally {
|
||||
// 4. Immer: kurz warten (go2rtc cam_hires freigeben), dann Live zurück
|
||||
await sleep(600);
|
||||
removeFreezeCanvas(cam);
|
||||
if (blobUrl) { URL.revokeObjectURL(blobUrl); blobUrl = null; }
|
||||
startStream(cam);
|
||||
cam.testing = false;
|
||||
if (blobUrl) setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
|
||||
cam.busy = false;
|
||||
cam.hdBtn.disabled = false;
|
||||
log(cam.id, '── HD-Grab beendet, zurück auf Live ──');
|
||||
log(cam.id, '── HD-Grab beendet ──');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Health-Anzeige ───────────────────────────────────────────────────────────
|
||||
// ── HD-Snapshot aller Kameras (parallel) ──────────────────────────────────────
|
||||
// cam0/cam1 liegen auf getrennten Geräten → der Schalter grabbt beide parallel
|
||||
// gefahrlos (jeder Schalter steuert nur sein eigenes Gerät).
|
||||
async function snapshotAllHires() {
|
||||
const snapBtn = document.getElementById('snapAllBtn');
|
||||
if (snapBtn) snapBtn.disabled = true;
|
||||
log('snap', `HD-Grab alle: ${cameras.map((c) => c.id).join(', ')}`);
|
||||
try {
|
||||
await Promise.allSettled(cameras.map((c) => runHiresGrab(c)));
|
||||
} finally {
|
||||
if (snapBtn) snapBtn.disabled = false;
|
||||
log('snap', '── HD-Grab alle beendet ──');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status-Anzeige ────────────────────────────────────────────────────────────
|
||||
function setInfo(cam, text, cls) {
|
||||
cam.infoEl.textContent = text;
|
||||
cam.infoEl.className = 'cam-info ' + (cls ?? '');
|
||||
}
|
||||
|
||||
function showConnecting(cam) {
|
||||
const secs = Math.round((performance.now() - cam.startedAt) / 1000);
|
||||
setInfo(cam, secs < CONNECT_GRACE_MS / 1000 ? `verbindet… ${secs}s` : 'kein Signal',
|
||||
secs < CONNECT_GRACE_MS / 1000 ? '' : 'crit');
|
||||
}
|
||||
|
||||
// ── Monitor: liest getStats (inbound-rtp) = die verlässliche Wahrheit ─────────
|
||||
// recv = Frames über Netz → niedrig = Server liefert nicht (Encode-CPU)
|
||||
// decoded = davon dekodiert → deutlich < recv = Client-Decoder überlastet
|
||||
// lost/jitter → Netz/WiFi
|
||||
async function monitor() {
|
||||
const now = performance.now();
|
||||
|
||||
for (const cam of cameras) {
|
||||
if (!cam.active) continue;
|
||||
|
||||
// MJPEG-Passthrough: kein PeerConnection, kein getStats, keine Decoder-Überlast.
|
||||
// Das Bild läuft, sobald das Element da ist – nur simple Status-Anzeige.
|
||||
if (IS_MJPEG) {
|
||||
const live = !!cam.box.querySelector('video-stream');
|
||||
setInfo(cam, live ? 'MJPEG · live' : 'aus', live ? 'ok' : '');
|
||||
continue;
|
||||
}
|
||||
|
||||
const el = cam.box.querySelector('video-stream');
|
||||
const pc = el && el.pc;
|
||||
// Noch nicht verbunden oder 'playing' noch nicht gefeuert → Aufbauphase
|
||||
if (!pc || typeof pc.getStats !== 'function' || cam.playingSince === null) {
|
||||
showConnecting(cam);
|
||||
continue;
|
||||
}
|
||||
|
||||
let stats;
|
||||
try { stats = await pc.getStats(); } catch { continue; }
|
||||
let v = null;
|
||||
stats.forEach(r => { if (r.type === 'inbound-rtp' && r.kind === 'video') v = r; });
|
||||
if (!v) { showConnecting(cam); continue; }
|
||||
|
||||
const cur = {
|
||||
t: v.timestamp,
|
||||
recv: v.framesReceived ?? 0,
|
||||
dec: v.framesDecoded ?? 0,
|
||||
drop: v.framesDropped ?? 0,
|
||||
lost: v.packetsLost ?? 0,
|
||||
bytes: v.bytesReceived ?? 0,
|
||||
};
|
||||
const last = cam.statsLast;
|
||||
cam.statsLast = cur;
|
||||
|
||||
// Erster Messpunkt oder Zähler-Reset → nur Baseline
|
||||
if (!last || cur.t <= last.t || cur.recv < last.recv) {
|
||||
setInfo(cam, 'misst…', '');
|
||||
continue;
|
||||
}
|
||||
|
||||
const dt = (cur.t - last.t) / 1000;
|
||||
const recvPs = Math.round((cur.recv - last.recv) / dt);
|
||||
const decPs = Math.round((cur.dec - last.dec) / dt);
|
||||
const dropPs = Math.round((cur.drop - last.drop) / dt);
|
||||
const lostD = Math.max(0, cur.lost - last.lost);
|
||||
const mbps = (((cur.bytes - last.bytes) * 8) / dt / 1e6).toFixed(2);
|
||||
const jitter = v.jitter != null ? Math.round(v.jitter * 1000) : 0;
|
||||
const size = (v.frameWidth && v.frameHeight) ? `${v.frameWidth}x${v.frameHeight}` : '?';
|
||||
|
||||
log(cam.id, `[stats] recv=${recvPs}/s decoded=${decPs}/s dropped=${dropPs}/s ` +
|
||||
`lost=+${lostD} jitter=${jitter}ms ${size} ${mbps}Mbps`);
|
||||
|
||||
// Während Aufwärmphase: anzeigen, aber NICHT bewerten
|
||||
if (now - cam.playingSince < WARMUP_MS) {
|
||||
const secs = Math.ceil((WARMUP_MS - (now - cam.playingSince)) / 1000);
|
||||
setInfo(cam, `startet… ${decPs} fps (${secs}s)`, '');
|
||||
cam.badTicks = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Bewertung nach Aufwärmphase ──
|
||||
const clientOverload = recvPs >= SERVER_LOW_FPS && decPs < recvPs * CLIENT_DECODE_RATIO;
|
||||
const serverLow = recvPs < SERVER_LOW_FPS;
|
||||
const netBad = lostD > NET_LOST_PER_TICK || jitter > NET_JITTER_MS;
|
||||
|
||||
if (clientOverload) {
|
||||
setInfo(cam, `Decoder hängt ${decPs}/${recvPs} fps`, 'crit');
|
||||
cam.badTicks++;
|
||||
} else {
|
||||
cam.badTicks = 0;
|
||||
if (serverLow) setInfo(cam, `Server liefert ${recvPs}/s`, 'warn');
|
||||
else if (netBad) setInfo(cam, `${decPs} fps · ⚠ ${jitter}ms / lost+${lostD}`, 'warn');
|
||||
else setInfo(cam, `${decPs} fps · ${mbps} Mbps`, 'ok');
|
||||
}
|
||||
|
||||
// Auto-Schutz NUR bei echter Client-Überlast (Decoder kommt nicht nach)
|
||||
if (cam.badTicks >= OVERLOAD_TICKS) {
|
||||
warn(cam.id, `Decoder überlastet (${cam.badTicks}× kritisch) → Auto-Abschaltung`);
|
||||
stopStream(cam, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hinweis-Banner bei Auto-Abschaltung ──────────────────────────────────────
|
||||
function showNotice() {
|
||||
const off = cameras.filter(c => c.autoOff && !c.active).map(c => c.id);
|
||||
const bar = document.getElementById('notice');
|
||||
if (off.length === 0) { bar.style.display = 'none'; return; }
|
||||
bar.innerHTML = `⚠ Client überlastet – automatisch deaktiviert: <b>${off.join(', ')}</b> `;
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Wieder aktivieren';
|
||||
btn.onclick = () => {
|
||||
cameras.filter(c => c.autoOff && !c.active).forEach(c => { c.autoOff = false; startStream(c); });
|
||||
showNotice();
|
||||
};
|
||||
bar.appendChild(btn);
|
||||
bar.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── HD-Snapshot aller Kameras (parallel) ─────────────────────────────────────
|
||||
// cam0 und cam1 liegen auf getrennten Geräten → gleichzeitiger Grab sicher.
|
||||
// Alle Live-Streams werden synchron eingefroren und losgelassen, dann beide
|
||||
// /hires-Requests parallel gefeuert. finally stellt immer alle zurück.
|
||||
async function snapshotAllHires() {
|
||||
if (cameras.some(c => c.testing)) return;
|
||||
|
||||
const snapBtn = document.getElementById('snapAllBtn');
|
||||
if (snapBtn) snapBtn.disabled = true;
|
||||
cameras.forEach(c => { c.testing = true; c.hdBtn.disabled = true; });
|
||||
log('snap', `HD-Grab alle: ${cameras.map(c => c.id).join(', ')}`);
|
||||
|
||||
try {
|
||||
// 1. Alle Freeze-Canvases gleichzeitig aufbauen (je ein /api/snapshot-Fetch)
|
||||
await Promise.all(cameras.map(c => showFreezeCanvas(c, 'Capturing HD…')));
|
||||
|
||||
// 2. Alle Live-Streams synchron loslassen → alle Consumer fallen gleichzeitig auf 0
|
||||
cameras.forEach(c => stopStream(c));
|
||||
|
||||
const ts = Date.now();
|
||||
|
||||
// 3. Alle /hires-Grabs parallel – Fehler einer Kamera blockieren die andere nicht
|
||||
await Promise.allSettled(cameras.map(async c => {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/snapshot/${encodeURIComponent(c.id)}/hires`,
|
||||
{ signal: AbortSignal.timeout(20000) }
|
||||
);
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `HTTP ${r.status}`);
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (c.freezeCanvas) {
|
||||
const ctx = c.freezeCanvas.getContext('2d');
|
||||
await new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => { ctx.drawImage(img, 0, 0, 640, 480); resolve(); };
|
||||
img.onerror = resolve;
|
||||
img.src = blobUrl;
|
||||
});
|
||||
updateBadge(c, 'HD ✓', '#8f8');
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${c.id}_hires_${ts}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
|
||||
setInfo(c, 'HD gespeichert', 'ok');
|
||||
log(c.id, `HD-Grab OK – ${blob.size} bytes`);
|
||||
} catch (e) {
|
||||
logErr(c.id, 'HD-Grab fehlgeschlagen', e);
|
||||
setInfo(c, `HD Fehler: ${e.message}`, 'crit');
|
||||
}
|
||||
}));
|
||||
|
||||
} finally {
|
||||
// 4. Immer: alle zurück auf Live
|
||||
await sleep(600);
|
||||
cameras.forEach(c => {
|
||||
removeFreezeCanvas(c);
|
||||
startStream(c);
|
||||
c.testing = false;
|
||||
c.hdBtn.disabled = false;
|
||||
});
|
||||
if (snapBtn) snapBtn.disabled = false;
|
||||
log('snap', '── HD-Grab alle beendet, alle zurück auf Live ──');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kamera-View aufbauen ─────────────────────────────────────────────────────
|
||||
// ── Kamera-View aufbauen ──────────────────────────────────────────────────────
|
||||
function buildCamera(camId, container) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'cam-box';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'cam-img';
|
||||
img.alt = camId;
|
||||
img.addEventListener('load', () => { if (cam.active && !cam.busy) setInfo(cam, 'MJPEG · live', 'ok'); });
|
||||
img.addEventListener('error', () => {
|
||||
if (!cam.active) return;
|
||||
setInfo(cam, 'Verbindungsfehler – neu…', 'crit');
|
||||
// Auto-Reconnect nach kurzer Pause (nicht während HD-Grab)
|
||||
setTimeout(() => { if (cam.active && !cam.busy) startStream(cam); }, 2000);
|
||||
});
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'cam-label';
|
||||
label.textContent = camId;
|
||||
@@ -413,19 +128,14 @@ function buildCamera(camId, container) {
|
||||
const hd = document.createElement('button');
|
||||
hd.className = 'cam-hdtest';
|
||||
hd.textContent = 'HD';
|
||||
hd.title = 'Hi-Res-Snapshot (1280×960) – cam loslassen, hires-Grab, Download';
|
||||
hd.title = 'Hi-Res-Snapshot (1280×960) – Live friert kurz ein, dann Download';
|
||||
|
||||
const cam = {
|
||||
id: camId, box, infoEl: info, toggleBtn: toggle, hdBtn: hd,
|
||||
active: false, startedAt: 0, playingSince: null, statsLast: null, badTicks: 0, autoOff: false,
|
||||
testing: false, freezeCanvas: null,
|
||||
};
|
||||
toggle.onclick = () => {
|
||||
if (cam.testing) return; // während HD-Test gesperrt
|
||||
cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice();
|
||||
};
|
||||
const cam = { id: camId, box, img, infoEl: info, toggleBtn: toggle, hdBtn: hd, active: false, busy: false };
|
||||
|
||||
toggle.onclick = () => { if (!cam.busy) (cam.active ? stopStream(cam) : startStream(cam)); };
|
||||
hd.onclick = () => runHiresGrab(cam);
|
||||
|
||||
box.appendChild(img);
|
||||
box.appendChild(label);
|
||||
box.appendChild(info);
|
||||
box.appendChild(toggle);
|
||||
@@ -436,34 +146,17 @@ function buildCamera(camId, container) {
|
||||
startStream(cam);
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
async function init() {
|
||||
log('init', 'Starte...');
|
||||
|
||||
try {
|
||||
const d = await (await fetch('/config.json')).json();
|
||||
GO2RTC_PORT = d.go2rtcPort ?? 1984;
|
||||
log('init', `go2rtc WS-Port: ${GO2RTC_PORT}`);
|
||||
} catch (e) {
|
||||
warn('init', `/config.json nicht ladbar, nehme Port ${GO2RTC_PORT}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await customElements.whenDefined('video-stream');
|
||||
log('init', '<video-stream> definiert');
|
||||
} catch (e) {
|
||||
logErr('init', '<video-stream> nicht geladen – /video-stream.js erreichbar?', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('cameras');
|
||||
const container = document.getElementById('cameras');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
let camIds = [];
|
||||
try {
|
||||
const r = await fetch('/api/snapshot');
|
||||
log('init', `/api/snapshot → HTTP ${r.status}`);
|
||||
if (r.ok) camIds = ((await r.json()).cameras ?? []).map(c => c.id);
|
||||
if (r.ok) camIds = ((await r.json()).cameras ?? []).map((c) => c.id);
|
||||
log('init', `Kameras: ${camIds.join(', ') || '(keine)'}`);
|
||||
} catch (e) {
|
||||
logErr('init', '/api/snapshot Fehler – Fallback', e);
|
||||
@@ -473,11 +166,9 @@ async function init() {
|
||||
const snapBtn = document.getElementById('snapAllBtn');
|
||||
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
|
||||
|
||||
camIds.forEach(id => buildCamera(id, container));
|
||||
statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · WebRTC`;
|
||||
|
||||
setInterval(monitor, MONITOR_INTERVAL); // getStats-basierte Überwachung + Diagnose
|
||||
log('init', 'Fertig – Überwachung (getStats) aktiv');
|
||||
camIds.forEach((id) => buildCamera(id, container));
|
||||
statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · MJPEG`;
|
||||
log('init', 'Fertig');
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user