mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-13 18:47:36 +03:00
add annotation offset slider
This commit is contained in:
parent
cc48dc7c4e
commit
ce7898b4bc
@ -37,8 +37,7 @@ export default function ObjectTrackOverlay({
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { annotationOffset } = useActivityStream();
|
const { annotationOffset } = useActivityStream();
|
||||||
|
|
||||||
// Offset currentTime by annotation offset for rendering
|
const effectiveCurrentTime = currentTime - annotationOffset / 1000;
|
||||||
const effectiveCurrentTime = currentTime - annotationOffset;
|
|
||||||
|
|
||||||
// Fetch the full event data to get saved path points
|
// Fetch the full event data to get saved path points
|
||||||
const { data: eventData } = useSWR(["event_ids", { ids: selectedObjectId }]);
|
const { data: eventData } = useSWR(["event_ids", { ids: selectedObjectId }]);
|
||||||
@ -157,8 +156,9 @@ export default function ObjectTrackOverlay({
|
|||||||
}, [savedPathPoints, eventSequencePoints, config, camera, currentTime]);
|
}, [savedPathPoints, eventSequencePoints, config, camera, currentTime]);
|
||||||
|
|
||||||
// get absolute positions on the svg canvas for each point
|
// get absolute positions on the svg canvas for each point
|
||||||
const getAbsolutePositions = useCallback(() => {
|
const absolutePositions = useMemo(() => {
|
||||||
if (!pathPoints) return [];
|
if (!pathPoints) return [];
|
||||||
|
|
||||||
return pathPoints.map((point) => {
|
return pathPoints.map((point) => {
|
||||||
// Find the corresponding timeline entry for this point
|
// Find the corresponding timeline entry for this point
|
||||||
const timelineEntry = objectTimeline?.find(
|
const timelineEntry = objectTimeline?.find(
|
||||||
@ -272,11 +272,6 @@ export default function ObjectTrackOverlay({
|
|||||||
: [255, 0, 0];
|
: [255, 0, 0];
|
||||||
}, [pathPoints, getObjectColor]);
|
}, [pathPoints, getObjectColor]);
|
||||||
|
|
||||||
const absolutePositions = useMemo(
|
|
||||||
() => getAbsolutePositions(),
|
|
||||||
[getAbsolutePositions],
|
|
||||||
);
|
|
||||||
|
|
||||||
// render any zones for object at current time
|
// render any zones for object at current time
|
||||||
const zonePolygons = useMemo(() => {
|
const zonePolygons = useMemo(() => {
|
||||||
return zones.map((zone) => {
|
return zones.map((zone) => {
|
||||||
|
|||||||
95
web/src/components/overlay/detail/AnnotationOffsetSlider.tsx
Normal file
95
web/src/components/overlay/detail/AnnotationOffsetSlider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -174,7 +174,7 @@ export function AnnotationSettingsPane({
|
|||||||
{t("objectLifecycle.annotationSettings.offset.label")}
|
{t("objectLifecycle.annotationSettings.offset.label")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8">
|
<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" />
|
<PiWarningCircle className="size-24" />
|
||||||
<div>
|
<div>
|
||||||
<Trans ns="views/explore">
|
<Trans ns="views/explore">
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
|||||||
import useUserInteraction from "@/hooks/use-user-interaction";
|
import useUserInteraction from "@/hooks/use-user-interaction";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
@ -27,7 +28,7 @@ export default function ActivityStream({
|
|||||||
const { selectedObjectId, annotationOffset } = useActivityStream();
|
const { selectedObjectId, annotationOffset } = useActivityStream();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const effectiveTime = currentTime + annotationOffset;
|
const effectiveTime = currentTime + annotationOffset / 1000;
|
||||||
|
|
||||||
// Track user interaction and adjust scrolling behavior
|
// Track user interaction and adjust scrolling behavior
|
||||||
const { userInteracting, setProgrammaticScroll } = useUserInteraction({
|
const { userInteracting, setProgrammaticScroll } = useUserInteraction({
|
||||||
@ -53,7 +54,8 @@ export default function ActivityStream({
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
timestamp: sortedActivities[0].timestamp, // Original timestamp for display
|
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,
|
activities: sortedActivities,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -113,27 +115,31 @@ export default function ActivityStream({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative">
|
||||||
ref={scrollRef}
|
<div
|
||||||
className="scrollbar-container h-full overflow-y-auto bg-secondary"
|
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="space-y-2 p-4">
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
{filteredGroups.length === 0 ? (
|
||||||
{t("activity.noActivitiesFound")}
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
</div>
|
{t("activity.noActivitiesFound")}
|
||||||
) : (
|
</div>
|
||||||
filteredGroups.map((group) => (
|
) : (
|
||||||
<ActivityGroup
|
filteredGroups.map((group) => (
|
||||||
key={group.timestamp}
|
<ActivityGroup
|
||||||
group={group}
|
key={group.timestamp}
|
||||||
config={config}
|
group={group}
|
||||||
isCurrent={group.effectiveTimestamp <= currentTime}
|
config={config}
|
||||||
onSeek={onSeek}
|
isCurrent={group.effectiveTimestamp <= currentTime}
|
||||||
/>
|
onSeek={onSeek}
|
||||||
))
|
/>
|
||||||
)}
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AnnotationOffsetSlider />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -8,7 +14,8 @@ interface ActivityStreamContextType {
|
|||||||
selectedObjectTimeline: ObjectLifecycleSequence[] | undefined;
|
selectedObjectTimeline: ObjectLifecycleSequence[] | undefined;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
camera: string;
|
camera: string;
|
||||||
annotationOffset: number;
|
annotationOffset: number; // milliseconds
|
||||||
|
setAnnotationOffset: (ms: number) => void;
|
||||||
setSelectedObjectId: (id: string | undefined) => void;
|
setSelectedObjectId: (id: string | undefined) => void;
|
||||||
isActivityMode: boolean;
|
isActivityMode: boolean;
|
||||||
}
|
}
|
||||||
@ -38,12 +45,15 @@ export function ActivityStreamProvider({
|
|||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const annotationOffset = useMemo(() => {
|
const [annotationOffset, setAnnotationOffset] = useState<number>(() => {
|
||||||
if (!config) {
|
if (!config) return 0;
|
||||||
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]);
|
}, [config, camera]);
|
||||||
|
|
||||||
const selectedObjectTimeline = useMemo(() => {
|
const selectedObjectTimeline = useMemo(() => {
|
||||||
@ -57,6 +67,7 @@ export function ActivityStreamProvider({
|
|||||||
currentTime,
|
currentTime,
|
||||||
camera,
|
camera,
|
||||||
annotationOffset,
|
annotationOffset,
|
||||||
|
setAnnotationOffset,
|
||||||
setSelectedObjectId,
|
setSelectedObjectId,
|
||||||
isActivityMode,
|
isActivityMode,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user