From 5d9a26f19aea95337e134226da64eeef138d3ebf Mon Sep 17 00:00:00 2001 From: John Chia Date: Mon, 3 Aug 2026 15:35:43 -0700 Subject: [PATCH 1/2] fix(go2rtc): cut snapshot cache from 30s to 1s The snapshot URL requested cache=30s, which makes go2rtc hand back byte-identical JPEG data for up to 30 seconds. go2rtc's cache is an unconditional TTL cache checked before the stream is touched at all (internal/mjpeg/mjpeg.go): on a hit it writes the stored payload and returns, so a healthy, actively producing stream is served a stale frame exactly as a stalled one is. The comment here claimed the cache only came into play when the producer was unavailable, which is not what go2rtc implements. API detection calls detect_objects_api_snapshot() ahead of any frame decode, so every detection ran against that cached frame. A stream at detection_interval=1 therefore re-ran detection once a second on one frozen image, and an object entering the scene stayed invisible until the cache expired - up to 30s later. Observed on a live 1 Hz stream as 28 consecutive person detections carrying a single distinct confidence value and a single distinct bounding box. Measured against a running instance: six requests with cache=30s interleaved with six without, over the same 12s window, while the stream was demonstrably healthy. The cached requests returned 1 distinct frame (~0.001s each), the uncached ones 6 distinct frames (~1s each). 1s preserves the reconnect tolerance the parameter was added for while matching the shortest interval lightNVR can express, since both detection gates compare whole seconds. --- src/video/go2rtc/go2rtc_snapshot.c | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/video/go2rtc/go2rtc_snapshot.c b/src/video/go2rtc/go2rtc_snapshot.c index f60f2e217..4e8db4b79 100644 --- a/src/video/go2rtc/go2rtc_snapshot.c +++ b/src/video/go2rtc/go2rtc_snapshot.c @@ -132,10 +132,17 @@ bool go2rtc_get_snapshot(const char *stream_name, unsigned char **jpeg_data, siz // Format the URL for the go2rtc snapshot API // go2rtc runs on port 1984 and provides snapshots at: /api/frame.jpeg?src={stream_name} - // We add cache=30s to allow go2rtc to return a cached frame if the stream is temporarily - // unavailable (e.g., video doorbell in sleep mode). This prevents timeouts when the - // producer is reconnecting, as long as a frame was captured within the last 30 seconds. - snprintf(url, sizeof(url), "http://localhost:1984" GO2RTC_BASE_PATH "/api/frame.jpeg?src=%s&cache=30s", encoded_name); + // cache= lets go2rtc hand back a recently captured frame rather than waiting on the + // producer, so a temporarily unavailable stream (e.g. a video doorbell in sleep mode) + // does not stall the caller while it reconnects. + // + // It must stay at or below the detection interval. Within the cache window go2rtc + // returns byte-identical data, so a window of N seconds makes every detection in it + // run on one frozen frame and delays a newly arrived object by up to N seconds. This + // was 30s, which silently defeated any detection_interval shorter than 30s. 1s matches + // the shortest interval lightNVR can express (both detection gates compare whole + // seconds), so it preserves the reconnect tolerance without freezing detection. + snprintf(url, sizeof(url), "http://localhost:1984" GO2RTC_BASE_PATH "/api/frame.jpeg?src=%s&cache=1s", encoded_name); log_debug("Fetching snapshot from go2rtc: %s", url); From 41561ccd4b7cb178e711bbe8bbf1e22551c41dd1 Mon Sep 17 00:00:00 2001 From: John Chia Date: Mon, 3 Aug 2026 16:04:46 -0700 Subject: [PATCH 2/2] docs(go2rtc): correct the cache= comment and record the keyframe-wait cost The rewritten comment still opened with the original framing -- that cache= exists so an unavailable producer does not stall the caller. That is the behaviour the old comment claimed, not the behaviour go2rtc implements: the TTL is checked unconditionally, ahead of any stream access, so stream health never enters into it. Also document the cost this shifts onto the caller. On a cache miss go2rtc waits for the next keyframe, so the fetch takes up to one GOP (~1s measured on a 1s GOP) against ~1ms for a hit, and that wait happens inline on the UDT main loop between av_read_frame() calls. --- src/video/go2rtc/go2rtc_snapshot.c | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/video/go2rtc/go2rtc_snapshot.c b/src/video/go2rtc/go2rtc_snapshot.c index 4e8db4b79..a069b8966 100644 --- a/src/video/go2rtc/go2rtc_snapshot.c +++ b/src/video/go2rtc/go2rtc_snapshot.c @@ -132,16 +132,21 @@ bool go2rtc_get_snapshot(const char *stream_name, unsigned char **jpeg_data, siz // Format the URL for the go2rtc snapshot API // go2rtc runs on port 1984 and provides snapshots at: /api/frame.jpeg?src={stream_name} - // cache= lets go2rtc hand back a recently captured frame rather than waiting on the - // producer, so a temporarily unavailable stream (e.g. a video doorbell in sleep mode) - // does not stall the caller while it reconnects. + // cache=N is an unconditional TTL cache, not a fallback for an unavailable producer. + // go2rtc checks it before touching the stream at all and, on a hit, writes the stored + // payload and returns (see handlerKeyframe in internal/mjpeg/mjpeg.go), so a healthy + // stream is served the same stale bytes as a stalled one. A window of N seconds makes + // every detection inside it run on one frozen frame and hides a newly arrived object + // for up to N seconds. This was 30s, which silently defeated any detection_interval + // shorter than 30s. // - // It must stay at or below the detection interval. Within the cache window go2rtc - // returns byte-identical data, so a window of N seconds makes every detection in it - // run on one frozen frame and delays a newly arrived object by up to N seconds. This - // was 30s, which silently defeated any detection_interval shorter than 30s. 1s matches - // the shortest interval lightNVR can express (both detection gates compare whole - // seconds), so it preserves the reconnect tolerance without freezing detection. + // On a miss go2rtc waits for the stream's *next* keyframe, so the fetch costs up to + // one GOP (measured ~1s on a 1s GOP; a cache hit is ~1ms). That cost lands on this + // thread: the UDT main loop calls run_detection_on_frame() between av_read_frame() + // calls, so a slow fetch stalls packet reading for the stream. A larger value trades + // detection freshness for less stalling. 1s bounds staleness at roughly one detection + // interval, which is as fine as lightNVR can express since both detection gates + // compare whole seconds. snprintf(url, sizeof(url), "http://localhost:1984" GO2RTC_BASE_PATH "/api/frame.jpeg?src=%s&cache=1s", encoded_name); log_debug("Fetching snapshot from go2rtc: %s", url);