From 39aeeff0bcdfaa5ac4520e1fc7b90266c761c3f5 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Tue, 30 Jun 2026 20:35:45 -0500 Subject: scrypted: gate snapshot POST on the live stream not yet painting Now that the prebuffered stream paints in ~0.7s, the parallel snapshot usually loses the race and, if slow (a Unifi takePicture cache-miss can be several seconds), would land after live video and overpaint it with a staler still. pushSnapshot takes a shouldPaint predicate; startStream passes () => !firstStreamFrameSeen (flipped by the ffmpeg first-frame handler), so a snapshot that finishes after the stream is already painting is dropped before the POST. Preserves the snapshot as a gap-filler for the slow paths (live-edge fallback / cold prebuffer) where the stream hasn't painted yet. --- scrypted/scrypted-viewport.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index 1115db0..381580b 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -1007,11 +1007,13 @@ class ScryptedViewportProvider extends ScryptedDeviceBase // image and resolves in 50–300ms, vs. the ~5s before the first // ffmpeg-emitted frame lands. The snapshot fills the gap so the panel // shows the camera near-instantly on tap/event. Fire-and-forget — - // runs in parallel with stream socket bring-up. If the stream's first - // frame arrives before the snapshot finishes, the snapshot just - // overpaints stale data on top of a fresher frame for ~1 paint cycle. + // runs in parallel with stream socket bring-up. Flipped true by the + // ffmpeg first-frame handler below; the snapshot's final POST is gated + // on !firstStreamFrameSeen so a slow snapshot can't overpaint live + // video with a staler still once the stream is already painting. // Errors are silent so a missing snapshot path doesn't break start. - this.pushSnapshot(v, cam, tEvent).catch(() => {}); + let firstStreamFrameSeen = false; + this.pushSnapshot(v, cam, tEvent, () => !firstStreamFrameSeen).catch(() => {}); // Fetch the panel's native dimensions from the firmware and // cache them on the viewport's storage. Falls back to 800x480 @@ -1368,6 +1370,9 @@ class ScryptedViewportProvider extends ScryptedDeviceBase if (!firstFfmpegFrameLogged) { this.console.log(`stream "${v.name}": first ffmpeg frame +${since()}ms (jpeg=${(frame.length / 1024).toFixed(0)}KB)`); firstFfmpegFrameLogged = true; + // Live video is now painting — suppress a late snapshot + // POST so it can't overpaint fresh frames with a still. + firstStreamFrameSeen = true; } // Drop only when the socket isn't connected yet @@ -1587,7 +1592,13 @@ class ScryptedViewportProvider extends ScryptedDeviceBase // 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, tEvent: number = Date.now()) { + // shouldPaint gates the final POST: the snapshot races the live stream + // and now usually LOSES (prebuffered video paints in ~0.7s). If the stream + // already produced a frame by the time our snapshot is ready, posting it + // would overpaint fresh video with a staler still — so we skip. The + // snapshot stays valuable only as a gap-filler when the stream is slow + // (live-edge fallback / cold prebuffer), where shouldPaint() is still true. + private async pushSnapshot(v: Viewport, cam: any, tEvent: number = Date.now(), shouldPaint: () => boolean = () => true) { const since = () => Date.now() - tEvent; this.console.log(`snapshot "${v.name}": start +${since()}ms`); let mo: any; @@ -1680,6 +1691,13 @@ class ScryptedViewportProvider extends ScryptedDeviceBase if (transformed.length < 4) return; this.console.log(`snapshot "${v.name}": transform +${since()}ms via ${path} (${(transformed.length / 1024).toFixed(0)}KB)`); + // Last check before painting: if the live stream already produced a + // frame, don't overpaint it with this (now staler) snapshot. + if (!shouldPaint()) { + this.console.log(`snapshot "${v.name}": skipped post +${since()}ms — live stream already painting`); + return; + } + try { this.console.log(`snapshot "${v.name}": post sent +${since()}ms`); const res = await fetch(`http://${v.host}/frame`, { -- cgit v1.2.3