Compare commits

...

15 Commits

Author SHA1 Message Date
Josh Hawkins
2a6ba378ab move wizard to subfolder 2025-10-12 18:48:05 -05:00
Josh Hawkins
a246b71580 normalize camera name to lower case and improve hash generation 2025-10-12 18:47:02 -05:00
Josh Hawkins
bedba7a05a tweaks 2025-10-12 18:33:55 -05:00
Josh Hawkins
6f7c32ac02 only display validation results and enable save button if all streams have been tested 2025-10-12 18:21:45 -05:00
Josh Hawkins
71e0ead490 ensure test is invalidated if stream is changed 2025-10-12 18:21:23 -05:00
Josh Hawkins
59b7dea971 add validation results pane to step 3 2025-10-12 18:17:17 -05:00
Josh Hawkins
ad3676f645 add stream details to overlay like stats in liveplayer 2025-10-12 18:16:56 -05:00
Josh Hawkins
e9119f899f keep spaces in friendly name 2025-10-12 17:11:15 -05:00
Josh Hawkins
f3bde69d73 add help/docs link popovers 2025-10-12 13:53:20 -05:00
Josh Hawkins
07340b88aa center camera name on mobile 2025-10-12 13:35:01 -05:00
Josh Hawkins
d113537c30 prevent dialog from closing when clicking outside 2025-10-12 13:26:44 -05:00
Josh Hawkins
44ff205966 consolidate validation logic 2025-10-12 13:24:03 -05:00
Josh Hawkins
e9459c5e29 add camera name to top 2025-10-12 13:11:39 -05:00
Josh Hawkins
c2183bd850 add i18n and popover for brand url 2025-10-12 12:51:23 -05:00
Josh Hawkins
84ac8d3654 extract logic for friendly_name and use in wizard 2025-10-12 12:36:46 -05:00
7 changed files with 840 additions and 549 deletions

View File

