275 lines
10 KiB
JavaScript
275 lines
10 KiB
JavaScript
'use strict';
|
||
|
||
const express = require('express');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { readJpegWidth } = require('./cameraSwitch');
|
||
|
||
// Datum → "YYYYMMDD_HHMMSS"
|
||
function formatTs(date) {
|
||
const p = n => String(n).padStart(2, '0');
|
||
return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}_${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
||
}
|
||
|
||
// Stabile Schnittstellen für Viewer und Homing-Projekt – lesen NUR aus den
|
||
// CameraSwitch-Instanzen (RAM-Puffer + Event-Stream). Kein Gerätezugriff hier,
|
||
// keine go2rtc-Abhängigkeit mehr.
|
||
//
|
||
// GET /api/snapshot → JSON-Liste der Kameras
|
||
// GET /api/snapshot/cam0 → letztes Live-JPEG (640) aus dem Puffer
|
||
// 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, cameras) {
|
||
const router = express.Router();
|
||
|
||
// cameras = camsMeta aus server.js: [{id, name, position, stream, hires, encode, mseCodec, note}]
|
||
router.get('/', (_req, res) => {
|
||
res.json({
|
||
cameras: cameras.map((c) => ({
|
||
id: c.id,
|
||
name: c.name,
|
||
position: c.position,
|
||
stream: c.stream,
|
||
hires: c.hires,
|
||
encode: c.encode, // 'copybsf' | 'mjpeg' | 'h264' → Viewer wählt Player
|
||
mseCodec: c.mseCodec, // nur bei h264: Codec-String für MSE-addSourceBuffer
|
||
url: `/api/snapshot/${c.id}`,
|
||
})),
|
||
});
|
||
});
|
||
|
||
// HD-Grab: delegiert an den Schalter. Der Schalter garantiert, dass Live
|
||
// sauber gestoppt ist (Prozess-close), bevor 1280 startet → kein Race.
|
||
router.get('/:id/hires', async (req, res) => {
|
||
const sw = switches[req.params.id];
|
||
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
|
||
try {
|
||
const jpeg = await sw.grabHires();
|
||
res.set({
|
||
'Content-Type': 'image/jpeg',
|
||
'Content-Length': jpeg.length,
|
||
'Cache-Control': 'no-store',
|
||
'X-Camera-Id': req.params.id,
|
||
'X-Frame-Width': String(readJpegWidth(jpeg) ?? ''),
|
||
'X-Timestamp': new Date().toISOString(),
|
||
});
|
||
res.end(jpeg);
|
||
} catch (err) {
|
||
res.status(503).json({ error: `hires: ${err.message}` });
|
||
}
|
||
});
|
||
|
||
router.get('/:id', async (req, res) => {
|
||
const sw = switches[req.params.id];
|
||
if (!sw) return res.status(404).json({ error: `Unbekannte Kamera: ${req.params.id}` });
|
||
try {
|
||
// grabSnapshot(): liefert das Live-Frame falls vorhanden, sonst (Snapshot-Modus,
|
||
// stream:false) ein one-shot Bild – öffnet das Gerät kurz und schliesst es wieder.
|
||
const frame = await sw.grabSnapshot();
|
||
res.set({
|
||
'Content-Type': 'image/jpeg',
|
||
'Content-Length': frame.length,
|
||
'Cache-Control': 'no-store',
|
||
'X-Camera-Id': req.params.id,
|
||
'X-Timestamp': new Date().toISOString(),
|
||
});
|
||
res.end(frame);
|
||
} catch (err) {
|
||
res.status(503).json({ error: `kein Frame: ${err.message}` });
|
||
}
|
||
});
|
||
|
||
return router;
|
||
}
|
||
|
||
|
||
// H.264-Live-Stream als fortlaufendes fragmentiertes MP4 (video/mp4). Ein FFmpeg
|
||
// (im Schalter) → Fan-out an beliebig viele Browser. Der Client speist die Bytes
|
||
// per MSE in ein <video>. Jeder neue Client bekommt ZUERST das gecachte
|
||
// Init-Segment (ftyp+moov), dann ab dem nächsten Fragment den Live-Stream.
|
||
function streamH264(sw, req, res) {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'video/mp4',
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
'Connection': 'close',
|
||
'X-Camera-Id': req.params.id,
|
||
});
|
||
if (res.socket) res.socket.setNoDelay(true);
|
||
|
||
let closed = false;
|
||
const cleanup = () => {
|
||
if (closed) return;
|
||
closed = true;
|
||
sw.removeListener('segment', onSegment);
|
||
sw.removeListener('init', onReinit);
|
||
sw.release();
|
||
};
|
||
|
||
const onSegment = (seg) => {
|
||
if (closed) return;
|
||
// Video-Fragmente dürfen NICHT einzeln gedroppt werden (Decode-Lücke) – ist ein
|
||
// Client zu langsam, lieber die Verbindung kappen; der Browser verbindet neu.
|
||
if (res.writableLength > (4 << 20)) { cleanup(); try { res.end(); } catch (_e) {} return; }
|
||
try { res.write(seg); } catch (_e) { cleanup(); }
|
||
};
|
||
|
||
// Neues Init-Segment ⇒ der Producer wurde neu gestartet (Auflösung/Encoder/Crash).
|
||
// Das alte moov passt nicht mehr → Verbindung beenden, Client holt sich frisch ab.
|
||
const onReinit = () => { if (!closed) { cleanup(); try { res.end(); } catch (_e) {} } };
|
||
|
||
sw.acquire(); // On-Demand: startet den Producer falls nötig
|
||
|
||
const start = (init) => {
|
||
if (closed) return;
|
||
try { res.write(init); } catch (_e) { cleanup(); return; }
|
||
sw.on('segment', onSegment);
|
||
sw.on('init', onReinit);
|
||
};
|
||
|
||
if (sw.initSegment) {
|
||
start(sw.initSegment);
|
||
} else {
|
||
// Auf das erste Init-Segment warten (Producer läuft warm).
|
||
const t = setTimeout(() => { sw.removeListener('init', onFirst); if (!closed) { sw.release(); try { res.end(); } catch (_e) {} } }, 10000);
|
||
const onFirst = (init) => { clearTimeout(t); start(init); };
|
||
sw.once('init', onFirst);
|
||
}
|
||
|
||
req.on('close', cleanup);
|
||
res.on('error', cleanup);
|
||
}
|
||
|
||
// MJPEG-Live-Stream als multipart/x-mixed-replace. Ein FFmpeg (im Schalter) →
|
||
// Fan-out an beliebig viele Browser. Browser rendert das nativ im <img>.
|
||
function createStreamRouter(switches) {
|
||
const router = express.Router();
|
||
|
||
router.get('/:id', (req, res) => {
|
||
const sw = switches[req.params.id];
|
||
if (!sw) return res.status(404).end();
|
||
|
||
// H.264-Kameras liefern fMP4 (MSE) statt MJPEG-multipart (<img>).
|
||
if (sw.encode === 'h264') return streamH264(sw, req, res);
|
||
|
||
res.writeHead(200, {
|
||
'Content-Type': 'multipart/x-mixed-replace; boundary=frame',
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
'Connection': 'close',
|
||
'X-Camera-Id': req.params.id,
|
||
});
|
||
if (res.socket) res.socket.setNoDelay(true); // Nagle aus → kein Sammel-Delay
|
||
|
||
let closed = false;
|
||
const cleanup = () => {
|
||
if (closed) return;
|
||
closed = true;
|
||
sw.removeListener('frame', onFrame);
|
||
sw.release(); // On-Demand: Verbraucher abmelden
|
||
};
|
||
|
||
const onFrame = (buf) => {
|
||
if (closed) return;
|
||
// Backpressure: langsamer Client bremst die anderen nicht – Frames droppen
|
||
if (res.writableLength > (1 << 20)) return;
|
||
// cork/uncork: Header+JPEG+Trailer als EIN TCP-Segment → minimale Latenz.
|
||
// try/catch: ein kaputter Client darf die anderen nicht aushungern.
|
||
try {
|
||
res.cork();
|
||
res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${buf.length}\r\n\r\n`);
|
||
res.write(buf);
|
||
res.write('\r\n');
|
||
res.uncork();
|
||
} catch (_e) {
|
||
cleanup();
|
||
}
|
||
};
|
||
|
||
sw.acquire(); // On-Demand: Verbraucher anmelden (startet Live falls nötig)
|
||
sw.on('frame', onFrame);
|
||
if (sw.latest) onFrame(sw.latest); // sofort erstes Bild, falls schon eins da
|
||
|
||
req.on('close', cleanup);
|
||
res.on('error', cleanup);
|
||
});
|
||
|
||
return router;
|
||
}
|
||
|
||
// GET /api/cameras → Kamera-Metadaten (ohne device-Pfad)
|
||
// GET /api/cameras/:id/calibration → .npz aus RAM (geladen beim Start)
|
||
// PUT /api/cameras/:id/calibration → neue .npz ablegen; schreibt Archiv + aktuell
|
||
function createCamerasRouter(cameras, calibrations = {}, { calibDir = null } = {}) {
|
||
const router = express.Router();
|
||
|
||
router.get('/', (_req, res) => {
|
||
res.json({
|
||
cameras: cameras.map(({ device: _d, ...rest }) => ({
|
||
...rest,
|
||
...(calibrations[rest.id] ? { calibrationUrl: `/api/cameras/${rest.id}/calibration` } : {}),
|
||
})),
|
||
});
|
||
});
|
||
|
||
router.get('/:id/calibration', (req, res) => {
|
||
const buf = calibrations[req.params.id];
|
||
if (!buf) return res.status(404).json({ error: `Keine Kalibrierdaten für: ${req.params.id}` });
|
||
res.set({
|
||
'Content-Type': 'application/octet-stream',
|
||
'Content-Length': buf.length,
|
||
'Content-Disposition': `attachment; filename="${req.params.id}_calibration.npz"`,
|
||
'Cache-Control': 'public, max-age=86400',
|
||
});
|
||
res.end(buf);
|
||
});
|
||
|
||
// PUT: Homing-Prozess liefert neue Kalibrierung.
|
||
// Speichert: calibration_YYYYMMDD_HHMMSS.npz (Archiv)
|
||
// + calibration.npz (aktuell, Kopie des Archivs)
|
||
// Migration: existiert nur calibration.npz (noch kein Archiv), wird es
|
||
// anhand seines mtime in calibration_<ts>.npz umbenannt.
|
||
router.put('/:id/calibration',
|
||
express.raw({ type: 'application/octet-stream', limit: '10mb' }),
|
||
(req, res) => {
|
||
const { id } = req.params;
|
||
if (!cameras.some(c => c.id === id)) {
|
||
return res.status(404).json({ error: `Unbekannte Kamera: ${id}` });
|
||
}
|
||
const buf = req.body;
|
||
if (!Buffer.isBuffer(buf) || buf.length === 0) {
|
||
return res.status(400).json({ error: 'Body muss eine .npz-Datei als application/octet-stream sein' });
|
||
}
|
||
if (!calibDir) {
|
||
return res.status(500).json({ error: 'calibDir nicht konfiguriert' });
|
||
}
|
||
|
||
const dir = path.join(calibDir, id);
|
||
const mainPath = path.join(dir, 'calibration.npz');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
|
||
// Migration: vorhandenes calibration.npz ohne Timestamp ins Archiv überführen
|
||
const hasArchive = fs.readdirSync(dir).some(f => /^calibration_\d{8}_\d{6}\.npz$/.test(f));
|
||
if (!hasArchive && fs.existsSync(mainPath)) {
|
||
const mtime = fs.statSync(mainPath).mtime;
|
||
fs.renameSync(mainPath, path.join(dir, `calibration_${formatTs(mtime)}.npz`));
|
||
}
|
||
|
||
// Neue Datei: Archiv-Kopie + aktuell
|
||
const tsName = `calibration_${formatTs(new Date())}.npz`;
|
||
fs.writeFileSync(path.join(dir, tsName), buf);
|
||
fs.writeFileSync(mainPath, buf);
|
||
|
||
// In-Memory sofort aktualisieren → GET liefert sofort die neue Datei
|
||
calibrations[id] = buf;
|
||
|
||
res.json({ id, saved: tsName, size: buf.length, calibrationUrl: `/api/cameras/${id}/calibration` });
|
||
},
|
||
);
|
||
|
||
return router;
|
||
}
|
||
|
||
module.exports = { createSnapshotRouter, createStreamRouter, createCamerasRouter };
|