mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-05 10:28:51 +03:00
crop motion previews to the selected filter region (#23903)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
When a motion region filter is active, zoom each preview clip into the outer bounds of the selected cells instead of showing the full frame. Tiles take on the aspect ratio of the cropped region, clamped to avoid slivers when the selection is a single row or column, so the grid stays uniform. A "Crop to filter" switch in the preview settings turns this off and restores the previous 16:9 tiles. The transform is applied to a wrapper holding both the media and the dim overlay canvas so the motion heatmap stays registered to the pixels. Fix the region filter grid, which mapped cells onto a hardcoded 16:9 box while the snapshot was letterboxed inside it with object-contain. Heatmap cells are indexed against the detect frame, so on a 4:3 camera every painted cell was off by up to 12.5% of the frame width, and the true left and right edges of the image could only be reached by painting the black bars. The grid box now takes the camera's detect aspect ratio, capped at 65dvh tall so 4:3 and portrait cameras do not overflow the dialog.
This commit is contained in:
@@ -74,7 +74,7 @@
|
|||||||
"menuItem": "View motion previews",
|
"menuItem": "View motion previews",
|
||||||
"title": "Motion previews: {{camera}}",
|
"title": "Motion previews: {{camera}}",
|
||||||
"mobileSettingsTitle": "Motion Preview Settings",
|
"mobileSettingsTitle": "Motion Preview Settings",
|
||||||
"mobileSettingsDesc": "Adjust playback speed and dimming, and choose a date to review motion-only clips.",
|
"mobileSettingsDesc": "Adjust playback speed, dimming, and cropping, and choose a date to review motion-only clips.",
|
||||||
"dim": "Dim",
|
"dim": "Dim",
|
||||||
"dimAria": "Adjust dimming intensity",
|
"dimAria": "Adjust dimming intensity",
|
||||||
"dimDesc": "Increase dimming to increase motion area visibility.",
|
"dimDesc": "Increase dimming to increase motion area visibility.",
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
"seekAria": "Seek {{camera}} player to {{time}}",
|
"seekAria": "Seek {{camera}} player to {{time}}",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"filterDesc": "Select areas to only show clips with motion in those regions.",
|
"filterDesc": "Select areas to only show clips with motion in those regions.",
|
||||||
"filterClear": "Clear"
|
"filterClear": "Clear",
|
||||||
|
"crop": "Crop to filter",
|
||||||
|
"cropAria": "Toggle cropping previews to the filtered areas",
|
||||||
|
"cropDesc": "Zoom previews into the selected filter areas instead of showing the full frame."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { useCallback, useRef } from "react";
|
import { CameraConfig } from "@/types/frigateConfig";
|
||||||
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
|
||||||
const GRID_SIZE = 16;
|
const GRID_SIZE = 16;
|
||||||
|
const DEFAULT_ASPECT_RATIO = 16 / 9;
|
||||||
|
// Cap how tall the grid can get for portrait and 4:3 cameras
|
||||||
|
const MAX_GRID_HEIGHT = "65dvh";
|
||||||
|
|
||||||
type MotionRegionFilterGridProps = {
|
type MotionRegionFilterGridProps = {
|
||||||
cameraName: string;
|
camera: CameraConfig;
|
||||||
selectedCells: Set<number>;
|
selectedCells: Set<number>;
|
||||||
onCellsChange: (cells: Set<number>) => void;
|
onCellsChange: (cells: Set<number>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MotionRegionFilterGrid({
|
export default function MotionRegionFilterGrid({
|
||||||
cameraName,
|
camera,
|
||||||
selectedCells,
|
selectedCells,
|
||||||
onCellsChange,
|
onCellsChange,
|
||||||
}: MotionRegionFilterGridProps) {
|
}: MotionRegionFilterGridProps) {
|
||||||
@@ -21,6 +25,18 @@ export default function MotionRegionFilterGrid({
|
|||||||
const lastCellRef = useRef<number>(-1);
|
const lastCellRef = useRef<number>(-1);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Cells are indexed against the detect frame, so the grid has to match the
|
||||||
|
// frame's aspect ratio or painted cells land on the wrong part of the image
|
||||||
|
const aspectRatio = useMemo(() => {
|
||||||
|
if (!camera.detect.width || !camera.detect.height) {
|
||||||
|
return DEFAULT_ASPECT_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = camera.detect.width / camera.detect.height;
|
||||||
|
|
||||||
|
return Number.isFinite(ratio) && ratio > 0 ? ratio : DEFAULT_ASPECT_RATIO;
|
||||||
|
}, [camera.detect.height, camera.detect.width]);
|
||||||
|
|
||||||
const toggleCell = useCallback(
|
const toggleCell = useCallback(
|
||||||
(index: number, forceAdd?: boolean) => {
|
(index: number, forceAdd?: boolean) => {
|
||||||
const next = new Set(selectedCells);
|
const next = new Set(selectedCells);
|
||||||
@@ -109,13 +125,17 @@ export default function MotionRegionFilterGrid({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div
|
<div
|
||||||
className="relative aspect-video w-full select-none overflow-hidden rounded-lg"
|
className="relative mx-auto select-none overflow-hidden rounded-lg"
|
||||||
style={{ touchAction: "none" }}
|
style={{
|
||||||
|
aspectRatio,
|
||||||
|
width: `min(100%, calc(${MAX_GRID_HEIGHT} * ${aspectRatio}))`,
|
||||||
|
touchAction: "none",
|
||||||
|
}}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerLeave={handlePointerUp}
|
onPointerLeave={handlePointerUp}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${baseUrl}api/${cameraName}/latest.jpg?h=500`}
|
src={`${baseUrl}api/${camera.name}/latest.jpg?h=500`}
|
||||||
className="absolute inset-0 size-full object-contain"
|
className="absolute inset-0 size-full object-contain"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
|||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { VolumeSlider } from "@/components/ui/slider";
|
import { VolumeSlider } from "@/components/ui/slider";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -1154,6 +1156,7 @@ function MotionReview({
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
||||||
|
const [cropToFilter, setCropToFilter] = useState(true);
|
||||||
|
|
||||||
// reset filter when camera changes
|
// reset filter when camera changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1393,7 +1396,7 @@ function MotionReview({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<MotionRegionFilterGrid
|
<MotionRegionFilterGrid
|
||||||
cameraName={selectedMotionPreviewCamera.name}
|
camera={selectedMotionPreviewCamera}
|
||||||
selectedCells={pendingFilterCells}
|
selectedCells={pendingFilterCells}
|
||||||
onCellsChange={setPendingFilterCells}
|
onCellsChange={setPendingFilterCells}
|
||||||
/>
|
/>
|
||||||
@@ -1448,7 +1451,12 @@ function MotionReview({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div>{t("motionPreviews.speed")}</div>
|
<Label
|
||||||
|
className="cursor-pointer"
|
||||||
|
htmlFor="motionPreviewSpeed"
|
||||||
|
>
|
||||||
|
{t("motionPreviews.speed")}
|
||||||
|
</Label>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{t("motionPreviews.speedDesc")}
|
{t("motionPreviews.speedDesc")}
|
||||||
</div>
|
</div>
|
||||||
@@ -1460,6 +1468,7 @@ function MotionReview({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
|
id="motionPreviewSpeed"
|
||||||
className="h-10 w-full"
|
className="h-10 w-full"
|
||||||
aria-label={t("motionPreviews.speedAria")}
|
aria-label={t("motionPreviews.speedAria")}
|
||||||
>
|
>
|
||||||
@@ -1477,7 +1486,7 @@ function MotionReview({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div>{t("motionPreviews.dim")}</div>
|
<Label>{t("motionPreviews.dim")}</Label>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{t("motionPreviews.dimDesc")}
|
{t("motionPreviews.dimDesc")}
|
||||||
</div>
|
</div>
|
||||||
@@ -1502,6 +1511,26 @@ function MotionReview({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label
|
||||||
|
className="cursor-pointer"
|
||||||
|
htmlFor="cropToFilter"
|
||||||
|
>
|
||||||
|
{t("motionPreviews.crop")}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="cropToFilter"
|
||||||
|
checked={cropToFilter}
|
||||||
|
onCheckedChange={setCropToFilter}
|
||||||
|
aria-label={t("motionPreviews.cropAria")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("motionPreviews.cropDesc")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isDesktop && (
|
{!isDesktop && (
|
||||||
<>
|
<>
|
||||||
<SelectSeparator />
|
<SelectSeparator />
|
||||||
@@ -1557,6 +1586,7 @@ function MotionReview({
|
|||||||
playbackRate={playbackRate}
|
playbackRate={playbackRate}
|
||||||
nonMotionAlpha={dimStrength / 100}
|
nonMotionAlpha={dimStrength / 100}
|
||||||
motionFilterCells={motionFilterCells}
|
motionFilterCells={motionFilterCells}
|
||||||
|
cropToFilter={cropToFilter}
|
||||||
onSeek={(timestamp) => {
|
onSeek={(timestamp) => {
|
||||||
onOpenRecording({
|
onOpenRecording({
|
||||||
camera: selectedMotionPreviewCamera.name,
|
camera: selectedMotionPreviewCamera.name,
|
||||||
|
|||||||
@@ -24,6 +24,94 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
|
|
||||||
const MOTION_HEATMAP_GRID_SIZE = 16;
|
const MOTION_HEATMAP_GRID_SIZE = 16;
|
||||||
const MIN_MOTION_CELL_ALPHA = 0.06;
|
const MIN_MOTION_CELL_ALPHA = 0.06;
|
||||||
|
const DEFAULT_TILE_ASPECT_RATIO = 16 / 9;
|
||||||
|
// Keep cropped tiles from collapsing into unusable slivers when the selection
|
||||||
|
// is a single row or column
|
||||||
|
const MIN_CROP_TILE_ASPECT_RATIO = 0.75;
|
||||||
|
const MAX_CROP_TILE_ASPECT_RATIO = 4;
|
||||||
|
|
||||||
|
type CropRegion = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaRect = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCropRegionForCells(cells?: Set<number>): CropRegion | undefined {
|
||||||
|
if (!cells || cells.size === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minRow = MOTION_HEATMAP_GRID_SIZE;
|
||||||
|
let maxRow = -1;
|
||||||
|
let minCol = MOTION_HEATMAP_GRID_SIZE;
|
||||||
|
let maxCol = -1;
|
||||||
|
|
||||||
|
cells.forEach((cellIndex) => {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(cellIndex) ||
|
||||||
|
cellIndex < 0 ||
|
||||||
|
cellIndex >= MOTION_HEATMAP_GRID_SIZE ** 2
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = Math.floor(cellIndex / MOTION_HEATMAP_GRID_SIZE);
|
||||||
|
const col = cellIndex % MOTION_HEATMAP_GRID_SIZE;
|
||||||
|
|
||||||
|
minRow = Math.min(minRow, row);
|
||||||
|
maxRow = Math.max(maxRow, row);
|
||||||
|
minCol = Math.min(minCol, col);
|
||||||
|
maxCol = Math.max(maxCol, col);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (maxRow < 0 || maxCol < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minCol / MOTION_HEATMAP_GRID_SIZE,
|
||||||
|
y: minRow / MOTION_HEATMAP_GRID_SIZE,
|
||||||
|
width: (maxCol - minCol + 1) / MOTION_HEATMAP_GRID_SIZE,
|
||||||
|
height: (maxRow - minRow + 1) / MOTION_HEATMAP_GRID_SIZE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendered area of object-contain media inside its container, accounting for
|
||||||
|
// letterboxing on whichever axis has slack
|
||||||
|
function getContainedMediaRect(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
mediaDimensions: { width: number; height: number } | null,
|
||||||
|
): MediaRect {
|
||||||
|
if (
|
||||||
|
!mediaDimensions ||
|
||||||
|
mediaDimensions.width <= 0 ||
|
||||||
|
mediaDimensions.height <= 0
|
||||||
|
) {
|
||||||
|
return { x: 0, y: 0, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerAspect = width / height;
|
||||||
|
const mediaAspect = mediaDimensions.width / mediaDimensions.height;
|
||||||
|
|
||||||
|
if (mediaAspect < containerAspect) {
|
||||||
|
// Portrait / tall: constrained by height, bars on left and right
|
||||||
|
const drawWidth = height * mediaAspect;
|
||||||
|
return { x: (width - drawWidth) / 2, y: 0, width: drawWidth, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wide / landscape: constrained by width, bars on top and bottom
|
||||||
|
const drawHeight = width / mediaAspect;
|
||||||
|
return { x: 0, y: (height - drawHeight) / 2, width, height: drawHeight };
|
||||||
|
}
|
||||||
|
|
||||||
function getPreviewForMotionRange(
|
function getPreviewForMotionRange(
|
||||||
cameraPreviews: Preview[],
|
cameraPreviews: Preview[],
|
||||||
@@ -132,6 +220,8 @@ type MotionPreviewClipProps = {
|
|||||||
fallbackFrameTimes?: number[];
|
fallbackFrameTimes?: number[];
|
||||||
motionHeatmap?: Record<string, number> | null;
|
motionHeatmap?: Record<string, number> | null;
|
||||||
nonMotionAlpha: number;
|
nonMotionAlpha: number;
|
||||||
|
cropRegion?: CropRegion;
|
||||||
|
aspectRatio: number;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
onSeek: (timestamp: number) => void;
|
onSeek: (timestamp: number) => void;
|
||||||
};
|
};
|
||||||
@@ -144,6 +234,8 @@ function MotionPreviewClip({
|
|||||||
fallbackFrameTimes,
|
fallbackFrameTimes,
|
||||||
motionHeatmap,
|
motionHeatmap,
|
||||||
nonMotionAlpha,
|
nonMotionAlpha,
|
||||||
|
cropRegion,
|
||||||
|
aspectRatio,
|
||||||
isVisible,
|
isVisible,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: MotionPreviewClipProps) {
|
}: MotionPreviewClipProps) {
|
||||||
@@ -398,34 +490,12 @@ function MotionPreviewClip({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the actual rendered media area (object-contain letterboxing)
|
const {
|
||||||
let drawX = 0;
|
x: drawX,
|
||||||
let drawY = 0;
|
y: drawY,
|
||||||
let drawWidth = width;
|
width: drawWidth,
|
||||||
let drawHeight = height;
|
height: drawHeight,
|
||||||
|
} = getContainedMediaRect(width, height, mediaDimensions);
|
||||||
if (
|
|
||||||
mediaDimensions &&
|
|
||||||
mediaDimensions.width > 0 &&
|
|
||||||
mediaDimensions.height > 0
|
|
||||||
) {
|
|
||||||
const containerAspect = width / height;
|
|
||||||
const mediaAspect = mediaDimensions.width / mediaDimensions.height;
|
|
||||||
|
|
||||||
if (mediaAspect < containerAspect) {
|
|
||||||
// Portrait / tall: constrained by height, bars on left and right
|
|
||||||
drawHeight = height;
|
|
||||||
drawWidth = height * mediaAspect;
|
|
||||||
drawX = (width - drawWidth) / 2;
|
|
||||||
drawY = 0;
|
|
||||||
} else {
|
|
||||||
// Wide / landscape: constrained by width, bars on top and bottom
|
|
||||||
drawWidth = width;
|
|
||||||
drawHeight = width / mediaAspect;
|
|
||||||
drawX = 0;
|
|
||||||
drawY = (height - drawHeight) / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const heatmapLevels = Object.values(motionHeatmap)
|
const heatmapLevels = Object.values(motionHeatmap)
|
||||||
.map((value) => Number(value))
|
.map((value) => Number(value))
|
||||||
@@ -484,17 +554,53 @@ function MotionPreviewClip({
|
|||||||
drawDimOverlay();
|
drawDimOverlay();
|
||||||
}, [drawDimOverlay]);
|
}, [drawDimOverlay]);
|
||||||
|
|
||||||
|
// Zoom the media (and its dim overlay) so the filtered region fills the tile
|
||||||
|
const mediaCropStyle = useMemo(() => {
|
||||||
|
if (!cropRegion || overlayWidth <= 0 || overlayHeight <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaRect = getContainedMediaRect(
|
||||||
|
overlayWidth,
|
||||||
|
overlayHeight,
|
||||||
|
mediaDimensions,
|
||||||
|
);
|
||||||
|
const cropWidth = cropRegion.width * mediaRect.width;
|
||||||
|
const cropHeight = cropRegion.height * mediaRect.height;
|
||||||
|
|
||||||
|
if (cropWidth <= 0 || cropHeight <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cropCenterX =
|
||||||
|
mediaRect.x + (cropRegion.x + cropRegion.width / 2) * mediaRect.width;
|
||||||
|
const cropCenterY =
|
||||||
|
mediaRect.y + (cropRegion.y + cropRegion.height / 2) * mediaRect.height;
|
||||||
|
const scale = Math.min(
|
||||||
|
overlayWidth / cropWidth,
|
||||||
|
overlayHeight / cropHeight,
|
||||||
|
);
|
||||||
|
const translateX = overlayWidth / 2 - scale * cropCenterX;
|
||||||
|
const translateY = overlayHeight / 2 - scale * cropCenterY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
|
||||||
|
transformOrigin: "0 0",
|
||||||
|
};
|
||||||
|
}, [cropRegion, mediaDimensions, overlayHeight, overlayWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayContainerRef}
|
ref={overlayContainerRef}
|
||||||
className="relative aspect-video size-full cursor-pointer overflow-hidden rounded-lg bg-black md:rounded-2xl"
|
className="relative size-full cursor-pointer overflow-hidden rounded-lg bg-black md:rounded-2xl"
|
||||||
|
style={{ aspectRatio }}
|
||||||
onClick={() => onSeek(range.start_time)}
|
onClick={() => onSeek(range.start_time)}
|
||||||
>
|
>
|
||||||
{showLoadingIndicator && (
|
{showLoadingIndicator && (
|
||||||
<Skeleton className="absolute inset-0 z-10 rounded-lg md:rounded-2xl" />
|
<Skeleton className="absolute inset-0 z-10 rounded-lg md:rounded-2xl" />
|
||||||
)}
|
)}
|
||||||
{preview && isVisible ? (
|
{preview && isVisible ? (
|
||||||
<>
|
<div className="absolute inset-0" style={mediaCropStyle}>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="size-full bg-black object-contain"
|
className="size-full bg-black object-contain"
|
||||||
@@ -548,9 +654,9 @@ function MotionPreviewClip({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
) : fallbackFrameSrc ? (
|
) : fallbackFrameSrc ? (
|
||||||
<>
|
<div className="absolute inset-0" style={mediaCropStyle}>
|
||||||
<img
|
<img
|
||||||
src={fallbackFrameSrc}
|
src={fallbackFrameSrc}
|
||||||
className="size-full bg-black object-contain"
|
className="size-full bg-black object-contain"
|
||||||
@@ -575,7 +681,7 @@ function MotionPreviewClip({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
|
||||||
{t("motionPreviews.noPreview")}
|
{t("motionPreviews.noPreview")}
|
||||||
@@ -605,6 +711,7 @@ type MotionPreviewsPaneProps = {
|
|||||||
playbackRate: number;
|
playbackRate: number;
|
||||||
nonMotionAlpha: number;
|
nonMotionAlpha: number;
|
||||||
motionFilterCells?: Set<number>;
|
motionFilterCells?: Set<number>;
|
||||||
|
cropToFilter?: boolean;
|
||||||
onSeek: (timestamp: number) => void;
|
onSeek: (timestamp: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -617,6 +724,7 @@ export default function MotionPreviewsPane({
|
|||||||
playbackRate,
|
playbackRate,
|
||||||
nonMotionAlpha,
|
nonMotionAlpha,
|
||||||
motionFilterCells,
|
motionFilterCells,
|
||||||
|
cropToFilter = true,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: MotionPreviewsPaneProps) {
|
}: MotionPreviewsPaneProps) {
|
||||||
const { t } = useTranslation(["views/events"]);
|
const { t } = useTranslation(["views/events"]);
|
||||||
@@ -916,6 +1024,35 @@ export default function MotionPreviewsPane({
|
|||||||
});
|
});
|
||||||
}, [clipData, motionFilterCells]);
|
}, [clipData, motionFilterCells]);
|
||||||
|
|
||||||
|
const cropRegion = useMemo(
|
||||||
|
() => (cropToFilter ? getCropRegionForCells(motionFilterCells) : undefined),
|
||||||
|
[cropToFilter, motionFilterCells],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every clip shares the same crop, so tiles stay uniform while matching the
|
||||||
|
// shape of the selected region instead of letterboxing it into 16:9
|
||||||
|
const tileAspectRatio = useMemo(() => {
|
||||||
|
if (!cropRegion) {
|
||||||
|
return DEFAULT_TILE_ASPECT_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraAspect =
|
||||||
|
camera.detect.width && camera.detect.height
|
||||||
|
? camera.detect.width / camera.detect.height
|
||||||
|
: DEFAULT_TILE_ASPECT_RATIO;
|
||||||
|
|
||||||
|
const croppedAspect = cameraAspect * (cropRegion.width / cropRegion.height);
|
||||||
|
|
||||||
|
if (!Number.isFinite(croppedAspect) || croppedAspect <= 0) {
|
||||||
|
return DEFAULT_TILE_ASPECT_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
MAX_CROP_TILE_ASPECT_RATIO,
|
||||||
|
Math.max(MIN_CROP_TILE_ASPECT_RATIO, croppedAspect),
|
||||||
|
);
|
||||||
|
}, [camera.detect.height, camera.detect.width, cropRegion]);
|
||||||
|
|
||||||
const hasCurrentHourRanges = useMemo(
|
const hasCurrentHourRanges = useMemo(
|
||||||
() => motionRanges.some((range) => isCurrentHour(range.end_time)),
|
() => motionRanges.some((range) => isCurrentHour(range.end_time)),
|
||||||
[motionRanges],
|
[motionRanges],
|
||||||
@@ -964,6 +1101,8 @@ export default function MotionPreviewsPane({
|
|||||||
fallbackFrameTimes={fallbackFrameTimes}
|
fallbackFrameTimes={fallbackFrameTimes}
|
||||||
motionHeatmap={motionHeatmap}
|
motionHeatmap={motionHeatmap}
|
||||||
nonMotionAlpha={nonMotionAlpha}
|
nonMotionAlpha={nonMotionAlpha}
|
||||||
|
cropRegion={cropRegion}
|
||||||
|
aspectRatio={tileAspectRatio}
|
||||||
isVisible={
|
isVisible={
|
||||||
windowVisible &&
|
windowVisible &&
|
||||||
(visibleClips.includes(clipId) ||
|
(visibleClips.includes(clipId) ||
|
||||||
@@ -972,7 +1111,10 @@ export default function MotionPreviewsPane({
|
|||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
|
<div
|
||||||
|
className="rounded-lg bg-black md:rounded-2xl"
|
||||||
|
style={{ aspectRatio: tileAspectRatio }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user