From e13ec59a2f950c130c597c121104194f9953b00b Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:21:38 +0200 Subject: [PATCH] Claude Multicam (a) --- cameras.json | 22 ++++++++++++ public/index.html | 6 ++++ public/viewer.js | 79 ++++++++++++++++++++++++++---------------- server.js | 59 ++++++++++++++++++++++--------- src/snapshotService.js | 25 +++++++++++-- 5 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 cameras.json diff --git a/cameras.json b/cameras.json new file mode 100644 index 0000000..fe20095 --- /dev/null +++ b/cameras.json @@ -0,0 +1,22 @@ +{ + "cameras": [ + { + "id": "cam0", + "device": "/dev/video0", + "name": "Kamera 0", + "position": "front", + "stream": true, + "hires": true, + "note": "" + }, + { + "id": "cam1", + "device": "/dev/video2", + "name": "Kamera 1", + "position": "left", + "stream": true, + "hires": true, + "note": "" + } + ] +} diff --git a/public/index.html b/public/index.html index a82ba3a..7d9864e 100644 --- a/public/index.html +++ b/public/index.html @@ -66,6 +66,12 @@ } .cam-toggle:hover { background: rgba(60,60,60,.85); } + /* Snapshot-only-Kamera: Platzhalter statt Live-Bild */ + .cam-placeholder { + display: flex; align-items: center; justify-content: center; + color: #444; font-size: 0.82rem; letter-spacing: 0.04em; + } + /* Hi-Res-Test-Button (Phase 1) – links neben dem Ein/Aus-Schalter */ .cam-hdtest { position: absolute; top: 5px; right: 40px; z-index: 2; diff --git a/public/viewer.js b/public/viewer.js index 77ece40..6efa222 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -43,7 +43,7 @@ async function runHiresGrab(cam) { if (cam.busy) return; cam.busy = true; cam.hdBtn.disabled = true; - setInfo(cam, 'HD: erfasse… (Stream friert kurz)', 'warn'); + setInfo(cam, cam.stream ? 'HD: erfasse… (Stream friert kurz)' : 'HD: erfasse…', 'warn'); log(cam.id, '── HD-Grab gestartet ──'); let blobUrl = null; @@ -99,51 +99,64 @@ function setInfo(cam, text, cls) { } // ── Kamera-View aufbauen ────────────────────────────────────────────────────── -function buildCamera(camId, container) { +// camMeta = { id, name, position, stream, hires } +function buildCamera(camMeta, 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 labelText = camMeta.name + (camMeta.position ? ` · ${camMeta.position}` : ''); const label = document.createElement('div'); label.className = 'cam-label'; - label.textContent = camId; + label.textContent = labelText; const info = document.createElement('div'); info.className = 'cam-info'; - info.textContent = '…'; - - const toggle = document.createElement('button'); - toggle.className = 'cam-toggle'; + info.textContent = camMeta.stream ? '…' : 'Nur Snapshot'; const hd = document.createElement('button'); hd.className = 'cam-hdtest'; hd.textContent = 'HD'; - hd.title = 'Hi-Res-Snapshot (1280×960) – Live friert kurz ein, dann Download'; - const cam = { id: camId, box, img, infoEl: info, toggleBtn: toggle, hdBtn: hd, active: false, busy: false }; + const cam = { id: camMeta.id, stream: camMeta.stream, box, infoEl: info, hdBtn: hd, active: false, busy: false }; - toggle.onclick = () => { if (!cam.busy) (cam.active ? stopStream(cam) : startStream(cam)); }; hd.onclick = () => runHiresGrab(cam); - box.appendChild(img); + if (camMeta.stream) { + const img = document.createElement('img'); + img.className = 'cam-img'; + img.alt = labelText; + 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'); + setTimeout(() => { if (cam.active && !cam.busy) startStream(cam); }, 2000); + }); + + const toggle = document.createElement('button'); + toggle.className = 'cam-toggle'; + toggle.onclick = () => { if (!cam.busy) (cam.active ? stopStream(cam) : startStream(cam)); }; + + cam.img = img; + cam.toggleBtn = toggle; + hd.title = 'Hi-Res-Snapshot (1280×960) – Live friert kurz ein, dann Download'; + + box.appendChild(img); + box.appendChild(toggle); + startStream(cam); + } else { + const placeholder = document.createElement('div'); + placeholder.className = 'cam-img cam-placeholder'; + placeholder.textContent = 'Kein Live-Stream'; + hd.title = 'Hi-Res-Snapshot (1280×960) – Download'; + box.appendChild(placeholder); + } + box.appendChild(label); box.appendChild(info); - box.appendChild(toggle); box.appendChild(hd); container.appendChild(box); cameras.push(cam); - startStream(cam); } // ── Init ────────────────────────────────────────────────────────────────────── @@ -152,22 +165,28 @@ async function init() { const container = document.getElementById('cameras'); const statusText = document.getElementById('statusText'); - let camIds = []; + let camList = []; 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); - log('init', `Kameras: ${camIds.join(', ') || '(keine)'}`); + if (r.ok) camList = (await r.json()).cameras ?? []; + log('init', `Kameras: ${camList.map((c) => c.id).join(', ') || '(keine)'}`); } catch (e) { logErr('init', '/api/snapshot Fehler – Fallback', e); } - if (camIds.length === 0) { warn('init', 'Fallback cam0, cam1'); camIds = ['cam0', 'cam1']; } + if (camList.length === 0) { + warn('init', 'Fallback cam0, cam1'); + camList = [ + { id: 'cam0', name: 'cam0', position: '', stream: true, hires: true }, + { id: 'cam1', name: 'cam1', position: '', stream: true, hires: true }, + ]; + } 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' : ''} · MJPEG`; + camList.forEach((c) => buildCamera(c, container)); + statusText.textContent = `${camList.length} Kamera${camList.length !== 1 ? 's' : ''} · MJPEG`; log('init', 'Fertig'); } diff --git a/server.js b/server.js index 2eaab25..2fb1a00 100644 --- a/server.js +++ b/server.js @@ -1,11 +1,11 @@ 'use strict'; const express = require('express'); +const fs = require('fs'); const http = require('http'); const path = require('path'); const { CameraSwitch } = require('./src/cameraSwitch'); -const { detectDevices } = require('./src/deviceDetect'); -const { createSnapshotRouter, createStreamRouter } = require('./src/snapshotService'); +const { createSnapshotRouter, createStreamRouter, createCamerasRouter } = require('./src/snapshotService'); const PORT = parseInt(process.env.PORT ?? '8444', 10); const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480'; @@ -16,31 +16,60 @@ const ENCODE_MODE = process.env.ENCODE_MODE ?? 'copybsf'; // 'copybsf' (niedrige const ON_DEMAND = (process.env.ON_DEMAND ?? 'true') !== 'false'; // Live nur bei Verbrauchern (spart idle-CPU) const IDLE_GRACE_MS = parseInt(process.env.IDLE_GRACE_MS ?? '15000', 10); -// ── Kameras: cam0 = erstes Gerät, cam1 = zweites … (DEV0/DEV1-Env überschreibt) ─ -const devices = detectDevices(); +// ── cameras.json → CameraSwitch-Instanzen ───────────────────────────────────── +let camerasJson; +try { + camerasJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'cameras.json'), 'utf8')); +} catch (e) { + console.error('cameras.json fehlt oder ungültig:', e.message); process.exit(1); +} +const camsConfig = camerasJson.cameras; +if (!Array.isArray(camsConfig) || camsConfig.length === 0) { + console.error('cameras.json: "cameras" muss ein nicht-leeres Array sein'); process.exit(1); +} + const switches = {}; -devices.forEach((device, i) => { - const id = `cam${i}`; - switches[id] = new CameraSwitch({ id, device, liveSize: LIVE_SIZE, liveFps: LIVE_FPS, hiresSize: HIRES_SIZE, hiresFps: HIRES_FPS, encode: ENCODE_MODE, onDemand: ON_DEMAND, idleGraceMs: IDLE_GRACE_MS }); -}); +const camsMeta = []; // { id, device, name, position, stream, hires, note } +for (const cam of camsConfig) { + if (!cam.id || !cam.device) { + console.error(`cameras.json: Eintrag ohne id/device: ${JSON.stringify(cam)}`); process.exit(1); + } + switches[cam.id] = new CameraSwitch({ + id: cam.id, device: cam.device, + liveSize: LIVE_SIZE, liveFps: LIVE_FPS, + hiresSize: HIRES_SIZE, hiresFps: HIRES_FPS, + encode: ENCODE_MODE, onDemand: ON_DEMAND, idleGraceMs: IDLE_GRACE_MS, + }); + camsMeta.push({ + id: cam.id, + device: cam.device, + name: cam.name ?? cam.id, + position: cam.position ?? '', + stream: cam.stream !== false, + hires: cam.hires !== false, + note: cam.note ?? '', + }); +} const app = express(); // ── 1. Eigene Endpunkte ─────────────────────────────────────────────────────── -app.use('/api/snapshot', createSnapshotRouter(switches)); +app.use('/api/snapshot', createSnapshotRouter(switches, camsMeta)); app.use('/api/stream', createStreamRouter(switches)); +app.use('/api/cameras', createCamerasRouter(camsMeta)); app.get('/health', (_req, res) => { res.json({ status: 'ok', - cameras: Object.values(switches).map((sw) => ({ - id: sw.id, device: sw.device, state: sw.state, hasFrame: !!sw.latest, - })), + cameras: camsMeta.map((c) => { + const sw = switches[c.id]; + return { id: c.id, name: c.name, device: c.device, state: sw?.state, hasFrame: !!sw?.latest }; + }), }); }); app.get('/config.json', (_req, res) => { - res.json({ cameras: Object.keys(switches) }); + res.json({ cameras: camsMeta.map((c) => ({ id: c.id, name: c.name, stream: c.stream })) }); }); // ── 2. Statische Dateien ────────────────────────────────────────────────────── @@ -54,11 +83,9 @@ const server = http.createServer(app); server.listen(PORT, '0.0.0.0', () => { console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`); - console.log(` Kameras: ${Object.entries(switches).map(([id, sw]) => `${id}=${sw.device}`).join(', ')}`); + console.log(` Kameras: ${camsMeta.map((c) => `${c.id}=${c.device} "${c.name}" stream=${c.stream}`).join(', ')}`); console.log(` Live: ${LIVE_SIZE}@${LIVE_FPS} · HD-Grab: ${HIRES_SIZE}@${HIRES_FPS} · Encode: ${ENCODE_MODE} · On-Demand: ${ON_DEMAND}`); console.log(` Viewer: http://0.0.0.0:${PORT}/`); - console.log(` Live-Stream: http://0.0.0.0:${PORT}/api/stream/cam0`); - console.log(` Snapshot API: http://0.0.0.0:${PORT}/api/snapshot/cam0 (+ /hires)`); // Live-Producer starten (Dauerbetrieb) Object.values(switches).forEach((sw) => sw.start()); diff --git a/src/snapshotService.js b/src/snapshotService.js index 497af49..60e1e45 100644 --- a/src/snapshotService.js +++ b/src/snapshotService.js @@ -12,12 +12,20 @@ const { readJpegWidth } = require('./cameraSwitch'); // GET /api/snapshot/cam0/hires → HD-JPEG (1280): Schalter pausiert Live kurz // GET /api/stream/cam0 → MJPEG multipart/x-mixed-replace (Live) -function createSnapshotRouter(switches) { +function createSnapshotRouter(switches, cameras) { const router = express.Router(); + // cameras = camsMeta aus server.js: [{id, name, position, stream, hires, note}] router.get('/', (_req, res) => { res.json({ - cameras: Object.keys(switches).map((id) => ({ id, url: `/api/snapshot/${id}` })), + cameras: cameras.map((c) => ({ + id: c.id, + name: c.name, + position: c.position, + stream: c.stream, + hires: c.hires, + url: `/api/snapshot/${c.id}`, + })), }); }); @@ -118,4 +126,15 @@ function createStreamRouter(switches) { return router; } -module.exports = { createSnapshotRouter, createStreamRouter }; +// GET /api/cameras → vollständige Kamera-Metadaten (ohne device-Pfad) +function createCamerasRouter(cameras) { + const router = express.Router(); + router.get('/', (_req, res) => { + res.json({ + cameras: cameras.map(({ device: _d, ...rest }) => rest), + }); + }); + return router; +} + +module.exports = { createSnapshotRouter, createStreamRouter, createCamerasRouter };