Compare commits

...

11 Commits

Author SHA1 Message Date
Josh Hawkins
40cc33fa37
Merge 8e98dff671 into d56cf59b9a 2025-11-14 09:23:55 -06:00
Nicolas Mowen
8e98dff671 Reduce review item buffering behavior 2025-11-12 17:10:50 -07:00
Nicolas Mowen
2d06055bbe Try and improve notification opening behavior 2025-11-12 16:29:12 -07:00
Nicolas Mowen
426699d3d0 Use timeline tab by default for notifications but add a query arg for customization 2025-11-12 16:22:55 -07:00
Josh Hawkins
73b9193a19 fix blue line height calc for in progress events 2025-11-12 11:42:50 -06:00
Josh Hawkins
daa9919966 don't show object track until video metadata is loaded 2025-11-12 11:21:40 -06:00
Nicolas Mowen
44077ebe43 Update reprocess message 2025-11-12 08:03:51 -07:00
Nicolas Mowen
341ffc194b Remove ability to right click on elements inside of face popup 2025-11-12 07:17:48 -07:00
Josh Hawkins
29a464f2b5 tracked object description box tweaks 2025-11-12 08:11:50 -06:00
Nicolas Mowen
16ac0b3f5e Properly sort keys for recording summary in StorageMetrics 2025-11-12 07:09:29 -07:00
Josh Hawkins
b105b3f2b4 don't flatten the search result cache when updating
this would cause an infinite swr fetch if something was mutated and then fetch was called again
2025-11-12 08:02:16 -06:00
10 changed files with 111 additions and 84 deletions

View File

