diff --git a/docs/docs/configuration/custom_classification/object_classification.md b/docs/docs/configuration/custom_classification/object_classification.md index 0fc3ee814..ac0b9387a 100644 --- a/docs/docs/configuration/custom_classification/object_classification.md +++ b/docs/docs/configuration/custom_classification/object_classification.md @@ -39,7 +39,7 @@ For object classification: :::note -A tracked object can only have a single sub label. If you are using Triggers or Face Recognition and you configure an object classification model for `person` using the sub label type, your sub label may not be assigned correctly as it depends on which enrichment completes its analysis first. Consider using the `attribute` type instead. +A tracked object can only have a single sub label. If you are using Triggers or Face Recognition and you configure an object classification model for `person` using the sub label type, your sub label may not be assigned correctly as it depends on which enrichment completes its analysis first. This could also occur with `car` objects that are assigned a sub label for a delivery carrier. Consider using the `attribute` type instead. ::: diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index f9a3e1de0..991562871 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -48,15 +48,29 @@ Using Ollama on CPU is not recommended, high inference times make using Generati ::: -[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It provides a nice API over [llama.cpp](https://github.com/ggerganov/llama.cpp). It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. +[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [Docker container](https://hub.docker.com/r/ollama/ollama) available. -Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-does-ollama-handle-concurrent-requests). +Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://docs.ollama.com/faq#how-does-ollama-handle-concurrent-requests). + +### Model Types: Instruct vs Thinking + +Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions. + +- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case. +- **Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models. + +Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, Frigate will always use instruct-style prompts and specifically disables thinking-mode behaviors to ensure concise, useful responses. + +**Recommendation:** +Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider’s documentation or model library for guidance on the correct model variant to use. + + ### Supported Models -You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/library). Note that Frigate will not automatically download the model you specify in your config, you must download the model to your local instance of Ollama first i.e. by running `ollama pull llava:7b` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag. +You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/search?c=vision). Note that Frigate will not automatically download the model you specify in your config, you must download the model to your local instance of Ollama first i.e. by running `ollama pull qwen3-vl:2b-instruct` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag. :::note diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 229dd698c..f6ee76ee5 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -157,7 +157,7 @@ A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite` #### YOLOv9 -[YOLOv9](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite) models that are compiled for Tensorflow Lite and properly quantized are supported, but not included by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`. Note that the model may require a custom label file (eg. [use this 17 label file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) for the model linked above.) +YOLOv9 models that are compiled for TensorFlow Lite and properly quantized are supported, but not included by default. [Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes.
YOLOv9 Setup & Config @@ -178,7 +178,7 @@ model: labelmap_path: /config/labels-coco17.txt ``` -Note that the labelmap uses a subset of the complete COCO label set that has only 17 objects. +Note that due to hardware limitations of the Coral, the labelmap is a subset of the COCO labels and includes only 17 object classes.
diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index 5c0f137b3..ea3ee853d 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -9,7 +9,11 @@ "empty": { "alert": "There are no alerts to review", "detection": "There are no detections to review", - "motion": "No motion data found" + "motion": "No motion data found", + "recordingsDisabled": { + "title": "Recordings must be enabled", + "description": "Review items can only be created for a camera when recordings are enabled for that camera." + } }, "timeline": "Timeline", "timeline.aria": "Select timeline", diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index ff95e2fc6..53b04e6c4 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -166,6 +166,9 @@ "tips": { "descriptionSaved": "Successfully saved description", "saveDescriptionFailed": "Failed to update the description: {{errorMessage}}" + }, + "title": { + "label": "Title" } }, "itemMenu": { diff --git a/web/src/components/card/EmptyCard.tsx b/web/src/components/card/EmptyCard.tsx index de934482f..8d6b67a68 100644 --- a/web/src/components/card/EmptyCard.tsx +++ b/web/src/components/card/EmptyCard.tsx @@ -2,15 +2,18 @@ import React from "react"; import { Button } from "../ui/button"; import Heading from "../ui/heading"; import { Link } from "react-router-dom"; +import { cn } from "@/lib/utils"; type EmptyCardProps = { + className?: string; icon: React.ReactNode; title: string; - description: string; + description?: string; buttonText?: string; link?: string; }; export function EmptyCard({ + className, icon, title, description, @@ -18,10 +21,12 @@ export function EmptyCard({ link, }: EmptyCardProps) { return ( -
+
{icon} {title} -
{description}
+ {description && ( +
{description}
+ )} {buttonText?.length && (
{event.data.metadata?.title && ( -
- - - {event.data.metadata.title} - -
+ +
+ + + {event.data.metadata.title} + +
+
)}
); diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 3116ae463..2313b5a03 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -195,7 +195,7 @@ export default function SearchResultActions({ ) : ( <> - + diff --git a/web/src/components/overlay/chip/GenAISummaryChip.tsx b/web/src/components/overlay/chip/GenAISummaryChip.tsx index 64eb4a10b..ead0104b1 100644 --- a/web/src/components/overlay/chip/GenAISummaryChip.tsx +++ b/web/src/components/overlay/chip/GenAISummaryChip.tsx @@ -6,16 +6,15 @@ import { ThreatLevel, THREAT_LEVEL_LABELS, } from "@/types/review"; -import { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { isDesktop } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { MdAutoAwesome } from "react-icons/md"; type GenAISummaryChipProps = { review?: ReviewSegment; - onClick: () => void; }; -export function GenAISummaryChip({ review, onClick }: GenAISummaryChipProps) { +export function GenAISummaryChip({ review }: GenAISummaryChipProps) { const [isVisible, setIsVisible] = useState(false); useEffect(() => { @@ -29,7 +28,6 @@ export function GenAISummaryChip({ review, onClick }: GenAISummaryChipProps) { isVisible ? "translate-y-0 opacity-100" : "-translate-y-4 opacity-0", isDesktop ? "bg-card" : "bg-secondary-foreground", )} - onClick={onClick} > {review?.data.metadata?.title} @@ -40,10 +38,12 @@ export function GenAISummaryChip({ review, onClick }: GenAISummaryChipProps) { type GenAISummaryDialogProps = { review?: ReviewSegment; onOpen?: (open: boolean) => void; + children: React.ReactNode; }; export function GenAISummaryDialog({ review, onOpen, + children, }: GenAISummaryDialogProps) { const { t } = useTranslation(["views/explore"]); @@ -104,7 +104,7 @@ export function GenAISummaryDialog({ return ( - setOpen(true)} /> +
{children}
{t("aiAnalysis.title")} +
+ {t("details.title.label")} +
+
{aiAnalysis.title}
{t("details.description.label")}
diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index c18cf9dff..9258ca457 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -25,10 +25,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Link } from "react-router-dom"; import { Switch } from "@/components/ui/switch"; import { useUserPersistence } from "@/hooks/use-user-persistence"; -import { isDesktop } from "react-device-detect"; +import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { resolveZoneName } from "@/hooks/use-zone-friendly-name"; import { PiSlidersHorizontalBold } from "react-icons/pi"; import { MdAutoAwesome } from "react-icons/md"; +import { isPWA } from "@/utils/isPWA"; +import { isInIframe } from "@/utils/isIFrame"; +import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip"; type DetailStreamProps = { reviewItems?: ReviewSegment[]; @@ -100,7 +103,25 @@ export default function DetailStream({ } }, [reviewItems, activeReviewId, effectiveTime]); - // Auto-scroll to current time + // Initial scroll to active review (runs immediately when user selects, not during playback) + useEffect(() => { + if (!scrollRef.current || !activeReviewId || userInteracting || isPlaying) + return; + + const element = scrollRef.current.querySelector( + `[data-review-id="${activeReviewId}"]`, + ) as HTMLElement; + + if (element) { + setProgrammaticScroll(); + scrollIntoView(element, { + scrollMode: "if-needed", + behavior: isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth", + }); + } + }, [activeReviewId, setProgrammaticScroll, userInteracting, isPlaying]); + + // Auto-scroll to current time during playback useEffect(() => { if (!scrollRef.current || userInteracting || !isPlaying) return; // Prefer the review whose range contains the effectiveTime. If none @@ -145,7 +166,8 @@ export default function DetailStream({ setProgrammaticScroll(); scrollIntoView(element, { scrollMode: "if-needed", - behavior: "smooth", + behavior: + isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth", }); } } @@ -417,7 +439,18 @@ function ReviewGroup({ {review.data.metadata.title} - {review.data.metadata.title} + { + if (open) { + onSeek(review.start_time, false); + } + }} + > + + {review.data.metadata.title} + + )}
@@ -782,21 +815,27 @@ function LifecycleItem({
- {t("trackingDetails.lifecycleItemDesc.header.score")} + {t("trackingDetails.lifecycleItemDesc.header.score", { + ns: "views/explore", + })} {score}
- {t("trackingDetails.lifecycleItemDesc.header.ratio")} + {t("trackingDetails.lifecycleItemDesc.header.ratio", { + ns: "views/explore", + })} {ratio}
- {t("trackingDetails.lifecycleItemDesc.header.area")}{" "} + {t("trackingDetails.lifecycleItemDesc.header.area", { + ns: "views/explore", + })}{" "} {attributeAreaPx !== undefined && attributeAreaPct !== undefined && ( @@ -806,7 +845,7 @@ function LifecycleItem({ {areaPx !== undefined && areaPct !== undefined ? ( - {areaPx} {t("pixels", { ns: "common" })}{" "} + {areaPx} {t("information.pixels", { ns: "common" })}{" "} ·{" "} {areaPct}% @@ -819,7 +858,9 @@ function LifecycleItem({ attributeAreaPct !== undefined && (
- {t("trackingDetails.lifecycleItemDesc.header.area")}{" "} + {t("trackingDetails.lifecycleItemDesc.header.area", { + ns: "views/explore", + })}{" "} {attributeAreaPx !== undefined && attributeAreaPct !== undefined && ( @@ -828,7 +869,8 @@ function LifecycleItem({ )} - {attributeAreaPx} {t("pixels", { ns: "common" })}{" "} + {attributeAreaPx}{" "} + {t("information.pixels", { ns: "common" })}{" "} ·{" "} {attributeAreaPct}% diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index cbecff330..cefd8f2d3 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -111,7 +111,7 @@ export function MotionReviewTimeline({ const getRecordingAvailability = useCallback( (time: number): boolean | undefined => { - if (!noRecordingRanges?.length) return undefined; + if (noRecordingRanges == undefined) return undefined; return !noRecordingRanges.some( (range) => time >= range.start_time && time < range.end_time, diff --git a/web/src/types/card.ts b/web/src/types/card.ts new file mode 100644 index 000000000..9e1da1632 --- /dev/null +++ b/web/src/types/card.ts @@ -0,0 +1,4 @@ +export type EmptyCardData = { + title: string; + description?: string; +}; diff --git a/web/src/utils/i18n.ts b/web/src/utils/i18n.ts index ca7ad8e25..f149f467e 100644 --- a/web/src/utils/i18n.ts +++ b/web/src/utils/i18n.ts @@ -79,9 +79,6 @@ i18n parseMissingKeyHandler: (key: string) => { const parts = key.split("."); - // eslint-disable-next-line no-console - console.warn(`Missing translation key: ${key}`); - if (parts[0] === "time" && parts[1]?.includes("formattedTimestamp")) { // Extract the format type from the last part (12hour, 24hour) const formatType = parts[parts.length - 1]; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 417c3231d..9e015dfe4 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -56,6 +56,8 @@ import { GiSoundWaves } from "react-icons/gi"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { useTranslation } from "react-i18next"; +import { EmptyCard } from "@/components/card/EmptyCard"; +import { EmptyCardData } from "@/types/card"; type EventViewProps = { reviewItems?: SegmentedReviewData; @@ -132,6 +134,24 @@ export default function EventView({ } }, [filter, showReviewed, reviewSummary]); + const emptyCardData: EmptyCardData = useMemo(() => { + if ( + !config || + Object.values(config.cameras).find( + (cam) => cam.record.enabled_in_config, + ) != undefined + ) { + return { + title: t("empty." + severity.replace(/_/g, " ")), + }; + } + + return { + title: t("empty.recordingsDisabled.title"), + description: t("empty.recordingsDisabled.description"), + }; + }, [config, severity, t]); + // review interaction const [selectedReviews, setSelectedReviews] = useState([]); @@ -412,6 +432,7 @@ export default function EventView({ timeRange={timeRange} startTime={startTime} loading={severity != severityToggle} + emptyCardData={emptyCardData} markItemAsReviewed={markItemAsReviewed} markAllItemsAsReviewed={markAllItemsAsReviewed} onSelectReview={onSelectReview} @@ -430,6 +451,7 @@ export default function EventView({ startTime={startTime} filter={filter} motionOnly={motionOnly} + emptyCardData={emptyCardData} onOpenRecording={onOpenRecording} /> )} @@ -455,6 +477,7 @@ type DetectionReviewProps = { timeRange: { before: number; after: number }; startTime?: number; loading: boolean; + emptyCardData: EmptyCardData; markItemAsReviewed: (review: ReviewSegment) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; onSelectReview: ( @@ -478,6 +501,7 @@ function DetectionReview({ timeRange, startTime, loading, + emptyCardData, markItemAsReviewed, markAllItemsAsReviewed, onSelectReview, @@ -737,10 +761,12 @@ function DetectionReview({ )} {!loading && currentItems?.length === 0 && ( -
- - {t("empty." + severity.replace(/_/g, " "))} -
+ } + /> )}
void; }; function MotionReview({ @@ -885,9 +912,9 @@ function MotionReview({ startTime, filter, motionOnly = false, + emptyCardData, onOpenRecording, }: MotionReviewProps) { - const { t } = useTranslation(["views/events"]); const segmentDuration = 30; const { data: config } = useSWR("config"); @@ -1080,9 +1107,12 @@ function MotionReview({ if (motionData?.length === 0) { return ( -
- - {t("empty.motion")} +
+ } + />
); } diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 839186d8e..aee3d09da 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -66,7 +66,10 @@ import { import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { DetailStreamProvider } from "@/context/detail-stream-context"; -import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip"; +import { + GenAISummaryDialog, + GenAISummaryChip, +} from "@/components/overlay/chip/GenAISummaryChip"; const DATA_REFRESH_TIME = 600000; // 10 minutes @@ -309,10 +312,18 @@ export function RecordingView({ currentTimeRange.after <= currentTime && currentTimeRange.before >= currentTime ) { - mainControllerRef.current?.seekToTimestamp( - currentTime, - mainControllerRef.current.isPlaying(), - ); + if (mainControllerRef.current != undefined) { + let shouldPlayback = true; + + if (timelineType == "detail") { + shouldPlayback = mainControllerRef.current.isPlaying(); + } + + mainControllerRef.current.seekToTimestamp( + currentTime, + shouldPlayback, + ); + } } else { updateSelectedSegment(currentTime, true); } @@ -731,7 +742,9 @@ export function RecordingView({ + > + + )} {isMobile && timelineType == "timeline" && ( - + + + )} {timelineType != "detail" && (