diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 477f6e0f90..9f842b79c0 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1649,6 +1649,7 @@ "addStream": "Add stream", "addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.", "addUrl": "Add URL", + "streamNumber": "Stream {{index}}", "streamName": "Stream name", "streamNamePlaceholder": "e.g., front_door", "streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream", @@ -1682,7 +1683,15 @@ "audioMp3": "Transcode to MP3", "audioExclude": "Exclude", "hardwareNone": "No hardware acceleration", - "hardwareAuto": "Automatic hardware acceleration" + "hardwareAuto": "Automatic (recommended)", + "hardwareVaapi": "VAAPI", + "hardwareCuda": "CUDA", + "hardwareV4l2m2m": "V4L2 M2M", + "hardwareDxva2": "DXVA2", + "hardwareVideotoolbox": "VideoToolbox", + "addVideoCodec": "Add video codec", + "addAudioCodec": "Add audio codec", + "removeCodec": "Remove codec" } }, "birdseye": { diff --git a/web/src/utils/go2rtcFfmpeg.ts b/web/src/utils/go2rtcFfmpeg.ts index 76f156993a..e9482f5251 100644 --- a/web/src/utils/go2rtcFfmpeg.ts +++ b/web/src/utils/go2rtcFfmpeg.ts @@ -8,13 +8,23 @@ export type FfmpegAudioOption = | "pcm" | "mp3" | "exclude"; -export type FfmpegHardwareOption = "none" | "auto"; +export type FfmpegHardwareOption = + | "none" + | "auto" + | "vaapi" + | "cuda" + | "v4l2m2m" + | "dxva2" + | "videotoolbox"; export type ParsedFfmpegUrl = { isFfmpeg: boolean; baseUrl: string; - video: FfmpegVideoOption; - audio: FfmpegAudioOption; + // go2rtc accepts repeatable #video=/#audio= fragments to express a fallback + // chain (copy if source codec matches, otherwise transcode). An empty array + // means no fragment is emitted for that track — equivalent to "exclude". + videos: FfmpegVideoOption[]; + audios: FfmpegAudioOption[]; hardware: FfmpegHardwareOption; extraFragments: string[]; }; @@ -37,13 +47,21 @@ const HARDWARE_SPECIFIC = new Set([ "videotoolbox", ]); +function isRecognizedFragment(frag: string): boolean { + if (frag === "hardware") return true; + if (frag.startsWith("video=")) return VIDEO_VALUES.has(frag.slice(6)); + if (frag.startsWith("audio=")) return AUDIO_VALUES.has(frag.slice(6)); + if (frag.startsWith("hardware=")) return HARDWARE_SPECIFIC.has(frag.slice(9)); + return false; +} + export function parseFfmpegUrl(url: string): ParsedFfmpegUrl { if (!url.startsWith("ffmpeg:")) { return { isFfmpeg: false, baseUrl: url, - video: "copy", - audio: "copy", + videos: [], + audios: [], hardware: "none", extraFragments: [], }; @@ -54,63 +72,76 @@ export function parseFfmpegUrl(url: string): ParsedFfmpegUrl { const baseUrl = parts[0]; const fragments = parts.slice(1); - let video: FfmpegVideoOption | null = null; - let audio: FfmpegAudioOption | null = null; + const videos: FfmpegVideoOption[] = []; + const audios: FfmpegAudioOption[] = []; let hardware: FfmpegHardwareOption = "none"; const extraFragments: string[] = []; for (const frag of fragments) { - if (frag.startsWith("video=")) { - const val = frag.slice(6); - if (VIDEO_VALUES.has(val)) { - video = val as FfmpegVideoOption; - } else { - extraFragments.push(frag); - } - } else if (frag.startsWith("audio=")) { - const val = frag.slice(6); - if (AUDIO_VALUES.has(val)) { - audio = val as FfmpegAudioOption; - } else { - extraFragments.push(frag); - } + if (frag.startsWith("video=") && VIDEO_VALUES.has(frag.slice(6))) { + videos.push(frag.slice(6) as FfmpegVideoOption); + } else if (frag.startsWith("audio=") && AUDIO_VALUES.has(frag.slice(6))) { + audios.push(frag.slice(6) as FfmpegAudioOption); } else if (frag === "hardware") { hardware = "auto"; - } else if (frag.startsWith("hardware=")) { - const val = frag.slice(9); - if (HARDWARE_SPECIFIC.has(val)) { - hardware = "auto"; - } else { - extraFragments.push(frag); - } + } else if ( + frag.startsWith("hardware=") && + HARDWARE_SPECIFIC.has(frag.slice(9)) + ) { + hardware = frag.slice(9) as FfmpegHardwareOption; } else { extraFragments.push(frag); } } - const hasAnyKnownFragment = video !== null || audio !== null; - return { isFfmpeg: true, baseUrl, - video: video ?? (hasAnyKnownFragment ? "exclude" : "copy"), - audio: audio ?? (hasAnyKnownFragment ? "exclude" : "copy"), + // Guarantee at least one row per track so the UI always has a primary + // dropdown to render; "exclude" is the sentinel meaning "no fragment". + videos: videos.length > 0 ? videos : ["exclude"], + audios: audios.length > 0 ? audios : ["exclude"], hardware, extraFragments, }; } +// Splits the editable "base URL + extra fragments" portion of a compat-mode +// URL into its parts. Recognized fragments (video=, audio=, hardware) are +// dropped — they are managed by the dedicated controls in the UI. +export function parseFfmpegBaseAndExtras(input: string): { + baseUrl: string; + extraFragments: string[]; +} { + const cleaned = input.startsWith("ffmpeg:") ? input.slice(7) : input; + const parts = cleaned.split("#"); + const baseUrl = parts[0]; + const extraFragments = parts.slice(1).filter((f) => !isRecognizedFragment(f)); + return { baseUrl, extraFragments }; +} + export function buildFfmpegUrl(parsed: ParsedFfmpegUrl): string { let url = `ffmpeg:${parsed.baseUrl}`; - if (parsed.video !== "exclude") { - url += `#video=${parsed.video}`; + // Exclude is a primary-row sentinel meaning "no fragment for this track" — + // it's mutually exclusive with fallbacks. If the primary is exclude, emit + // nothing for that track regardless of trailing entries. + if (parsed.videos[0] !== "exclude") { + for (const v of parsed.videos) { + if (v === "exclude") continue; + url += `#video=${v}`; + } } - if (parsed.audio !== "exclude") { - url += `#audio=${parsed.audio}`; + if (parsed.audios[0] !== "exclude") { + for (const a of parsed.audios) { + if (a === "exclude") continue; + url += `#audio=${a}`; + } } if (parsed.hardware === "auto") { url += "#hardware"; + } else if (parsed.hardware !== "none") { + url += `#hardware=${parsed.hardware}`; } for (const frag of parsed.extraFragments) { url += `#${frag}`; @@ -131,7 +162,9 @@ export function toggleFfmpegMode(url: string, enable: boolean): string { return url; } - const withoutPrefix = url.slice(7); - const baseUrl = withoutPrefix.split("#")[0]; - return baseUrl; + // Preserve unknown fragments (e.g. #timeout=10) when leaving compat mode; + // only video/audio/hardware are go2rtc-ffmpeg directives that should be + // dropped along with the prefix. + const parsed = parseFfmpegUrl(url); + return [parsed.baseUrl, ...parsed.extraFragments].join("#"); } diff --git a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx index 1d5781ad0e..1cda35e2d3 100644 --- a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx +++ b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx @@ -10,15 +10,21 @@ import { LuEye, LuEyeOff, LuPencil, - LuPlus, + LuCirclePlus, + LuSlidersHorizontal, LuTrash2, + LuX, } from "react-icons/lu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Link } from "react-router-dom"; import Heading from "@/components/ui/heading"; import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, @@ -62,11 +68,13 @@ import { } from "@/utils/credentialMask"; import { parseFfmpegUrl, + parseFfmpegBaseAndExtras, buildFfmpegUrl, toggleFfmpegMode, type FfmpegVideoOption, type FfmpegAudioOption, type FfmpegHardwareOption, + type ParsedFfmpegUrl, } from "@/utils/go2rtcFfmpeg"; type RawPathsResponse = { @@ -365,7 +373,7 @@ export default function Go2RtcStreamsSettingsView({ variant="outline" className="my-4" > - + {t("go2rtcStreams.addStream")} @@ -703,7 +711,7 @@ function StreamCard({ -
+
{urls.map((url, urlIndex) => ( - + {t("go2rtcStreams.addUrl")}
@@ -764,7 +772,9 @@ function StreamUrlEntry({ const [isFocused, setIsFocused] = useState(false); const parsed = useMemo(() => parseFfmpegUrl(url), [url]); - const rawBaseUrl = parsed.isFfmpeg ? parsed.baseUrl : url; + const rawBaseUrl = parsed.isFfmpeg + ? [parsed.baseUrl, ...parsed.extraFragments].join("#") + : url; const canToggleCredentials = hasCredentials(rawBaseUrl) && !isMaskedPath(rawBaseUrl); @@ -778,15 +788,16 @@ function StreamUrlEntry({ }, [rawBaseUrl, showCredentials, isFocused]); const isTranscodingVideo = - parsed.isFfmpeg && parsed.video !== "copy" && parsed.video !== "exclude"; + parsed.isFfmpeg && parsed.videos.some((v) => v === "h264" || v === "h265"); const handleBaseUrlChange = useCallback( - (newBaseUrl: string) => { + (newInput: string) => { if (parsed.isFfmpeg) { - const newUrl = buildFfmpegUrl({ ...parsed, baseUrl: newBaseUrl }); + const { baseUrl, extraFragments } = parseFfmpegBaseAndExtras(newInput); + const newUrl = buildFfmpegUrl({ ...parsed, baseUrl, extraFragments }); onUpdateUrl(streamName, urlIndex, newUrl); } else { - onUpdateUrl(streamName, urlIndex, newBaseUrl); + onUpdateUrl(streamName, urlIndex, newInput); } }, [parsed, streamName, urlIndex, onUpdateUrl], @@ -800,64 +811,103 @@ function StreamUrlEntry({ [url, streamName, urlIndex, onUpdateUrl], ); - const handleFfmpegOptionChange = useCallback( - ( - field: "video" | "audio" | "hardware", - value: FfmpegVideoOption | FfmpegAudioOption | FfmpegHardwareOption, - ) => { - const updated = { ...parsed, [field]: value }; - // Clear hardware when switching away from transcoding video - if (field === "video" && (value === "copy" || value === "exclude")) { - updated.hardware = "none"; + const persistFfmpeg = useCallback( + (next: Partial) => { + const merged = { ...parsed, ...next }; + // Hardware acceleration is meaningless without a transcoding video codec + if (!merged.videos.some((v) => v === "h264" || v === "h265")) { + merged.hardware = "none"; } - const newUrl = buildFfmpegUrl(updated); - onUpdateUrl(streamName, urlIndex, newUrl); + onUpdateUrl(streamName, urlIndex, buildFfmpegUrl(merged)); }, [parsed, streamName, urlIndex, onUpdateUrl], ); - const audioDisplayLabel = useMemo(() => { - const labels: Record = { - copy: t("go2rtcStreams.ffmpeg.audioCopy"), - aac: t("go2rtcStreams.ffmpeg.audioAac"), - opus: t("go2rtcStreams.ffmpeg.audioOpus"), - pcmu: t("go2rtcStreams.ffmpeg.audioPcmu"), - pcma: t("go2rtcStreams.ffmpeg.audioPcma"), - pcm: t("go2rtcStreams.ffmpeg.audioPcm"), - mp3: t("go2rtcStreams.ffmpeg.audioMp3"), - exclude: t("go2rtcStreams.ffmpeg.audioExclude"), - }; - return labels[parsed.audio] || parsed.audio; - }, [parsed.audio, t]); + const updateVideoAt = useCallback( + (idx: number, value: FfmpegVideoOption) => { + // Picking exclude on the primary row drops any existing fallbacks — + // they have no meaning when the track is excluded entirely. + const videos = + idx === 0 && value === "exclude" + ? ["exclude" as FfmpegVideoOption] + : parsed.videos.map((v, i) => (i === idx ? value : v)); + persistFfmpeg({ videos }); + }, + [parsed.videos, persistFfmpeg], + ); + + const addVideo = useCallback(() => { + persistFfmpeg({ videos: [...parsed.videos, "copy"] }); + }, [parsed.videos, persistFfmpeg]); + + const removeVideoAt = useCallback( + (idx: number) => { + persistFfmpeg({ videos: parsed.videos.filter((_, i) => i !== idx) }); + }, + [parsed.videos, persistFfmpeg], + ); + + const updateAudioAt = useCallback( + (idx: number, value: FfmpegAudioOption) => { + // Picking exclude on the primary row drops any existing fallbacks — + // they have no meaning when the track is excluded entirely. + const audios = + idx === 0 && value === "exclude" + ? ["exclude" as FfmpegAudioOption] + : parsed.audios.map((a, i) => (i === idx ? value : a)); + persistFfmpeg({ audios }); + }, + [parsed.audios, persistFfmpeg], + ); + + const addAudio = useCallback(() => { + persistFfmpeg({ audios: [...parsed.audios, "copy"] }); + }, [parsed.audios, persistFfmpeg]); + + const removeAudioAt = useCallback( + (idx: number) => { + persistFfmpeg({ audios: parsed.audios.filter((_, i) => i !== idx) }); + }, + [parsed.audios, persistFfmpeg], + ); + + const updateHardware = useCallback( + (value: FfmpegHardwareOption) => { + persistFfmpeg({ hardware: value }); + }, + [persistFfmpeg], + ); + + const videoLabels: Record = { + copy: t("go2rtcStreams.ffmpeg.videoCopy"), + h264: t("go2rtcStreams.ffmpeg.videoH264"), + h265: t("go2rtcStreams.ffmpeg.videoH265"), + exclude: t("go2rtcStreams.ffmpeg.videoExclude"), + }; + const audioLabels: Record = { + copy: t("go2rtcStreams.ffmpeg.audioCopy"), + aac: t("go2rtcStreams.ffmpeg.audioAac"), + opus: t("go2rtcStreams.ffmpeg.audioOpus"), + pcmu: t("go2rtcStreams.ffmpeg.audioPcmu"), + pcma: t("go2rtcStreams.ffmpeg.audioPcma"), + pcm: t("go2rtcStreams.ffmpeg.audioPcm"), + mp3: t("go2rtcStreams.ffmpeg.audioMp3"), + exclude: t("go2rtcStreams.ffmpeg.audioExclude"), + }; + const hardwareLabels: Record = { + none: t("go2rtcStreams.ffmpeg.hardwareNone"), + auto: t("go2rtcStreams.ffmpeg.hardwareAuto"), + vaapi: t("go2rtcStreams.ffmpeg.hardwareVaapi"), + cuda: t("go2rtcStreams.ffmpeg.hardwareCuda"), + v4l2m2m: t("go2rtcStreams.ffmpeg.hardwareV4l2m2m"), + dxva2: t("go2rtcStreams.ffmpeg.hardwareDxva2"), + videotoolbox: t("go2rtcStreams.ffmpeg.hardwareVideotoolbox"), + }; return ( -
-
-
- handleBaseUrlChange(e.target.value)} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - placeholder={t("go2rtcStreams.streamUrlPlaceholder")} - /> - {canToggleCredentials && ( - - )} -
+
+
+ {t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })} {canRemove && (
- - {/* ffmpeg module toggle */} -
- - -
- - {/* ffmpeg options */} - {parsed.isFfmpeg && ( -
- {/* Video */} -
- - -
- - {/* Audio */} -
- - -
- - {/* Hardware acceleration - only when transcoding video */} - {isTranscodingVideo && ( -
- - handleBaseUrlChange(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder={t("go2rtcStreams.streamUrlPlaceholder")} + /> + {canToggleCredentials && ( +
- )} + {showCredentials || isFocused ? ( + + ) : ( + + )} + + )} +
+ + + + + + {t("go2rtcStreams.ffmpeg.useFfmpegModule")} + +
- )} + + {/* ffmpeg options */} + {parsed.isFfmpeg && ( +
+ {/* Video — one row per #video= fragment */} +
+
+ + {parsed.videos[0] !== "exclude" && ( + + )} +
+ {parsed.videos.map((v, idx) => ( +
+ + {idx > 0 ? ( + + ) : ( + // Reserve the same horizontal slot so the primary Select + // doesn't stretch wider than fallback rows. + + ))} +
+ + {/* Audio — one row per #audio= fragment */} +
+
+ + {parsed.audios[0] !== "exclude" && ( + + )} +
+ {parsed.audios.map((a, idx) => ( +
+ + {idx > 0 ? ( + + ) : ( + + ))} +
+ + {/* Hardware acceleration — only when transcoding video */} + {isTranscodingVideo && ( +
+
+ +
+ +
+ )} +
+ )} +
); }