src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-15 06:47:57 -0500
committerLuke Hoersten <[email protected]>2026-06-15 06:47:57 -0500
commit80defa7ee6815984477030464e3587b54e0e9f51 (patch)
treebb8dc4c4db6ef7701c11a13efe1b21020b348c1e
parent7b04ad6faf3bb70a0c3783e8446275f6f152b61e (diff)
scrypted: snapshot quality — fetch native res, resize + re-encode via ffmpeg
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.
-rw-r--r--scrypted/scrypted-viewport.ts47
1 files changed, 42 insertions, 5 deletions
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",
@@ -644,6 +648,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<any> {
+ const { spawn } = require("child_process");
+ const ffmpegPath =
+ (mediaManager.getFFmpegPath ? await mediaManager.getFFmpegPath() : undefined) ||
+ "ffmpeg";
+ return await new Promise<any>((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
// ------------------------------------------------------------------------