diff --git a/web/public/locales/en/config/groups.json b/web/public/locales/en/config/groups.json
index 581b77d0d..1663ad169 100644
--- a/web/public/locales/en/config/groups.json
+++ b/web/public/locales/en/config/groups.json
@@ -64,5 +64,10 @@
"retention": "Retention",
"events": "Events"
}
+ },
+ "ffmpeg": {
+ "cameras": {
+ "cameraFfmpeg": "Camera-specific FFmpeg arguments"
+ }
}
}
diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts
index cada4bd45..ccbca5609 100644
--- a/web/src/components/config-form/section-configs/ffmpeg.ts
+++ b/web/src/components/config-form/section-configs/ffmpeg.ts
@@ -159,6 +159,9 @@ const ffmpeg: SectionConfigOverrides = {
},
},
camera: {
+ fieldGroups: {
+ cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
+ },
restartRequired: [
"inputs",
"path",
diff --git a/web/src/components/config-form/theme/fields/CameraInputsField.tsx b/web/src/components/config-form/theme/fields/CameraInputsField.tsx
index 375aef3bd..ee19dbc95 100644
--- a/web/src/components/config-form/theme/fields/CameraInputsField.tsx
+++ b/web/src/components/config-form/theme/fields/CameraInputsField.tsx
@@ -24,6 +24,11 @@ import {
LuTrash2,
} from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
type FfmpegInput = {
path?: string;
@@ -351,6 +356,7 @@ export function CameraInputsField(props: FieldProps) {
{renderField(index, "path", {
extraUiSchema: {
+ "ui:widget": "CameraPathWidget",
"ui:options": {
size: "full",
splitLayout: false,
@@ -377,16 +383,23 @@ export function CameraInputsField(props: FieldProps) {
{renderField(index, "output_args")}
-
+
+
+
+
+
+ {t("button.delete", { ns: "common" })}
+
+
diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts
index 1b40dd813..3baa2f3ad 100644
--- a/web/src/components/config-form/theme/frigateTheme.ts
+++ b/web/src/components/config-form/theme/frigateTheme.ts
@@ -25,6 +25,7 @@ import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
import { InputRolesWidget } from "./widgets/InputRolesWidget";
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
+import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@@ -57,6 +58,7 @@ export const frigateTheme: FrigateTheme = {
CheckboxWidget: SwitchWidget,
ArrayAsTextWidget: ArrayAsTextWidget,
FfmpegArgsWidget: FfmpegArgsWidget,
+ CameraPathWidget: CameraPathWidget,
inputRoles: InputRolesWidget,
// Custom widgets
switch: SwitchWidget,
diff --git a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx
new file mode 100644
index 000000000..b2490d2ab
--- /dev/null
+++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx
@@ -0,0 +1,202 @@
+import type { WidgetProps } from "@rjsf/utils";
+import useSWR from "swr";
+import { useMemo, useState, type FocusEvent } from "react";
+import { useTranslation } from "react-i18next";
+import { LuEye, LuEyeOff } from "react-icons/lu";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import type { ConfigFormContext } from "@/types/configForm";
+import { cn } from "@/lib/utils";
+import { getSizedFieldClassName } from "../utils";
+
+type RawPathsResponse = {
+ cameras?: Record<
+ string,
+ {
+ ffmpeg?: {
+ inputs?: Array<{
+ path?: string;
+ }>;
+ };
+ }
+ >;
+};
+
+const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
+const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
+
+const getInputIndexFromWidgetId = (id: string): number | undefined => {
+ const match = id.match(/_inputs_(\d+)_path$/);
+ if (!match) {
+ return undefined;
+ }
+
+ const index = Number(match[1]);
+ return Number.isNaN(index) ? undefined : index;
+};
+
+const isMaskedPath = (value: string): boolean =>
+ MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
+
+const hasCredentials = (value: string): boolean => {
+ if (!value) {
+ return false;
+ }
+
+ if (isMaskedPath(value)) {
+ return true;
+ }
+
+ try {
+ const parsed = new URL(value);
+ if (parsed.username || parsed.password) {
+ return true;
+ }
+
+ return (
+ parsed.searchParams.has("user") && parsed.searchParams.has("password")
+ );
+ } catch {
+ return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
+ }
+};
+
+const maskCredentials = (value: string): string => {
+ if (!value) {
+ return value;
+ }
+
+ const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
+
+ return maskedAuth
+ .replace(/([?&]user=)[^&]*/gi, "$1*")
+ .replace(/([?&]password=)[^&]*/gi, "$1*");
+};
+
+export function CameraPathWidget(props: WidgetProps) {
+ const {
+ id,
+ value,
+ disabled,
+ readonly,
+ onChange,
+ onBlur,
+ onFocus,
+ placeholder,
+ schema,
+ options,
+ } = props;
+
+ const { t } = useTranslation(["common", "views/settings"]);
+ const [showCredentials, setShowCredentials] = useState(false);
+
+ const formContext = props.registry?.formContext as
+ | ConfigFormContext
+ | undefined;
+ const isCameraLevel = formContext?.level === "camera";
+ const cameraName = formContext?.cameraName;
+ const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]);
+
+ const shouldFetchRawPaths =
+ isCameraLevel && !!cameraName && inputIndex !== undefined;
+ const { data: rawPaths } = useSWR
(
+ shouldFetchRawPaths ? "config/raw_paths" : null,
+ );
+
+ const rawPath = useMemo(() => {
+ if (!cameraName || inputIndex === undefined) {
+ return undefined;
+ }
+
+ const path =
+ rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
+ return typeof path === "string" ? path : undefined;
+ }, [cameraName, inputIndex, rawPaths]);
+
+ const rawValue = typeof value === "string" ? value : "";
+ const resolvedValue =
+ isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
+ const canReveal =
+ hasCredentials(resolvedValue) && !isMaskedPath(resolvedValue);
+ const canToggle = canReveal || isMaskedPath(rawValue);
+
+ const isMaskedView = canToggle && !showCredentials;
+ const displayValue = isMaskedView
+ ? maskCredentials(resolvedValue)
+ : resolvedValue;
+
+ const isNullable = Array.isArray(schema.type)
+ ? schema.type.includes("null")
+ : false;
+
+ const fieldClassName = getSizedFieldClassName(options, "xs");
+ const uriLabel = t("cameraWizard.step3.url", {
+ ns: "views/settings",
+ defaultValue: schema.title,
+ });
+ const toggleLabel = showCredentials
+ ? t("label.hide", { ns: "common", item: uriLabel })
+ : t("label.show", { ns: "common", item: uriLabel });
+
+ const handleFocus = (event: FocusEvent) => {
+ if (isMaskedView && canReveal) {
+ setShowCredentials(true);
+ onFocus(id, resolvedValue);
+ return;
+ }
+
+ onFocus(id, event.target.value);
+ };
+
+ const handleBlur = (event: FocusEvent) => {
+ if (canToggle) {
+ setShowCredentials(false);
+ }
+
+ onBlur(id, event.target.value);
+ };
+
+ return (
+
+
+ onChange(
+ e.target.value === ""
+ ? isNullable
+ ? null
+ : undefined
+ : e.target.value,
+ )
+ }
+ onBlur={handleBlur}
+ onFocus={handleFocus}
+ aria-label={schema.title}
+ />
+
+ {canToggle ? (
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/indicators/RestartRequiredIndicator.tsx b/web/src/components/indicators/RestartRequiredIndicator.tsx
index 3268599ea..f2f71c05c 100644
--- a/web/src/components/indicators/RestartRequiredIndicator.tsx
+++ b/web/src/components/indicators/RestartRequiredIndicator.tsx
@@ -1,6 +1,8 @@
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { LuRefreshCcw } from "react-icons/lu";
+import { Tooltip, TooltipContent } from "../ui/tooltip";
+import { TooltipTrigger } from "@radix-ui/react-tooltip";
type RestartRequiredIndicatorProps = {
className?: string;
@@ -18,15 +20,19 @@ export default function RestartRequiredIndicator({
});
return (
-
-
-
+
+
+
+
+
+
+ {restartRequiredLabel}
+
);
}