add annotation offset slider

This commit is contained in:
Josh Hawkins 2025-10-15 07:52:26 -05:00
parent cc48dc7c4e
commit ce7898b4bc
5 changed files with 145 additions and 38 deletions

View File

@ -37,8 +37,7 @@ export default function ObjectTrackOverlay({
const { data: config } = useSWR<FrigateConfig>("config");
const { annotationOffset } = useActivityStream();
// Offset currentTime by annotation offset for rendering
const effectiveCurrentTime = currentTime - annotationOffset;
const effectiveCurrentTime = currentTime - annotationOffset / 1000;
// Fetch the full event data to get saved path points
const { data: eventData } = useSWR(["event_ids", { ids: selectedObjectId }]);
@ -157,8 +156,9 @@ export default function ObjectTrackOverlay({
}, [savedPathPoints, eventSequencePoints, config, camera, currentTime]);
// get absolute positions on the svg canvas for each point
const getAbsolutePositions = useCallback(() => {
const absolutePositions = useMemo(() => {
if (!pathPoints) return [];
return pathPoints.map((point) => {
// Find the corresponding timeline entry for this point
const timelineEntry = objectTimeline?.find(
@ -272,11 +272,6 @@ export default function ObjectTrackOverlay({
: [255, 0, 0];
}, [pathPoints, getObjectColor]);
const absolutePositions = useMemo(
() => getAbsolutePositions(),
[getAbsolutePositions],
);
// render any zones for object at current time
const zonePolygons = useMemo(() => {
return zones.map((zone) => {

View File

@ -0,0 +1,95 @@
import { useCallback, useState } from "react";
import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button";
import { useActivityStream } from "@/contexts/ActivityStreamContext";
import axios from "axios";
import { useSWRConfig } from "swr";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
type Props = {
className?: string;
};
export default function AnnotationOffsetSlider({ className }: Props) {
const { annotationOffset, setAnnotationOffset, camera } = useActivityStream();
const { mutate } = useSWRConfig();
const { t } = useTranslation(["views/explore"]);
const [isSaving, setIsSaving] = useState(false);
const handleChange = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
const valueMs = values[0];
setAnnotationOffset(valueMs);
},
[setAnnotationOffset],
);
const reset = useCallback(() => {
setAnnotationOffset(0);
}, [setAnnotationOffset]);
const save = useCallback(async () => {
setIsSaving(true);
try {
// save value in milliseconds to config
await axios.put(
`config/set?cameras.${camera}.detect.annotation_offset=${annotationOffset}`,
{ requires_restart: 0 },
);
toast.success(
t("objectLifecycle.annotationSettings.offset.toast.success", {
camera,
}),
{ position: "top-center" },
);
// refresh config
await mutate("config");
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.message || err?.message || "Unknown error";
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
position: "top-center",
});
} finally {
setIsSaving(false);
}
}, [annotationOffset, camera, mutate, t]);
return (
<div
className={`absolute bottom-0 left-0 right-0 z-30 flex items-center gap-3 bg-gradient-to-t from-secondary/90 to-transparent p-3 ${className ?? ""}`}
style={{ pointerEvents: "auto" }}
>
<div className="w-56 text-sm">
Annotation offset (ms): {annotationOffset}
</div>
<div className="flex-1">
<Slider
value={[annotationOffset]}
min={-1500}
max={1500}
step={50}
onValueChange={handleChange}
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={reset}>
Reset
</Button>
<Button size="sm" onClick={save} disabled={isSaving}>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
</div>
</div>
);
}

View File

@ -174,7 +174,7 @@ export function AnnotationSettingsPane({
{t("objectLifecycle.annotationSettings.offset.label")}
</FormLabel>
<div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8">
<div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-0 md:my-5">
<div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-5">
<PiWarningCircle className="size-24" />
<div>
<Trans ns="views/explore">

View File

@ -7,6 +7,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import useUserInteraction from "@/hooks/use-user-interaction";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useTranslation } from "react-i18next";
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import ActivityIndicator from "../indicators/activity-indicator";
@ -27,7 +28,7 @@ export default function ActivityStream({
const { selectedObjectId, annotationOffset } = useActivityStream();
const scrollRef = useRef<HTMLDivElement>(null);
const effectiveTime = currentTime + annotationOffset;
const effectiveTime = currentTime + annotationOffset / 1000;
// Track user interaction and adjust scrolling behavior
const { userInteracting, setProgrammaticScroll } = useUserInteraction({
@ -53,7 +54,8 @@ export default function ActivityStream({
);
return {
timestamp: sortedActivities[0].timestamp, // Original timestamp for display
effectiveTimestamp: sortedActivities[0].timestamp + annotationOffset, // Adjusted for sorting/comparison
effectiveTimestamp:
sortedActivities[0].timestamp + annotationOffset / 1000,
activities: sortedActivities,
};
})
@ -113,27 +115,31 @@ export default function ActivityStream({
}
return (
<div
ref={scrollRef}
className="scrollbar-container h-full overflow-y-auto bg-secondary"
>
<div className="space-y-2 p-4">
{filteredGroups.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
{t("activity.noActivitiesFound")}
</div>
) : (
filteredGroups.map((group) => (
<ActivityGroup
key={group.timestamp}
group={group}
config={config}
isCurrent={group.effectiveTimestamp <= currentTime}
onSeek={onSeek}
/>
))
)}
<div className="relative">
<div
ref={scrollRef}
className="scrollbar-container h-[calc(100vh-70px)] overflow-y-auto bg-secondary"
>
<div className="space-y-2 p-4">
{filteredGroups.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
{t("activity.noActivitiesFound")}
</div>
) : (
filteredGroups.map((group) => (
<ActivityGroup
key={group.timestamp}
group={group}
config={config}
isCurrent={group.effectiveTimestamp <= currentTime}
onSeek={onSeek}
/>
))
)}
</div>
</div>
<AnnotationOffsetSlider />
</div>
);
}

View File

@ -1,4 +1,10 @@
import React, { createContext, useContext, useState, useMemo } from "react";
import React, {
createContext,
useContext,
useState,
useMemo,
useEffect,
} from "react";
import { ObjectLifecycleSequence } from "@/types/timeline";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
@ -8,7 +14,8 @@ interface ActivityStreamContextType {
selectedObjectTimeline: ObjectLifecycleSequence[] | undefined;
currentTime: number;
camera: string;
annotationOffset: number;
annotationOffset: number; // milliseconds
setAnnotationOffset: (ms: number) => void;
setSelectedObjectId: (id: string | undefined) => void;
isActivityMode: boolean;
}
@ -38,12 +45,15 @@ export function ActivityStreamProvider({
const { data: config } = useSWR<FrigateConfig>("config");
const annotationOffset = useMemo(() => {
if (!config) {
return 0;
}
const [annotationOffset, setAnnotationOffset] = useState<number>(() => {
if (!config) return 0;
return config.cameras[camera]?.detect?.annotation_offset || 0;
});
return (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000; // Convert to seconds
useEffect(() => {
if (!config) return;
const cfgOffset = config.cameras[camera]?.detect?.annotation_offset || 0;
setAnnotationOffset(cfgOffset);
}, [config, camera]);
const selectedObjectTimeline = useMemo(() => {
@ -57,6 +67,7 @@ export function ActivityStreamProvider({
currentTime,
camera,
annotationOffset,
setAnnotationOffset,
setSelectedObjectId,
isActivityMode,
};