src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-20 11:46:33 -0500
committerLuke Hoersten <[email protected]>2026-06-20 11:46:33 -0500
commit568de1068e7fc3b4faefac6d218d7919337b212c (patch)
treea4d6722cc5b61357646a9b6859543653cdec7914
parent6e0e0270e2cb9c54b1b7e63d9e67d8ac16792cef (diff)
g2g semantics now per-frame, drop sharp mozjpeg for ~4x faster snapshot
Two follow-up fixes informed by the first-round field data: #1 — g2g measures display age, not time-since-wake Previously eventUsLow was stamped once at wake event and reused for the whole stream session. /state's last_paint_event_us_low echoed back the wake-time anchor, so g2g = "elapsed time since user wake." That metric is true but not actually informative — it just grows linearly with stream duration. What's actually useful for the "is the panel showing current reality" question is the AGE of the currently-displayed frame. Stamp event_us_low per emitted frame at Date.now()*1000 (low 32 bits) inside the stream demux loop. Now last_paint_event_us_low reflects the most recent painted frame's emit time, and g2g = (script_now_us_low - last_paint_event_us_low) = display staleness in ms. #2 — sharp first-paint dropped from ~1800ms to ~800ms Previous commit (phase 5) turned on sharp's mozjpeg encoder for slightly tighter file size at the same JPEG quality. Field measurement showed it costs ~1.5s of CPU per snapshot vs libjpeg-turbo's ~400ms — a ~4× regression on the metric we care most about (event → first-paint). The file-size win is ~5%; on 800x480 panel output that's not perceptible. Drop mozjpeg: true. Quality settings (quality=100, chroma 4:4:4 at jpegQuality ≤ 2) stay the same, so visual output is unchanged. First-paint comes back down to where it should be.
-rw-r--r--scrypted/scrypted-viewport.ts38
1 files changed, 20 insertions, 18 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index c82425a..7310851 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 = "e4a546c";
+const SCRIPT_VERSION = "pending";
//
// Architecture
// ------------
@@ -627,11 +627,10 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
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: low 32 bits of the Scrypted-host monotonic µs
- // at camera-event arrival. The firmware stamps this on the
- // most recently painted frame and echoes it back via /state,
- // letting us compute glass-to-glass.
- const eventUsLow = (tEvent * 1000) >>> 0;
+ // (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.
@@ -891,12 +890,14 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
seq++;
// 16-byte v1 header. Magic "VPRT" (0x56505254) lets
// the firmware autodetect old-vs-new clients during
- // the rollout window.
+ // 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(eventUsLow, 12);
+ 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
@@ -1078,15 +1079,16 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// missing native module just falls through.
//
// Quality math: ffmpeg's mjpeg -q:v 1 corresponds to sharp JPEG
- // quality ~99-100 (their scales aren't 1:1 but the practical
- // result matches). Our previous formula maxed out at 97 — that
- // was the visible delta vs the stream. New formula: at
- // jpegQuality=1 emit 100; at 10 emit ~82; at 31 emit ~40.
- // Also force chromaSubsampling 4:4:4 at the top end so colored
- // edges (text, UI overlays) don't smear — sharp's default 4:2:0
- // is half-rate chroma and is the dominant visible artifact at
- // panel-native resolution. mozjpeg encoder for tighter files at
- // the same visual quality.
+ // 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");
@@ -1096,7 +1098,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
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, mozjpeg: true })
+ .jpeg({ quality: sharpQuality, chromaSubsampling: chroma })
.toBuffer();
path = "sharp";
} catch { /* fall through */ }