Improve History's seek startup time and recordings query performance (#24011)

* serve a segment startup ladder so seeks begin playing sooner

nginx-vod was handed one 10s segment per recording file, so every playlist start had to download and decode a full segment before the first frame. Declare real keyframe data per clip and let nginx cut short leading segments from it.

- add vod_bootstrap_segment_durations 1000/2000/4000 so each playlist starts with 1s/2s/4s segments before settling at 10s
- emit real clip-relative keyFrameDurations (plus firstKeyFrameOffset when nonzero) from the recording keyframe index; rows without an index keep the whole-clip declaration, the only safe cut without keyframe knowledge
- drop the manifest's segment_duration field, which was always inert: nginx-vod parses only camelCase segmentDuration
- rebuild the player source at the seek target, quantized to a 10s grid, so the ladder applies to every seek and seek URLs stay repeatable for nginx's mapping and response caches
- route the seek model, in-range checks, and the stale-report guard through the source window rather than the chunk range
- bridge repositioning seeks (>2s from the last played timestamp) through the preview player and hold the release anchor one commit, so neither path paints a stale frame
- clear a pending loading timer before replacing it; an orphaned timer escaped onPlaying's clearTimeout and flashed loading mid-playback

* keep recordings queries on their indexes

Several recordings queries degraded into full scans or large sorts on big databases: the planner ignored index order, or the query shape gave it nothing tight to seek on. Reshape them into bounded seeks and add the composite index the per-stream lookups need.

- index recordings on (camera, stream_type, start_time DESC) and drop the (camera, stream_type) index it supersedes
- walk the recordings summary day by day with EXISTS probes and per-camera MIN/MAX seeks, skipping ahead over empty gaps instead of bucketing every row for the requested cameras
- run the summary endpoint on the event loop rather than the threadpool
- bound the unavailable-recordings query by start_time per camera and merge the results in Python
- bound the expire query's start_time so it seeks the retention window instead of scanning a camera's whole history
- enumerate deleted cameras with one index seek each rather than a camera NOT IN (...) scan
- compute bandwidth with segment_size filtered in a CASE projection; as a WHERE predicate it baited the planner into the (camera, segment_size) index plus a full sort of the camera's history
- fall back to a 1000-segment window when the recent 100 are all zero-size, so an ingest glitch doesn't report zero bandwidth
- limit the needs_refresh count instead of counting every segment
- cover sub-only and sparse calendar days, midnight-spanning day attribution, multi-camera gap merging, deleted-camera expiry, and zero-size segment runs

