diff options
| author | Luke Hoersten <[email protected]> | 2026-06-20 10:02:38 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-20 10:02:38 -0500 |
| commit | 6d74d02acf3a544617fce1291ad22a87624aceee (patch) | |
| tree | a98ae4e0448c0713c3a6a5b7d2f06c735f8e3ec9 | |
| parent | 72639d47771dbcf3f83360536431beff7d7e2243 (diff) | |
faster snapshot + deeper firmware timing + camera substream UI control
Three independent improvements landing together because they all
target the post-streaming-pivot "where do we spend the wall clock?"
question.
#1 — Snapshot: sharp → mediaManager native → ffmpeg cascade
pushSnapshot now tries three transforms in order of cost:
- sharp (~5-15ms, libvips bindings, handles resize + rotate)
- mediaManager.convertMediaObjectToBuffer with image/jpeg;width=W
;height=H mime hint (~10-30ms, Scrypted's native converter,
used only for landscape since rotation isn't standard)
- ffmpeg one-shot (~500-700ms cold start, the old slow path)
A `path=...` field in the snapshot log identifies which transform
actually ran so the user can confirm the fast paths are reachable
in their Scrypted runtime. takePicture, transform, and POST timings
are each broken out so we can see exactly where the snapshot wall
goes.
#2 — Firmware: windowed min/avg/max breakdown + idle-gap
Stream server replaces the per-frame single-sample log with a 30-
frame window summary:
N frames over Xs: Yfps Z MB/s avg-jpeg=KB |
lock min/avg/max | recv min/avg/max | dec min/avg/max |
paint min/avg/max | idle min/avg/max
- lock = mutex acquire time (sanity check; should be ~0us with one
client owning the decoder)
- recv = body bytes off the wire
- dec = HW JPEG decode
- paint = backbuffer flip + DMA queue
- idle = gap between previous paint completing and next header
landing (= upstream slack). Large idle means we're waiting on
ffmpeg/network; near-zero means we're the bottleneck.
#3 — Camera substream picker
"Stream-source choice drives end-to-end latency more than anything
else" — the existing hardcoded low-latency-first walk lands on the
camera's preview substream which is typically capped at 5-8 fps.
Adds a per-viewport setting under Display:
Camera substream: auto | low-resolution | medium-resolution
| local | remote | remote-recorder
auto keeps the current behavior; the pinned options let the user
force a higher-fps source when they want stream rate > preview
rate. The chosen destination is logged at stream start.
| -rw-r--r-- | main/stream_server.c | 81 | ||||
| -rw-r--r-- | scrypted/scrypted-viewport.ts | 149 |
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 */ } } |
