feat: camera edit form add go2rtc

This commit is contained in:
ZhaiSoul 2025-08-28 17:39:42 +00:00
parent d78b6e528b
commit 78e431d5e3
7 changed files with 550 additions and 38 deletions

View File

@ -80,6 +80,31 @@ class CameraConfigUpdateSubscriber:
self.camera_configs[camera] = updated_config self.camera_configs[camera] = updated_config
return return
elif update_type == CameraConfigUpdateEnum.remove: elif update_type == CameraConfigUpdateEnum.remove:
"""Remove go2rtc streams with camera"""
camera_config = self.camera_configs.get(camera)
if (
camera_config
and hasattr(self.config, "go2rtc")
and hasattr(camera_config, "ffmpeg")
and hasattr(camera_config.ffmpeg, "inputs")
):
for input_item in camera_config.ffmpeg.inputs:
if hasattr(input_item, "path") and isinstance(input_item.path, str):
if (
"rtsp://" in input_item.path
and "127.0.0.1:8554/" in input_item.path
):
stream_name = (
input_item.path.split("127.0.0.1:8554/")[-1]
.split("&")[0]
.split("?")[0]
)
if (
hasattr(self.config.go2rtc, "streams")
and stream_name in self.config.go2rtc.streams
):
self.config.go2rtc.streams.pop(stream_name)
self.config.cameras.pop(camera) self.config.cameras.pop(camera)
self.camera_configs.pop(camera) self.camera_configs.pop(camera)
return return

View File

