Files
appRobotWebcam/server.js
2026-06-10 09:36:43 +02:00

169 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const express = require('express');
const fs = require('fs');
const http = require('http');
const path = require('path');
const { CameraSwitch } = require('./src/cameraSwitch');
const { createSnapshotRouter, createStreamRouter, createCamerasRouter } = require('./src/snapshotService');
const { createConfigRouter } = require('./src/configService');
const { resolveHwenc, mseCodecString } = require('./src/hwencode');
const PORT = parseInt(process.env.PORT ?? '8444', 10);
const LIVE_SIZE = process.env.LIVE_SIZE ?? '640x480';
const LIVE_FPS = parseInt(process.env.LIVE_FPS ?? '30', 10);
const HIRES_SIZE = process.env.HIRES_SIZE ?? '1280x960';
const HIRES_FPS = parseInt(process.env.HIRES_FPS ?? '15', 10);
const ENCODE_MODE = process.env.ENCODE_MODE ?? 'copybsf'; // 'copybsf' (niedrige CPU) | 'mjpeg' (Re-Encode) | 'h264' (GPU)
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);
// ── Hardware-Encoding (nur relevant für Kameras mit encode='h264') ────────────
// GPU=intel|amd|auto|none Maschinen-GPU (intel/amd → VAAPI, none → libx264)
// HWENC=vaapi|qsv|libx264 Encoder erzwingen (überschreibt GPU)
// HWENC_DEVICE=/dev/dri/renderD128 VAAPI/QSV-Renderknoten
const HWENC = resolveHwenc({
vendor: process.env.GPU ?? 'auto',
encoder: process.env.HWENC,
device: process.env.HWENC_DEVICE,
});
const H264 = {
bitrate: process.env.H264_BITRATE ?? '3M',
gop: process.env.H264_GOP ? parseInt(process.env.H264_GOP, 10) : undefined, // default in hwencode: ~2×fps
profile: process.env.H264_PROFILE ?? 'main',
fragMs: parseInt(process.env.H264_FRAG_MS ?? '200', 10),
jpegFps: parseInt(process.env.H264_JPEG_FPS ?? '2', 10), // Snapshot-Nebenausgang
};
const MSE_CODEC = process.env.H264_MSE_CODEC ?? mseCodecString(H264.profile, process.env.H264_LEVEL ?? '1F');
// ── Kalibrierungsdaten laden (data/calibration/{id}/calibration.npz) ──────────
// Einmalig beim Start in den RAM; kein fs-Zugriff pro Request.
function loadCalibrations(camsConfig) {
const calib = {};
for (const cam of camsConfig) {
const p = cam.calibrationFile
? path.resolve(__dirname, cam.calibrationFile)
: path.join(__dirname, 'data', 'calibration', cam.id, 'calibration.npz');
if (fs.existsSync(p)) {
calib[cam.id] = fs.readFileSync(p);
console.log(` Kalibrierung: ${cam.id} (${calib[cam.id].length} Bytes)`);
}
}
return calib;
}
// ── cameras.json → CameraSwitch-Instanzen ─────────────────────────────────────
const CAMERAS_PATH = path.join(__dirname, 'cameras.json');
let camerasJson;
try {
camerasJson = JSON.parse(fs.readFileSync(CAMERAS_PATH, 'utf8'));
} catch (e) {
console.error('cameras.json fehlt oder ungültig:', e.message); process.exit(1);
}
// Atomar schreiben: tmp + rename → nie ein halb-geschriebenes cameras.json.
function persistCameras(obj) {
const tmp = CAMERAS_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
fs.renameSync(tmp, CAMERAS_PATH);
}
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 calibrations = loadCalibrations(camsConfig);
const switches = {};
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);
}
const camEncode = cam.encode ?? ENCODE_MODE;
// Per-Kamera-Felder in cameras.json überschreiben die globalen Env-Werte
switches[cam.id] = new CameraSwitch({
id: cam.id, device: cam.device,
liveSize: cam.liveSize ?? LIVE_SIZE,
liveFps: cam.liveFps ?? LIVE_FPS,
hiresSize: cam.hiresSize ?? HIRES_SIZE,
hiresFps: cam.hiresFps ?? HIRES_FPS,
encode: camEncode,
hiresEncode: cam.hiresEncode, // undefined → fällt im Konstruktor auf encode zurück
stream: cam.stream !== false, // UI "Aus" → Kamera startet nicht live
onDemand: ON_DEMAND, idleGraceMs: IDLE_GRACE_MS,
hwenc: HWENC, h264: H264, mseCodec: MSE_CODEC, // nur für encode='h264' relevant
});
camsMeta.push({
id: cam.id,
device: cam.device,
name: cam.name ?? cam.id,
position: cam.position ?? '',
stream: cam.stream !== false,
hires: cam.hires !== false,
encode: camEncode,
mseCodec: camEncode === 'h264' ? MSE_CODEC : null,
note: cam.note ?? '',
});
}
const app = express();
app.use(express.json()); // POST /api/config liest JSON-Body
// ── 1. Eigene Endpunkte ───────────────────────────────────────────────────────
app.use('/api/snapshot', createSnapshotRouter(switches, camsMeta));
app.use('/api/stream', createStreamRouter(switches));
app.use('/api/cameras', createCamerasRouter(camsMeta, calibrations, {
calibDir: path.join(__dirname, 'data', 'calibration'),
}));
app.use('/api/config', createConfigRouter({
switches, camsMeta,
getCamerasJson: () => camerasJson,
setCamerasJson: (v) => { camerasJson = v; },
persist: persistCameras,
mseCodec: MSE_CODEC,
}));
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
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: camsMeta.map((c) => ({ id: c.id, name: c.name, stream: c.stream })) });
});
// ── 2. Statische Dateien ──────────────────────────────────────────────────────
// no-cache: Browser MUSS index.html/viewer.js vor Nutzung revalidieren.
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
}));
// ── 3. Start ──────────────────────────────────────────────────────────────────
const server = http.createServer(app);
server.listen(PORT, '0.0.0.0', () => {
console.log(`AppRobotWebcam http://0.0.0.0:${PORT}`);
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(` H.264-GPU: ${HWENC.encoder} @ ${HWENC.device} · ${H264.bitrate}, profile=${H264.profile}, MSE=${MSE_CODEC} (nur für encode=h264)`);
console.log(` Viewer: http://0.0.0.0:${PORT}/`);
// Live-Producer starten (Dauerbetrieb)
Object.values(switches).forEach((sw) => sw.start());
});
const shutdown = (sig) => {
console.log(`\n${sig} shutting down`);
Object.values(switches).forEach((sw) => { sw.stopping = true; if (sw.proc) { try { sw.proc.kill('SIGKILL'); } catch (_e) {} } });
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 3000); // Sicherheitsnetz
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));