Compare commits

..

No commits in common. "a510ea903675327dbb1d752403efbc066fe29272" and "b75122847668d6c500c95178e5fc767328c2945e" have entirely different histories.

20 changed files with 105 additions and 670 deletions

View File

@ -5,12 +5,6 @@ set -euxo pipefail
SQLITE3_VERSION="3.46.1" SQLITE3_VERSION="3.46.1"
PYSQLITE3_VERSION="0.5.3" 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 # Fetch the pre-built sqlite amalgamation instead of building from source
if [[ ! -d "sqlite" ]]; then if [[ ! -d "sqlite" ]]; then
mkdir sqlite mkdir sqlite

View File

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

View File

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

View File

@ -10,27 +10,23 @@
"deleteImages": "Delete Images", "deleteImages": "Delete Images",
"trainModel": "Train Model", "trainModel": "Train Model",
"addClassification": "Add Classification", "addClassification": "Add Classification",
"deleteModels": "Delete Models", "deleteModels": "Delete Models"
"editModel": "Edit Model"
}, },
"toast": { "toast": {
"success": { "success": {
"deletedCategory": "Deleted Class", "deletedCategory": "Deleted Class",
"deletedImage": "Deleted Images", "deletedImage": "Deleted Images",
"deletedModel_one": "Successfully deleted {{count}} model", "deletedModel": "Successfully deleted {{count}} model(s)",
"deletedModel_other": "Successfully deleted {{count}} models",
"categorizedImage": "Successfully Classified Image", "categorizedImage": "Successfully Classified Image",
"trainedModel": "Successfully trained model.", "trainedModel": "Successfully trained model.",
"trainingModel": "Successfully started model training.", "trainingModel": "Successfully started model training."
"updatedModel": "Successfully updated model configuration"
}, },
"error": { "error": {
"deleteImageFailed": "Failed to delete: {{errorMessage}}", "deleteImageFailed": "Failed to delete: {{errorMessage}}",
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}", "deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
"deleteModelFailed": "Failed to delete model: {{errorMessage}}", "deleteModelFailed": "Failed to delete model: {{errorMessage}}",
"categorizeFailed": "Failed to categorize image: {{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": { "deleteCategory": {
@ -42,12 +38,6 @@
"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.", "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." "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": { "deleteDatasetImages": {
"title": "Delete Dataset Images", "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." "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"; } from "@/types/classification";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { forwardRef, useMemo, useRef, useState } from "react"; import { forwardRef, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@ -264,8 +264,8 @@ export function GroupedClassificationCard({
const Overlay = isDesktop ? Dialog : MobilePage; const Overlay = isDesktop ? Dialog : MobilePage;
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger; const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : MobilePageHeader; const Header = isDesktop ? DialogHeader : MobilePageHeader;
const Content = isDesktop ? DialogContent : MobilePageContent;
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle; const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
const ContentDescription = isDesktop const ContentDescription = isDesktop
? DialogDescription ? DialogDescription
@ -298,9 +298,9 @@ export function GroupedClassificationCard({
<Trigger asChild></Trigger> <Trigger asChild></Trigger>
<Content <Content
className={cn( className={cn(
"scrollbar-container", "",
isDesktop && "min-w-[50%] max-w-[65%]", isDesktop && "min-w-[50%] max-w-[65%]",
isMobile && "overflow-y-auto", isMobile && "flex flex-col",
)} )}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
@ -308,16 +308,16 @@ export function GroupedClassificationCard({
<Header <Header
className={cn( className={cn(
"mx-2 flex flex-row items-center gap-4", "mx-2 flex flex-row items-center gap-4",
isMobileOnly && "top-0 mx-4", isMobile && "flex-shrink-0",
)} )}
> >
<div <div>
<ContentTitle
className={cn( className={cn(
"", "flex items-center gap-2 font-normal capitalize",
isMobile && "flex flex-col items-center justify-center", isMobile && "px-2",
)} )}
> >
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
{event?.sub_label && event.sub_label !== "none" {event?.sub_label && event.sub_label !== "none"
? event.sub_label ? event.sub_label
: t(noClassificationLabel)} : t(noClassificationLabel)}
@ -390,7 +390,7 @@ export function GroupedClassificationCard({
className={cn( 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", "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", isDesktop && "p-2",
isMobile && "px-4 pb-4", isMobile && "scrollbar-container flex-1 overflow-y-auto",
)} )}
> >
{group.map((data: ClassificationItemData) => ( {group.map((data: ClassificationItemData) => (

View File

@ -38,7 +38,6 @@ import { Button, buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LuCircle } from "react-icons/lu"; import { LuCircle } from "react-icons/lu";
import { MdAutoAwesome } from "react-icons/md";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -165,7 +164,8 @@ export default function ReviewCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-2"> <div className="flex items-center justify-evenly gap-1">
<>
<LuCircle <LuCircle
className={cn( className={cn(
"size-2", "size-2",
@ -174,24 +174,19 @@ export default function ReviewCard({
: "fill-severity_detection text-severity_detection", : "fill-severity_detection text-severity_detection",
)} )}
/> />
<div className="flex items-center gap-1"> {event.data.objects.map((object) => {
{event.data.objects.map((object, idx) => ( return getIconForLabel(
<div object,
key={`${object}-${idx}`} "size-3 text-primary dark:text-white",
className="rounded-full bg-muted-foreground p-1" );
> })}
{getIconForLabel(object, "size-3 text-white")} {event.data.audio.map((audio) => {
</div> return getIconForLabel(
))} audio,
{event.data.audio.map((audio, idx) => ( "size-3 text-primary dark:text-white",
<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 className="font-extra-light text-xs">{formattedDate}</div>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
@ -218,14 +213,6 @@ export default function ReviewCard({
dense dense
/> />
</div> </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> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -157,11 +157,9 @@ function MobileMenuItem({
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
return ( return (
<div <Button
className={cn( variant="ghost"
"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={cn("w-full justify-between pr-2", className)}
className,
)}
onClick={() => { onClick={() => {
onSelect(item.key); onSelect(item.key);
onClose?.(); onClose?.();
@ -169,7 +167,7 @@ function MobileMenuItem({
> >
<div className="smart-capitalize">{t("menu." + item.key)}</div> <div className="smart-capitalize">{t("menu." + item.key)}</div>
<LuChevronRight className="size-4" /> <LuChevronRight className="size-4" />
</div> </Button>
); );
} }
@ -275,9 +273,6 @@ export default function Settings() {
} else { } else {
setPageToggle(page as SettingsType); setPageToggle(page as SettingsType);
} }
if (isMobile) {
setContentMobileOpen(true);
}
} }
// don't clear url params if we're creating a new object mask // don't clear url params if we're creating a new object mask
return !(searchParams.has("object_mask") || searchParams.has("event_id")); return !(searchParams.has("object_mask") || searchParams.has("event_id"));
@ -287,9 +282,6 @@ export default function Settings() {
const cameraNames = cameras.map((c) => c.name); const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) { if (cameraNames.includes(camera)) {
setSelectedCamera(camera); setSelectedCamera(camera);
if (isMobile) {
setContentMobileOpen(true);
}
} }
// don't clear url params if we're creating a new object mask or trigger // don't clear url params if we're creating a new object mask or trigger
return !(searchParams.has("object_mask") || searchParams.has("event_id")); return !(searchParams.has("object_mask") || searchParams.has("event_id"));

View File

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

View File

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

View File

@ -327,7 +327,6 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
</AlertDialog> </AlertDialog>
<div className="flex flex-row justify-between gap-2 p-2 align-middle"> <div className="flex flex-row justify-between gap-2 p-2 align-middle">
{(isDesktop || !selectedImages?.length) && (
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
<Button <Button
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
@ -341,7 +340,6 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
</div> </div>
)} )}
</Button> </Button>
<LibrarySelector <LibrarySelector
pageToggle={pageToggle} pageToggle={pageToggle}
dataset={dataset || {}} dataset={dataset || {}}
@ -351,15 +349,9 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
onRename={() => {}} onRename={() => {}}
/> />
</div> </div>
)}
{selectedImages?.length > 0 ? ( {selectedImages?.length > 0 ? (
<div <div className="flex items-center justify-center gap-2">
className={cn( <div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
"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">{`${selectedImages.length} selected`}</div>
<div className="p-1">{"|"}</div> <div className="p-1">{"|"}</div>
<div <div

View File

@ -970,11 +970,12 @@ function Timeline({
"relative overflow-hidden", "relative overflow-hidden",
isDesktop isDesktop
? cn( ? cn(
"no-scrollbar overflow-y-auto",
timelineType == "timeline" timelineType == "timeline"
? "w-[100px] flex-shrink-0" ? "w-[100px] flex-shrink-0"
: timelineType == "detail" : 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%]" ? "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-80 flex-shrink-0", : "w-60 flex-shrink-0",
) )
: cn( : cn(
timelineType == "timeline" 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%]"> <div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })} aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
type="button" type="button"
> >
<Trans>button.reset</Trans> <Trans>button.cancel</Trans>
</Button> </Button>
<Button <Button
variant="select" variant="select"