Claude: WebSocket
This commit is contained in:
@@ -1,28 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const express = require('express');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// ── Kamera-Konfiguration ──────────────────────────────────────────────────────
|
||||
// Muss zur go2rtc-Config in docker-compose.yaml passen.
|
||||
const CAM_CONFIG = {
|
||||
cam0: {
|
||||
device: '/dev/video0',
|
||||
hiresSize: '1280x960',
|
||||
streamUrl: 'ffmpeg:device?video=/dev/video0&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg',
|
||||
},
|
||||
cam1: {
|
||||
device: '/dev/video2',
|
||||
hiresSize: '1280x960',
|
||||
streamUrl: 'ffmpeg:device?video=/dev/video2&input_format=mjpeg&video_size=640x480&framerate=30#video=mjpeg',
|
||||
},
|
||||
};
|
||||
|
||||
// Stabile Snapshot-Schnittstelle für das Homing-Projekt.
|
||||
// Entkoppelt den Consumer von go2rtc-Interna – proxied intern auf /api/frame.jpeg.
|
||||
//
|
||||
// GET /api/snapshot → JSON-Liste der Kameras (aus go2rtc /api/streams)
|
||||
// GET /api/snapshot/cam0 → aktuelles JPEG (aus go2rtc /api/frame.jpeg?src=cam0)
|
||||
// GET /api/snapshot → JSON-Liste der Kameras
|
||||
// GET /api/snapshot/cam0 → aktueller Frame (640×480, go2rtc passthrough)
|
||||
// GET /api/snapshot/cam0/hires → einmaliger Hi-Res-Frame (1280×960, Blackout ~1–2 s)
|
||||
//
|
||||
// Hi-Res-Ablauf:
|
||||
// 1. go2rtc-Stream temporär löschen → Gerät wird freigegeben
|
||||
// 2. FFmpeg one-shot direkt auf /dev/videoX → 1280×960 MJPEG
|
||||
// 3. go2rtc-Stream wiederherstellen → Live-Video läuft wieder
|
||||
//
|
||||
function createSnapshotRouter(go2rtcUrl) {
|
||||
const router = express.Router();
|
||||
|
||||
// ── Kamera-Liste ─────────────────────────────────────────────────────────────
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const r = await fetch(`${go2rtcUrl}/api/streams`);
|
||||
if (!r.ok) throw new Error(`go2rtc HTTP ${r.status}`);
|
||||
const streams = await r.json();
|
||||
res.json({
|
||||
cameras: Object.keys(streams).map(id => ({ id, url: `/api/snapshot/${id}` })),
|
||||
cameras: Object.keys(streams).map(id => ({
|
||||
id,
|
||||
url: `/api/snapshot/${id}`,
|
||||
hiresUrl: `/api/snapshot/${id}/hires`,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(503).json({ error: `go2rtc nicht erreichbar: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Standard-Snapshot (Stream-Auflösung, sofort) ─────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
@@ -46,7 +74,132 @@ function createSnapshotRouter(go2rtcUrl) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Hi-Res-Snapshot (Blackout ~1–2 s, 1280×960) ──────────────────────────────
|
||||
let hiresLock = false;
|
||||
|
||||
router.get('/:id/hires', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const cfg = CAM_CONFIG[id];
|
||||
if (!cfg) {
|
||||
return res.status(404).json({ error: `Kamera '${id}' nicht in CAM_CONFIG` });
|
||||
}
|
||||
if (hiresLock) {
|
||||
return res.status(429).json({ error: 'Hi-Res-Snapshot läuft bereits – bitte warten' });
|
||||
}
|
||||
|
||||
hiresLock = true;
|
||||
console.log(`[snapshot][${id}] hires-Start (${cfg.hiresSize})`);
|
||||
|
||||
try {
|
||||
// 1. go2rtc-Stream stoppen → gibt /dev/videoX frei
|
||||
const delRes = await fetch(
|
||||
`${go2rtcUrl}/api/streams?src=${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
console.log(`[snapshot][${id}] go2rtc DELETE stream → HTTP ${delRes.status}`);
|
||||
|
||||
// kurz warten bis FFmpeg-Prozess in go2rtc beendet und Gerät freigegeben ist
|
||||
await sleep(900);
|
||||
|
||||
// 2. Hi-Res-Frame via FFmpeg one-shot
|
||||
const jpeg = await captureOneFrame(cfg.device, cfg.hiresSize);
|
||||
console.log(`[snapshot][${id}] Frame captured (${jpeg.length} bytes)`);
|
||||
|
||||
// 3. go2rtc-Stream wiederherstellen
|
||||
const putRes = await fetch(
|
||||
`${go2rtcUrl}/api/streams?src=${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: cfg.streamUrl,
|
||||
}
|
||||
);
|
||||
console.log(`[snapshot][${id}] go2rtc PUT stream → HTTP ${putRes.status}`);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': jpeg.length,
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Camera-Id': id,
|
||||
'X-Resolution': cfg.hiresSize,
|
||||
'X-Timestamp': new Date().toISOString(),
|
||||
});
|
||||
res.end(jpeg);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[snapshot][${id}] hires-Fehler:`, err.message);
|
||||
|
||||
// Stream auf jeden Fall wiederherstellen, auch im Fehlerfall
|
||||
try {
|
||||
await fetch(`${go2rtcUrl}/api/streams?src=${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: cfg.streamUrl,
|
||||
});
|
||||
console.log(`[snapshot][${id}] Stream nach Fehler wiederhergestellt`);
|
||||
} catch (restoreErr) {
|
||||
console.error(`[snapshot][${id}] Stream-Wiederherstellung fehlgeschlagen:`, restoreErr.message);
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
} finally {
|
||||
hiresLock = false;
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// Startet FFmpeg, liest genau einen MJPEG-Frame aus stdout, gibt ihn als Buffer zurück.
|
||||
function captureOneFrame(device, size, timeoutMs = 8000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-f', 'v4l2',
|
||||
'-input_format', 'mjpeg',
|
||||
'-video_size', size,
|
||||
'-framerate', '10', // niedrige FPS → schnellerer erster Frame
|
||||
'-i', device,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '1', // beste JPEG-Qualität
|
||||
'-f', 'mjpeg',
|
||||
'pipe:1',
|
||||
];
|
||||
|
||||
const chunks = [];
|
||||
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
proc.stdout.on('data', chunk => chunks.push(chunk));
|
||||
proc.stderr.on('data', () => {}); // FFmpeg-Infos unterdrücken (loglevel error)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
reject(new Error(`FFmpeg timeout nach ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
proc.on('close', code => {
|
||||
clearTimeout(timer);
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (buf.length > 0) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error(`FFmpeg exit ${code}, kein Frame erhalten`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', err => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createSnapshotRouter };
|
||||
|
||||
Reference in New Issue
Block a user