src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--main/stream_server.c81
-rw-r--r--scrypted/scrypted-viewport.ts149
2 files changed, 175 insertions, 55 deletions
diff --git a/main/stream_server.c b/main/stream_server.c
index cebe3cb..7876968 100644
--- a/main/stream_server.c
+++ b/main/stream_server.c
@@ -67,6 +67,15 @@ static void handle_client(int fd, const char *peer)
uint64_t frames_decoded = 0;
int64_t t_window_start = esp_timer_get_time();
uint64_t bytes_in_window = 0;
+ int64_t t_prev_paint_done = 0; // for idle-gap measurement
+
+ // Per-window min/max accumulators (microseconds).
+ int64_t recv_min = INT64_MAX, recv_max = 0, recv_sum = 0;
+ int64_t dec_min = INT64_MAX, dec_max = 0, dec_sum = 0;
+ int64_t pnt_min = INT64_MAX, pnt_max = 0, pnt_sum = 0;
+ int64_t idle_min = INT64_MAX, idle_max = 0, idle_sum = 0;
+ int64_t lock_min = INT64_MAX, lock_max = 0, lock_sum = 0;
+ uint64_t window_samples = 0;
while (1) {
uint8_t hdr[HEADER_BYTES];
@@ -86,12 +95,18 @@ static void handle_client(int fd, const char *peer)
}
int64_t t_entry = esp_timer_get_time();
+ // Idle gap: time between the previous paint completing and
+ // this header landing. Large idle = ffmpeg/network is the
+ // bottleneck; near-zero idle = we're the bottleneck, source
+ // is queued up waiting.
+ int64_t idle_us = t_prev_paint_done ? (t_entry - t_prev_paint_done) : 0;
if (!jpeg_decoder_try_lock(2000)) {
ESP_LOGW(TAG, "decoder busy 2s — skipping seq %u", (unsigned)seq);
if (drain_n(fd, jpeg_len) != ESP_OK) return;
continue;
}
+ int64_t t_lock_done = esp_timer_get_time();
uint8_t *in = jpeg_decoder_input_buffer();
if (read_n(fd, in, jpeg_len) != ESP_OK) {
@@ -154,27 +169,65 @@ static void handle_client(int fd, const char *peer)
state_machine_frame_painted();
frames_decoded++;
- // Every 30 frames log a per-stage breakdown + window throughput
- // — same shape as the old /frame log so the serial output stays
- // useful for debugging without the script side.
- if (frames_decoded % 30 == 0) {
+ // Stage timings for this frame, in microseconds.
+ int64_t lock_us = t_lock_done - t_entry;
+ int64_t recv_us = t_recv - t_lock_done;
+ int64_t dec_us = t_decode - t_recv;
+ int64_t pnt_us = t_paint - t_decode;
+
+ if (lock_us < lock_min) lock_min = lock_us;
+ if (lock_us > lock_max) lock_max = lock_us;
+ lock_sum += lock_us;
+ if (recv_us < recv_min) recv_min = recv_us;
+ if (recv_us > recv_max) recv_max = recv_us;
+ recv_sum += recv_us;
+ if (dec_us < dec_min) dec_min = dec_us;
+ if (dec_us > dec_max) dec_max = dec_us;
+ dec_sum += dec_us;
+ if (pnt_us < pnt_min) pnt_min = pnt_us;
+ if (pnt_us > pnt_max) pnt_max = pnt_us;
+ pnt_sum += pnt_us;
+ if (idle_us > 0) {
+ if (idle_us < idle_min) idle_min = idle_us;
+ if (idle_us > idle_max) idle_max = idle_us;
+ idle_sum += idle_us;
+ }
+ window_samples++;
+ t_prev_paint_done = t_paint;
+
+ // Every 30 frames log a windowed min/avg/max breakdown +
+ // sustained throughput. Idle = gap between previous paint
+ // and next header arriving (= upstream slack). lock = mutex
+ // acquire (should be ~0 with single client). recv = body
+ // bytes off the wire. dec = HW JPEG. paint = backbuffer flip.
+ if (frames_decoded % 30 == 0 && window_samples > 0) {
int64_t now = esp_timer_get_time();
double win_s = (now - t_window_start) / 1.0e6;
double mb_per_s = (win_s > 0)
? ((double)bytes_in_window / win_s) / (1024.0 * 1024.0) : 0.0;
+ double fps = (win_s > 0) ? (double)window_samples / win_s : 0.0;
ESP_LOGI(TAG,
- "frame %llu seq=%u: recv=%lldus dec=%lldus paint=%lldus "
- "total=%lldus (jpeg=%uKB) window=%llumiB/%.1fs (%.2fMB/s)",
- (unsigned long long)frames_decoded, (unsigned)seq,
- (long long)(t_recv - t_entry),
- (long long)(t_decode - t_recv),
- (long long)(t_paint - t_decode),
- (long long)(t_paint - t_entry),
- (unsigned)(jpeg_len / 1024),
- (unsigned long long)(bytes_in_window / (1024 * 1024)),
- win_s, mb_per_s);
+ "%llu frames over %.1fs: %.1ffps %.2fMB/s avg-jpeg=%uKB | "
+ "lock min/avg/max=%lld/%lld/%lldus | "
+ "recv min/avg/max=%lld/%lld/%lldus | "
+ "dec min/avg/max=%lld/%lld/%lldus | "
+ "paint min/avg/max=%lld/%lld/%lldus | "
+ "idle min/avg/max=%lld/%lld/%lldus",
+ (unsigned long long)window_samples, win_s, fps, mb_per_s,
+ (unsigned)((bytes_in_window / window_samples) / 1024),
+ (long long)lock_min, (long long)(lock_sum / window_samples), (long long)lock_max,
+ (long long)recv_min, (long long)(recv_sum / window_samples), (long long)recv_max,
+ (long long)dec_min, (long long)(dec_sum / window_samples), (long long)dec_max,
+ (long long)pnt_min, (long long)(pnt_sum / window_samples), (long long)pnt_max,
+ (long long)(idle_min == INT64_MAX ? 0 : idle_min),
+ (long long)(idle_sum / window_samples),
+ (long long)idle_max);
t_window_start = now;
bytes_in_window = 0;
+ recv_min = dec_min = pnt_min = idle_min = lock_min = INT64_MAX;
+ recv_max = dec_max = pnt_max = idle_max = lock_max = 0;
+ recv_sum = dec_sum = pnt_sum = idle_sum = lock_sum = 0;
+ window_samples = 0;
}
}
}
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 */ }
}