mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-29 16:41:16 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useState, useMemo, useRef, useCallback, useEffect } from "react";
|
|
import useSWR from "swr";
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { LuX, LuPlus } from "react-icons/lu";
|
|
import { Stage, Layer, Rect, Transformer } from "react-konva";
|
|
import Konva from "konva";
|
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
|
import { useApiHost } from "@/api";
|
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
|
import Heading from "@/components/ui/heading";
|
|
import { isMobile } from "react-device-detect";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type CameraAreaConfig = {
|
|
camera: string;
|
|
crop: [number, number, number, number];
|
|
};
|
|
|
|
export type Step2FormData = {
|
|
cameraAreas: CameraAreaConfig[];
|
|
};
|
|
|
|
type Step2StateAreaProps = {
|
|
initialData?: Partial<Step2FormData>;
|
|
onNext: (data: Step2FormData) => void;
|
|
onBack: () => void;
|
|
};
|
|
|
|
export default function Step2StateArea({
|
|
initialData,
|
|
onNext,
|
|
onBack,
|
|
}: Step2StateAreaProps) {
|
|
const { t } = useTranslation(["views/classificationModel"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
const apiHost = useApiHost();
|
|
|
|
const [cameraAreas, setCameraAreas] = useState<CameraAreaConfig[]>(
|
|
initialData?.cameraAreas || [],
|
|
);
|
|
const [selectedCameraIndex, setSelectedCameraIndex] = useState<number>(0);
|
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
const stageRef = useRef<Konva.Stage>(null);
|
|
const rectRef = useRef<Konva.Rect>(null);
|
|
const transformerRef = useRef<Konva.Transformer>(null);
|
|
|
|
const [{ width: containerWidth }] = useResizeObserver(containerRef);
|
|
|
|
const availableCameras = useMemo(() => {
|
|
if (!config) return [];
|
|
|
|
const selectedCameraNames = cameraAreas.map((ca) => ca.camera);
|
|
return Object.entries(config.cameras)
|
|
.sort()
|
|
.filter(
|
|
([name, cam]) =>
|
|
cam.enabled &&
|
|
cam.enabled_in_config &&
|
|
!isReplayCamera(name) &&
|
|
!selectedCameraNames.includes(name),
|
|
)
|
|
.map(([name]) => ({
|
|
name,
|
|
displayName: resolveCameraName(config, name),
|
|
}));
|
|
}, [config, cameraAreas]);
|
|
|
|
const selectedCamera = useMemo(() => {
|
|
if (cameraAreas.length === 0) return null;
|
|
return cameraAreas[selectedCameraIndex];
|
|
}, [cameraAreas, selectedCameraIndex]);
|
|
|
|
const selectedCameraConfig = useMemo(() => {
|
|
if (!config || !selectedCamera) return null;
|
|
return config.cameras[selectedCamera.camera];
|
|
}, [config, selectedCamera]);
|
|
|
|
const imageSize = useMemo(() => {
|
|
if (!containerWidth || !selectedCameraConfig) {
|
|
return { width: 0, height: 0 };
|
|
}
|
|
|
|
const containerAspectRatio = 16 / 9;
|
|
const containerHeight = containerWidth / containerAspectRatio;
|
|
|
|
const cameraAspectRatio =
|
|
selectedCameraConfig.detect.width / selectedCameraConfig.detect.height;
|
|
|
|
// Fit camera within 16:9 container
|
|
let imageWidth, imageHeight;
|
|
if (cameraAspectRatio > containerAspectRatio) {
|
|
imageWidth = containerWidth;
|
|
imageHeight = imageWidth / cameraAspectRatio;
|
|
} else {
|
|
imageHeight = containerHeight;
|
|
imageWidth = imageHeight * cameraAspectRatio;
|
|
}
|
|
|
|
return { width: imageWidth, height: imageHeight };
|
|
}, [containerWidth, selectedCameraConfig]);
|
|
|
|
const handleAddCamera = useCallback(
|
|
(cameraName: string) => {
|
|
// Calculate a square crop in pixel space
|
|
const camera = config?.cameras[cameraName];
|
|
if (!camera) return;
|
|
|
|
const cameraAspect = camera.detect.width / camera.detect.height;
|
|
const cropSize = 0.3;
|
|
let x1, y1, x2, y2;
|
|
|
|
if (cameraAspect >= 1) {
|
|
const pixelSize = cropSize * camera.detect.height;
|
|
const normalizedWidth = pixelSize / camera.detect.width;
|
|
x1 = (1 - normalizedWidth) / 2;
|
|
y1 = (1 - cropSize) / 2;
|
|
x2 = x1 + normalizedWidth;
|
|
y2 = y1 + cropSize;
|
|
} else {
|
|
const pixelSize = cropSize * camera.detect.width;
|
|
const normalizedHeight = pixelSize / camera.detect.height;
|
|
x1 = (1 - cropSize) / 2;
|
|
y1 = (1 - normalizedHeight) / 2;
|
|
x2 = x1 + cropSize;
|
|
y2 = y1 + normalizedHeight;
|
|
}
|
|
|
|
const newArea: CameraAreaConfig = {
|
|
camera: cameraName,
|
|
crop: [x1, y1, x2, y2],
|
|
};
|
|
setCameraAreas([...cameraAreas, newArea]);
|
|
setSelectedCameraIndex(cameraAreas.length);
|
|
setIsPopoverOpen(false);
|
|
},
|
|
[cameraAreas, config],
|
|
);
|
|
|
|
const handleRemoveCamera = useCallback(
|
|
(index: number) => {
|
|
const newAreas = cameraAreas.filter((_, i) => i !== index);
|
|
setCameraAreas(newAreas);
|
|
if (selectedCameraIndex >= newAreas.length) {
|
|
setSelectedCameraIndex(Math.max(0, newAreas.length - 1));
|
|
}
|
|
},
|
|
[cameraAreas, selectedCameraIndex],
|
|
);
|
|
|
|
const handleCropChange = useCallback(
|
|
(crop: [number, number, number, number]) => {
|
|
const newAreas = [...cameraAreas];
|
|
newAreas[selectedCameraIndex] = {
|
|
...newAreas[selectedCameraIndex],
|
|
crop,
|
|
};
|
|
setCameraAreas(newAreas);
|
|
},
|
|
[cameraAreas, selectedCameraIndex],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setImageLoaded(false);
|
|
}, [selectedCamera]);
|
|
|
|
useEffect(() => {
|
|
const rect = rectRef.current;
|
|
const transformer = transformerRef.current;
|
|
|
|
if (
|
|
rect &&
|
|
transformer &&
|
|
selectedCamera &&
|
|
imageSize.width > 0 &&
|
|
imageLoaded
|
|
) {
|
|
rect.scaleX(1);
|
|
rect.scaleY(1);
|
|
transformer.nodes([rect]);
|
|
transformer.getLayer()?.batchDraw();
|
|
}
|
|
}, [selectedCamera, imageSize, imageLoaded]);
|
|
|
|
const handleRectChange = useCallback(() => {
|
|
const rect = rectRef.current;
|
|
|
|
if (rect && imageSize.width > 0) {
|
|
const actualWidth = rect.width() * rect.scaleX();
|
|
const actualHeight = rect.height() * rect.scaleY();
|
|
|
|
// Average dimensions to maintain perfect square
|
|
const size = (actualWidth + actualHeight) / 2;
|
|
|
|
rect.width(size);
|
|
rect.height(size);
|
|
rect.scaleX(1);
|
|
rect.scaleY(1);
|
|
|
|
const x1 = rect.x() / imageSize.width;
|
|
const y1 = rect.y() / imageSize.height;
|
|
const x2 = (rect.x() + size) / imageSize.width;
|
|
const y2 = (rect.y() + size) / imageSize.height;
|
|
|
|
handleCropChange([x1, y1, x2, y2]);
|
|
}
|
|
}, [imageSize, handleCropChange]);
|
|
|
|
const handleContinue = useCallback(() => {
|
|
onNext({ cameraAreas });
|
|
}, [cameraAreas, onNext]);
|
|
|
|
const canContinue = cameraAreas.length > 0;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div
|
|
className={cn(
|
|
"flex gap-4 overflow-hidden",
|
|
isMobile ? "flex-col" : "flex-row",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex flex-shrink-0 flex-col gap-2 overflow-y-auto rounded-lg bg-secondary p-4",
|
|
isMobile ? "w-full" : "w-64",
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium">{t("wizard.step2.cameras")}</h3>
|
|
{availableCameras.length > 0 ? (
|
|
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
|
aria-label="Add camera"
|
|
>
|
|
<LuPlus />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="scrollbar-container w-64 border bg-background p-3 shadow-lg"
|
|
align="start"
|
|
sideOffset={5}
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
<Heading as="h4" className="text-sm text-primary-variant">
|
|
{t("wizard.step2.selectCamera")}
|
|
</Heading>
|
|
<div className="scrollbar-container flex max-h-[30vh] flex-col gap-1 overflow-y-auto">
|
|
{availableCameras.map((cam) => (
|
|
<Button
|
|
key={cam.name}
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-auto justify-start p-2 capitalize text-primary"
|
|
onClick={() => {
|
|
handleAddCamera(cam.name);
|
|
}}
|
|
>
|
|
{cam.displayName}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<Button
|
|
variant="secondary"
|
|
className="size-6 cursor-not-allowed rounded-md bg-muted p-1 text-muted-foreground"
|
|
disabled
|
|
>
|
|
<LuPlus />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
{cameraAreas.map((area, index) => {
|
|
const isSelected = index === selectedCameraIndex;
|
|
const displayName = resolveCameraName(config, area.camera);
|
|
|
|
return (
|
|
<div
|
|
key={area.camera}
|
|
className={`flex items-center justify-between rounded-md p-2 ${
|
|
isSelected
|
|
? "bg-selected/20 ring-1 ring-selected"
|
|
: "hover:bg-secondary/50"
|
|
} cursor-pointer`}
|
|
onClick={() => setSelectedCameraIndex(index)}
|
|
>
|
|
<span className="text-sm capitalize">{displayName}</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveCamera(index);
|
|
}}
|
|
>
|
|
<LuX className="size-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{cameraAreas.length === 0 && (
|
|
<div className="flex flex-1 items-center justify-center text-center text-sm text-muted-foreground">
|
|
{t("wizard.step2.noCameras")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-1 items-center justify-center overflow-hidden rounded-lg p-4">
|
|
<div
|
|
ref={containerRef}
|
|
className="flex items-center justify-center"
|
|
style={{
|
|
width: "100%",
|
|
aspectRatio: "16 / 9",
|
|
maxHeight: "100%",
|
|
}}
|
|
>
|
|
{selectedCamera && selectedCameraConfig && imageSize.width > 0 ? (
|
|
<div
|
|
style={{
|
|
width: imageSize.width,
|
|
height: imageSize.height,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<img
|
|
ref={imageRef}
|
|
src={`${apiHost}api/${selectedCamera.camera}/latest.jpg?h=500`}
|
|
alt={resolveCameraName(config, selectedCamera.camera)}
|
|
className="h-full w-full object-contain"
|
|
onLoad={() => setImageLoaded(true)}
|
|
/>
|
|
<Stage
|
|
ref={stageRef}
|
|
width={imageSize.width}
|
|
height={imageSize.height}
|
|
className="absolute inset-0"
|
|
>
|
|
<Layer>
|
|
<Rect
|
|
ref={rectRef}
|
|
x={selectedCamera.crop[0] * imageSize.width}
|
|
y={selectedCamera.crop[1] * imageSize.height}
|
|
width={
|
|
(selectedCamera.crop[2] - selectedCamera.crop[0]) *
|
|
imageSize.width
|
|
}
|
|
height={
|
|
(selectedCamera.crop[3] - selectedCamera.crop[1]) *
|
|
imageSize.height
|
|
}
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
fill="rgba(59, 130, 246, 0.1)"
|
|
draggable
|
|
dragBoundFunc={(pos) => {
|
|
const rect = rectRef.current;
|
|
if (!rect) return pos;
|
|
|
|
const size = rect.width();
|
|
const x = Math.max(
|
|
0,
|
|
Math.min(pos.x, imageSize.width - size),
|
|
);
|
|
const y = Math.max(
|
|
0,
|
|
Math.min(pos.y, imageSize.height - size),
|
|
);
|
|
|
|
return { x, y };
|
|
}}
|
|
onDragEnd={handleRectChange}
|
|
onTransformEnd={handleRectChange}
|
|
/>
|
|
<Transformer
|
|
ref={transformerRef}
|
|
rotateEnabled={false}
|
|
enabledAnchors={[
|
|
"top-left",
|
|
"top-right",
|
|
"bottom-left",
|
|
"bottom-right",
|
|
]}
|
|
boundBoxFunc={(_oldBox, newBox) => {
|
|
const minSize = 50;
|
|
const maxSize = Math.min(
|
|
imageSize.width,
|
|
imageSize.height,
|
|
);
|
|
|
|
// Clamp dimensions to stage bounds first
|
|
const clampedWidth = Math.max(
|
|
minSize,
|
|
Math.min(newBox.width, maxSize),
|
|
);
|
|
const clampedHeight = Math.max(
|
|
minSize,
|
|
Math.min(newBox.height, maxSize),
|
|
);
|
|
|
|
// Enforce square using average
|
|
const size = (clampedWidth + clampedHeight) / 2;
|
|
|
|
// Clamp position to keep square within bounds
|
|
const x = Math.max(
|
|
0,
|
|
Math.min(newBox.x, imageSize.width - size),
|
|
);
|
|
const y = Math.max(
|
|
0,
|
|
Math.min(newBox.y, imageSize.height - size),
|
|
);
|
|
|
|
return {
|
|
...newBox,
|
|
x,
|
|
y,
|
|
width: size,
|
|
height: size,
|
|
};
|
|
}}
|
|
/>
|
|
</Layer>
|
|
</Stage>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center text-muted-foreground">
|
|
{t("wizard.step2.selectCameraPrompt")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
|
<Button type="button" onClick={onBack} className="sm:flex-1">
|
|
{t("button.back", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handleContinue}
|
|
variant="select"
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
disabled={!canContinue}
|
|
>
|
|
{t("button.continue", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|