diff --git a/web/public/locales/en/config/validation.json b/web/public/locales/en/config/validation.json index 638725a56..6f3b5f686 100644 --- a/web/public/locales/en/config/validation.json +++ b/web/public/locales/en/config/validation.json @@ -25,7 +25,8 @@ "ffmpeg": { "inputs": { "rolesUnique": "Each role can only be assigned to one input stream.", - "detectRequired": "At least one input stream must be assigned the 'detect' role." + "detectRequired": "At least one input stream must be assigned the 'detect' role.", + "hwaccelDetectOnly": "Only the input stream with the detect role can define hardware acceleration arguments." } } } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 70aea99ea..776e9f459 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -22,7 +22,7 @@ "integrations": "Integrations", "cameras": "Camera configuration", "ui": "UI", - "profileSettings": "Profile Settings", + "profileSettings": "Profile settings", "globalDetect": "Object detection", "globalRecording": "Recording", "globalSnapshots": "Snapshots", @@ -1247,9 +1247,13 @@ "ffmpegArgs": { "preset": "Preset", "manual": "Manual arguments", + "inherit": "Inherit from camera setting", "selectPreset": "Select preset", "manualPlaceholder": "Enter FFmpeg arguments" }, + "cameraInputs": { + "itemTitle": "Stream {{index}}: {{path}}" + }, "restartRequiredField": "Restart required", "restartRequiredFooter": "Configuration changed - Restart required", "sections": { @@ -1341,7 +1345,8 @@ }, "timestamp_style": { "title": "Timestamp Settings" - } + }, + "searchPlaceholder": "Search..." }, "globalConfig": { "title": "Global Configuration", diff --git a/web/src/components/card/SettingsGroupCard.tsx b/web/src/components/card/SettingsGroupCard.tsx index 0377e0797..4bfaa1402 100644 --- a/web/src/components/card/SettingsGroupCard.tsx +++ b/web/src/components/card/SettingsGroupCard.tsx @@ -2,7 +2,7 @@ import { ReactNode } from "react"; import { Label } from "../ui/label"; export const SPLIT_ROW_CLASS_NAME = - "space-y-2 md:grid md:grid-cols-[minmax(14rem,22rem)_minmax(0,1fr)] md:items-start md:gap-x-6 md:space-y-0"; + "space-y-2 md:grid md:grid-cols-[minmax(14rem,24rem)_minmax(0,1fr)] md:items-start md:gap-x-6 md:space-y-0"; export const DESCRIPTION_CLASS_NAME = "text-sm text-muted-foreground"; export const CONTROL_COLUMN_CLASS_NAME = "w-full md:max-w-2xl"; diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index b3980af30..d8cb7b8fa 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -7,11 +7,15 @@ const arrayAsTextWidget = { }, }; -const ffmpegArgsWidget = (presetField: string) => ({ +const ffmpegArgsWidget = ( + presetField: string, + extraOptions?: Record, +) => ({ "ui:widget": "FfmpegArgsWidget", "ui:options": { suppressMultiSchema: true, ffmpegPresetField: presetField, + ...extraOptions, }, }); @@ -23,35 +27,36 @@ const ffmpeg: SectionConfigOverrides = { "inputs.hwaccel_args": "/configuration/ffmpeg_presets#hwaccel-presets", input_args: "/configuration/ffmpeg_presets#input-args-presets", "inputs.input_args": "/configuration/ffmpeg_presets#input-args-presets", + output_args: "/configuration/ffmpeg_presets#output-args-presets", + "inputs.output_args": "/configuration/ffmpeg_presets#output-args-presets", "output_args.record": "/configuration/ffmpeg_presets#output-args-presets", }, restartRequired: [], fieldOrder: [ "inputs", - "path", "global_args", - "hwaccel_args", "input_args", + "hwaccel_args", "output_args", + "path", "retry_interval", "apple_compatibility", "gpu", ], hiddenFields: [], advancedFields: [ + "path", "global_args", - "hwaccel_args", - "input_args", - "output_args", "retry_interval", "apple_compatibility", "gpu", ], overrideFields: [ + "inputs", "path", "global_args", - "hwaccel_args", "input_args", + "hwaccel_args", "output_args", "retry_interval", "apple_compatibility", @@ -73,6 +78,7 @@ const ffmpeg: SectionConfigOverrides = { }, }, inputs: { + "ui:field": "CameraInputsField", items: { path: { "ui:options": { size: "full" }, @@ -83,8 +89,15 @@ const ffmpeg: SectionConfigOverrides = { global_args: { "ui:widget": "hidden", }, - hwaccel_args: ffmpegArgsWidget("hwaccel_args"), - input_args: ffmpegArgsWidget("input_args"), + hwaccel_args: ffmpegArgsWidget("hwaccel_args", { + allowInherit: true, + hideDescription: true, + showArrayItemDescription: true, + }), + input_args: ffmpegArgsWidget("input_args", { + hideDescription: true, + showArrayItemDescription: true, + }), output_args: { items: { detect: arrayAsTextWidget, @@ -107,9 +120,9 @@ const ffmpeg: SectionConfigOverrides = { "gpu", ], fieldOrder: [ + "hwaccel_args", "path", "global_args", - "hwaccel_args", "input_args", "output_args", "retry_interval", @@ -120,6 +133,7 @@ const ffmpeg: SectionConfigOverrides = { "global_args", "input_args", "output_args", + "path", "retry_interval", "apple_compatibility", "gpu", diff --git a/web/src/components/config-form/section-validations/ffmpeg.ts b/web/src/components/config-form/section-validations/ffmpeg.ts index 6143eb7dd..d751a84db 100644 --- a/web/src/components/config-form/section-validations/ffmpeg.ts +++ b/web/src/components/config-form/section-validations/ffmpeg.ts @@ -3,6 +3,18 @@ import type { TFunction } from "i18next"; import { isJsonObject } from "@/lib/utils"; import type { JsonObject } from "@/types/configForm"; +function hasValue(value: unknown): boolean { + if (value === null || value === undefined || value === "") { + return false; + } + + if (Array.isArray(value)) { + return value.length > 0; + } + + return true; +} + export function validateFfmpegInputRoles( formData: unknown, errors: FormValidation, @@ -19,6 +31,7 @@ export function validateFfmpegInputRoles( const roleCounts = new Map(); let hasDetect = false; + let hasInvalidHwaccel = false; inputs.forEach((input) => { if (!isJsonObject(input) || !Array.isArray(input.roles)) { return; @@ -31,6 +44,8 @@ export function validateFfmpegInputRoles( }); if (input.roles.includes("detect")) { hasDetect = true; + } else if (hasValue(input.hwaccel_args)) { + hasInvalidHwaccel = true; } }); @@ -56,5 +71,14 @@ export function validateFfmpegInputRoles( ); } + if (hasInvalidHwaccel) { + const inputsErrors = errors.inputs as { + addError?: (message: string) => void; + }; + inputsErrors?.addError?.( + t("ffmpeg.inputs.hwaccelDetectOnly", { ns: "config/validation" }), + ); + } + return errors; } diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 64c239367..7b845287c 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -211,6 +211,7 @@ export function ConfigSection({ [onPendingDataChange, sectionPath, cameraName], ); const [isSaving, setIsSaving] = useState(false); + const [hasValidationErrors, setHasValidationErrors] = useState(false); const [extraHasChanges, setExtraHasChanges] = useState(false); const [formKey, setFormKey] = useState(0); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); @@ -754,6 +755,7 @@ export function ConfigSection({ schema={modifiedSchema} formData={currentFormData} onChange={handleChange} + onValidationChange={setHasValidationErrors} fieldOrder={sectionConfig.fieldOrder} fieldGroups={sectionConfig.fieldGroups} hiddenFields={sectionConfig.hiddenFields} @@ -851,7 +853,9 @@ export function ConfigSection({ + + + + + + ); + })} + + + + ); +} + +export default CameraInputsField; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 1d4ac3107..1b40dd813 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -40,6 +40,7 @@ import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate"; import { LayoutGridField } from "./fields/LayoutGridField"; import { DetectorHardwareField } from "./fields/DetectorHardwareField"; import { ReplaceRulesField } from "./fields/ReplaceRulesField"; +import { CameraInputsField } from "./fields/CameraInputsField"; export interface FrigateTheme { widgets: RegistryWidgetsType; @@ -87,5 +88,6 @@ export const frigateTheme: FrigateTheme = { LayoutGridField: LayoutGridField, DetectorHardwareField: DetectorHardwareField, ReplaceRulesField: ReplaceRulesField, + CameraInputsField: CameraInputsField, }, }; diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index e36dcd597..38e146f4d 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -28,6 +28,7 @@ import { import { normalizeOverridePath } from "../utils/overrides"; import get from "lodash/get"; import isEqual from "lodash/isEqual"; +import { SPLIT_ROW_CLASS_NAME } from "@/components/card/SettingsGroupCard"; function _isArrayItemInAdditionalProperty( pathSegments: Array, @@ -101,6 +102,8 @@ export function FieldTemplate(props: FieldTemplateProps) { const uiOptionsFromSchema = uiSchema?.["ui:options"] || {}; const suppressDescription = uiOptionsFromSchema.suppressDescription === true; + const showArrayItemDescription = + uiOptionsFromSchema.showArrayItemDescription === true; // Determine field characteristics const isBoolean = @@ -155,7 +158,7 @@ export function FieldTemplate(props: FieldTemplateProps) { !isMultiSchemaWrapper && !isObjectField && !isAdditionalProperty && - !isArrayItemInAdditionalProp && + (!isArrayItemInAdditionalProp || showArrayItemDescription) && !suppressDescription; const translationPath = buildTranslationPath( @@ -512,7 +515,7 @@ export function FieldTemplate(props: FieldTemplateProps) { {renderDocsLink()} -
+
{renderBooleanLabel()} {renderDescription()} @@ -537,7 +540,7 @@ export function FieldTemplate(props: FieldTemplateProps) { ); const renderSplitValueLayout = () => ( -
+
{renderSplitLabel()} {renderDescription("hidden md:block")} diff --git a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx index b1926db60..76de7ae94 100644 --- a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx +++ b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx @@ -22,7 +22,7 @@ type FfmpegPresetResponse = { }; }; -type FfmpegArgsMode = "preset" | "manual"; +type FfmpegArgsMode = "preset" | "manual" | "inherit"; type PresetField = | "hwaccel_args" @@ -58,7 +58,12 @@ const resolveMode = ( value: unknown, presets: string[], defaultMode: FfmpegArgsMode, + allowInherit: boolean, ): FfmpegArgsMode => { + if (allowInherit && (value === null || value === undefined)) { + return "inherit"; + } + if (Array.isArray(value)) { return "manual"; } @@ -109,6 +114,8 @@ export function FfmpegArgsWidget(props: WidgetProps) { id, } = props; const presetField = options?.ffmpegPresetField as PresetField | undefined; + const allowInherit = options?.allowInherit === true; + const hideDescription = options?.hideDescription === true; const { data } = useSWR("ffmpeg/presets"); @@ -121,14 +128,14 @@ export function FfmpegArgsWidget(props: WidgetProps) { const defaultMode: FfmpegArgsMode = canUsePresets ? "preset" : "manual"; const detectedMode = useMemo( - () => resolveMode(value, presetOptions, defaultMode), - [value, presetOptions, defaultMode], + () => resolveMode(value, presetOptions, defaultMode, allowInherit), + [value, presetOptions, defaultMode, allowInherit], ); const [mode, setMode] = useState(detectedMode); useEffect(() => { - if (!canUsePresets) { + if (!canUsePresets && detectedMode === "preset") { setMode("manual"); return; } @@ -140,6 +147,11 @@ export function FfmpegArgsWidget(props: WidgetProps) { (nextMode: FfmpegArgsMode) => { setMode(nextMode); + if (nextMode === "inherit") { + onChange(null); + return; + } + if (nextMode === "preset") { const currentValue = typeof value === "string" ? value : undefined; const presetValue = @@ -255,9 +267,26 @@ export function FfmpegArgsWidget(props: WidgetProps) { {t("configForm.ffmpegArgs.manual", { ns: "views/settings" })}
+ {allowInherit ? ( +
+ + +
+ ) : null} - {mode === "preset" && canUsePresets ? ( + {mode === "inherit" ? null : mode === "preset" && canUsePresets ? ( setSearchTerm(e.target.value)} - className="mb-2" - /> +
+ setSearchTerm(e.target.value)} + className="mb-2" + /> +
)}
{filteredEntities.map((entity) => {