@ -198,10 +198,59 @@
"enabled": "Enabled", "enabled": "Enabled",
"ffmpeg": { "ffmpeg": {
"inputs": "Input Streams", "inputs": "Input Streams",
"go2rtc": {
"title": "Use go2rtc stream",
"description": "will use go2rtc",
"compatibility": {
"title": "Video codec compatibility (Advanced)",
"description": "Here you can configure common go2rtc FFmpeg encoding parameters. Some of these parameters may help resolve certain video or audio issues.",
"codec": {
"title": "Video Codec",
"description": "You can use go2rtc to convert the stream to a specified encoding. Note that converting video from one format to another is a resource-intensive task, so additional conversion is not recommended if the original setup works properly.",
"h264": "H.264",
"h265": "H.265",
"copy": "Copy (default)"
},
"audio": {
"title": "Audio Codec",
"description": "If your camera does not support AAC audio, you can use this parameter to convert the audio to AAC. If you can see the video but hear no sound, try adding this parameter.",
"copy": "Copy (default)",
"aac": "AAC",
"opus": "Opus"
},
"rotate": {
"title": "Rotation",
"description": "Some cameras may not set the correct rotation flag in the video stream. If your camera's video appears rotated incorrectly, you can use this parameter to manually set the rotation.",
"90": "90°",
"180": "180°",
"270": "270°"
},
"hardware": {
"title": "Hardware",
"description": "If you are using a hardware encoder, you can use this parameter to specify the hardware encoder to use.",
"useHardware": "Force use hardware"
}
}
},
"path": "Stream Path", "path": "Stream Path",
"pathRequired": "Stream path is required", "pathRequired": "Stream path is required",
"pathPlaceholder": "rtsp://...", "pathPlaceholder": "rtsp://...",
"roles": "Roles", "roles": {
"title": "Roles",
"record": {
"label": "Record",
"info": "5555"
},
"audio": {
"label": "Audio",
"info": "6666"
},
"detect": {
"label": "Detect",
"info": "77777"
}
},
"rolesRequired": "At least one role is required", "rolesRequired": "At least one role is required",
"rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream", "rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
"addInput": "Add Input Stream", "addInput": "Add Input Stream",

View File

@ -7,7 +7,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input, MultiSelectInput } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@ -22,6 +22,14 @@ import { LuTrash2, LuPlus } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import i18n from "@/utils/i18n";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
type ConfigSetBody = { type ConfigSetBody = {
requires_restart: number; requires_restart: number;
@ -55,6 +63,100 @@ export default function CameraEditForm({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const go2rtc_ffmpegOptions = [
{
id: "codec",
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.title"),
description: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.description",
),
options: [
{
value: "#video=h264",
label: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.h264",
),
},
{
value: "#video=h265",
label: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.h265",
),
},
{
value: "#video=copy",
label: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.copy",
),
},
],
},
{
id: "audio",
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.title"),
description: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.description",
),
options: [
{
value: "#audio=aac",
label: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.aac"),
},
{
value: "#audio=opus",
label: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.opus",
),
},
{
value: "#audio=copy",
label: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.copy",
),
},
],
},
{
id: "rotate",
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.title"),
description: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.description",
),
options: [
{
value: "#rotate=90",
label: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.90"),
},
{
value: "#rotate=180",
label: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.180",
),
},
{
value: "#rotate=270",
label: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.270",
),
},
],
},
{
id: "hardware",
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.title"),
description: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.description",
),
options: [
{
value: "#hardware",
label: t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.useHardware",
),
},
],
},
];
const formSchema = useMemo( const formSchema = useMemo(
() => () =>
z.object({ z.object({
@ -72,6 +174,8 @@ export default function CameraEditForm({
roles: z.array(RoleEnum).min(1, { roles: z.array(RoleEnum).min(1, {
message: t("camera.cameraConfig.ffmpeg.rolesRequired"), message: t("camera.cameraConfig.ffmpeg.rolesRequired"),
}), }),
go2rtc: z.boolean(),
go2rtc_ffmpeg: z.string().optional(),
}), }),
) )
.min(1, { .min(1, {
@ -133,6 +237,8 @@ export default function CameraEditForm({
ffmpeg: { ffmpeg: {
inputs: [ inputs: [
{ {
go2rtc: false,
go2rtc_ffmpeg: "",
path: "", path: "",
roles: cameraInfo.roles.has("detect") ? [] : ["detect"], roles: cameraInfo.roles.has("detect") ? [] : ["detect"],
}, },
@ -145,10 +251,51 @@ export default function CameraEditForm({
const camera = config.cameras[cameraName]; const camera = config.cameras[cameraName];
defaultValues.enabled = camera.enabled ?? true; defaultValues.enabled = camera.enabled ?? true;
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
? camera.ffmpeg.inputs.map((input) => ({ ? camera.ffmpeg.inputs.map((input) => {
path: input.path, const isGo2rtcPath = input.path.match(
roles: input.roles as Role[], /^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
})) );
const go2rtcStreamName = isGo2rtcPath ? isGo2rtcPath[1] : null;
let originalPath = input.path;
let ffmpegParams = "";
if (go2rtcStreamName && config.go2rtc?.streams) {
Object.entries(config.go2rtc.streams).forEach(
([streamKey, streamConfig]) => {
if (
streamKey === go2rtcStreamName ||
streamKey.startsWith(`${cameraName}_`)
) {
if (Array.isArray(streamConfig) && streamConfig.length >= 1) {
originalPath = streamConfig[0] || "";
ffmpegParams = streamConfig[1] || "";
}
}
},
);
if (
originalPath === input.path &&
config.go2rtc.streams[go2rtcStreamName]
) {
const streamConfig = config.go2rtc.streams[go2rtcStreamName];
if (Array.isArray(streamConfig) && streamConfig.length >= 1) {
originalPath = streamConfig[0] || "";
ffmpegParams = streamConfig[1] || "";
}
}
}
return {
path: isGo2rtcPath ? originalPath : input.path,
roles: input.roles as Role[],
go2rtc: !!isGo2rtcPath,
go2rtc_ffmpeg: isGo2rtcPath
? ffmpegParams
: config.go2rtc?.streams?.[cameraName]?.[1] || "",
};
})
: defaultValues.ffmpeg.inputs; : defaultValues.ffmpeg.inputs;
} }
@ -183,7 +330,9 @@ export default function CameraEditForm({
...(friendly_name && { friendly_name }), ...(friendly_name && { friendly_name }),
ffmpeg: { ffmpeg: {
inputs: values.ffmpeg.inputs.map((input) => ({ inputs: values.ffmpeg.inputs.map((input) => ({
path: input.path, path: input.go2rtc
? `rtsp://127.0.0.1:8554/${finalCameraName}_${input.roles.join("_")}`
: input.path,
roles: input.roles, roles: input.roles,
})), })),
}, },
@ -191,6 +340,24 @@ export default function CameraEditForm({
}, },
}; };
const hasGo2rtcEnabled = values.ffmpeg.inputs.some((input) => input.go2rtc);
if (hasGo2rtcEnabled) {
const go2rtcStreams: Record<string, string[]> = {};
values.ffmpeg.inputs.forEach((input) => {
if (input.go2rtc) {
const streamName = `${finalCameraName}_${input.roles.join("_")}`;
go2rtcStreams[streamName] = [input.path, input.go2rtc_ffmpeg || ""];
}
});
configData.go2rtc = {
streams: go2rtcStreams,
};
}
const requestBody: ConfigSetBody = { const requestBody: ConfigSetBody = {
requires_restart: 1, requires_restart: 1,
config_data: configData, config_data: configData,
@ -232,11 +399,7 @@ export default function CameraEditForm({
}; };
const onSubmit = (values: FormValues) => { const onSubmit = (values: FormValues) => {
if ( if (cameraName && values.cameraName !== cameraName) {
cameraName &&
values.cameraName !== cameraName &&
values.cameraName !== cameraInfo?.friendly_name
) {
// If camera name changed, delete old camera config // If camera name changed, delete old camera config
const deleteRequestBody: ConfigSetBody = { const deleteRequestBody: ConfigSetBody = {
requires_restart: 1, requires_restart: 1,
@ -247,6 +410,20 @@ export default function CameraEditForm({
}, },
update_topic: `config/cameras/${cameraName}/remove`, update_topic: `config/cameras/${cameraName}/remove`,
}; };
const camera = config?.cameras[cameraName];
if (values.ffmpeg.inputs.some((input) => input.go2rtc)) {
camera?.ffmpeg.inputs.map((input) => {
const isGo2rtcPath = input.path.match(
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
);
const go2rtcStreamName = isGo2rtcPath ? isGo2rtcPath[1] : "";
deleteRequestBody.config_data.go2rtc = {
streams: {
[go2rtcStreamName]: "",
},
};
});
}
axios axios
.put("config/set", deleteRequestBody) .put("config/set", deleteRequestBody)
@ -372,32 +549,56 @@ export default function CameraEditForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("camera.cameraConfig.ffmpeg.roles")} {t("camera.cameraConfig.ffmpeg.roles.title")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(["audio", "detect", "record"] as const).map( {(["audio", "detect", "record"] as const).map(
(role) => ( (role) => (
<label <Tooltip>
key={role} <TooltipTrigger asChild>
className="flex items-center space-x-2" <label
> key={role}
<input className="flex items-center space-x-2"
type="checkbox" >
checked={field.value.includes(role)} <input
onChange={(e) => { type="checkbox"
const updatedRoles = e.target.checked checked={field.value.includes(role)}
? [...field.value, role] onChange={(e) => {
: field.value.filter((r) => r !== role); const updatedRoles = e.target.checked
field.onChange(updatedRoles); ? [...field.value, role]
}} : field.value.filter(
disabled={ (r) => r !== role,
!field.value.includes(role) && );
getUsedRolesExcludingIndex(index).has(role) field.onChange(updatedRoles);
} }}
/> disabled={
<span>{role}</span> !field.value.includes(role) &&
</label> getUsedRolesExcludingIndex(index).has(
role,
)
}
/>
<span>
{i18n.language === "en"
? role
: t(
`camera.cameraConfig.ffmpeg.roles.${role}.label`,
) +
"(" +
role +
")"}
</span>
</label>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t(
`camera.cameraConfig.ffmpeg.roles.${role}.info`,
)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
), ),
)} )}
</div> </div>
@ -406,7 +607,73 @@ export default function CameraEditForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name={`ffmpeg.inputs.${index}.go2rtc`}
render={({ field }) => (
<FormItem>
<FormLabel>
{t("camera.cameraConfig.ffmpeg.go2rtc.title")}
<div className="my-3 text-sm text-muted-foreground">
{t("camera.cameraConfig.ffmpeg.go2rtc.description")}
</div>
</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel>
{t("camera.cameraConfig.enabled")}
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ffmpeg.inputs.${index}.go2rtc_ffmpeg`}
render={({ field }) => (
<FormItem>
{form.watch(`ffmpeg.inputs.${index}.go2rtc`) ? (
<>
<FormLabel>
{t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.title",
)}
</FormLabel>
<div className="my-3 text-sm text-muted-foreground">
{t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.description",
)}
</div>
<FormControl>
<MultiSelectInput
parameterGroups={go2rtc_ffmpegOptions}
onParameterChange={(params) => {
const combinedValue =
Object.values(params).join("");
field.onChange(combinedValue);
}}
parameterPlaceholder={t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.select",
)}
inputPlaceholder={t(
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.custom",
)}
{...field}
disabled={!!cameraName} // Prevent editing name for existing cameras
/>
</FormControl>
<FormMessage />
</>
) : null}
</FormItem>
)}
/>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@ -426,7 +693,9 @@ export default function CameraEditForm({
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-2" className="mt-2"
onClick={() => append({ path: "", roles: getAvailableRoles() })} onClick={() =>
append({ path: "", roles: getAvailableRoles(), go2rtc: false })
}
> >
<LuPlus className="mr-2 h-4 w-4" /> <LuPlus className="mr-2 h-4 w-4" />
{t("camera.cameraConfig.ffmpeg.addInput")} {t("camera.cameraConfig.ffmpeg.addInput")}

View File

@ -22,4 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
) )
Input.displayName = "Input" Input.displayName = "Input"
export { Input } import { SelectInput } from "./select-input"
import { MultiSelectInput } from "./multi-select-input"
export { Input, SelectInput, MultiSelectInput }

View File

@ -0,0 +1,166 @@
import * as React from "react"
import { Input, InputProps } from "./input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from "./select"
import { cn } from "@/lib/utils"
import { LuX } from "react-icons/lu"
export type ParameterOption = {
value: string
label: string
}
export type ParameterGroup = {
id: string
name: string
description?: string
options: ParameterOption[]
}
export interface MultiSelectInputProps extends InputProps {
parameterGroups: ParameterGroup[]
onParameterChange?: (parameters: Record<string, string>) => void
parameterPlaceholder?: string
inputPlaceholder?: string
}
const MultiSelectInput = React.forwardRef<HTMLInputElement, MultiSelectInputProps>(
({
className,
parameterGroups,
onParameterChange,
parameterPlaceholder = "Select...",
inputPlaceholder = "input...",
...props
}, ref) => {
const [selectedParameters, setSelectedParameters] = React.useState<Record<string, string>>({})
const [currentParameter, setCurrentParameter] = React.useState<string | null>(null)
const [inputValue, setInputValue] = React.useState("")
const getCurrentParameterOptions = () => {
if (!currentParameter) return []
const group = parameterGroups.find(g => g.id === currentParameter)
return group?.options || []
}
const handleParameterSelect = (parameterId: string) => {
setCurrentParameter(parameterId)
}
const handleOptionSelect = (value: string) => {
if (currentParameter) {
const newSelectedParameters = {
...selectedParameters,
[currentParameter]: value
}
setSelectedParameters(newSelectedParameters)
if (onParameterChange) {
onParameterChange(newSelectedParameters)
}
setCurrentParameter(null)
}
}
const removeParameter = (parameterId: string) => {
const newSelectedParameters = { ...selectedParameters }
delete newSelectedParameters[parameterId]
setSelectedParameters(newSelectedParameters)
if (onParameterChange) {
onParameterChange(newSelectedParameters)
}
}
const getParameterDisplayName = (parameterId: string) => {
const group = parameterGroups.find(g => g.id === parameterId)
return group?.name || parameterId
}
const getOptionDisplayName = (parameterId: string, optionValue: string) => {
const group = parameterGroups.find(g => g.id === parameterId)
const option = group?.options.find(o => o.value === optionValue)
return option?.label || optionValue
}
return (
<div className="space-y-2">
{Object.keys(selectedParameters).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(selectedParameters).map(([parameterId, optionValue]) => (
<div
key={parameterId}
className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-sm text-green-800"
>
<span>{getParameterDisplayName(parameterId)}:</span>
<span>{getOptionDisplayName(parameterId, optionValue)}</span>
<button
type="button"
onClick={() => removeParameter(parameterId)}
className="ml-1 rounded-full p-0.5 hover:bg-green-200"
>
<LuX className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<Select value={currentParameter || ""} onValueChange={handleParameterSelect}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={parameterPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{parameterGroups
.filter(group => !(group.id in selectedParameters))
.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{currentParameter ? (
<Select value="" onValueChange={handleOptionSelect}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{getCurrentParameterOptions().map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Input
className={cn("flex-1", className)}
placeholder={inputPlaceholder}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
ref={ref}
{...props}
/>
)}
</div>
<div className="my-3 text-sm text-muted-foreground">{parameterGroups.find(g => g.id === currentParameter)?.description}</div>
</div>
)
}
)
MultiSelectInput.displayName = "MultiSelectInput"
export { MultiSelectInput }

View File

@ -62,7 +62,7 @@ export default function useStats(stats: FrigateStats | undefined) {
} }
const cameraName = config.cameras?.[name]?.friendly_name ?? name; const cameraName = config.cameras?.[name]?.friendly_name ?? name;
if (config.cameras[name].enabled && cam["camera_fps"] == 0) { if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
problems.push({ problems.push({
text: t("stats.cameraIsOffline", { text: t("stats.cameraIsOffline", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)), camera: capitalizeFirstLetter(capitalizeAll(cameraName)),

View File

@ -421,7 +421,7 @@ export interface FrigateConfig {
}; };
go2rtc: { go2rtc: {
streams: string[]; streams: { [streamName: string]: string[] };
webrtc: { webrtc: {
candidates: string[]; candidates: string[];
}; };