batch annotation offset to seek atomically and throttle slider drag

This commit is contained in:
Josh Hawkins 2026-05-02 07:32:38 -05:00
parent 93694c6215
commit 28722c7d70
4 changed files with 150 additions and 48 deletions

View File

@ -1,4 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { flushSync } from "react-dom";
import { throttle } from "lodash";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
@ -19,11 +21,21 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
const SLIDER_DRAG_THROTTLE_MS = 80;
type Props = { type Props = {
className?: string; className?: string;
// Optional side-effect invoked atomically with setAnnotationOffset (inside
// flushSync) so callers like the timeline panel can re-seek the video in the
// same React commit as the offset state update — preventing a one-frame
// overlay mismatch where annotationOffset has changed but currentTime has not.
onApplyOffset?: (newOffset: number) => void;
}; };
export default function AnnotationOffsetSlider({ className }: Props) { export default function AnnotationOffsetSlider({
className,
onApplyOffset,
}: Props) {
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream(); const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
@ -31,31 +43,62 @@ export default function AnnotationOffsetSlider({ className }: Props) {
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const applyOffset = useCallback(
(newOffset: number) => {
flushSync(() => {
setAnnotationOffset(newOffset);
onApplyOffset?.(newOffset);
});
},
[setAnnotationOffset, onApplyOffset],
);
const throttledApplyOffset = useMemo(
() =>
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
leading: true,
trailing: true,
}),
[applyOffset],
);
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
const handleChange = useCallback( const handleChange = useCallback(
(values: number[]) => { (values: number[]) => {
if (!values || values.length === 0) return; if (!values || values.length === 0) return;
const valueMs = values[0]; throttledApplyOffset(values[0]);
setAnnotationOffset(valueMs);
}, },
[setAnnotationOffset], [throttledApplyOffset],
);
const handleCommit = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
// Ensure the final value lands even if it would otherwise be discarded
// by the trailing edge of the throttle window.
throttledApplyOffset.cancel();
applyOffset(values[0]);
},
[throttledApplyOffset, applyOffset],
); );
const stepOffset = useCallback( const stepOffset = useCallback(
(delta: number) => { (delta: number) => {
setAnnotationOffset((prev) => { const next = Math.max(
const next = prev + delta; ANNOTATION_OFFSET_MIN,
return Math.max( Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
ANNOTATION_OFFSET_MIN, );
Math.min(ANNOTATION_OFFSET_MAX, next), throttledApplyOffset.cancel();
); applyOffset(next);
});
}, },
[setAnnotationOffset], [annotationOffset, applyOffset, throttledApplyOffset],
); );
const reset = useCallback(() => { const reset = useCallback(() => {
setAnnotationOffset(0); throttledApplyOffset.cancel();
}, [setAnnotationOffset]); applyOffset(0);
}, [applyOffset, throttledApplyOffset]);
const save = useCallback(async () => { const save = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
@ -130,6 +173,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
max={ANNOTATION_OFFSET_MAX} max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP} step={ANNOTATION_OFFSET_STEP}
onValueChange={handleChange} onValueChange={handleChange}
onValueCommit={handleCommit}
/> />
</div> </div>
<Button <Button

View File

@ -1,7 +1,9 @@
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { flushSync } from "react-dom";
import { throttle } from "lodash";
import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu"; import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@ -19,6 +21,8 @@ import {
ANNOTATION_OFFSET_STEP, ANNOTATION_OFFSET_STEP,
} from "@/lib/const"; } from "@/lib/const";
const SLIDER_DRAG_THROTTLE_MS = 80;
type AnnotationSettingsPaneProps = { type AnnotationSettingsPaneProps = {
event: Event; event: Event;
annotationOffset: number; annotationOffset: number;
@ -38,30 +42,64 @@ export function AnnotationSettingsPane({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleSliderChange = useCallback( // flushSync ensures setAnnotationOffset commits synchronously so the
(values: number[]) => { // useLayoutEffect in TrackingDetails (which seeks the video and sets
if (!values || values.length === 0) return; // currentTime in response) runs before the browser paints — preventing a
setAnnotationOffset(values[0]); // one-frame overlay mismatch where annotationOffset has changed but
}, // currentTime has not.
[setAnnotationOffset], const applyOffset = useCallback(
); (newOffset: number) => {
flushSync(() => {
const stepOffset = useCallback( setAnnotationOffset(newOffset);
(delta: number) => {
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
}); });
}, },
[setAnnotationOffset], [setAnnotationOffset],
); );
const throttledApplyOffset = useMemo(
() =>
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
leading: true,
trailing: true,
}),
[applyOffset],
);
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
const handleSliderChange = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
throttledApplyOffset(values[0]);
},
[throttledApplyOffset],
);
const handleSliderCommit = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
throttledApplyOffset.cancel();
applyOffset(values[0]);
},
[throttledApplyOffset, applyOffset],
);
const stepOffset = useCallback(
(delta: number) => {
const next = Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
);
throttledApplyOffset.cancel();
applyOffset(next);
},
[annotationOffset, applyOffset, throttledApplyOffset],
);
const reset = useCallback(() => { const reset = useCallback(() => {
setAnnotationOffset(0); throttledApplyOffset.cancel();
}, [setAnnotationOffset]); applyOffset(0);
}, [applyOffset, throttledApplyOffset]);
const saveToConfig = useCallback(async () => { const saveToConfig = useCallback(async () => {
if (!config || !event) return; if (!config || !event) return;
@ -143,6 +181,7 @@ export function AnnotationSettingsPane({
max={ANNOTATION_OFFSET_MAX} max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP} step={ANNOTATION_OFFSET_STEP}
onValueChange={handleSliderChange} onValueChange={handleSliderChange}
onValueCommit={handleSliderCommit}
className="flex-1" className="flex-1"
/> />
<Button <Button

View File

@ -1,5 +1,13 @@
import useSWR from "swr"; import useSWR from "swr";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { flushSync } from "react-dom";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { useFullscreen } from "@/hooks/use-fullscreen"; import { useFullscreen } from "@/hooks/use-fullscreen";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
@ -389,7 +397,12 @@ export function TrackingDetails({
// When the pinned timestamp or offset changes, re-seek the video and // When the pinned timestamp or offset changes, re-seek the video and
// explicitly update currentTime so the overlay shows the pinned event's box. // explicitly update currentTime so the overlay shows the pinned event's box.
useEffect(() => { // useLayoutEffect + flushSync force the setCurrentTime commit to land before
// the browser paints, so the overlay never shows a frame where
// annotationOffset has changed but currentTime has not — that mismatch would
// resolve effectiveCurrentTime away from the pinned detect timestamp and
// make the bounding box disappear or jump for one frame.
useLayoutEffect(() => {
const pinned = pinnedDetectTimestampRef.current; const pinned = pinnedDetectTimestampRef.current;
if (!isAnnotationSettingsOpen || pinned == null) return; if (!isAnnotationSettingsOpen || pinned == null) return;
if (!videoRef.current || displaySource !== "video") return; if (!videoRef.current || displaySource !== "video") return;
@ -398,10 +411,9 @@ export function TrackingDetails({
const relativeTime = timestampToVideoTime(targetTimeRecord); const relativeTime = timestampToVideoTime(targetTimeRecord);
videoRef.current.currentTime = relativeTime; videoRef.current.currentTime = relativeTime;
// Explicitly update currentTime state so the overlay's effectiveCurrentTime flushSync(() => {
// resolves back to the pinned detect timestamp: setCurrentTime(targetTimeRecord);
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned });
setCurrentTime(targetTimeRecord);
}, [ }, [
isAnnotationSettingsOpen, isAnnotationSettingsOpen,
annotationOffset, annotationOffset,

View File

@ -126,13 +126,20 @@ export default function DetailStream({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlsExpanded]); }, [controlsExpanded]);
// Re-seek on annotation offset change while settings panel is open // The slider invokes this atomically with setAnnotationOffset (inside the
useEffect(() => { // same flushSync) so currentTime advances in the same React commit as the
const pinned = pinnedDetectTimestampRef.current; // offset. Without this, the overlay would render one frame with the new
if (!controlsExpanded || pinned == null) return; // offset but the old currentTime, briefly resolving effectiveCurrentTime to
const recordTime = pinned + annotationOffset / 1000; // the wrong detect-stream timestamp and making the bounding box vanish or
onSeek(recordTime, false); // jump.
}, [controlsExpanded, annotationOffset, onSeek]); const handleApplyOffset = useCallback(
(newOffset: number) => {
const pinned = pinnedDetectTimestampRef.current;
if (!controlsExpanded || pinned == null) return;
onSeek(pinned + newOffset / 1000, false);
},
[controlsExpanded, onSeek],
);
// Ensure we initialize the active review when reviewItems first arrive. // Ensure we initialize the active review when reviewItems first arrive.
// This helps when the component mounts while the video is already // This helps when the component mounts while the video is already
@ -337,7 +344,7 @@ export default function DetailStream({
</button> </button>
{controlsExpanded && ( {controlsExpanded && (
<div className="space-y-4 px-3 pb-5 pt-2"> <div className="space-y-4 px-3 pb-5 pt-2">
<AnnotationOffsetSlider /> <AnnotationOffsetSlider onApplyOffset={handleApplyOffset} />
<Separator /> <Separator />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">