@ -164,7 +164,7 @@
"step1": {
"description": "Enter your camera details and test the connection.",
"cameraName": "Camera Name",
"cameraNamePlaceholder": "e.g., front_door",
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
"host": "Host/IP Address",
"port": "Port",
"username": "Username",
@ -175,6 +175,8 @@
"cameraBrand": "Camera Brand",
"selectBrand": "Select camera brand for URL template",
"customUrl": "Custom Stream URL",
"brandInformation": "Brand information",
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
"testConnection": "Test Connection",
"testSuccess": "Connection test successful!",
@ -184,7 +186,11 @@
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
},
"errors": {
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL"
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
"nameRequired": "Camera name is required",
"nameLength": "Camera name must be 64 characters or less",
"invalidCharacters": "Camera name contains invalid characters",
"nameExists": "Camera name already exists"
}
},
"step2": {
@ -214,16 +220,26 @@
"notConnected": "Not Connected",
"featuresTitle": "Features",
"go2rtc": "Reduce connections to camera",
"detectRoleWarning": "At least one stream must have the \"detect\" role to proceed."
"detectRoleWarning": "At least one stream must have the \"detect\" role to proceed.",
"rolesPopover": {
"title": "Stream Roles",
"detect": "Main feed for object detection.",
"record": "Saves segments of the video feed based on configuration settings.",
"audio": "Feed for audio based detection."
},
"featuresPopover": {
"title": "Stream Features",
"description": "Use go2rtc restreaming to reduce connections to your camera."
}
},
"step3": {
"description": "Final validation and bandwidth analysis before saving your camera configuration.",
"validationTitle": "Stream Validation",
"validating": "Validating...",
"revalidateStreams": "Re-validate Streams",
"testAllStreams": "Test All Streams",
"validationSuccess": "Validation completed successfully!",
"validationPartial": "Some streams failed validation.",
"streamUnavailable": "Stream unavailable",
"streamUnavailable": "Stream preview unavailable",
"reload": "Reload",
"connecting": "Connecting...",
"streamTitle": "Stream {{number}}",
@ -231,14 +247,22 @@
"failed": "Failed",
"notTested": "Not tested",
"testStream": "Test Stream",
"estimatedBandwidth": "Estimated Bandwidth:",
"estimatedBandwidth": "Estimated Bandwidth",
"roles": "Roles",
"none": "None",
"error": "Error",
"streamValidated": "Stream {{number}} validated successfully",
"streamValidationFailed": "Stream {{number}} validation failed",
"saveAndApply": "Save New Camera Configuration",
"saveError": "Invalid configuration. Please check your settings."
"saveAndApply": "Save New Camera",
"saveError": "Invalid configuration. Please check your settings.",
"issues": {
"title": "Stream Validation",
"videoCodecGood": "Video codec is {{codec}}.",
"audioCodecGood": "Audio codec is AAC.",
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
"audioCodecError": "The AAC audio codec is required to support audio in recordings.",
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly."
}
}
},
"camera": {
@ -292,8 +316,8 @@
"description": "Configure camera settings including stream inputs and roles.",
"name": "Camera Name",
"nameRequired": "Camera name is required",
"nameLength": "Camera name must be less than 24 characters.",
"namePlaceholder": "e.g., front_door",
"nameLength": "Camera name must be less than 64 characters.",
"namePlaceholder": "e.g., front_door or Back Yard Overview",
"enabled": "Enabled",
"ffmpeg": {
"inputs": "Input Streams",

View File

@ -22,6 +22,7 @@ import { LuTrash2, LuPlus } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { processCameraName } from "@/utils/cameraUtil";
type ConfigSetBody = {
requires_restart: number;
@ -30,12 +31,6 @@ type ConfigSetBody = {
config_data: any;
update_topic?: string;
};
const generateFixedHash = (name: string): string => {
const encoded = encodeURIComponent(name);
const base64 = btoa(encoded);
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
return `cam_${cleanHash.toLowerCase()}`;
};
const RoleEnum = z.enum(["audio", "detect", "record"]);
type Role = z.infer<typeof RoleEnum>;
@ -168,19 +163,15 @@ export default function CameraEditForm({
const saveCameraConfig = (values: FormValues) => {
setIsLoading(true);
let finalCameraName = values.cameraName;
let friendly_name: string | undefined = undefined;
const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName);
if (!isValidName) {
finalCameraName = generateFixedHash(finalCameraName);
friendly_name = values.cameraName;
}
const { finalCameraName, friendlyName } = processCameraName(
values.cameraName,
);
const configData: ConfigSetBody["config_data"] = {
cameras: {
[finalCameraName]: {
enabled: values.enabled,
...(friendly_name && { friendly_name }),
...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: {
inputs: values.ffmpeg.inputs.map((input) => ({
path: input.path,

View File

@ -11,14 +11,15 @@ import { useCallback, useState, useEffect, useReducer } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import axios from "axios";
import Step1NameCamera from "./Step1NameCamera";
import Step2StreamConfig from "./Step2StreamConfig";
import Step3Validation from "./Step3Validation";
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
import Step3Validation from "@/components/settings/wizard/Step3Validation";
import type {
WizardFormData,
CameraConfigData,
ConfigSetBody,
} from "@/types/cameraWizard";
import { processCameraName } from "@/utils/cameraUtil";
type WizardState = {
wizardData: Partial<WizardFormData>;
@ -158,12 +159,17 @@ export default function CameraWizardDialog({
setIsLoading(true);
// Process camera name and friendly name
const { finalCameraName, friendlyName } = processCameraName(
wizardData.cameraName,
);
// Convert wizard data to Frigate config format
const cameraName = wizardData.cameraName;
const configData: CameraConfigData = {
cameras: {
[cameraName]: {
[finalCameraName]: {
enabled: true,
...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: {
inputs: wizardData.streams.map((stream, index) => {
const isRestreamed =
@ -171,8 +177,8 @@ export default function CameraWizardDialog({
if (isRestreamed) {
const go2rtcStreamName =
wizardData.streams!.length === 1
? cameraName
: `${cameraName}_${index + 1}`;
? finalCameraName
: `${finalCameraName}_${index + 1}`;
return {
path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`,
input_args: "preset-rtsp-restream",
@ -190,30 +196,26 @@ export default function CameraWizardDialog({
},
};
// Add friendly name if different from camera name
if (wizardData.cameraName !== cameraName) {
configData.cameras[cameraName].friendly_name = wizardData.cameraName;
}
// Add live.streams configuration for go2rtc streams
if (wizardData.streams && wizardData.streams.length > 0) {
configData.cameras[cameraName].live = {
configData.cameras[finalCameraName].live = {
streams: {},
};
wizardData.streams.forEach((_, index) => {
const go2rtcStreamName =
wizardData.streams!.length === 1
? cameraName
: `${cameraName}_${index + 1}`;
configData.cameras[cameraName].live!.streams[`Stream ${index + 1}`] =
go2rtcStreamName;
? finalCameraName
: `${finalCameraName}_${index + 1}`;
configData.cameras[finalCameraName].live!.streams[
`Stream ${index + 1}`
] = go2rtcStreamName;
});
}
const requestBody: ConfigSetBody = {
requires_restart: 1,
config_data: configData,
update_topic: `config/cameras/${cameraName}/add`,
update_topic: `config/cameras/${finalCameraName}/add`,
};
axios
@ -228,8 +230,8 @@ export default function CameraWizardDialog({
// Use camera name with index suffix for multiple streams
const streamName =
wizardData.streams!.length === 1
? cameraName
: `${cameraName}_${index + 1}`;
? finalCameraName
: `${finalCameraName}_${index + 1}`;
go2rtcStreams[streamName] = [stream.url];
});
@ -260,7 +262,7 @@ export default function CameraWizardDialog({
Promise.allSettled(updatePromises).then(() => {
toast.success(
t("cameraWizard.save.successWithLive", {
cameraName: wizardData.cameraName,
cameraName: friendlyName || finalCameraName,
}),
{ position: "top-center" },
);
@ -272,7 +274,7 @@ export default function CameraWizardDialog({
// log the error but don't fail the entire save
toast.warning(
t("cameraWizard.save.successWithoutLive", {
cameraName: wizardData.cameraName,
cameraName: friendlyName || finalCameraName,
}),
{ position: "top-center" },
);
@ -283,7 +285,7 @@ export default function CameraWizardDialog({
// No valid streams found
toast.success(
t("cameraWizard.save.successWithoutLive", {
cameraName: wizardData.cameraName,
cameraName: friendlyName || finalCameraName,
}),
{ position: "top-center" },
);
@ -332,7 +334,12 @@ export default function CameraWizardDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-h-[90dvh] max-w-4xl overflow-y-auto">
<DialogContent
className="max-h-[90dvh] max-w-4xl overflow-y-auto"
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<StepIndicator
steps={STEPS}
currentStep={currentStep}
@ -341,10 +348,20 @@ export default function CameraWizardDialog({
/>
<DialogHeader>
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
<DialogDescription>{t("cameraWizard.description")}</DialogDescription>
{currentStep === 0 && (
<DialogDescription>
{t("cameraWizard.description")}
</DialogDescription>
)}
</DialogHeader>
<div className="py-4">
{currentStep > 0 && state.wizardData.cameraName && (
<div className="text-center text-primary-variant md:text-start">
{state.wizardData.cameraName}
</div>
)}
<div className="pb-4">
<div className="size-full">
{currentStep === 0 && (
<Step1NameCamera

View File

@ -38,7 +38,13 @@ import {
StreamConfig,
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { Card, CardContent, CardTitle } from "../ui/card";
import { Card, CardContent, CardTitle } from "../../ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo } from "react-icons/lu";
type Step1NameCameraProps = {
wizardData: Partial<WizardFormData>;
@ -71,12 +77,15 @@ export default function Step1NameCamera({
.object({
cameraName: z
.string()
.min(1, "Camera name is required")
.max(64, "Camera name must be 64 characters or less")
.regex(/^[a-zA-Z0-9\s_-]+$/, "Camera name contains invalid characters")
.min(1, t("cameraWizard.step1.errors.nameRequired"))
.max(64, t("cameraWizard.step1.errors.nameLength"))
.regex(
/^[a-zA-Z0-9\s_-]+$/,
t("cameraWizard.step1.errors.invalidCharacters"),
)
.refine(
(value) => !existingCameraNames.includes(value),
"Camera name already exists",
t("cameraWizard.step1.errors.nameExists"),
),
host: z.string().optional(),
username: z.string().optional(),
@ -357,9 +366,29 @@ export default function Step1NameCamera({
const selectedBrand = CAMERA_BRANDS.find(
(brand) => brand.value === field.value,
);
return selectedBrand ? (
return selectedBrand &&
selectedBrand.value != "other" ? (
<FormDescription className="mt-1 pt-0.5 text-xs text-muted-foreground">
{selectedBrand.exampleUrl}
<Popover>
<PopoverTrigger>
<div className="flex flex-row items-center gap-0.5 text-xs text-muted-foreground hover:text-primary">
<LuInfo className="mr-1 size-3" />
{t("cameraWizard.step1.brandInformation")}
</div>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<h4 className="font-medium">
{selectedBrand.label}
</h4>
<p className="text-sm text-muted-foreground">
{t("cameraWizard.step1.brandUrlFormat", {
exampleUrl: selectedBrand.exampleUrl,
})}
</p>
</div>
</PopoverContent>
</Popover>
</FormDescription>
) : null;
})()}
@ -481,61 +510,29 @@ export default function Step1NameCamera({
</div>
<div className="space-y-3">
{testResult.snapshot && (
<div className="flex justify-center">
{testResult.snapshot ? (
<div className="relative flex justify-center">
<img
src={testResult.snapshot}
alt="Camera snapshot"
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
/>
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
<div className="space-y-1">
<StreamDetails testResult={testResult} />
</div>
)}
</div>
</div>
) : (
<Card className="p-4">
<CardTitle className="mb-2 text-sm">
{t("cameraWizard.step1.streamDetails")}
</CardTitle>
<CardContent className="p-0 text-sm">
{testResult.resolution && (
<div>
<span className="text-secondary-foreground">
{t("cameraWizard.testResultLabels.resolution")}:
</span>{" "}
<span className="text-primary">
{testResult.resolution}
</span>
</div>
)}
{testResult.videoCodec && (
<div>
<span className="text-secondary-foreground">
{t("cameraWizard.testResultLabels.video")}:
</span>{" "}
<span className="text-primary">
{testResult.videoCodec}
</span>
</div>
)}
{testResult.audioCodec && (
<div>
<span className="text-secondary-foreground">
{t("cameraWizard.testResultLabels.audio")}:
</span>{" "}
<span className="text-primary">
{testResult.audioCodec}
</span>
</div>
)}
{testResult.fps && (
<div>
<span className="text-secondary-foreground">
{t("cameraWizard.testResultLabels.fps")}:
</span>{" "}
<span className="text-primary">{testResult.fps}</span>
</div>
)}
<StreamDetails testResult={testResult} />
</CardContent>
</Card>
)}
</div>
</div>
)}
@ -575,3 +572,44 @@ export default function Step1NameCamera({
</div>
);
}
function StreamDetails({ testResult }: { testResult: TestResult }) {
const { t } = useTranslation(["views/settings"]);
return (
<>
{testResult.resolution && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.resolution")}:
</span>{" "}
<span className="text-white">{testResult.resolution}</span>
</div>
)}
{testResult.fps && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.fps")}:
</span>{" "}
<span className="text-white">{testResult.fps}</span>
</div>
)}
{testResult.videoCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.video")}:
</span>{" "}
<span className="text-white">{testResult.videoCodec}</span>
</div>
)}
{testResult.audioCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.audio")}:
</span>{" "}
<span className="text-white">{testResult.audioCodec}</span>
</div>
)}
</>
);
}

View File

@ -15,8 +15,16 @@ import {
TestResult,
FfprobeStream,
} from "@/types/cameraWizard";
import { Label } from "../ui/label";
import { Label } from "../../ui/label";
import { FaCircleCheck } from "react-icons/fa6";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo, LuExternalLink } from "react-icons/lu";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
type Step2StreamConfigProps = {
wizardData: Partial<WizardFormData>;
@ -33,7 +41,8 @@ export default function Step2StreamConfig({
onNext,
canProceed,
}: Step2StreamConfigProps) {
const { t } = useTranslation(["views/settings"]);
const { t } = useTranslation(["views/settings", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain();
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
@ -226,7 +235,7 @@ export default function Step2StreamConfig({
{[
stream.testResult.resolution,
stream.testResult.fps
? `${stream.testResult.fps} fps`
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
: null,
stream.testResult.videoCodec,
stream.testResult.audioCodec,
@ -275,7 +284,10 @@ export default function Step2StreamConfig({
<Input
value={stream.url}
onChange={(e) =>
updateStream(stream.id, { url: e.target.value })
updateStream(stream.id, {
url: e.target.value,
testResult: undefined,
})
}
className="h-8 flex-1"
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
@ -312,7 +324,50 @@ export default function Step2StreamConfig({
)}
<div className="space-y-2">
<Label className="text-sm font-medium">Roles</Label>
<div className="flex items-center gap-1">
<Label className="text-sm font-medium">
{t("cameraWizard.step2.roles")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step2.rolesPopover.title")}
</div>
<div className="space-y-1 text-muted-foreground">
<div>
<strong>detect</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.detect")}
</div>
<div>
<strong>record</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.record")}
</div>
<div>
<strong>audio</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.audio")}
</div>
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/cameras")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map((role) => {
@ -339,9 +394,41 @@ export default function Step2StreamConfig({
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium">
{t("cameraWizard.step2.featuresTitle")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step2.featuresPopover.title")}
</div>
<div className="text-muted-foreground">
{t("cameraWizard.step2.featuresPopover.description")}
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/restream#reduce-connections-to-camera",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex items-center justify-between">
<span className="text-sm">

View File

@ -1,4 +1,5 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { LuRotateCcw } from "react-icons/lu";
import { useState, useCallback, useMemo, useEffect } from "react";
@ -8,6 +9,9 @@ import { toast } from "sonner";
import MSEPlayer from "@/components/player/MsePlayer";
import { WizardFormData, StreamConfig, TestResult } from "@/types/cameraWizard";
import { PlayerStatsType } from "@/types/live";
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card";
type Step3ValidationProps = {
wizardData: Partial<WizardFormData>;
@ -17,6 +21,510 @@ type Step3ValidationProps = {
isLoading?: boolean;
};
export default function Step3Validation({
wizardData,
onUpdate,
onSave,
onBack,
isLoading = false,
}: Step3ValidationProps) {
const { t } = useTranslation(["views/settings"]);
const [isValidating, setIsValidating] = useState(false);
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const [measuredBandwidth, setMeasuredBandwidth] = useState<
Map<string, number>
>(new Map());
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
const handleBandwidthUpdate = useCallback(
(streamId: string, bandwidth: number) => {
setMeasuredBandwidth((prev) => new Map(prev).set(streamId, bandwidth));
},
[],
);
// Use test results from Step 2, but allow re-validation in Step 3
const validationResults = useMemo(() => {
const results = new Map<string, TestResult>();
streams.forEach((stream) => {
if (stream.testResult) {
results.set(stream.id, stream.testResult);
}
});
return results;
}, [streams]);
const performStreamValidation = useCallback(
async (stream: StreamConfig): Promise<TestResult> => {
try {
const response = await axios.get("ffprobe", {
params: { paths: stream.url, detailed: true },
timeout: 10000,
});
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streamData = probeData.stdout.streams || [];
const videoStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.r_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1])
: undefined;
return {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
return { success: false, error };
}
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
return { success: false, error: errorMessage };
}
},
[],
);
const validateStream = useCallback(
async (stream: StreamConfig) => {
if (!stream.url.trim()) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setTestingStreams((prev) => new Set(prev).add(stream.id));
const testResult = await performStreamValidation(stream);
onUpdate({
streams: streams.map((s) =>
s.id === stream.id ? { ...s, testResult } : s,
),
});
if (testResult.success) {
toast.success(
t("cameraWizard.step3.streamValidated", {
number: streams.findIndex((s) => s.id === stream.id) + 1,
}),
);
} else {
toast.error(
t("cameraWizard.step3.streamValidationFailed", {
number: streams.findIndex((s) => s.id === stream.id) + 1,
}),
);
}
setTestingStreams((prev) => {
const newSet = new Set(prev);
newSet.delete(stream.id);
return newSet;
});
},
[streams, onUpdate, t, performStreamValidation],
);
const validateAllStreams = useCallback(async () => {
setIsValidating(true);
const results = new Map<string, TestResult>();
// Only test streams that haven't been tested or failed
const streamsToTest = streams.filter(
(stream) => !stream.testResult || !stream.testResult.success,
);
for (const stream of streamsToTest) {
if (!stream.url.trim()) continue;
const testResult = await performStreamValidation(stream);
results.set(stream.id, testResult);
}
// Update wizard data with new test results
if (results.size > 0) {
const updatedStreams = streams.map((stream) => {
const newResult = results.get(stream.id);
if (newResult) {
return { ...stream, testResult: newResult };
}
return stream;
});
onUpdate({ streams: updatedStreams });
}
setIsValidating(false);
if (results.size > 0) {
const successfulTests = Array.from(results.values()).filter(
(r) => r.success,
).length;
if (successfulTests === results.size) {
toast.success(t("cameraWizard.step3.validationSuccess"));
} else {
toast.warning(t("cameraWizard.step3.validationPartial"));
}
}
}, [streams, onUpdate, t, performStreamValidation]);
const handleSave = useCallback(() => {
if (!wizardData.cameraName || !wizardData.streams?.length) {
toast.error(t("cameraWizard.step3.saveError"));
return;
}
// Convert wizard data to final config format
const configData = {
cameraName: wizardData.cameraName,
host: wizardData.host,
username: wizardData.username,
password: wizardData.password,
brandTemplate: wizardData.brandTemplate,
customUrl: wizardData.customUrl,
streams: wizardData.streams,
restreamIds: wizardData.restreamIds,
};
onSave(configData);
}, [wizardData, onSave, t]);
const canSave = useMemo(() => {
return (
wizardData.cameraName &&
wizardData.streams?.length &&
wizardData.streams.some((s) => s.roles.includes("detect")) &&
wizardData.streams.every((s) => s.testResult) // All streams must be tested
);
}, [wizardData]);
return (
<div className="space-y-6">
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step3.description")}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">
{t("cameraWizard.step3.validationTitle")}
</h3>
<Button
onClick={validateAllStreams}
disabled={isValidating || streams.length === 0}
variant="outline"
>
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
{isValidating
? t("cameraWizard.step3.validating")
: t("cameraWizard.step3.testAllStreams")}
</Button>
</div>
<div className="space-y-3">
{streams.map((stream, index) => {
const result = validationResults.get(stream.id);
return (
<Card key={stream.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-end gap-2">
<h4 className="mr-2 font-medium">
{t("cameraWizard.step3.streamTitle", {
number: index + 1,
})}
</h4>
{stream.roles.map((role) => (
<Badge variant="outline" key={role} className="text-xs">
{role}
</Badge>
))}
</div>
{result?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{result && !result.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
</div>
{result && result.success && (
<div className="mb-2 text-sm text-muted-foreground">
{[
result.resolution,
result.fps
? `${result.fps} ${t("cameraWizard.testResultLabels.fps")}`
: null,
result.videoCodec,
result.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
<div className="mb-3">
<StreamPreview
stream={stream}
onBandwidthUpdate={handleBandwidthUpdate}
/>
</div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{stream.url}
</span>
<Button
onClick={() => validateStream(stream)}
disabled={
testingStreams.has(stream.id) || !stream.url.trim()
}
variant="outline"
size="sm"
>
{testingStreams.has(stream.id) && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("cameraWizard.step3.testStream")}
</Button>
</div>
{result && (
<div className="rounded-lg bg-background p-3">
<StreamIssues
stream={stream}
measuredBandwidth={measuredBandwidth}
wizardData={wizardData}
/>
</div>
)}
{result && !result.success && (
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
<div className="font-medium">
{t("cameraWizard.step2.testFailedTitle")}
</div>
<div className="mt-1 text-xs">{result.error}</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
)}
<Button
type="button"
onClick={handleSave}
disabled={!canSave || isLoading}
className="sm:flex-1"
variant="select"
>
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
{isLoading
? t("button.saving", { ns: "common" })
: t("cameraWizard.step3.saveAndApply")}
</Button>
</div>
</div>
);
}
type StreamIssuesProps = {
stream: StreamConfig;
measuredBandwidth: Map<string, number>;
wizardData: Partial<WizardFormData>;
};
function StreamIssues({
stream,
measuredBandwidth,
wizardData,
}: StreamIssuesProps) {
const { t } = useTranslation(["views/settings"]);
const issues = useMemo(() => {
const result: Array<{
type: "good" | "warning" | "error";
message: string;
}> = [];
// Video codec check
if (stream.testResult?.videoCodec) {
const videoCodec = stream.testResult.videoCodec.toLowerCase();
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
message: t("cameraWizard.step3.issues.videoCodecGood", {
codec: stream.testResult.videoCodec,
}),
});
}
}
// Audio codec check
if (stream.roles.includes("record")) {
if (stream.testResult?.audioCodec) {
const audioCodec = stream.testResult.audioCodec.toLowerCase();
if (audioCodec === "aac") {
result.push({
type: "good",
message: t("cameraWizard.step3.issues.audioCodecGood"),
});
} else {
result.push({
type: "error",
message: t("cameraWizard.step3.issues.audioCodecError"),
});
}
} else {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.noAudioWarning"),
});
}
}
// Restreaming check
if (stream.roles.includes("record")) {
const restreamIds = wizardData.restreamIds || [];
if (restreamIds.includes(stream.id)) {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.restreamingWarning"),
});
}
}
return result;
}, [stream, wizardData, t]);
if (issues.length === 0) {
return null;
}
return (
<div className="space-y-2">
<div className="font-medium">{t("cameraWizard.step3.issues.title")}</div>
<BandwidthDisplay
streamId={stream.id}
measuredBandwidth={measuredBandwidth}
/>
<div className="space-y-1">
{issues.map((issue, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
{issue.type === "good" && (
<FaCircleCheck className="size-4 flex-shrink-0 text-success" />
)}
{issue.type === "warning" && (
<FaTriangleExclamation className="size-4 flex-shrink-0 text-yellow-500" />
)}
{issue.type === "error" && (
<LuX className="size-4 flex-shrink-0 text-danger" />
)}
<span
className={
issue.type === "good"
? "text-success"
: issue.type === "warning"
? "text-yellow-500"
: "text-danger"
}
>
{issue.message}
</span>
</div>
))}
</div>
</div>
);
}
type BandwidthDisplayProps = {
streamId: string;
measuredBandwidth: Map<string, number>;
};
function BandwidthDisplay({
streamId,
measuredBandwidth,
}: BandwidthDisplayProps) {
const { t } = useTranslation(["views/settings"]);
const streamBandwidth = measuredBandwidth.get(streamId);
if (!streamBandwidth) return null;
const perHour = streamBandwidth * 3600; // kB/hour
const perHourDisplay =
perHour >= 1000000
? `${(perHour / 1000000).toFixed(1)} ${t("unit.data.gbph", { ns: "common" })}`
: perHour >= 1000
? `${(perHour / 1000).toFixed(1)} ${t("unit.data.mbph", { ns: "common" })}`
: `${perHour.toFixed(0)} ${t("unit.data.kbph", { ns: "common" })}`;
return (
<div className="mb-2 text-sm">
<span className="font-medium text-muted-foreground">
{t("cameraWizard.step3.estimatedBandwidth")}:
</span>{" "}
<span className="text-secondary-foreground">
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
</span>
<span className="ml-2 text-muted-foreground">({perHourDisplay})</span>
</div>
);
}
type StreamPreviewProps = {
stream: StreamConfig;
onBandwidthUpdate?: (streamId: string, bandwidth: number) => void;
@ -78,7 +586,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
if (error) {
return (
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-lg bg-danger/20 p-4">
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-lg bg-secondary p-4">
<span className="text-sm text-danger">
{t("cameraWizard.step3.streamUnavailable")}
</span>
@ -110,444 +618,10 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
<MSEPlayer
camera={streamId}
playbackEnabled={true}
className="max-h-[20dvh] w-full rounded-lg"
className="max-h-[30dvh] rounded-lg md:max-h-[20dvh]"
getStats={true}
setStats={handleStats}
onError={() => setError(true)}
/>
);
}
export default function Step3Validation({
wizardData,
onUpdate,
onSave,
onBack,
isLoading = false,
}: Step3ValidationProps) {
const { t } = useTranslation(["views/settings"]);
const [isValidating, setIsValidating] = useState(false);
const [measuredBandwidth, setMeasuredBandwidth] = useState<
Map<string, number>
>(new Map());
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
const handleBandwidthUpdate = useCallback(
(streamId: string, bandwidth: number) => {
setMeasuredBandwidth((prev) => new Map(prev).set(streamId, bandwidth));
},
[],
);
// Use test results from Step 2, but allow re-validation in Step 3
const validationResults = useMemo(() => {
const results = new Map<string, TestResult>();
streams.forEach((stream) => {
if (stream.testResult) {
results.set(stream.id, stream.testResult);
}
});
return results;
}, [streams]);
const validateStream = useCallback(
async (stream: StreamConfig) => {
try {
const response = await axios.get("ffprobe", {
params: { paths: stream.url, detailed: true },
timeout: 10000,
});
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streamData = probeData.stdout.streams || [];
const videoStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.r_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1])
: undefined;
const testResult: TestResult = {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
onUpdate({
streams: streams.map((s) =>
s.id === stream.id ? { ...s, testResult } : s,
),
});
toast.success(
t("cameraWizard.step3.streamValidated", {
number: streams.findIndex((s) => s.id === stream.id) + 1,
}),
);
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
const testResult: TestResult = { success: false, error };
onUpdate({
streams: streams.map((s) =>
s.id === stream.id ? { ...s, testResult } : s,
),
});
toast.error(
`Stream ${streams.findIndex((s) => s.id === stream.id) + 1} validation failed`,
);
}
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
const testResult: TestResult = { success: false, error: errorMessage };
onUpdate({
streams: streams.map((s) =>
s.id === stream.id ? { ...s, testResult } : s,
),
});
toast.error(
t("cameraWizard.step3.streamValidationFailed", {
number: streams.findIndex((s) => s.id === stream.id) + 1,
}),
);
}
},
[streams, onUpdate, t],
);
const validateAllStreams = useCallback(async () => {
setIsValidating(true);
const results = new Map<string, TestResult>();
// Only test streams that haven't been tested or failed
const streamsToTest = streams.filter(
(stream) => !stream.testResult || !stream.testResult.success,
);
for (const stream of streamsToTest) {
if (!stream.url.trim()) continue;
try {
const response = await axios.get("ffprobe", {
params: { paths: stream.url, detailed: true },
timeout: 10000,
});
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streamData = probeData.stdout.streams || [];
const videoStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.r_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1])
: undefined;
const testResult: TestResult = {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
results.set(stream.id, testResult);
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
results.set(stream.id, { success: false, error });
}
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
results.set(stream.id, { success: false, error: errorMessage });
}
}
// Update wizard data with new test results
if (results.size > 0) {
const updatedStreams = streams.map((stream) => {
const newResult = results.get(stream.id);
if (newResult) {
return { ...stream, testResult: newResult };
}
return stream;
});
onUpdate({ streams: updatedStreams });
}
setIsValidating(false);
if (results.size > 0) {
const successfulTests = Array.from(results.values()).filter(
(r) => r.success,
).length;
if (successfulTests === results.size) {
toast.success(t("cameraWizard.step3.validationSuccess"));
} else {
toast.warning(t("cameraWizard.step3.validationPartial"));
}
}
}, [streams, onUpdate, t]);
const handleSave = useCallback(() => {
if (!wizardData.cameraName || !wizardData.streams?.length) {
toast.error(t("cameraWizard.step3.saveError"));
return;
}
// Convert wizard data to final config format
const configData = {
cameraName: wizardData.cameraName,
host: wizardData.host,
username: wizardData.username,
password: wizardData.password,
brandTemplate: wizardData.brandTemplate,
customUrl: wizardData.customUrl,
streams: wizardData.streams,
restreamIds: wizardData.restreamIds,
};
onSave(configData);
}, [wizardData, onSave, t]);
const canSave = useMemo(() => {
return (
wizardData.cameraName &&
wizardData.streams?.length &&
wizardData.streams.some((s) => s.roles.includes("detect"))
);
}, [wizardData]);
return (
<div className="space-y-6">
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step3.description")}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">
{t("cameraWizard.step3.validationTitle")}
</h3>
<Button
onClick={validateAllStreams}
disabled={isValidating || streams.length === 0}
variant="outline"
>
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
{isValidating
? t("cameraWizard.step3.validating")
: t("cameraWizard.step3.revalidateStreams")}
</Button>
</div>
<div className="space-y-3">
{streams.map((stream, index) => {
const result = validationResults.get(stream.id);
return (
<div key={stream.id} className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="font-medium">
{t("cameraWizard.step3.streamTitle", { number: index + 1 })}
</h4>
{result ? (
<span
className={`rounded px-2 py-1 text-sm ${
result.success
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{result.success
? t("cameraWizard.step3.valid")
: t("cameraWizard.step3.failed")}
</span>
) : (
<span className="text-sm text-muted-foreground">
{t("cameraWizard.step3.notTested")}
</span>
)}
</div>
<div className="mb-3">
<StreamPreview
stream={stream}
onBandwidthUpdate={handleBandwidthUpdate}
/>
</div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{stream.url}
</span>
<Button
onClick={() => validateStream(stream)}
variant="outline"
size="sm"
>
{t("cameraWizard.step3.testStream")}
</Button>
</div>
{(() => {
const streamBandwidth = measuredBandwidth.get(stream.id);
if (!streamBandwidth) return null;
const perHour = streamBandwidth * 3600; // kB/hour
const perHourDisplay =
perHour >= 1000000
? `${(perHour / 1000000).toFixed(1)} ${t("unit.data.gbph", { ns: "common" })}`
: perHour >= 1000
? `${(perHour / 1000).toFixed(1)} ${t("unit.data.mbph", { ns: "common" })}`
: `${perHour.toFixed(0)} ${t("unit.data.kbph", { ns: "common" })}`;
return (
<div className="mb-2 text-sm">
<span className="font-medium">
{t("cameraWizard.step3.estimatedBandwidth")}:
</span>{" "}
<span className="text-selected">
{streamBandwidth.toFixed(1)}{" "}
{t("unit.data.kbps", { ns: "common" })}
</span>
<span className="ml-2 text-muted-foreground">
({perHourDisplay})
</span>
</div>
);
})()}
<div className="text-sm">
<span className="font-medium">
{t("cameraWizard.step3.roles")}:
</span>{" "}
{stream.roles.join(", ") || t("cameraWizard.step3.none")}
</div>
{result && (
<div className="mt-2 text-sm">
{result.success ? (
<div className="space-y-1">
{result.resolution && (
<div>
{t("cameraWizard.testResultLabels.resolution")}:{" "}
{result.resolution}
</div>
)}
{result.videoCodec && (
<div>
{t("cameraWizard.testResultLabels.video")}:{" "}
{result.videoCodec}
</div>
)}
{result.audioCodec && (
<div>
{t("cameraWizard.testResultLabels.audio")}:{" "}
{result.audioCodec}
</div>
)}
{result.fps && (
<div>
{t("cameraWizard.testResultLabels.fps")}:{" "}
{result.fps}
</div>
)}
</div>
) : (
<div className="text-danger">
{t("cameraWizard.step3.error")}: {result.error}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
{onBack && (
<Button
type="button"
onClick={onBack}
variant="outline"
className="sm:flex-1"
>
{t("button.back", { ns: "common" })}
</Button>
)}
<Button
type="button"
onClick={handleSave}
disabled={!canSave || isLoading}
className="sm:flex-1"
variant="select"
>
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
{isLoading
? t("button.saving", { ns: "common" })
: t("cameraWizard.step3.saveAndApply")}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
/**
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
* Works safely with Unicode input while outputting Latin-only identifiers.
*
* @param name - The original camera name/display name
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
*/
export function generateFixedHash(name: string): string {
// Safely encode Unicode as UTF-8 bytes
const utf8Bytes = new TextEncoder().encode(name);
// Convert to base64 manually
let binary = "";
for (const byte of utf8Bytes) {
binary += String.fromCharCode(byte);
}
const base64 = btoa(binary);
// Strip out non-alphanumeric characters and truncate
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
return `cam_${cleanHash.toLowerCase()}`;
}
/**
* Checks if a string is a valid camera name identifier.
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
*
* @param name - The camera name to validate
* @returns True if the name is valid, false otherwise
*/
export function isValidCameraName(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name);
}
/**
* Processes a user-entered camera name and returns both the final camera name
* and friendly name for Frigate configuration.
*
* @param userInput - The name entered by the user (could be display name)
* @returns Object with finalCameraName and friendlyName
*/
export function processCameraName(userInput: string): {
finalCameraName: string;
friendlyName?: string;
} {
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
if (isValidCameraName(normalizedInput)) {
return {
finalCameraName: normalizedInput,
friendlyName: userInput.includes(" ") ? userInput : undefined,
};
}
return {
finalCameraName: generateFixedHash(userInput),
friendlyName: userInput,
};
}