From 3777f1d90668237cbfe0a331492c25dab1209573 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:28:55 -0600 Subject: [PATCH] add backend endpoint and frontend widget for ffmpeg presets and manual args --- frigate/api/app.py | 48 ++++ web/public/locales/en/views/settings.json | 6 + .../config-form/section-configs/ffmpeg.ts | 153 +++-------- .../config-form/sections/BaseSection.tsx | 44 +++- .../sections/section-special-cases.ts | 46 ++++ .../config-form/theme/frigateTheme.ts | 2 + .../theme/widgets/FfmpegArgsWidget.tsx | 249 ++++++++++++++++++ 7 files changed, 430 insertions(+), 118 deletions(-) create mode 100644 web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx diff --git a/frigate/api/app.py b/frigate/api/app.py index 62accfa47..11fba80ab 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -196,6 +196,54 @@ def config(request: Request): return JSONResponse(content=config) +@router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())]) +def ffmpeg_presets(): + """Return available ffmpeg preset keys for config UI usage.""" + + # Whitelist based on documented presets in ffmpeg_presets.md + hwaccel_presets = { + "preset-rpi-64-h264", + "preset-rpi-64-h265", + "preset-vaapi", + "preset-intel-qsv-h264", + "preset-intel-qsv-h265", + "preset-nvidia", + "preset-jetson-h264", + "preset-jetson-h265", + "preset-rkmpp", + } + input_presets = { + "preset-http-jpeg-generic", + "preset-http-mjpeg-generic", + "preset-http-reolink", + "preset-rtmp-generic", + "preset-rtsp-generic", + "preset-rtsp-restream", + "preset-rtsp-restream-low-latency", + "preset-rtsp-udp", + "preset-rtsp-blue-iris", + } + record_output_presets = { + "preset-record-generic", + "preset-record-generic-audio-copy", + "preset-record-generic-audio-aac", + "preset-record-mjpeg", + "preset-record-jpeg", + "preset-record-ubiquiti", + } + + return JSONResponse( + content={ + "hwaccel_args": hwaccel_presets, + "input_args": input_presets, + "output_args": { + "record": record_output_presets, + "detect": [], + }, + } + ) + + @router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))]) def config_raw_paths(request: Request): """Admin-only endpoint that returns camera paths and go2rtc streams without credential masking.""" diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index eb83ff717..91326f70c 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1196,6 +1196,12 @@ "keyPlaceholder": "New key", "remove": "Remove" }, + "ffmpegArgs": { + "preset": "Preset", + "manual": "Manual arguments", + "selectPreset": "Select preset", + "manualPlaceholder": "Enter FFmpeg arguments" + }, "sections": { "detect": "Detection", "record": "Recording", diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index def8c3d37..8fee1be3d 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -1,8 +1,30 @@ import type { SectionConfigOverrides } from "./types"; +const arrayAsTextWidget = { + "ui:widget": "ArrayAsTextWidget", + "ui:options": { + suppressMultiSchema: true, + }, +}; + +const ffmpegArgsWidget = (presetField: string) => ({ + "ui:widget": "FfmpegArgsWidget", + "ui:options": { + suppressMultiSchema: true, + ffmpegPresetField: presetField, + }, +}); + const ffmpeg: SectionConfigOverrides = { base: { sectionDocs: "/configuration/ffmpeg_presets", + fieldDocs: { + hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets", + "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.record": "/configuration/ffmpeg_presets#output-args-presets", + }, restartRequired: [], fieldOrder: [ "inputs", @@ -36,94 +58,26 @@ const ffmpeg: SectionConfigOverrides = { "gpu", ], uiSchema: { - global_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - hwaccel_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - input_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, + global_args: arrayAsTextWidget, + hwaccel_args: ffmpegArgsWidget("hwaccel_args"), + input_args: ffmpegArgsWidget("input_args"), output_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - detect: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - record: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, + detect: arrayAsTextWidget, + record: ffmpegArgsWidget("output_args.record"), items: { - detect: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - record: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, + detect: arrayAsTextWidget, + record: ffmpegArgsWidget("output_args.record"), }, }, inputs: { items: { - global_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - hwaccel_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - input_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, + global_args: arrayAsTextWidget, + hwaccel_args: ffmpegArgsWidget("hwaccel_args"), + input_args: ffmpegArgsWidget("input_args"), output_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, items: { - detect: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - record: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, + detect: arrayAsTextWidget, + record: ffmpegArgsWidget("output_args.record"), }, }, }, @@ -151,41 +105,12 @@ const ffmpeg: SectionConfigOverrides = { "gpu", ], uiSchema: { - global_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - hwaccel_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - input_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, + global_args: arrayAsTextWidget, + hwaccel_args: ffmpegArgsWidget("hwaccel_args"), + input_args: ffmpegArgsWidget("input_args"), output_args: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - detect: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, - record: { - "ui:widget": "ArrayAsTextWidget", - "ui:options": { - suppressMultiSchema: true, - }, - }, + detect: arrayAsTextWidget, + record: ffmpegArgsWidget("output_args.record"), }, }, }, diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 7398598bf..d2ec798dd 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -14,6 +14,7 @@ import type { FormValidation, UiSchema } from "@rjsf/utils"; import { modifySchemaForSection, getEffectiveDefaultsForSection, + sanitizeOverridesForSection, } from "./section-special-cases"; import { getSectionValidation } from "../section-validations"; import { @@ -348,6 +349,13 @@ export function ConfigSection({ } if (Array.isArray(current)) { + if ( + current.length === 0 && + (base === undefined || base === null) && + (defaults === undefined || defaults === null) + ) { + return undefined; + } if ( (base === undefined && defaults !== undefined && @@ -366,6 +374,10 @@ export function ConfigSection({ const result: JsonObject = {}; for (const [key, value] of Object.entries(currentObj)) { + if (value === undefined && baseObj && baseObj[key] !== undefined) { + result[key] = ""; + continue; + } const overrideValue = buildOverrides( value, baseObj ? baseObj[key] : undefined, @@ -376,6 +388,18 @@ export function ConfigSection({ } } + if (baseObj) { + for (const [key, baseValue] of Object.entries(baseObj)) { + if (Object.prototype.hasOwnProperty.call(currentObj, key)) { + continue; + } + if (baseValue === undefined) { + continue; + } + result[key] = ""; + } + } + return Object.keys(result).length > 0 ? result : undefined; } @@ -506,25 +530,34 @@ export function ConfigSection({ rawData, effectiveSchemaDefaults, ); + const sanitizedOverrides = sanitizeOverridesForSection( + sectionPath, + level, + overrides, + ); - if (!overrides || Object.keys(overrides).length === 0) { + if ( + !sanitizedOverrides || + typeof sanitizedOverrides !== "object" || + Object.keys(sanitizedOverrides).length === 0 + ) { setPendingData(null); return; } - const needsRestart = requiresRestartForOverrides(overrides); + const needsRestart = requiresRestartForOverrides(sanitizedOverrides); await axios.put("config/set", { requires_restart: needsRestart ? 1 : 0, update_topic: updateTopic, config_data: { - [basePath]: overrides, + [basePath]: sanitizedOverrides, }, }); // log save to console for debugging // eslint-disable-next-line no-console console.log("Saved config data:", { - [basePath]: overrides, + [basePath]: sanitizedOverrides, update_topic: updateTopic, requires_restart: needsRestart ? 1 : 0, }); @@ -776,6 +809,9 @@ export function ConfigSection({ cameraName, globalValue, cameraValue, + hasChanges, + formData: (pendingData || formData) as ConfigSectionData, + onFormDataChange: (data: ConfigSectionData) => handleChange(data), // For widgets that need access to full camera config (e.g., zone names) fullCameraConfig: level === "camera" && cameraName diff --git a/web/src/components/config-form/sections/section-special-cases.ts b/web/src/components/config-form/sections/section-special-cases.ts index ba771d5c5..7a882a5c3 100644 --- a/web/src/components/config-form/sections/section-special-cases.ts +++ b/web/src/components/config-form/sections/section-special-cases.ts @@ -8,6 +8,8 @@ import { RJSFSchema } from "@rjsf/utils"; import { applySchemaDefaults } from "@/lib/config-schema"; +import { isJsonObject } from "@/lib/utils"; +import { JsonObject } from "@/types/configForm"; /** * Sections that require special handling at the global level. @@ -102,3 +104,47 @@ export function getEffectiveDefaultsForSection( return schemaDefaults; } + +/** + * Sanitize overrides payloads for section-specific quirks. + */ +export function sanitizeOverridesForSection( + sectionPath: string, + level: string, + overrides: unknown, +): unknown { + if (!overrides || !isJsonObject(overrides)) { + return overrides; + } + + if (sectionPath === "ffmpeg" && level === "camera") { + const overridesObj = overrides as JsonObject; + const inputs = overridesObj.inputs; + if (!Array.isArray(inputs)) { + return overrides; + } + + const cleanedInputs = inputs.map((input) => { + if (!isJsonObject(input)) { + return input; + } + + const cleanedInput = { ...input } as JsonObject; + ["global_args", "hwaccel_args", "input_args"].forEach((key) => { + const value = cleanedInput[key]; + if (Array.isArray(value) && value.length === 0) { + delete cleanedInput[key]; + } + }); + + return cleanedInput; + }); + + return { + ...overridesObj, + inputs: cleanedInputs, + }; + } + + return overrides; +} diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 225ecd4d9..224854221 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -22,6 +22,7 @@ import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget"; import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget"; import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget"; import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget"; +import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget"; import { FieldTemplate } from "./templates/FieldTemplate"; import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate"; @@ -48,6 +49,7 @@ export const frigateTheme: FrigateTheme = { SelectWidget: SelectWidget, CheckboxWidget: SwitchWidget, ArrayAsTextWidget: ArrayAsTextWidget, + FfmpegArgsWidget: FfmpegArgsWidget, // Custom widgets switch: SwitchWidget, password: PasswordWidget, diff --git a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx new file mode 100644 index 000000000..84702de02 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx @@ -0,0 +1,249 @@ +import type { WidgetProps } from "@rjsf/utils"; +import useSWR from "swr"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +type FfmpegPresetResponse = { + hwaccel_args: string[]; + input_args: string[]; + output_args: { + record: string[]; + detect: string[]; + }; +}; + +type FfmpegArgsMode = "preset" | "manual"; + +type PresetField = + | "hwaccel_args" + | "input_args" + | "output_args.record" + | "output_args.detect"; + +const getPresetOptions = ( + data: FfmpegPresetResponse | undefined, + field: PresetField | undefined, +): string[] => { + if (!data || !field) { + return []; + } + + if (field === "hwaccel_args") { + return data.hwaccel_args; + } + + if (field === "input_args") { + return data.input_args; + } + + if (field.startsWith("output_args.")) { + const key = field.split(".")[1] as "record" | "detect"; + return data.output_args?.[key] ?? []; + } + + return []; +}; + +const resolveMode = ( + value: unknown, + presets: string[], + defaultMode: FfmpegArgsMode, +): FfmpegArgsMode => { + if (Array.isArray(value)) { + return "manual"; + } + + if (typeof value === "string") { + if (presets.length === 0) { + return defaultMode; + } + + return presets.includes(value) ? "preset" : "manual"; + } + + return defaultMode; +}; + +const normalizeManualText = (value: unknown): string => { + if (Array.isArray(value)) { + return value.join(" "); + } + + if (typeof value === "string") { + return value; + } + + return ""; +}; + +export function FfmpegArgsWidget(props: WidgetProps) { + const { t } = useTranslation(["views/settings"]); + const { + value, + onChange, + disabled, + readonly, + options, + placeholder, + schema, + id, + } = props; + const presetField = options?.ffmpegPresetField as PresetField | undefined; + + const { data } = useSWR("ffmpeg/presets"); + + const presetOptions = useMemo( + () => getPresetOptions(data, presetField), + [data, presetField], + ); + + const canUsePresets = presetOptions.length > 0; + const defaultMode: FfmpegArgsMode = canUsePresets ? "preset" : "manual"; + + const detectedMode = useMemo( + () => resolveMode(value, presetOptions, defaultMode), + [value, presetOptions, defaultMode], + ); + + const [mode, setMode] = useState(detectedMode); + + useEffect(() => { + if (!canUsePresets) { + setMode("manual"); + return; + } + + setMode(detectedMode); + }, [canUsePresets, detectedMode]); + + const handleModeChange = useCallback( + (nextMode: FfmpegArgsMode) => { + setMode(nextMode); + + if (nextMode === "preset") { + const currentValue = typeof value === "string" ? value : undefined; + const presetValue = + currentValue && presetOptions.includes(currentValue) + ? currentValue + : presetOptions[0]; + if (presetValue) { + onChange(presetValue); + } + return; + } + + const manualText = normalizeManualText(value); + onChange(manualText); + }, + [onChange, presetOptions, value], + ); + + const handlePresetChange = useCallback( + (preset: string) => { + onChange(preset); + }, + [onChange], + ); + + const handleManualChange = useCallback( + (event: React.ChangeEvent) => { + const newText = event.target.value; + onChange(newText); + }, + [onChange], + ); + + const manualValue = normalizeManualText(value); + const presetValue = + typeof value === "string" && presetOptions.includes(value) ? value : ""; + + return ( +
+ handleModeChange(next as FfmpegArgsMode)} + className="gap-3" + > +
+ + +
+
+ + +
+
+ + {mode === "preset" && canUsePresets ? ( + + ) : ( + + )} +
+ ); +}