diff --git a/public/viewer.js b/public/viewer.js index d2f28e6..64828f4 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -351,19 +351,80 @@ function showNotice() { bar.style.display = 'flex'; } -// ── Snapshot aller Kameras ─────────────────────────────────────────────────── -function snapshotAll() { - const ts = Date.now(); - const ids = cameras.map(c => c.id); - log('snap', `Snapshot alle: ${ids.join(', ')}`); - ids.forEach(id => { - const a = document.createElement('a'); - a.href = `/api/snapshot/${id}`; - a.download = `${id}_${ts}.jpg`; - document.body.appendChild(a); - a.click(); - a.remove(); - }); +// ── 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 ───────────────────────────────────────────────────── @@ -443,7 +504,7 @@ async function init() { if (camIds.length === 0) { warn('init', 'Fallback cam0, cam1'); camIds = ['cam0', 'cam1']; } const snapBtn = document.getElementById('snapAllBtn'); - if (snapBtn) { snapBtn.onclick = snapshotAll; snapBtn.disabled = false; } + if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; } camIds.forEach(id => buildCamera(id, container)); statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · WebRTC`; diff --git a/src/snapshotService.js b/src/snapshotService.js index dc80254..4d7a7a6 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -29,7 +29,7 @@ function readJpegWidth(buf) { // GET /api/snapshot/cam0/hires → 1280×960 JPEG via cam0_hires (Phase 2) function createSnapshotRouter(go2rtcUrl) { const router = express.Router(); - let hiresLock = false; // Mutex: nie zwei Hi-Res-Grabs gleichzeitig + const hiresLocks = {}; // Mutex pro Kamera: { cam0: false, cam1: false, … } // ── PHASE 1: Geräte-Freigabe messen (rein LESEND, kein Grab) ──────────────── // Linchpin-Test aus doc/05_screenShot_roadmap.md: Gibt go2rtc das Gerät frei, @@ -118,10 +118,10 @@ function createSnapshotRouter(go2rtcUrl) { const { id } = req.params; const hiresId = `${id}_hires`; - if (hiresLock) { - return res.status(429).json({ error: 'Hi-Res-Grab läuft bereits – bitte warten' }); + if (hiresLocks[id]) { + return res.status(429).json({ error: `Hi-Res-Grab für ${id} läuft bereits – bitte warten` }); } - hiresLock = true; + hiresLocks[id] = true; const t0 = Date.now(); try { @@ -206,7 +206,7 @@ function createSnapshotRouter(go2rtcUrl) { } catch (err) { if (!res.headersSent) res.status(503).json({ error: `hires: ${err.message}` }); } finally { - hiresLock = false; + hiresLocks[id] = false; } });