mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-16 18:16:44 +03:00
Compare commits
3 Commits
190925375b
...
5ff7a47ba9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff7a47ba9 | ||
|
|
5715ed62ad | ||
|
|
43706eb48d |
@ -402,25 +402,25 @@ def run_analysis(
|
||||
"duration": round(final_data["end_time"] - final_data["start_time"]),
|
||||
}
|
||||
|
||||
objects = []
|
||||
named_objects = []
|
||||
unified_objects = []
|
||||
|
||||
objects_list = final_data["data"]["objects"]
|
||||
sub_labels_list = final_data["data"]["sub_labels"]
|
||||
|
||||
for i, verified_label in enumerate(final_data["data"]["verified_objects"]):
|
||||
object_type = verified_label.replace("-verified", "").replace("_", " ")
|
||||
name = sub_labels_list[i].replace("_", " ").title()
|
||||
unified_objects.append(f"{name} ({object_type})")
|
||||
|
||||
# Add non-verified objects as "Unknown (type)"
|
||||
for label in objects_list:
|
||||
if "-verified" in label:
|
||||
continue
|
||||
elif label in labelmap_objects:
|
||||
objects.append(label.replace("_", " ").title())
|
||||
object_type = label.replace("_", " ")
|
||||
unified_objects.append(f"Unknown ({object_type})")
|
||||
|
||||
for i, verified_label in enumerate(final_data["data"]["verified_objects"]):
|
||||
named_objects.append(
|
||||
f"{sub_labels_list[i].replace('_', ' ').title()} ({verified_label.replace('-verified', '')})"
|
||||
)
|
||||
|
||||
analytics_data["objects"] = objects
|
||||
analytics_data["recognized_objects"] = named_objects
|
||||
analytics_data["unified_objects"] = unified_objects
|
||||
|
||||
metadata = genai_client.generate_review_description(
|
||||
analytics_data,
|
||||
|
||||
@ -63,20 +63,17 @@ class GenAIClient:
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_verified_object_prompt() -> str:
|
||||
if review_data["recognized_objects"]:
|
||||
object_list = " - " + "\n - ".join(review_data["recognized_objects"])
|
||||
return f"""## Verified Objects (USE THESE NAMES)
|
||||
When any of the following verified objects are present in the scene, you MUST use these exact names in your title and scene description:
|
||||
{object_list}
|
||||
"""
|
||||
def get_objects_list() -> str:
|
||||
if review_data["unified_objects"]:
|
||||
return "\n- " + "\n- ".join(review_data["unified_objects"])
|
||||
else:
|
||||
return ""
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
context_prompt = f"""
|
||||
Your task is to analyze the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"].replace("_", " ")} security camera.
|
||||
|
||||
## Normal Activity Patterns for This Property
|
||||
|
||||
{activity_context_prompt}
|
||||
|
||||
## Task Instructions
|
||||
@ -91,7 +88,7 @@ Your task is to provide a clear, accurate description of the scene that:
|
||||
## Analysis Guidelines
|
||||
|
||||
When forming your description:
|
||||
- **CRITICAL: Only describe objects explicitly listed in "Detected objects" below.** Do not infer or mention additional people, vehicles, or objects not present in the detected objects list, even if visual patterns suggest them. If only a car is detected, do not describe a person interacting with it unless "person" is also in the detected objects list.
|
||||
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
|
||||
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
|
||||
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
|
||||
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
|
||||
@ -103,7 +100,7 @@ When forming your description:
|
||||
## Response Format
|
||||
|
||||
Your response MUST be a flat JSON object with:
|
||||
- `title` (string): A concise, one-sentence title that captures the main activity. Include any verified recognized objects (from the "Verified recognized objects" list below) and key detected objects. Examples: "Joe walking dog in backyard", "Unknown person testing car doors at night".
|
||||
- `title` (string): A concise, one-sentence title that captures the main activity. Use the exact names from "Objects in Scene" below (e.g., if the list shows "Joe (person)" and "Unknown (person)", say "Joe and unknown person"). Examples: "Joe walking dog in backyard", "Unknown person testing car doors at night", "Joe and unknown person in driveway".
|
||||
- `scene` (string): A narrative description of what happens across the sequence from start to finish. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign.
|
||||
- `confidence` (float): 0-1 confidence in your analysis. Higher confidence when objects/actions are clearly visible and context is unambiguous. Lower confidence when the sequence is unclear, objects are partially obscured, or context is ambiguous.
|
||||
- `potential_threat_level` (integer): 0, 1, or 2 as defined below. Your threat level must be consistent with your scene description and the guidance above.
|
||||
@ -119,14 +116,17 @@ Your response MUST be a flat JSON object with:
|
||||
|
||||
- Frame 1 = earliest, Frame {len(thumbnails)} = latest
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Detected objects: {", ".join(review_data["objects"])}
|
||||
- Zones involved: {", ".join(z.replace("_", " ").title() for z in review_data["zones"]) or "None"}
|
||||
|
||||
{get_verified_object_prompt()}
|
||||
## Objects in Scene
|
||||
|
||||
Each line represents one object in the scene. Named objects are verified identities; "Unknown" indicates unverified objects of that type:
|
||||
{get_objects_list()}
|
||||
|
||||
## Important Notes
|
||||
- Values must be plain strings, floats, or integers — no nested objects, no extra commentary.
|
||||
- Only describe objects from the "Detected objects" list above. Do not hallucinate additional objects.
|
||||
- Only describe objects from the "Objects in Scene" list above. Do not hallucinate additional objects.
|
||||
- When describing people or vehicles, use the exact names provided.
|
||||
{get_language_prompt()}
|
||||
"""
|
||||
logger.debug(
|
||||
@ -161,7 +161,10 @@ Your response MUST be a flat JSON object with:
|
||||
try:
|
||||
metadata = ReviewMetadata.model_validate_json(clean_json)
|
||||
|
||||
if review_data["recognized_objects"]:
|
||||
if any(
|
||||
not obj.startswith("Unknown")
|
||||
for obj in review_data["unified_objects"]
|
||||
):
|
||||
metadata.potential_threat_level = 0
|
||||
|
||||
metadata.time = review_data["start"]
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
"export": "Export",
|
||||
"selectOrExport": "Select or Export",
|
||||
"toast": {
|
||||
"success": "Successfully started export. View the file in the /exports folder.",
|
||||
"success": "Successfully started export. View the file in the exports page.",
|
||||
"error": {
|
||||
"failed": "Failed to start export: {{error}}",
|
||||
"endTimeMustAfterStartTime": "End time must be after start time",
|
||||
|
||||
@ -36,8 +36,8 @@
|
||||
"video": "video",
|
||||
"object_lifecycle": "object lifecycle"
|
||||
},
|
||||
"objectLifecycle": {
|
||||
"title": "Object Lifecycle",
|
||||
"trackingDetails": {
|
||||
"title": "Tracking Details",
|
||||
"noImageFound": "No image found for this timestamp.",
|
||||
"createObjectMask": "Create Object Mask",
|
||||
"adjustAnnotationSettings": "Adjust annotation settings",
|
||||
@ -168,9 +168,9 @@
|
||||
"label": "Download snapshot",
|
||||
"aria": "Download snapshot"
|
||||
},
|
||||
"viewObjectLifecycle": {
|
||||
"label": "View object lifecycle",
|
||||
"aria": "Show the object lifecycle"
|
||||
"viewTrackingDetails": {
|
||||
"label": "View tracking details",
|
||||
"aria": "Show the tracking details"
|
||||
},
|
||||
"findSimilar": {
|
||||
"label": "Find similar",
|
||||
@ -205,7 +205,7 @@
|
||||
"dialog": {
|
||||
"confirmDelete": {
|
||||
"title": "Confirm Delete",
|
||||
"desc": "Deleting this tracked object removes the snapshot, any saved embeddings, and any associated object lifecycle entries. Recorded footage of this tracked object in History view will <em>NOT</em> be deleted.<br /><br />Are you sure you want to proceed?"
|
||||
"desc": "Deleting this tracked object removes the snapshot, any saved embeddings, and any associated tracking details entries. Recorded footage of this tracked object in History view will <em>NOT</em> be deleted.<br /><br />Are you sure you want to proceed?"
|
||||
}
|
||||
},
|
||||
"noTrackedObjects": "No Tracked Objects Found",
|
||||
|
||||
@ -34,7 +34,7 @@ import { toast } from "sonner";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { buttonVariants } from "../ui/button";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -83,6 +83,11 @@ export default function ReviewCard({
|
||||
if (response.status == 200) {
|
||||
toast.success(t("export.toast.success"), {
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
<Button>View</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@ -13,7 +13,7 @@ type SearchThumbnailProps = {
|
||||
columns: number;
|
||||
findSimilar: () => void;
|
||||
refreshResults: () => void;
|
||||
showObjectLifecycle: () => void;
|
||||
showTrackingDetails: () => void;
|
||||
showSnapshot: () => void;
|
||||
addTrigger: () => void;
|
||||
};
|
||||
@ -23,7 +23,7 @@ export default function SearchThumbnailFooter({
|
||||
columns,
|
||||
findSimilar,
|
||||
refreshResults,
|
||||
showObjectLifecycle,
|
||||
showTrackingDetails,
|
||||
showSnapshot,
|
||||
addTrigger,
|
||||
}: SearchThumbnailProps) {
|
||||
@ -61,7 +61,7 @@ export default function SearchThumbnailFooter({
|
||||
searchResult={searchResult}
|
||||
findSimilar={findSimilar}
|
||||
refreshResults={refreshResults}
|
||||
showObjectLifecycle={showObjectLifecycle}
|
||||
showTrackingDetails={showTrackingDetails}
|
||||
showSnapshot={showSnapshot}
|
||||
addTrigger={addTrigger}
|
||||
/>
|
||||
|
||||
@ -47,7 +47,7 @@ type SearchResultActionsProps = {
|
||||
searchResult: SearchResult;
|
||||
findSimilar: () => void;
|
||||
refreshResults: () => void;
|
||||
showObjectLifecycle: () => void;
|
||||
showTrackingDetails: () => void;
|
||||
showSnapshot: () => void;
|
||||
addTrigger: () => void;
|
||||
isContextMenu?: boolean;
|
||||
@ -58,7 +58,7 @@ export default function SearchResultActions({
|
||||
searchResult,
|
||||
findSimilar,
|
||||
refreshResults,
|
||||
showObjectLifecycle,
|
||||
showTrackingDetails,
|
||||
showSnapshot,
|
||||
addTrigger,
|
||||
isContextMenu = false,
|
||||
@ -125,11 +125,11 @@ export default function SearchResultActions({
|
||||
)}
|
||||
{searchResult.data.type == "object" && (
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.viewObjectLifecycle.aria")}
|
||||
onClick={showObjectLifecycle}
|
||||
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||
onClick={showTrackingDetails}
|
||||
>
|
||||
<FaArrowsRotate className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.viewObjectLifecycle.label")}</span>
|
||||
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{config?.semantic_search?.enabled && isContextMenu && (
|
||||
|
||||
@ -95,6 +95,11 @@ export default function ExportDialog({
|
||||
if (response.status == 200) {
|
||||
toast.success(t("export.toast.success"), {
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
<Button>View</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
setName("");
|
||||
setRange(undefined);
|
||||
|
||||
@ -104,6 +104,11 @@ export default function MobileReviewSettingsDrawer({
|
||||
t("export.toast.success", { ns: "components/dialog" }),
|
||||
{
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
<Button>View</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
setName("");
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { ObjectLifecycleSequence, LifecycleClassType } from "@/types/timeline";
|
||||
import { TrackingDetailsSequence, LifecycleClassType } from "@/types/timeline";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
@ -27,7 +27,7 @@ type PathPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
lifecycle_item?: ObjectLifecycleSequence;
|
||||
lifecycle_item?: TrackingDetailsSequence;
|
||||
objectId: string;
|
||||
};
|
||||
|
||||
@ -63,7 +63,7 @@ export default function ObjectTrackOverlay({
|
||||
);
|
||||
|
||||
// Fetch timeline data for each object ID using fixed number of hooks
|
||||
const { data: timelineData } = useSWR<ObjectLifecycleSequence[]>(
|
||||
const { data: timelineData } = useSWR<TrackingDetailsSequence[]>(
|
||||
selectedObjectIds.length > 0
|
||||
? `timeline?source_id=${selectedObjectIds.join(",")}&limit=1000`
|
||||
: null,
|
||||
@ -74,7 +74,7 @@ export default function ObjectTrackOverlay({
|
||||
// Group timeline entries by source_id
|
||||
if (!timelineData) return selectedObjectIds.map(() => []);
|
||||
|
||||
const grouped: Record<string, ObjectLifecycleSequence[]> = {};
|
||||
const grouped: Record<string, TrackingDetailsSequence[]> = {};
|
||||
for (const entry of timelineData) {
|
||||
if (!grouped[entry.source_id]) {
|
||||
grouped[entry.source_id] = [];
|
||||
@ -152,9 +152,9 @@ export default function ObjectTrackOverlay({
|
||||
const eventSequencePoints: PathPoint[] =
|
||||
timelineData
|
||||
?.filter(
|
||||
(event: ObjectLifecycleSequence) => event.data.box !== undefined,
|
||||
(event: TrackingDetailsSequence) => event.data.box !== undefined,
|
||||
)
|
||||
.map((event: ObjectLifecycleSequence) => {
|
||||
.map((event: TrackingDetailsSequence) => {
|
||||
const [left, top, width, height] = event.data.box!;
|
||||
return {
|
||||
x: left + width / 2, // Center x
|
||||
@ -183,22 +183,22 @@ export default function ObjectTrackOverlay({
|
||||
const currentZones =
|
||||
timelineData
|
||||
?.filter(
|
||||
(event: ObjectLifecycleSequence) =>
|
||||
(event: TrackingDetailsSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime,
|
||||
)
|
||||
.sort(
|
||||
(a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) =>
|
||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.zones || [];
|
||||
|
||||
// Get current bounding box
|
||||
const currentBox = timelineData
|
||||
?.filter(
|
||||
(event: ObjectLifecycleSequence) =>
|
||||
(event: TrackingDetailsSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime && event.data.box,
|
||||
)
|
||||
.sort(
|
||||
(a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) =>
|
||||
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.box;
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("objectLifecycle.annotationSettings.offset.toast.success", {
|
||||
t("trackingDetails.annotationSettings.offset.toast.success", {
|
||||
camera,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
|
||||
@ -79,7 +79,7 @@ export function AnnotationSettingsPane({
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("objectLifecycle.annotationSettings.offset.toast.success", {
|
||||
t("trackingDetails.annotationSettings.offset.toast.success", {
|
||||
camera: event?.camera,
|
||||
}),
|
||||
{
|
||||
@ -142,7 +142,7 @@ export function AnnotationSettingsPane({
|
||||
return (
|
||||
<div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2">
|
||||
<Heading as="h4" className="my-2">
|
||||
{t("objectLifecycle.annotationSettings.title")}
|
||||
{t("trackingDetails.annotationSettings.title")}
|
||||
</Heading>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center justify-start gap-2 p-3">
|
||||
@ -152,11 +152,11 @@ export function AnnotationSettingsPane({
|
||||
onCheckedChange={setShowZones}
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="show-zones">
|
||||
{t("objectLifecycle.annotationSettings.showAllZones.title")}
|
||||
{t("trackingDetails.annotationSettings.showAllZones.title")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("objectLifecycle.annotationSettings.showAllZones.desc")}
|
||||
{t("trackingDetails.annotationSettings.showAllZones.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
@ -171,14 +171,14 @@ export function AnnotationSettingsPane({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("objectLifecycle.annotationSettings.offset.label")}
|
||||
{t("trackingDetails.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-5">
|
||||
<PiWarningCircle className="size-24" />
|
||||
<div>
|
||||
<Trans ns="views/explore">
|
||||
objectLifecycle.annotationSettings.offset.desc
|
||||
trackingDetails.annotationSettings.offset.desc
|
||||
</Trans>
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
@ -203,10 +203,10 @@ export function AnnotationSettingsPane({
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans ns="views/explore">
|
||||
objectLifecycle.annotationSettings.offset.millisecondsToOffset
|
||||
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||
</Trans>
|
||||
<div className="mt-2">
|
||||
{t("objectLifecycle.annotationSettings.offset.tips")}
|
||||
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||
</div>
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
@ -105,7 +105,7 @@ export function ObjectPath({
|
||||
<TooltipContent side="top" className="smart-capitalize">
|
||||
{pos.lifecycle_item
|
||||
? getLifecycleItemDescription(pos.lifecycle_item)
|
||||
: t("objectLifecycle.trackedPoint")}
|
||||
: t("trackingDetails.trackedPoint")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
|
||||
@ -20,7 +20,7 @@ import { Event } from "@/types/event";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
||||
import ObjectLifecycle from "./ObjectLifecycle";
|
||||
import TrackingDetails from "./TrackingDetails";
|
||||
import Chip from "@/components/indicators/Chip";
|
||||
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
|
||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||
@ -411,7 +411,7 @@ export default function ReviewDetailDialog({
|
||||
|
||||
{pane == "details" && selectedEvent && (
|
||||
<div className="mt-0 flex size-full flex-col gap-2">
|
||||
<ObjectLifecycle event={selectedEvent} setPane={setPane} />
|
||||
<TrackingDetails event={selectedEvent} setPane={setPane} />
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
@ -544,7 +544,7 @@ function EventItem({
|
||||
</Chip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.viewObjectLifecycle.label")}
|
||||
{t("itemMenu.viewTrackingDetails.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@ -34,8 +34,7 @@ import {
|
||||
FaRegListAlt,
|
||||
FaVideo,
|
||||
} from "react-icons/fa";
|
||||
import { FaRotate } from "react-icons/fa6";
|
||||
import ObjectLifecycle from "./ObjectLifecycle";
|
||||
import TrackingDetails from "./TrackingDetails";
|
||||
import {
|
||||
MobilePage,
|
||||
MobilePageContent,
|
||||
@ -80,12 +79,13 @@ import FaceSelectionDialog from "../FaceSelectionDialog";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { CgTranscript } from "react-icons/cg";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { PiPath } from "react-icons/pi";
|
||||
|
||||
const SEARCH_TABS = [
|
||||
"details",
|
||||
"snapshot",
|
||||
"video",
|
||||
"object_lifecycle",
|
||||
"tracking_details",
|
||||
] as const;
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||
|
||||
@ -160,7 +160,7 @@ export default function SearchDetailDialog({
|
||||
}
|
||||
|
||||
if (search.data.type != "object" || !search.has_clip) {
|
||||
const index = views.indexOf("object_lifecycle");
|
||||
const index = views.indexOf("tracking_details");
|
||||
views.splice(index, 1);
|
||||
}
|
||||
|
||||
@ -235,9 +235,7 @@ export default function SearchDetailDialog({
|
||||
{item == "details" && <FaRegListAlt className="size-4" />}
|
||||
{item == "snapshot" && <FaImage className="size-4" />}
|
||||
{item == "video" && <FaVideo className="size-4" />}
|
||||
{item == "object_lifecycle" && (
|
||||
<FaRotate className="size-4" />
|
||||
)}
|
||||
{item == "tracking_details" && <PiPath className="size-4" />}
|
||||
<div className="smart-capitalize">{t(`type.${item}`)}</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
@ -268,8 +266,8 @@ export default function SearchDetailDialog({
|
||||
/>
|
||||
)}
|
||||
{page == "video" && <VideoTab search={search} />}
|
||||
{page == "object_lifecycle" && (
|
||||
<ObjectLifecycle
|
||||
{page == "tracking_details" && (
|
||||
<TrackingDetails
|
||||
className="w-full overflow-x-hidden"
|
||||
event={search as unknown as Event}
|
||||
fullscreen={true}
|
||||
|
||||
@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Event } from "@/types/event";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { ReviewDetailPaneType } from "@/types/review";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@ -41,6 +41,13 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ObjectPath } from "./ObjectPath";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
@ -48,23 +55,26 @@ import { IoPlayCircleOutline } from "react-icons/io5";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ObjectLifecycleProps = {
|
||||
type TrackingDetailsProps = {
|
||||
className?: string;
|
||||
event: Event;
|
||||
fullscreen?: boolean;
|
||||
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
||||
};
|
||||
|
||||
export default function ObjectLifecycle({
|
||||
export default function TrackingDetails({
|
||||
className,
|
||||
event,
|
||||
fullscreen = false,
|
||||
setPane,
|
||||
}: ObjectLifecycleProps) {
|
||||
}: TrackingDetailsProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
|
||||
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
|
||||
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
|
||||
"timeline",
|
||||
{
|
||||
source_id: event.id,
|
||||
@ -447,7 +457,7 @@ export default function ObjectLifecycle({
|
||||
<div className="relative aspect-video">
|
||||
<div className="flex flex-col items-center justify-center p-20 text-center">
|
||||
<LuFolderX className="size-16" />
|
||||
{t("objectLifecycle.noImageFound")}
|
||||
{t("trackingDetails.noImageFound")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -558,7 +568,7 @@ export default function ObjectLifecycle({
|
||||
}
|
||||
>
|
||||
<div className="text-primary">
|
||||
{t("objectLifecycle.createObjectMask")}
|
||||
{t("trackingDetails.createObjectMask")}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
@ -568,7 +578,7 @@ export default function ObjectLifecycle({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-row items-center justify-between">
|
||||
<Heading as="h4">{t("objectLifecycle.title")}</Heading>
|
||||
<Heading as="h4">{t("trackingDetails.title")}</Heading>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Tooltip>
|
||||
@ -576,7 +586,7 @@ export default function ObjectLifecycle({
|
||||
<Button
|
||||
variant={showControls ? "select" : "default"}
|
||||
className="size-7 p-1.5"
|
||||
aria-label={t("objectLifecycle.adjustAnnotationSettings")}
|
||||
aria-label={t("trackingDetails.adjustAnnotationSettings")}
|
||||
>
|
||||
<LuSettings
|
||||
className="size-5"
|
||||
@ -586,7 +596,7 @@ export default function ObjectLifecycle({
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("objectLifecycle.adjustAnnotationSettings")}
|
||||
{t("trackingDetails.adjustAnnotationSettings")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
@ -594,10 +604,10 @@ export default function ObjectLifecycle({
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
{t("objectLifecycle.scrollViewTips")}
|
||||
{t("trackingDetails.scrollViewTips")}
|
||||
</div>
|
||||
<div className="min-w-20 text-right text-sm text-muted-foreground">
|
||||
{t("objectLifecycle.count", {
|
||||
{t("trackingDetails.count", {
|
||||
first: selectedIndex + 1,
|
||||
second: eventSequence?.length ?? 0,
|
||||
})}
|
||||
@ -605,7 +615,7 @@ export default function ObjectLifecycle({
|
||||
</div>
|
||||
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
|
||||
<div className="-mt-2 mb-2 text-sm text-danger">
|
||||
{t("objectLifecycle.autoTrackingTips")}
|
||||
{t("trackingDetails.autoTrackingTips")}
|
||||
</div>
|
||||
)}
|
||||
{showControls && (
|
||||
@ -756,7 +766,7 @@ export default function ObjectLifecycle({
|
||||
}
|
||||
|
||||
type GetTimelineIconParams = {
|
||||
lifecycleItem: ObjectLifecycleSequence;
|
||||
lifecycleItem: TrackingDetailsSequence;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@ -794,7 +804,7 @@ export function LifecycleIcon({
|
||||
}
|
||||
|
||||
type LifecycleIconRowProps = {
|
||||
item: ObjectLifecycleSequence;
|
||||
item: TrackingDetailsSequence;
|
||||
isActive?: boolean;
|
||||
formattedEventTimestamp: string;
|
||||
ratio: string;
|
||||
@ -816,7 +826,11 @@ function LifecycleIconRow({
|
||||
setSelectedZone,
|
||||
getZoneColor,
|
||||
}: LifecycleIconRowProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const { t } = useTranslation(["views/explore", "components/player"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -846,13 +860,13 @@ function LifecycleIconRow({
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-secondary-foreground md:gap-5">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-primary-variant">
|
||||
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
|
||||
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
||||
</span>
|
||||
<span className="font-medium text-primary">{ratio}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-primary-variant">
|
||||
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")}
|
||||
</span>
|
||||
{areaPx !== undefined && areaPct !== undefined ? (
|
||||
<span className="font-medium text-primary">
|
||||
@ -903,7 +917,69 @@ function LifecycleIconRow({
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
||||
{(config?.plus?.enabled || item.data.box) && (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{config?.plus?.enabled && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={async () => {
|
||||
const resp = await axios.post(
|
||||
`/${item.camera}/plus/${item.timestamp}`,
|
||||
);
|
||||
|
||||
if (resp && resp.status == 200) {
|
||||
toast.success(
|
||||
t("toast.success.submittedFrigatePlus", {
|
||||
ns: "components/player",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t("toast.error.submitFrigatePlusFailed", {
|
||||
ns: "components/player",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("itemMenu.submitToPlus.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{item.data.box && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={() => {
|
||||
setIsOpen(false);
|
||||
setTimeout(() => {
|
||||
navigate(
|
||||
`/settings?page=masksAndZones&camera=${item.camera}&object_mask=${item.data.box}`,
|
||||
);
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
{t("trackingDetails.createObjectMask")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,7 +1,7 @@
|
||||
import { Recording } from "@/types/record";
|
||||
import { DynamicPlayback } from "@/types/playback";
|
||||
import { PreviewController } from "../PreviewPlayer";
|
||||
import { TimeRange, ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
|
||||
import { calculateInpointOffset } from "@/utils/videoUtil";
|
||||
|
||||
type PlayerMode = "playback" | "scrubbing";
|
||||
@ -12,7 +12,7 @@ export class DynamicVideoController {
|
||||
private playerController: HTMLVideoElement;
|
||||
private previewController: PreviewController;
|
||||
private setNoRecording: (noRecs: boolean) => void;
|
||||
private setFocusedItem: (timeline: ObjectLifecycleSequence) => void;
|
||||
private setFocusedItem: (timeline: TrackingDetailsSequence) => void;
|
||||
private playerMode: PlayerMode = "playback";
|
||||
|
||||
// playback
|
||||
@ -29,7 +29,7 @@ export class DynamicVideoController {
|
||||
annotationOffset: number,
|
||||
defaultMode: PlayerMode,
|
||||
setNoRecording: (noRecs: boolean) => void,
|
||||
setFocusedItem: (timeline: ObjectLifecycleSequence) => void,
|
||||
setFocusedItem: (timeline: TrackingDetailsSequence) => void,
|
||||
) {
|
||||
this.camera = camera;
|
||||
this.playerController = playerController;
|
||||
@ -132,7 +132,7 @@ export class DynamicVideoController {
|
||||
});
|
||||
}
|
||||
|
||||
seekToTimelineItem(timeline: ObjectLifecycleSequence) {
|
||||
seekToTimelineItem(timeline: TrackingDetailsSequence) {
|
||||
this.playerController.pause();
|
||||
this.seekToTimestamp(timeline.timestamp + this.annotationOffset);
|
||||
this.setFocusedItem(timeline);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
@ -430,7 +430,8 @@ function EventList({
|
||||
}: EventListProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const { selectedObjectIds, toggleObjectSelection } = useDetailStream();
|
||||
const { selectedObjectIds, setSelectedObjectIds, toggleObjectSelection } =
|
||||
useDetailStream();
|
||||
|
||||
const isSelected = selectedObjectIds.includes(event.id);
|
||||
|
||||
@ -438,13 +439,19 @@ function EventList({
|
||||
|
||||
const handleObjectSelect = (event: Event | undefined) => {
|
||||
if (event) {
|
||||
// onSeek(event.start_time ?? 0);
|
||||
toggleObjectSelection(event.id);
|
||||
setSelectedObjectIds([]);
|
||||
setSelectedObjectIds([event.id]);
|
||||
onSeek(event.start_time);
|
||||
} else {
|
||||
toggleObjectSelection(undefined);
|
||||
setSelectedObjectIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimelineClick = (ts: number, play?: boolean) => {
|
||||
handleObjectSelect(event);
|
||||
onSeek(ts, play);
|
||||
};
|
||||
|
||||
// Clear selection when effectiveTime has passed this event's end_time
|
||||
useEffect(() => {
|
||||
if (isSelected && effectiveTime && event.end_time) {
|
||||
@ -468,11 +475,6 @@ function EventList({
|
||||
isSelected
|
||||
? "bg-secondary-highlight"
|
||||
: "outline-transparent duration-500",
|
||||
!isSelected &&
|
||||
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
||||
(effectiveTime ?? 0) <=
|
||||
(event.end_time ?? event.start_time ?? 0) + 0.5 &&
|
||||
"bg-secondary-highlight",
|
||||
)}
|
||||
>
|
||||
<div className="ml-1.5 flex w-full items-end justify-between">
|
||||
@ -480,12 +482,18 @@ function EventList({
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-full p-1 text-white",
|
||||
isSelected ? "bg-selected" : "bg-muted-foreground",
|
||||
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
||||
(effectiveTime ?? 0) <=
|
||||
(event.end_time ?? event.start_time ?? 0) + 0.5
|
||||
? "bg-selected"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectSelect(isSelected ? undefined : event);
|
||||
onSeek(event.start_time);
|
||||
handleObjectSelect(event);
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
{getIconForLabel(
|
||||
event.sub_label ? event.label + "-verified" : event.label,
|
||||
@ -496,7 +504,8 @@ function EventList({
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSeek(event.start_time ?? 0);
|
||||
onSeek(event.start_time);
|
||||
handleObjectSelect(event);
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
@ -532,8 +541,10 @@ function EventList({
|
||||
<div className="mt-2">
|
||||
<ObjectTimeline
|
||||
eventId={event.id}
|
||||
onSeek={onSeek}
|
||||
onSeek={handleTimelineClick}
|
||||
effectiveTime={effectiveTime}
|
||||
startTime={event.start_time}
|
||||
endTime={event.end_time}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -542,10 +553,11 @@ function EventList({
|
||||
}
|
||||
|
||||
type LifecycleItemProps = {
|
||||
item: ObjectLifecycleSequence;
|
||||
item: TrackingDetailsSequence;
|
||||
isActive?: boolean;
|
||||
onSeek?: (timestamp: number, play?: boolean) => void;
|
||||
effectiveTime?: number;
|
||||
isTimelineActive?: boolean;
|
||||
};
|
||||
|
||||
function LifecycleItem({
|
||||
@ -553,6 +565,7 @@ function LifecycleItem({
|
||||
isActive,
|
||||
onSeek,
|
||||
effectiveTime,
|
||||
isTimelineActive = false,
|
||||
}: LifecycleItemProps) {
|
||||
const { t } = useTranslation("views/events");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -605,7 +618,7 @@ function LifecycleItem({
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
onSeek?.(item.timestamp ?? 0, false);
|
||||
onSeek?.(item.timestamp, false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
|
||||
@ -617,8 +630,9 @@ function LifecycleItem({
|
||||
<div className="relative flex size-4 items-center justify-center">
|
||||
<LuCircle
|
||||
className={cn(
|
||||
"relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none",
|
||||
"relative z-10 size-2.5 fill-secondary-foreground stroke-none",
|
||||
(isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
|
||||
isTimelineActive &&
|
||||
"fill-selected duration-300",
|
||||
)}
|
||||
/>
|
||||
@ -636,14 +650,14 @@ function LifecycleItem({
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-start gap-1">
|
||||
<span className="text-muted-foreground">
|
||||
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
|
||||
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{ratio}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-1">
|
||||
<span className="text-muted-foreground">
|
||||
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")}
|
||||
</span>
|
||||
{areaPx !== undefined && areaPct !== undefined ? (
|
||||
<span className="font-medium text-foreground">
|
||||
@ -673,13 +687,17 @@ function ObjectTimeline({
|
||||
eventId,
|
||||
onSeek,
|
||||
effectiveTime,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
eventId: string;
|
||||
onSeek: (ts: number, play?: boolean) => void;
|
||||
effectiveTime?: number;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
}) {
|
||||
const { t } = useTranslation("views/events");
|
||||
const { data: timeline, isValidating } = useSWR<ObjectLifecycleSequence[]>([
|
||||
const { data: timeline, isValidating } = useSWR<TrackingDetailsSequence[]>([
|
||||
"timeline",
|
||||
{
|
||||
source_id: eventId,
|
||||
@ -698,9 +716,17 @@ function ObjectTimeline({
|
||||
);
|
||||
}
|
||||
|
||||
// Check if current time is within the event's start/stop range
|
||||
const isWithinEventRange =
|
||||
effectiveTime !== undefined &&
|
||||
startTime !== undefined &&
|
||||
endTime !== undefined &&
|
||||
effectiveTime >= startTime &&
|
||||
effectiveTime <= endTime;
|
||||
|
||||
// Calculate how far down the blue line should extend based on effectiveTime
|
||||
const calculateLineHeight = () => {
|
||||
if (!timeline || timeline.length === 0) return 0;
|
||||
if (!timeline || timeline.length === 0 || !isWithinEventRange) return 0;
|
||||
|
||||
const currentTime = effectiveTime ?? 0;
|
||||
|
||||
@ -742,15 +768,19 @@ function ObjectTimeline({
|
||||
);
|
||||
};
|
||||
|
||||
const blueLineHeight = calculateLineHeight();
|
||||
const activeLineHeight = calculateLineHeight();
|
||||
|
||||
return (
|
||||
<div className="-pb-2 relative mx-2">
|
||||
<div className="absolute -top-2 bottom-2 left-2 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
|
||||
{isWithinEventRange && (
|
||||
<div
|
||||
className="absolute left-2 top-2 z-[5] max-h-[calc(100%-1rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
|
||||
style={{ height: `${blueLineHeight}%` }}
|
||||
className={cn(
|
||||
"absolute left-2 top-2 z-[5] max-h-[calc(100%-1rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300",
|
||||
)}
|
||||
style={{ height: `${activeLineHeight}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{timeline.map((event, idx) => {
|
||||
const isActive =
|
||||
@ -763,6 +793,7 @@ function ObjectTimeline({
|
||||
onSeek={onSeek}
|
||||
isActive={isActive}
|
||||
effectiveTime={effectiveTime}
|
||||
isTimelineActive={isWithinEventRange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -212,13 +212,13 @@ const CarouselPrevious = React.forwardRef<
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
aria-label={t("objectLifecycle.carousel.previous")}
|
||||
aria-label={t("trackingDetails.carousel.previous")}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">{t("objectLifecycle.carousel.previous")}</span>
|
||||
<span className="sr-only">{t("trackingDetails.carousel.previous")}</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@ -243,13 +243,13 @@ const CarouselNext = React.forwardRef<
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
aria-label={t("objectLifecycle.carousel.next")}
|
||||
aria-label={t("trackingDetails.carousel.next")}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">{t("objectLifecycle.carousel.next")}</span>
|
||||
<span className="sr-only">{t("trackingDetails.carousel.next")}</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@ export interface DetailStreamContextType {
|
||||
currentTime: number;
|
||||
camera: string;
|
||||
annotationOffset: number; // milliseconds
|
||||
setSelectedObjectIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setAnnotationOffset: (ms: number) => void;
|
||||
toggleObjectSelection: (id: string | undefined) => void;
|
||||
isDetailMode: boolean;
|
||||
@ -69,6 +70,7 @@ export function DetailStreamProvider({
|
||||
camera,
|
||||
annotationOffset,
|
||||
setAnnotationOffset,
|
||||
setSelectedObjectIds,
|
||||
toggleObjectSelection,
|
||||
isDetailMode,
|
||||
};
|
||||
|
||||
@ -10,7 +10,7 @@ export enum LifecycleClassType {
|
||||
PATH_POINT = "path_point",
|
||||
}
|
||||
|
||||
export type ObjectLifecycleSequence = {
|
||||
export type TrackingDetailsSequence = {
|
||||
camera: string;
|
||||
timestamp: number;
|
||||
data: {
|
||||
@ -38,5 +38,5 @@ export type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
lifecycle_item?: ObjectLifecycleSequence;
|
||||
lifecycle_item?: TrackingDetailsSequence;
|
||||
};
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
import { t } from "i18next";
|
||||
import { getTranslatedLabel } from "./i18n";
|
||||
import { capitalizeFirstLetter } from "./stringUtil";
|
||||
|
||||
export function getLifecycleItemDescription(
|
||||
lifecycleItem: ObjectLifecycleSequence,
|
||||
lifecycleItem: TrackingDetailsSequence,
|
||||
) {
|
||||
const rawLabel = Array.isArray(lifecycleItem.data.sub_label)
|
||||
? lifecycleItem.data.sub_label[0]
|
||||
@ -16,23 +16,23 @@ export function getLifecycleItemDescription(
|
||||
|
||||
switch (lifecycleItem.class_type) {
|
||||
case "visible":
|
||||
return t("objectLifecycle.lifecycleItemDesc.visible", {
|
||||
return t("trackingDetails.lifecycleItemDesc.visible", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
});
|
||||
case "entered_zone":
|
||||
return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
|
||||
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
|
||||
});
|
||||
case "active":
|
||||
return t("objectLifecycle.lifecycleItemDesc.active", {
|
||||
return t("trackingDetails.lifecycleItemDesc.active", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
});
|
||||
case "stationary":
|
||||
return t("objectLifecycle.lifecycleItemDesc.stationary", {
|
||||
return t("trackingDetails.lifecycleItemDesc.stationary", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
});
|
||||
@ -43,7 +43,7 @@ export function getLifecycleItemDescription(
|
||||
lifecycleItem.data.attribute == "license_plate"
|
||||
) {
|
||||
title = t(
|
||||
"objectLifecycle.lifecycleItemDesc.attribute.faceOrLicense_plate",
|
||||
"trackingDetails.lifecycleItemDesc.attribute.faceOrLicense_plate",
|
||||
{
|
||||
ns: "views/explore",
|
||||
label,
|
||||
@ -53,7 +53,7 @@ export function getLifecycleItemDescription(
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
|
||||
title = t("trackingDetails.lifecycleItemDesc.attribute.other", {
|
||||
ns: "views/explore",
|
||||
label: lifecycleItem.data.label,
|
||||
attribute: getTranslatedLabel(
|
||||
@ -64,17 +64,17 @@ export function getLifecycleItemDescription(
|
||||
return title;
|
||||
}
|
||||
case "gone":
|
||||
return t("objectLifecycle.lifecycleItemDesc.gone", {
|
||||
return t("trackingDetails.lifecycleItemDesc.gone", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
});
|
||||
case "heard":
|
||||
return t("objectLifecycle.lifecycleItemDesc.heard", {
|
||||
return t("trackingDetails.lifecycleItemDesc.heard", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
});
|
||||
case "external":
|
||||
return t("objectLifecycle.lifecycleItemDesc.external", {
|
||||
return t("trackingDetails.lifecycleItemDesc.external", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
});
|
||||
|
||||
@ -202,6 +202,11 @@ export default function EventView({
|
||||
t("export.toast.success", { ns: "components/dialog" }),
|
||||
{
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
<Button>View</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -232,8 +232,8 @@ function ExploreThumbnailImage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowObjectLifecycle = () => {
|
||||
onSelectSearch(event, false, "object_lifecycle");
|
||||
const handleShowTrackingDetails = () => {
|
||||
onSelectSearch(event, false, "tracking_details");
|
||||
};
|
||||
|
||||
const handleShowSnapshot = () => {
|
||||
@ -251,7 +251,7 @@ function ExploreThumbnailImage({
|
||||
searchResult={event}
|
||||
findSimilar={handleFindSimilar}
|
||||
refreshResults={mutate}
|
||||
showObjectLifecycle={handleShowObjectLifecycle}
|
||||
showTrackingDetails={handleShowTrackingDetails}
|
||||
showSnapshot={handleShowSnapshot}
|
||||
addTrigger={handleAddTrigger}
|
||||
isContextMenu={true}
|
||||
|
||||
@ -644,8 +644,8 @@ export default function SearchView({
|
||||
}
|
||||
}}
|
||||
refreshResults={refresh}
|
||||
showObjectLifecycle={() =>
|
||||
onSelectSearch(value, false, "object_lifecycle")
|
||||
showTrackingDetails={() =>
|
||||
onSelectSearch(value, false, "tracking_details")
|
||||
}
|
||||
showSnapshot={() =>
|
||||
onSelectSearch(value, false, "snapshot")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user