src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-15 09:29:52 -0500
committerLuke Hoersten <[email protected]>2026-06-15 09:29:52 -0500
commit28a6335576cfec144dc3860bc47f7f6d87274651 (patch)
tree3c220a6b70a1ace878f017cd3af7ba51488a0018
parentbba40948edccd4ac6c4a661c8f16c6469b212985 (diff)
pipeline two /frame POSTs + HTTP keep-alive on the Scrypted side
Decouples network upload from firmware decode-and-paint. Before, the inFlight boolean guard meant Scrypted sent frame N, waited for the full ~80ms response (~40ms body upload + ~20ms decode + ~50µs paint + ack), THEN sent frame N+1. The next ffmpeg frame arriving during that window got dropped. After: - ScryptedViewportProvider keeps a per-host undici Agent with keepAliveTimeout 30s, connections:2, pipelining:0. Sockets stay open across /frame, /state, /config — saves the SYN+SYN-ACK+ACK round-trip every POST (small on LAN but real on Wi-Fi). - Stream loop's inFlight boolean is now a counter capped at 2 so frame N+1 can begin uploading on socket B while frame N is still being decoded on the device via socket A. Roughly doubles effective throughput when body upload time dominates. Firmware side: - esp_http_server max_open_sockets bumped 7→4 explicitly: 2 for pipelined /frame + 2 spare for concurrent /state and /config slots. The JPEG decoder mutex still serialises decode (only one /frame can be decoding at a time); this change only unblocks the network half of the pipeline.
-rw-r--r--main/http_api.c6
-rw-r--r--scrypted/scrypted-viewport.ts52
2 files changed, 52 insertions, 6 deletions
diff --git a/main/http_api.c b/main/http_api.c
index 28ceeeb..4e03868 100644
--- a/main/http_api.c
+++ b/main/http_api.c
@@ -486,6 +486,12 @@ esp_err_t http_api_start(void)
cfg.max_uri_handlers = 8;
cfg.lru_purge_enable = true;
cfg.stack_size = 8192; // POST /config alone has ~2.4 KiB of stack locals
+ // Allow Scrypted to keep two POST /frame in flight at once so the
+ // body upload of frame N+1 overlaps with the JPEG decode of frame
+ // N. The decoder mutex still serialises decode itself; this only
+ // unblocks the network half of the pipeline. +1 socket reserve for
+ // a concurrent /state or /config request landing during a stream.
+ cfg.max_open_sockets = 4;
httpd_handle_t server = NULL;
ESP_RETURN_ON_ERROR(httpd_start(&server, &cfg), TAG, "httpd_start");
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index 66f89dc..ae99e09 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -291,6 +291,30 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}>();
private scryptedBase = "";
+ // Per-host undici Agent so /frame POSTs reuse the TCP connection
+ // and pipeline two-in-flight. Reusing the socket saves the SYN +
+ // SYN-ACK + ACK round-trip (~1ms on LAN, more painful on Wi-Fi),
+ // and the connections:2 cap lets us overlap upload of frame N+1
+ // with the firmware's decode-and-paint of frame N. pipelining:0
+ // because esp_http_server doesn't support HTTP/1.1 request
+ // pipelining on a single connection — we want two independent
+ // sockets, not two requests stacked on one.
+ private dispatchers = new Map<string, any>();
+ private dispatcherFor(host: string): any {
+ let d = this.dispatchers.get(host);
+ if (!d) {
+ const { Agent } = require("undici");
+ d = new Agent({
+ keepAliveTimeout: 30_000,
+ keepAliveMaxTimeout: 60_000,
+ connections: 2,
+ pipelining: 0,
+ });
+ this.dispatchers.set(host, d);
+ }
+ return d;
+ }
+
constructor(nativeId?: string) {
super(nativeId);
this.start().catch(e => this.console.error("start failed", e));
@@ -683,7 +707,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// Single-flight: only one POST /frame in flight at a time. The
// firmware's JPEG decoder mutex returns 503 otherwise; we just
// drop the spare frames silently — natural rate-limiter.
- let inFlight = false;
+ // Allow up to MAX_INFLIGHT concurrent /frame POSTs so that the
+ // network upload of frame N+1 overlaps with the firmware's
+ // decode+paint of frame N. The firmware's JPEG decoder mutex
+ // serialises the decode itself; this only buys us the upload
+ // overlap (≈ half of total /frame wall time). Keep this in
+ // sync with cfg.max_open_sockets in main/http_api.c — that
+ // sits at 4 (2 for streaming + 2 spare for /state, /config).
+ const MAX_INFLIGHT = 2;
+ let inFlight = 0;
let droppedFrames = 0;
let sentFrames = 0;
let lastLogUs = Date.now();
@@ -737,8 +769,8 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
workBuf = workBuf.subarray(eoi + 2);
if (frame.length < 4 || frame[0] !== 0xff || frame[1] !== 0xd8) continue;
- if (inFlight) { droppedFrames++; continue; }
- inFlight = true;
+ if (inFlight >= MAX_INFLIGHT) { droppedFrames++; continue; }
+ inFlight++;
sentFrames++;
this.pushStreamFrame(v, frame, abort)
.catch(e => {
@@ -747,7 +779,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
const cause = (err as any)?.cause?.code || "";
this.console.warn(`pushStreamFrame "${v.name}":`, err.message, cause ? `(${cause})` : "");
})
- .finally(() => { inFlight = false; });
+ .finally(() => { inFlight--; });
}
});
@@ -858,7 +890,12 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
headers: { "Content-Type": "image/jpeg" },
body: jpeg,
signal: abort.signal,
- });
+ // @ts-ignore - undici dispatcher: keep-alive + 2 connections
+ // per host. Lets frame N+1 begin uploading on socket B while
+ // frame N is still being decoded/painted on the device via
+ // socket A.
+ dispatcher: this.dispatcherFor(v.host),
+ } as any);
const wallMs = Date.now() - t0;
const n = (this.fetchCount.get(v.nativeId!) || 0) + 1;
this.fetchCount.set(v.nativeId!, n);
@@ -949,12 +986,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// ------------------------------------------------------------------------
private async postJSON(url: string, body: any) {
+ const host = new URL(url).host.split(":")[0];
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
- });
+ // @ts-ignore - undici dispatcher
+ dispatcher: this.dispatcherFor(host),
+ } as any);
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => "");
throw new Error(`POST ${url} -> ${res.status} ${text}`);