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

275 lines
10 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 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 };