From 80defa7ee6815984477030464e3587b54e0e9f51 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Mon, 15 Jun 2026 06:47:57 -0500 Subject: scrypted: snapshot quality — fetch native res, resize + re-encode via ffmpeg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopped asking cam.takePicture for a specific dimension. Most camera plugins default snapshot JPEGs to q≈75 and do a quick bilinear downscale from native (1920x1080 or so) to whatever we requested, which looked visibly worse than the H.264 keyframe from the same camera at the same panel resolution. pushFrame now: 1. takePicture without picture.{width,height} → native-res JPEG 2. spawn ffmpeg child_process: scale w:h:flags=lanczos, mjpeg q:v 2 3. POST the result. q:v 2 is near-lossless; Lanczos is a sharper downscale than the camera plugin's default. Adds ~10-20 ms ffmpeg work per frame on a real CPU (noise-level on a Pi 4), well under the snapshot-source ceiling that already gates pushFrame at ~500 ms. --- scrypted/scrypted-viewport.ts | 47 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) (limited to 'scrypted/scrypted-viewport.ts') diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index 405b019..c29276b 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -562,11 +562,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase const w = v.orientation === "portrait" ? 480 : 800; const h = v.orientation === "portrait" ? 800 : 480; - const picture = await cam.takePicture({ - picture: { width: w, height: h }, - reason: "event", - }); - const buf: Buffer = await mediaManager.convertMediaObjectToBuffer(picture, "image/jpeg"); + // 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", @@ -643,6 +647,39 @@ 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