From 96778b37aa6ebe999fcb894a7ae8d0151a2def28 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Mon, 15 Jun 2026 06:52:43 -0500 Subject: scrypted: live ffmpeg streaming replaces snapshot polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the setInterval(cam.takePicture) loop with a single ffmpeg child per active stream that: 1. pulls the camera's video stream via getVideoStream + converts to ffmpeg input args via ScryptedMimeTypes.FFmpegInput, 2. transcodes inline: scale=W:H:flags=lanczos, fps=N, c:v=mjpeg q:v=2, -f image2pipe to stdout, 3. demuxes JPEG frames from stdout (FFD8…FFD9 boundaries), 4. POSTs each frame to the firmware's existing /frame endpoint with the same single-flight semantics as before — surplus frames are dropped silently and counted for a periodic skip-rate log so the operator can dial frame_interval_ms to where the firmware sustains. Wins vs. the snapshot path: - The camera's main H.264 encoder is producing keyframes for free; we pay near-zero on the source side instead of triggering a full snapshot fetch per frame (the cost that capped us at ~2 fps). - ffmpeg sustains the requested fps as long as the firmware can decode + paint at that rate. Measured ceiling is the firmware, not the source, so the actual fps lands near the panel's true limit. - Lanczos + q:v 2 keeps the picture sharp end-to-end. stopStream now SIGTERMs the ffmpeg child alongside aborting outstanding fetches. Each successful paint also nudges the Scrypted-side idle timer so a healthy stream stays open as long as frames are landing. Drop the now-unused pushFrame + resizeJpegHQ + the old setInterval machinery. --- scrypted/scrypted-viewport.ts | 228 +++++++++++++++++++++++++----------------- 1 file changed, 139 insertions(+), 89 deletions(-) diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index c29276b..cc83e52 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -26,14 +26,21 @@ // 6. Edit a viewport's settings from its own device page in the UI. The // script re-registers and re-subscribes whenever a setting changes. // -// v1 limits (path to v2: a packaged plugin with FFmpeg streaming) -// --------------------------------------------------------------- -// - Snapshot-rate (~1 fps) via Camera.takePicture(). Live-rate MJPEG over -// POST /stream is v2. +// Streaming +// --------- +// On wake we subscribe to the camera's video stream, spawn one ffmpeg +// child that scales + re-encodes to MJPEG (q:v 2, lanczos) at the +// viewport's configured fps, demux JPEG frames out of stdout (FFD8…FFD9) +// and POST each one to the firmware's existing /frame endpoint. Single- +// flight semantics gate against the firmware's in-flight mutex; surplus +// frames are dropped silently and counted for a periodic skip-rate log. +// +// Limits +// ------ // - Manual IP per viewport (see README for how to find it via mDNS from // your shell). DHCP reservation recommended so the IP stays stable. -// - Camera must respect picture.width/height OR be paired with a snapshot -// plugin that resizes. Otherwise /frame returns 400. +// - Camera must expose a video stream; pure-snapshot cameras need a +// transcoder mixin upstream. // The Scripts plugin (in @scrypted/core) evaluates this file inside the // scryptedEval sandbox. The runtime pre-injects the SDK names as scope @@ -233,9 +240,10 @@ class ScryptedViewportProvider extends ScryptedDeviceBase private viewports = new Map(); // nativeId -> child instance private listeners = new Map(); // nativeId -> camera event listener private streams = new Map stream control - interval: NodeJS.Timeout; timeout: NodeJS.Timeout; - abort: AbortController; + abort: AbortController; + proc?: any; // ffmpeg child process (live-stream mode) + interval?: NodeJS.Timeout; // legacy snapshot-poll mode }>(); private scryptedBase = ""; @@ -499,31 +507,113 @@ class ScryptedViewportProvider extends ScryptedDeviceBase await this.postJSON(`http://${v.host}/state`, { state: "wake" }); + const cam: any = systemManager.getDeviceById(v.cameraId); + if (!cam) return; + const w = v.orientation === "portrait" ? 480 : 800; + const h = v.orientation === "portrait" ? 800 : 480; + const fps = Math.max(1, Math.round(1000 / v.frameIntervalMs)); + + // Pull the camera's video stream, convert to ffmpeg input args, and + // pipe through a single ffmpeg child: input → scale(lanczos) → + // mjpeg q:v 2 → image2pipe. We then framer the raw MJPEG bytes + // out of stdout into individual JPEGs and POST each one. This + // beats the snapshot path because: + // - the camera's main encoder is producing keyframes anyway, so + // we're paying ~zero extra on the source side, + // - ffmpeg sustains real fps; the takePicture loop never could, + // - quality stays high (lanczos + q:v 2 ≈ visually lossless). + let stream: any; + try { + stream = await cam.getVideoStream({ destination: "remote-recorder" }); + } catch { + stream = await cam.getVideoStream(); + } + const ffmpegInputBuf: Buffer = await mediaManager.convertMediaObjectToBuffer( + stream, "x-scrypted/x-ffmpeg-input"); + let ffmpegInput: any; + try { ffmpegInput = JSON.parse(ffmpegInputBuf.toString("utf8")); } + catch (e) { + this.console.warn(`"${v.name}" no usable video stream for ffmpeg — skipping`); + return; + } + + const { spawn } = require("child_process"); + const ffmpegPath = + (mediaManager.getFFmpegPath ? await mediaManager.getFFmpegPath() : undefined) || + "ffmpeg"; + const abort = new AbortController(); - // Single-flight guard: at low intervals (≤ 200 ms) the camera's - // takePicture or the network round-trip can take longer than the - // tick. Without this we pile up overlapping fetches and Node - // surfaces them as the unhelpful "fetch failed". + const proc = spawn(ffmpegPath, [ + "-hide_banner", "-loglevel", "error", + "-fflags", "+genpts+nobuffer", "-flags", "low_delay", + ...(ffmpegInput.inputArguments || []), + "-an", "-sn", + "-vf", `scale=${w}:${h}:flags=lanczos,fps=${fps}`, + "-c:v", "mjpeg", "-q:v", "2", + "-f", "image2pipe", "-flush_packets", "1", + "pipe:1", + ]); + + // Single-flight: only one POST /frame in flight at a time. The + // firmware's JPEG decoder mutex returns 503 otherwise; we just + // drop the spare frames silently — natural rate-limiter. let inFlight = false; - let dropped = 0; - const interval = setInterval(() => { + let droppedFrames = 0; + let lastLogUs = Date.now(); + let workBuf: Buffer = Buffer.alloc(0); + + proc.stdout.on("data", (chunk: Buffer) => { if (abort.signal.aborted) return; - if (inFlight) { dropped++; return; } - inFlight = true; - this.pushFrame(v, abort) - .catch(e => { - if (abort.signal.aborted) return; - const err = (e as Error); - const cause = (err as any)?.cause?.code || (err as any)?.cause?.message || ""; - this.console.warn(`pushFrame "${v.name}":`, err.message, cause ? `(${cause})` : ""); - }) - .finally(() => { inFlight = false; }); - // Log dropped ticks once per second so the user sees if the - // configured interval is actually achievable. - if (dropped > 0 && dropped % Math.max(1, Math.round(1000 / v.frameIntervalMs)) === 0) { - this.console.log(`"${v.name}": ${dropped} ticks skipped (in-flight) — interval ${v.frameIntervalMs}ms is too fast for the pipeline`); + workBuf = workBuf.length === 0 ? chunk : Buffer.concat([workBuf, chunk]); + // Frame each complete JPEG (SOI = 0xFF 0xD8 ... EOI = 0xFF 0xD9). + // ffmpeg with image2pipe + mjpeg writes back-to-back complete + // JPEGs so we just split on EOI. + while (true) { + const eoi = workBuf.indexOf(Buffer.from([0xff, 0xd9])); + if (eoi < 0) break; + const frame = workBuf.subarray(0, eoi + 2); + workBuf = workBuf.subarray(eoi + 2); + if (frame.length < 4 || frame[0] !== 0xff || frame[1] !== 0xd8) continue; + + if (inFlight) { droppedFrames++; continue; } + inFlight = true; + this.pushStreamFrame(v, frame, abort) + .catch(e => { + if (abort.signal.aborted) return; + const err = (e as Error); + const cause = (err as any)?.cause?.code || ""; + this.console.warn(`pushStreamFrame "${v.name}":`, err.message, cause ? `(${cause})` : ""); + }) + .finally(() => { inFlight = false; }); } - }, v.frameIntervalMs); + }); + + proc.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8").trim(); + if (text) this.console.warn(`ffmpeg "${v.name}": ${text}`); + }); + proc.on("error", (e: any) => { + if (!abort.signal.aborted) this.console.warn(`ffmpeg "${v.name}" spawn error:`, e.message); + }); + proc.on("close", (code: number) => { + if (!abort.signal.aborted) this.console.log(`ffmpeg "${v.name}" exited (code=${code})`); + }); + abort.signal.addEventListener("abort", () => { + try { proc.kill("SIGTERM"); } catch {} + }); + + // Periodic skip-rate log so the operator sees the effective fps + // and can decide whether to bump frame_interval_ms up or down. + const skipLogger = setInterval(() => { + const now = Date.now(); + if (droppedFrames > 0) { + const window = (now - lastLogUs) / 1000; + this.console.log(`"${v.name}": dropping ~${(droppedFrames / window).toFixed(1)} fps over the last ${window.toFixed(1)}s; firmware decode-paint is the ceiling`); + droppedFrames = 0; + lastLogUs = now; + } + }, 10_000); + abort.signal.addEventListener("abort", () => clearInterval(skipLogger)); const timeoutMs = v.idleTimeoutMs > 0 ? v.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS; const timeout = setTimeout(() => { @@ -531,15 +621,16 @@ class ScryptedViewportProvider extends ScryptedDeviceBase this.stopStream(v.name); }, timeoutMs); - this.streams.set(v.name, { interval, timeout, abort }); + this.streams.set(v.name, { timeout, abort, proc }); } private stopStream(name: string, sendSleep = true) { const s = this.streams.get(name); if (!s) return; s.abort.abort(); - clearInterval(s.interval); + if (s.interval) clearInterval(s.interval); clearTimeout(s.timeout); + if (s.proc) { try { s.proc.kill("SIGTERM"); } catch {} } this.streams.delete(name); if (sendSleep) { const v = this.findByName(name); @@ -549,47 +640,39 @@ class ScryptedViewportProvider extends ScryptedDeviceBase } } - private findByName(name: string): Viewport | undefined { - for (const v of this.viewports.values()) if (v.name === name) return v; - return undefined; - } - - private async pushFrame(v: Viewport, abort: AbortController) { + private async pushStreamFrame(v: Viewport, jpeg: Buffer, abort: AbortController) { if (abort.signal.aborted) return; - const cam: any = systemManager.getDeviceById(v.cameraId); - if (!cam) return; - - const w = v.orientation === "portrait" ? 480 : 800; - const h = v.orientation === "portrait" ? 800 : 480; - - // Request the camera's native-resolution snapshot (no width/height - // in the picture options) and downsize + re-encode locally with - // ffmpeg at high quality. Most camera plugins default snapshot - // JPEGs to q≈75 and do a quick bilinear downscale, which looked - // visibly worse than the original H.264 stream. Lanczos + q=2 - // gets snapshot fidelity back close to the keyframe. - const picture = await cam.takePicture({ reason: "event" }); - const native: Buffer = await mediaManager.convertMediaObjectToBuffer(picture, "image/jpeg"); - const buf: Buffer = await this.resizeJpegHQ(native, w, h); - const res = await fetch(`http://${v.host}/frame`, { method: "POST", headers: { "Content-Type": "image/jpeg" }, - body: buf, + body: jpeg, signal: abort.signal, }); - if (res.status === 409) { this.console.log(`"${v.name}" returned 409 — device went to sleep, stopping stream`); this.stopStream(v.name, /*sendSleep=*/ false); } else if (res.status === 400) { const reason = await res.text().catch(() => ""); - this.console.warn(`"${v.name}" returned 400: ${reason}`); + this.console.warn(`"${v.name}" /frame -> 400: ${reason}`); } else if (!res.ok) { this.console.warn(`"${v.name}" /frame -> ${res.status}`); + } else { + // Frame painted; reset Scrypted-side idle timer alongside the + // device's own (each successful paint extends both). + const s = this.streams.get(v.name); + if (s) { + clearTimeout(s.timeout); + s.timeout = setTimeout(() => this.stopStream(v.name), + Math.max(5000, v.idleTimeoutMs)); + } } } + private findByName(name: string): Viewport | undefined { + for (const v of this.viewports.values()) if (v.name === name) return v; + return undefined; + } + // ------------------------------------------------------------------------ // Inbound: device → Scrypted POST /state // ------------------------------------------------------------------------ @@ -647,39 +730,6 @@ class ScryptedViewportProvider extends ScryptedDeviceBase async putSetting(_key: string, _value: SettingValue) {} - // ------------------------------------------------------------------------ - // Image resize via ffmpeg (snapshot fidelity ≫ camera-plugin downscale) - // ------------------------------------------------------------------------ - - private async resizeJpegHQ(jpeg: any, w: number, h: number): Promise { - const { spawn } = require("child_process"); - const ffmpegPath = - (mediaManager.getFFmpegPath ? await mediaManager.getFFmpegPath() : undefined) || - "ffmpeg"; - return await new Promise((resolve, reject) => { - const proc = spawn(ffmpegPath, [ - "-hide_banner", "-loglevel", "error", "-y", - "-f", "image2pipe", "-c:v", "mjpeg", "-i", "pipe:0", - "-vf", `scale=${w}:${h}:flags=lanczos`, - "-c:v", "mjpeg", "-q:v", "2", - "-f", "image2pipe", "pipe:1", - ]); - const chunks: any[] = []; - const errs: any[] = []; - proc.stdout.on("data", (c: any) => chunks.push(c)); - proc.stderr.on("data", (c: any) => errs.push(c)); - proc.on("error", (e: any) => reject(e)); - proc.on("close", (code: number) => { - if (code !== 0) { - reject(new Error(`ffmpeg exit ${code}: ${Buffer.concat(errs).toString("utf8").trim()}`)); - return; - } - resolve(Buffer.concat(chunks)); - }); - proc.stdin.end(jpeg); - }); - } - // ------------------------------------------------------------------------ // Tiny HTTP helper // ------------------------------------------------------------------------ -- cgit v1.2.3