Claude Multicam (a)
This commit is contained in:
22
cameras.json
Normal file
22
cameras.json
Normal file
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -66,6 +66,12 @@
|
|||||||
}
|
}
|
||||||
.cam-toggle:hover { background: rgba(60,60,60,.85); }
|
.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 */
|
/* Hi-Res-Test-Button (Phase 1) – links neben dem Ein/Aus-Schalter */
|
||||||
.cam-hdtest {
|
.cam-hdtest {
|
||||||
position: absolute; top: 5px; right: 40px; z-index: 2;
|
position: absolute; top: 5px; right: 40px; z-index: 2;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async function runHiresGrab(cam) {
|
|||||||
if (cam.busy) return;
|
if (cam.busy) return;
|
||||||
cam.busy = true;
|
cam.busy = true;
|
||||||
cam.hdBtn.disabled = 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 ──');
|
log(cam.id, '── HD-Grab gestartet ──');
|
||||||
|
|
||||||
let blobUrl = null;
|
let blobUrl = null;
|
||||||
@@ -99,51 +99,64 @@ function setInfo(cam, text, cls) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Kamera-View aufbauen ──────────────────────────────────────────────────────
|
// ── Kamera-View aufbauen ──────────────────────────────────────────────────────
|
||||||
function buildCamera(camId, container) {
|
// camMeta = { id, name, position, stream, hires }
|
||||||
|
function buildCamera(camMeta, container) {
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'cam-box';
|
box.className = 'cam-box';
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const labelText = camMeta.name + (camMeta.position ? ` · ${camMeta.position}` : '');
|
||||||
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');
|
const label = document.createElement('div');
|
||||||
label.className = 'cam-label';
|
label.className = 'cam-label';
|
||||||
label.textContent = camId;
|
label.textContent = labelText;
|
||||||
|
|
||||||
const info = document.createElement('div');
|
const info = document.createElement('div');
|
||||||
info.className = 'cam-info';
|
info.className = 'cam-info';
|
||||||
info.textContent = '…';
|
info.textContent = camMeta.stream ? '…' : 'Nur Snapshot';
|
||||||
|
|
||||||
const toggle = document.createElement('button');
|
|
||||||
toggle.className = 'cam-toggle';
|
|
||||||
|
|
||||||
const hd = document.createElement('button');
|
const hd = document.createElement('button');
|
||||||
hd.className = 'cam-hdtest';
|
hd.className = 'cam-hdtest';
|
||||||
hd.textContent = 'HD';
|
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);
|
hd.onclick = () => runHiresGrab(cam);
|
||||||
|
|
||||||
|
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(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(label);
|
||||||
box.appendChild(info);
|
box.appendChild(info);
|
||||||
box.appendChild(toggle);
|
|
||||||
box.appendChild(hd);
|
box.appendChild(hd);
|
||||||
container.appendChild(box);
|
container.appendChild(box);
|
||||||
|
|
||||||
cameras.push(cam);
|
cameras.push(cam);
|
||||||
startStream(cam);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -152,22 +165,28 @@ async function init() {
|
|||||||
const container = document.getElementById('cameras');
|
const container = document.getElementById('cameras');
|
||||||
const statusText = document.getElementById('statusText');
|
const statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
let camIds = [];
|
let camList = [];
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/snapshot');
|
const r = await fetch('/api/snapshot');
|
||||||
log('init', `/api/snapshot → HTTP ${r.status}`);
|
log('init', `/api/snapshot → HTTP ${r.status}`);
|
||||||
if (r.ok) camIds = ((await r.json()).cameras ?? []).map((c) => c.id);
|
if (r.ok) camList = (await r.json()).cameras ?? [];
|
||||||
log('init', `Kameras: ${camIds.join(', ') || '(keine)'}`);
|
log('init', `Kameras: ${camList.map((c) => c.id).join(', ') || '(keine)'}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logErr('init', '/api/snapshot Fehler – Fallback', 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');
|
const snapBtn = document.getElementById('snapAllBtn');
|
||||||
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
|
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
|
||||||
|
|
||||||
camIds.forEach((id) => buildCamera(id, container));
|
camList.forEach((c) => buildCamera(c, container));
|
||||||
statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · MJPEG`;
|
statusText.textContent = `${camList.length} Kamera${camList.length !== 1 ? 's' : ''} · MJPEG`;
|
||||||
log('init', 'Fertig');
|
log('init', 'Fertig');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
server.js
59
server.js
@@ -1,11 +1,11 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { CameraSwitch } = require('./src/cameraSwitch');
|
const { CameraSwitch } = require('./src/cameraSwitch');
|
||||||
const { detectDevices } = require('./src/deviceDetect');
|
const { createSnapshotRouter, createStreamRouter, createCamerasRouter } = require('./src/snapshotService');
|
||||||
const { createSnapshotRouter, createStreamRouter } = require('./src/snapshotService');
|
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
const PORT = parseInt(process.env.PORT ?? '8444', 10);
|
||||||
const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
|
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 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);
|
const IDLE_GRACE_MS = parseInt(process.env.IDLE_GRACE_MS ?? '15000', 10);
|
||||||
|
|
||||||
// ── Kameras: cam0 = erstes Gerät, cam1 = zweites … (DEV0/DEV1-Env überschreibt) ─
|
// ── cameras.json → CameraSwitch-Instanzen ─────────────────────────────────────
|
||||||
const devices = detectDevices();
|
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 = {};
|
const switches = {};
|
||||||
devices.forEach((device, i) => {
|
const camsMeta = []; // { id, device, name, position, stream, hires, note }
|
||||||
const id = `cam${i}`;
|
for (const cam of camsConfig) {
|
||||||
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 });
|
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();
|
const app = express();
|
||||||
|
|
||||||
// ── 1. Eigene Endpunkte ───────────────────────────────────────────────────────
|
// ── 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/stream', createStreamRouter(switches));
|
||||||
|
app.use('/api/cameras', createCamerasRouter(camsMeta));
|
||||||
|
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
cameras: Object.values(switches).map((sw) => ({
|
cameras: camsMeta.map((c) => {
|
||||||
id: sw.id, device: sw.device, state: sw.state, hasFrame: !!sw.latest,
|
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) => {
|
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 ──────────────────────────────────────────────────────
|
// ── 2. Statische Dateien ──────────────────────────────────────────────────────
|
||||||
@@ -54,11 +83,9 @@ const server = http.createServer(app);
|
|||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
|
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(` 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(` 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)
|
// Live-Producer starten (Dauerbetrieb)
|
||||||
Object.values(switches).forEach((sw) => sw.start());
|
Object.values(switches).forEach((sw) => sw.start());
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ const { readJpegWidth } = require('./cameraSwitch');
|
|||||||
// GET /api/snapshot/cam0/hires → HD-JPEG (1280): Schalter pausiert Live kurz
|
// GET /api/snapshot/cam0/hires → HD-JPEG (1280): Schalter pausiert Live kurz
|
||||||
// GET /api/stream/cam0 → MJPEG multipart/x-mixed-replace (Live)
|
// GET /api/stream/cam0 → MJPEG multipart/x-mixed-replace (Live)
|
||||||
|
|
||||||
function createSnapshotRouter(switches) {
|
function createSnapshotRouter(switches, cameras) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// cameras = camsMeta aus server.js: [{id, name, position, stream, hires, note}]
|
||||||
router.get('/', (_req, res) => {
|
router.get('/', (_req, res) => {
|
||||||
res.json({
|
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;
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user