BetterFrame/server/src/shared/snapshot.ts
Mitchell R 975cc184b3 feat: multi-display + snapshot + health + GPIO + nodered embed
Multi-display:
- Bundle ships displays[] each with own layouts + idle/sleep
- Rust kiosk creates one ApplicationWindow per gdk monitor
- Per-display state (layout, idle, sleep) via HashMap
- WARM_CAMERAS pool shared across displays
- Backward-compat top-level display/layouts still emitted

System Health (/admin/health):
- Online status, CPU temp (color-coded), fan RPM/PWM
- Bundle version mismatch detection
- 30s auto-refresh

Camera snapshot/test:
- shared/snapshot.ts: ffmpeg/gst-launch fallback, 5s timeout
- /admin/entities/:id/snapshot returns JPEG
- EntityEditPage shows live preview with Refresh

GPIO (Pi buttons/sensors):
- kiosk_gpio_bindings table + CRUD admin UI
- Bundle ships gpio_bindings[]
- kiosk/src/gpio.rs with gpiod crate, worker thread per pin
- Edge events POST to /api/kiosk/event with source_type=gpio

Layout switch fixes:
- GET aliases added so direct URL hits work
- New /admin/displays/:displayId/layout/:layoutId for multi-display
- DisplayEditPage gets "Switch Layout Now" section

Node-RED embed:
- /admin/nodered renders iframe at /nrdp/
- Sandbox attrs allow scripts/forms/popups
- Sidebar link now opens embedded view
2026-05-13 01:18:22 +02:00

123 lines
3.4 KiB
TypeScript

/**
* Camera snapshot capture.
*
* Spawns ffmpeg (preferred) or gst-launch-1.0 to pull one frame from an RTSP
* URL and return it as a JPEG buffer. Bounded by a hard timeout so a stuck
* connection can't pile up subprocesses.
*/
import { spawn } from "node:child_process";
import { unlink, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
const DEFAULT_TIMEOUT_MS = 8000;
export interface SnapshotOptions {
timeoutMs?: number;
}
/**
* Capture a single frame from an RTSP URL. Returns a JPEG buffer, or null on
* any failure (timeout, missing binary, non-zero exit, empty file).
*
* Tries ffmpeg first (fast, widely installed). Falls back to gst-launch-1.0.
*/
export async function captureSnapshot(
rtspUrl: string,
opts: SnapshotOptions = {},
): Promise<Buffer | null> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!rtspUrl || !rtspUrl.startsWith("rtsp://")) return null;
const result = await tryFfmpeg(rtspUrl, timeoutMs);
if (result) return result;
return tryGstLaunch(rtspUrl, timeoutMs);
}
async function tryFfmpeg(rtspUrl: string, timeoutMs: number): Promise<Buffer | null> {
const out = join(tmpdir(), `bf-snap-${randomBytes(8).toString("hex")}.jpg`);
// -y overwrite, -rtsp_transport tcp (most reliable), -frames:v 1 single frame,
// -an no audio. Use mjpeg via filename extension.
const args = [
"-y",
"-rtsp_transport", "tcp",
"-i", rtspUrl,
"-frames:v", "1",
"-q:v", "5",
"-an",
out,
];
const ok = await runWithTimeout("ffmpeg", args, timeoutMs);
if (!ok) {
void unlink(out).catch(() => undefined);
return null;
}
try {
const buf = await readFile(out);
return buf.length > 0 ? buf : null;
} catch {
return null;
} finally {
void unlink(out).catch(() => undefined);
}
}
async function tryGstLaunch(rtspUrl: string, timeoutMs: number): Promise<Buffer | null> {
const out = join(tmpdir(), `bf-snap-${randomBytes(8).toString("hex")}.jpg`);
// rtspsrc → decodebin → videoconvert → jpegenc → single-frame filesink
const args = [
"-q",
"rtspsrc", `location=${rtspUrl}`, "protocols=tcp", "latency=200", "!",
"decodebin", "!",
"videoconvert", "!",
"jpegenc", "!",
"filesink", `location=${out}`,
"num-buffers=1",
];
const ok = await runWithTimeout("gst-launch-1.0", args, timeoutMs);
if (!ok) {
void unlink(out).catch(() => undefined);
return null;
}
try {
const buf = await readFile(out);
return buf.length > 0 ? buf : null;
} catch {
return null;
} finally {
void unlink(out).catch(() => undefined);
}
}
function runWithTimeout(bin: string, args: string[], timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
let child;
try {
child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
} catch {
resolve(false);
return;
}
let settled = false;
const t = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill("SIGKILL"); } catch { /* ignore */ }
resolve(false);
}, timeoutMs);
child.on("error", () => {
if (settled) return;
settled = true;
clearTimeout(t);
resolve(false);
});
child.on("exit", (code) => {
if (settled) return;
settled = true;
clearTimeout(t);
resolve(code === 0);
});
});
}