add backend endpoint and frontend widget for ffmpeg presets and manual args

This commit is contained in:
Josh Hawkins 2026-02-04 10:28:55 -06:00
parent 7f916dfb47
commit 3777f1d906
7 changed files with 430 additions and 118 deletions

View File

@ -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."""

View File

@ -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",

View File

@ -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"),
},
},
},

View File

@ -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

View File

@ -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;
}

View File

@ -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,

View File

@ -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<FfmpegPresetResponse>("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<FfmpegArgsMode>(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<HTMLInputElement>) => {
const newText = event.target.value;
onChange(newText);
},
[onChange],
);
const manualValue = normalizeManualText(value);
const presetValue =
typeof value === "string" && presetOptions.includes(value) ? value : "";
return (
<div className="space-y-2">
<RadioGroup
value={mode}
onValueChange={(next) => handleModeChange(next as FfmpegArgsMode)}
className="gap-3"
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="preset"
id={`${id}-preset`}
disabled={disabled || readonly || !canUsePresets}
className={
mode === "preset"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={`${id}-preset`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.preset", { ns: "views/settings" })}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="manual"
id={`${id}-manual`}
disabled={disabled || readonly}
className={
mode === "manual"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={`${id}-manual`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.manual", { ns: "views/settings" })}
</label>
</div>
</RadioGroup>
{mode === "preset" && canUsePresets ? (
<Select
value={presetValue}
onValueChange={handlePresetChange}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className="w-full">
<SelectValue
placeholder={
placeholder ||
schema.title ||
t("configForm.ffmpegArgs.selectPreset", {
ns: "views/settings",
})
}
/>
</SelectTrigger>
<SelectContent>
{presetOptions.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={id}
value={manualValue}
onChange={handleManualChange}
disabled={disabled || readonly}
placeholder={
placeholder ||
t("configForm.ffmpegArgs.manualPlaceholder", {
ns: "views/settings",
})
}
/>
)}
</div>
);
}