Compare commits

..

1 Commits

Author SHA1 Message Date
lucaszhu-hue
6330a3b494
Merge a2b92caab0 into 652ea2454f 2026-06-19 23:02:47 +02:00
3 changed files with 25 additions and 234 deletions

View File

@ -1,9 +1,7 @@
"""Preview apis.""" """Preview apis."""
import bisect
import logging import logging
import os import os
import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import pytz import pytz
@ -135,32 +133,6 @@ def preview_hour(
return preview_ts(camera_name, start_ts, end_ts, allowed_cameras) return preview_ts(camera_name, start_ts, end_ts, allowed_cameras)
# cache one sorted listing of the shared preview_frames dir
_preview_listing_lock = threading.Lock()
_preview_listing_cache: tuple[float, list[str]] = (-1.0, [])
def _get_preview_frame_listing(preview_dir: str) -> list[str]:
"""Return the sorted preview_frames listing, cached until the dir changes."""
global _preview_listing_cache
# mtime bumps when a frame is added or removed, invalidating the cache
mtime = os.stat(preview_dir).st_mtime
cached_mtime, files = _preview_listing_cache
if mtime == cached_mtime:
return files
with _preview_listing_lock:
# another thread may have refreshed the cache while we waited
cached_mtime, files = _preview_listing_cache
if mtime == cached_mtime:
return files
files = sorted(entry.name for entry in os.scandir(preview_dir))
_preview_listing_cache = (mtime, files)
return files
@router.get( @router.get(
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames", "/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames",
response_model=PreviewFramesResponse, response_model=PreviewFramesResponse,
@ -177,15 +149,23 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
files = _get_preview_frame_listing(preview_dir) camera_files = [
entry.name
# a camera's frames form a contiguous slice of the sorted listing; for entry in os.scandir(preview_dir)
# bisect locates it without scanning the whole directory if entry.name.startswith(file_start)
left = bisect.bisect_left(files, start_file)
right = bisect.bisect_right(files, end_file)
selected_previews = [
file for file in files[left:right] if file.startswith(file_start)
] ]
camera_files.sort()
selected_previews = []
for file in camera_files:
if file < start_file:
continue
if file > end_file:
break
selected_previews.append(file)
return JSONResponse( return JSONResponse(
content=selected_previews, content=selected_previews,

View File

@ -70,13 +70,6 @@
"selectFromTimeline": "Select from Timeline", "selectFromTimeline": "Select from Timeline",
"cameraSelection": "Cameras", "cameraSelection": "Cameras",
"cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected", "cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected",
"searchOrSelectGroup": "Search, or select a camera group...",
"selectAll": "Select all cameras",
"clearSelection": "Clear selection",
"selectWithActivity": "Cameras with tracked objects",
"selectGroup": "Select group",
"noMatchingCameras": "No cameras match your search",
"selectedCount": "{{selected}} / {{total}} selected",
"checkingActivity": "Checking camera activity...", "checkingActivity": "Checking camera activity...",
"noCameras": "No cameras available", "noCameras": "No cameras available",
"detectionCount_one": "1 tracked object", "detectionCount_one": "1 tracked object",

View File

@ -39,16 +39,6 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "../ui/command";
import { IconRenderer } from "../icons/IconPicker";
import * as LuIcons from "react-icons/lu";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay"; import SaveExportOverlay from "./SaveExportOverlay";
@ -386,9 +376,6 @@ export function ExportContent({
const [newCaseName, setNewCaseName] = useState(""); const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState("");
const [isStartingBatchExport, setIsStartingBatchExport] = useState(false); const [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
const [cameraSearch, setCameraSearch] = useState("");
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
const cameraMenuRef = useRef<HTMLDivElement>(null);
const multiRangeKey = useMemo(() => { const multiRangeKey = useMemo(() => {
if (activeTab !== "multi" || !range) { if (activeTab !== "multi" || !range) {
return undefined; return undefined;
@ -590,75 +577,6 @@ export function ExportContent({
); );
}, []); }, []);
const availableCameraIds = useMemo(
() => cameraActivities.map((activity) => activity.camera),
[cameraActivities],
);
const activeCameraIds = useMemo(
() =>
cameraActivities
.filter((activity) => activity.hasDetections)
.map((activity) => activity.camera),
[cameraActivities],
);
const cameraGroups = useMemo(
() =>
Object.entries(config?.camera_groups ?? {})
.map(([name, group]) => ({
name,
icon: group.icon,
order: group.order,
cameras: group.cameras.filter((cameraId) =>
availableCameraIds.includes(cameraId),
),
}))
.filter((group) => group.cameras.length > 0)
.sort((a, b) => a.order - b.order),
[config?.camera_groups, availableCameraIds],
);
// Filter the rendered camera cards by the search query
const filteredCameraActivities = useMemo(() => {
const query = cameraSearch.trim().toLowerCase();
if (!query) {
return cameraActivities;
}
return cameraActivities.filter((activity) => {
const friendlyName = resolveCameraName(config, activity.camera);
return (
activity.camera.toLowerCase().includes(query) ||
friendlyName.toLowerCase().includes(query)
);
});
}, [cameraActivities, cameraSearch, config]);
// Group/all/activity selection replaces the current selection
const applyCameraSelection = useCallback((cameraIds: string[]) => {
setHasManualCameraSelection(true);
setSelectedCameraIds(cameraIds);
setCameraMenuOpen(false);
}, []);
// Close the dropdown when focus leaves the camera selection control entirely
const handleCameraInputBlur = useCallback((event: React.FocusEvent) => {
if (
cameraMenuRef.current &&
!cameraMenuRef.current.contains(event.relatedTarget as Node)
) {
setCameraMenuOpen(false);
}
}, []);
// Reset the search and dropdown when leaving the multi-camera tab
useEffect(() => {
if (activeTab !== "multi") {
setCameraSearch("");
setCameraMenuOpen(false);
}
}, [activeTab]);
const startBatchExport = useCallback(async () => { const startBatchExport = useCallback(async () => {
if (isStartingBatchExport) { if (isStartingBatchExport) {
return; return;
@ -884,7 +802,7 @@ export function ExportContent({
{isAdmin && ( {isAdmin && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm text-primary"> <Label className="text-sm text-secondary-foreground">
{t("export.case.label")} {t("export.case.label")}
</Label> </Label>
<Select <Select
@ -941,7 +859,7 @@ export function ExportContent({
)} )}
> >
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm text-primary"> <Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.timeRange")} {t("export.multiCamera.timeRange")}
</Label> </Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -984,109 +902,16 @@ export function ExportContent({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-2"> <Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary"> {t("export.multiCamera.cameraSelection")}
{t("export.multiCamera.cameraSelection")} </Label>
</Label>
{availableCameraIds.length > 0 && (
<span className="text-xs text-muted-foreground">
{t("export.multiCamera.selectedCount", {
selected: selectedCameraCount,
total: availableCameraIds.length,
})}
</span>
)}
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{t("export.multiCamera.cameraSelectionHelp")} {t("export.multiCamera.cameraSelectionHelp")}
</div> </div>
{!isEventsLoading && availableCameraIds.length > 0 && (
<div className="relative" ref={cameraMenuRef}>
<Command
shouldFilter={false}
className="overflow-visible rounded-md border bg-secondary/40"
>
<CommandInput
value={cameraSearch}
onValueChange={setCameraSearch}
onFocus={() => setCameraMenuOpen(true)}
onBlur={handleCameraInputBlur}
placeholder={t("export.multiCamera.searchOrSelectGroup")}
/>
{/* Hide the actions/groups menu while a search query is
active so it doesn't cover the filtered camera cards. */}
{cameraMenuOpen && cameraSearch.trim().length === 0 && (
<CommandList className="absolute top-full z-10 mt-1 max-h-72 w-full rounded-md border bg-background shadow-md">
<CommandGroup>
<CommandItem
value="action:select-all"
className="cursor-pointer"
onSelect={() =>
applyCameraSelection(availableCameraIds)
}
>
<span>{t("export.multiCamera.selectAll")}</span>
<span className="ml-auto text-xs text-muted-foreground">
{availableCameraIds.length}
</span>
</CommandItem>
<CommandItem
value="action:clear"
className="cursor-pointer"
onSelect={() => applyCameraSelection([])}
>
{t("export.multiCamera.clearSelection")}
</CommandItem>
<CommandItem
value="action:activity"
className="cursor-pointer"
onSelect={() => applyCameraSelection(activeCameraIds)}
>
<span>
{t("export.multiCamera.selectWithActivity")}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{activeCameraIds.length}
</span>
</CommandItem>
</CommandGroup>
{cameraGroups.length > 0 && (
<>
<CommandSeparator />
<CommandGroup
heading={t("export.multiCamera.selectGroup")}
>
{cameraGroups.map((group) => (
<CommandItem
key={group.name}
value={`group:${group.name}`}
className="cursor-pointer"
onSelect={() =>
applyCameraSelection(group.cameras)
}
>
<IconRenderer
icon={LuIcons[group.icon]}
className="mr-2 size-4 text-secondary-foreground"
/>
<span className="truncate">{group.name}</span>
<span className="ml-auto text-xs text-muted-foreground">
{group.cameras.length}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
)}
</Command>
</div>
)}
<div <div
className={cn( className={cn(
"scrollbar-container space-y-2", "scrollbar-container space-y-2",
isDesktop && "max-h-64 overflow-y-auto p-0.5 pr-1", isDesktop && "max-h-64 overflow-y-auto pr-1",
)} )}
> >
{isEventsLoading && ( {isEventsLoading && (
@ -1099,14 +924,7 @@ export function ExportContent({
{t("export.multiCamera.noCameras")} {t("export.multiCamera.noCameras")}
</div> </div>
)} )}
{!isEventsLoading && {cameraActivities.map((activity) => {
cameraActivities.length > 0 &&
filteredCameraActivities.length === 0 && (
<div className="px-2 py-4 text-sm text-muted-foreground">
{t("export.multiCamera.noMatchingCameras")}
</div>
)}
{filteredCameraActivities.map((activity) => {
const isSelected = selectedCameraIds.includes(activity.camera); const isSelected = selectedCameraIds.includes(activity.camera);
return ( return (
@ -1163,7 +981,7 @@ export function ExportContent({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm text-primary"> <Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.nameLabel")} {t("export.multiCamera.nameLabel")}
</Label> </Label>
<Input <Input
@ -1176,7 +994,7 @@ export function ExportContent({
{isAdmin && ( {isAdmin && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm text-primary"> <Label className="text-sm text-secondary-foreground">
{t("export.case.label")} {t("export.case.label")}
</Label> </Label>
<Select <Select