mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
improve layout and handling of multiple ffmpeg args in go2rtc pane
This commit is contained in:
parent
43d97acd21
commit
bf8a4acc92
@ -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": {
|
||||
|
||||
@ -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("#");
|
||||
}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
<LuCirclePlus className="mr-2 size-4" />
|
||||
{t("go2rtcStreams.addStream")}
|
||||
</Button>
|
||||
</div>
|
||||
@ -703,7 +711,7 @@ function StreamCard({
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-3 px-4 pb-4">
|
||||
<div className="space-y-2 px-4 pb-4">
|
||||
{urls.map((url, urlIndex) => (
|
||||
<StreamUrlEntry
|
||||
key={urlIndex}
|
||||
@ -728,7 +736,7 @@ function StreamCard({
|
||||
onClick={onAddUrl}
|
||||
className="w-fit"
|
||||
>
|
||||
<LuPlus className="mr-2 size-4" />
|
||||
<LuCirclePlus className="mr-2 size-4" />
|
||||
{t("go2rtcStreams.addUrl")}
|
||||
</Button>
|
||||
</div>
|
||||
@ -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<ParsedFfmpegUrl>) => {
|
||||
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<string, string> = {
|
||||
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<FfmpegVideoOption, string> = {
|
||||
copy: t("go2rtcStreams.ffmpeg.videoCopy"),
|
||||
h264: t("go2rtcStreams.ffmpeg.videoH264"),
|
||||
h265: t("go2rtcStreams.ffmpeg.videoH265"),
|
||||
exclude: t("go2rtcStreams.ffmpeg.videoExclude"),
|
||||
};
|
||||
const audioLabels: Record<FfmpegAudioOption, string> = {
|
||||
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<FfmpegHardwareOption, string> = {
|
||||
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 (
|
||||
<div className="space-y-2 rounded-lg bg-background p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
value={baseUrlForDisplay}
|
||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={t("go2rtcStreams.streamUrlPlaceholder")}
|
||||
/>
|
||||
{canToggleCredentials && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={onToggleCredentialVisibility}
|
||||
>
|
||||
{showCredentials ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<div className="flex flex-1 flex-row items-center justify-start text-sm text-primary-variant">
|
||||
{t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })}
|
||||
{canRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -869,143 +919,219 @@ function StreamUrlEntry({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ffmpeg module toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={parsed.isFfmpeg}
|
||||
onCheckedChange={handleFfmpegToggle}
|
||||
/>
|
||||
<Label className="text-sm">
|
||||
{t("go2rtcStreams.ffmpeg.useFfmpegModule")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* ffmpeg options */}
|
||||
{parsed.isFfmpeg && (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-3 pl-4",
|
||||
isTranscodingVideo ? "sm:grid-cols-3" : "sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{/* Video */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("go2rtcStreams.ffmpeg.video")}
|
||||
</Label>
|
||||
<Select
|
||||
value={parsed.video}
|
||||
onValueChange={(v) =>
|
||||
handleFfmpegOptionChange("video", v as FfmpegVideoOption)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
{parsed.video === "copy"
|
||||
? t("go2rtcStreams.ffmpeg.videoCopy")
|
||||
: parsed.video === "h264"
|
||||
? t("go2rtcStreams.ffmpeg.videoH264")
|
||||
: parsed.video === "h265"
|
||||
? t("go2rtcStreams.ffmpeg.videoH265")
|
||||
: t("go2rtcStreams.ffmpeg.videoExclude")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="copy">
|
||||
{t("go2rtcStreams.ffmpeg.videoCopy")}
|
||||
</SelectItem>
|
||||
<SelectItem value="h264">
|
||||
{t("go2rtcStreams.ffmpeg.videoH264")}
|
||||
</SelectItem>
|
||||
<SelectItem value="h265">
|
||||
{t("go2rtcStreams.ffmpeg.videoH265")}
|
||||
</SelectItem>
|
||||
<SelectItem value="exclude">
|
||||
{t("go2rtcStreams.ffmpeg.videoExclude")}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Audio */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("go2rtcStreams.ffmpeg.audio")}
|
||||
</Label>
|
||||
<Select
|
||||
value={parsed.audio}
|
||||
onValueChange={(v) =>
|
||||
handleFfmpegOptionChange("audio", v as FfmpegAudioOption)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">{audioDisplayLabel}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="copy">
|
||||
{t("go2rtcStreams.ffmpeg.audioCopy")}
|
||||
</SelectItem>
|
||||
<SelectItem value="aac">
|
||||
{t("go2rtcStreams.ffmpeg.audioAac")}
|
||||
</SelectItem>
|
||||
<SelectItem value="opus">
|
||||
{t("go2rtcStreams.ffmpeg.audioOpus")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pcmu">
|
||||
{t("go2rtcStreams.ffmpeg.audioPcmu")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pcma">
|
||||
{t("go2rtcStreams.ffmpeg.audioPcma")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pcm">
|
||||
{t("go2rtcStreams.ffmpeg.audioPcm")}
|
||||
</SelectItem>
|
||||
<SelectItem value="mp3">
|
||||
{t("go2rtcStreams.ffmpeg.audioMp3")}
|
||||
</SelectItem>
|
||||
<SelectItem value="exclude">
|
||||
{t("go2rtcStreams.ffmpeg.audioExclude")}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Hardware acceleration - only when transcoding video */}
|
||||
{isTranscodingVideo && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("go2rtcStreams.ffmpeg.hardware")}
|
||||
</Label>
|
||||
<Select
|
||||
value={parsed.hardware}
|
||||
onValueChange={(v) =>
|
||||
handleFfmpegOptionChange(
|
||||
"hardware",
|
||||
v as FfmpegHardwareOption,
|
||||
)
|
||||
}
|
||||
<div className="space-y-4 rounded-lg bg-background p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="text-md h-8 pr-10"
|
||||
value={baseUrlForDisplay}
|
||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={t("go2rtcStreams.streamUrlPlaceholder")}
|
||||
/>
|
||||
{canToggleCredentials && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={onToggleCredentialVisibility}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
{parsed.hardware === "auto"
|
||||
? t("go2rtcStreams.ffmpeg.hardwareAuto")
|
||||
: t("go2rtcStreams.ffmpeg.hardwareNone")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
{t("go2rtcStreams.ffmpeg.hardwareNone")}
|
||||
</SelectItem>
|
||||
<SelectItem value="auto">
|
||||
{t("go2rtcStreams.ffmpeg.hardwareAuto")}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{showCredentials || isFocused ? (
|
||||
<LuEyeOff className="size-4" />
|
||||
) : (
|
||||
<LuEye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={parsed.isFfmpeg ? "select" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={parsed.isFfmpeg}
|
||||
aria-label={t("go2rtcStreams.ffmpeg.useFfmpegModule")}
|
||||
onClick={() => handleFfmpegToggle(!parsed.isFfmpeg)}
|
||||
className="size-8 p-0"
|
||||
>
|
||||
<LuSlidersHorizontal className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("go2rtcStreams.ffmpeg.useFfmpegModule")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ffmpeg options */}
|
||||
{parsed.isFfmpeg && (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-3 pl-4",
|
||||
isTranscodingVideo ? "sm:grid-cols-3" : "sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{/* Video — one row per #video= fragment */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-7 items-center justify-start gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("go2rtcStreams.ffmpeg.video")}
|
||||
</Label>
|
||||
{parsed.videos[0] !== "exclude" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addVideo}
|
||||
className="size-6 p-0 text-muted-foreground hover:text-primary"
|
||||
aria-label={t("go2rtcStreams.ffmpeg.addVideoCodec")}
|
||||
>
|
||||
<LuCirclePlus className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{parsed.videos.map((v, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
<Select
|
||||
value={v}
|
||||
onValueChange={(next) =>
|
||||
updateVideoAt(idx, next as FfmpegVideoOption)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1">
|
||||
{videoLabels[v] ?? v}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(videoLabels) as FfmpegVideoOption[])
|
||||
// Exclude is only meaningful on the primary row.
|
||||
.filter((opt) => idx === 0 || opt !== "exclude")
|
||||
.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{videoLabels[opt]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{idx > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeVideoAt(idx)}
|
||||
className="size-8 p-0 text-muted-foreground hover:text-primary"
|
||||
aria-label={t("go2rtcStreams.ffmpeg.removeCodec")}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
// Reserve the same horizontal slot so the primary Select
|
||||
// doesn't stretch wider than fallback rows.
|
||||
<div className="size-8 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Audio — one row per #audio= fragment */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-7 items-center justify-start gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("go2rtcStreams.ffmpeg.audio")}
|
||||
</Label>
|
||||
{parsed.audios[0] !== "exclude" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addAudio}
|
||||
className="size-6 p-0 text-muted-foreground hover:text-primary"
|
||||
aria-label={t("go2rtcStreams.ffmpeg.addAudioCodec")}
|
||||
>
|
||||
<LuCirclePlus className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{parsed.audios.map((a, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
<Select
|
||||
value={a}
|
||||
onValueChange={(next) =>
|
||||
updateAudioAt(idx, next as FfmpegAudioOption)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1">
|
||||
{audioLabels[a] ?? a}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(audioLabels) as FfmpegAudioOption[])
|
||||
// Exclude is only meaningful on the primary row.
|
||||
.filter((opt) => idx === 0 || opt !== "exclude")
|
||||
.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{audioLabels[opt]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{idx > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAudioAt(idx)}
|
||||
className="size-8 p-0 text-muted-foreground hover:text-primary"
|
||||
aria-label={t("go2rtcStreams.ffmpeg.removeCodec")}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="size-8 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hardware acceleration — only when transcoding video */}
|
||||
{isTranscodingVideo && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-7 items-center">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("go2rtcStreams.ffmpeg.hardware")}
|
||||
</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={parsed.hardware}
|
||||
onValueChange={(v) =>
|
||||
updateHardware(v as FfmpegHardwareOption)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
{hardwareLabels[parsed.hardware] ?? parsed.hardware}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(
|
||||
Object.keys(hardwareLabels) as FfmpegHardwareOption[]
|
||||
).map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{hardwareLabels[opt]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user