Claude: WebSocket

This commit is contained in:
chk
2026-06-04 15:06:45 +02:00
parent 118441995d
commit 306aacac80
4 changed files with 378 additions and 52 deletions

View File

@@ -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 ~12 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 ~12 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 };