diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index 08050977e..ce168c346 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -1,6 +1,6 @@ { "description": { - "addFace": "Walk through adding a new collection to the Face Library.", + "addFace": "Add a new collection to the Face Library by uploading your first image.", "placeholder": "Enter a name for this collection", "invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens." }, diff --git a/web/src/components/card/ClassificationCard.tsx b/web/src/components/card/ClassificationCard.tsx index de99c60fe..15404db1b 100644 --- a/web/src/components/card/ClassificationCard.tsx +++ b/web/src/components/card/ClassificationCard.tsx @@ -305,7 +305,7 @@ export function GroupedClassificationCard({
diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx index e23d1c3f6..9a6f4e6ee 100644 --- a/web/src/components/card/SearchThumbnailFooter.tsx +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -42,11 +42,11 @@ export default function SearchThumbnailFooter({ return (
4 && "items-start sm:flex-col lg:flex-row lg:items-center", )} > -
+
{searchResult.end_time ? ( ) : ( diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 2c928becf..99d9e4881 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -214,10 +214,14 @@ export default function SearchResultActions({ searchResult.data.type == "object" && ( - +
+ {/* blurred circular hover background */} +
+ +
{t("itemMenu.findSimilar.label")} @@ -233,10 +237,13 @@ export default function SearchResultActions({ !searchResult.plus_id && ( - +
+
+ +
{t("itemMenu.submitToPlus.label")} @@ -246,7 +253,10 @@ export default function SearchResultActions({ - +
+
+ +
{menuItems} diff --git a/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx index 3ed512303..6436ef040 100644 --- a/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx +++ b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx @@ -102,17 +102,23 @@ export default function CreateFaceWizardDialog({ }} > -
- {t("button.addFace")} - {isDesktop && {t("description.addFace")}} -
+
+ {t("button.addFace")} + {isDesktop && {t("description.addFace")}} +
+ {step == 0 && ( { + if (!eventSequence || eventSequence.length === 0) return 0; + + const currentTime = timeIndex ?? 0; + + // Find which events have been passed + let lastPassedIndex = -1; + for (let i = 0; i < eventSequence.length; i++) { + if (currentTime >= (eventSequence[i].timestamp ?? 0)) { + lastPassedIndex = i; + } else { + break; + } + } + + // No events passed yet + if (lastPassedIndex < 0) return 0; + + // All events passed + if (lastPassedIndex >= eventSequence.length - 1) return 100; + + // Calculate percentage based on item position, not time + // Each item occupies an equal visual space regardless of time gaps + const itemPercentage = 100 / (eventSequence.length - 1); + + // Find progress between current and next event for smooth transition + const currentEvent = eventSequence[lastPassedIndex]; + const nextEvent = eventSequence[lastPassedIndex + 1]; + const currentTimestamp = currentEvent.timestamp ?? 0; + const nextTimestamp = nextEvent.timestamp ?? 0; + + // Calculate interpolation between the two events + const timeBetween = nextTimestamp - currentTimestamp; + const timeElapsed = currentTime - currentTimestamp; + const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0; + + // Base position plus interpolated progress to next item + return Math.min( + 100, + lastPassedIndex * itemPercentage + interpolation * itemPercentage, + ); + }; + + const blueLineHeight = calculateLineHeight(); + if (!config) { return ; } @@ -569,7 +616,7 @@ export default function ObjectLifecycle({
@@ -581,10 +628,12 @@ export default function ObjectLifecycle({ }} role="button" > - {getIconForLabel( - event.label, - "size-6 text-primary dark:text-white", - )} +
+ {getIconForLabel( + event.label, + "size-6 text-primary dark:text-white", + )} +
{getTranslatedLabel(event.label)} @@ -602,147 +651,79 @@ export default function ObjectLifecycle({ {t("detail.noObjectDetailData", { ns: "views/events" })}
) : ( -
- {eventSequence.map((item, idx) => { - const isActive = - Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5; - const formattedEventTimestamp = config - ? formatUnixTimestampToDateTime(item.timestamp ?? 0, { - timezone: config.ui.timezone, - date_format: - config.ui.time_format == "24hour" - ? t( - "time.formattedTimestampHourMinuteSecond.24hour", - { ns: "common" }, - ) - : t( - "time.formattedTimestampHourMinuteSecond.12hour", - { ns: "common" }, - ), - time_style: "medium", - date_style: "medium", - }) - : ""; +
+
+
+
+ {eventSequence.map((item, idx) => { + const isActive = + Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5; + const formattedEventTimestamp = config + ? formatUnixTimestampToDateTime(item.timestamp ?? 0, { + timezone: config.ui.timezone, + date_format: + config.ui.time_format == "24hour" + ? t( + "time.formattedTimestampHourMinuteSecond.24hour", + { ns: "common" }, + ) + : t( + "time.formattedTimestampHourMinuteSecond.12hour", + { ns: "common" }, + ), + time_style: "medium", + date_style: "medium", + }) + : ""; - const ratio = - 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 - ? Math.round( - (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 - ? (item.data.box[2] * item.data.box[3]).toFixed(4) - : undefined; + const ratio = + 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 + ? Math.round( + (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 + ? (item.data.box[2] * item.data.box[3]).toFixed(4) + : undefined; - return ( -
{ - setTimeIndex(item.timestamp ?? 0); - handleSetBox( - item.data.box ?? [], - item.data.attribute_box, - ); - setLifecycleZones(item.data.zones); - setSelectedZone(""); - }} - className={cn( - "flex cursor-pointer flex-col gap-1 rounded-md p-2 text-sm text-primary-variant", - isActive - ? "bg-secondary-highlight font-semibold text-primary outline-[1.5px] -outline-offset-[1.1px] outline-primary/40 dark:font-normal" - : "duration-500", - )} - > -
-
- -
-
-
{getLifecycleItemDescription(item)}
-
- {formattedEventTimestamp} -
-
-
- -
-
-
- - {t( - "objectLifecycle.lifecycleItemDesc.header.ratio", - )} - - - {ratio} - -
- -
- - {t( - "objectLifecycle.lifecycleItemDesc.header.area", - )} - - {areaPx !== undefined && areaPct !== undefined ? ( - - px: {areaPx} · %: {areaPct} - - ) : ( - N/A - )} -
- {item.class_type === "entered_zone" && ( -
- - {t( - "objectLifecycle.lifecycleItemDesc.header.zones", - )} - -
- {item.data.zones.map((zone, zidx) => ( -
{ - e.stopPropagation(); - setSelectedZone(zone); - }} - > -
- - {zone.replaceAll("_", " ")} - -
- ))} -
-
- )} -
-
-
- ); - })} + return ( + { + setTimeIndex(item.timestamp ?? 0); + handleSetBox( + item.data.box ?? [], + item.data.attribute_box, + ); + setLifecycleZones(item.data.zones); + setSelectedZone(""); + }} + setSelectedZone={setSelectedZone} + getZoneColor={getZoneColor} + /> + ); + })} +
)}
@@ -789,3 +770,117 @@ export function LifecycleIcon({ return null; } } + +type LifecycleIconRowProps = { + item: ObjectLifecycleSequence; + isActive?: boolean; + formattedEventTimestamp: string; + ratio: string; + areaPx?: number; + areaPct?: string; + onClick: () => void; + setSelectedZone: (z: string) => void; + getZoneColor: (zoneName: string) => number[] | undefined; +}; + +function LifecycleIconRow({ + item, + isActive, + formattedEventTimestamp, + ratio, + areaPx, + areaPct, + onClick, + setSelectedZone, + getZoneColor, +}: LifecycleIconRowProps) { + const { t } = useTranslation(["views/explore"]); + + return ( +
+
+
+ +
+ +
+
+
{getLifecycleItemDescription(item)}
+
+
+ + {t("objectLifecycle.lifecycleItemDesc.header.ratio")} + + {ratio} +
+
+ + {t("objectLifecycle.lifecycleItemDesc.header.area")} + + {areaPx !== undefined && areaPct !== undefined ? ( + + {t("information.pixels", { ns: "common", area: areaPx })} ·{" "} + {areaPct}% + + ) : ( + N/A + )} +
+ + {item.data?.zones && item.data.zones.length > 0 && ( +
+ {item.data.zones.map((zone, zidx) => { + const color = getZoneColor(zone)?.join(",") ?? "0,0,0"; + return ( + { + e.stopPropagation(); + setSelectedZone(zone); + }} + style={{ + borderColor: `rgba(${color}, 0.6)`, + background: `rgba(${color}, 0.08)`, + }} + > + + + {zone.replaceAll("_", " ")} + + + ); + })} +
+ )} +
+
+ +
{formattedEventTimestamp}
+
+
+
+ ); +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index f4bc75a5a..bdb78e0d3 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -66,6 +66,7 @@ export default function Events() { camera: resp.data.camera, startTime, severity: resp.data.severity, + timelineType: "detail", }, true, ); diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 677bb58e0..c01a0875a 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -858,14 +858,20 @@ function FaceAttemptGroup({ faceNames={faceNames} onTrainAttempt={(name) => onTrainAttempt(data, name)} > - +
+
+ +
- onReprocess(data)} - /> +
+
+ onReprocess(data)} + /> +
{t("button.reprocessFace")} diff --git a/web/src/types/record.ts b/web/src/types/record.ts index a93029376..b662ce547 100644 --- a/web/src/types/record.ts +++ b/web/src/types/record.ts @@ -37,6 +37,7 @@ export type RecordingStartingPoint = { camera: string; startTime: number; severity: ReviewSeverity; + timelineType?: "timeline" | "events" | "detail"; }; export type RecordingPlayerError = "stalled" | "startup"; diff --git a/web/src/views/classification/ModelTrainingView.tsx b/web/src/views/classification/ModelTrainingView.tsx index f313d6829..ed96406e6 100644 --- a/web/src/views/classification/ModelTrainingView.tsx +++ b/web/src/views/classification/ModelTrainingView.tsx @@ -811,7 +811,10 @@ function StateTrainGrid({ image={data.filename} onRefresh={onRefresh} > - +
+
+ +
@@ -958,7 +961,10 @@ function ObjectTrainGrid({ image={data.filename} onRefresh={onRefresh} > - +
+
+ +
)} diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 83c080555..f2c2606df 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -53,8 +53,6 @@ import { cn } from "@/lib/utils"; import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter"; import { GiSoundWaves } from "react-icons/gi"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; -import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog"; - import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { useTranslation } from "react-i18next"; @@ -398,6 +396,7 @@ export default function EventView({ onSelectAllReviews={onSelectAllReviews} setSelectedReviews={setSelectedReviews} pullLatestData={pullLatestData} + onOpenRecording={onOpenRecording} /> )} {severity == "significant_motion" && ( @@ -441,6 +440,7 @@ type DetectionReviewProps = { onSelectAllReviews: () => void; setSelectedReviews: (reviews: ReviewSegment[]) => void; pullLatestData: () => void; + onOpenRecording: (recordingInfo: RecordingStartingPoint) => void; }; function DetectionReview({ contentRef, @@ -460,15 +460,12 @@ function DetectionReview({ onSelectAllReviews, setSelectedReviews, pullLatestData, + onOpenRecording, }: DetectionReviewProps) { const { t } = useTranslation(["views/events"]); const reviewTimelineRef = useRef(null); - // detail - - const [reviewDetail, setReviewDetail] = useState(); - // preview const [previewTime, setPreviewTime] = useState(); @@ -688,8 +685,6 @@ function DetectionReview({ return ( <> - -
{ if (detail) { - setReviewDetail(review); + onOpenRecording({ + camera: review.camera, + startTime: review.start_time - REVIEW_PADDING, + severity: review.severity, + timelineType: "detail", + }); } else { onSelectReview(review, ctrl); } diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 4e0aa83ae..80671ea1b 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -53,6 +53,7 @@ import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT, RecordingSegment, + RecordingStartingPoint, } from "@/types/record"; import { useResizeObserver } from "@/hooks/resize-observer"; import { cn } from "@/lib/utils"; @@ -141,9 +142,15 @@ export function RecordingView({ // timeline + const [recording] = useOverlayState( + "recording", + undefined, + false, + ); + const [timelineType, setTimelineType] = useOverlayState( "timelineType", - "timeline", + recording?.timelineType ?? "timeline", ); const chunkedTimeRange = useMemo( diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 5cd36beab..6f2b3f86c 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -577,7 +577,7 @@ export default function SearchView({ >
)} +
+ { + if (config?.semantic_search.enabled) { + setSimilaritySearch(value); + } + }} + refreshResults={refresh} + showObjectLifecycle={() => + onSelectSearch(value, false, "object_lifecycle") + } + showSnapshot={() => + onSelectSearch(value, false, "snapshot") + } + addTrigger={() => { + if ( + config?.semantic_search.enabled && + value.data.type == "object" + ) { + navigate( + `/settings?page=triggers&camera=${value.camera}&event_id=${value.id}`, + ); + } + }} + /> +
-
- { - if (config?.semantic_search.enabled) { - setSimilaritySearch(value); - } - }} - refreshResults={refresh} - showObjectLifecycle={() => - onSelectSearch(value, false, "object_lifecycle") - } - showSnapshot={() => - onSelectSearch(value, false, "snapshot") - } - addTrigger={() => { - if ( - config?.semantic_search.enabled && - value.data.type == "object" - ) { - navigate( - `/settings?page=triggers&camera=${value.camera}&event_id=${value.id}`, - ); - } - }} - /> -
); })}