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} /> ) : ( -
+
)}
);