mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-13 00:26: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": {
|
"step1": {
|
||||||
"description": "Enter your camera details and test the connection.",
|
"description": "Enter your camera details and test the connection.",
|
||||||
"cameraName": "Camera Name",
|
"cameraName": "Camera Name",
|
||||||
"cameraNamePlaceholder": "e.g., front_door",
|
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
|
||||||
"host": "Host/IP Address",
|
"host": "Host/IP Address",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
@ -175,6 +175,8 @@
|
|||||||
"cameraBrand": "Camera Brand",
|
"cameraBrand": "Camera Brand",
|
||||||
"selectBrand": "Select camera brand for URL template",
|
"selectBrand": "Select camera brand for URL template",
|
||||||
"customUrl": "Custom Stream URL",
|
"customUrl": "Custom Stream URL",
|
||||||
|
"brandInformation": "Brand information",
|
||||||
|
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
|
||||||
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
|
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||||
"testConnection": "Test Connection",
|
"testConnection": "Test Connection",
|
||||||
"testSuccess": "Connection test successful!",
|
"testSuccess": "Connection test successful!",
|
||||||
@ -184,7 +186,11 @@
|
|||||||
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
|
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
|
||||||
},
|
},
|
||||||
"errors": {
|
"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": {
|
"step2": {
|
||||||
@ -214,16 +220,26 @@
|
|||||||
"notConnected": "Not Connected",
|
"notConnected": "Not Connected",
|
||||||
"featuresTitle": "Features",
|
"featuresTitle": "Features",
|
||||||
"go2rtc": "Reduce connections to camera",
|
"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": {
|
"step3": {
|
||||||
"description": "Final validation and bandwidth analysis before saving your camera configuration.",
|
"description": "Final validation and bandwidth analysis before saving your camera configuration.",
|
||||||
"validationTitle": "Stream Validation",
|
"validationTitle": "Stream Validation",
|
||||||
"validating": "Validating...",
|
"validating": "Validating...",
|
||||||
"revalidateStreams": "Re-validate Streams",
|
"testAllStreams": "Test All Streams",
|
||||||
"validationSuccess": "Validation completed successfully!",
|
"validationSuccess": "Validation completed successfully!",
|
||||||
"validationPartial": "Some streams failed validation.",
|
"validationPartial": "Some streams failed validation.",
|
||||||
"streamUnavailable": "Stream unavailable",
|
"streamUnavailable": "Stream preview unavailable",
|
||||||
"reload": "Reload",
|
"reload": "Reload",
|
||||||
"connecting": "Connecting...",
|
"connecting": "Connecting...",
|
||||||
"streamTitle": "Stream {{number}}",
|
"streamTitle": "Stream {{number}}",
|
||||||
@ -231,14 +247,22 @@
|
|||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"notTested": "Not tested",
|
"notTested": "Not tested",
|
||||||
"testStream": "Test Stream",
|
"testStream": "Test Stream",
|
||||||
"estimatedBandwidth": "Estimated Bandwidth:",
|
"estimatedBandwidth": "Estimated Bandwidth",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"streamValidated": "Stream {{number}} validated successfully",
|
"streamValidated": "Stream {{number}} validated successfully",
|
||||||
"streamValidationFailed": "Stream {{number}} validation failed",
|
"streamValidationFailed": "Stream {{number}} validation failed",
|
||||||
"saveAndApply": "Save New Camera Configuration",
|
"saveAndApply": "Save New Camera",
|
||||||
"saveError": "Invalid configuration. Please check your settings."
|
"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": {
|
"camera": {
|
||||||
@ -292,8 +316,8 @@
|
|||||||
"description": "Configure camera settings including stream inputs and roles.",
|
"description": "Configure camera settings including stream inputs and roles.",
|
||||||
"name": "Camera Name",
|
"name": "Camera Name",
|
||||||
"nameRequired": "Camera name is required",
|
"nameRequired": "Camera name is required",
|
||||||
"nameLength": "Camera name must be less than 24 characters.",
|
"nameLength": "Camera name must be less than 64 characters.",
|
||||||
"namePlaceholder": "e.g., front_door",
|
"namePlaceholder": "e.g., front_door or Back Yard Overview",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"inputs": "Input Streams",
|
"inputs": "Input Streams",
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { LuTrash2, LuPlus } from "react-icons/lu";
|
|||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { processCameraName } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
type ConfigSetBody = {
|
type ConfigSetBody = {
|
||||||
requires_restart: number;
|
requires_restart: number;
|
||||||
@ -30,12 +31,6 @@ type ConfigSetBody = {
|
|||||||
config_data: any;
|
config_data: any;
|
||||||
update_topic?: string;
|
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"]);
|
const RoleEnum = z.enum(["audio", "detect", "record"]);
|
||||||
type Role = z.infer<typeof RoleEnum>;
|
type Role = z.infer<typeof RoleEnum>;
|
||||||
@ -168,19 +163,15 @@ export default function CameraEditForm({
|
|||||||
|
|
||||||
const saveCameraConfig = (values: FormValues) => {
|
const saveCameraConfig = (values: FormValues) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
let finalCameraName = values.cameraName;
|
const { finalCameraName, friendlyName } = processCameraName(
|
||||||
let friendly_name: string | undefined = undefined;
|
values.cameraName,
|
||||||
const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName);
|
);
|
||||||
if (!isValidName) {
|
|
||||||
finalCameraName = generateFixedHash(finalCameraName);
|
|
||||||
friendly_name = values.cameraName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configData: ConfigSetBody["config_data"] = {
|
const configData: ConfigSetBody["config_data"] = {
|
||||||
cameras: {
|
cameras: {
|
||||||
[finalCameraName]: {
|
[finalCameraName]: {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
...(friendly_name && { friendly_name }),
|
...(friendlyName && { friendly_name: friendlyName }),
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
inputs: values.ffmpeg.inputs.map((input) => ({
|
inputs: values.ffmpeg.inputs.map((input) => ({
|
||||||
path: input.path,
|
path: input.path,
|
||||||
|
|||||||
@ -11,14 +11,15 @@ import { useCallback, useState, useEffect, useReducer } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Step1NameCamera from "./Step1NameCamera";
|
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
||||||
import Step2StreamConfig from "./Step2StreamConfig";
|
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
|
||||||
import Step3Validation from "./Step3Validation";
|
import Step3Validation from "@/components/settings/wizard/Step3Validation";
|
||||||
import type {
|
import type {
|
||||||
WizardFormData,
|
WizardFormData,
|
||||||
CameraConfigData,
|
CameraConfigData,
|
||||||
ConfigSetBody,
|
ConfigSetBody,
|
||||||
} from "@/types/cameraWizard";
|
} from "@/types/cameraWizard";
|
||||||
|
import { processCameraName } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
type WizardState = {
|
type WizardState = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@ -158,12 +159,17 @@ export default function CameraWizardDialog({
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Process camera name and friendly name
|
||||||
|
const { finalCameraName, friendlyName } = processCameraName(
|
||||||
|
wizardData.cameraName,
|
||||||
|
);
|
||||||
|
|
||||||
// Convert wizard data to Frigate config format
|
// Convert wizard data to Frigate config format
|
||||||
const cameraName = wizardData.cameraName;
|
|
||||||
const configData: CameraConfigData = {
|
const configData: CameraConfigData = {
|
||||||
cameras: {
|
cameras: {
|
||||||
[cameraName]: {
|
[finalCameraName]: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
...(friendlyName && { friendly_name: friendlyName }),
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
inputs: wizardData.streams.map((stream, index) => {
|
inputs: wizardData.streams.map((stream, index) => {
|
||||||
const isRestreamed =
|
const isRestreamed =
|
||||||
@ -171,8 +177,8 @@ export default function CameraWizardDialog({
|
|||||||
if (isRestreamed) {
|
if (isRestreamed) {
|
||||||
const go2rtcStreamName =
|
const go2rtcStreamName =
|
||||||
wizardData.streams!.length === 1
|
wizardData.streams!.length === 1
|
||||||
? cameraName
|
? finalCameraName
|
||||||
: `${cameraName}_${index + 1}`;
|
: `${finalCameraName}_${index + 1}`;
|
||||||
return {
|
return {
|
||||||
path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`,
|
path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`,
|
||||||
input_args: "preset-rtsp-restream",
|
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
|
// Add live.streams configuration for go2rtc streams
|
||||||
if (wizardData.streams && wizardData.streams.length > 0) {
|
if (wizardData.streams && wizardData.streams.length > 0) {
|
||||||
configData.cameras[cameraName].live = {
|
configData.cameras[finalCameraName].live = {
|
||||||
streams: {},
|
streams: {},
|
||||||
};
|
};
|
||||||
wizardData.streams.forEach((_, index) => {
|
wizardData.streams.forEach((_, index) => {
|
||||||
const go2rtcStreamName =
|
const go2rtcStreamName =
|
||||||
wizardData.streams!.length === 1
|
wizardData.streams!.length === 1
|
||||||
? cameraName
|
? finalCameraName
|
||||||
: `${cameraName}_${index + 1}`;
|
: `${finalCameraName}_${index + 1}`;
|
||||||
configData.cameras[cameraName].live!.streams[`Stream ${index + 1}`] =
|
configData.cameras[finalCameraName].live!.streams[
|
||||||
go2rtcStreamName;
|
`Stream ${index + 1}`
|
||||||
|
] = go2rtcStreamName;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestBody: ConfigSetBody = {
|
const requestBody: ConfigSetBody = {
|
||||||
requires_restart: 1,
|
requires_restart: 1,
|
||||||
config_data: configData,
|
config_data: configData,
|
||||||
update_topic: `config/cameras/${cameraName}/add`,
|
update_topic: `config/cameras/${finalCameraName}/add`,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@ -228,8 +230,8 @@ export default function CameraWizardDialog({
|
|||||||
// Use camera name with index suffix for multiple streams
|
// Use camera name with index suffix for multiple streams
|
||||||
const streamName =
|
const streamName =
|
||||||
wizardData.streams!.length === 1
|
wizardData.streams!.length === 1
|
||||||
? cameraName
|
? finalCameraName
|
||||||
: `${cameraName}_${index + 1}`;
|
: `${finalCameraName}_${index + 1}`;
|
||||||
go2rtcStreams[streamName] = [stream.url];
|
go2rtcStreams[streamName] = [stream.url];
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -260,7 +262,7 @@ export default function CameraWizardDialog({
|
|||||||
Promise.allSettled(updatePromises).then(() => {
|
Promise.allSettled(updatePromises).then(() => {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("cameraWizard.save.successWithLive", {
|
t("cameraWizard.save.successWithLive", {
|
||||||
cameraName: wizardData.cameraName,
|
cameraName: friendlyName || finalCameraName,
|
||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
@ -272,7 +274,7 @@ export default function CameraWizardDialog({
|
|||||||
// log the error but don't fail the entire save
|
// log the error but don't fail the entire save
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t("cameraWizard.save.successWithoutLive", {
|
t("cameraWizard.save.successWithoutLive", {
|
||||||
cameraName: wizardData.cameraName,
|
cameraName: friendlyName || finalCameraName,
|
||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
@ -283,7 +285,7 @@ export default function CameraWizardDialog({
|
|||||||
// No valid streams found
|
// No valid streams found
|
||||||
toast.success(
|
toast.success(
|
||||||
t("cameraWizard.save.successWithoutLive", {
|
t("cameraWizard.save.successWithoutLive", {
|
||||||
cameraName: wizardData.cameraName,
|
cameraName: friendlyName || finalCameraName,
|
||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
@ -332,7 +334,12 @@ export default function CameraWizardDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<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
|
<StepIndicator
|
||||||
steps={STEPS}
|
steps={STEPS}
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
@ -341,10 +348,20 @@ export default function CameraWizardDialog({
|
|||||||
/>
|
/>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
|
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
|
||||||
<DialogDescription>{t("cameraWizard.description")}</DialogDescription>
|
{currentStep === 0 && (
|
||||||
|
<DialogDescription>
|
||||||
|
{t("cameraWizard.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
</DialogHeader>
|
</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">
|
<div className="size-full">
|
||||||
{currentStep === 0 && (
|
{currentStep === 0 && (
|
||||||
<Step1NameCamera
|
<Step1NameCamera
|
||||||
|
|||||||
@ -38,7 +38,13 @@ import {
|
|||||||
StreamConfig,
|
StreamConfig,
|
||||||
} from "@/types/cameraWizard";
|
} from "@/types/cameraWizard";
|
||||||
import { FaCircleCheck } from "react-icons/fa6";
|
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 = {
|
type Step1NameCameraProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@ -71,12 +77,15 @@ export default function Step1NameCamera({
|
|||||||
.object({
|
.object({
|
||||||
cameraName: z
|
cameraName: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Camera name is required")
|
.min(1, t("cameraWizard.step1.errors.nameRequired"))
|
||||||
.max(64, "Camera name must be 64 characters or less")
|
.max(64, t("cameraWizard.step1.errors.nameLength"))
|
||||||
.regex(/^[a-zA-Z0-9\s_-]+$/, "Camera name contains invalid characters")
|
.regex(
|
||||||
|
/^[a-zA-Z0-9\s_-]+$/,
|
||||||
|
t("cameraWizard.step1.errors.invalidCharacters"),
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(value) => !existingCameraNames.includes(value),
|
(value) => !existingCameraNames.includes(value),
|
||||||
"Camera name already exists",
|
t("cameraWizard.step1.errors.nameExists"),
|
||||||
),
|
),
|
||||||
host: z.string().optional(),
|
host: z.string().optional(),
|
||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
@ -357,9 +366,29 @@ export default function Step1NameCamera({
|
|||||||
const selectedBrand = CAMERA_BRANDS.find(
|
const selectedBrand = CAMERA_BRANDS.find(
|
||||||
(brand) => brand.value === field.value,
|
(brand) => brand.value === field.value,
|
||||||
);
|
);
|
||||||
return selectedBrand ? (
|
return selectedBrand &&
|
||||||
|
selectedBrand.value != "other" ? (
|
||||||
<FormDescription className="mt-1 pt-0.5 text-xs text-muted-foreground">
|
<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>
|
</FormDescription>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
@ -481,61 +510,29 @@ export default function Step1NameCamera({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{testResult.snapshot && (
|
{testResult.snapshot ? (
|
||||||
<div className="flex justify-center">
|
<div className="relative flex justify-center">
|
||||||
<img
|
<img
|
||||||
src={testResult.snapshot}
|
src={testResult.snapshot}
|
||||||
alt="Camera snapshot"
|
alt="Camera snapshot"
|
||||||
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -575,3 +572,44 @@ export default function Step1NameCamera({
|
|||||||
</div>
|
</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,
|
TestResult,
|
||||||
FfprobeStream,
|
FfprobeStream,
|
||||||
} from "@/types/cameraWizard";
|
} from "@/types/cameraWizard";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "../../ui/label";
|
||||||
import { FaCircleCheck } from "react-icons/fa6";
|
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 = {
|
type Step2StreamConfigProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@ -33,7 +41,8 @@ export default function Step2StreamConfig({
|
|||||||
onNext,
|
onNext,
|
||||||
canProceed,
|
canProceed,
|
||||||
}: Step2StreamConfigProps) {
|
}: 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 [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
|
||||||
@ -226,7 +235,7 @@ export default function Step2StreamConfig({
|
|||||||
{[
|
{[
|
||||||
stream.testResult.resolution,
|
stream.testResult.resolution,
|
||||||
stream.testResult.fps
|
stream.testResult.fps
|
||||||
? `${stream.testResult.fps} fps`
|
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
|
||||||
: null,
|
: null,
|
||||||
stream.testResult.videoCodec,
|
stream.testResult.videoCodec,
|
||||||
stream.testResult.audioCodec,
|
stream.testResult.audioCodec,
|
||||||
@ -275,7 +284,10 @@ export default function Step2StreamConfig({
|
|||||||
<Input
|
<Input
|
||||||
value={stream.url}
|
value={stream.url}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateStream(stream.id, { url: e.target.value })
|
updateStream(stream.id, {
|
||||||
|
url: e.target.value,
|
||||||
|
testResult: undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
className="h-8 flex-1"
|
className="h-8 flex-1"
|
||||||
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
|
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
|
||||||
@ -312,7 +324,50 @@ export default function Step2StreamConfig({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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="rounded-lg bg-background p-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(["detect", "record", "audio"] as const).map((role) => {
|
{(["detect", "record", "audio"] as const).map((role) => {
|
||||||
@ -339,9 +394,41 @@ export default function Step2StreamConfig({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">
|
<div className="flex items-center gap-1">
|
||||||
{t("cameraWizard.step2.featuresTitle")}
|
<Label className="text-sm font-medium">
|
||||||
</Label>
|
{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="rounded-lg bg-background p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuRotateCcw } from "react-icons/lu";
|
import { LuRotateCcw } from "react-icons/lu";
|
||||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
@ -8,6 +9,9 @@ import { toast } from "sonner";
|
|||||||
import MSEPlayer from "@/components/player/MsePlayer";
|
import MSEPlayer from "@/components/player/MsePlayer";
|
||||||
import { WizardFormData, StreamConfig, TestResult } from "@/types/cameraWizard";
|
import { WizardFormData, StreamConfig, TestResult } from "@/types/cameraWizard";
|
||||||
import { PlayerStatsType } from "@/types/live";
|
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 = {
|
type Step3ValidationProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@ -17,6 +21,510 @@ type Step3ValidationProps = {
|
|||||||
isLoading?: boolean;
|
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 = {
|
type StreamPreviewProps = {
|
||||||
stream: StreamConfig;
|
stream: StreamConfig;
|
||||||
onBandwidthUpdate?: (streamId: string, bandwidth: number) => void;
|
onBandwidthUpdate?: (streamId: string, bandwidth: number) => void;
|
||||||
@ -78,7 +586,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<span className="text-sm text-danger">
|
||||||
{t("cameraWizard.step3.streamUnavailable")}
|
{t("cameraWizard.step3.streamUnavailable")}
|
||||||
</span>
|
</span>
|
||||||
@ -110,444 +618,10 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
|
|||||||
<MSEPlayer
|
<MSEPlayer
|
||||||
camera={streamId}
|
camera={streamId}
|
||||||
playbackEnabled={true}
|
playbackEnabled={true}
|
||||||
className="max-h-[20dvh] w-full rounded-lg"
|
className="max-h-[30dvh] rounded-lg md:max-h-[20dvh]"
|
||||||
getStats={true}
|
getStats={true}
|
||||||
setStats={handleStats}
|
setStats={handleStats}
|
||||||
onError={() => setError(true)}
|
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