* fix mypy
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 50e7b76eb5
commit e77155a4f1
12 changed files with 654 additions and 120 deletions
@@ -45,6 +45,16 @@ import { useUserPersistence } from "@/hooks/use-user-persistence";
// a longer buffer cheap and it rides out connection variance better
const SUB_STREAM_BUFFER_LENGTH_S = 30;
// seeks rebuild the source starting at the seek target so the vod
// bootstrap segment ladder applies to every seek; quantizing keeps
// seek URLs repeatable for nginx's mapping/response caches
const SOURCE_START_GRID_S = 10;
// seeks beyond this bridge the source load with the preview player (the
// held video frame is pre-seek content); continuations (quality switch,
// natural chunk advance) keep the held frame
const REPOSITION_PREVIEW_THRESHOLD_S = 2;
/**
* Dynamically switches between video playback and scrubbing preview player.
*/
@@ -157,6 +167,11 @@ export default function DynamicVideoPlayer({
useEffect(() => {
if (!isScrubbing) {
// never overwrite a pending timer: an orphaned one escapes
// onPlaying's clearTimeout and flashes loading mid-playback
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
loadingTimeoutRef.current = setTimeout(() => setIsLoading(true), 1000);
}
@@ -179,6 +194,85 @@ export default function DynamicVideoPlayer({
// playlist, so the playback effect skips its loading indicator
const modelOnlyUpdateRef = useRef(false);
// re-anchors quality-switch rebuilds and classifies rebuilds as
// repositioning vs continuation
const lastPlayedTimestampRef = useRef<number | undefined>(undefined);
// start of the current source window within the chunk; undefined plays
// from the chunk start. Follows explicit seeks (startTimestamp),
// cleared when a chunk change leaves the seek target behind
const [sourceAfter, setSourceAfter] = useState<number | undefined>(undefined);
// adjusted during render: an effect lands one commit late, briefly
// painting the stale video frame between drag preview and load bridge
const nextSourceAfter =
startTimestamp !== undefined &&
startTimestamp > timeRange.after &&
startTimestamp < timeRange.before
? Math.max(
timeRange.after,
Math.floor(startTimestamp / SOURCE_START_GRID_S) *
SOURCE_START_GRID_S,
)
: undefined;
if (nextSourceAfter !== sourceAfter) {
setSourceAfter(nextSourceAfter);
const lastPlayed = lastPlayedTimestampRef.current;
if (
!isLoading &&
startTimestamp !== undefined &&
lastPlayed !== undefined &&
Math.abs(startTimestamp - lastPlayed) > REPOSITION_PREVIEW_THRESHOLD_S
) {
setIsLoading(true);
}
}
// the release anchor lands one commit after isScrubbing flips false
// (the view writes playbackStart from an effect), which would flash
// the stale frame; hold one commit. The clearing effect runs before
// the parent's release effect, so clear and anchor batch into one render
const [releaseHold, setReleaseHold] = useState(false);
const [wasScrubbing, setWasScrubbing] = useState(isScrubbing);
if (isScrubbing !== wasScrubbing) {
setWasScrubbing(isScrubbing);
if (!isScrubbing) {
setReleaseHold(true);
}
}
useEffect(() => {
if (releaseHold) {
setReleaseHold(false);
}
}, [releaseHold]);
const recordingParams = useMemo(
() => ({
before: timeRange.before,
// clamp: during the adjust-render pass this memo can evaluate with
// the previous sourceAfter against a new timeRange
after:
sourceAfter !== undefined &&
sourceAfter > timeRange.after &&
sourceAfter < timeRange.before
? sourceAfter
: timeRange.after,
timelines: true,
}),
[timeRange, sourceAfter],
);
// the window the current source covers; the seek model, in-range
// checks, and stale-report guard use this, not the chunk timeRange
const sourceTimeRange = useMemo<TimeRange>(
() => ({ after: recordingParams.after, before: recordingParams.before }),
[recordingParams],
);
const onPlayerLoaded = useCallback(() => {
sourceLoadedRef.current = true;
governorRef.current?.sourceLoadEnded();
@@ -189,9 +283,9 @@ export default function DynamicVideoPlayer({
return;
}
// an anchor outside this chunk is stale (e.g. a natural clip
// an anchor outside this source window is stale (e.g. a natural clip
// advance); the playlist already starts where playback should
if (anchor < timeRange.after || anchor > timeRange.before) {
if (anchor < sourceTimeRange.after || anchor > sourceTimeRange.before) {
return;
}
@@ -199,11 +293,7 @@ export default function DynamicVideoPlayer({
// start it: a mid-drag chunk prefetch can audibly blip before
// onPlaying pauses it. The release seek starts playback
controller.seekToTimestamp(anchor, !isScrubbing);
}, [controller, timeRange, isScrubbing]);
// used to re-anchor the source when an auto quality switch rebuilds
// the playlist mid-playback
const lastPlayedTimestampRef = useRef<number | undefined>(undefined);
}, [controller, sourceTimeRange, isScrubbing]);
// the range the controller's playback model was last built for; while
// a chunk change awaits its coverage, the outgoing source reports
@@ -223,10 +313,10 @@ export default function DynamicVideoPlayer({
return;
}
// drop reports until the controller's model matches this chunk
// drop reports until the controller's model matches this source
if (
modelTimeRangeRef.current?.after !== timeRange.after ||
modelTimeRangeRef.current?.before !== timeRange.before
modelTimeRangeRef.current?.after !== sourceTimeRange.after ||
modelTimeRangeRef.current?.before !== sourceTimeRange.before
) {
return;
}
@@ -249,7 +339,7 @@ export default function DynamicVideoPlayer({
isBuffering,
isLoading,
isScrubbing,
timeRange,
sourceTimeRange,
],
);
@@ -310,14 +400,6 @@ export default function DynamicVideoPlayer({
// state of playback player
const recordingParams = useMemo(
() => ({
before: timeRange.before,
after: timeRange.after,
timelines: true,
}),
[timeRange],
);
const { data: coverage } = useSWR<RecordingCoverage>(
[`${camera}/recordings/coverage`, recordingParams],
{ revalidateOnFocus: false },
@@ -628,8 +710,8 @@ export default function DynamicVideoPlayer({
const anchorTimestamp =
qualityChanged &&
lastPlayed !== undefined &&
lastPlayed >= timeRange.after &&
lastPlayed <= timeRange.before
lastPlayed >= recordingParams.after &&
lastPlayed <= recordingParams.before
? lastPlayed
: startTimestamp;
sourceAnchorRef.current = anchorTimestamp;
@@ -679,6 +761,10 @@ export default function DynamicVideoPlayer({
HTMLMediaElement.HAVE_CURRENT_DATA;
if (!modelOnlyUpdate) {
// an overwritten pending timer would escape onPlaying's clearTimeout
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
loadingTimeoutRef.current = setTimeout(
() => (hasDecodedFrame ? setIsBuffering(true) : setIsLoading(true)),
1000,
@@ -687,9 +773,9 @@ export default function DynamicVideoPlayer({
controller.newPlayback({
recordings: recordings ?? [],
timeRange,
timeRange: sourceTimeRange,
});
modelTimeRangeRef.current = timeRange;
modelTimeRangeRef.current = sourceTimeRange;
// we only want this to change when controller or recordings update
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -733,7 +819,7 @@ export default function DynamicVideoPlayer({
<HlsVideoPlayer
videoRef={playerRef}
containerRef={containerRef}
visible={!(isScrubbing || isLoading)}
visible={!(isScrubbing || isLoading || releaseHold)}
currentSource={source}
hotKeys={hotKeys}
supportsFullscreen={supportsFullscreen}
@@ -788,7 +874,7 @@ export default function DynamicVideoPlayer({
<PreviewPlayer
className={cn(
className,
isScrubbing || isLoading ? "visible" : "hidden",
isScrubbing || isLoading || releaseHold ? "visible" : "hidden",
)}
camera={camera}
timeRange={timeRange}