Files
frigate/web/src/components/settings/CameraWizardDialog.tsx
T
Josh Hawkins 2a4d48aa3c resolve hwaccel per camera and clarify recording retention
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.
2026-08-21 10:44:47 -05:00

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>
);
}