// Scrypted Viewport — v1 Scripts-plugin script
//
// SCRIPT_VERSION is bumped on every commit that touches this file.
// The boot log emits it so we can verify the user re-pasted the
// latest version when reading the plugin console. Format is the
// 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 = "e75891e";
//
// Architecture
// ------------
// One parent device (DeviceProvider + DeviceCreator + HttpRequestHandler)
// spawns N child "Viewport" devices via the Scrypted UI. Each child holds
// the per-viewport binding (host, camera, orientation, idle timeout,
// brightness) as plain device settings and is editable from its own
// Settings page in the Scrypted UI. The parent owns the camera event
// subscriptions, the snapshot push loop, the per-stream safety timer,
// and the inbound `POST /state` handler.
//
// Install
// -------
// 1. In Scrypted: Plugins → install "Scripts" if needed.
// 2. + Add Device → Scripts plugin → New Script.
// 3. Paste this entire file. Save.
// 4. Open the new "Scrypted Viewport" device. Click "+ Add Device" on
// its page to create a viewport binding:
// Viewport name e.g. "mudroom" (friendly routing key)
// IP or hostname e.g. "192.168.1.42"
// Camera pick from the dropdown of Camera devices
// Orientation portrait (480×800) or landscape (800×480)
// 5. The script POSTs /config to the device immediately and re-issues it
// every 5 minutes so a reboot or DHCP renumber re-syncs.
// 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.
//
// 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 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
// locals — no `import` is needed (or allowed: any `import ... from
// "@scrypted/sdk"` compiles to a require() that fails to resolve).
// All we do here is `declare` each one so TypeScript is happy; the
// declarations erase at compile time and the values come from the
// runtime scope.
declare const sdk: any;
declare const ScryptedDeviceBase: any;
declare const ScryptedDeviceType: any;
declare const ScryptedInterface: any;
declare const systemManager: any;
declare const endpointManager: any;
declare const mediaManager: any;
declare const deviceManager: any;
declare const log: any;
declare const device: any;
declare const require: any;
// Loose type aliases — purely cosmetic for the rest of the script's
// signatures, since the runtime values are `any`.
type DeviceCreator = any;
type DeviceCreatorSettings = any;
type DeviceProvider = any;
type EventListenerRegister = any;
type HttpRequest = any;
type HttpRequestHandler = any;
type HttpResponse = any;
type Setting = any;
type Settings = any;
type SettingValue = any;
// Tuning constants.
// Stream rate is paced by camera + TCP backpressure; no app-level fps
// cap, no per-frame pipelining semaphore.
const REREGISTER_INTERVAL_MS = 5 * 60_000;
// 5s is generous for snapshot POSTs (the only HTTP traffic during a
// stream) and survives long-tail latency on a busy Scrypted host.
const HTTP_TIMEOUT_MS = 5_000;
const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
const DEFAULT_BRIGHTNESS = 100;
// ============================================================================
// Child: one viewport binding
// ============================================================================
class Viewport extends ScryptedDeviceBase implements Settings {
constructor(public provider: ScryptedViewportProvider, nativeId: string) {
super(nativeId);
}
get host(): string { return this.storage.getItem("host") || ""; }
get cameraId(): string { return this.storage.getItem("cameraId") || ""; }
get orientation(): "portrait" | "landscape" {
const v = this.storage.getItem("orientation");
return v === "landscape" ? "landscape" : "portrait";
}
get idleTimeoutMs(): number {
const v = this.storage.getItem("idle_timeout_ms");
return v ? Math.max(0, parseInt(v, 10) || 0) : DEFAULT_IDLE_TIMEOUT_MS;
}
get brightness(): number {
const v = this.storage.getItem("brightness");
return v ? Math.max(0, Math.min(100, parseInt(v, 10) || 0)) : DEFAULT_BRIGHTNESS;
}
// 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
// body-upload headroom for the bigger frames. Bump up only if
// you're chasing fewer bytes on the wire at the cost of visible
// artifacts.
get jpegQuality(): number {
const v = this.storage.getItem("jpeg_quality");
const parsed = v ? parseInt(v, 10) : NaN;
return Number.isFinite(parsed) ? Math.max(1, Math.min(31, parsed)) : 1;
}
// Hard cap on Node's TCP send buffer for the stream socket, in MB.
// ffmpeg pumps faster than firmware can ingest so socket.write
// returning false (kernel buffer full) causes Node to accumulate
// the surplus in its own internal queue — which is unbounded by
// default. Without this cap, a long-running stream's heap grows
// monotonically and every frame painted on the panel becomes
// progressively staler (the buffer depth = display lag). When the
// measured node_buf exceeds the cap, the demux loop drops new
// ffmpeg frames at source instead of queueing them. Lower =
// tighter latency + more drops under sustained backpressure;
// higher = more memory + worse latency but less visual choppiness.
get maxNodeBufMb(): number {
const v = this.storage.getItem("max_node_buf_mb");
const parsed = v ? parseInt(v, 10) : NaN;
return Number.isFinite(parsed) ? Math.max(1, Math.min(200, parsed)) : 20;
}
// Which camera-event types wake this viewport. Empty = tap-only,
// never woken by Scrypted. Default = all three (doorbell + motion +
// person detection).
get triggers(): Set {
const v = this.storage.getItem("triggers");
if (v === null) return new Set(["doorbell", "motion", "person"]);
try { return new Set(JSON.parse(v)); } catch { return new Set(); }
}
async getSettings(): Promise {
const settings: Setting[] = [
{
group: "Binding",
key: "host",
title: "IP or hostname",
description: "Viewport's address on the LAN. Set this manually — find it via your DHCP table, or `dns-sd -G v4 viewport-.local` on macOS, or `avahi-resolve -n viewport-.local` on Linux. The info screen on the device itself shows its MAC + IP.",
placeholder: "192.168.1.42",
value: this.host,
} as any,
{
group: "Binding",
key: "cameraId",
title: "Camera",
description: "Camera whose events drive this viewport's wake/sleep, and whose snapshots get streamed.",
type: "device",
deviceFilter: `interfaces.includes('${ScryptedInterface.Camera}')`,
value: this.cameraId,
} as any,
{
group: "Binding",
key: "triggers",
title: "Wake triggers",
description: "Which camera-event types automatically wake the viewport. Clear all of them for tap-only mode (the viewport never wakes from Scrypted; user must tap to see the camera).",
choices: ["doorbell", "motion", "person"],
multiple: true,
value: Array.from(this.triggers),
} as any,
{
group: "Display",
key: "orientation",
title: "Orientation",
description: "Panel orientation. Frames are sent at this effective resolution.",
choices: ["portrait", "landscape"],
value: this.orientation,
} as any,
{
group: "Display",
key: "brightness",
title: "Brightness (0–100)",
description: "Sent to the device via /config. Gamma-corrected on the panel.",
type: "number",
value: this.brightness,
} as any,
{
group: "Display",
key: "idle_timeout_ms",
title: "Idle timeout (ms)",
description: "How long the device stays awake after the last paint before it sleeps itself. 0 disables; non-zero must be ≥ 5000.",
type: "number",
value: this.idleTimeoutMs,
} 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.",
type: "number",
value: this.jpegQuality,
} as any,
{
group: "Display",
key: "max_node_buf_mb",
title: "Max Scrypted-side buffer (MB)",
description: "Hard cap on Node's TCP send queue for the stream socket. When exceeded, the socket is destroyed and reconnected — drops the entire backlog so the next frame painted is fresh. Lower = tighter glass-to-glass at cost of brief reconnect gaps under sustained backpressure. Default 20.",
type: "number",
value: this.maxNodeBufMb,
} as any,
{
group: "Actions",
key: "action_wake",
title: "Wake now",
description: "Toggle on to POST /state {wake} and start streaming the bound camera. Resets automatically after firing.",
type: "boolean",
value: false,
} as any,
{
group: "Actions",
key: "action_sleep",
title: "Sleep now",
description: "Toggle on to POST /state {sleep} and stop the active stream. Resets automatically after firing.",
type: "boolean",
value: false,
} as any,
];
// Live device snapshot: GET /state then /config sequentially
// (parallel ate both httpd slots simultaneously after Phase 2
// dropped max_open_sockets to 2, and could collide with an
// in-flight stream-socket cap-flush reconnect). 3s timeout is
// generous but not so long that an offline device feels
// unresponsive in the UI.
if (this.host) {
try {
const stateRes = await fetch(`http://${this.host}/state`,
{ signal: AbortSignal.timeout(3000) }).then(r => r.json());
const configRes = await fetch(`http://${this.host}/config`,
{ signal: AbortSignal.timeout(3000) }).then(r => r.json());
settings.push(
{ group: "Status (live)", key: "_st_name", title: "name", value: stateRes.name, readonly: true } as any,
{ group: "Status (live)", key: "_st_mac", title: "mac", value: stateRes.mac, readonly: true } as any,
{ group: "Status (live)", key: "_st_ip", title: "ip", value: stateRes.ip, readonly: true } as any,
{ group: "Status (live)", key: "_st_state", title: "state", value: stateRes.state, readonly: true } as any,
{ group: "Status (live)", key: "_st_cfg", title: "configured", value: String(stateRes.configured), readonly: true } as any,
{ group: "Status (live)", key: "_st_uptime", title: "uptime (ms)", value: String(stateRes.uptime_ms), readonly: true } as any,
{ group: "Status (live)", key: "_st_last", title: "last frame (ms ago)", value: String(stateRes.last_frame_ms_ago ?? "(none)"), readonly: true } as any,
{ group: "Status (live)", key: "_st_fr", title: "frames received", value: String(stateRes.frames_received), readonly: true } as any,
{ group: "Status (live)", key: "_st_err", title: "decode errors", value: String(stateRes.decode_errors), readonly: true } as any,
{ group: "Status (live)", key: "_st_post", title: "state post failures", value: String(stateRes.state_post_failures), readonly: true } as any,
{ group: "Status (live)", key: "_st_res", title: "resolution", value: stateRes.resolution, readonly: true } as any,
{ group: "Status (live)", key: "_st_heap", title: "free heap (bytes)", value: String(stateRes.free_heap), readonly: true } as any,
{ group: "Status (live)", key: "_st_psram", title: "free PSRAM (bytes)", value: String(stateRes.free_psram), readonly: true } as any,
{ group: "Status (live)", key: "_st_ver", title: "firmware", value: stateRes.version, readonly: true } as any,
{ group: "Status (live)", key: "_cfg_scrypt",title: "config: scrypted URL",value: configRes.scrypted ?? "(not set)", readonly: true } as any,
);
} catch (e) {
settings.push({ group: "Status (live)", key: "_st_err", title: "device", value: `offline / unreachable (${(e as Error).message})`, readonly: true } as any);
}
}
return settings;
}
async putSetting(key: string, value: SettingValue) {
if (key.startsWith("_")) return; // ignore read-only status fields
if (key === "action_wake" || key === "action_sleep") {
// Manual override from the Scrypted UI. Wake also starts a
// stream so the user sees the camera immediately; Sleep
// tears down the live ffmpeg and POSTs sleep.
// Boolean acts as a one-shot trigger — fire on truthy then
// re-render with the toggle cleared so it's ready to fire
// again next time.
const truthy = value === true || value === "true";
if (!truthy) return;
if (!this.host) return;
if (key === "action_wake") {
if (!this.provider.streams.has(this.name) &&
!this.provider.streamStarting.has(this.nativeId!)) {
this.provider.streamStarting.add(this.nativeId!);
this.provider.startStream(this)
.catch(e => this.console.error("manual wake failed", e))
.finally(() => this.provider.streamStarting.delete(this.nativeId!));
}
} else {
this.provider.stopStream(this.name, /*sendSleep=*/ true);
}
return;
}
if (key === "triggers") {
// multi-select arrives as array; serialise to JSON for storage
this.storage.setItem("triggers", JSON.stringify(Array.isArray(value) ? value : []));
} else {
this.storage.setItem(key, String(value ?? ""));
}
await this.provider.onBindingChanged(this);
}
}
// ============================================================================
// Parent: provider + HTTP handler + global tuning
// ============================================================================
class ScryptedViewportProvider extends ScryptedDeviceBase
implements DeviceProvider, DeviceCreator, HttpRequestHandler, Settings {
private viewports = new Map(); // nativeId -> child instance
private listeners = new Map(); // nativeId -> camera event listener
streams = new Map stream control (accessed by Viewport.putSetting for manual wake/sleep)
timeout: NodeJS.Timeout;
abort: AbortController; // also tears down the ffmpeg child via its listener
interval?: NodeJS.Timeout; // legacy snapshot-poll mode
}>();
private scryptedBase = "";
constructor(nativeId?: string) {
super(nativeId);
this.start().catch(e => this.console.error("start failed", e));
}
// ------------------------------------------------------------------------
// Lifecycle
// ------------------------------------------------------------------------
private get childIds(): string[] {
try { return JSON.parse(this.storage.getItem("childIds") || "[]"); }
catch { return []; }
}
private set childIds(ids: string[]) {
this.storage.setItem("childIds", JSON.stringify(ids));
}
private async start() {
// endpointManager.getInsecurePublicLocalEndpoint() takes a nativeId
// (string) — passing this.id (numeric Scrypted DB ID) throws
// "invalid nativeId N". this.nativeId is the right key, and an
// omitted nativeId falls back to the plugin's own endpoint.
const raw = await endpointManager.getInsecurePublicLocalEndpoint(this.nativeId);
this.scryptedBase = raw.replace(/\/$/, "");
this.console.log(`Scrypted Viewport up (script=${SCRIPT_VERSION}). Callback URL base: ${this.scryptedBase}`);
// Re-discover every known child so Scrypted reattaches its storage
// to the nativeId. Without this, `new Viewport(...)` instantiates
// with `this.storage === undefined` and every storage-backed getter
// (host / cameraId / orientation / ...) throws on script reload.
// Then eagerly instantiate so each child's registration + camera
// event subscription happen at plugin load.
for (const nativeId of this.childIds) {
try {
// Use the persisted display_name as the canonical device
// name so a script reload doesn't reset it to the nativeId.
// First-time provision falls back to the nativeId.
const displayName =
deviceManager.getDeviceStorage(nativeId).getItem("display_name") || nativeId;
await deviceManager.onDeviceDiscovered({
providerNativeId: this.nativeId,
nativeId,
name: displayName,
type: ScryptedDeviceType.SmartDisplay,
interfaces: [ScryptedInterface.Settings],
});
await this.getDevice(nativeId);
}
catch (e) { this.console.warn(`load child ${nativeId} failed:`, (e as Error).message); }
}
// Periodic re-register so a device that rebooted or got a new IP
// re-syncs without manual intervention.
//
// Important: Scrypted's Scripts sandbox does NOT garbage-collect
// setInterval handles when the script is re-pasted/reloaded.
// Without the globalThis cancel below, every re-paste leaves an
// orphan interval still running against the previous Provider
// instance. After N reloads the user gets N rapid-fire
// "registered ..." log lines every 5 minutes.
const G = globalThis as any;
if (G.__viewportRegisterInterval) {
try { clearInterval(G.__viewportRegisterInterval); } catch {}
}
// Tear down any camera event listeners left over from a
// previous script load (same Scripts-sandbox lifecycle gap as
// the setInterval handle). Without this every re-paste stacks
// an extra callback on the camera, producing duplicate stream
// starts + concurrent snapshot transforms that race for the
// firmware decoder lock and visibly degrade quality.
if (Array.isArray(G.__viewportListenerCleaners)) {
for (const remove of G.__viewportListenerCleaners) {
try { remove(); } catch {}
}
}
G.__viewportListenerCleaners = [];
G.__viewportRegisterInterval = setInterval(() => {
for (const v of this.viewports.values()) {
this.registerViewport(v).catch(() => {});
}
}, REREGISTER_INTERVAL_MS);
}
// ------------------------------------------------------------------------
// DeviceProvider
// ------------------------------------------------------------------------
async getDevice(nativeId: string): Promise {
let v = this.viewports.get(nativeId);
if (!v) {
v = new Viewport(this, nativeId);
this.viewports.set(nativeId, v);
this.attachListener(v);
await this.registerViewport(v);
}
return v;
}
async releaseDevice(id: string, nativeId: string) {
const v = this.viewports.get(nativeId);
if (v) {
this.stopStream(v.name, /*sendSleep=*/ false);
this.detachListener(nativeId);
this.viewports.delete(nativeId);
}
this.childIds = this.childIds.filter(x => x !== nativeId);
}
// ------------------------------------------------------------------------
// DeviceCreator — "+ Add Device" form on the parent
// ------------------------------------------------------------------------
async getCreateDeviceSettings(): Promise {
return [
{
key: "name",
title: "Viewport name",
description: 'Friendly routing key sent back in callbacks. Lowercase, no spaces. Example: "mudroom".',
placeholder: "mudroom",
},
{
key: "host",
title: "IP or hostname",
description: "Where the firmware lives on the LAN — either an IP or `viewport-.local` (the device prints its own MAC on the info screen). The script POSTs to this string directly; no auto-resolution.",
placeholder: "192.168.1.42",
},
{
key: "cameraId",
title: "Camera",
type: "device",
deviceFilter: `interfaces.includes('${ScryptedInterface.Camera}')`,
},
{
key: "triggers",
title: "Wake triggers",
description: "Which camera-event types automatically wake the viewport. Clear all of them for tap-only mode (the viewport never wakes from Scrypted; user must tap to see the camera).",
choices: ["doorbell", "motion", "person"],
multiple: true,
value: ["doorbell", "motion", "person"],
} as any,
{
key: "orientation",
title: "Orientation",
choices: ["portrait", "landscape"],
value: "portrait",
},
];
}
async createDevice(settings: DeviceCreatorSettings): Promise {
const name = String(settings.name || "viewport").trim();
const nativeId = `vp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
// 1. Register the device with Scrypted FIRST. deviceManager
// materialises the storage container only after discovery —
// calling getDeviceStorage before this returns undefined and
// setItem() throws "Cannot read properties of undefined".
await deviceManager.onDeviceDiscovered({
providerNativeId: this.nativeId,
nativeId,
name,
type: ScryptedDeviceType.SmartDisplay,
interfaces: [ScryptedInterface.Settings],
});
// 2. Now safe to seed the child's storage from the form values.
// display_name is the canonical user-facing name; v.name (the
// ScryptedDeviceBase one) is async-loaded from Scrypted's record
// and races with our first registerViewport call, so we mirror
// it into storage as a stable fallback for register/log paths.
const childStore = deviceManager.getDeviceStorage(nativeId);
childStore.setItem("display_name", name);
childStore.setItem("host", String(settings.host || ""));
childStore.setItem("cameraId", String(settings.cameraId || ""));
childStore.setItem("orientation", String(settings.orientation || "portrait"));
// settings.triggers arrives as an array from the multi-select.
// JSON-encode to match how Viewport.putSetting stores it on
// subsequent edits.
const trigs = Array.isArray(settings.triggers)
? settings.triggers
: ["doorbell", "motion", "person"];
childStore.setItem("triggers", JSON.stringify(trigs));
this.childIds = [...this.childIds, nativeId];
this.console.log(`created viewport "${name}" (${nativeId})`);
// Kick off the first register cycle (POST /config to the device).
// Fire-and-forget — the new device shows up immediately either way.
const child = await this.getDevice(nativeId);
if (child) this.registerViewport(child).catch(() => {});
return nativeId;
}
// ------------------------------------------------------------------------
// Per-binding plumbing (camera subscription + /config registration)
// ------------------------------------------------------------------------
// Per-viewport debounce timer. Scrypted's Settings UI does one
// putSetting per field on save, so a typical "Save" with 5 fields
// changed used to register 5 times. Coalesce into a single apply.
private bindingDebounce = new Map();
onBindingChanged = async (v: Viewport): Promise => {
const nid = v.nativeId!;
const pending = this.bindingDebounce.get(nid);
if (pending) clearTimeout(pending);
this.bindingDebounce.set(nid, setTimeout(() => {
this.bindingDebounce.delete(nid);
this.detachListener(nid);
// Any active stream for this viewport is now stale (camera
// or orientation may have changed). Stop cleanly; if it
// was live we relaunch immediately under the new settings
// so the user sees the change without waiting for the next
// camera event.
const wasStreaming = this.streams.has(v.name);
this.stopStream(v.name, /*sendSleep=*/ false);
this.attachListener(v);
this.registerViewport(v)
.then(() => {
if (wasStreaming) {
if (this.streamStarting.has(nid)) return;
this.streamStarting.add(nid);
this.startStream(v)
.catch(e => this.console.error("restart after setting change failed", e))
.finally(() => this.streamStarting.delete(nid));
}
})
.catch(() => {});
}, 300));
};
private attachListener(v: Viewport) {
if (!v.cameraId) return;
const cam = systemManager.getDeviceById(v.cameraId);
const tag = v.name || v.storage.getItem("display_name") || v.nativeId;
if (!cam) {
this.console.warn(`viewport "${tag}": camera ${v.cameraId} not found`);
return;
}
const ifaces = [
ScryptedInterface.BinarySensor, // doorbell
ScryptedInterface.MotionSensor, // motion
ScryptedInterface.ObjectDetector, // person / etc
];
const reg = cam.listen(ifaces, (source, details, data) => {
this.handleCameraEvent(v, details, data);
});
this.listeners.set(v.nativeId!, reg);
// Track on globalThis so script reload can remove this
// listener from the camera plugin. Without that, every
// re-paste leaves a dead callback subscribed to the camera
// and every motion/doorbell event triggers handleCameraEvent
// N times for N stacked reloads — observable as duplicate
// "stream start" log lines + two simultaneous pushSnapshots
// racing for the firmware decoder lock.
const G = globalThis as any;
if (!G.__viewportListenerCleaners) G.__viewportListenerCleaners = [];
G.__viewportListenerCleaners.push(() => { try { reg.removeListener(); } catch {} });
this.console.log(`viewport "${tag}": subscribed to "${cam.name}"`);
}
private detachListener(nativeId: string) {
const reg = this.listeners.get(nativeId);
if (reg) {
try { reg.removeListener(); } catch {}
this.listeners.delete(nativeId);
}
}
private async registerViewport(v: Viewport) {
// display_name is the canonical user-facing name (written on
// createDevice and on every Settings save). v.name is just a
// render of it from the Scrypted device record and can briefly
// drift to the nativeId on script reload, so prefer the storage
// value as the source of truth.
const stored = v.storage.getItem("display_name");
const name = (stored && stored.trim()) || (v.name && v.name.trim()) || "";
if (!name) {
this.console.warn(`register skipped — empty name on ${v.nativeId}; will retry on next event`);
return;
}
if (!v.host) {
this.console.warn(`register "${name}" skipped — no host. Set the viewport's "IP or hostname" field; see the README for how to find it via mDNS from your shell.`);
return;
}
try {
await this.postJSON(`http://${v.host}/config`, {
viewport: name,
scrypted: this.scryptedBase,
idle_timeout_ms: v.idleTimeoutMs,
orientation: v.orientation,
brightness: v.brightness,
});
// Cache the name in storage so a future empty-.name event can
// still find it. createDevice + putSetting always update this.
v.storage.setItem("display_name", name);
this.console.log(`registered "${name}" (${v.host})`);
} catch (e) {
this.console.warn(`register "${name}" failed:`, (e as Error).message);
}
}
// ------------------------------------------------------------------------
// Camera event → stream
// ------------------------------------------------------------------------
// Per-viewport "stream is actively starting" guard. handleCameraEvent
// can fire multiple times in the same second (MotionSensor often
// re-asserts every ~500 ms while motion is sustained); without this,
// each event launches its own startStream which races with the
// previous one and saturates the firmware's httpd. If a stream is
// already live we just leave it running — the per-stream timeout
// anchored to the event still fires correctly.
streamStarting = new Set();
private handleCameraEvent(v: Viewport, details: any, data: any) {
const iface = details.eventInterface;
const allowed = v.triggers;
let trigger = false;
if (allowed.has("doorbell") && iface === ScryptedInterface.BinarySensor && data === true) trigger = true;
if (allowed.has("motion") && iface === ScryptedInterface.MotionSensor && data === true) trigger = true;
if (allowed.has("person") && iface === ScryptedInterface.ObjectDetector) {
const detections = data?.detections ?? [];
if (detections.some((d: any) => d?.className === "person")) trigger = true;
}
if (!trigger) return;
// Capture the wall-clock at event arrival so every downstream
// log line can rebase onto it (+Xms since event). This is the
// anchor for measuring glass-to-glass and confirming that
// snapshot + stream start truly in parallel.
const tEvent = Date.now();
this.console.log(`event ${iface} -> "${v.name}": fired at +0ms (wake)`);
// If a stream is already in flight for this viewport, the event
// is just reinforcement — the existing ffmpeg child is already
// pushing frames. We do NOT relaunch (would race with previous).
if (this.streams.has(v.name)) return;
if (this.streamStarting.has(v.nativeId!)) return;
this.streamStarting.add(v.nativeId!);
this.startStream(v, tEvent)
.catch(e => this.console.error("startStream failed", e))
.finally(() => this.streamStarting.delete(v.nativeId!));
}
async startStream(v: Viewport, tEvent: number = Date.now()) {
const since = () => Date.now() - tEvent;
this.console.log(`stream "${v.name}": start +${since()}ms`);
// (event_us_low is stamped per frame at emit time inside the
// demux loop — see the writeUInt32BE call below. This gives
// "age of the currently-displayed frame" semantics for g2g,
// not "time since wake".)
// Race rule: cancel pending operations on every callback before
// beginning a fresh stream.
this.stopStream(v.name, /*sendSleep=*/ false);
if (!v.host || !v.cameraId) return;
await this.postJSON(`http://${v.host}/state`, { state: "wake" });
const cam: any = systemManager.getDeviceById(v.cameraId);
if (!cam) return;
// Snapshot-then-stream: fire takePicture in parallel with the
// main ffmpeg spawn below. takePicture often hits a cached
// image and resolves in 50–300ms, vs. 0.5–3s before the first
// ffmpeg-emitted frame lands (ffmpeg startup + RTSP connect +
// first H.264 keyframe wait). The snapshot fills the gap so
// the panel shows the camera near-instantly on tap/event.
// Fire-and-forget — runs in parallel with stream socket bring-up.
// Whichever lands first wins user-visibly; if the stream's
// first frame arrives before the snapshot finishes, the snapshot
// just overpaints stale data on top of a fresher frame for
// ~1 paint cycle. Errors are silent so a missing snapshot path
// doesn't break the stream start.
this.pushSnapshot(v, cam, tEvent).catch(() => {});
// Fetch the panel's native dimensions from the firmware and
// cache them on the viewport's storage. Falls back to 800x480
// if /state is unreachable (e.g. mid-reboot). Panel dims never
// change for a given device so this only really needs to run
// once per discovery; refreshing on every wake costs ~5 ms and
// self-heals if the firmware is replaced.
const pw = parseInt(v.storage.getItem("panel_w") || "0", 10);
const ph = parseInt(v.storage.getItem("panel_h") || "0", 10);
let panelW = pw || 800;
let panelH = ph || 480;
try {
const st = await fetch(`http://${v.host}/state`, { signal: AbortSignal.timeout(1500) }).then(r => r.json());
if (st?.panel_width && st?.panel_height) {
panelW = Number(st.panel_width);
panelH = Number(st.panel_height);
v.storage.setItem("panel_w", String(panelW));
v.storage.setItem("panel_h", String(panelH));
}
} catch { /* keep cached values */ }
// Always send panel-native dimensions (panelW x panelH). For a
// portrait viewport we scale to the logical (panelH x panelW)
// target then transpose 90° CW so the buffer that arrives at the
// panel is already in the right rotation. The firmware never
// touches pixels — the hardware JPEG decoder writes BGR888
// straight into a DMA buffer that gets handed to the DSI engine.
// Filter order matters here. Earlier we did
// scale=480:800,transpose=1
// which intermittently produced a JPEG with a SOF marker
// reporting 480x800 — the firmware then rejected it with
// "expected 800x480, got 480x800". Rotating *first* and then
// scaling to an EXPLICIT panelWxpanelH (with setsar to clear
// any leftover aspect-ratio metadata) makes the final encoded
// dimensions deterministic regardless of source resolution.
const vf = this.buildVf(v.orientation, panelW, panelH);
const qv = String(v.jpegQuality);
// Diagnostic — confirms which filter chain the *currently loaded*
// script is actually using. If you don't see this line in the
// plugin log, the Scrypted Script editor is still on stale code
// and a re-paste/save didn't take. If you do see it but the
// firmware still rejects 480x800, the rotation didn't apply
// (very rare ffmpeg build issue) and we'd need to look at
// installed ffmpeg version.
// (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) →
// 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).
// Stream-source choice drives end-to-end latency more than
// anything else. remote-recorder hands us the high-bitrate
// main encoder with a large GOP and the camera's own ~10s
// prebuffer baked in — we'd watch the past, not the present.
// Walk substreams from lowest-latency → highest-latency and
// take the first one that resolves.
// Source substream selection. We always want the highest-fps
// highest-quality option that still has acceptable latency.
// The wire cost is unaffected — we re-encode to panel-native
// 800x480 mjpeg q:v 1 regardless of input resolution — so the
// only tradeoff is Scrypted-side ffmpeg CPU (irrelevant here).
//
// Order of preference:
// medium-resolution — usually the camera's main 1080p
// stream at 15-30 fps, low latency
// local — main stream for local clients
// remote — main stream for remote clients
// (camera default) — last-ditch fallback
//
// Explicitly NOT trying:
// low-resolution — preview substream, capped 5-8 fps
// remote-recorder — has ~10s prebuffer baked in (we'd watch
// the past, not the present)
let stream: any;
let pickedDest = "(default)";
for (const destination of ["medium-resolution", "local", "remote"]) {
try { stream = await cam.getVideoStream({ destination }); pickedDest = destination; break; }
catch { /* try next */ }
}
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;
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();
// ── DATA PLANE: raw TCP socket to firmware port 81 ────────────
// Replaces per-frame HTTP POSTs. One socket per stream session.
// Frame format on the wire (big-endian):
// [4 bytes jpeg_len][4 bytes seq][jpeg_len bytes JPEG body]
// We let TCP flow-control backpressure us naturally: if
// socket.write() returns false the kernel buffer is full —
// we drop incoming ffmpeg frames until 'drain' fires. No HTTP
// headers, no per-frame ACK round-trip, no Nagle/delayed-ACK
// dance, no httpd worker churn.
const net = require("net");
let sock: any = null;
let socketReady = false;
// Diagnostic only. Never gates writes; we keep pushing past
// kernel-buffer fullness because the firmware's FIONREAD skip
// (stream_server.c) drops superseded frames before decode.
let socketBackpressured = false;
let seq = 0;
let droppedFrames = 0;
let sentFrames = 0;
let bytesSent = 0;
let flushCount = 0; // socket destroy+reconnect count due to buffer cap
let lastLogUs = Date.now();
let workBuf: Buffer = Buffer.alloc(0);
// Latency probe — wall-clock from "ffmpeg emitted the JPEG"
// to "kernel accepted the socket.write". With TCP_NODELAY on
// the stream socket this is sub-millisecond steady state;
// double-digit ms numbers here mean kernel send buffer is
// full (= firmware can't ingest fast enough), which is fine
// — we don't gate on it and the firmware's FIONREAD skip
// drops the surplus before decode.
const writeLatencies: number[] = [];
const openStreamSocket = () => {
if (abort.signal.aborted) return;
socketReady = false;
socketBackpressured = false;
this.console.log(`stream "${v.name}": socket connect requested +${since()}ms`);
sock = net.createConnection({
host: v.host,
port: 81,
noDelay: true, // TCP_NODELAY on the outbound socket
});
sock.on("connect", () => {
socketReady = true;
this.console.log(`stream "${v.name}": socket connect open +${since()}ms`);
});
sock.on("drain", () => { socketBackpressured = false; });
sock.on("error", (e: Error) => {
this.console.warn(`stream "${v.name}" socket: ${e.message}`);
socketReady = false;
if (!abort.signal.aborted) setTimeout(openStreamSocket, 500);
});
sock.on("close", () => {
socketReady = false;
if (!abort.signal.aborted) setTimeout(openStreamSocket, 500);
});
};
openStreamSocket();
abort.signal.addEventListener("abort", () => {
try { sock?.destroy(); } catch {}
});
// Auto-restart accounting: cameras occasionally end their RTSP
// stream mid-event (network blip, source rotation, etc.) and
// ffmpeg exits clean. If the stream-timeout hasn't fired yet
// we respawn so the panel doesn't freeze on a stale frame.
// Capped at 5 restarts per 60s — past that we give up and
// wait for the next camera event.
let currentProc: any = null;
let restartCount = 0;
let restartWindow = Date.now();
const spawnFfmpeg = () => {
if (abort.signal.aborted) return;
workBuf = Buffer.alloc(0); // reset framer state on each respawn
const p = spawn(ffmpegPath, [
"-hide_banner", "-loglevel", "error",
// Latency tuning on the INPUT side: don't buffer, don't
// probe, decode straight through. probesize/analyzeduration
// at the minimum keeps ffmpeg from sitting on the first
// ~5s of source to learn the stream layout.
"-fflags", "+genpts+nobuffer+discardcorrupt",
"-flags", "low_delay",
"-avioflags", "direct",
"-probesize", "32",
"-analyzeduration", "0",
...(ffmpegInput.inputArguments || []),
"-an", "-sn",
"-vf", vf,
// -fps_mode drop: when the decoder is behind, throw the
// late frame on the floor instead of queueing it. Without
// this, ffmpeg's output queue fills up and the displayed
// image lags further and further behind reality.
"-fps_mode", "drop",
"-c:v", "mjpeg", "-q:v", qv,
"-f", "image2pipe", "-flush_packets", "1",
"pipe:1",
]);
currentProc = p;
let firstFfmpegFrameLogged = false;
let firstSocketWriteLogged = false;
p.stdout.on("data", (chunk: Buffer) => {
if (abort.signal.aborted) return;
workBuf = workBuf.length === 0 ? chunk : Buffer.concat([workBuf, chunk]);
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 (!firstFfmpegFrameLogged) {
this.console.log(`stream "${v.name}": first ffmpeg frame +${since()}ms (jpeg=${(frame.length / 1024).toFixed(0)}KB)`);
firstFfmpegFrameLogged = true;
}
// Drop only when the socket isn't connected yet
// (initial-open race) — once it's up we keep writing
// through normal backpressure and let the firmware's
// FIONREAD skip shed superseded frames before decode.
if (!socketReady) {
droppedFrames++;
continue;
}
// Runaway-buffer guard. ffmpeg outpaces firmware
// ingest by ~0.5 MB/s; Node's socket write queue
// is unbounded by default and would grow to MB of
// stale frames over a long stream. Each queued
// byte is a frame the firmware hasn't seen yet, so
// the buffer depth literally IS the steady-state
// glass-to-glass lag.
//
// When the queue exceeds the per-viewport cap,
// destroy the socket: the firmware's accept loop
// picks up the next reconnect within ~500ms and
// the pipeline restarts on a live frame. Trade a
// sub-second reconnect gap for clearing seconds
// of stale backlog.
const queued = sock.writableLength ?? 0;
if (queued > v.maxNodeBufMb * 1024 * 1024) {
flushCount++;
this.console.log(
`stream "${v.name}": buffer ${(queued / (1024 * 1024)).toFixed(1)}MB > ` +
`${v.maxNodeBufMb}MB cap — destroying socket to drop backlog (flush #${flushCount})`);
try { sock.destroy(); } catch {}
// close event triggers openStreamSocket reconnect.
droppedFrames++;
continue;
}
seq++;
// 16-byte v1 header. Magic "VPRT" (0x56505254) lets
// the firmware autodetect old-vs-new clients during
// the rollout window. event_us_low is stamped per
// frame at emit time so /state's g2g = age of the
// most recently painted frame (not time since wake).
const header = Buffer.alloc(16);
header.writeUInt32BE(0x56505254, 0); // "VPRT"
header.writeUInt32BE(frame.length, 4);
header.writeUInt32BE(seq, 8);
header.writeUInt32BE((Date.now() * 1000) >>> 0, 12);
const t0 = Date.now();
// Single combined write avoids splitting header
// and body across two TCP packets — the firmware
// sees them as one contiguous segment when possible.
const ok = sock.write(Buffer.concat([header, frame]));
if (!firstSocketWriteLogged) {
this.console.log(`stream "${v.name}": first socket.write +${since()}ms (jpeg=${(frame.length / 1024).toFixed(0)}KB)`);
firstSocketWriteLogged = true;
}
writeLatencies.push(Date.now() - t0);
if (writeLatencies.length > 200) writeLatencies.shift();
bytesSent += header.length + frame.length;
sentFrames++;
// Track but don't gate on backpressure — the metric
// is still useful as a "kernel buffer was full"
// indicator for diagnostics.
if (!ok) socketBackpressured = true;
else socketBackpressured = false;
}
});
p.stderr.on("data", (chunk: Buffer) => {
if (abort.signal.aborted) return;
const text = chunk.toString("utf8").trim();
if (!text) return;
if (text.includes("Immediate exit requested")) return;
this.console.warn(`ffmpeg "${v.name}": ${text}`);
});
p.on("error", (e: any) => {
if (!abort.signal.aborted) this.console.warn(`ffmpeg "${v.name}" spawn error:`, e.message);
});
p.on("close", (code: number) => {
if (abort.signal.aborted) return;
const now = Date.now();
if (now - restartWindow > 60_000) { // rolling 60s window
restartCount = 0;
restartWindow = now;
}
if (restartCount >= 5) {
this.console.warn(`ffmpeg "${v.name}" exited (code=${code}) and has restarted ≥5x in the last 60s — giving up; next camera event will retry`);
this.stopStream(v.name);
return;
}
restartCount++;
this.console.log(`ffmpeg "${v.name}" exited (code=${code}) — respawning (#${restartCount}/5 within window)`);
setTimeout(spawnFfmpeg, 250);
});
};
spawnFfmpeg();
abort.signal.addEventListener("abort", () => {
try { currentProc?.kill("SIGTERM"); } catch {}
});
// Unified stream-health log every 10s: Scrypted-side sent fps
// + firmware-side painted fps side-by-side, so the user can
// see at a glance "we sent N, the panel showed M." The gap
// (sent - painted) is what the firmware's FIONREAD skip
// dropped to keep the panel showing the freshest frame.
// Includes firmware-side per-stage timings (recv/dec/paint/
// idle min/avg/max) + glass-to-glass age of the most recent
// painted frame. /state poll is folded in so there's one log
// line per window instead of two interleaved timelines.
const streamLogger = setInterval(async () => {
const now = Date.now();
const window = (now - lastLogUs) / 1000;
if (window <= 0 || (sentFrames === 0 && droppedFrames === 0)) return;
const sentRate = sentFrames / window;
const dropRate = droppedFrames / window;
const mbPerSec = (bytesSent / window) / (1024 * 1024);
const sortedW = writeLatencies.slice().sort((a, b) => a - b);
const p50 = sortedW.length ? sortedW[Math.floor(sortedW.length * 0.5)] : 0;
const p95 = sortedW.length ? sortedW[Math.floor(sortedW.length * 0.95)] : 0;
const max = sortedW.length ? sortedW[sortedW.length - 1] : 0;
// Best-effort firmware-side snapshot. Timeout < 1s so a
// missed /state never wedges the logger.
let painted = "?", paintedMb = "?", g2g = "?", paintedNum = -1;
let recvStr = "?", decStr = "?", paintStr = "?", idleStr = "?";
try {
const st: any = await fetch(`http://${v.host}/state`, {
signal: AbortSignal.timeout(800),
}).then(r => r.json());
const fs = st?.stream;
if (fs?.frames && fs.window_us > 0) {
paintedNum = fs.frames / (fs.window_us / 1e6);
painted = paintedNum.toFixed(1);
paintedMb = ((fs.bytes / (fs.window_us / 1e6)) / (1024 * 1024)).toFixed(2);
recvStr = `${fs.recv_min_us}/${fs.recv_avg_us}/${fs.recv_max_us}`;
decStr = `${fs.dec_min_us}/${fs.dec_avg_us}/${fs.dec_max_us}`;
paintStr = `${fs.paint_min_us}/${fs.paint_avg_us}/${fs.paint_max_us}`;
idleStr = `${fs.idle_min_us}/${fs.idle_avg_us}/${fs.idle_max_us}`;
}
if (fs?.last_paint_event_us_low) {
const nowUsLow = (Date.now() * 1000) >>> 0;
const diff = (nowUsLow - fs.last_paint_event_us_low) >>> 0;
if (diff < 30_000_000) g2g = (diff / 1000).toFixed(0) + "ms";
}
} catch { /* keep the local stats; firmware-side just shows ? */ }
const skipped = paintedNum >= 0 ? Math.max(0, sentRate - paintedNum).toFixed(1) : "?";
// node_buf = bytes Scrypted has handed to socket.write but
// the kernel send buffer hasn't accepted yet — they sit in
// Node's internal queue, NOT on the wire. Divide by the
// current send rate to estimate how many seconds of
// already-emitted frames are waiting at the source. This
// is the load-bearing piece of the g2g "buffer depth"
// story: when this is large, the firmware can't possibly
// be showing the freshest bytes because we haven't even
// sent them yet.
const nodeBufBytes = sock?.writableLength ?? 0;
const sentBps = mbPerSec * 1024 * 1024;
const nodeBufMs = sentBps > 0 ? (nodeBufBytes / sentBps * 1000).toFixed(0) : "?";
this.console.log(
`stream "${v.name}": sent=${sentRate.toFixed(1)}fps painted=${painted}fps ` +
`(fw-skipped=${skipped}fps, drops=${droppedFrames}, flushes=${flushCount}) ` +
`${mbPerSec.toFixed(2)}MB/s sent / ${paintedMb}MB/s painted | ` +
`socket.write p50=${p50}ms p95=${p95}ms max=${max}ms backpressured=${socketBackpressured} ` +
`node_buf=${(nodeBufBytes / 1024).toFixed(0)}KB≈${nodeBufMs}ms/${v.maxNodeBufMb}MB cap | ` +
`recv=${recvStr}us dec=${decStr}us paint=${paintStr}us idle=${idleStr}us | g2g=${g2g}`);
droppedFrames = 0;
sentFrames = 0;
bytesSent = 0;
writeLatencies.length = 0;
lastLogUs = now;
}, 10_000);
abort.signal.addEventListener("abort", () => clearInterval(streamLogger));
const timeoutMs = v.idleTimeoutMs > 0 ? v.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
const timeout = setTimeout(() => {
this.console.log(`"${v.name}": Scrypted-side stream timeout — stopping`);
this.stopStream(v.name);
}, timeoutMs);
// The latest spawned ffmpeg child is held in the spawnFfmpeg
// closure (currentProc); the abort signal listener kills it on
// shutdown. We don't store the proc in the streams entry
// because it can change across auto-restarts.
this.streams.set(v.name, { timeout, abort });
}
stopStream(name: string, sendSleep = true) {
const s = this.streams.get(name);
if (!s) return;
s.abort.abort(); // aborts the in-flight ffmpeg child via its listener
if (s.interval) clearInterval(s.interval);
clearTimeout(s.timeout);
this.streams.delete(name);
const v = this.findByName(name);
if (sendSleep && v?.host) {
this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {});
}
}
// 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, tEvent: number = Date.now()) {
const since = () => Date.now() - tEvent;
this.console.log(`snapshot "${v.name}": start +${since()}ms`);
let mo: any;
try { mo = await cam.takePicture({ reason: "event" }); }
catch (e) { return; } // camera doesn't support snapshots
if (!mo) return;
const srcJpeg: Buffer = await mediaManager.convertMediaObjectToBuffer(mo, "image/jpeg");
if (!srcJpeg || srcJpeg.length < 4) return;
this.console.log(`snapshot "${v.name}": takePicture +${since()}ms`);
// 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 needsRotate = v.orientation === "portrait";
let transformed: Buffer = Buffer.alloc(0);
let path = "";
// Path 1: sharp. require()-fail caught at the boundary so a
// missing native module just falls through.
//
// Quality math: ffmpeg's mjpeg -q:v 1 corresponds to sharp JPEG
// quality ~99-100. At jpegQuality=1 emit 100; at 10 emit ~82;
// at 31 emit ~40. chromaSubsampling 4:4:4 at the top end (≤2)
// so colored edges don't smear — sharp's default 4:2:0 is
// half-rate chroma and was the dominant visible artifact at
// panel-native resolution.
//
// mozjpeg: false intentionally. mozjpeg gave us ~3-4× slower
// encode (sharp transform 1.6s vs 400ms) for a maybe-5% file
// size win that we can't perceive at 800x480. libjpeg-turbo
// default is the right call when first-paint latency matters.
if (!transformed.length) {
try {
const sharp = require("sharp");
let img = sharp(srcJpeg, { failOnError: false });
if (needsRotate) img = img.rotate(90);
const sharpQuality = Math.min(100, 102 - v.jpegQuality * 2);
const chroma = v.jpegQuality <= 2 ? "4:4:4" : "4:2:0";
transformed = await img
.resize(panelW, panelH, { fit: "fill", kernel: "lanczos3" })
.jpeg({ quality: sharpQuality, chromaSubsampling: chroma })
.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 = this.buildVf(v.orientation, panelW, panelH);
const { spawn } = require("child_process");
const ffmpegPath =
(mediaManager.getFFmpegPath ? await mediaManager.getFFmpegPath() : undefined) ||
"ffmpeg";
transformed = await new Promise((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;
this.console.log(`snapshot "${v.name}": transform +${since()}ms via ${path} (${(transformed.length / 1024).toFixed(0)}KB)`);
try {
this.console.log(`snapshot "${v.name}": post sent +${since()}ms`);
const res = await fetch(`http://${v.host}/frame`, {
method: "POST",
headers: { "Content-Type": "image/jpeg" },
body: transformed,
signal: AbortSignal.timeout(2000),
});
await res.text().catch(() => "");
// post_acked is the snapshot's true glass-to-glass — /frame
// returns after display_flip_back_buffer, so the firmware
// has the new pixels queued for the DPI scanout by then.
this.console.log(`snapshot "${v.name}": post acked +${since()}ms ← first user-visible paint`);
} catch { /* stream is starting anyway */ }
}
// ffmpeg -vf filter chain producing panel-native 800x480 BGR888.
// Used by both startStream (live) and pushSnapshot (one-shot
// ffmpeg fallback). Rotation goes FIRST so the final mjpeg encoder
// sees the exact target dimensions — earlier we observed mjpeg
// writing pre-rotation dims into the JPEG SOF marker when scale
// came first, breaking the firmware's strict dim check.
private buildVf(orientation: string, panelW: number, panelH: number): string {
return orientation === "portrait"
? `transpose=1,scale=${panelW}:${panelH}:flags=lanczos,setsar=1`
: `scale=${panelW}:${panelH}:flags=lanczos,setsar=1`;
}
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
// ------------------------------------------------------------------------
async onRequest(request: HttpRequest, response: HttpResponse) {
if (request.method !== "POST") { response.send("", { code: 405 }); return; }
if (!request.url.endsWith("/state")) { response.send("", { code: 404 }); return; }
let body: any;
try { body = JSON.parse(request.body); }
catch { response.send("invalid JSON", { code: 400 }); return; }
const { viewport, state } = body ?? {};
const v = typeof viewport === "string" ? this.findByName(viewport) : undefined;
if (!v) { response.send(`unknown viewport: ${viewport}`, { code: 404 }); return; }
if (state !== "wake" && state !== "sleep") {
response.send("state must be wake or sleep", { code: 400 });
return;
}
this.console.log(`recv "${viewport}" -> ${state} (device-initiated)`);
if (state === "wake") {
await this.startStream(v);
} else {
this.stopStream(v.name, /*sendSleep=*/ false);
}
response.send("", { code: 204 });
}
// ------------------------------------------------------------------------
// Parent Settings — informational only; per-viewport tuning lives on each
// child's own Settings page.
// ------------------------------------------------------------------------
async getSettings(): Promise {
const count = this.viewports.size;
return [
{
key: "viewport_count",
title: "Registered viewports",
description: "Number of child viewport bindings under this parent. Each one's host / camera / brightness / orientation / fps lives on its own Settings page.",
value: String(count),
readonly: true,
} as any,
{
key: "callback_base",
title: "Callback base URL",
description: "Endpoint the firmware POSTs back to for tap-initiated wake/sleep.",
value: this.scryptedBase || "(not yet resolved)",
readonly: true,
} as any,
];
}
async putSetting(_key: string, _value: SettingValue) {}
// ------------------------------------------------------------------------
// Tiny HTTP helper
// ------------------------------------------------------------------------
private async postJSON(url: string, body: any) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
if (!res.ok && res.status !== 204) {
throw new Error(`POST ${url} -> ${res.status}`);
}
}
}
export default ScryptedViewportProvider;