Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
81bbbad584
Merge 21fc654592e58ccefb863e5c7ff7b5b09b2e58f0 into d44340eca611e1fbc27457e39e9ce69b6554125e 2025-11-03 11:01:24 +00:00
Josh Hawkins
d44340eca6
Tracked Object Details pane tweaks (#20762)
* normalize path and points sizes

* fix bounding box display to only show on actual points that have a box

* add support for using snapshots
2025-11-02 06:48:43 -07:00
GuoQing Liu
aff82f809c
feat: add search filter group audio i18n (#20760) 2025-11-02 07:45:24 -06:00
Josh Hawkins
1e50d83d06
create i18n key for list separator and use in zones (#20749) 2025-11-01 12:20:32 -06:00
Josh Hawkins
36fb27ef56
Refactor Tracked Object Details dialog (#20748)
* detail stream settings

* remove old review detail dialog

* change layout

* use detail stream in tracking details

* reusable tabs component

* pass in tabs for desktop

* fix object selection and time updating

* i18n

* aspect fixes

* include tolerance for displaying of path and zone

some browsers (firefox and probably brave) intentionally reduce precision of seeking with currentTime for privacy reasons

* detail stream seeking fixes

* tracking details seeking fixes

* layout tweaks

* add download button back for now

* remove

* remove

* snapshot is now default tab
2025-11-01 09:19:30 -05:00
Nicolas Mowen
9937a7cc3d
Add ability to delete classification models (#20747)
* fix typo

* Add ability to delete classification models
2025-11-01 09:11:24 -05:00
Nicolas Mowen
7aac6b4f21
Don't remove tensorflow on trt (#20743) 2025-10-31 16:17:41 -05:00
Nicolas Mowen
338b681ed0
Various Tweaks (#20742)
* Pull context size from openai models

* Adjust wording based on type of model

* Instruct to not use parenthesis

* Simplify genai config

* Don't use GPU for training
2025-10-31 12:40:31 -06:00
dependabot[bot]
21fc654592
Bump i18next from 24.2.0 to 25.6.0 in /web
Bumps [i18next](https://github.com/i18next/i18next) from 24.2.0 to 25.6.0.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v24.2.0...v25.6.0)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 11:44:04 +00:00
26 changed files with 1173 additions and 1423 deletions

View File

@ -21,7 +21,7 @@ FROM deps AS frigate-tensorrt
ARG PIP_BREAK_SYSTEM_PACKAGES
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
pip3 uninstall -y onnxruntime tensorflow-cpu \
pip3 uninstall -y onnxruntime \
&& pip3 install -U /deps/trt-wheels/*.whl
COPY --from=rootfs / /

View File

@ -13,7 +13,6 @@ nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
tensorflow==2.19.*; platform_machine == 'x86_64'
onnx==1.16.*; platform_machine == 'x86_64'
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
protobuf==3.20.3; platform_machine == 'x86_64'

View File

@ -10,7 +10,6 @@ Object classification allows you to train a custom MobileNetV2 classification mo
Object classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer.
When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training.
## Classes

View File

@ -10,7 +10,6 @@ State classification allows you to train a custom MobileNetV2 classification mod
State classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer.
When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training.
## Classes

View File

@ -804,3 +804,42 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
content={"success": True, "message": "Example generation completed"},
status_code=200,
)
@router.delete(
"/classification/{name}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Delete a classification model",
description="""Deletes a specific classification model and all its associated data.
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
)
def delete_classification_model(request: Request, name: str):
config: FrigateConfig = request.app.frigate_config
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
# Delete the classification model's data directory
model_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
if os.path.exists(model_dir):
shutil.rmtree(model_dir)
return JSONResponse(
content=(
{
"success": True,
"message": f"Successfully deleted classification model {name}.",
}
),
status_code=200,
)

View File

@ -114,7 +114,7 @@ Your response MUST be a flat JSON object with:
## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. Objects with names in parentheses (e.g., "Name (person)") are verified identities. Objects without names (e.g., "Person") are detected but not identified.
Each line represents a detection state, not necessarily unique individuals. Parentheses indicate object type or category, use only the name/label in your response, not the parentheses.
**CRITICAL: When you see both recognized and unrecognized entries of the same type (e.g., "Joe (person)" and "Person"), visually count how many distinct people/objects you actually see based on appearance and clothing. If you observe only ONE person throughout the sequence, use ONLY the recognized name (e.g., "Joe"). The same person may be recognized in some frames but not others. Only describe both if you visually see MULTIPLE distinct people with clearly different appearances.**

View File

@ -18,6 +18,7 @@ class OpenAIClient(GenAIClient):
"""Generative AI client for Frigate using OpenAI."""
provider: OpenAI
context_size: Optional[int] = None
def _init_provider(self):
"""Initialize the client."""
@ -69,5 +70,33 @@ class OpenAIClient(GenAIClient):
def get_context_size(self) -> int:
"""Get the context window size for OpenAI."""
# OpenAI GPT-4 Vision models have 128K token context window
return 128000
if self.context_size is not None:
return self.context_size
try:
models = self.provider.models.list()
for model in models.data:
if model.id == self.genai_config.model:
if hasattr(model, "max_model_len") and model.max_model_len:
self.context_size = model.max_model_len
logger.debug(
f"Retrieved context size {self.context_size} for model {self.genai_config.model}"
)
return self.context_size
except Exception as e:
logger.debug(
f"Failed to fetch model context size from API: {e}, using default"
)
# Default to 128K for ChatGPT models, 8K for others
model_name = self.genai_config.model.lower()
if "gpt" in model_name:
self.context_size = 128000
else:
self.context_size = 8192
logger.debug(
f"Using default context size {self.context_size} for model {self.genai_config.model}"
)
return self.context_size

View File

@ -384,10 +384,10 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
new_object_config["genai"] = {}
for key in global_genai.keys():
if key not in ["enabled", "model", "provider", "base_url", "api_key"]:
new_object_config["genai"][key] = global_genai[key]
else:
if key in ["model", "provider", "base_url", "api_key"]:
new_genai_config[key] = global_genai[key]
else:
new_object_config["genai"][key] = global_genai[key]
config["genai"] = new_genai_config

24
web/package-lock.json generated
View File

@ -43,7 +43,7 @@
"embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"hls.js": "^1.5.20",
"i18next": "^24.2.0",
"i18next": "^25.6.0",
"i18next-http-backend": "^3.0.1",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
@ -198,13 +198,10 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
@ -6502,9 +6499,9 @@
}
},
"node_modules/i18next": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
"integrity": "sha512-ArJJTS1lV6lgKH7yEf4EpgNZ7+THl7bsGxxougPYiXRTJ/Fe1j08/TBpV9QsXCIYVfdE/HWG/xLezJ5DOlfBOA==",
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.0.tgz",
"integrity": "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==",
"funding": [
{
"type": "individual",
@ -6521,7 +6518,7 @@
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
@ -8630,11 +8627,6 @@
"node": ">=8"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

View File

@ -49,7 +49,7 @@
"embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"hls.js": "^1.5.20",
"i18next": "^24.2.0",
"i18next": "^25.6.0",
"i18next-http-backend": "^3.0.1",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",

View File

@ -100,7 +100,8 @@
},
"list": {
"two": "{{0}} and {{1}}",
"many": "{{items}}, and {{last}}"
"many": "{{items}}, and {{last}}",
"separatorWithSpace": ", "
},
"field": {
"optional": "Optional",

View File

@ -5,12 +5,15 @@
"renameCategory": "Rename Class",
"deleteCategory": "Delete Class",
"deleteImages": "Delete Images",
"trainModel": "Train Model"
"trainModel": "Train Model",
"addClassification": "Add Classification",
"deleteModels": "Delete Models"
},
"toast": {
"success": {
"deletedCategory": "Deleted Class",
"deletedImage": "Deleted Images",
"deletedModel": "Successfully deleted {{count}} model(s)",
"categorizedImage": "Successfully Classified Image",
"trainedModel": "Successfully trained model.",
"trainingModel": "Successfully started model training."
@ -18,6 +21,7 @@
"error": {
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
"trainingFailed": "Failed to start model training: {{errorMessage}}"
}
@ -26,6 +30,11 @@
"title": "Delete Class",
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
},
"deleteModel": {
"title": "Delete Classification Model",
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
},
"deleteDatasetImages": {
"title": "Delete Dataset Images",
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
@ -52,6 +61,10 @@
},
"categorizeImageAs": "Classify Image As:",
"categorizeImage": "Classify Image",
"menu": {
"objects": "Objects",
"states": "States"
},
"noModels": {
"object": {
"title": "No Object Classification Models",
@ -86,6 +99,7 @@
"classificationSubLabel": "Sub Label",
"classificationAttribute": "Attribute",
"classes": "Classes",
"states": "States",
"classesTip": "Learn about classes",
"classesStateDesc": "Define the different states your camera area can be in. For example: 'open' and 'closed' for a garage door.",
"classesObjectDesc": "Define the different categories to classify detected objects into. For example: 'delivery_person', 'resident', 'stranger' for person classification.",

View File

@ -33,6 +33,7 @@
"type": {
"details": "details",
"snapshot": "snapshot",
"thumbnail": "thumbnail",
"video": "video",
"object_lifecycle": "object lifecycle"
},
@ -41,7 +42,7 @@
"noImageFound": "No image found for this timestamp.",
"createObjectMask": "Create Object Mask",
"adjustAnnotationSettings": "Adjust annotation settings",
"scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.",
"scrollViewTips": "Click to view the significant moments of this object's lifecycle.",
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
"count": "{{first}} of {{second}}",
"trackedPoint": "Tracked Point",

View File

@ -394,7 +394,9 @@ export default function Step1NameAndDefine({
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<FormLabel className="text-primary-variant">
{t("wizard.step1.classes")}
{watchedModelType === "state"
? t("wizard.step1.states")
: t("wizard.step1.classes")}
</FormLabel>
<Popover>
<PopoverTrigger asChild>

View File

@ -348,6 +348,26 @@ export function GeneralFilterContent({
onClose,
}: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allAudioListenLabels = useMemo<string[]>(() => {
if (!config) {
return [];
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
if (camera?.audio?.enabled) {
camera.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return [...labels].sort();
}, [config]);
return (
<>
<div className="overflow-x-hidden">
@ -373,7 +393,10 @@ export function GeneralFilterContent({
{allLabels.map((item) => (
<FilterSwitch
key={item}
label={getTranslatedLabel(item)}
label={getTranslatedLabel(
item,
allAudioListenLabels.includes(item) ? "audio" : "object",
)}
isChecked={currentLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {

View File

@ -13,6 +13,9 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { Event } from "@/types/event";
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
const TOLERANCE = 0.01;
type ObjectTrackOverlayProps = {
camera: string;
showBoundingBoxes?: boolean;
@ -55,6 +58,47 @@ export default function ObjectTrackOverlay({
const effectiveCurrentTime = currentTime - annotationOffset / 1000;
const {
pathStroke,
pointRadius,
pointStroke,
zoneStroke,
boxStroke,
highlightRadius,
} = useMemo(() => {
const BASE_WIDTH = 1280;
const BASE_HEIGHT = 720;
const BASE_PATH_STROKE = 5;
const BASE_POINT_RADIUS = 7;
const BASE_POINT_STROKE = 3;
const BASE_ZONE_STROKE = 5;
const BASE_BOX_STROKE = 5;
const BASE_HIGHLIGHT_RADIUS = 5;
const scale = Math.sqrt(
(videoWidth * videoHeight) / (BASE_WIDTH * BASE_HEIGHT),
);
const pathStroke = Math.max(1, Math.round(BASE_PATH_STROKE * scale));
const pointRadius = Math.max(2, Math.round(BASE_POINT_RADIUS * scale));
const pointStroke = Math.max(1, Math.round(BASE_POINT_STROKE * scale));
const zoneStroke = Math.max(1, Math.round(BASE_ZONE_STROKE * scale));
const boxStroke = Math.max(1, Math.round(BASE_BOX_STROKE * scale));
const highlightRadius = Math.max(
2,
Math.round(BASE_HIGHLIGHT_RADIUS * scale),
);
return {
pathStroke,
pointRadius,
pointStroke,
zoneStroke,
boxStroke,
highlightRadius,
};
}, [videoWidth, videoHeight]);
// Fetch all event data in a single request (CSV ids)
const { data: eventsData } = useSWR<Event[]>(
selectedObjectIds.length > 0
@ -166,41 +210,50 @@ export default function ObjectTrackOverlay({
}) || [];
// show full path once current time has reached the object's start time
const combinedPoints = [...savedPathPoints, ...eventSequencePoints]
.sort((a, b) => a.timestamp - b.timestamp)
.filter(
// event.start_time is in DETECT stream time, so convert it to record stream time for comparison
const eventStartTimeRecord =
(eventData?.start_time ?? 0) + annotationOffset / 1000;
const allPoints = [...savedPathPoints, ...eventSequencePoints].sort(
(a, b) => a.timestamp - b.timestamp,
);
const combinedPoints = allPoints.filter(
(point) =>
currentTime >= (eventData?.start_time ?? 0) &&
point.timestamp >= (eventData?.start_time ?? 0) &&
point.timestamp <= (eventData?.end_time ?? Infinity),
currentTime >= eventStartTimeRecord - TOLERANCE &&
point.timestamp <= effectiveCurrentTime + TOLERANCE,
);
// Get color for this object
const label = eventData?.label || "unknown";
const color = getObjectColor(label, objectId);
// Get current zones
// zones (with tolerance for browsers with seek precision by-design issues)
const currentZones =
timelineData
?.filter(
(event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime,
event.timestamp <= effectiveCurrentTime + TOLERANCE,
)
.sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp,
)[0]?.data?.zones || [];
// Get current bounding box
const currentBox = timelineData
?.filter(
(event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime && event.data.box,
)
// bounding box - only show if there's a timeline event at/near the current time with a box
// Search all timeline events (not just those before current time) to find one matching the seek position
const nearbyTimelineEvent = timelineData
?.filter((event: TrackingDetailsSequence) => event.data.box)
.sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp,
)[0]?.data?.box;
Math.abs(a.timestamp - effectiveCurrentTime) -
Math.abs(b.timestamp - effectiveCurrentTime),
)
.find(
(event: TrackingDetailsSequence) =>
Math.abs(event.timestamp - effectiveCurrentTime) <= TOLERANCE,
);
const currentBox = nearbyTimelineEvent?.data?.box;
return {
objectId,
@ -221,6 +274,7 @@ export default function ObjectTrackOverlay({
getObjectColor,
config,
camera,
annotationOffset,
]);
// Collect all zones across all objects
@ -274,9 +328,10 @@ export default function ObjectTrackOverlay({
const handlePointClick = useCallback(
(timestamp: number) => {
onSeekToTime?.(timestamp, false);
// Convert detect stream timestamp to record stream timestamp before seeking
onSeekToTime?.(timestamp + annotationOffset / 1000, false);
},
[onSeekToTime],
[onSeekToTime, annotationOffset],
);
const zonePolygons = useMemo(() => {
@ -324,7 +379,7 @@ export default function ObjectTrackOverlay({
points={zone.points}
fill={zone.fill}
stroke={zone.stroke}
strokeWidth="5"
strokeWidth={zoneStroke}
opacity="0.7"
/>
))}
@ -344,7 +399,7 @@ export default function ObjectTrackOverlay({
d={generateStraightPath(absolutePositions)}
fill="none"
stroke={objData.color}
strokeWidth="5"
strokeWidth={pathStroke}
strokeLinecap="round"
strokeLinejoin="round"
/>
@ -356,13 +411,13 @@ export default function ObjectTrackOverlay({
<circle
cx={pos.x}
cy={pos.y}
r="7"
r={pointRadius}
fill={getPointColor(
objData.color,
pos.lifecycle_item?.class_type,
)}
stroke="white"
strokeWidth="3"
strokeWidth={pointStroke}
style={{ cursor: onSeekToTime ? "pointer" : "default" }}
onClick={() => handlePointClick(pos.timestamp)}
/>
@ -391,7 +446,7 @@ export default function ObjectTrackOverlay({
height={objData.currentBox[3] * videoHeight}
fill="none"
stroke={objData.color}
strokeWidth="5"
strokeWidth={boxStroke}
opacity="0.9"
/>
<circle
@ -403,10 +458,10 @@ export default function ObjectTrackOverlay({
(objData.currentBox[1] + objData.currentBox[3]) *
videoHeight
}
r="5"
r={highlightRadius}
fill="rgb(255, 255, 0)" // yellow highlight
stroke={objData.color}
strokeWidth="5"
strokeWidth={boxStroke}
opacity="1"
/>
</g>

View File

@ -91,8 +91,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<div className="w-full flex-1 landscape:flex">
<Slider
value={[annotationOffset]}
min={-1500}
max={1500}
min={-2500}
max={2500}
step={50}
onValueChange={handleChange}
/>

View File

@ -1,577 +0,0 @@
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "../../ui/sheet";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api";
import {
ReviewDetailPaneType,
ReviewSegment,
ThreatLevel,
} from "@/types/review";
import { Event } from "@/types/event";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
import TrackingDetails from "./TrackingDetails";
import Chip from "@/components/indicators/Chip";
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { FaArrowsRotate } from "react-icons/fa6";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { baseUrl } from "@/api/baseUrl";
import { shareOrCopy } from "@/utils/browserUtil";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { LuSearch } from "react-icons/lu";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type ReviewDetailDialogProps = {
review?: ReviewSegment;
setReview: (review: ReviewSegment | undefined) => void;
};
export default function ReviewDetailDialog({
review,
setReview,
}: ReviewDetailDialogProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const navigate = useNavigate();
// upload
const [upload, setUpload] = useState<Event>();
// data
const { data: events } = useSWR<Event[]>(
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
);
const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
const aiThreatLevel = useMemo(() => {
if (
!aiAnalysis ||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
) {
return "None";
}
let concerns = "";
switch (aiAnalysis.potential_threat_level) {
case ThreatLevel.SUSPICIOUS:
concerns = `${t("suspiciousActivity", { ns: "views/events" })}\n`;
break;
case ThreatLevel.DANGER:
concerns = `${t("threateningActivity", { ns: "views/events" })}\n`;
break;
}
(aiAnalysis.other_concerns ?? []).forEach((c) => {
concerns += `${c}\n`;
});
return concerns || "None";
}, [aiAnalysis, t]);
const hasMismatch = useMemo(() => {
if (!review || !events) {
return false;
}
return events.length != review?.data.detections.length;
}, [review, events]);
const missingObjects = useMemo(() => {
if (!review || !events) {
return [];
}
const detectedIds = review.data.detections;
const missing = Array.from(
new Set(
events
.filter((event) => !detectedIds.includes(event.id))
.map((event) => event.label),
),
);
return missing;
}, [review, events]);
const formattedDate = useFormattedTimestamp(
review?.start_time ?? 0,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
ns: "common",
})
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
ns: "common",
}),
config?.ui.timezone,
);
// content
const [selectedEvent, setSelectedEvent] = useState<Event>();
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
// dialog and mobile page
const [isOpen, setIsOpen] = useState(review != undefined);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (!open) {
// short timeout to allow the mobile page animation
// to complete before updating the state
setTimeout(() => {
setReview(undefined);
setSelectedEvent(undefined);
setPane("overview");
}, 300);
}
},
[setReview, setIsOpen],
);
useEffect(() => {
setIsOpen(review != undefined);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [review]);
// keyboard listener
useKeyboardListener(["Esc"], (key, modifiers) => {
if (key == "Esc" && modifiers.down && !modifiers.repeat) {
setIsOpen(false);
}
return true;
});
const Overlay = isDesktop ? Sheet : MobilePage;
const Content = isDesktop ? SheetContent : MobilePageContent;
const Header = isDesktop ? SheetHeader : MobilePageHeader;
const Title = isDesktop ? SheetTitle : MobilePageTitle;
const Description = isDesktop ? SheetDescription : MobilePageDescription;
if (!review) {
return;
}
return (
<>
<Overlay
open={isOpen ?? false}
onOpenChange={handleOpenChange}
enableHistoryBack={true}
>
<FrigatePlusDialog
upload={upload}
onClose={() => setUpload(undefined)}
onEventUploaded={() => {
if (upload) {
upload.plus_id = "new_upload";
}
}}
/>
<Content
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && pane == "overview"
? "sm:max-w-xl"
: "pt-2 sm:max-w-4xl",
isMobile && "px-4",
)}
>
<span tabIndex={0} className="sr-only" />
{pane == "overview" && (
<Header className="justify-center">
<Title>{t("details.item.title")}</Title>
<Description className="sr-only">
{t("details.item.desc")}
</Description>
<div
className={cn(
"absolute flex gap-2 lg:flex-col",
isDesktop && "right-1 top-8",
isMobile && "right-0 top-3",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("details.item.button.share")}
size="sm"
onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`)
}
>
<FaShareAlt className="size-4 text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.share")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<DownloadVideoButton
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
camera={review.camera}
startTime={review.start_time}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</Header>
)}
{pane == "overview" && (
<div className="flex flex-col gap-5 md:mt-3">
{aiAnalysis != undefined && (
<div
className={cn(
"flex h-full w-full flex-col gap-2 rounded-md bg-card p-2",
isDesktop && "m-2 w-[90%]",
)}
>
{t("aiAnalysis.title")}
<div className="text-sm text-primary/40">
{t("details.description.label")}
</div>
<div className="text-sm">{aiAnalysis.scene}</div>
<div className="text-sm text-primary/40">
{t("details.score.label")}
</div>
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
<div className="text-sm text-primary/40">
{t("concerns.label")}
</div>
<div className="text-sm">{aiThreatLevel}</div>
</div>
)}
<div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.camera")}
</div>
<div className="text-sm smart-capitalize">
<CameraNameLabel camera={review.camera} />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.timestamp")}
</div>
<div className="text-sm">{formattedDate}</div>
</div>
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
<div className="text-sm text-primary/40">
{t("details.objects")}
</div>
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
{events?.map((event) => {
return (
<div
key={event.id}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{getIconForLabel(
event.label,
"size-3 text-primary",
)}
{event.sub_label ??
event.label.replaceAll("_", " ")}{" "}
({Math.round(event.data.top_score * 100)}%)
<Tooltip>
<TooltipTrigger>
<div
className="cursor-pointer"
onClick={() => {
navigate(`/explore?event_id=${event.id}`);
}}
>
<LuSearch className="size-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.viewInExplore")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
);
})}
</div>
</div>
{review.data.zones.length > 0 && (
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.zones")}
</div>
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
{review.data.zones.map((zone) => {
return (
<div
key={zone}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{zone.replaceAll("_", " ")}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{hasMismatch && (
<div className="p-4 text-center text-sm">
{(() => {
const detectedCount = Math.abs(
(events?.length ?? 0) -
(review?.data.detections.length ?? 0),
);
return t("details.item.tips.mismatch", {
count: detectedCount,
});
})()}
{missingObjects.length > 0 && (
<div className="mt-2">
<Trans
ns="views/explore"
values={{
objects: missingObjects
.map((x) => getTranslatedLabel(x))
.join(", "),
}}
>
details.item.tips.hasMissingObjects
</Trans>
</div>
)}
</div>
)}
<div className="relative flex size-full flex-col gap-2">
{events?.map((event) => (
<EventItem
key={event.id}
event={event}
setPane={setPane}
setSelectedEvent={setSelectedEvent}
setUpload={setUpload}
/>
))}
</div>
</div>
)}
{pane == "details" && selectedEvent && (
<div className="mt-0 flex size-full flex-col gap-2">
<TrackingDetails event={selectedEvent} setPane={setPane} />
</div>
)}
</Content>
</Overlay>
</>
);
}
type EventItemProps = {
event: Event;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
setSelectedEvent: React.Dispatch<React.SetStateAction<Event | undefined>>;
setUpload?: React.Dispatch<React.SetStateAction<Event | undefined>>;
};
function EventItem({
event,
setPane,
setSelectedEvent,
setUpload,
}: EventItemProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const apiHost = useApiHost();
const imgRef = useRef(null);
const [hovered, setHovered] = useState(isMobile);
const navigate = useNavigate();
return (
<>
<div
className={cn(
"relative mr-auto",
!event.has_snapshot && "flex flex-row items-center justify-center",
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
key={event.id}
>
{event.has_snapshot && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
<img
ref={imgRef}
className={cn(
"select-none rounded-lg object-contain transition-opacity",
)}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
/>
{hovered && (
<div>
<div
className={cn("absolute right-1 top-1 flex items-center gap-2")}
>
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</Tooltip>
{event.has_snapshot &&
event.plus_id == undefined &&
event.data.type == "object" &&
config?.plus.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setUpload?.(event);
}}
>
<FrigatePlusIcon className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_clip && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setPane("details");
setSelectedEvent(event);
}}
>
<FaArrowsRotate className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.viewTrackingDetails.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_snapshot && config?.semantic_search.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
navigate(
`/explore?search_type=similarity&event_id=${event.id}`,
);
}}
>
<FaImages className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
</div>
</>
);
}

View File

@ -31,10 +31,9 @@ import {
FaDownload,
FaHistory,
FaImage,
FaRegListAlt,
FaVideo,
} from "react-icons/fa";
import TrackingDetails from "./TrackingDetails";
import { TrackingDetails } from "./TrackingDetails";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import {
MobilePage,
MobilePageContent,
@ -80,13 +79,9 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { PiPath } from "react-icons/pi";
import Heading from "@/components/ui/heading";
const SEARCH_TABS = [
"details",
"snapshot",
"video",
"tracking_details",
] as const;
const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = {
@ -109,6 +104,7 @@ export default function SearchDetailDialog({
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const apiHost = useApiHost();
// tabs
@ -149,16 +145,6 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS];
if (!search.has_snapshot) {
const index = views.indexOf("snapshot");
views.splice(index, 1);
}
if (!search.has_clip) {
const index = views.indexOf("video");
views.splice(index, 1);
}
if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("tracking_details");
views.splice(index, 1);
@ -173,10 +159,50 @@ export default function SearchDetailDialog({
}
if (!searchTabs.includes(pageToggle)) {
setSearchPage("details");
setSearchPage("snapshot");
}
}, [pageToggle, searchTabs, setSearchPage]);
// Tabs component for reuse
const tabsComponent = (
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "snapshot" && <FaImage className="size-4" />}
{item == "tracking_details" && <PiPath className="size-4" />}
<div className="smart-capitalize">
{item === "snapshot"
? search?.has_snapshot
? t("type.snapshot")
: t("type.thumbnail")
: t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
);
if (!search) {
return;
}
@ -190,6 +216,12 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription;
return (
<DetailStreamProvider
isDetailMode={true}
currentTime={(search as unknown as Event)?.start_time ?? 0}
camera={(search as unknown as Event)?.camera ?? ""}
initialSelectedObjectIds={[(search as unknown as Event).id as string]}
>
<Overlay
open={isOpen}
onOpenChange={handleOpenChange}
@ -200,6 +232,9 @@ export default function SearchDetailDialog({
"scrollbar-container overflow-y-auto",
isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isDesktop &&
page == "tracking_details" &&
"lg:max-w-[75%] xl:max-w-[80%]",
isMobile && "px-4",
)}
>
@ -209,6 +244,71 @@ export default function SearchDetailDialog({
{t("trackedObjectDetails")}
</Description>
</Header>
{isDesktop ? (
page === "tracking_details" ? (
<TrackingDetails
className="size-full"
event={search as unknown as Event}
tabs={tabsComponent}
/>
) : (
<div className="flex h-full gap-4 overflow-hidden">
<div
className={cn(
"scrollbar-container flex-[3] overflow-y-hidden",
page === "snapshot" && !search.has_snapshot && "flex-[2]",
)}
>
{page === "snapshot" && search.has_snapshot && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
search.plus_id = "new_upload";
}}
/>
)}
{page === "snapshot" && !search.has_snapshot && (
<img
className="size-full select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
</div>
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
{tabsComponent}
<div className="scrollbar-container flex-1 overflow-y-auto">
{page == "snapshot" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
)}
</div>
</div>
</div>
)
) : (
<>
<ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
>
@ -227,37 +327,34 @@ export default function SearchDetailDialog({
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "details" && <FaRegListAlt className="size-4" />}
{item == "snapshot" && <FaImage className="size-4" />}
{item == "video" && <FaVideo className="size-4" />}
{item == "tracking_details" && <PiPath className="size-4" />}
<div className="smart-capitalize">{t(`type.${item}`)}</div>
{item == "tracking_details" && (
<PiPath className="size-4" />
)}
<div className="smart-capitalize">
{t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
{page == "details" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
/>
)}
{page == "snapshot" && (
<>
{search.has_snapshot && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled",
plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
@ -265,17 +362,42 @@ export default function SearchDetailDialog({
}}
/>
)}
{page == "video" && <VideoTab search={search} />}
{page == "tracking_details" && (
<TrackingDetails
className="w-full overflow-x-hidden"
event={search as unknown as Event}
fullscreen={true}
setPane={() => {}}
{page == "snapshot" && !search.has_snapshot && (
<img
className="w-full select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
<Heading as="h3" className="mt-2 smart-capitalize">
{t("type.details")}
</Heading>
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
</>
)}
{page == "tracking_details" && (
<TrackingDetails event={search as unknown as Event} />
)}
</>
)}
</Content>
</Overlay>
</DetailStreamProvider>
);
}
@ -285,6 +407,7 @@ type ObjectDetailsTabProps = {
setSearch: (search: SearchResult | undefined) => void;
setSimilarity?: () => void;
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
showThumbnail?: boolean;
};
function ObjectDetailsTab({
search,
@ -292,6 +415,7 @@ function ObjectDetailsTab({
setSearch,
setSimilarity,
setInputFocused,
showThumbnail = true,
}: ObjectDetailsTabProps) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
@ -873,6 +997,7 @@ function ObjectDetailsTab({
<div className="text-sm">{formattedDate}</div>
</div>
</div>
{showThumbnail && (
<div className="flex w-full flex-col gap-2 pl-6">
<img
className="aspect-video select-none rounded-lg object-contain transition-opacity"
@ -888,7 +1013,10 @@ function ObjectDetailsTab({
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
<div
className={cn("flex w-full flex-row gap-2", isMobile && "flex-col")}
className={cn(
"flex w-full flex-row gap-2",
isMobile && "flex-col",
)}
>
{config?.semantic_search.enabled &&
setSimilarity != undefined &&
@ -933,6 +1061,7 @@ function ObjectDetailsTab({
)}
</div>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
{config?.cameras[search.camera].objects.genai.enabled &&
@ -1167,7 +1296,7 @@ export function ObjectSnapshotTab({
search.label != "on_demand" && (
<Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className={cn("flex flex-col space-y-3")}>
<div className={cn("flex max-w-sm flex-col space-y-3")}>
<div className={"text-lg leading-none"}>
{t("explore.plus.submitToPlus.label")}
</div>
@ -1176,7 +1305,7 @@ export function ObjectSnapshotTab({
</div>
</div>
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:flex-1 md:justify-end">
{state == "reviewing" && (
<>
<div>

View File

@ -5,29 +5,11 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import { TrackingDetailsSequence } from "@/types/timeline";
import Heading from "@/components/ui/heading";
import { ReviewDetailPaneType } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { getIconForLabel } from "@/utils/iconUtil";
import {
LuCircle,
LuCircleDot,
LuEar,
LuFolderX,
LuPlay,
LuSettings,
LuTruck,
} from "react-icons/lu";
import { IoMdArrowRoundBack, IoMdExit } from "react-icons/io";
import {
MdFaceUnlock,
MdOutlineLocationOn,
MdOutlinePictureInPictureAlt,
} from "react-icons/md";
import { LuCircle, LuFolderX, LuSettings } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { useApiHost } from "@/api";
import { isDesktop, isIOS, isSafari } from "react-device-detect";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import {
Tooltip,
TooltipContent,
@ -35,12 +17,10 @@ import {
} from "@/components/ui/tooltip";
import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
import { baseUrl } from "@/api/baseUrl";
import { REVIEW_PADDING } from "@/types/review";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import {
DropdownMenu,
DropdownMenuTrigger,
@ -49,30 +29,53 @@ import {
DropdownMenuPortal,
} from "@/components/ui/dropdown-menu";
import { Link, useNavigate } from "react-router-dom";
import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
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";
import { useDetailStream } from "@/context/detail-stream-context";
import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect";
import Chip from "@/components/indicators/Chip";
import { FaDownload, FaHistory } from "react-icons/fa";
import { useApiHost } from "@/api";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import ObjectTrackOverlay from "../ObjectTrackOverlay";
type TrackingDetailsProps = {
className?: string;
event: Event;
fullscreen?: boolean;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
tabs?: React.ReactNode;
};
export default function TrackingDetails({
export function TrackingDetails({
className,
event,
fullscreen = false,
setPane,
tabs,
}: TrackingDetailsProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const { t } = useTranslation(["views/explore"]);
const navigate = useNavigate();
const apiHost = useApiHost();
const imgRef = useRef<HTMLImageElement | null>(null);
const [imgLoaded, setImgLoaded] = useState(false);
const [displaySource, _setDisplaySource] = useState<"video" | "image">(
"video",
);
const { setSelectedObjectIds, annotationOffset, setAnnotationOffset } =
useDetailStream();
// manualOverride holds a record-stream timestamp explicitly chosen by the
// user (eg, clicking a lifecycle row). When null we display `currentTime`.
const [manualOverride, setManualOverride] = useState<number | null>(null);
// event.start_time is detect time, convert to record, then subtract padding
const [currentTime, setCurrentTime] = useState(
(event.start_time ?? 0) + annotationOffset / 1000 - REVIEW_PADDING,
);
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
"timeline",
@ -82,16 +85,21 @@ export default function TrackingDetails({
]);
const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost();
const navigate = useNavigate();
const [imgLoaded, setImgLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
// Use manualOverride (set when seeking in image mode) if present so
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
// back to currentTime used for video mode.
const effectiveTime = useMemo(() => {
const displayedRecordTime = manualOverride ?? currentTime;
return displayedRecordTime - annotationOffset / 1000;
}, [manualOverride, currentTime, annotationOffset]);
const [selectedZone, setSelectedZone] = useState("");
const [lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const containerRef = useRef<HTMLDivElement | null>(null);
const [_selectedZone, setSelectedZone] = useState("");
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [showControls, setShowControls] = useState(false);
const [showZones, setShowZones] = useState(true);
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
const aspectRatio = useMemo(() => {
if (!config) {
@ -120,178 +128,37 @@ export default function TrackingDetails({
[config, event],
);
const getObjectColor = useCallback(
(label: string) => {
const objectColor = config?.model?.colormap[label];
if (objectColor) {
const reversed = [...objectColor].reverse();
return reversed;
}
},
[config],
);
// Set the selected object ID in the context so ObjectTrackOverlay can display it
useEffect(() => {
setSelectedObjectIds([event.id]);
}, [event.id, setSelectedObjectIds]);
const getZonePolygon = useCallback(
(zoneName: string) => {
if (!imgRef.current || !config) {
const handleLifecycleClick = useCallback(
(item: TrackingDetailsSequence) => {
if (!videoRef.current && !imgRef.current) return;
// Convert lifecycle timestamp (detect stream) to record stream time
const targetTimeRecord = item.timestamp + annotationOffset / 1000;
if (displaySource === "image") {
// For image mode: set a manual override timestamp and update
// currentTime so overlays render correctly.
setManualOverride(targetTimeRecord);
setCurrentTime(targetTimeRecord);
return;
}
const zonePoints =
config?.cameras[event.camera].zones[zoneName].coordinates;
const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect();
return zonePoints
.split(",")
.map(Number.parseFloat)
.reduce((acc, value, index) => {
const isXCoordinate = index % 2 === 0;
const coordinate = isXCoordinate
? value * imgRect.width
: value * imgRect.height;
acc.push(coordinate);
return acc;
}, [] as number[])
.join(",");
},
[config, imgRef, event],
);
// For video mode: convert to video-relative time and seek player
const eventStartRecord =
(event.start_time ?? 0) + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = targetTimeRecord - videoStartTime;
const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null);
const [attributeBoxStyle, setAttributeBoxStyle] =
useState<React.CSSProperties | null>(null);
const configAnnotationOffset = useMemo(() => {
if (!config) {
return 0;
}
return config.cameras[event.camera]?.detect?.annotation_offset || 0;
}, [config, event]);
const [annotationOffset, setAnnotationOffset] = useState<number>(
configAnnotationOffset,
);
const savedPathPoints = useMemo(() => {
return (
event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({
x: coords[0],
y: coords[1],
timestamp,
lifecycle_item: undefined,
})) || []
);
}, [event.data.path_data]);
const eventSequencePoints = useMemo(() => {
return (
eventSequence
?.filter((event) => event.data.box !== undefined)
.map((event) => {
const [left, top, width, height] = event.data.box!;
return {
x: left + width / 2, // Center x-coordinate
y: top + height, // Bottom y-coordinate
timestamp: event.timestamp,
lifecycle_item: event,
};
}) || []
);
}, [eventSequence]);
// final object path with timeline points included
const pathPoints = useMemo(() => {
// don't display a path if we don't have any saved path points
if (
savedPathPoints.length === 0 ||
config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config
)
return [];
return [...savedPathPoints, ...eventSequencePoints].sort(
(a, b) => a.timestamp - b.timestamp,
);
}, [savedPathPoints, eventSequencePoints, config, event]);
const [timeIndex, setTimeIndex] = useState(0);
const handleSetBox = useCallback(
(box: number[], attrBox: number[] | undefined) => {
if (imgRef.current && Array.isArray(box) && box.length === 4) {
const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect();
const style = {
left: `${box[0] * imgRect.width}px`,
top: `${box[1] * imgRect.height}px`,
width: `${box[2] * imgRect.width}px`,
height: `${box[3] * imgRect.height}px`,
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
};
if (attrBox) {
const attrStyle = {
left: `${attrBox[0] * imgRect.width}px`,
top: `${attrBox[1] * imgRect.height}px`,
width: `${attrBox[2] * imgRect.width}px`,
height: `${attrBox[3] * imgRect.height}px`,
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
};
setAttributeBoxStyle(attrStyle);
} else {
setAttributeBoxStyle(null);
}
setBoxStyle(style);
if (videoRef.current) {
videoRef.current.currentTime = relativeTime;
}
},
[imgRef, event, getObjectColor],
);
// image
const [src, setSrc] = useState(
`${apiHost}api/${event.camera}/recordings/${event.start_time + annotationOffset / 1000}/snapshot.jpg?height=500`,
);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (timeIndex) {
const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`;
setSrc(newSrc);
}
setImgLoaded(false);
setHasError(false);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeIndex, annotationOffset]);
// carousels
// Selected lifecycle item index; -1 when viewing a path-only point
const handlePathPointClick = useCallback(
(index: number) => {
if (!eventSequence) return;
const sequenceIndex = eventSequence.findIndex(
(item) => item.timestamp === pathPoints[index].timestamp,
);
if (sequenceIndex !== -1) {
setTimeIndex(eventSequence[sequenceIndex].timestamp);
handleSetBox(
eventSequence[sequenceIndex]?.data.box ?? [],
eventSequence[sequenceIndex]?.data?.attribute_box,
);
setLifecycleZones(eventSequence[sequenceIndex]?.data.zones);
} else {
// click on a normal path point, not a lifecycle point
setTimeIndex(pathPoints[index].timestamp);
setBoxStyle(null);
setLifecycleZones([]);
}
},
[eventSequence, pathPoints, handleSetBox],
[event.start_time, annotationOffset, displaySource],
);
const formattedStart = config
@ -328,53 +195,54 @@ export default function TrackingDetails({
useEffect(() => {
if (!eventSequence || eventSequence.length === 0) return;
// If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp
if (!timeIndex) {
setTimeIndex(eventSequence[0].timestamp);
handleSetBox(
eventSequence[0]?.data.box ?? [],
eventSequence[0]?.data?.attribute_box,
);
setLifecycleZones(eventSequence[0]?.data.zones);
}
}, [eventSequence, timeIndex, handleSetBox]);
}, [eventSequence]);
// When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear
useEffect(() => {
if (!eventSequence || timeIndex == null) return;
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
if (idx !== -1) {
if (imgLoaded) {
handleSetBox(
eventSequence[idx]?.data.box ?? [],
eventSequence[idx]?.data?.attribute_box,
);
if (seekToTimestamp === null) return;
if (displaySource === "image") {
// For image mode, set the manual override so the snapshot updates to
// the exact record timestamp.
setManualOverride(seekToTimestamp);
setSeekToTimestamp(null);
return;
}
setLifecycleZones(eventSequence[idx]?.data.zones);
} else {
// Non-lifecycle point (e.g., saved path point)
setBoxStyle(null);
setLifecycleZones([]);
// seekToTimestamp is a record stream timestamp
// event.start_time is detect stream time, convert to record
// The video clip starts at (eventStartRecord - REVIEW_PADDING)
if (!videoRef.current) return;
const eventStartRecord = event.start_time + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = seekToTimestamp - videoStartTime;
if (relativeTime >= 0) {
videoRef.current.currentTime = relativeTime;
}
}, [timeIndex, imgLoaded, eventSequence, handleSetBox]);
setSeekToTimestamp(null);
}, [
seekToTimestamp,
event.start_time,
annotationOffset,
apiHost,
event.camera,
displaySource,
]);
const selectedLifecycle = useMemo(() => {
if (!eventSequence || eventSequence.length === 0) return undefined;
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
return idx !== -1 ? eventSequence[idx] : eventSequence[0];
}, [eventSequence, timeIndex]);
const isWithinEventRange =
effectiveTime !== undefined &&
event.start_time !== undefined &&
event.end_time !== undefined &&
effectiveTime >= event.start_time &&
effectiveTime <= event.end_time;
const selectedIndex = useMemo(() => {
if (!eventSequence || eventSequence.length === 0) return 0;
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
return idx === -1 ? 0 : idx;
}, [eventSequence, timeIndex]);
// Calculate how far down the blue line should extend based on effectiveTime
const calculateLineHeight = useCallback(() => {
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
return 0;
}
// Calculate how far down the blue line should extend based on timeIndex
const calculateLineHeight = () => {
if (!eventSequence || eventSequence.length === 0) return 0;
const currentTime = timeIndex ?? 0;
const currentTime = effectiveTime ?? 0;
// Find which events have been passed
let lastPassedIndex = -1;
@ -412,43 +280,128 @@ export default function TrackingDetails({
100,
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
);
};
}, [eventSequence, effectiveTime, isWithinEventRange]);
const blueLineHeight = calculateLineHeight();
const videoSource = useMemo(() => {
// event.start_time and event.end_time are in DETECT stream time
// Convert to record stream time, then create video clip with padding
const eventStartRecord = event.start_time + annotationOffset / 1000;
const eventEndRecord =
(event.end_time ?? Date.now() / 1000) + annotationOffset / 1000;
const startTime = eventStartRecord - REVIEW_PADDING;
const endTime = eventEndRecord + REVIEW_PADDING;
const playlist = `${baseUrl}vod/${event.camera}/start/${startTime}/end/${endTime}/index.m3u8`;
return {
playlist,
startPosition: 0,
};
}, [event, annotationOffset]);
// Determine camera aspect ratio category
const cameraAspect = useMemo(() => {
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
return "wide";
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
return "tall";
} else {
return "normal";
}
}, [aspectRatio]);
const handleSeekToTime = useCallback((timestamp: number, _play?: boolean) => {
// Set the target timestamp to seek to
setSeekToTimestamp(timestamp);
}, []);
const handleTimeUpdate = useCallback(
(time: number) => {
// event.start_time is detect stream time, convert to record
const eventStartRecord = event.start_time + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const absoluteTime = time + videoStartTime;
setCurrentTime(absoluteTime);
},
[event.start_time, annotationOffset],
);
const [src, setSrc] = useState(
`${apiHost}api/${event.camera}/recordings/${currentTime + REVIEW_PADDING}/snapshot.jpg?height=500`,
);
const [hasError, setHasError] = useState(false);
// Derive the record timestamp to display: manualOverride if present,
// otherwise use currentTime.
const displayedRecordTime = manualOverride ?? currentTime;
useEffect(() => {
if (displayedRecordTime) {
const newSrc = `${apiHost}api/${event.camera}/recordings/${displayedRecordTime}/snapshot.jpg?height=500`;
setSrc(newSrc);
}
setImgLoaded(false);
setHasError(false);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedRecordTime]);
if (!config) {
return <ActivityIndicator />;
}
return (
<div className={className}>
<span tabIndex={0} className="sr-only" />
{!fullscreen && (
<div className={cn("flex items-center gap-2")}>
<Button
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => setPane("overview")}
<div
className={cn(
isDesktop
? "flex size-full gap-4 overflow-hidden"
: "flex size-full flex-col gap-2",
className,
)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
</div>
)}
<span tabIndex={0} className="sr-only" />
<div
className={cn(
"relative mx-auto flex max-h-[50dvh] flex-row justify-center",
"flex items-center justify-center",
isDesktop && "overflow-hidden",
cameraAspect === "tall" ? "max-h-[50dvh] lg:max-h-[70dvh]" : "w-full",
cameraAspect === "tall" && isMobileOnly && "w-full",
cameraAspect !== "tall" && isDesktop && "flex-[3]",
)}
style={{
aspectRatio: !imgLoaded ? aspectRatio : undefined,
}}
style={{ aspectRatio: aspectRatio }}
ref={containerRef}
>
<div
className={cn(
"relative",
cameraAspect === "tall" ? "h-full" : "w-full",
)}
>
{displaySource == "video" && (
<HlsVideoPlayer
videoRef={videoRef}
containerRef={containerRef}
visible={true}
currentSource={videoSource}
hotKeys={false}
supportsFullscreen={false}
fullscreen={false}
frigateControls={true}
onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime}
isDetailMode={true}
camera={event.camera}
currentTimeOverride={currentTime}
/>
)}
{displaySource == "image" && (
<>
<ImageLoadingIndicator
className="absolute inset-0"
imgLoaded={imgLoaded}
@ -457,18 +410,25 @@ export default function TrackingDetails({
<div className="relative aspect-video">
<div className="flex flex-col items-center justify-center p-20 text-center">
<LuFolderX className="size-16" />
{t("trackingDetails.noImageFound")}
{t("objectLifecycle.noImageFound")}
</div>
</div>
)}
<div
className={cn(
"relative inline-block",
imgLoaded ? "visible" : "invisible",
)}
className={cn("relative", imgLoaded ? "visible" : "invisible")}
>
<ContextMenu>
<ContextMenuTrigger>
<div className="absolute z-50 size-full">
<ObjectTrackOverlay
key={`overlay-${displayedRecordTime}`}
camera={event.camera}
showBoundingBoxes={true}
currentTime={displayedRecordTime}
videoWidth={imgRef?.current?.naturalWidth ?? 0}
videoHeight={imgRef?.current?.naturalHeight ?? 0}
className="absolute inset-0 z-10"
onSeekToTime={handleSeekToTime}
/>
</div>
<img
key={event.id}
ref={imgRef}
@ -489,95 +449,68 @@ export default function TrackingDetails({
onLoad={() => setImgLoaded(true)}
onError={() => setHasError(true)}
/>
{showZones &&
imgRef.current?.width &&
imgRef.current?.height &&
lifecycleZones?.map((zone) => (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key={zone}
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<polygon
points={getZonePolygon(zone)}
className="fill-none stroke-2"
style={{
stroke: `rgb(${getZoneColor(zone)?.join(",")})`,
fill:
selectedZone == zone
? `rgba(${getZoneColor(zone)?.join(",")}, 0.5)`
: `rgba(${getZoneColor(zone)?.join(",")}, 0.3)`,
strokeWidth: selectedZone == zone ? 4 : 2,
}}
/>
</svg>
</div>
))}
{boxStyle && (
<div className="absolute border-2" style={boxStyle}>
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
</div>
</>
)}
{attributeBoxStyle && (
<div className="absolute border-2" style={attributeBoxStyle} />
)}
{imgRef.current?.width &&
imgRef.current?.height &&
pathPoints &&
pathPoints.length > 0 && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key="path"
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<ObjectPath
positions={pathPoints}
color={getObjectColor(event.label)}
width={2}
imgRef={imgRef}
onPointClick={handlePathPointClick}
/>
</svg>
</div>
className={cn(
"absolute top-2 z-[5] flex items-center gap-2",
isIOS ? "right-8" : "right-2",
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={() =>
navigate(
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`,
)
>
{event && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
if (event?.id) {
const params = new URLSearchParams({
id: event.id,
}).toString();
navigate(`/review?${params}`);
}
}}
>
<div className="text-primary">
{t("trackingDetails.createObjectMask")}
<FaHistory className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("itemMenu.viewInHistory.label")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={`${baseUrl}api/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${(event.end_time ?? Date.now() / 1000) + REVIEW_PADDING}/clip.mp4`}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
</div>
<div className="mt-3 flex flex-row items-center justify-between">
<div className={cn(isDesktop && "flex-[2] overflow-hidden")}>
{isDesktop && tabs && <div className="mb-4">{tabs}</div>}
<div
className={cn(
isDesktop && "scrollbar-container h-full overflow-y-auto",
)}
>
<div className="flex flex-row items-center justify-between">
<Heading as="h4">{t("trackingDetails.title")}</Heading>
<div className="flex flex-row gap-2">
@ -608,12 +541,13 @@ export default function TrackingDetails({
</div>
<div className="min-w-20 text-right text-sm text-muted-foreground">
{t("trackingDetails.count", {
first: selectedIndex + 1,
first: eventSequence?.length ?? 0,
second: eventSequence?.length ?? 0,
})}
</div>
</div>
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
{config?.cameras[event.camera]?.onvif.autotracking
.enabled_in_config && (
<div className="-mt-2 mb-2 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")}
</div>
@ -624,7 +558,14 @@ export default function TrackingDetails({
showZones={showZones}
setShowZones={setShowZones}
annotationOffset={annotationOffset}
setAnnotationOffset={setAnnotationOffset}
setAnnotationOffset={(value) => {
if (typeof value === "function") {
const newValue = value(annotationOffset);
setAnnotationOffset(newValue);
} else {
setAnnotationOffset(value);
}
}}
/>
)}
@ -639,7 +580,10 @@ export default function TrackingDetails({
className="flex items-center gap-2 font-medium"
onClick={(e) => {
e.stopPropagation();
setTimeIndex(event.start_time ?? 0);
// event.start_time is detect time, convert to record
handleSeekToTime(
(event.start_time ?? 0) + annotationOffset / 1000,
);
}}
role="button"
>
@ -683,16 +627,20 @@ export default function TrackingDetails({
{t("detail.noObjectDetailData", { ns: "views/events" })}
</div>
) : (
<div className="-pb-2 relative mx-2">
<div className="absolute -top-2 bottom-8 left-4 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
<div className="-pb-2 relative mx-0">
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
{isWithinEventRange && (
<div
className="absolute left-4 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
style={{ height: `${blueLineHeight}%` }}
/>
)}
<div className="space-y-2">
{eventSequence.map((item, idx) => {
const isActive =
Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5;
Math.abs(
(effectiveTime ?? 0) - (item.timestamp ?? 0),
) <= 0.5;
const formattedEventTimestamp = config
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
timezone: config.ui.timezone,
@ -712,23 +660,27 @@ export default function TrackingDetails({
: "";
const ratio =
Array.isArray(item.data.box) && item.data.box.length >= 4
Array.isArray(item.data.box) &&
item.data.box.length >= 4
? (
aspectRatio *
(item.data.box[2] / item.data.box[3])
).toFixed(2)
: "N/A";
const areaPx =
Array.isArray(item.data.box) && item.data.box.length >= 4
Array.isArray(item.data.box) &&
item.data.box.length >= 4
? Math.round(
(config.cameras[event.camera]?.detect?.width ?? 0) *
(config.cameras[event.camera]?.detect?.height ??
(config.cameras[event.camera]?.detect?.width ??
0) *
(config.cameras[event.camera]?.detect
?.height ?? 0) *
(item.data.box[2] * item.data.box[3]),
)
: undefined;
const areaPct =
Array.isArray(item.data.box) && item.data.box.length >= 4
Array.isArray(item.data.box) &&
item.data.box.length >= 4
? (item.data.box[2] * item.data.box[3]).toFixed(4)
: undefined;
@ -741,17 +693,11 @@ export default function TrackingDetails({
ratio={ratio}
areaPx={areaPx}
areaPct={areaPct}
onClick={() => {
setTimeIndex(item.timestamp ?? 0);
handleSetBox(
item.data.box ?? [],
item.data.attribute_box,
);
setLifecycleZones(item.data.zones);
setSelectedZone("");
}}
onClick={() => handleLifecycleClick(item)}
setSelectedZone={setSelectedZone}
getZoneColor={getZoneColor}
effectiveTime={effectiveTime}
isTimelineActive={isWithinEventRange}
/>
);
})}
@ -762,47 +708,11 @@ export default function TrackingDetails({
</div>
</div>
</div>
</div>
</div>
);
}
type GetTimelineIconParams = {
lifecycleItem: TrackingDetailsSequence;
className?: string;
};
export function LifecycleIcon({
lifecycleItem,
className,
}: GetTimelineIconParams) {
switch (lifecycleItem.class_type) {
case "visible":
return <LuPlay className={cn(className)} />;
case "gone":
return <IoMdExit className={cn(className)} />;
case "active":
return <IoPlayCircleOutline className={cn(className)} />;
case "stationary":
return <LuCircle className={cn(className)} />;
case "entered_zone":
return <MdOutlineLocationOn className={cn(className)} />;
case "attribute":
switch (lifecycleItem.data?.attribute) {
case "face":
return <MdFaceUnlock className={cn(className)} />;
case "license_plate":
return <MdOutlinePictureInPictureAlt className={cn(className)} />;
default:
return <LuTruck className={cn(className)} />;
}
case "heard":
return <LuEar className={cn(className)} />;
case "external":
return <LuCircleDot className={cn(className)} />;
default:
return null;
}
}
type LifecycleIconRowProps = {
item: TrackingDetailsSequence;
isActive?: boolean;
@ -813,6 +723,8 @@ type LifecycleIconRowProps = {
onClick: () => void;
setSelectedZone: (z: string) => void;
getZoneColor: (zoneName: string) => number[] | undefined;
effectiveTime?: number;
isTimelineActive?: boolean;
};
function LifecycleIconRow({
@ -825,6 +737,8 @@ function LifecycleIconRow({
onClick,
setSelectedZone,
getZoneColor,
effectiveTime,
isTimelineActive,
}: LifecycleIconRowProps) {
const { t } = useTranslation(["views/explore", "components/player"]);
const { data: config } = useSWR<FrigateConfig>("config");
@ -837,17 +751,19 @@ function LifecycleIconRow({
role="button"
onClick={onClick}
className={cn(
"rounded-md p-2 text-sm text-primary-variant",
"rounded-md p-2 pr-0 text-sm text-primary-variant",
isActive && "bg-secondary-highlight font-semibold text-primary",
!isActive && "duration-500",
)}
>
<div className="flex items-center gap-2">
<div className="relative flex size-4 items-center justify-center">
<div className="relative ml-2 flex size-4 items-center justify-center">
<LuCircle
className={cn(
"relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none",
isActive && "fill-selected duration-300",
"relative z-10 size-2.5 fill-secondary-foreground stroke-none",
(isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
isTimelineActive &&
"fill-selected duration-300",
)}
/>
</div>

View File

@ -57,7 +57,7 @@ export default function DetailStream({
elementRef: scrollRef,
});
const effectiveTime = currentTime + annotationOffset / 1000;
const effectiveTime = currentTime - annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
@ -213,6 +213,7 @@ export default function DetailStream({
config={config}
onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)}
onOpenUpload={(e) => setUpload(e)}
@ -278,6 +279,7 @@ type ReviewGroupProps = {
onActivate?: () => void;
onOpenUpload?: (e: Event) => void;
effectiveTime?: number;
annotationOffset: number;
alwaysExpandActive?: boolean;
};
@ -290,11 +292,14 @@ function ReviewGroup({
onActivate,
onOpenUpload,
effectiveTime,
annotationOffset,
alwaysExpandActive = false,
}: ReviewGroupProps) {
const { t } = useTranslation("views/events");
const [open, setOpen] = useState(false);
const start = review.start_time ?? 0;
// review.start_time is in detect time, convert to record for seeking
const startRecord = start + annotationOffset / 1000;
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
useEffect(() => {
@ -371,7 +376,7 @@ function ReviewGroup({
)}
onClick={() => {
onActivate?.();
onSeek(start);
onSeek(startRecord);
}}
>
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
@ -450,6 +455,7 @@ function ReviewGroup({
key={event.id}
event={event}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
onSeek={onSeek}
onOpenUpload={onOpenUpload}
/>
@ -483,12 +489,14 @@ function ReviewGroup({
type EventListProps = {
event: Event;
effectiveTime?: number;
annotationOffset: number;
onSeek: (ts: number, play?: boolean) => void;
onOpenUpload?: (e: Event) => void;
};
function EventList({
event,
effectiveTime,
annotationOffset,
onSeek,
onOpenUpload,
}: EventListProps) {
@ -505,14 +513,17 @@ function EventList({
if (event) {
setSelectedObjectIds([]);
setSelectedObjectIds([event.id]);
onSeek(event.start_time);
// event.start_time is detect time, convert to record
const recordTime = event.start_time + annotationOffset / 1000;
onSeek(recordTime);
} else {
setSelectedObjectIds([]);
}
};
const handleTimelineClick = (ts: number, play?: boolean) => {
handleObjectSelect(event);
setSelectedObjectIds([]);
setSelectedObjectIds([event.id]);
onSeek(ts, play);
};
@ -554,7 +565,6 @@ function EventList({
)}
onClick={(e) => {
e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event);
}}
role="button"
@ -568,7 +578,6 @@ function EventList({
className="flex flex-1 items-center gap-2"
onClick={(e) => {
e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event);
}}
role="button"
@ -607,6 +616,7 @@ function EventList({
eventId={event.id}
onSeek={handleTimelineClick}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
startTime={event.start_time}
endTime={event.end_time}
/>
@ -621,6 +631,7 @@ type LifecycleItemProps = {
isActive?: boolean;
onSeek?: (timestamp: number, play?: boolean) => void;
effectiveTime?: number;
annotationOffset: number;
isTimelineActive?: boolean;
};
@ -629,6 +640,7 @@ function LifecycleItem({
isActive,
onSeek,
effectiveTime,
annotationOffset,
isTimelineActive = false,
}: LifecycleItemProps) {
const { t } = useTranslation("views/events");
@ -682,7 +694,8 @@ function LifecycleItem({
<div
role="button"
onClick={() => {
onSeek?.(item.timestamp, false);
const recordTimestamp = item.timestamp + annotationOffset / 1000;
onSeek?.(recordTimestamp, false);
}}
className={cn(
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
@ -751,12 +764,14 @@ function ObjectTimeline({
eventId,
onSeek,
effectiveTime,
annotationOffset,
startTime,
endTime,
}: {
eventId: string;
onSeek: (ts: number, play?: boolean) => void;
effectiveTime?: number;
annotationOffset: number;
startTime?: number;
endTime?: number;
}) {
@ -857,6 +872,7 @@ function ObjectTimeline({
onSeek={onSeek}
isActive={isActive}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isTimelineActive={isWithinEventRange}
/>
);

View File

@ -22,6 +22,7 @@ interface DetailStreamProviderProps {
isDetailMode: boolean;
currentTime: number;
camera: string;
initialSelectedObjectIds?: string[];
}
export function DetailStreamProvider({
@ -29,8 +30,11 @@ export function DetailStreamProvider({
isDetailMode,
currentTime,
camera,
initialSelectedObjectIds,
}: DetailStreamProviderProps) {
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]);
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>(
() => initialSelectedObjectIds ?? [],
);
const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) {

View File

@ -13,7 +13,8 @@ function formatZonesList(zones: string[]): string {
});
}
const allButLast = zones.slice(0, -1).join(", ");
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
const allButLast = zones.slice(0, -1).join(separatorWithSpace);
return t("list.many", {
items: allButLast,
last: zones[zones.length - 1],

View File

@ -2,7 +2,7 @@ import { baseUrl } from "@/api/baseUrl";
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import useOptimisticState from "@/hooks/use-optimistic-state";
import { cn } from "@/lib/utils";
@ -10,13 +10,35 @@ import {
CustomClassificationModelConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaFolderPlus } from "react-icons/fa";
import { MdModelTraining } from "react-icons/md";
import { LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi";
import useSWR from "swr";
import Heading from "@/components/ui/heading";
import { useOverlayState } from "@/hooks/use-overlay-state";
import axios from "axios";
import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import BlurredIconButton from "@/components/button/BlurredIconButton";
const allModelTypes = ["objects", "states"] as const;
type ModelType = (typeof allModelTypes)[number];
@ -126,7 +148,7 @@ export default function ModelSelectionView({
onClick={() => setNewModel(true)}
>
<FaFolderPlus />
Add Classification
{t("button.addClassification")}
</Button>
</div>
</div>
@ -142,6 +164,7 @@ export default function ModelSelectionView({
key={config.name}
config={config}
onClick={() => onClick(config)}
onDelete={() => refreshConfig()}
/>
))}
</div>
@ -179,12 +202,53 @@ function NoModelsView({
type ModelCardProps = {
config: CustomClassificationModelConfig;
onClick: () => void;
onDelete: () => void;
};
function ModelCard({ config, onClick }: ModelCardProps) {
function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
const { t } = useTranslation(["views/classificationModel"]);
const { data: dataset } = useSWR<{
[id: string]: string[];
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const bypassDialogRef = useRef(false);
useKeyboardListener(["Shift"], (_, modifiers) => {
bypassDialogRef.current = modifiers.shift;
return false;
});
const handleDelete = useCallback(async () => {
await axios
.delete(`classification/${config.name}`)
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.deletedModel", { count: 1 }), {
position: "top-center",
});
onDelete();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.deleteModelFailed", { errorMessage }), {
position: "top-center",
});
});
}, [config, onDelete, t]);
const handleDeleteClick = useCallback(() => {
if (bypassDialogRef.current) {
handleDelete();
} else {
setDeleteDialogOpen(true);
}
}, [handleDelete]);
const coverImage = useMemo(() => {
if (!dataset) {
return undefined;
@ -204,22 +268,66 @@ function ModelCard({ config, onClick }: ModelCardProps) {
}, [dataset]);
return (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteModel.title")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("deleteModel.single", { name: config.name })}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div
key={config.name}
className={cn(
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg",
"outline-transparent duration-500",
)}
onClick={() => onClick()}
onClick={onClick}
>
<img
className="size-full"
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
/>
<ImageShadowOverlay />
<div className="absolute bottom-2 left-3 text-lg smart-capitalize">
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
{config.name}
</div>
<div className="absolute bottom-2 right-2 z-40">
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<BlurredIconButton>
<FiMoreVertical className="size-5 text-white" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleDeleteClick}>
<LuTrash2 className="mr-2 size-4" />
<span>
{bypassDialogRef.current
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</>
);
}

View File

@ -893,7 +893,7 @@ function ObjectTrainGrid({
// selection
const [selectedEvent, setSelectedEvent] = useState<Event>();
const [dialogTab, setDialogTab] = useState<SearchTab>("details");
const [dialogTab, setDialogTab] = useState<SearchTab>("snapshot");
// handlers

View File

@ -214,7 +214,7 @@ export default function SearchView({
// detail
const [searchDetail, setSearchDetail] = useState<SearchResult>();
const [page, setPage] = useState<SearchTab>("details");
const [page, setPage] = useState<SearchTab>("snapshot");
// search interaction
@ -222,7 +222,7 @@ export default function SearchView({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback(
(item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
(item: SearchResult, ctrl: boolean, page: SearchTab = "snapshot") => {
if (selectedObjects.length > 1 || ctrl) {
const index = selectedObjects.indexOf(item.id);