'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'));