diff options
| author | Luke Hoersten <[email protected]> | 2026-06-20 11:46:33 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-20 11:46:33 -0500 |
| commit | 568de1068e7fc3b4faefac6d218d7919337b212c (patch) | |
| tree | a4d6722cc5b61357646a9b6859543653cdec7914 | |
| parent | 6e0e0270e2cb9c54b1b7e63d9e67d8ac16792cef (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.ts | 38 |
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 */ } |
