add camera image and stream details on successful test

This commit is contained in:
Josh Hawkins 2025-10-10 15:08:13 -05:00
parent 617d2a239e
commit be896789c7

View File

@ -21,7 +21,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu"; import { LuCircleCheck, LuEye, LuEyeOff } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
@ -40,12 +40,15 @@ type Step1NameCameraProps = {
wizardData: Partial<WizardFormData>; wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void; onUpdate: (data: Partial<WizardFormData>) => void;
onCancel: () => void; onCancel: () => void;
onNext?: () => void;
canProceed?: boolean;
}; };
export default function Step1NameCamera({ export default function Step1NameCamera({
wizardData, wizardData,
onUpdate, onUpdate,
onCancel, onCancel,
onNext,
}: Step1NameCameraProps) { }: Step1NameCameraProps) {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -147,97 +150,120 @@ export default function Step1NameCamera({
setIsTesting(true); setIsTesting(true);
setTestResult(null); setTestResult(null);
await axios // First get probe data for metadata
.get("ffprobe", { const probePromise = axios.get("ffprobe", {
params: { paths: streamUrl, detailed: true }, params: { paths: streamUrl, detailed: true },
timeout: 10000, timeout: 10000,
}) });
.then((response) => {
if (
response.data &&
response.data.length > 0 &&
response.data[0].return_code === 0
) {
const probeData = response.data[0];
const ffprobeData = probeData.stdout;
const streams = ffprobeData.streams || [];
// Extract video stream info // Then get snapshot for preview
const videoStream = streams.find( const snapshotPromise = axios.get("ffprobe/snapshot", {
(s: FfprobeStream) => params: { url: streamUrl },
s.codec_type === "video" || responseType: "blob",
s.codec_name?.includes("h264") || timeout: 10000,
s.codec_name?.includes("h265"), });
);
const audioStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
// Calculate resolution try {
const resolution = videoStream // First get probe data for metadata
? `${videoStream.width}x${videoStream.height}` const probeResponse = await probePromise;
: undefined; let probeData = null;
if (
probeResponse.data &&
probeResponse.data.length > 0 &&
probeResponse.data[0].return_code === 0
) {
probeData = probeResponse.data[0];
}
// Extract FPS from rational (e.g., "15/1" -> 15) // Then get snapshot for preview (only if probe succeeded)
const fps = videoStream?.r_frame_rate let snapshotBlob = null;
? parseFloat(videoStream.r_frame_rate.split("/")[0]) / if (probeData) {
parseFloat(videoStream.r_frame_rate.split("/")[1]) try {
: undefined; const snapshotResponse = await snapshotPromise;
snapshotBlob = snapshotResponse.data;
const testResult: TestResult = { } catch (snapshotError) {
success: true, // Snapshot is optional, don't fail if it doesn't work
resolution, toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
setTestResult(testResult);
toast.success(t("cameraWizard.step1.testSuccess"));
// Auto-populate stream if successful
const streamId = `stream_${Date.now()}`;
onUpdate({
...data,
streams: [
{
id: streamId,
url: streamUrl,
roles: ["detect"],
resolution: testResult.resolution,
testResult,
},
],
});
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
setTestResult({
success: false,
error: error,
});
toast.error(t("cameraWizard.step1.testFailed", { error }));
} }
}) }
.catch((error) => {
const errorMessage = if (probeData) {
error.response?.data?.message || const ffprobeData = probeData.stdout;
error.response?.data?.detail || const streams = ffprobeData.streams || [];
"Connection failed";
// Extract video stream info
const videoStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
// Calculate resolution
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
// Extract FPS from rational (e.g., "15/1" -> 15)
const fps = videoStream?.r_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1])
: undefined;
// Convert snapshot blob to base64 if available
let snapshotBase64 = undefined;
if (snapshotBlob) {
snapshotBase64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(snapshotBlob);
});
}
const testResult: TestResult = {
success: true,
snapshot: snapshotBase64,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
setTestResult(testResult);
toast.success(t("cameraWizard.step1.testSuccess"));
} else {
const error = probeData?.stderr || "Unknown error";
setTestResult({ setTestResult({
success: false, success: false,
error: errorMessage, error: error,
}); });
toast.error( toast.error(t("cameraWizard.step1.testFailed", { error }));
t("cameraWizard.step1.testFailed", { error: errorMessage }), }
); } catch (error) {
}) const axiosError = error as {
.finally(() => { response?: { data?: { message?: string; detail?: string } };
setIsTesting(false); message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
setTestResult({
success: false,
error: errorMessage,
}); });
}, [form, generateStreamUrl, onUpdate, t]); toast.error(t("cameraWizard.step1.testFailed", { error: errorMessage }));
} finally {
setIsTesting(false);
}
}, [form, generateStreamUrl, t]);
const onSubmit = (data: z.infer<typeof step1FormData>) => { const onSubmit = (data: z.infer<typeof step1FormData>) => {
onUpdate(data); onUpdate(data);
@ -245,104 +271,25 @@ export default function Step1NameCamera({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-sm text-muted-foreground"> {!testResult?.success && (
{t("cameraWizard.step1.description")} <>
</div> <div className="text-sm text-muted-foreground">
{t("cameraWizard.step1.description")}
</div>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="cameraName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraWizard.step1.cameraName")}</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandTemplate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraWizard.step1.cameraBrand")}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="h-8">
<SelectValue
placeholder={t("cameraWizard.step1.selectBrand")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{CAMERA_BRANDS.map((brand) => (
<SelectItem key={brand.value} value={brand.value}>
{brand.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
{field.value &&
(() => {
const selectedBrand = CAMERA_BRANDS.find(
(brand) => brand.value === field.value,
);
return selectedBrand ? (
<FormDescription className="pt-0.5">
<div className="mt-1 text-xs text-muted-foreground">
{selectedBrand.exampleUrl}
</div>
</FormDescription>
) : null;
})()}
</FormItem>
)}
/>
{watchedBrand !== "other" && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="host" name="cameraName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("cameraWizard.step1.host")}</FormLabel> <FormLabel>{t("cameraWizard.step1.cameraName")}</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder="192.168.1.100"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraWizard.step1.username")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="h-8" className="h-8"
placeholder={t( placeholder={t(
"cameraWizard.step1.usernamePlaceholder", "cameraWizard.step1.cameraNamePlaceholder",
)} )}
{...field} {...field}
/> />
@ -354,109 +301,264 @@ export default function Step1NameCamera({
<FormField <FormField
control={form.control} control={form.control}
name="password" name="brandTemplate"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("cameraWizard.step1.password")}</FormLabel> <FormLabel>{t("cameraWizard.step1.cameraBrand")}</FormLabel>
<FormControl> <Select
<div className="relative"> onValueChange={field.onChange}
<Input defaultValue={field.value}
className="h-8 pr-10" >
type={showPassword ? "text" : "password"} <FormControl>
placeholder={t( <SelectTrigger className="h-8">
"cameraWizard.step1.passwordPlaceholder", <SelectValue
)} placeholder={t("cameraWizard.step1.selectBrand")}
{...field} />
/> </SelectTrigger>
<Button </FormControl>
type="button" <SelectContent>
variant="ghost" {CAMERA_BRANDS.map((brand) => (
size="sm" <SelectItem key={brand.value} value={brand.value}>
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" {brand.label}
onClick={() => setShowPassword(!showPassword)} </SelectItem>
> ))}
{showPassword ? ( </SelectContent>
<LuEyeOff className="h-4 w-4" /> </Select>
) : (
<LuEye className="h-4 w-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage /> <FormMessage />
{field.value &&
(() => {
const selectedBrand = CAMERA_BRANDS.find(
(brand) => brand.value === field.value,
);
return selectedBrand ? (
<FormDescription className="pt-0.5">
<div className="mt-1 text-xs text-muted-foreground">
{selectedBrand.exampleUrl}
</div>
</FormDescription>
) : null;
})()}
</FormItem> </FormItem>
)} )}
/> />
</>
)}
{watchedBrand == "other" && ( {watchedBrand !== "other" && (
<FormField <>
control={form.control} <FormField
name="customUrl" control={form.control}
render={({ field }) => ( name="host"
<FormItem> render={({ field }) => (
<FormLabel>{t("cameraWizard.step1.customUrl")}</FormLabel> <FormItem>
<FormControl> <FormLabel>{t("cameraWizard.step1.host")}</FormLabel>
<Input <FormControl>
className="h-8" <Input
placeholder="rtsp://username:password@host:port/path" className="h-8"
{...field} placeholder="192.168.1.100"
/> {...field}
</FormControl> />
<FormMessage /> </FormControl>
</FormItem> <FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("cameraWizard.step1.username")}
</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder={t(
"cameraWizard.step1.usernamePlaceholder",
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("cameraWizard.step1.password")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
className="h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
)}
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)} )}
/>
)}
{testResult && ( {watchedBrand == "other" && (
<div className="mt-4"> <FormField
<div control={form.control}
className={`text-sm font-medium ${testResult.success ? "text-success" : "text-danger"}`} name="customUrl"
> render={({ field }) => (
{testResult.success <FormItem>
? t("cameraWizard.step1.testSuccess") <FormLabel>{t("cameraWizard.step1.customUrl")}</FormLabel>
: t("cameraWizard.step1.testFailed")} <FormControl>
<Input
className="h-8"
placeholder="rtsp://username:password@host:port/path"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</>
)}
{testResult?.success && (
<div className="p-4">
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
<LuCircleCheck />
{t("cameraWizard.step1.testSuccess")}
</div>
<div className="space-y-3">
{testResult.snapshot && (
<div className="flex justify-center">
<img
src={testResult.snapshot}
alt="Camera snapshot"
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
/>
</div> </div>
{testResult.success ? ( )}
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
{testResult.resolution && ( <div className="text-sm font-semibold text-primary-variant">
<div>Resolution: {testResult.resolution}</div> {t("cameraWizard.step1.streamDetails")}
)} </div>
{testResult.videoCodec && ( <div className="text-sm">
<div>Video: {testResult.videoCodec}</div> {testResult.resolution && (
)} <div>
{testResult.audioCodec && ( <span className="text-secondary-foreground">
<div>Audio: {testResult.audioCodec}</div> {t("cameraWizard.step1.testResultLabels.resolution")}:
)} </span>{" "}
{testResult.fps && <div>FPS: {testResult.fps}</div>} <span className="text-primary">{testResult.resolution}</span>
</div> </div>
) : ( )}
<div className="mt-2 text-xs text-danger"> {testResult.videoCodec && (
{testResult.error} <div>
<span className="text-secondary-foreground">
{t("cameraWizard.step1.testResultLabels.video")}:
</span>{" "}
<span className="text-primary">{testResult.videoCodec}</span>
</div>
)}
{testResult.audioCodec && (
<div>
<span className="text-secondary-foreground">
{t("cameraWizard.step1.testResultLabels.audio")}:
</span>{" "}
<span className="text-primary">{testResult.audioCodec}</span>
</div>
)}
{testResult.fps && (
<div>
<span className="text-secondary-foreground">
{t("cameraWizard.step1.testResultLabels.fps")}:
</span>{" "}
<span className="text-primary">{testResult.fps}</span>
</div> </div>
)} )}
</div> </div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="button"
onClick={testConnection}
disabled={isTesting || !isTestButtonEnabled}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step1.testConnection")}
</Button>
</div> </div>
</form> </div>
</Form> )}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button
type="button"
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
className="sm:flex-1"
>
{testResult?.success
? t("button.back", { ns: "common" })
: t("button.cancel", { ns: "common" })}
</Button>
{testResult?.success ? (
<Button
type="button"
onClick={() => {
// Auto-populate stream and proceed to next step
const data = form.getValues();
const streamUrl = generateStreamUrl(data);
const streamId = `stream_${Date.now()}`;
onUpdate({
...data,
streams: [
{
id: streamId,
url: streamUrl,
roles: ["detect"],
resolution: testResult.resolution,
testResult: testResult || undefined,
},
],
});
onNext?.();
}}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.continue", { ns: "common" })}
</Button>
) : (
<Button
type="button"
onClick={testConnection}
disabled={isTesting || !isTestButtonEnabled}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step1.testConnection")}
</Button>
)}
</div>
</div> </div>
); );
} }