mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-12 16:16:42 +03:00
Compare commits
15 Commits
3c86363f82
...
2a6ba378ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6ba378ab | ||
|
|
a246b71580 | ||
|
|
bedba7a05a | ||
|
|
6f7c32ac02 | ||
|
|
71e0ead490 | ||
|
|
59b7dea971 | ||
|
|
ad3676f645 | ||
|
|
e9119f899f | ||
|
|
f3bde69d73 | ||
|
|
07340b88aa | ||
|
|
d113537c30 | ||
|
|
44ff205966 | ||
|
|
e9459c5e29 | ||
|
|
c2183bd850 | ||
|
|
84ac8d3654 |
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
<StreamDetails testResult={testResult} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("cameraWizard.step2.featuresTitle")}
|
||||
</Label>
|
||||
<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">
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
60
web/src/utils/cameraUtil.ts
Normal file
60
web/src/utils/cameraUtil.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user