Compare commits

...

6 Commits

Author SHA1 Message Date
Nicolas Mowen
a510ea9036
Review card refactor (#20813)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Use the review card in event timeline popover

* Show review title in review card
2025-11-05 09:48:47 -06:00
Josh Hawkins
e1bc7360ad
Form validation tweaks (#20812)
* Always show ID field when editing a trigger

* use onBlur method for form validation

this will prevent the trigger ID from expanding too soon when a user is typing the friendly name
2025-11-05 09:18:10 -06:00
Josh Hawkins
4638c22c16
UI tweaks (#20811)
* camera wizard input mobile font zooming

* ensure the selected page is visible when navigating via url on mobile

* Filter detail stream to only show items from within the review item

* remove incorrect classes causing extra scroll in detail stream

* change button label

* fix mobile menu button highlight issue

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2025-11-05 07:49:31 -07:00
Nicolas Mowen
81faa8899d
Classification Improvements (#20807)
* Don't show model selection or back button when in multi select mode

* Add dialog to edit classification models

* Fix header spacing

* Cleanup desktop

* Incrase max number of object classifications

* fix iOS mobile card

* Cleanup
2025-11-05 07:11:12 -07:00
Nicolas Mowen
043bd9e6ee
Fix jetson build (#20808)
* Fix jetson build

* Set numpy version in model wheels

* Use constraint instead

* Simplify
2025-11-05 07:10:56 -07:00
Artem Vladimirov
9f0b6004f2
fix: add pluralization for deletedModel toast message (#20803)
* fix: add pluralization for deletedModel toast message

* revert ru translation

---------

Co-authored-by: Artem Vladimirov <a.vladimirov@small.kz>
2025-11-05 05:02:54 -07:00
20 changed files with 670 additions and 105 deletions

View File

@ -5,6 +5,12 @@ set -euxo pipefail
SQLITE3_VERSION="3.46.1"
PYSQLITE3_VERSION="0.5.3"
# Install libsqlite3-dev if not present (needed for some base images like NVIDIA TensorRT)
if ! dpkg -l | grep -q libsqlite3-dev; then
echo "Installing libsqlite3-dev for compilation..."
apt-get update && apt-get install -y libsqlite3-dev && rm -rf /var/lib/apt/lists/*
fi
# Fetch the pre-built sqlite amalgamation instead of building from source
if [[ ! -d "sqlite" ]]; then
mkdir sqlite

View File

@ -112,7 +112,7 @@ RUN apt-get update \
&& apt-get install -y protobuf-compiler libprotobuf-dev \
&& rm -rf /var/lib/apt/lists/*
RUN --mount=type=bind,source=docker/tensorrt/requirements-models-arm64.txt,target=/requirements-tensorrt-models.txt \
pip3 wheel --wheel-dir=/trt-model-wheels -r /requirements-tensorrt-models.txt
pip3 wheel --wheel-dir=/trt-model-wheels --no-deps -r /requirements-tensorrt-models.txt
FROM wget AS jetson-ffmpeg
ARG DEBIAN_FRONTEND
@ -145,7 +145,8 @@ COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
pip3 uninstall -y onnxruntime \
&& pip3 install -U /deps/trt-wheels/*.whl /deps/trt-model-wheels/*.whl \
&& pip3 install -U /deps/trt-wheels/*.whl \
&& pip3 install -U /deps/trt-model-wheels/*.whl \
&& ldconfig
WORKDIR /opt/frigate/

View File

@ -466,6 +466,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
now,
self.labelmap[best_id],
score,
max_files=200,
)
if score < self.model_config.threshold:
@ -529,6 +530,7 @@ def write_classification_attempt(
timestamp: float,
label: str,
score: float,
max_files: int = 100,
) -> None:
if "-" in label:
label = label.replace("-", "_")
@ -544,5 +546,5 @@ def write_classification_attempt(
)
# delete oldest face image if maximum is reached
if len(files) > 100:
if len(files) > max_files:
os.unlink(os.path.join(folder, files[-1]))

View File

@ -10,23 +10,27 @@
"deleteImages": "Delete Images",
"trainModel": "Train Model",
"addClassification": "Add Classification",
"deleteModels": "Delete Models"
"deleteModels": "Delete Models",
"editModel": "Edit Model"
},
"toast": {
"success": {
"deletedCategory": "Deleted Class",
"deletedImage": "Deleted Images",
"deletedModel": "Successfully deleted {{count}} model(s)",
"deletedModel_one": "Successfully deleted {{count}} model",
"deletedModel_other": "Successfully deleted {{count}} models",
"categorizedImage": "Successfully Classified Image",
"trainedModel": "Successfully trained model.",
"trainingModel": "Successfully started model training."
"trainingModel": "Successfully started model training.",
"updatedModel": "Successfully updated model configuration"
},
"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}}"
"trainingFailed": "Failed to start model training: {{errorMessage}}",
"updateModelFailed": "Failed to update model: {{errorMessage}}"
}
},
"deleteCategory": {
@ -38,6 +42,12 @@
"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."
},
"edit": {
"title": "Edit Classification Model",
"descriptionState": "Edit the classes for this state classification model. Changes will require retraining the model.",
"descriptionObject": "Edit the object type and classification type for this object classification model.",
"stateClassesInfo": "Note: Changing state classes requires retraining the model with the updated classes."
},
"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."

View File

@ -7,7 +7,7 @@ import {
} from "@/types/classification";
import { Event } from "@/types/event";
import { forwardRef, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@ -264,8 +264,8 @@ export function GroupedClassificationCard({
const Overlay = isDesktop ? Dialog : MobilePage;
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
const Header = isDesktop ? DialogHeader : MobilePageHeader;
const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : MobilePageHeader;
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
const ContentDescription = isDesktop
? DialogDescription
@ -298,9 +298,9 @@ export function GroupedClassificationCard({
<Trigger asChild></Trigger>
<Content
className={cn(
"",
"scrollbar-container",
isDesktop && "min-w-[50%] max-w-[65%]",
isMobile && "flex flex-col",
isMobile && "overflow-y-auto",
)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
@ -308,16 +308,16 @@ export function GroupedClassificationCard({
<Header
className={cn(
"mx-2 flex flex-row items-center gap-4",
isMobile && "flex-shrink-0",
isMobileOnly && "top-0 mx-4",
)}
>
<div>
<ContentTitle
className={cn(
"flex items-center gap-2 font-normal capitalize",
isMobile && "px-2",
)}
>
<div
className={cn(
"",
isMobile && "flex flex-col items-center justify-center",
)}
>
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
{event?.sub_label && event.sub_label !== "none"
? event.sub_label
: t(noClassificationLabel)}
@ -390,7 +390,7 @@ export function GroupedClassificationCard({
className={cn(
"grid w-full auto-rows-min grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-8",
isDesktop && "p-2",
isMobile && "scrollbar-container flex-1 overflow-y-auto",
isMobile && "px-4 pb-4",
)}
>
{group.map((data: ClassificationItemData) => (

View File

@ -38,6 +38,7 @@ import { Button, buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { LuCircle } from "react-icons/lu";
import { MdAutoAwesome } from "react-icons/md";
type ReviewCardProps = {
event: ReviewSegment;
@ -164,29 +165,33 @@ export default function ReviewCard({
<div className="flex items-center justify-between">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-evenly gap-1">
<>
<LuCircle
className={cn(
"size-2",
event.severity == "alert"
? "fill-severity_alert text-severity_alert"
: "fill-severity_detection text-severity_detection",
)}
/>
{event.data.objects.map((object) => {
return getIconForLabel(
object,
"size-3 text-primary dark:text-white",
);
})}
{event.data.audio.map((audio) => {
return getIconForLabel(
audio,
"size-3 text-primary dark:text-white",
);
})}
</>
<div className="flex items-center gap-2">
<LuCircle
className={cn(
"size-2",
event.severity == "alert"
? "fill-severity_alert text-severity_alert"
: "fill-severity_detection text-severity_detection",
)}
/>
<div className="flex items-center gap-1">
{event.data.objects.map((object, idx) => (
<div
key={`${object}-${idx}`}
className="rounded-full bg-muted-foreground p-1"
>
{getIconForLabel(object, "size-3 text-white")}
</div>
))}
{event.data.audio.map((audio, idx) => (
<div
key={`${audio}-${idx}`}
className="rounded-full bg-muted-foreground p-1"
>
{getIconForLabel(audio, "size-3 text-white")}
</div>
))}
</div>
<div className="font-extra-light text-xs">{formattedDate}</div>
</div>
</TooltipTrigger>
@ -213,6 +218,14 @@ export default function ReviewCard({
dense
/>
</div>
{event.data.metadata?.title && (
<div className="flex items-center gap-1.5 rounded bg-secondary/50">
<MdAutoAwesome className="size-3 shrink-0 text-primary" />
<span className="truncate text-xs text-primary">
{event.data.metadata.title}
</span>
</div>
)}
</div>
);

View File

@ -0,0 +1,477 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CustomClassificationModelConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import { getTranslatedLabel } from "@/utils/i18n";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { LuPlus, LuX } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
import { z } from "zod";
type ClassificationModelEditDialogProps = {
open: boolean;
model: CustomClassificationModelConfig;
onClose: () => void;
onSuccess: () => void;
};
type ObjectClassificationType = "sub_label" | "attribute";
type ObjectFormData = {
objectLabel: string;
objectType: ObjectClassificationType;
};
type StateFormData = {
classes: string[];
};
export default function ClassificationModelEditDialog({
open,
model,
onClose,
onSuccess,
}: ClassificationModelEditDialogProps) {
const { t } = useTranslation(["views/classificationModel"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [isSaving, setIsSaving] = useState(false);
const isStateModel = model.state_config !== undefined;
const isObjectModel = model.object_config !== undefined;
const objectLabels = useMemo(() => {
if (!config) return [];
const labels = new Set<string>();
Object.values(config.cameras).forEach((cameraConfig) => {
if (!cameraConfig.enabled || !cameraConfig.enabled_in_config) {
return;
}
cameraConfig.objects.track.forEach((label) => {
if (!config.model.all_attributes.includes(label)) {
labels.add(label);
}
});
});
return [...labels].sort();
}, [config]);
// Define form schema based on model type
const formSchema = useMemo(() => {
if (isObjectModel) {
return z.object({
objectLabel: z
.string()
.min(1, t("wizard.step1.errors.objectLabelRequired")),
objectType: z.enum(["sub_label", "attribute"]),
});
} else {
// State model
return z.object({
classes: z
.array(z.string())
.min(1, t("wizard.step1.errors.classRequired"))
.refine(
(classes) => {
const nonEmpty = classes.filter((c) => c.trim().length > 0);
return nonEmpty.length >= 2;
},
{ message: t("wizard.step1.errors.stateRequiresTwoClasses") },
)
.refine(
(classes) => {
const nonEmpty = classes.filter((c) => c.trim().length > 0);
const unique = new Set(nonEmpty.map((c) => c.toLowerCase()));
return unique.size === nonEmpty.length;
},
{ message: t("wizard.step1.errors.classesUnique") },
),
});
}
}, [isObjectModel, t]);
const form = useForm<ObjectFormData | StateFormData>({
resolver: zodResolver(formSchema),
defaultValues: isObjectModel
? ({
objectLabel: model.object_config?.objects?.[0] || "",
objectType:
(model.object_config
?.classification_type as ObjectClassificationType) || "sub_label",
} as ObjectFormData)
: ({
classes: [""], // Will be populated from dataset
} as StateFormData),
mode: "onChange",
});
// Fetch dataset to get current classes for state models
const { data: dataset } = useSWR<{
[id: string]: string[];
}>(isStateModel ? `classification/${model.name}/dataset` : null, {
revalidateOnFocus: false,
});
// Update form with classes from dataset when loaded
useEffect(() => {
if (isStateModel && dataset) {
const classes = Object.keys(dataset).filter((key) => key !== "none");
if (classes.length > 0) {
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
"classes",
classes,
);
}
}
}, [dataset, isStateModel, form]);
const watchedClasses = isStateModel
? (form as ReturnType<typeof useForm<StateFormData>>).watch("classes")
: undefined;
const watchedObjectType = isObjectModel
? (form as ReturnType<typeof useForm<ObjectFormData>>).watch("objectType")
: undefined;
const handleAddClass = useCallback(() => {
const currentClasses = (
form as ReturnType<typeof useForm<StateFormData>>
).getValues("classes");
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
"classes",
[...currentClasses, ""],
{
shouldValidate: true,
},
);
}, [form]);
const handleRemoveClass = useCallback(
(index: number) => {
const currentClasses = (
form as ReturnType<typeof useForm<StateFormData>>
).getValues("classes");
const newClasses = currentClasses.filter((_, i) => i !== index);
// Ensure at least one field remains (even if empty)
if (newClasses.length === 0) {
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
"classes",
[""],
{ shouldValidate: true },
);
} else {
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
"classes",
newClasses,
{ shouldValidate: true },
);
}
},
[form],
);
const onSubmit = useCallback(
async (data: ObjectFormData | StateFormData) => {
setIsSaving(true);
try {
if (isObjectModel) {
const objectData = data as ObjectFormData;
// Update the config
await axios.put("/config/set", {
requires_restart: 0,
update_topic: `config/classification/custom/${model.name}`,
config_data: {
classification: {
custom: {
[model.name]: {
enabled: model.enabled,
name: model.name,
threshold: model.threshold,
object_config: {
objects: [objectData.objectLabel],
classification_type: objectData.objectType,
},
},
},
},
},
});
toast.success(t("toast.success.updatedModel"), {
position: "top-center",
});
} else {
// State model - update classes
// Note: For state models, updating classes requires renaming categories
// which is handled through the dataset API, not the config API
// We'll need to implement this by calling the rename endpoint for each class
// For now, we just show a message that this requires retraining
toast.info(t("edit.stateClassesInfo"), {
position: "top-center",
});
}
onSuccess();
onClose();
} catch (err) {
const error = err as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.updateModelFailed", { errorMessage }), {
position: "top-center",
});
} finally {
setIsSaving(false);
}
},
[isObjectModel, model, t, onSuccess, onClose],
);
const handleCancel = useCallback(() => {
form.reset();
onClose();
}, [form, onClose]);
return (
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("edit.title")}</DialogTitle>
<DialogDescription>
{isStateModel
? t("edit.descriptionState")
: t("edit.descriptionObject")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{isObjectModel && (
<>
<FormField
control={form.control}
name="objectLabel"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("wizard.step1.objectLabel")}
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="h-8">
<SelectValue
placeholder={t(
"wizard.step1.objectLabelPlaceholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{objectLabels.map((label) => (
<SelectItem
key={label}
value={label}
className="cursor-pointer hover:bg-secondary-highlight"
>
{getTranslatedLabel(label)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="objectType"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("wizard.step1.classificationType")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col gap-4 pt-2"
>
<div className="flex items-center gap-2">
<RadioGroupItem
className={
watchedObjectType === "sub_label"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id="sub_label"
value="sub_label"
/>
<Label
className="cursor-pointer"
htmlFor="sub_label"
>
{t("wizard.step1.classificationSubLabel")}
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
className={
watchedObjectType === "attribute"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id="attribute"
value="attribute"
/>
<Label
className="cursor-pointer"
htmlFor="attribute"
>
{t("wizard.step1.classificationAttribute")}
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{isStateModel && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel className="text-primary-variant">
{t("wizard.step1.states")}
</FormLabel>
<Button
type="button"
variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
onClick={handleAddClass}
>
<LuPlus />
</Button>
</div>
<div className="space-y-2">
{watchedClasses?.map((_: string, index: number) => (
<FormField
key={index}
control={
(form as ReturnType<typeof useForm<StateFormData>>)
.control
}
name={`classes.${index}` as const}
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Input
className="text-md h-8"
placeholder={t(
"wizard.step1.classPlaceholder",
)}
{...field}
/>
{watchedClasses &&
watchedClasses.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveClass(index)}
>
<LuX className="size-4" />
</Button>
)}
</div>
</FormControl>
</FormItem>
)}
/>
))}
</div>
{isStateModel &&
"classes" in form.formState.errors &&
form.formState.errors.classes && (
<p className="text-sm font-medium text-destructive">
{form.formState.errors.classes.message}
</p>
)}
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button
type="button"
onClick={handleCancel}
className="sm:flex-1"
disabled={isSaving}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
disabled={!form.formState.isValid || isSaving}
>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -25,6 +25,7 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
processId?: (name: string) => string;
placeholderName?: string;
placeholderId?: string;
idVisible?: boolean;
};
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
@ -39,10 +40,11 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
processId,
placeholderName,
placeholderId,
idVisible,
}: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]);
const { watch, setValue, trigger, formState } = useFormContext<T>();
const [isIdVisible, setIsIdVisible] = useState(false);
const [isIdVisible, setIsIdVisible] = useState(idVisible ?? false);
const hasUserTypedRef = useRef(false);
const defaultProcessId = (name: string) => {

View File

@ -258,6 +258,7 @@ export default function CreateTriggerDialog({
nameLabel={t("triggers.dialog.form.name.title")}
nameDescription={t("triggers.dialog.form.name.description")}
placeholderName={t("triggers.dialog.form.name.placeholder")}
idVisible={!!trigger}
/>
<FormField

View File

@ -385,7 +385,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="h-8"
className="text-md h-8"
placeholder={t(
"cameraWizard.step1.cameraNamePlaceholder",
)}
@ -475,7 +475,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="h-8"
className="text-md h-8"
placeholder="192.168.1.100"
{...field}
/>
@ -495,7 +495,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="h-8"
className="text-md h-8"
placeholder={t(
"cameraWizard.step1.usernamePlaceholder",
)}
@ -518,7 +518,7 @@ export default function Step1NameCamera({
<FormControl>
<div className="relative">
<Input
className="h-8 pr-10"
className="text-md h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
@ -558,7 +558,7 @@ export default function Step1NameCamera({
</FormLabel>
<FormControl>
<Input
className="h-8"
className="text-md h-8"
placeholder="rtsp://username:password@host:port/path"
{...field}
/>

View File

@ -22,6 +22,7 @@ import {
LuChevronRight,
LuSettings,
} from "react-icons/lu";
import { MdAutoAwesome } from "react-icons/md";
import { getTranslatedLabel } from "@/utils/i18n";
import EventMenu from "@/components/timeline/EventMenu";
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
@ -410,8 +411,9 @@ function ReviewGroup({
</div>
<div className="flex flex-col gap-0.5">
{review.data.metadata?.title && (
<div className="mb-1 text-sm text-primary-variant">
{review.data.metadata.title}
<div className="mb-1 flex items-center gap-1 text-sm text-primary-variant">
<MdAutoAwesome className="size-3 shrink-0" />
<span className="truncate">{review.data.metadata.title}</span>
</div>
)}
<div className="flex flex-row items-center gap-1.5">
@ -458,6 +460,7 @@ function ReviewGroup({
<EventList
key={event.id}
event={event}
review={review}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
onSeek={onSeek}
@ -492,6 +495,7 @@ function ReviewGroup({
type EventListProps = {
event: Event;
review: ReviewSegment;
effectiveTime?: number;
annotationOffset: number;
onSeek: (ts: number, play?: boolean) => void;
@ -499,6 +503,7 @@ type EventListProps = {
};
function EventList({
event,
review,
effectiveTime,
annotationOffset,
onSeek,
@ -617,6 +622,7 @@ function EventList({
<div className="mt-2">
<ObjectTimeline
review={review}
eventId={event.id}
onSeek={handleTimelineClick}
effectiveTime={effectiveTime}
@ -765,6 +771,7 @@ function LifecycleItem({
// Fetch and render timeline entries for a single event id on demand.
function ObjectTimeline({
review,
eventId,
onSeek,
effectiveTime,
@ -772,6 +779,7 @@ function ObjectTimeline({
startTime,
endTime,
}: {
review: ReviewSegment;
eventId: string;
onSeek: (ts: number, play?: boolean) => void;
effectiveTime?: number;
@ -780,13 +788,27 @@ function ObjectTimeline({
endTime?: number;
}) {
const { t } = useTranslation("views/events");
const { data: timeline, isValidating } = useSWR<TrackingDetailsSequence[]>([
const { data: fullTimeline, isValidating } = useSWR<
TrackingDetailsSequence[]
>([
"timeline",
{
source_id: eventId,
},
]);
const timeline = useMemo(() => {
if (!fullTimeline) {
return fullTimeline;
}
return fullTimeline.filter(
(t) =>
t.timestamp >= review.start_time &&
(review.end_time == undefined || t.timestamp <= review.end_time),
);
}, [fullTimeline, review]);
if (isValidating && (!timeline || timeline.length === 0)) {
return <ActivityIndicator className="ml-2 size-3" />;
}

View File

@ -1,4 +1,3 @@
import { useApiHost } from "@/api";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
@ -18,6 +17,7 @@ import { HoverCardPortal } from "@radix-ui/react-hover-card";
import scrollIntoView from "scroll-into-view-if-needed";
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import useTapUtils from "@/hooks/use-tap-utils";
import ReviewCard from "../card/ReviewCard";
type EventSegmentProps = {
events: ReviewSegment[];
@ -54,7 +54,7 @@ export function EventSegment({
displaySeverityType,
shouldShowRoundedCorners,
getEventStart,
getEventThumbnail,
getEvent,
} = useEventSegmentUtils(segmentDuration, events, severityType);
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
@ -87,13 +87,11 @@ export function EventSegment({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getEventStart, segmentTime]);
const apiHost = useApiHost();
const { handleTouchStart } = useTapUtils();
const eventThumbnail = useMemo(() => {
return getEventThumbnail(segmentTime);
}, [getEventThumbnail, segmentTime]);
const segmentEvent = useMemo(() => {
return getEvent(segmentTime);
}, [getEvent, segmentTime]);
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
const segmentKey = useMemo(
@ -252,10 +250,7 @@ export function EventSegment({
className="w-[250px] rounded-lg p-2 md:rounded-2xl"
side="left"
>
<img
className="rounded-lg"
src={`${apiHost}${eventThumbnail.replace("/media/frigate/", "")}`}
/>
{segmentEvent && <ReviewCard event={segmentEvent} />}
</HoverCardContent>
</HoverCardPortal>
</HoverCard>

View File

@ -101,7 +101,7 @@ export default function Step1NameAndType({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
mode: "onBlur",
defaultValues: {
enabled: true,
name: initialData?.name ?? trigger?.name ?? "",

View File

@ -191,8 +191,8 @@ export const useEventSegmentUtils = (
[events, getSegmentStart, getSegmentEnd, severityType],
);
const getEventThumbnail = useCallback(
(time: number): string => {
const getEvent = useCallback(
(time: number): ReviewSegment | undefined => {
const matchingEvent = events.find((event) => {
return (
time >= getSegmentStart(event.start_time) &&
@ -201,7 +201,7 @@ export const useEventSegmentUtils = (
);
});
return matchingEvent?.thumb_path ?? "";
return matchingEvent;
},
[events, getSegmentStart, getSegmentEnd, severityType],
);
@ -214,6 +214,6 @@ export const useEventSegmentUtils = (
getReviewed,
shouldShowRoundedCorners,
getEventStart,
getEventThumbnail,
getEvent,
};
};

View File

@ -157,9 +157,11 @@ function MobileMenuItem({
const { t } = useTranslation(["views/settings"]);
return (
<Button
variant="ghost"
className={cn("w-full justify-between pr-2", className)}
<div
className={cn(
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
className,
)}
onClick={() => {
onSelect(item.key);
onClose?.();
@ -167,7 +169,7 @@ function MobileMenuItem({
>
<div className="smart-capitalize">{t("menu." + item.key)}</div>
<LuChevronRight className="size-4" />
</Button>
</div>
);
}
@ -273,6 +275,9 @@ export default function Settings() {
} else {
setPageToggle(page as SettingsType);
}
if (isMobile) {
setContentMobileOpen(true);
}
}
// don't clear url params if we're creating a new object mask
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
@ -282,6 +287,9 @@ export default function Settings() {
const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
if (isMobile) {
setContentMobileOpen(true);
}
}
// don't clear url params if we're creating a new object mask or trigger
return !(searchParams.has("object_mask") || searchParams.has("event_id"));

View File

@ -306,6 +306,7 @@ export type CustomClassificationModelConfig = {
threshold: number;
object_config?: {
objects: string[];
classification_type: string;
};
state_config?: {
cameras: {

View File

@ -1,5 +1,6 @@
import { baseUrl } from "@/api/baseUrl";
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
import ClassificationModelEditDialog from "@/components/classification/ClassificationModelEditDialog";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
import { Button, buttonVariants } from "@/components/ui/button";
@ -14,7 +15,7 @@ import { useCallback, useEffect, useMemo, 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 { LuPencil, LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi";
import useSWR from "swr";
import Heading from "@/components/ui/heading";
@ -163,6 +164,7 @@ export default function ModelSelectionView({
key={config.name}
config={config}
onClick={() => onClick(config)}
onUpdate={() => refreshConfig()}
onDelete={() => refreshConfig()}
/>
))}
@ -201,9 +203,10 @@ function NoModelsView({
type ModelCardProps = {
config: CustomClassificationModelConfig;
onClick: () => void;
onUpdate: () => void;
onDelete: () => void;
};
function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
const { t } = useTranslation(["views/classificationModel"]);
const { data: dataset } = useSWR<{
@ -211,6 +214,7 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const handleDelete = useCallback(async () => {
try {
@ -250,6 +254,11 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
setDeleteDialogOpen(true);
}, []);
const handleEditClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setEditDialogOpen(true);
}, []);
const coverImage = useMemo(() => {
if (!dataset) {
return undefined;
@ -270,6 +279,13 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
return (
<>
<ClassificationModelEditDialog
open={editDialogOpen}
model={config}
onClose={() => setEditDialogOpen(false)}
onSuccess={() => onUpdate()}
/>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
@ -320,6 +336,10 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
align="end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={handleEditClick}>
<LuPencil className="mr-2 size-4" />
<span>{t("button.edit", { ns: "common" })}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDeleteClick}>
<LuTrash2 className="mr-2 size-4" />
<span>{t("button.delete", { ns: "common" })}</span>

View File

@ -327,31 +327,39 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
</AlertDialog>
<div className="flex flex-row justify-between gap-2 p-2 align-middle">
<div className="flex flex-row items-center justify-center gap-2">
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
<LibrarySelector
pageToggle={pageToggle}
dataset={dataset || {}}
trainImages={trainImages || []}
setPageToggle={setPageToggle}
onDelete={onDelete}
onRename={() => {}}
/>
</div>
{(isDesktop || !selectedImages?.length) && (
<div className="flex flex-row items-center justify-center gap-2">
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
<LibrarySelector
pageToggle={pageToggle}
dataset={dataset || {}}
trainImages={trainImages || []}
setPageToggle={setPageToggle}
onDelete={onDelete}
onRename={() => {}}
/>
</div>
)}
{selectedImages?.length > 0 ? (
<div className="flex items-center justify-center gap-2">
<div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
<div
className={cn(
"flex w-full items-center justify-end gap-2",
isMobileOnly && "justify-between",
)}
>
<div className="flex w-48 items-center justify-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedImages.length} selected`}</div>
<div className="p-1">{"|"}</div>
<div

View File

@ -970,12 +970,11 @@ function Timeline({
"relative overflow-hidden",
isDesktop
? cn(
"no-scrollbar overflow-y-auto",
timelineType == "timeline"
? "w-[100px] flex-shrink-0"
: timelineType == "detail"
? "min-w-[20rem] max-w-[30%] flex-shrink-0 flex-grow-0 basis-[30rem] md:min-w-[20rem] md:max-w-[25%] lg:min-w-[30rem] lg:max-w-[33%]"
: "w-60 flex-shrink-0",
: "w-80 flex-shrink-0",
)
: cn(
timelineType == "timeline"

View File

@ -717,11 +717,11 @@ export default function CameraSettingsView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
type="button"
>
<Trans>button.cancel</Trans>
<Trans>button.reset</Trans>
</Button>
<Button
variant="select"