mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-11 05:11:13 +03:00
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
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.
1129 lines
31 KiB
TypeScript
1129 lines
31 KiB
TypeScript
import { MotionOnlyRange } from "@/hooks/use-camera-activity";
|
|
import { Preview } from "@/types/preview";
|
|
import {
|
|
MutableRefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { isCurrentHour } from "@/utils/dateUtil";
|
|
import { isFirefox, isMobile, isSafari } from "react-device-detect";
|
|
import { useTranslation } from "react-i18next";
|
|
import { CameraConfig } from "@/types/frigateConfig";
|
|
import useSWR from "swr";
|
|
import { baseUrl } from "@/api/baseUrl";
|
|
import { Recording } from "@/types/record";
|
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import TimeAgo from "@/components/dynamic/TimeAgo";
|
|
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
|
|
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<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(
|
|
cameraPreviews: Preview[],
|
|
cameraName: string,
|
|
range: MotionOnlyRange,
|
|
) {
|
|
const matchingPreviews = cameraPreviews.filter(
|
|
(preview) =>
|
|
preview.camera === cameraName &&
|
|
preview.end > range.start_time &&
|
|
preview.start < range.end_time,
|
|
);
|
|
|
|
if (!matchingPreviews.length) {
|
|
return;
|
|
}
|
|
|
|
const getOverlap = (preview: Preview) =>
|
|
Math.max(
|
|
0,
|
|
Math.min(preview.end, range.end_time) -
|
|
Math.max(preview.start, range.start_time),
|
|
);
|
|
|
|
return matchingPreviews.reduce((best, current) => {
|
|
return getOverlap(current) > getOverlap(best) ? current : best;
|
|
});
|
|
}
|
|
|
|
function getRangeOverlapSeconds(
|
|
rangeStart: number,
|
|
rangeEnd: number,
|
|
recordingStart: number,
|
|
recordingEnd: number,
|
|
) {
|
|
return Math.max(
|
|
0,
|
|
Math.min(rangeEnd, recordingEnd) - Math.max(rangeStart, recordingStart),
|
|
);
|
|
}
|
|
|
|
function getMotionHeatmapForRange(
|
|
recordings: Recording[],
|
|
range: MotionOnlyRange,
|
|
) {
|
|
const weightedHeatmap = new Map<number, number>();
|
|
let totalWeight = 0;
|
|
|
|
recordings.forEach((recording) => {
|
|
const overlapSeconds = getRangeOverlapSeconds(
|
|
range.start_time,
|
|
range.end_time,
|
|
recording.start_time,
|
|
recording.end_time,
|
|
);
|
|
|
|
if (overlapSeconds <= 0) {
|
|
return;
|
|
}
|
|
|
|
totalWeight += overlapSeconds;
|
|
|
|
if (!recording.motion_heatmap) {
|
|
return;
|
|
}
|
|
|
|
Object.entries(recording.motion_heatmap).forEach(
|
|
([cellIndex, intensity]) => {
|
|
const index = Number(cellIndex);
|
|
const level = Number(intensity);
|
|
|
|
if (Number.isNaN(index) || Number.isNaN(level) || level <= 0) {
|
|
return;
|
|
}
|
|
|
|
const existingWeight = weightedHeatmap.get(index) ?? 0;
|
|
weightedHeatmap.set(index, existingWeight + level * overlapSeconds);
|
|
},
|
|
);
|
|
});
|
|
|
|
if (!totalWeight || weightedHeatmap.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
const mergedHeatmap: Record<string, number> = {};
|
|
weightedHeatmap.forEach((weightedLevel, index) => {
|
|
const normalizedLevel = Math.max(
|
|
0,
|
|
Math.min(255, Math.round(weightedLevel / totalWeight)),
|
|
);
|
|
|
|
if (normalizedLevel > 0) {
|
|
mergedHeatmap[index.toString()] = normalizedLevel;
|
|
}
|
|
});
|
|
|
|
return Object.keys(mergedHeatmap).length > 0 ? mergedHeatmap : null;
|
|
}
|
|
|
|
type MotionPreviewClipProps = {
|
|
cameraName: string;
|
|
range: MotionOnlyRange;
|
|
playbackRate: number;
|
|
preview?: Preview;
|
|
fallbackFrameTimes?: number[];
|
|
motionHeatmap?: Record<string, number> | null;
|
|
nonMotionAlpha: number;
|
|
cropRegion?: CropRegion;
|
|
aspectRatio: number;
|
|
isVisible: boolean;
|
|
onSeek: (timestamp: number) => void;
|
|
};
|
|
|
|
function MotionPreviewClip({
|
|
cameraName,
|
|
range,
|
|
playbackRate,
|
|
preview,
|
|
fallbackFrameTimes,
|
|
motionHeatmap,
|
|
nonMotionAlpha,
|
|
cropRegion,
|
|
aspectRatio,
|
|
isVisible,
|
|
onSeek,
|
|
}: MotionPreviewClipProps) {
|
|
const { t } = useTranslation(["views/events", "common"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
const dimOverlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const overlayContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const [{ width: overlayWidth, height: overlayHeight }] =
|
|
useResizeObserver(overlayContainerRef);
|
|
const [videoLoaded, setVideoLoaded] = useState(false);
|
|
const [videoPlaying, setVideoPlaying] = useState(false);
|
|
const [fallbackImageLoaded, setFallbackImageLoaded] = useState(false);
|
|
const [mediaDimensions, setMediaDimensions] = useState<{
|
|
width: number;
|
|
height: number;
|
|
} | null>(null);
|
|
|
|
const [fallbackFrameIndex, setFallbackFrameIndex] = useState(0);
|
|
const [fallbackFramesReady, setFallbackFramesReady] = useState(false);
|
|
|
|
const is24Hour = use24HourTime(config);
|
|
const formattedDate = useFormattedTimestamp(
|
|
range.start_time,
|
|
is24Hour
|
|
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
|
ns: "common",
|
|
})
|
|
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
|
|
ns: "common",
|
|
}),
|
|
config?.ui.timezone,
|
|
);
|
|
const fallbackFrameSrcs = useMemo(() => {
|
|
if (!fallbackFrameTimes || fallbackFrameTimes.length === 0) {
|
|
return [] as string[];
|
|
}
|
|
|
|
return fallbackFrameTimes.map(
|
|
(frameTime) =>
|
|
`${baseUrl}api/preview/preview_${cameraName}-${frameTime}.webp/thumbnail.webp`,
|
|
);
|
|
}, [cameraName, fallbackFrameTimes]);
|
|
|
|
useEffect(() => {
|
|
setFallbackFrameIndex(0);
|
|
setFallbackFramesReady(false);
|
|
}, [range.start_time, range.end_time, fallbackFrameTimes]);
|
|
|
|
useEffect(() => {
|
|
if (fallbackFrameSrcs.length === 0) {
|
|
setFallbackFramesReady(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const preloadFrames = async () => {
|
|
await Promise.allSettled(
|
|
fallbackFrameSrcs.map(
|
|
(src) =>
|
|
new Promise<void>((resolve) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve();
|
|
image.onerror = () => resolve();
|
|
image.src = src;
|
|
}),
|
|
),
|
|
);
|
|
|
|
if (!cancelled) {
|
|
setFallbackFramesReady(true);
|
|
}
|
|
};
|
|
|
|
void preloadFrames();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [fallbackFrameSrcs]);
|
|
|
|
useEffect(() => {
|
|
if (!fallbackFramesReady || fallbackFrameSrcs.length <= 1 || !isVisible) {
|
|
return;
|
|
}
|
|
|
|
const intervalMs = Math.max(
|
|
50,
|
|
Math.round(1000 / Math.max(1, playbackRate)),
|
|
);
|
|
const intervalId = window.setInterval(() => {
|
|
setFallbackFrameIndex((previous) => {
|
|
return (previous + 1) % fallbackFrameSrcs.length;
|
|
});
|
|
}, intervalMs);
|
|
|
|
return () => {
|
|
window.clearInterval(intervalId);
|
|
};
|
|
}, [fallbackFrameSrcs.length, fallbackFramesReady, isVisible, playbackRate]);
|
|
|
|
const fallbackFrameSrc = useMemo(() => {
|
|
if (fallbackFrameSrcs.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return fallbackFrameSrcs[fallbackFrameIndex] ?? fallbackFrameSrcs[0];
|
|
}, [fallbackFrameIndex, fallbackFrameSrcs]);
|
|
|
|
useEffect(() => {
|
|
setVideoLoaded(false);
|
|
setVideoPlaying(false);
|
|
setMediaDimensions(null);
|
|
}, [preview?.src]);
|
|
|
|
useEffect(() => {
|
|
if (!preview || !isVisible || videoLoaded || !videoRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (videoRef.current.currentSrc || videoRef.current.error) {
|
|
setVideoLoaded(true);
|
|
}
|
|
}, [isVisible, preview, videoLoaded]);
|
|
|
|
useEffect(() => {
|
|
setFallbackImageLoaded(false);
|
|
setMediaDimensions(null);
|
|
}, [fallbackFrameSrcs]);
|
|
|
|
useEffect(() => {
|
|
if (!fallbackFrameSrc || !isVisible || !fallbackFramesReady) {
|
|
return;
|
|
}
|
|
|
|
setFallbackImageLoaded(true);
|
|
}, [fallbackFrameSrc, fallbackFramesReady, isVisible]);
|
|
|
|
const showLoadingIndicator =
|
|
(preview != undefined && isVisible && !videoPlaying) ||
|
|
(fallbackFrameSrc != undefined && isVisible && !fallbackImageLoaded);
|
|
|
|
const clipStart = useMemo(() => {
|
|
if (!preview) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.max(0, range.start_time - preview.start);
|
|
}, [preview, range.start_time]);
|
|
|
|
const clipEnd = useMemo(() => {
|
|
if (!preview) {
|
|
return 0;
|
|
}
|
|
|
|
const previewDuration = preview.end - preview.start;
|
|
return Math.min(
|
|
previewDuration,
|
|
Math.max(clipStart + 0.1, range.end_time - preview.start),
|
|
);
|
|
}, [clipStart, preview, range.end_time]);
|
|
|
|
const compatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (compatIntervalRef.current) {
|
|
clearInterval(compatIntervalRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const resetPlayback = useCallback(() => {
|
|
if (!videoRef.current || !preview) {
|
|
return;
|
|
}
|
|
|
|
if (compatIntervalRef.current) {
|
|
clearInterval(compatIntervalRef.current);
|
|
compatIntervalRef.current = null;
|
|
}
|
|
|
|
videoRef.current.currentTime = clipStart;
|
|
|
|
if (isSafari || (isFirefox && isMobile)) {
|
|
// Safari / iOS can't play at speeds > 2x, so manually step through frames
|
|
videoRef.current.pause();
|
|
compatIntervalRef.current = setInterval(() => {
|
|
if (!videoRef.current) {
|
|
return;
|
|
}
|
|
|
|
videoRef.current.currentTime += 1;
|
|
|
|
if (videoRef.current.currentTime >= clipEnd) {
|
|
videoRef.current.currentTime = clipStart;
|
|
}
|
|
}, 1000 / playbackRate);
|
|
} else {
|
|
videoRef.current.playbackRate = playbackRate;
|
|
}
|
|
}, [clipStart, clipEnd, playbackRate, preview]);
|
|
|
|
useEffect(() => {
|
|
if (!videoRef.current || !preview || !videoPlaying) {
|
|
return;
|
|
}
|
|
|
|
if (isSafari || (isFirefox && isMobile)) {
|
|
// These browsers step frames manually; rebuild the interval at the new rate
|
|
resetPlayback();
|
|
} else {
|
|
videoRef.current.playbackRate = playbackRate;
|
|
}
|
|
}, [playbackRate, preview, videoPlaying, resetPlayback]);
|
|
|
|
const drawDimOverlay = useCallback(() => {
|
|
if (!dimOverlayCanvasRef.current) {
|
|
return;
|
|
}
|
|
|
|
const canvas = dimOverlayCanvasRef.current;
|
|
const context = canvas.getContext("2d");
|
|
|
|
if (!context) {
|
|
return;
|
|
}
|
|
|
|
if (overlayWidth <= 0 || overlayHeight <= 0) {
|
|
return;
|
|
}
|
|
|
|
const width = Math.max(1, overlayWidth);
|
|
const height = Math.max(1, overlayHeight);
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const pixelWidth = Math.max(1, Math.round(width * dpr));
|
|
const pixelHeight = Math.max(1, Math.round(height * dpr));
|
|
|
|
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
|
canvas.width = pixelWidth;
|
|
canvas.height = pixelHeight;
|
|
}
|
|
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
|
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
context.clearRect(0, 0, width, height);
|
|
|
|
if (!motionHeatmap) {
|
|
return;
|
|
}
|
|
|
|
const {
|
|
x: drawX,
|
|
y: drawY,
|
|
width: drawWidth,
|
|
height: drawHeight,
|
|
} = getContainedMediaRect(width, height, mediaDimensions);
|
|
|
|
const heatmapLevels = Object.values(motionHeatmap)
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value > 0);
|
|
|
|
const maxHeatmapLevel =
|
|
heatmapLevels.length > 0 ? Math.max(...heatmapLevels) : 0;
|
|
|
|
const maskCanvas = document.createElement("canvas");
|
|
maskCanvas.width = MOTION_HEATMAP_GRID_SIZE;
|
|
maskCanvas.height = MOTION_HEATMAP_GRID_SIZE;
|
|
|
|
const maskContext = maskCanvas.getContext("2d");
|
|
if (!maskContext) {
|
|
return;
|
|
}
|
|
|
|
const imageData = maskContext.createImageData(
|
|
MOTION_HEATMAP_GRID_SIZE,
|
|
MOTION_HEATMAP_GRID_SIZE,
|
|
);
|
|
|
|
for (let index = 0; index < MOTION_HEATMAP_GRID_SIZE ** 2; index++) {
|
|
const level = Number(motionHeatmap[index.toString()] ?? 0);
|
|
const normalizedLevel =
|
|
maxHeatmapLevel > 0
|
|
? Math.min(1, Math.max(0, level / maxHeatmapLevel))
|
|
: 0;
|
|
const boostedLevel = Math.sqrt(normalizedLevel);
|
|
const alpha =
|
|
nonMotionAlpha -
|
|
boostedLevel * (nonMotionAlpha - MIN_MOTION_CELL_ALPHA);
|
|
|
|
const pixelOffset = index * 4;
|
|
imageData.data[pixelOffset] = 0;
|
|
imageData.data[pixelOffset + 1] = 0;
|
|
imageData.data[pixelOffset + 2] = 0;
|
|
imageData.data[pixelOffset + 3] = Math.round(
|
|
Math.max(0, Math.min(1, alpha)) * 255,
|
|
);
|
|
}
|
|
|
|
maskContext.putImageData(imageData, 0, 0);
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = "high";
|
|
context.drawImage(maskCanvas, drawX, drawY, drawWidth, drawHeight);
|
|
}, [
|
|
motionHeatmap,
|
|
nonMotionAlpha,
|
|
overlayHeight,
|
|
overlayWidth,
|
|
mediaDimensions,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
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 (
|
|
<div
|
|
ref={overlayContainerRef}
|
|
className="relative size-full cursor-pointer overflow-hidden rounded-lg bg-black md:rounded-2xl"
|
|
style={{ aspectRatio }}
|
|
onClick={() => onSeek(range.start_time)}
|
|
>
|
|
{showLoadingIndicator && (
|
|
<Skeleton className="absolute inset-0 z-10 rounded-lg md:rounded-2xl" />
|
|
)}
|
|
{preview && isVisible ? (
|
|
<div className="absolute inset-0" style={mediaCropStyle}>
|
|
<video
|
|
ref={videoRef}
|
|
className="size-full bg-black object-contain"
|
|
preload="auto"
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
disableRemotePlayback
|
|
loop
|
|
onLoadedMetadata={() => {
|
|
setVideoLoaded(true);
|
|
|
|
if (videoRef.current) {
|
|
setMediaDimensions({
|
|
width: videoRef.current.videoWidth,
|
|
height: videoRef.current.videoHeight,
|
|
});
|
|
}
|
|
}}
|
|
onCanPlay={() => {
|
|
setVideoLoaded(true);
|
|
}}
|
|
onPlay={() => {
|
|
setVideoPlaying(true);
|
|
resetPlayback();
|
|
}}
|
|
onLoadedData={() => setVideoLoaded(true)}
|
|
onError={() => {
|
|
setVideoLoaded(true);
|
|
setVideoPlaying(true);
|
|
}}
|
|
onTimeUpdate={() => {
|
|
if (!videoRef.current || !preview) {
|
|
return;
|
|
}
|
|
|
|
if (videoRef.current.currentTime >= clipEnd) {
|
|
videoRef.current.currentTime = clipStart;
|
|
}
|
|
}}
|
|
>
|
|
<source
|
|
src={`${baseUrl}${preview.src.substring(1)}`}
|
|
type={preview.type}
|
|
/>
|
|
</video>
|
|
{motionHeatmap && (
|
|
<canvas
|
|
ref={dimOverlayCanvasRef}
|
|
className="pointer-events-none absolute inset-0"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
) : fallbackFrameSrc ? (
|
|
<div className="absolute inset-0" style={mediaCropStyle}>
|
|
<img
|
|
src={fallbackFrameSrc}
|
|
className="size-full bg-black object-contain"
|
|
loading="lazy"
|
|
alt=""
|
|
onLoad={(e) => {
|
|
setFallbackImageLoaded(true);
|
|
const img = e.currentTarget;
|
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
setMediaDimensions({
|
|
width: img.naturalWidth,
|
|
height: img.naturalHeight,
|
|
});
|
|
}
|
|
}}
|
|
onError={() => setFallbackImageLoaded(true)}
|
|
/>
|
|
{motionHeatmap && (
|
|
<canvas
|
|
ref={dimOverlayCanvasRef}
|
|
className="pointer-events-none absolute inset-0"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
|
|
{t("motionPreviews.noPreview")}
|
|
</div>
|
|
)}
|
|
|
|
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-30 p-2">
|
|
<div className="flex flex-col items-start text-xs text-white/90 drop-shadow-lg">
|
|
{range.end_time ? (
|
|
<TimeAgo time={range.start_time * 1000} dense />
|
|
) : (
|
|
<ActivityIndicator size={14} />
|
|
)}
|
|
{formattedDate}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type MotionPreviewsPaneProps = {
|
|
camera: CameraConfig;
|
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
|
cameraPreviews: Preview[];
|
|
motionRanges: MotionOnlyRange[];
|
|
isLoadingMotionRanges?: boolean;
|
|
playbackRate: number;
|
|
nonMotionAlpha: number;
|
|
motionFilterCells?: Set<number>;
|
|
cropToFilter?: boolean;
|
|
onSeek: (timestamp: number) => void;
|
|
};
|
|
|
|
export default function MotionPreviewsPane({
|
|
camera,
|
|
contentRef,
|
|
cameraPreviews,
|
|
motionRanges,
|
|
isLoadingMotionRanges = false,
|
|
playbackRate,
|
|
nonMotionAlpha,
|
|
motionFilterCells,
|
|
cropToFilter = true,
|
|
onSeek,
|
|
}: MotionPreviewsPaneProps) {
|
|
const { t } = useTranslation(["views/events"]);
|
|
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(
|
|
null,
|
|
);
|
|
|
|
const [windowVisible, setWindowVisible] = useState(true);
|
|
useEffect(() => {
|
|
const visibilityListener = () => {
|
|
setWindowVisible(document.visibilityState == "visible");
|
|
};
|
|
|
|
addEventListener("visibilitychange", visibilityListener);
|
|
|
|
return () => {
|
|
removeEventListener("visibilitychange", visibilityListener);
|
|
};
|
|
}, []);
|
|
|
|
const [visibleClips, setVisibleClips] = useState<string[]>([]);
|
|
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
|
const clipObserver = useRef<IntersectionObserver | null>(null);
|
|
|
|
const [mountedClips, setMountedClips] = useState<Set<string>>(new Set());
|
|
const mountObserver = useRef<IntersectionObserver | null>(null);
|
|
|
|
const recordingTimeRange = useMemo(() => {
|
|
if (!motionRanges.length) {
|
|
return null;
|
|
}
|
|
|
|
return motionRanges.reduce(
|
|
(bounds, range) => ({
|
|
after: Math.min(bounds.after, range.start_time),
|
|
before: Math.max(bounds.before, range.end_time),
|
|
}),
|
|
{
|
|
after: motionRanges[0].start_time,
|
|
before: motionRanges[0].end_time,
|
|
},
|
|
);
|
|
}, [motionRanges]);
|
|
|
|
const { data: cameraRecordings } = useSWR<Recording[]>(
|
|
recordingTimeRange
|
|
? [
|
|
`${camera.name}/recordings`,
|
|
{
|
|
after: Math.floor(recordingTimeRange.after),
|
|
before: Math.ceil(recordingTimeRange.before),
|
|
},
|
|
]
|
|
: null,
|
|
{
|
|
revalidateOnFocus: false,
|
|
revalidateOnReconnect: false,
|
|
},
|
|
);
|
|
const { data: previewFrames } = useSWR<string[]>(
|
|
recordingTimeRange
|
|
? `preview/${camera.name}/start/${Math.floor(recordingTimeRange.after)}/end/${Math.ceil(recordingTimeRange.before)}/frames`
|
|
: null,
|
|
{
|
|
revalidateOnFocus: false,
|
|
revalidateOnReconnect: false,
|
|
},
|
|
);
|
|
|
|
const previewFrameTimes = useMemo(() => {
|
|
if (!previewFrames) {
|
|
return [] as number[];
|
|
}
|
|
|
|
return previewFrames
|
|
.map((frame) => {
|
|
const timestampPart = frame.split("-").at(-1)?.replace(".webp", "");
|
|
return timestampPart ? Number(timestampPart) : NaN;
|
|
})
|
|
.filter((value) => Number.isFinite(value))
|
|
.sort((a, b) => a - b);
|
|
}, [previewFrames]);
|
|
|
|
const getFallbackFrameTimesForRange = useCallback(
|
|
(range: MotionOnlyRange) => {
|
|
if (!isCurrentHour(range.end_time) || previewFrameTimes.length === 0) {
|
|
return [] as number[];
|
|
}
|
|
|
|
const inRangeFrames = previewFrameTimes.filter(
|
|
(frameTime) =>
|
|
frameTime >= range.start_time && frameTime <= range.end_time,
|
|
);
|
|
|
|
// Use all in-range frames when enough data exists for natural animation
|
|
if (inRangeFrames.length > 1) {
|
|
return inRangeFrames;
|
|
}
|
|
|
|
// If sparse, keep the single in-range frame and add only the next 2 frames
|
|
if (inRangeFrames.length === 1) {
|
|
const inRangeFrame = inRangeFrames[0];
|
|
const nextFrames = previewFrameTimes
|
|
.filter((frameTime) => frameTime > inRangeFrame)
|
|
.slice(0, 2);
|
|
|
|
return [inRangeFrame, ...nextFrames];
|
|
}
|
|
|
|
const nextFramesFromStart = previewFrameTimes
|
|
.filter((frameTime) => frameTime >= range.start_time)
|
|
.slice(0, 3);
|
|
// If no in-range frame exists, take up to 3 frames starting at clip start
|
|
if (nextFramesFromStart.length > 0) {
|
|
return nextFramesFromStart;
|
|
}
|
|
|
|
const lastFrame = previewFrameTimes.at(-1);
|
|
return lastFrame != undefined ? [lastFrame] : [];
|
|
},
|
|
[previewFrameTimes],
|
|
);
|
|
|
|
const setContentNode = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
contentRef.current = node;
|
|
setScrollContainer(node);
|
|
},
|
|
[contentRef],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!scrollContainer) {
|
|
return;
|
|
}
|
|
|
|
const visibleClipIds = new Set<string>();
|
|
clipObserver.current = new IntersectionObserver(
|
|
(entries) => {
|
|
setHasVisibilityData(true);
|
|
|
|
entries.forEach((entry) => {
|
|
const clipId = (entry.target as HTMLElement).dataset.clipId;
|
|
|
|
if (!clipId) {
|
|
return;
|
|
}
|
|
|
|
if (entry.isIntersecting) {
|
|
visibleClipIds.add(clipId);
|
|
} else {
|
|
visibleClipIds.delete(clipId);
|
|
}
|
|
});
|
|
|
|
const rootRect = scrollContainer.getBoundingClientRect();
|
|
const prunedVisibleClipIds = [...visibleClipIds].filter((clipId) => {
|
|
const clipElement = scrollContainer.querySelector<HTMLElement>(
|
|
`[data-clip-id="${clipId}"]`,
|
|
);
|
|
|
|
if (!clipElement) {
|
|
return false;
|
|
}
|
|
|
|
const clipRect = clipElement.getBoundingClientRect();
|
|
|
|
return (
|
|
clipRect.bottom > rootRect.top && clipRect.top < rootRect.bottom
|
|
);
|
|
});
|
|
|
|
setVisibleClips(prunedVisibleClipIds);
|
|
},
|
|
{
|
|
root: scrollContainer,
|
|
threshold: 0,
|
|
},
|
|
);
|
|
|
|
scrollContainer
|
|
.querySelectorAll<HTMLElement>("[data-clip-id]")
|
|
.forEach((node) => {
|
|
clipObserver.current?.observe(node);
|
|
});
|
|
|
|
return () => {
|
|
clipObserver.current?.disconnect();
|
|
};
|
|
}, [scrollContainer]);
|
|
|
|
useEffect(() => {
|
|
if (!scrollContainer) {
|
|
return;
|
|
}
|
|
|
|
const nearClipIds = new Set<string>();
|
|
mountObserver.current = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
const clipId = (entry.target as HTMLElement).dataset.clipId;
|
|
|
|
if (!clipId) {
|
|
return;
|
|
}
|
|
|
|
if (entry.isIntersecting) {
|
|
nearClipIds.add(clipId);
|
|
} else {
|
|
nearClipIds.delete(clipId);
|
|
}
|
|
});
|
|
|
|
setMountedClips(new Set(nearClipIds));
|
|
},
|
|
{
|
|
root: scrollContainer,
|
|
rootMargin: "200% 0px",
|
|
threshold: 0,
|
|
},
|
|
);
|
|
|
|
scrollContainer
|
|
.querySelectorAll<HTMLElement>("[data-clip-id]")
|
|
.forEach((node) => {
|
|
mountObserver.current?.observe(node);
|
|
});
|
|
|
|
return () => {
|
|
mountObserver.current?.disconnect();
|
|
};
|
|
}, [scrollContainer]);
|
|
|
|
const clipRef = useCallback((node: HTMLElement | null) => {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
clipObserver.current?.observe(node);
|
|
mountObserver.current?.observe(node);
|
|
} catch {
|
|
// no op
|
|
}
|
|
}, []);
|
|
|
|
const clipData = useMemo(
|
|
() =>
|
|
motionRanges
|
|
.filter((range) => range.end_time > range.start_time)
|
|
.sort((left, right) => right.start_time - left.start_time)
|
|
.map((range) => {
|
|
const preview = getPreviewForMotionRange(
|
|
cameraPreviews,
|
|
camera.name,
|
|
range,
|
|
);
|
|
|
|
return {
|
|
range,
|
|
preview,
|
|
fallbackFrameTimes: !preview
|
|
? getFallbackFrameTimesForRange(range)
|
|
: undefined,
|
|
motionHeatmap: getMotionHeatmapForRange(
|
|
cameraRecordings ?? [],
|
|
range,
|
|
),
|
|
};
|
|
}),
|
|
[
|
|
cameraPreviews,
|
|
camera.name,
|
|
cameraRecordings,
|
|
getFallbackFrameTimesForRange,
|
|
motionRanges,
|
|
],
|
|
);
|
|
|
|
const filteredClipData = useMemo(() => {
|
|
if (!motionFilterCells || motionFilterCells.size === 0) {
|
|
return clipData;
|
|
}
|
|
|
|
return clipData.filter(({ motionHeatmap }) => {
|
|
if (!motionHeatmap) {
|
|
return false;
|
|
}
|
|
|
|
for (const cellIndex of motionFilterCells) {
|
|
if ((motionHeatmap[cellIndex.toString()] ?? 0) > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}, [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],
|
|
);
|
|
|
|
const isLoadingPane =
|
|
isLoadingMotionRanges ||
|
|
(motionRanges.length > 0 && cameraRecordings == undefined) ||
|
|
(hasCurrentHourRanges && previewFrames == undefined);
|
|
|
|
if (isLoadingPane) {
|
|
return (
|
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-1 md:mx-2 md:gap-4">
|
|
<div
|
|
ref={setContentNode}
|
|
className="no-scrollbar min-h-0 flex-1 overflow-y-auto"
|
|
>
|
|
{filteredClipData.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center text-lg text-primary">
|
|
{t("motionPreviews.empty")}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
|
{filteredClipData.map(
|
|
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
|
|
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
|
|
const isMounted = mountedClips.has(clipId);
|
|
|
|
return (
|
|
<div
|
|
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
|
data-clip-id={clipId}
|
|
ref={clipRef}
|
|
>
|
|
{isMounted ? (
|
|
<MotionPreviewClip
|
|
cameraName={camera.name}
|
|
range={range}
|
|
playbackRate={playbackRate}
|
|
preview={preview}
|
|
fallbackFrameTimes={fallbackFrameTimes}
|
|
motionHeatmap={motionHeatmap}
|
|
nonMotionAlpha={nonMotionAlpha}
|
|
cropRegion={cropRegion}
|
|
aspectRatio={tileAspectRatio}
|
|
isVisible={
|
|
windowVisible &&
|
|
(visibleClips.includes(clipId) ||
|
|
(!hasVisibilityData && idx < 8))
|
|
}
|
|
onSeek={onSeek}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="rounded-lg bg-black md:rounded-2xl"
|
|
style={{ aspectRatio: tileAspectRatio }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|