149 lines
6.8 KiB
JavaScript
149 lines
6.8 KiB
JavaScript
'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');
|
||
|
||
// ── 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 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));
|
||
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'));
|