mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
batch annotation offset to seek atomically and throttle slider drag
This commit is contained in:
parent
93694c6215
commit
28722c7d70
@ -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;
|
|
||||||
return Math.max(
|
|
||||||
ANNOTATION_OFFSET_MIN,
|
ANNOTATION_OFFSET_MIN,
|
||||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
||||||
);
|
);
|
||||||
});
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
|
||||||
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned
|
|
||||||
setCurrentTime(targetTimeRecord);
|
setCurrentTime(targetTimeRecord);
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
isAnnotationSettingsOpen,
|
isAnnotationSettingsOpen,
|
||||||
annotationOffset,
|
annotationOffset,
|
||||||
|
|||||||
@ -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
|
||||||
|
// offset. Without this, the overlay would render one frame with the new
|
||||||
|
// offset but the old currentTime, briefly resolving effectiveCurrentTime to
|
||||||
|
// the wrong detect-stream timestamp and making the bounding box vanish or
|
||||||
|
// jump.
|
||||||
|
const handleApplyOffset = useCallback(
|
||||||
|
(newOffset: number) => {
|
||||||
const pinned = pinnedDetectTimestampRef.current;
|
const pinned = pinnedDetectTimestampRef.current;
|
||||||
if (!controlsExpanded || pinned == null) return;
|
if (!controlsExpanded || pinned == null) return;
|
||||||
const recordTime = pinned + annotationOffset / 1000;
|
onSeek(pinned + newOffset / 1000, false);
|
||||||
onSeek(recordTime, false);
|
},
|
||||||
}, [controlsExpanded, annotationOffset, onSeek]);
|
[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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user