@ -132,17 +132,15 @@ class ReviewDescriptionProcessor(PostProcessorApi):
if image_source == ImageSourceEnum.recordings: if image_source == ImageSourceEnum.recordings:
duration = final_data["end_time"] - final_data["start_time"] duration = final_data["end_time"] - final_data["start_time"]
buffer_extension = min( buffer_extension = min(5, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
10, max(2, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
)
# Ensure minimum total duration for short review items # Ensure minimum total duration for short review items
# This provides better context for brief events # This provides better context for brief events
total_duration = duration + (2 * buffer_extension) total_duration = duration + (2 * buffer_extension)
if total_duration < MIN_RECORDING_DURATION: if total_duration < MIN_RECORDING_DURATION:
# Expand buffer to reach minimum duration, still respecting max of 10s per side # Expand buffer to reach minimum duration, still respecting max of 5s per side
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2 additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
buffer_extension = min(10, additional_buffer_per_side) buffer_extension = min(5, additional_buffer_per_side)
thumbs = self.get_recording_frames( thumbs = self.get_recording_frames(
camera, camera,

View File

@ -424,7 +424,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
if not res: if not res:
return { return {
"message": "No face was recognized.", "message": "Model is still training, please try again in a few moments.",
"success": False, "success": False,
} }

View File

@ -44,11 +44,16 @@ self.addEventListener("notificationclick", (event) => {
switch (event.action ?? "default") { switch (event.action ?? "default") {
case "markReviewed": case "markReviewed":
if (event.notification.data) { if (event.notification.data) {
fetch("/api/reviews/viewed", { event.waitUntil(
method: "POST", fetch("/api/reviews/viewed", {
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": 1 }, method: "POST",
body: JSON.stringify({ ids: [event.notification.data.id] }), headers: {
}); "Content-Type": "application/json",
"X-CSRF-TOKEN": 1,
},
body: JSON.stringify({ ids: [event.notification.data.id] }),
})
);
} }
break; break;
default: default:
@ -58,7 +63,7 @@ self.addEventListener("notificationclick", (event) => {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
if (clients.openWindow) { if (clients.openWindow) {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
return clients.openWindow(url); event.waitUntil(clients.openWindow(url));
} }
} }
} }

View File

@ -398,11 +398,7 @@ export function GroupedClassificationCard({
threshold={threshold} threshold={threshold}
selected={false} selected={false}
i18nLibrary={i18nLibrary} i18nLibrary={i18nLibrary}
onClick={(data, meta) => { onClick={() => {}}
if (meta || selectedItems.length > 0) {
onClick(data);
}
}}
> >
{children?.(data)} {children?.(data)}
</ClassificationCard> </ClassificationCard>

View File

@ -683,6 +683,22 @@ function ObjectDetailsTab({
const mutate = useGlobalMutation(); const mutate = useGlobalMutation();
// Helper to map over SWR cached search results while preserving
// either paginated format (SearchResult[][]) or flat format (SearchResult[])
const mapSearchResults = useCallback(
(
currentData: SearchResult[][] | SearchResult[] | undefined,
fn: (event: SearchResult) => SearchResult,
) => {
if (!currentData) return currentData;
if (Array.isArray(currentData[0])) {
return (currentData as SearchResult[][]).map((page) => page.map(fn));
}
return (currentData as SearchResult[]).map(fn);
},
[],
);
// users // users
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
@ -810,17 +826,12 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => { (currentData: SearchResult[][] | SearchResult[] | undefined) =>
if (!currentData) return currentData; mapSearchResults(currentData, (event) =>
// optimistic update event.id === search.id
return currentData ? { ...event, data: { ...event.data, description: desc } }
.flat() : event,
.map((event) => ),
event.id === search.id
? { ...event, data: { ...event.data, description: desc } }
: event,
);
},
{ {
optimisticData: true, optimisticData: true,
rollbackOnError: true, rollbackOnError: true,
@ -843,7 +854,7 @@ function ObjectDetailsTab({
); );
setDesc(search.data.description); setDesc(search.data.description);
}); });
}, [desc, search, mutate, t]); }, [desc, search, mutate, t, mapSearchResults]);
const regenerateDescription = useCallback( const regenerateDescription = useCallback(
(source: "snapshot" | "thumbnails") => { (source: "snapshot" | "thumbnails") => {
@ -915,9 +926,8 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => { (currentData: SearchResult[][] | SearchResult[] | undefined) =>
if (!currentData) return currentData; mapSearchResults(currentData, (event) =>
return currentData.flat().map((event) =>
event.id === search.id event.id === search.id
? { ? {
...event, ...event,
@ -928,8 +938,7 @@ function ObjectDetailsTab({
}, },
} }
: event, : event,
); ),
},
{ {
optimisticData: true, optimisticData: true,
rollbackOnError: true, rollbackOnError: true,
@ -963,7 +972,7 @@ function ObjectDetailsTab({
); );
}); });
}, },
[search, apiHost, mutate, setSearch, t], [search, apiHost, mutate, setSearch, t, mapSearchResults],
); );
// recognized plate // recognized plate
@ -992,9 +1001,8 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => { (currentData: SearchResult[][] | SearchResult[] | undefined) =>
if (!currentData) return currentData; mapSearchResults(currentData, (event) =>
return currentData.flat().map((event) =>
event.id === search.id event.id === search.id
? { ? {
...event, ...event,
@ -1005,8 +1013,7 @@ function ObjectDetailsTab({
}, },
} }
: event, : event,
); ),
},
{ {
optimisticData: true, optimisticData: true,
rollbackOnError: true, rollbackOnError: true,
@ -1040,7 +1047,7 @@ function ObjectDetailsTab({
); );
}); });
}, },
[search, apiHost, mutate, setSearch, t], [search, apiHost, mutate, setSearch, t, mapSearchResults],
); );
// speech transcription // speech transcription
@ -1102,17 +1109,12 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => { (currentData: SearchResult[][] | SearchResult[] | undefined) =>
if (!currentData) return currentData; mapSearchResults(currentData, (event) =>
// optimistic update event.id === search.id
return currentData ? { ...event, plus_id: "new_upload" }
.flat() : event,
.map((event) => ),
event.id === search.id
? { ...event, plus_id: "new_upload" }
: event,
);
},
{ {
optimisticData: true, optimisticData: true,
rollbackOnError: true, rollbackOnError: true,
@ -1120,7 +1122,7 @@ function ObjectDetailsTab({
}, },
); );
}, },
[search, mutate], [search, mutate, mapSearchResults],
); );
const popoverContainerRef = useRef<HTMLDivElement | null>(null); const popoverContainerRef = useRef<HTMLDivElement | null>(null);
@ -1503,7 +1505,7 @@ function ObjectDetailsTab({
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Textarea <Textarea
className="text-md h-32" className="text-md h-32 md:text-sm"
placeholder={t("details.description.placeholder")} placeholder={t("details.description.placeholder")}
value={desc} value={desc}
onChange={(e) => setDesc(e.target.value)} onChange={(e) => setDesc(e.target.value)}
@ -1511,25 +1513,7 @@ function ObjectDetailsTab({
onBlur={handleDescriptionBlur} onBlur={handleDescriptionBlur}
autoFocus autoFocus
/> />
<div className="flex flex-row justify-end gap-4"> <div className="mb-10 flex flex-row justify-end gap-5">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={t("button.save", { ns: "common" })}
className="text-primary/40 hover:text-primary/80"
onClick={() => {
setIsEditingDesc(false);
updateDescription();
}}
>
<FaCheck className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("button.save", { ns: "common" })}
</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -1540,13 +1524,31 @@ function ObjectDetailsTab({
setDesc(originalDescRef.current ?? ""); setDesc(originalDescRef.current ?? "");
}} }}
> >
<FaTimes className="size-4" /> <FaTimes className="size-5" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={t("button.save", { ns: "common" })}
className="text-primary/40 hover:text-primary/80"
onClick={() => {
setIsEditingDesc(false);
updateDescription();
}}
>
<FaCheck className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("button.save", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
)} )}

