mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-28 11:08:22 +03:00
Tweaks (#22656)
* tweak language * show validation errors in json response * fix export hwaccel args field in UI * increase annotation offset consts * fix save button race conditions, add reset spinner, and fix enrichments profile leak - Disable both Save and SaveAll buttons while either operation is in progress so users cannot trigger concurrent saves - Show activity indicator on Reset to Default/Global button during the API call - Enrichments panes (semantic search, genai, face recognition) now always show base config fields regardless of profile selection in the header dropdown * fix genai additional_concerns validation error with textarea array widget The additional_concerns field is list[str] in the backend but was using the textarea widget which produces a string value, causing validation errors. Created a TextareaArrayWidget that converts between array (one item per line) and textarea display, and switched additional_concerns to use it * populate and sort global audio filters for all audio labels * add column labels in profiles view * enforce a minimum value of 2 for min_initialized * reuse widget and refactor for multiline * fix * change record copy preset to transcode audio to aac
This commit is contained in:
parent
909b40ba96
commit
4772e6a2ab
@ -613,6 +613,34 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
|
||||
try:
|
||||
config = FrigateConfig.parse(new_raw_config)
|
||||
except ValidationError as e:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
f.close()
|
||||
logger.error(
|
||||
f"Config Validation Error:\n\n{str(traceback.format_exc())}"
|
||||
)
|
||||
error_messages = []
|
||||
for err in e.errors():
|
||||
msg = err.get("msg", "")
|
||||
# Strip pydantic "Value error, " prefix for cleaner display
|
||||
if msg.startswith("Value error, "):
|
||||
msg = msg[len("Value error, ") :]
|
||||
error_messages.append(msg)
|
||||
message = (
|
||||
"; ".join(error_messages)
|
||||
if error_messages
|
||||
else "Check logs for error message."
|
||||
)
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Error saving config: {message}",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
except Exception:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
|
||||
@ -71,6 +71,7 @@ class DetectConfig(FrigateBaseModel):
|
||||
default=None,
|
||||
title="Minimum initialization frames",
|
||||
description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.",
|
||||
ge=2,
|
||||
)
|
||||
max_disappeared: Optional[int] = Field(
|
||||
default=None,
|
||||
|
||||
@ -614,6 +614,21 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if self.ffmpeg.hwaccel_args == "auto":
|
||||
self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
|
||||
|
||||
# Populate global audio filters for all audio labels
|
||||
all_audio_labels = {
|
||||
label
|
||||
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
|
||||
if label
|
||||
}
|
||||
|
||||
if self.audio.filters is None:
|
||||
self.audio.filters = {}
|
||||
|
||||
for key in sorted(all_audio_labels - self.audio.filters.keys()):
|
||||
self.audio.filters[key] = AudioFilterConfig()
|
||||
|
||||
self.audio.filters = dict(sorted(self.audio.filters.items()))
|
||||
|
||||
# Global config to propagate down to camera level
|
||||
global_config = self.model_dump(
|
||||
include={
|
||||
@ -672,12 +687,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
detector_config.model = model
|
||||
self.detectors[key] = detector_config
|
||||
|
||||
all_audio_labels = {
|
||||
label
|
||||
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
|
||||
if label
|
||||
}
|
||||
|
||||
for name, camera in self.cameras.items():
|
||||
modified_global_config = global_config.copy()
|
||||
|
||||
@ -755,7 +764,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
# Default min_initialized configuration
|
||||
min_initialized = int(camera_config.detect.fps / 2)
|
||||
min_initialized = max(int(camera_config.detect.fps / 2), 2)
|
||||
if camera_config.detect.min_initialized is None:
|
||||
camera_config.detect.min_initialized = min_initialized
|
||||
|
||||
@ -801,11 +810,13 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if camera_config.audio.filters is None:
|
||||
camera_config.audio.filters = {}
|
||||
|
||||
audio_keys = all_audio_labels
|
||||
audio_keys = audio_keys - camera_config.audio.filters.keys()
|
||||
for key in audio_keys:
|
||||
for key in sorted(all_audio_labels - camera_config.audio.filters.keys()):
|
||||
camera_config.audio.filters[key] = AudioFilterConfig()
|
||||
|
||||
camera_config.audio.filters = dict(
|
||||
sorted(camera_config.audio.filters.items())
|
||||
)
|
||||
|
||||
# Add default filters
|
||||
object_keys = camera_config.objects.track
|
||||
if camera_config.objects.filters is None:
|
||||
|
||||
@ -47,7 +47,7 @@ class ModelTypeEnum(str, Enum):
|
||||
class ModelConfig(BaseModel):
|
||||
path: Optional[str] = Field(
|
||||
None,
|
||||
title="Custom Object detection model path",
|
||||
title="Custom object detector model path",
|
||||
description="Path to a custom detection model file (or plus://<model_id> for Frigate+ models).",
|
||||
)
|
||||
labelmap_path: Optional[str] = Field(
|
||||
|
||||
@ -465,6 +465,16 @@ PRESETS_RECORD_OUTPUT = {
|
||||
"-c:a",
|
||||
"aac",
|
||||
],
|
||||
# NOTE: This preset originally used "-c:a copy" to pass through audio
|
||||
# without re-encoding. FFmpeg 7.x introduced a threaded pipeline where
|
||||
# demuxing, encoding, and muxing run in parallel via a Scheduler. This
|
||||
# broke audio streamcopy from RTSP sources: packets are demuxed correctly
|
||||
# but silently dropped before reaching the muxer (0 bytes written). The
|
||||
# issue is specific to RTSP + streamcopy; file inputs and transcoding both
|
||||
# work. Transcoding AAC audio is very lightweight (~30KiB per 10s segment)
|
||||
# and adds negligible CPU overhead, so this is an acceptable workaround.
|
||||
# The benefits of FFmpeg 7.x — particularly the removal of gamma correction
|
||||
# hacks required by earlier versions — outweigh this trade-off.
|
||||
"preset-record-generic-audio-copy": [
|
||||
"-f",
|
||||
"segment",
|
||||
@ -476,8 +486,10 @@ PRESETS_RECORD_OUTPUT = {
|
||||
"1",
|
||||
"-strftime",
|
||||
"1",
|
||||
"-c",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
],
|
||||
"preset-record-mjpeg": [
|
||||
"-f",
|
||||
|
||||
@ -293,7 +293,7 @@
|
||||
"label": "Detector specific model configuration",
|
||||
"description": "Detector-specific model configuration options (path, input size, etc.).",
|
||||
"path": {
|
||||
"label": "Custom Object detection model path",
|
||||
"label": "Custom object detector model path",
|
||||
"description": "Path to a custom detection model file (or plus://<model_id> for Frigate+ models)."
|
||||
},
|
||||
"labelmap_path": {
|
||||
@ -466,7 +466,7 @@
|
||||
"label": "Detection model",
|
||||
"description": "Settings to configure a custom object detection model and its input shape.",
|
||||
"path": {
|
||||
"label": "Custom Object detection model path",
|
||||
"label": "Custom object detector model path",
|
||||
"description": "Path to a custom detection model file (or plus://<model_id> for Frigate+ models)."
|
||||
},
|
||||
"labelmap_path": {
|
||||
|
||||
@ -1521,6 +1521,8 @@
|
||||
"noOverrides": "No overrides",
|
||||
"cameraCount_one": "{{count}} camera",
|
||||
"cameraCount_other": "{{count}} cameras",
|
||||
"columnCamera": "Camera",
|
||||
"columnOverrides": "Profile Overrides",
|
||||
"baseConfig": "Base Config",
|
||||
"addProfile": "Add Profile",
|
||||
"newProfile": "New Profile",
|
||||
|
||||
@ -23,7 +23,7 @@ const record: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
export: {
|
||||
hwaccel_args: {
|
||||
"ui:options": { size: "lg" },
|
||||
"ui:options": { suppressMultiSchema: true, size: "lg" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -29,9 +29,10 @@ const review: SectionConfigOverrides = {
|
||||
},
|
||||
genai: {
|
||||
additional_concerns: {
|
||||
"ui:widget": "textarea",
|
||||
"ui:widget": "ArrayAsTextWidget",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
multiline: true,
|
||||
},
|
||||
},
|
||||
activity_context_prompt: {
|
||||
|
||||
@ -152,6 +152,10 @@ export interface BaseSectionProps {
|
||||
profileBorderColor?: string;
|
||||
/** Callback to delete the current profile's overrides for this section */
|
||||
onDeleteProfileSection?: () => void;
|
||||
/** Whether a SaveAll operation is in progress (disables individual Save) */
|
||||
isSavingAll?: boolean;
|
||||
/** Callback when this section's saving state changes */
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
}
|
||||
|
||||
export interface CreateSectionOptions {
|
||||
@ -186,6 +190,8 @@ export function ConfigSection({
|
||||
profileFriendlyName,
|
||||
profileBorderColor,
|
||||
onDeleteProfileSection,
|
||||
isSavingAll = false,
|
||||
onSavingChange,
|
||||
}: ConfigSectionProps) {
|
||||
// For replay level, treat as camera-level config access
|
||||
const effectiveLevel = level === "replay" ? "camera" : level;
|
||||
@ -246,6 +252,7 @@ export function ConfigSection({
|
||||
[onPendingDataChange, effectiveSectionPath, cameraName],
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isResettingToDefault, setIsResettingToDefault] = useState(false);
|
||||
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||
const [extraHasChanges, setExtraHasChanges] = useState(false);
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
@ -577,6 +584,7 @@ export function ConfigSection({
|
||||
if (!pendingData) return;
|
||||
|
||||
setIsSaving(true);
|
||||
onSavingChange?.(true);
|
||||
try {
|
||||
const basePath =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
@ -699,6 +707,7 @@ export function ConfigSection({
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
onSavingChange?.(false);
|
||||
}
|
||||
}, [
|
||||
sectionPath,
|
||||
@ -718,12 +727,14 @@ export function ConfigSection({
|
||||
setPendingData,
|
||||
requiresRestartForOverrides,
|
||||
skipSave,
|
||||
onSavingChange,
|
||||
]);
|
||||
|
||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||
const handleResetToGlobal = useCallback(async () => {
|
||||
if (effectiveLevel === "camera" && !cameraName) return;
|
||||
|
||||
setIsResettingToDefault(true);
|
||||
try {
|
||||
const basePath =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
@ -758,6 +769,8 @@ export function ConfigSection({
|
||||
defaultValue: "Failed to reset settings",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsResettingToDefault(false);
|
||||
}
|
||||
}, [
|
||||
effectiveSectionPath,
|
||||
@ -945,9 +958,12 @@ export function ConfigSection({
|
||||
<Button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
disabled={isSaving || isResettingToDefault || disabled}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
{isResettingToDefault && (
|
||||
<ActivityIndicator className="h-4 w-4" />
|
||||
)}
|
||||
{effectiveLevel === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
@ -980,7 +996,7 @@ export function ConfigSection({
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
disabled={isSaving || isSavingAll || disabled}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
{t("button.undo", { ns: "common", defaultValue: "Undo" })}
|
||||
@ -990,7 +1006,11 @@ export function ConfigSection({
|
||||
onClick={handleSave}
|
||||
variant="select"
|
||||
disabled={
|
||||
!hasChanges || hasValidationErrors || isSaving || disabled
|
||||
!hasChanges ||
|
||||
hasValidationErrors ||
|
||||
isSaving ||
|
||||
isSavingAll ||
|
||||
disabled
|
||||
}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
|
||||
@ -1,33 +1,101 @@
|
||||
// Widget that displays an array as a concatenated text string
|
||||
// Widget that displays an array as editable text.
|
||||
// Single-line mode (default): space-separated in an Input.
|
||||
// Multiline mode (options.multiline): one item per line in a Textarea.
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCallback } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
function arrayToText(value: unknown, multiline: boolean): string {
|
||||
const sep = multiline ? "\n" : " ";
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value.join(sep);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function textToArray(text: string, multiline: boolean): string[] {
|
||||
if (text.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
return multiline
|
||||
? text.split("\n").filter((line) => line.trim() !== "")
|
||||
: text.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
export function ArrayAsTextWidget(props: WidgetProps) {
|
||||
const { value, onChange, disabled, readonly, placeholder } = props;
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
// Convert array or string to text
|
||||
let textValue = "";
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
textValue = value;
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
textValue = value.join(" ");
|
||||
}
|
||||
const multiline = !!(options.multiline as boolean);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newText = event.target.value;
|
||||
// Convert space-separated string back to array
|
||||
const newArray = newText.trim() ? newText.trim().split(/\s+/) : [];
|
||||
onChange(newArray);
|
||||
// Local state keeps raw text so newlines aren't stripped mid-typing
|
||||
const [text, setText] = useState(() => arrayToText(value, multiline));
|
||||
|
||||
useEffect(() => {
|
||||
setText(arrayToText(value, multiline));
|
||||
}, [value, multiline]);
|
||||
|
||||
const fieldClassName = multiline
|
||||
? getSizedFieldClassName(options, "md")
|
||||
: undefined;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const raw = e.target.value;
|
||||
setText(raw);
|
||||
onChange(textToArray(raw, multiline));
|
||||
},
|
||||
[onChange],
|
||||
[onChange, multiline],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Clean up: strip empty entries and sync
|
||||
const cleaned = textToArray(e.target.value, multiline);
|
||||
onChange(cleaned);
|
||||
setText(arrayToText(cleaned, multiline));
|
||||
onBlur?.(id, e.target.value);
|
||||
},
|
||||
[id, onChange, onBlur, multiline],
|
||||
);
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
value={text}
|
||||
disabled={disabled || readonly}
|
||||
rows={(options.rows as number) || 3}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={(e) => onFocus?.(id, e.target.value)}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={textValue}
|
||||
onChange={handleChange}
|
||||
value={text}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
placeholder={placeholder}
|
||||
|
||||
@ -9,15 +9,16 @@ import { toast } from "sonner";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { LuExternalLink, LuInfo, LuMinus, LuPlus } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ANNOTATION_OFFSET_MAX,
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
ANNOTATION_OFFSET_STEP,
|
||||
} from "@/lib/const";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const OFFSET_MIN = -2500;
|
||||
const OFFSET_MAX = 2500;
|
||||
const OFFSET_STEP = 50;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
@ -43,7 +44,10 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
@ -114,17 +118,17 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="-50ms"
|
||||
onClick={() => stepOffset(-OFFSET_STEP)}
|
||||
disabled={annotationOffset <= OFFSET_MIN}
|
||||
onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
|
||||
disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
|
||||
>
|
||||
<LuMinus className="size-4" />
|
||||
</Button>
|
||||
<div className="w-full flex-1 landscape:flex">
|
||||
<Slider
|
||||
value={[annotationOffset]}
|
||||
min={OFFSET_MIN}
|
||||
max={OFFSET_MAX}
|
||||
step={OFFSET_STEP}
|
||||
min={ANNOTATION_OFFSET_MIN}
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
@ -134,8 +138,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="+50ms"
|
||||
onClick={() => stepOffset(OFFSET_STEP)}
|
||||
disabled={annotationOffset >= OFFSET_MAX}
|
||||
onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
|
||||
disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
</Button>
|
||||
|
||||
@ -13,10 +13,11 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
const OFFSET_MIN = -2500;
|
||||
const OFFSET_MAX = 2500;
|
||||
const OFFSET_STEP = 50;
|
||||
import {
|
||||
ANNOTATION_OFFSET_MAX,
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
ANNOTATION_OFFSET_STEP,
|
||||
} from "@/lib/const";
|
||||
|
||||
type AnnotationSettingsPaneProps = {
|
||||
event: Event;
|
||||
@ -49,7 +50,10 @@ export function AnnotationSettingsPane({
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
@ -128,16 +132,16 @@ export function AnnotationSettingsPane({
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="-50ms"
|
||||
onClick={() => stepOffset(-OFFSET_STEP)}
|
||||
disabled={annotationOffset <= OFFSET_MIN}
|
||||
onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
|
||||
disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
|
||||
>
|
||||
<LuMinus className="size-4" />
|
||||
</Button>
|
||||
<Slider
|
||||
value={[annotationOffset]}
|
||||
min={OFFSET_MIN}
|
||||
max={OFFSET_MAX}
|
||||
step={OFFSET_STEP}
|
||||
min={ANNOTATION_OFFSET_MIN}
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleSliderChange}
|
||||
className="flex-1"
|
||||
/>
|
||||
@ -147,8 +151,8 @@ export function AnnotationSettingsPane({
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="+50ms"
|
||||
onClick={() => stepOffset(OFFSET_STEP)}
|
||||
disabled={annotationOffset >= OFFSET_MAX}
|
||||
onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
|
||||
disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
</Button>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
|
||||
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
|
||||
|
||||
export const ANNOTATION_OFFSET_MIN = -10000;
|
||||
export const ANNOTATION_OFFSET_MAX = 5000;
|
||||
export const ANNOTATION_OFFSET_STEP = 50;
|
||||
|
||||
export const supportedLanguageKeys = [
|
||||
"en",
|
||||
"es",
|
||||
|
||||
@ -724,6 +724,7 @@ export default function Settings() {
|
||||
|
||||
// Save All state
|
||||
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||
const [isAnySectionSaving, setIsAnySectionSaving] = useState(false);
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const { send: sendRestart } = useRestart();
|
||||
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
@ -1299,6 +1300,10 @@ export default function Settings() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSectionSavingChange = useCallback((saving: boolean) => {
|
||||
setIsAnySectionSaving(saving);
|
||||
}, []);
|
||||
|
||||
// The active profile being edited for the selected camera
|
||||
const activeEditingProfile = selectedCamera
|
||||
? (editingProfile[selectedCamera] ?? null)
|
||||
@ -1508,7 +1513,7 @@ export default function Settings() {
|
||||
onClick={handleUndoAll}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSavingAll}
|
||||
disabled={isSavingAll || isAnySectionSaving}
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
{t("button.undoAll", {
|
||||
@ -1520,7 +1525,11 @@ export default function Settings() {
|
||||
onClick={handleSaveAll}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={isSavingAll || hasPendingValidationErrors}
|
||||
disabled={
|
||||
isSavingAll ||
|
||||
isAnySectionSaving ||
|
||||
hasPendingValidationErrors
|
||||
}
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
{isSavingAll ? (
|
||||
@ -1606,6 +1615,8 @@ export default function Settings() {
|
||||
}
|
||||
profilesUIEnabled={profilesUIEnabled}
|
||||
setProfilesUIEnabled={setProfilesUIEnabled}
|
||||
isSavingAll={isSavingAll}
|
||||
onSectionSavingChange={handleSectionSavingChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
@ -1674,7 +1685,7 @@ export default function Settings() {
|
||||
onClick={handleUndoAll}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSavingAll}
|
||||
disabled={isSavingAll || isAnySectionSaving}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
{t("button.undoAll", {
|
||||
@ -1686,7 +1697,11 @@ export default function Settings() {
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={handleSaveAll}
|
||||
disabled={isSavingAll || hasPendingValidationErrors}
|
||||
disabled={
|
||||
isSavingAll ||
|
||||
isAnySectionSaving ||
|
||||
hasPendingValidationErrors
|
||||
}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSavingAll ? (
|
||||
@ -1843,6 +1858,8 @@ export default function Settings() {
|
||||
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
|
||||
profilesUIEnabled={profilesUIEnabled}
|
||||
setProfilesUIEnabled={setProfilesUIEnabled}
|
||||
isSavingAll={isSavingAll}
|
||||
onSectionSavingChange={handleSectionSavingChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -542,6 +542,20 @@ export default function ProfilesView({
|
||||
<CollapsibleContent>
|
||||
{cameras.length > 0 ? (
|
||||
<div className="mx-4 mb-3 ml-11 border-l border-border/50 pl-4">
|
||||
<div className="flex items-baseline gap-3 border-b border-border/30 pb-1.5">
|
||||
<span className="min-w-[120px] shrink-0 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t("profiles.columnCamera", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Camera",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t("profiles.columnOverrides", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Profile Overrides",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{cameras.map((camera) => {
|
||||
const sections = cameraData[camera];
|
||||
return (
|
||||
|
||||
@ -39,6 +39,10 @@ export type SettingsPageProps = {
|
||||
onDeleteProfileSection?: (profileName: string) => void;
|
||||
profilesUIEnabled?: boolean;
|
||||
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Whether a SaveAll operation is in progress */
|
||||
isSavingAll?: boolean;
|
||||
/** Callback when a section's saving state changes */
|
||||
onSectionSavingChange?: (isSaving: boolean) => void;
|
||||
};
|
||||
|
||||
export type SectionStatus = {
|
||||
@ -73,6 +77,8 @@ export function SingleSectionPage({
|
||||
onPendingDataChange,
|
||||
profileState,
|
||||
onDeleteProfileSection,
|
||||
isSavingAll,
|
||||
onSectionSavingChange,
|
||||
}: SingleSectionPageProps) {
|
||||
const sectionNamespace =
|
||||
level === "camera" ? "config/cameras" : "config/global";
|
||||
@ -95,9 +101,10 @@ export function SingleSectionPage({
|
||||
? getLocaleDocUrl(resolvedSectionConfig.sectionDocs)
|
||||
: undefined;
|
||||
|
||||
const currentEditingProfile = selectedCamera
|
||||
? (profileState?.editingProfile[selectedCamera] ?? null)
|
||||
: null;
|
||||
const currentEditingProfile =
|
||||
level === "camera" && selectedCamera
|
||||
? (profileState?.editingProfile[selectedCamera] ?? null)
|
||||
: null;
|
||||
|
||||
const profileColor = useMemo(
|
||||
() =>
|
||||
@ -273,6 +280,8 @@ export function SingleSectionPage({
|
||||
onDeleteProfileSection={
|
||||
currentEditingProfile ? handleDeleteProfileSection : undefined
|
||||
}
|
||||
isSavingAll={isSavingAll}
|
||||
onSavingChange={onSectionSavingChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user