From 0ff5d03b760d726f6151cd5f3f23ef1a1039d319 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Mon, 15 Jun 2026 09:32:57 -0500 Subject: add X-Frame-Seq monotonic ordering for pipelined /frame POSTs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With two /frame POSTs in flight on separate sockets, the firmware's JPEG decoder mutex serialises decode but FreeRTOS semaphore acquisition is not FIFO — under jitter the later-arriving older frame can grab the lock first and paint over the newer one, producing brief "panel travels backward in time" glitches. Scrypted side: - Per-viewport monotonic frameSeq counter, sent as X-Frame-Seq on every /frame POST. Reset on stopStream so each new stream starts at 1. Firmware side: - s_last_painted_seq tracks the highest seq we've painted. Frame arrives, mutex acquired, body received — if seq <= s_last_painted_seq the frame is dropped (200 OK + X-Frame-Drop: stale-seq header) and the mutex released without touching the back buffer. - s_last_painted_seq resets to 0 on POST /state {wake} so the next stream's seq=1 isn't rejected because the previous session reached a higher counter. - Missing/zero X-Frame-Seq (legacy clients) skips the check entirely — preserves pre-pipelining behaviour. --- scrypted/scrypted-viewport.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) (limited to 'scrypted') diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index ae99e09..e91a510 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -869,11 +869,10 @@ class ScryptedViewportProvider extends ScryptedDeviceBase if (s.interval) clearInterval(s.interval); clearTimeout(s.timeout); this.streams.delete(name); - if (sendSleep) { - const v = this.findByName(name); - if (v?.host) { - this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {}); - } + const v = this.findByName(name); + if (v) this.frameSeq.delete(v.nativeId!); + if (sendSleep && v?.host) { + this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {}); } } @@ -882,12 +881,21 @@ class ScryptedViewportProvider extends ScryptedDeviceBase // 10 fetches alongside the wall-clock duration of the fetch itself. private fetchCount = new Map(); + // Monotonic per-viewport sequence number paired with X-Frame-Seq + // so the firmware can drop pipelined-out-of-order frames. Reset + // on every stopStream so each stream session starts fresh; the + // firmware also resets its comparator on /state {wake}, so the + // two stay in step. + private frameSeq = new Map(); + private async pushStreamFrame(v: Viewport, jpeg: Buffer, abort: AbortController) { if (abort.signal.aborted) return; + const seq = (this.frameSeq.get(v.nativeId!) || 0) + 1; + this.frameSeq.set(v.nativeId!, seq); const t0 = Date.now(); const res = await fetch(`http://${v.host}/frame`, { method: "POST", - headers: { "Content-Type": "image/jpeg" }, + headers: { "Content-Type": "image/jpeg", "X-Frame-Seq": String(seq) }, body: jpeg, signal: abort.signal, // @ts-ignore - undici dispatcher: keep-alive + 2 connections -- cgit v1.2.3