View File

@ -221,12 +221,26 @@ export function TrackingDetails({
displaySource, displaySource,
]); ]);
const isWithinEventRange = const isWithinEventRange = useMemo(() => {
effectiveTime !== undefined && if (effectiveTime === undefined || event.start_time === undefined) {
event.start_time !== undefined && return false;
event.end_time !== undefined && }
effectiveTime >= event.start_time &&
effectiveTime <= event.end_time; // If an event has not ended yet, fall back to last timestamp in eventSequence
let eventEnd = event.end_time;
if (eventEnd == null && eventSequence && eventSequence.length > 0) {
const last = eventSequence[eventSequence.length - 1];
if (last && last.timestamp !== undefined) {
eventEnd = last.timestamp;
}
}
if (eventEnd == null) {
return false;
}
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
// Calculate how far down the blue line should extend based on effectiveTime // Calculate how far down the blue line should extend based on effectiveTime
const calculateLineHeight = useCallback(() => { const calculateLineHeight = useCallback(() => {

View File

@ -318,6 +318,7 @@ export default function HlsVideoPlayer({
{isDetailMode && {isDetailMode &&
camera && camera &&
currentTime && currentTime &&
loadedMetadata &&
videoDimensions.width > 0 && videoDimensions.width > 0 &&
videoDimensions.height > 0 && ( videoDimensions.height > 0 && (
<div className="absolute z-50 size-full"> <div className="absolute z-50 size-full">

View File

@ -15,6 +15,7 @@ import {
ReviewSummary, ReviewSummary,
SegmentedReviewData, SegmentedReviewData,
} from "@/types/review"; } from "@/types/review";
import { TimelineType } from "@/types/timeline";
import { import {
getBeginningOfDayTimestamp, getBeginningOfDayTimestamp,
getEndOfDayTimestamp, getEndOfDayTimestamp,
@ -49,6 +50,16 @@ export default function Events() {
false, false,
); );
const [notificationTab, setNotificationTab] =
useState<TimelineType>("timeline");
useSearchEffect("tab", (tab: string) => {
if (tab === "timeline" || tab === "events" || tab === "detail") {
setNotificationTab(tab as TimelineType);
}
return true;
});
useSearchEffect("id", (reviewId: string) => { useSearchEffect("id", (reviewId: string) => {
axios axios
.get(`review/${reviewId}`) .get(`review/${reviewId}`)
@ -66,7 +77,7 @@ export default function Events() {
camera: resp.data.camera, camera: resp.data.camera,
startTime, startTime,
severity: resp.data.severity, severity: resp.data.severity,
timelineType: "detail", timelineType: notificationTab,
}, },
true, true,
); );

View File

@ -1,4 +1,5 @@
import { ReviewSeverity } from "./review"; import { ReviewSeverity } from "./review";
import { TimelineType } from "./timeline";
export type Recording = { export type Recording = {
id: string; id: string;
@ -37,7 +38,7 @@ export type RecordingStartingPoint = {
camera: string; camera: string;
startTime: number; startTime: number;
severity: ReviewSeverity; severity: ReviewSeverity;
timelineType?: "timeline" | "events" | "detail"; timelineType?: TimelineType;
}; };
export type RecordingPlayerError = "stalled" | "startup"; export type RecordingPlayerError = "stalled" | "startup";

View File

@ -72,8 +72,7 @@ export default function StorageMetrics({
const earliestDate = useMemo(() => { const earliestDate = useMemo(() => {
const keys = Object.keys(recordingsSummary || {}); const keys = Object.keys(recordingsSummary || {});
return keys.length return keys.length
? new TZDate(keys[keys.length - 1] + "T00:00:00", timezone).getTime() / ? new TZDate(keys[0] + "T00:00:00", timezone).getTime() / 1000
1000
: null; : null;
}, [recordingsSummary, timezone]); }, [recordingsSummary, timezone]);