src.nth.io/

summaryrefslogtreecommitdiff
path: root/scrypted/scrypted-viewport.ts
diff options
context:
space:
mode:
Diffstat (limited to 'scrypted/scrypted-viewport.ts')
-rw-r--r--scrypted/scrypted-viewport.ts149
1 files changed, 108 insertions, 41 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index cace56f..6aa063f 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -6,7 +6,7 @@
// short git hash of the commit that added this constant — if the
// hash in the log doesn't match the HEAD this file came from, the
// Scrypted Script editor is still on stale code.
-const SCRIPT_VERSION = "d8d9a66";
+const SCRIPT_VERSION = "pending";
//
// Architecture
// ------------
@@ -123,6 +123,16 @@ class Viewport extends ScryptedDeviceBase implements Settings {
const v = this.storage.getItem("brightness");
return v ? Math.max(0, Math.min(100, parseInt(v, 10) || 0)) : DEFAULT_BRIGHTNESS;
}
+ // Preferred camera substream. "auto" walks a low-latency-first
+ // priority list and takes the first that resolves; the other
+ // options pin a specific substream so the user can pick a higher-
+ // fps source when "auto" lands on a slow 5-fps preview stream.
+ get streamDestination(): string {
+ const v = this.storage.getItem("stream_destination");
+ const allowed = new Set(["auto", "low-resolution", "medium-resolution",
+ "local", "remote", "remote-recorder"]);
+ return allowed.has(v as any) ? (v as string) : "auto";
+ }
// ffmpeg mjpeg encoder -q:v. Valid range 1..31, lower = higher
// quality + bigger JPEG (1 ≈ visually lossless, 31 ≈ very lossy).
// Default 1 — with HTTP keep-alive + NODELAY we have plenty of
@@ -197,6 +207,14 @@ class Viewport extends ScryptedDeviceBase implements Settings {
} as any,
{
group: "Display",
+ key: "stream_destination",
+ title: "Camera substream",
+ description: "Which camera-side stream to pull. 'auto' walks low-latency-first and picks the first that resolves (typically a low-fps preview substream — ~5-8 fps). Pin to medium-resolution or remote-recorder to force a higher-fps stream at the cost of larger frames + latency.",
+ choices: ["auto", "low-resolution", "medium-resolution", "local", "remote", "remote-recorder"],
+ value: this.streamDestination,
+ } as any,
+ {
+ group: "Display",
key: "jpeg_quality",
title: "JPEG quality (1–31, lower = better)",
description: "ffmpeg mjpeg encoder -q:v. 1 ≈ visually lossless (~140KB at panel-native), 5 ≈ good (~70KB), 10+ noticeably lossy. Default 1.",
@@ -759,7 +777,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// firmware still rejects 480x800, the rotation didn't apply
// (very rare ffmpeg build issue) and we'd need to look at
// installed ffmpeg version.
- this.console.log(`stream "${v.name}": orientation=${v.orientation} panel=${panelW}x${panelH} vf="${vf}"`);
+ // (stream config log emitted after substream selection below)
// Pull the camera's video stream, convert to ffmpeg input args, and
// pipe through a single ffmpeg child: input → scale(lanczos) →
@@ -777,12 +795,17 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// Walk substreams from lowest-latency → highest-latency and
// take the first one that resolves.
let stream: any;
- const destOrder = ["low-resolution", "medium-resolution", "local", "remote", "remote-recorder"];
+ let pickedDest = "(default)";
+ const userPref = v.streamDestination;
+ const destOrder = userPref === "auto"
+ ? ["low-resolution", "medium-resolution", "local", "remote", "remote-recorder"]
+ : [userPref];
for (const destination of destOrder) {
- try { stream = await cam.getVideoStream({ destination }); break; }
+ try { stream = await cam.getVideoStream({ destination }); pickedDest = destination; break; }
catch { /* try next */ }
}
- if (!stream) stream = await cam.getVideoStream();
+ if (!stream) { stream = await cam.getVideoStream(); pickedDest = "(camera-default)"; }
+ this.console.log(`stream "${v.name}": orientation=${v.orientation} panel=${panelW}x${panelH} vf="${vf}" substream=${pickedDest}`);
const ffmpegInputBuf: Buffer = await mediaManager.convertMediaObjectToBuffer(
stream, "x-scrypted/x-ffmpeg-input");
let ffmpegInput: any;
@@ -1022,11 +1045,17 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}
}
- // First-paint fast path. takePicture → quick ffmpeg one-shot to
- // transpose+scale to panel-native dims → POST /frame. Shares the
- // viewport's frameSeq counter so a slow snapshot can't paint over
- // a faster stream frame — whichever loses the race gets stale-
- // dropped by the firmware.
+ // First-paint fast path. takePicture → resize/rotate to panel
+ // native → POST /frame. Tries three transforms in order of cost:
+ // 1. sharp — libvips bindings, ~5-15ms per image, handles
+ // resize + rotate in one call. Not always present
+ // in Scrypted's plugin sandbox.
+ // 2. mediaManager.convertMediaObjectToBuffer with size hint —
+ // Scrypted's native converter (often vips-backed).
+ // Resize-capable; rotation support varies. We only
+ // use it for landscape (no rotate needed).
+ // 3. ffmpeg one-shot — old slow path, ~500-700ms cold start.
+ // Always works; the safety net.
private async pushSnapshot(v: Viewport, cam: any) {
const t0 = Date.now();
let mo: any;
@@ -1036,43 +1065,76 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
const srcJpeg: Buffer = await mediaManager.convertMediaObjectToBuffer(mo, "image/jpeg");
if (!srcJpeg || srcJpeg.length < 4) return;
+ const tDecoded = Date.now();
// Cached dims from prior /state read; falls back to 800x480.
const panelW = parseInt(v.storage.getItem("panel_w") || "0", 10) || 800;
const panelH = parseInt(v.storage.getItem("panel_h") || "0", 10) || 480;
- const vf = v.orientation === "portrait"
- ? `transpose=1,scale=${panelW}:${panelH}:flags=lanczos,setsar=1`
- : `scale=${panelW}:${panelH}:flags=lanczos,setsar=1`;
+ const needsRotate = v.orientation === "portrait";
- const { spawn } = require("child_process");
- const ffmpegPath =
- (mediaManager.getFFmpegPath ? await mediaManager.getFFmpegPath() : undefined) ||
- "ffmpeg";
+ let transformed: Buffer = Buffer.alloc(0);
+ let path = "";
- const transformed: Buffer = await new Promise<Buffer>((resolve, reject) => {
- const p = spawn(ffmpegPath, [
- "-hide_banner", "-loglevel", "error",
- "-f", "image2pipe", "-i", "pipe:0",
- "-vf", vf,
- "-frames:v", "1",
- "-c:v", "mjpeg", "-q:v", String(v.jpegQuality),
- "-f", "image2pipe", "pipe:1",
- ]);
- const chunks: Buffer[] = [];
- p.stdout.on("data", (c: Buffer) => chunks.push(c));
- p.on("close", (code: number) => {
- if (code !== 0) reject(new Error(`ffmpeg snapshot exit ${code}`));
- else resolve(Buffer.concat(chunks));
- });
- p.on("error", reject);
- p.stdin.on("error", () => {}); // suppress EPIPE if ffmpeg fails early
- p.stdin.end(srcJpeg);
- // Hard cap so a stuck ffmpeg can't delay the main stream
- // (which is starting in parallel anyway).
- setTimeout(() => { try { p.kill("SIGTERM"); } catch {} }, 2000);
- }).catch(() => Buffer.alloc(0));
+ // Path 1: sharp. require()-fail caught at the boundary so a
+ // missing native module just falls through.
+ if (!transformed.length) {
+ try {
+ const sharp = require("sharp");
+ let img = sharp(srcJpeg, { failOnError: false });
+ if (needsRotate) img = img.rotate(90);
+ transformed = await img
+ .resize(panelW, panelH, { fit: "fill", kernel: "lanczos3" })
+ .jpeg({ quality: Math.max(50, 100 - v.jpegQuality * 3) })
+ .toBuffer();
+ path = "sharp";
+ } catch { /* fall through */ }
+ }
+
+ // Path 2: Scrypted's native converter. Only used for landscape
+ // because the mime-parameter spec has no documented rotation
+ // and most implementations don't support it.
+ if (!transformed.length && !needsRotate) {
+ try {
+ transformed = await mediaManager.convertMediaObjectToBuffer(
+ mo, `image/jpeg;width=${panelW};height=${panelH}`);
+ if (transformed?.length) path = "media-mgr";
+ } catch { /* fall through */ }
+ }
+
+ // Path 3: ffmpeg fallback. The slow ~500ms cold-start path.
+ if (!transformed.length) {
+ const vf = needsRotate
+ ? `transpose=1,scale=${panelW}:${panelH}:flags=lanczos,setsar=1`
+ : `scale=${panelW}:${panelH}:flags=lanczos,setsar=1`;
+ const { spawn } = require("child_process");
+ const ffmpegPath =
+ (mediaManager.getFFmpegPath ? await mediaManager.getFFmpegPath() : undefined) ||
+ "ffmpeg";
+ transformed = await new Promise<Buffer>((resolve, reject) => {
+ const p = spawn(ffmpegPath, [
+ "-hide_banner", "-loglevel", "error",
+ "-f", "image2pipe", "-i", "pipe:0",
+ "-vf", vf,
+ "-frames:v", "1",
+ "-c:v", "mjpeg", "-q:v", String(v.jpegQuality),
+ "-f", "image2pipe", "pipe:1",
+ ]);
+ const chunks: Buffer[] = [];
+ p.stdout.on("data", (c: Buffer) => chunks.push(c));
+ p.on("close", (code: number) => {
+ if (code !== 0) reject(new Error(`ffmpeg snapshot exit ${code}`));
+ else resolve(Buffer.concat(chunks));
+ });
+ p.on("error", reject);
+ p.stdin.on("error", () => {});
+ p.stdin.end(srcJpeg);
+ setTimeout(() => { try { p.kill("SIGTERM"); } catch {} }, 2000);
+ }).catch(() => Buffer.alloc(0));
+ if (transformed.length) path = "ffmpeg";
+ }
if (transformed.length < 4) return;
+ const tTransformed = Date.now();
const seq = (this.frameSeq.get(v.nativeId!) || 0) + 1;
this.frameSeq.set(v.nativeId!, seq);
@@ -1084,8 +1146,13 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
signal: AbortSignal.timeout(2000),
});
await res.text().catch(() => "");
- const wasStale = res.headers.get("X-Frame-Drop") === "stale-seq";
- this.console.log(`snapshot "${v.name}": ${Date.now() - t0}ms total (${(transformed.length / 1024).toFixed(0)}KB)${wasStale ? " — beaten by stream frame" : ""}`);
+ const tPosted = Date.now();
+ const wasStale = res.headers.get("X-Frame-Drop") === "stale-seq";
+ this.console.log(
+ `snapshot "${v.name}" via ${path}: ${tPosted - t0}ms total ` +
+ `(takePicture=${tDecoded - t0}ms transform=${tTransformed - tDecoded}ms ` +
+ `post=${tPosted - tTransformed}ms, ${(transformed.length / 1024).toFixed(0)}KB)` +
+ (wasStale ? " — beaten by stream frame" : ""));
} catch { /* stream is starting anyway */ }
}