mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
add backend endpoint and frontend widget for ffmpeg presets and manual args
This commit is contained in:
parent
7f916dfb47
commit
3777f1d906
@ -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."""
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user