mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 19:06:52 +03:00
The hwaccel step listed every preset Frigate ships, so an Intel box was offered Raspberry Pi and Rockchip decoding, and the codec specific presets (`preset-intel-qsv-h264` vs `-h265`) were offered as global values that break as soon as two cameras use different codecs. `/hardware/hwaccel` now returns the decoding families the probed hardware can actually use, each carrying a preset per codec, and the wizard resolves the family against the detect stream codec the camera wizard already probed: one global `ffmpeg.hwaccel_args` when every camera agrees, per-camera `cameras.<name>.ffmpeg.hwaccel_args` when they don't. The global stays on `auto` in that case so cameras added later still resolve at startup. A gen13+ Intel machine keeps its QuickSync recommendation with mixed h264 and h265 cameras instead of dropping to vaapi. The recording step's "Days to retain recordings" only wrote alert and detection retention, and the storage estimate under it assumed continuous recording. It now asks what to record in plain language, writes `record.continuous.days` to match, shows the estimate only for continuous, and drops the spinner arrows on the number input.
485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
import StepIndicator from "@/components/indicators/StepIndicator";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useCallback, useState, useEffect, useReducer } from "react";
|
|
import { toast } from "sonner";
|
|
import useSWR from "swr";
|
|
import axios from "axios";
|
|
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
|
|
import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
|
|
import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
|
|
import Step4Validation from "@/components/settings/wizard/Step4Validation";
|
|
import type {
|
|
WizardFormData,
|
|
CameraConfigData,
|
|
ConfigSetBody,
|
|
} from "@/types/cameraWizard";
|
|
import {
|
|
processCameraName,
|
|
calculateDetectDimensions,
|
|
} from "@/utils/cameraUtil";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type WizardState = {
|
|
wizardData: Partial<WizardFormData>;
|
|
shouldNavigateNext: boolean;
|
|
};
|
|
|
|
type WizardAction =
|
|
| { type: "UPDATE_DATA"; payload: Partial<WizardFormData> }
|
|
| { type: "UPDATE_AND_NEXT"; payload: Partial<WizardFormData> }
|
|
| { type: "RESET_NAVIGATE" }
|
|
| { type: "RESET_ALL" };
|
|
|
|
const wizardReducer = (
|
|
state: WizardState,
|
|
action: WizardAction,
|
|
): WizardState => {
|
|
switch (action.type) {
|
|
case "UPDATE_DATA":
|
|
return {
|
|
...state,
|
|
wizardData: { ...state.wizardData, ...action.payload },
|
|
};
|
|
case "UPDATE_AND_NEXT":
|
|
return {
|
|
wizardData: { ...state.wizardData, ...action.payload },
|
|
shouldNavigateNext: true,
|
|
};
|
|
case "RESET_NAVIGATE":
|
|
return { ...state, shouldNavigateNext: false };
|
|
case "RESET_ALL":
|
|
return {
|
|
wizardData: { streams: [] },
|
|
shouldNavigateNext: false,
|
|
};
|
|
default:
|
|
return state;
|
|
}
|
|
};
|
|
|
|
const STEPS = [
|
|
"cameraWizard.steps.nameAndConnection",
|
|
"cameraWizard.steps.probeOrSnapshot",
|
|
"cameraWizard.steps.streamConfiguration",
|
|
"cameraWizard.steps.validationAndTesting",
|
|
];
|
|
|
|
type CameraWizardDialogProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
// reports each camera the wizard saves, so callers can act on the streams
|
|
// that were probed without probing them again
|
|
onCameraAdded?: (camera: { name: string; detectCodec?: string }) => void;
|
|
};
|
|
|
|
export default function CameraWizardDialog({
|
|
open,
|
|
onClose,
|
|
onCameraAdded,
|
|
}: CameraWizardDialogProps) {
|
|
const { t } = useTranslation(["views/settings"]);
|
|
const { mutate: updateConfig } = useSWR("config");
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [state, dispatch] = useReducer(wizardReducer, {
|
|
wizardData: { streams: [] },
|
|
shouldNavigateNext: false,
|
|
});
|
|
|
|
// Reset wizard when opened
|
|
useEffect(() => {
|
|
if (open) {
|
|
setCurrentStep(0);
|
|
dispatch({ type: "RESET_ALL" });
|
|
}
|
|
}, [open]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setCurrentStep(0);
|
|
dispatch({ type: "RESET_ALL" });
|
|
onClose();
|
|
}, [onClose]);
|
|
|
|
const onUpdate = useCallback((data: Partial<WizardFormData>) => {
|
|
dispatch({ type: "UPDATE_DATA", payload: data });
|
|
}, []);
|
|
|
|
const canProceedToNext = useCallback((): boolean => {
|
|
switch (currentStep) {
|
|
case 0:
|
|
// Step 1: Can proceed if camera name is set
|
|
return !!state.wizardData.cameraName;
|
|
case 1:
|
|
// Step 2: Can proceed if at least one stream exists (from probe or manual test)
|
|
return (state.wizardData.streams?.length ?? 0) > 0;
|
|
case 2: {
|
|
// Step 3: requires a detect stream; if PTZ is enabled, also require
|
|
// ONVIF host + port (fields are pre-filled but the user may clear them)
|
|
const hasDetect = !!(
|
|
state.wizardData.streams?.some((stream) =>
|
|
stream.roles.includes("detect"),
|
|
) ?? false
|
|
);
|
|
const onvif = state.wizardData.onvif;
|
|
const onvifOk =
|
|
!onvif?.enabled || (!!onvif.host?.trim() && !!onvif.port);
|
|
return hasDetect && onvifOk;
|
|
}
|
|
case 3:
|
|
// Step 4: Always can proceed from final step (save will be handled there)
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}, [currentStep, state.wizardData]);
|
|
|
|
const handleNext = useCallback(
|
|
(data?: Partial<WizardFormData>) => {
|
|
if (data) {
|
|
// Atomic update and navigate
|
|
dispatch({ type: "UPDATE_AND_NEXT", payload: data });
|
|
} else {
|
|
// Just navigate
|
|
if (currentStep < STEPS.length - 1 && canProceedToNext()) {
|
|
setCurrentStep((s) => s + 1);
|
|
}
|
|
}
|
|
},
|
|
[currentStep, canProceedToNext],
|
|
);
|
|
|
|
const handleBack = () => {
|
|
if (currentStep > 0) {
|
|
setCurrentStep(currentStep - 1);
|
|
}
|
|
};
|
|
|
|
// Handle navigation after atomic update
|
|
useEffect(() => {
|
|
if (state.shouldNavigateNext) {
|
|
if (currentStep < STEPS.length - 1 && canProceedToNext()) {
|
|
setCurrentStep((s) => s + 1);
|
|
}
|
|
dispatch({ type: "RESET_NAVIGATE" });
|
|
}
|
|
}, [state.shouldNavigateNext, currentStep, canProceedToNext]);
|
|
|
|
// Handle wizard save
|
|
const handleSave = useCallback(
|
|
(wizardData: WizardFormData) => {
|
|
if (!wizardData.cameraName || !wizardData.streams) {
|
|
toast.error("Invalid wizard data");
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
// Process camera name and friendly name
|
|
const { finalCameraName, friendlyName } = processCameraName(
|
|
wizardData.cameraName,
|
|
);
|
|
|
|
// Convert wizard data to Frigate config format
|
|
const configData: CameraConfigData = {
|
|
cameras: {
|
|
[finalCameraName]: {
|
|
enabled: true,
|
|
...(friendlyName && { friendly_name: friendlyName }),
|
|
ffmpeg: {
|
|
inputs: wizardData.streams.map((stream, index) => {
|
|
if (stream.restream) {
|
|
const go2rtcStreamName =
|
|
wizardData.streams!.length === 1
|
|
? finalCameraName
|
|
: `${finalCameraName}_${index + 1}`;
|
|
return {
|
|
path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`,
|
|
input_args: "preset-rtsp-restream",
|
|
roles: stream.roles,
|
|
};
|
|
} else {
|
|
return {
|
|
path: stream.url,
|
|
roles: stream.roles,
|
|
};
|
|
}
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// Calculate detect dimensions from the detect stream's probed resolution
|
|
const detectStream = wizardData.streams.find((stream) =>
|
|
stream.roles.includes("detect"),
|
|
);
|
|
if (detectStream?.testResult?.resolution) {
|
|
const [streamWidth, streamHeight] = detectStream.testResult.resolution
|
|
.split("x")
|
|
.map(Number);
|
|
if (streamWidth > 0 && streamHeight > 0) {
|
|
const detectDimensions = calculateDetectDimensions(
|
|
streamWidth,
|
|
streamHeight,
|
|
);
|
|
if (detectDimensions) {
|
|
configData.cameras[finalCameraName].detect = detectDimensions;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add live.streams configuration for go2rtc streams
|
|
if (wizardData.streams && wizardData.streams.length > 0) {
|
|
configData.cameras[finalCameraName].live = {
|
|
streams: {},
|
|
};
|
|
wizardData.streams.forEach((_, index) => {
|
|
const go2rtcStreamName =
|
|
wizardData.streams!.length === 1
|
|
? finalCameraName
|
|
: `${finalCameraName}_${index + 1}`;
|
|
configData.cameras[finalCameraName].live!.streams[
|
|
`Stream ${index + 1}`
|
|
] = go2rtcStreamName;
|
|
});
|
|
}
|
|
|
|
// Write the ONVIF section when PTZ controls are enabled
|
|
if (wizardData.onvif?.enabled && wizardData.onvif.host.trim()) {
|
|
configData.cameras[finalCameraName].onvif = {
|
|
host: wizardData.onvif.host.trim(),
|
|
port: wizardData.onvif.port,
|
|
...(wizardData.onvif.user?.trim() && {
|
|
user: wizardData.onvif.user.trim(),
|
|
}),
|
|
...(wizardData.onvif.password && {
|
|
password: wizardData.onvif.password,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const requestBody: ConfigSetBody = {
|
|
requires_restart: 1,
|
|
config_data: configData,
|
|
update_topic: `config/cameras/${finalCameraName}/add`,
|
|
};
|
|
|
|
axios
|
|
.put("config/set", requestBody)
|
|
.then((response) => {
|
|
if (response.status === 200) {
|
|
onCameraAdded?.({
|
|
name: finalCameraName,
|
|
detectCodec: wizardData.streams?.find((stream) =>
|
|
stream.roles.includes("detect"),
|
|
)?.testResult?.videoCodec,
|
|
});
|
|
|
|
// Configure go2rtc streams for all streams
|
|
if (wizardData.streams && wizardData.streams.length > 0) {
|
|
const go2rtcStreams: Record<string, string[]> = {};
|
|
|
|
wizardData.streams.forEach((stream, index) => {
|
|
// Use camera name with index suffix for multiple streams
|
|
const streamName =
|
|
wizardData.streams!.length === 1
|
|
? finalCameraName
|
|
: `${finalCameraName}_${index + 1}`;
|
|
|
|
const streamUrl = stream.useFfmpeg
|
|
? `ffmpeg:${stream.url}`
|
|
: stream.url;
|
|
|
|
if (wizardData.hasBackchannel ?? false) {
|
|
// Add two streams: one with #backchannel=0 and one without
|
|
// in order to avoid taking control of the microphone during connections
|
|
go2rtcStreams[streamName] = [
|
|
`${streamUrl}#backchannel=0`,
|
|
streamUrl,
|
|
];
|
|
} else {
|
|
// Add single stream as normal
|
|
go2rtcStreams[streamName] = [streamUrl];
|
|
}
|
|
});
|
|
|
|
if (Object.keys(go2rtcStreams).length > 0) {
|
|
// Update frigate go2rtc config for persistence
|
|
const go2rtcConfigData = {
|
|
go2rtc: {
|
|
streams: go2rtcStreams,
|
|
},
|
|
};
|
|
|
|
const go2rtcRequestBody = {
|
|
requires_restart: 0,
|
|
config_data: go2rtcConfigData,
|
|
};
|
|
|
|
axios
|
|
.put("config/set", go2rtcRequestBody)
|
|
.then(() => {
|
|
// also update the running go2rtc instance for immediate effect
|
|
const updatePromises = Object.entries(go2rtcStreams).map(
|
|
([streamName, urls]) =>
|
|
axios.put(
|
|
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
|
|
),
|
|
);
|
|
|
|
Promise.allSettled(updatePromises).then(() => {
|
|
toast.success(
|
|
t("cameraWizard.save.success", {
|
|
cameraName: friendlyName || finalCameraName,
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
updateConfig();
|
|
onClose();
|
|
});
|
|
})
|
|
.catch(() => {
|
|
// log the error but don't fail the entire save
|
|
toast.warning(
|
|
t("cameraWizard.save.failure", {
|
|
cameraName: friendlyName || finalCameraName,
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
updateConfig();
|
|
onClose();
|
|
});
|
|
} else {
|
|
// No valid streams found
|
|
toast.success(
|
|
t("cameraWizard.save.failure", {
|
|
cameraName: friendlyName || finalCameraName,
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
updateConfig();
|
|
onClose();
|
|
}
|
|
} else {
|
|
toast.success(
|
|
t("cameraWizard.save.success", {
|
|
cameraName: friendlyName || finalCameraName,
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
updateConfig();
|
|
onClose();
|
|
}
|
|
} else {
|
|
throw new Error(response.statusText);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
const apiError = error as {
|
|
response?: { data?: { message?: string; detail?: string } };
|
|
message?: string;
|
|
};
|
|
const errorMessage =
|
|
apiError.response?.data?.message ||
|
|
apiError.response?.data?.detail ||
|
|
apiError.message ||
|
|
"Unknown error";
|
|
|
|
toast.error(
|
|
t("toast.save.error.title", {
|
|
errorMessage,
|
|
ns: "common",
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
})
|
|
.finally(() => {
|
|
setIsLoading(false);
|
|
});
|
|
},
|
|
[updateConfig, t, onClose, onCameraAdded],
|
|
);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent
|
|
className={cn(
|
|
"scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
|
|
)}
|
|
onInteractOutside={(e) => {
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<StepIndicator
|
|
steps={STEPS}
|
|
currentStep={currentStep}
|
|
variant="dots"
|
|
className="mb-4 justify-start"
|
|
/>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
|
|
{currentStep === 0 && (
|
|
<DialogDescription>
|
|
{t("cameraWizard.description")}
|
|
</DialogDescription>
|
|
)}
|
|
</DialogHeader>
|
|
|
|
{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
|
|
wizardData={state.wizardData}
|
|
onUpdate={onUpdate}
|
|
onNext={handleNext}
|
|
onCancel={handleClose}
|
|
canProceed={canProceedToNext()}
|
|
/>
|
|
)}
|
|
{currentStep === 1 && (
|
|
<Step2ProbeOrSnapshot
|
|
wizardData={state.wizardData}
|
|
onUpdate={onUpdate}
|
|
onNext={handleNext}
|
|
onBack={handleBack}
|
|
probeMode={state.wizardData.probeMode ?? true}
|
|
/>
|
|
)}
|
|
{currentStep === 2 && (
|
|
<Step3StreamConfig
|
|
wizardData={state.wizardData}
|
|
onUpdate={onUpdate}
|
|
onBack={handleBack}
|
|
onNext={handleNext}
|
|
canProceed={canProceedToNext()}
|
|
/>
|
|
)}
|
|
{currentStep === 3 && (
|
|
<Step4Validation
|
|
wizardData={state.wizardData}
|
|
onUpdate={onUpdate}
|
|
onSave={handleSave}
|
|
onBack={handleBack}
|
|
isLoading={isLoading}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|