mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 02:29:19 +03:00
972 lines
26 KiB
TypeScript
972 lines
26 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 { 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 } from "@/hooks/use-date-utils";
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
|
|
const MOTION_HEATMAP_GRID_SIZE = 16;
|
|
const MIN_MOTION_CELL_ALPHA = 0.06;
|
|
|
|
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;
|
|
isVisible: boolean;
|
|
onSeek: (timestamp: number) => void;
|
|
};
|
|
|
|
function MotionPreviewClip({
|
|
cameraName,
|
|
range,
|
|
playbackRate,
|
|
preview,
|
|
fallbackFrameTimes,
|
|
motionHeatmap,
|
|
nonMotionAlpha,
|
|
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 formattedDate = useFormattedTimestamp(
|
|
range.start_time,
|
|
config?.ui.time_format == "24hour"
|
|
? 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 resetPlayback = useCallback(() => {
|
|
if (!videoRef.current || !preview) {
|
|
return;
|
|
}
|
|
|
|
videoRef.current.currentTime = clipStart;
|
|
videoRef.current.playbackRate = playbackRate;
|
|
}, [clipStart, playbackRate, preview]);
|
|
|
|
useEffect(() => {
|
|
if (!videoRef.current || !preview) {
|
|
return;
|
|
}
|
|
|
|
if (!isVisible) {
|
|
videoRef.current.pause();
|
|
videoRef.current.currentTime = clipStart;
|
|
return;
|
|
}
|
|
|
|
if (videoRef.current.readyState >= 2) {
|
|
resetPlayback();
|
|
void videoRef.current.play().catch(() => undefined);
|
|
}
|
|
}, [clipStart, isVisible, preview, 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;
|
|
}
|
|
|
|
// 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 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]);
|
|
|
|
return (
|
|
<div
|
|
ref={overlayContainerRef}
|
|
className="relative aspect-video size-full cursor-pointer overflow-hidden rounded-lg bg-black md:rounded-2xl"
|
|
onClick={() => onSeek(range.start_time)}
|
|
>
|
|
{showLoadingIndicator && (
|
|
<Skeleton className="absolute inset-0 z-10 rounded-lg md:rounded-2xl" />
|
|
)}
|
|
{preview ? (
|
|
<>
|
|
<video
|
|
ref={videoRef}
|
|
className="size-full bg-black object-contain"
|
|
playsInline
|
|
preload={isVisible ? "metadata" : "none"}
|
|
muted
|
|
autoPlay={isVisible}
|
|
onLoadedMetadata={() => {
|
|
setVideoLoaded(true);
|
|
|
|
if (videoRef.current) {
|
|
setMediaDimensions({
|
|
width: videoRef.current.videoWidth,
|
|
height: videoRef.current.videoHeight,
|
|
});
|
|
}
|
|
|
|
if (!isVisible) {
|
|
return;
|
|
}
|
|
|
|
resetPlayback();
|
|
|
|
if (videoRef.current) {
|
|
void videoRef.current.play().catch(() => undefined);
|
|
}
|
|
}}
|
|
onCanPlay={() => {
|
|
setVideoLoaded(true);
|
|
|
|
if (!isVisible) {
|
|
return;
|
|
}
|
|
|
|
if (videoRef.current) {
|
|
void videoRef.current.play().catch(() => undefined);
|
|
}
|
|
}}
|
|
onPlay={() => setVideoPlaying(true)}
|
|
onLoadedData={() => setVideoLoaded(true)}
|
|
onError={() => {
|
|
setVideoLoaded(true);
|
|
setVideoPlaying(true);
|
|
}}
|
|
onTimeUpdate={() => {
|
|
if (!videoRef.current || !preview || !isVisible) {
|
|
return;
|
|
}
|
|
|
|
if (videoRef.current.currentTime >= clipEnd) {
|
|
videoRef.current.currentTime = clipStart;
|
|
}
|
|
}}
|
|
>
|
|
{isVisible && (
|
|
<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"
|
|
/>
|
|
)}
|
|
</>
|
|
) : fallbackFrameSrc ? (
|
|
<>
|
|
<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 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>;
|
|
onSeek: (timestamp: number) => void;
|
|
};
|
|
|
|
export default function MotionPreviewsPane({
|
|
camera,
|
|
contentRef,
|
|
cameraPreviews,
|
|
motionRanges,
|
|
isLoadingMotionRanges = false,
|
|
playbackRate,
|
|
nonMotionAlpha,
|
|
motionFilterCells,
|
|
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 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}
|
|
isVisible={
|
|
windowVisible &&
|
|
(visibleClips.includes(clipId) ||
|
|
(!hasVisibilityData && idx < 8))
|
|
}
|
|
onSeek={onSeek}
|
|
/>
|
|
) : (
|
|
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|