From 33c00a27e4bac8b8d276a6bf6f004570bedd3b5c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:07:06 -0500 Subject: [PATCH] crop motion previews to the selected filter region (#23903) 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. --- web/public/locales/en/views/events.json | 7 +- .../filter/MotionRegionFilterGrid.tsx | 32 ++- web/src/views/events/EventView.tsx | 36 ++- web/src/views/events/MotionPreviewsPane.tsx | 210 +++++++++++++++--- 4 files changed, 240 insertions(+), 45 deletions(-) diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index f25e3022c5..f895d8f5e6 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -74,7 +74,7 @@ "menuItem": "View motion previews", "title": "Motion previews: {{camera}}", "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", "dimAria": "Adjust dimming intensity", "dimDesc": "Increase dimming to increase motion area visibility.", @@ -87,6 +87,9 @@ "seekAria": "Seek {{camera}} player to {{time}}", "filter": "Filter", "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." } } diff --git a/web/src/components/filter/MotionRegionFilterGrid.tsx b/web/src/components/filter/MotionRegionFilterGrid.tsx index 86457d07be..0fa9e0bdb8 100644 --- a/web/src/components/filter/MotionRegionFilterGrid.tsx +++ b/web/src/components/filter/MotionRegionFilterGrid.tsx @@ -1,16 +1,20 @@ 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 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 = { - cameraName: string; + camera: CameraConfig; selectedCells: Set; onCellsChange: (cells: Set) => void; }; export default function MotionRegionFilterGrid({ - cameraName, + camera, selectedCells, onCellsChange, }: MotionRegionFilterGridProps) { @@ -21,6 +25,18 @@ export default function MotionRegionFilterGrid({ const lastCellRef = useRef(-1); const gridRef = useRef(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( (index: number, forceAdd?: boolean) => { const next = new Set(selectedCells); @@ -109,13 +125,17 @@ export default function MotionRegionFilterGrid({ return (
{ @@ -1393,7 +1396,7 @@ function MotionReview({ @@ -1448,7 +1451,12 @@ function MotionReview({
-
{t("motionPreviews.speed")}
+
{t("motionPreviews.speedDesc")}
@@ -1460,6 +1468,7 @@ function MotionReview({ } > @@ -1477,7 +1486,7 @@ function MotionReview({
-
{t("motionPreviews.dim")}
+
{t("motionPreviews.dimDesc")}
@@ -1502,6 +1511,26 @@ function MotionReview({
+
+
+ + +
+
+ {t("motionPreviews.cropDesc")} +
+
+ {!isDesktop && ( <> @@ -1557,6 +1586,7 @@ function MotionReview({ playbackRate={playbackRate} nonMotionAlpha={dimStrength / 100} motionFilterCells={motionFilterCells} + cropToFilter={cropToFilter} onSeek={(timestamp) => { onOpenRecording({ camera: selectedMotionPreviewCamera.name, diff --git a/web/src/views/events/MotionPreviewsPane.tsx b/web/src/views/events/MotionPreviewsPane.tsx index 913548139c..3fcc3500be 100644 --- a/web/src/views/events/MotionPreviewsPane.tsx +++ b/web/src/views/events/MotionPreviewsPane.tsx @@ -24,6 +24,94 @@ import { FrigateConfig } from "@/types/frigateConfig"; const MOTION_HEATMAP_GRID_SIZE = 16; 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): 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( cameraPreviews: Preview[], @@ -132,6 +220,8 @@ type MotionPreviewClipProps = { fallbackFrameTimes?: number[]; motionHeatmap?: Record | null; nonMotionAlpha: number; + cropRegion?: CropRegion; + aspectRatio: number; isVisible: boolean; onSeek: (timestamp: number) => void; }; @@ -144,6 +234,8 @@ function MotionPreviewClip({ fallbackFrameTimes, motionHeatmap, nonMotionAlpha, + cropRegion, + aspectRatio, isVisible, onSeek, }: MotionPreviewClipProps) { @@ -398,34 +490,12 @@ function MotionPreviewClip({ return; } - // Calculate the actual rendered media area (object-contain letterboxing) - let drawX = 0; - let drawY = 0; - let drawWidth = width; - let drawHeight = height; - - 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 { + x: drawX, + y: drawY, + width: drawWidth, + height: drawHeight, + } = getContainedMediaRect(width, height, mediaDimensions); const heatmapLevels = Object.values(motionHeatmap) .map((value) => Number(value)) @@ -484,17 +554,53 @@ function MotionPreviewClip({ 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 (
onSeek(range.start_time)} > {showLoadingIndicator && ( )} {preview && isVisible ? ( - <> +
) : fallbackFrameSrc ? ( - <> +
)} - +
) : (
{t("motionPreviews.noPreview")} @@ -605,6 +711,7 @@ type MotionPreviewsPaneProps = { playbackRate: number; nonMotionAlpha: number; motionFilterCells?: Set; + cropToFilter?: boolean; onSeek: (timestamp: number) => void; }; @@ -617,6 +724,7 @@ export default function MotionPreviewsPane({ playbackRate, nonMotionAlpha, motionFilterCells, + cropToFilter = true, onSeek, }: MotionPreviewsPaneProps) { const { t } = useTranslation(["views/events"]); @@ -916,6 +1024,35 @@ export default function MotionPreviewsPane({ }); }, [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( () => motionRanges.some((range) => isCurrentHour(range.end_time)), [motionRanges], @@ -964,6 +1101,8 @@ export default function MotionPreviewsPane({ fallbackFrameTimes={fallbackFrameTimes} motionHeatmap={motionHeatmap} nonMotionAlpha={nonMotionAlpha} + cropRegion={cropRegion} + aspectRatio={tileAspectRatio} isVisible={ windowVisible && (visibleClips.includes(clipId) || @@ -972,7 +1111,10 @@ export default function MotionPreviewsPane({ onSeek={onSeek} /> ) : ( -
+
)}
);