mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-26 21:59:02 +03:00
Tweaks (#23292)
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
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
* add review padding to explore debug replay api calls * add semantic search model size widget disables model_size select with n/a text when an embeddings genai provider is selected * regenerate zone contours and per-zone filter masks on detect resolution change * treat null as a clear sentinel in buildOverrides so nullable field edits don't snap back * extract replay config sheet to new component * add validation and messages for detect settings
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
|
||||
// Mirrors the current section's in-flight form data so widgets can react
|
||||
// to changes that RJSF wouldn't otherwise re-render them for. RJSF's
|
||||
// Form memoizes SchemaField via deep equality and, in some transitions
|
||||
// (notably reverting a field to its saved value), can skip re-rendering
|
||||
// a widget even though the form data it depends on changed. useContext
|
||||
// re-runs consumers directly on every provider value update, sidestepping
|
||||
// that.
|
||||
export const LiveFormDataContext = createContext<ConfigSectionData | null>(
|
||||
null,
|
||||
);
|
||||
@@ -11,6 +11,50 @@ const detect: SectionConfigOverrides = {
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" && ctx.formData?.enabled === false,
|
||||
},
|
||||
{
|
||||
key: "detect-resolution-not-multiple-of-four",
|
||||
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
const width = ctx.formData?.width as number | null | undefined;
|
||||
const height = ctx.formData?.height as number | null | undefined;
|
||||
const isEvenButNotFour = (v: unknown) =>
|
||||
typeof v === "number" && v % 2 === 0 && v % 4 !== 0;
|
||||
return isEvenButNotFour(width) || isEvenButNotFour(height);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "detect-aspect-ratio-mismatch",
|
||||
messageKey: "configMessages.detect.aspectRatioMismatch",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
const newWidth = ctx.formData?.width as number | null | undefined;
|
||||
const newHeight = ctx.formData?.height as number | null | undefined;
|
||||
if (typeof newWidth !== "number" || typeof newHeight !== "number") {
|
||||
return false;
|
||||
}
|
||||
const saved =
|
||||
ctx.level === "camera"
|
||||
? ctx.fullCameraConfig?.detect
|
||||
: ctx.fullConfig?.detect;
|
||||
const savedWidth = saved?.width;
|
||||
const savedHeight = saved?.height;
|
||||
if (
|
||||
typeof savedWidth !== "number" ||
|
||||
typeof savedHeight !== "number" ||
|
||||
savedWidth <= 0 ||
|
||||
savedHeight <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (newWidth === savedWidth && newHeight === savedHeight) {
|
||||
return false;
|
||||
}
|
||||
const newRatio = newWidth / newHeight;
|
||||
const savedRatio = savedWidth / savedHeight;
|
||||
return Math.abs(newRatio - savedRatio) > 0.01;
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
|
||||
@@ -29,28 +29,13 @@ const semanticSearch: SectionConfigOverrides = {
|
||||
ctx.formData?.model === "jinav2" &&
|
||||
ctx.formData?.model_size === "small",
|
||||
},
|
||||
{
|
||||
key: "model-size-ignored-for-provider",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.semanticSearch.modelSizeIgnoredForProvider",
|
||||
severity: "info",
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
const model = ctx.formData?.model;
|
||||
return (
|
||||
typeof model === "string" &&
|
||||
model !== "" &&
|
||||
model !== "jinav1" &&
|
||||
model !== "jinav2"
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
model: {
|
||||
"ui:widget": "semanticSearchModel",
|
||||
},
|
||||
model_size: {
|
||||
"ui:widget": "semanticSearchModelSize",
|
||||
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import type { JsonObject } from "@/types/configForm";
|
||||
|
||||
export function validateDetectDimensions(
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
t: TFunction,
|
||||
): FormValidation {
|
||||
if (!isJsonObject(formData as JsonObject)) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const data = formData as JsonObject;
|
||||
const width = data.width;
|
||||
const height = data.height;
|
||||
|
||||
const widthErrors = errors.width as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
const heightErrors = errors.height as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
|
||||
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
|
||||
|
||||
if (typeof width === "number" && width % 2 !== 0) {
|
||||
widthErrors?.addError?.(message);
|
||||
}
|
||||
if (typeof height === "number" && height % 2 !== 0) {
|
||||
heightErrors?.addError?.(message);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { validateDetectDimensions } from "./detect";
|
||||
import { validateFfmpegInputRoles } from "./ffmpeg";
|
||||
import { validateProxyRoleHeader } from "./proxy";
|
||||
|
||||
@@ -19,6 +20,10 @@ export function getSectionValidation({
|
||||
level,
|
||||
t,
|
||||
}: SectionValidationOptions): SectionValidation | undefined {
|
||||
if (sectionPath === "detect") {
|
||||
return (formData, errors) => validateDetectDimensions(formData, errors, t);
|
||||
}
|
||||
|
||||
if (sectionPath === "ffmpeg" && level === "camera") {
|
||||
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ import type {
|
||||
import { useConfigMessages } from "@/hooks/use-config-messages";
|
||||
import { ConfigMessageBanner } from "../ConfigMessageBanner";
|
||||
import { FieldMessagesContext } from "../FieldMessagesContext";
|
||||
import { LiveFormDataContext } from "../LiveFormDataContext";
|
||||
|
||||
export interface SectionConfig {
|
||||
/** Field ordering within the section */
|
||||
@@ -998,59 +999,63 @@ export function ConfigSection({
|
||||
<div className="space-y-6">
|
||||
<ConfigMessageBanner messages={activeMessages} />
|
||||
<FieldMessagesContext.Provider value={activeFieldMessages}>
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
formData={currentFormData}
|
||||
onChange={handleChange}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={sectionConfig.uiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
extraHasChanges,
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
// When rendering camera-level sections, provide the section path so
|
||||
// field templates can look up keys under the `config/cameras` namespace
|
||||
// When using a consolidated global namespace, keys are nested
|
||||
// under the section name (e.g., `audio.label`) so provide the
|
||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||
sectionI18nPrefix: sectionPath,
|
||||
t,
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
<LiveFormDataContext.Provider
|
||||
value={(currentFormData as ConfigSectionData | null) ?? null}
|
||||
>
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
formData={currentFormData}
|
||||
onChange={handleChange}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={sectionConfig.uiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
extraHasChanges,
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
// When rendering camera-level sections, provide the section path so
|
||||
// field templates can look up keys under the `config/cameras` namespace
|
||||
// When using a consolidated global namespace, keys are nested
|
||||
// under the section name (e.g., `audio.label`) so provide the
|
||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||
sectionI18nPrefix: sectionPath,
|
||||
t,
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
</LiveFormDataContext.Provider>
|
||||
</FieldMessagesContext.Provider>
|
||||
|
||||
{!embedded && (
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
|
||||
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
|
||||
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
@@ -86,6 +87,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
optionalField: OptionalFieldWidget,
|
||||
semanticSearchModel: SemanticSearchModelWidget,
|
||||
semanticSearchModelSize: SemanticSearchModelSizeWidget,
|
||||
onvifProfile: OnvifProfileWidget,
|
||||
},
|
||||
templates: {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Disables model_size and shows "N/A" when a GenAI provider is selected.
|
||||
// Reads model via LiveFormDataContext so it re-runs even when RJSF's
|
||||
// SchemaField memoization would skip this widget.
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LiveFormDataContext } from "../../LiveFormDataContext";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
import { SelectWidget } from "./SelectWidget";
|
||||
|
||||
export function SemanticSearchModelSizeWidget(props: WidgetProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const liveFormData = useContext(LiveFormDataContext);
|
||||
const model = liveFormData?.model;
|
||||
const isProvider =
|
||||
typeof model === "string" &&
|
||||
model !== "" &&
|
||||
model !== "jinav1" &&
|
||||
model !== "jinav2";
|
||||
|
||||
// Clear model_size while on a provider (buildOverrides converts to ""
|
||||
// which the backend treats as "remove"). Restore the schema default
|
||||
// when returning to a Jina model so the field isn't left empty.
|
||||
const { value, onChange, schema } = props;
|
||||
const schemaDefault = schema?.default as string | undefined;
|
||||
useEffect(() => {
|
||||
if (isProvider && value !== undefined) {
|
||||
onChange(undefined);
|
||||
} else if (!isProvider && value === undefined && schemaDefault) {
|
||||
onChange(schemaDefault);
|
||||
}
|
||||
}, [isProvider, value, onChange, schemaDefault]);
|
||||
|
||||
if (isProvider) {
|
||||
const fieldClassName = getSizedFieldClassName(props.options ?? {}, "sm");
|
||||
return (
|
||||
<Select value="" disabled>
|
||||
<SelectTrigger className={fieldClassName}>
|
||||
<SelectValue
|
||||
placeholder={t("configForm.semanticSearchModelSize.notApplicable", {
|
||||
defaultValue: "Not applicable for GenAI providers",
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return <SelectWidget {...props} />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, ReactNode, useCallback } from "react";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { REVIEW_PADDING } from "@/types/review";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { toast } from "sonner";
|
||||
@@ -94,8 +95,8 @@ export default function SearchResultActions({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: event.camera,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSettings } from "react-icons/lu";
|
||||
import useSWR from "swr";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
|
||||
import { useConfigSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
type DebugReplayConfigSheetProps = {
|
||||
replayCamera: string | undefined;
|
||||
};
|
||||
|
||||
export function DebugReplayConfigSheet({
|
||||
replayCamera,
|
||||
}: DebugReplayConfigSheetProps) {
|
||||
const { t } = useTranslation(["views/replay"]);
|
||||
const configSchema = useConfigSchema();
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<PlatformAwareSheet
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-2">
|
||||
<LuSettings className="size-4" />
|
||||
<span className="hidden md:inline">{t("page.configuration")}</span>
|
||||
</Button>
|
||||
}
|
||||
title={t("page.configuration")}
|
||||
titleClassName="text-lg font-semibold"
|
||||
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
|
||||
content={
|
||||
<>
|
||||
<p className="mb-5 text-sm text-muted-foreground">
|
||||
{t("page.configurationDesc")}
|
||||
</p>
|
||||
{configSchema == null ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="detect"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="motion"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="objects"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
{config?.face_recognition?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="face_recognition"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
{config?.lpr?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="lpr"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -63,8 +63,8 @@ export default function DetailActionsMenu({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: search.camera,
|
||||
start_time: search.start_time,
|
||||
end_time: search.end_time,
|
||||
start_time: (search.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Event } from "@/types/event";
|
||||
import { REVIEW_PADDING } from "@/types/review";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
@@ -58,8 +59,8 @@ export default function EventMenu({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: event.camera,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
|
||||
Reference in New Issue
Block a user