mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
Frontend updates (#22327)
* add optional field widget adds a switch to enable nullable fields like skip_motion_threshold * config field updates add skip_motion_threshold optional switch add fps back to detect restart required * don't use ternary operator when displaying motion previews the main previews were being unnecessarily unmounted * lazy mount motion preview clips to reduce DOM overhead
This commit is contained in:
parent
ef07563d0a
commit
df27e04c0f
@ -30,10 +30,22 @@ const detect: SectionConfigOverrides = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
restartRequired: [
|
||||||
|
"fps",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"min_initialized",
|
||||||
|
"max_disappeared",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
restartRequired: [
|
||||||
|
"fps",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"min_initialized",
|
||||||
|
"max_disappeared",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,12 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const motion: SectionConfigOverrides = {
|
const motion: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/motion_detection",
|
sectionDocs: "/configuration/motion_detection",
|
||||||
|
fieldDocs: {
|
||||||
|
lightning_threshold:
|
||||||
|
"/configuration/motion_detection#lightning_threshold",
|
||||||
|
skip_motion_threshold:
|
||||||
|
"/configuration/motion_detection#skip_motion_on_large_scene_changes",
|
||||||
|
},
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"enabled",
|
"enabled",
|
||||||
@ -20,6 +26,16 @@ const motion: SectionConfigOverrides = {
|
|||||||
sensitivity: ["enabled", "threshold", "contour_area"],
|
sensitivity: ["enabled", "threshold", "contour_area"],
|
||||||
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
||||||
},
|
},
|
||||||
|
uiSchema: {
|
||||||
|
skip_motion_threshold: {
|
||||||
|
"ui:widget": "optionalField",
|
||||||
|
"ui:options": {
|
||||||
|
innerWidget: "range",
|
||||||
|
step: 0.05,
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
||||||
advancedFields: [
|
advancedFields: [
|
||||||
"lightning_threshold",
|
"lightning_threshold",
|
||||||
@ -58,7 +74,7 @@ const motion: SectionConfigOverrides = {
|
|||||||
"frame_alpha",
|
"frame_alpha",
|
||||||
"frame_height",
|
"frame_height",
|
||||||
],
|
],
|
||||||
advancedFields: ["lightning_threshold"],
|
advancedFields: ["lightning_threshold", "skip_motion_threshold"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
|||||||
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
||||||
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||||
|
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||||
|
|
||||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||||
@ -73,6 +74,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
audioLabels: AudioLabelSwitchesWidget,
|
audioLabels: AudioLabelSwitchesWidget,
|
||||||
zoneNames: ZoneSwitchesWidget,
|
zoneNames: ZoneSwitchesWidget,
|
||||||
timezoneSelect: TimezoneSelectWidget,
|
timezoneSelect: TimezoneSelectWidget,
|
||||||
|
optionalField: OptionalFieldWidget,
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
// Optional Field Widget - wraps any inner widget with an enable/disable switch
|
||||||
|
// Used for nullable fields where None means "disabled" (not the same as 0)
|
||||||
|
|
||||||
|
import type { WidgetProps } from "@rjsf/utils";
|
||||||
|
import { getWidget } from "@rjsf/utils";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getNonNullSchema } from "../fields/nullableUtils";
|
||||||
|
|
||||||
|
export function OptionalFieldWidget(props: WidgetProps) {
|
||||||
|
const { id, value, disabled, readonly, onChange, schema, options, registry } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
const innerWidgetName = (options.innerWidget as string) || undefined;
|
||||||
|
const isEnabled = value !== undefined && value !== null;
|
||||||
|
|
||||||
|
// Extract the non-null branch from anyOf [Type, null]
|
||||||
|
const innerSchema = getNonNullSchema(schema) ?? schema;
|
||||||
|
|
||||||
|
const InnerWidget = getWidget(innerSchema, innerWidgetName, registry.widgets);
|
||||||
|
|
||||||
|
const getDefaultValue = () => {
|
||||||
|
if (innerSchema.default !== undefined && innerSchema.default !== null) {
|
||||||
|
return innerSchema.default;
|
||||||
|
}
|
||||||
|
if (innerSchema.minimum !== undefined) {
|
||||||
|
return innerSchema.minimum;
|
||||||
|
}
|
||||||
|
if (innerSchema.type === "integer" || innerSchema.type === "number") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (innerSchema.type === "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (checked: boolean) => {
|
||||||
|
onChange(checked ? getDefaultValue() : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const innerProps: WidgetProps = {
|
||||||
|
...props,
|
||||||
|
schema: innerSchema,
|
||||||
|
disabled: disabled || readonly || !isEnabled,
|
||||||
|
value: isEnabled ? value : getDefaultValue(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id={`${id}-toggle`}
|
||||||
|
checked={isEnabled}
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn("flex-1", !isEnabled && "pointer-events-none opacity-40")}
|
||||||
|
>
|
||||||
|
<InnerWidget {...innerProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1284,7 +1284,7 @@ function MotionReview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{motionPreviewsCamera && selectedMotionPreviewCamera ? (
|
{selectedMotionPreviewCamera && (
|
||||||
<>
|
<>
|
||||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
|
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
|
||||||
<Button
|
<Button
|
||||||
@ -1465,104 +1465,108 @@ function MotionReview({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<div className="no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
className={cn(
|
|
||||||
"no-scrollbar grid w-full grid-cols-1",
|
|
||||||
isMobile && "landscape:grid-cols-2",
|
|
||||||
reviewCameras.length > 3 &&
|
|
||||||
isMobile &&
|
|
||||||
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
|
|
||||||
isDesktop && "grid-cols-2 lg:grid-cols-3",
|
|
||||||
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{reviewCameras.map((camera) => {
|
|
||||||
let grow;
|
|
||||||
let spans;
|
|
||||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
|
||||||
if (aspectRatio > 2) {
|
|
||||||
grow = "aspect-wide";
|
|
||||||
spans = "sm:col-span-2";
|
|
||||||
} else if (aspectRatio < 1) {
|
|
||||||
grow = "h-full aspect-tall";
|
|
||||||
spans = "md:row-span-2";
|
|
||||||
} else {
|
|
||||||
grow = "aspect-video";
|
|
||||||
}
|
|
||||||
const detectionType = getDetectionType(camera.name);
|
|
||||||
return (
|
|
||||||
<div key={camera.name} className={`relative ${spans}`}>
|
|
||||||
{motionData ? (
|
|
||||||
<>
|
|
||||||
<PreviewPlayer
|
|
||||||
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
|
||||||
camera={camera.name}
|
|
||||||
timeRange={currentTimeRange}
|
|
||||||
startTime={previewStart}
|
|
||||||
cameraPreviews={relevantPreviews}
|
|
||||||
isScrubbing={scrubbing}
|
|
||||||
onControllerReady={(controller) => {
|
|
||||||
videoPlayersRef.current[camera.name] = controller;
|
|
||||||
}}
|
|
||||||
onClick={() =>
|
|
||||||
onOpenRecording({
|
|
||||||
camera: camera.name,
|
|
||||||
startTime: Math.min(
|
|
||||||
currentTime,
|
|
||||||
Date.now() / 1000 - 30,
|
|
||||||
),
|
|
||||||
severity: "significant_motion",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 right-2 z-30">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<BlurredIconButton
|
|
||||||
aria-label={t("motionSearch.openMenu")}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<FiMoreVertical className="size-5" />
|
|
||||||
</BlurredIconButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMotionPreviewsCamera(camera.name);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("motionPreviews.menuItem")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMotionSearchCamera(camera.name);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("motionSearch.menuItem")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Skeleton
|
|
||||||
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4",
|
||||||
|
selectedMotionPreviewCamera && "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={selectedMotionPreviewCamera ? undefined : contentRef}
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar grid w-full grid-cols-1",
|
||||||
|
isMobile && "landscape:grid-cols-2",
|
||||||
|
reviewCameras.length > 3 &&
|
||||||
|
isMobile &&
|
||||||
|
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
|
||||||
|
isDesktop && "grid-cols-2 lg:grid-cols-3",
|
||||||
|
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{reviewCameras.map((camera) => {
|
||||||
|
let grow;
|
||||||
|
let spans;
|
||||||
|
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||||
|
if (aspectRatio > 2) {
|
||||||
|
grow = "aspect-wide";
|
||||||
|
spans = "sm:col-span-2";
|
||||||
|
} else if (aspectRatio < 1) {
|
||||||
|
grow = "h-full aspect-tall";
|
||||||
|
spans = "md:row-span-2";
|
||||||
|
} else {
|
||||||
|
grow = "aspect-video";
|
||||||
|
}
|
||||||
|
const detectionType = getDetectionType(camera.name);
|
||||||
|
return (
|
||||||
|
<div key={camera.name} className={`relative ${spans}`}>
|
||||||
|
{motionData ? (
|
||||||
|
<>
|
||||||
|
<PreviewPlayer
|
||||||
|
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||||
|
camera={camera.name}
|
||||||
|
timeRange={currentTimeRange}
|
||||||
|
startTime={previewStart}
|
||||||
|
cameraPreviews={relevantPreviews}
|
||||||
|
isScrubbing={scrubbing}
|
||||||
|
onControllerReady={(controller) => {
|
||||||
|
videoPlayersRef.current[camera.name] = controller;
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
onOpenRecording({
|
||||||
|
camera: camera.name,
|
||||||
|
startTime: Math.min(
|
||||||
|
currentTime,
|
||||||
|
Date.now() / 1000 - 30,
|
||||||
|
),
|
||||||
|
severity: "significant_motion",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 right-2 z-30">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<BlurredIconButton
|
||||||
|
aria-label={t("motionSearch.openMenu")}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<FiMoreVertical className="size-5" />
|
||||||
|
</BlurredIconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMotionPreviewsCamera(camera.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("motionPreviews.menuItem")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMotionSearchCamera(camera.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("motionSearch.menuItem")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Skeleton
|
||||||
|
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{!selectedMotionPreviewCamera && (
|
{!selectedMotionPreviewCamera && (
|
||||||
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
||||||
{motionData ? (
|
{motionData ? (
|
||||||
|
|||||||
@ -624,6 +624,9 @@ export default function MotionPreviewsPane({
|
|||||||
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
||||||
const clipObserver = useRef<IntersectionObserver | null>(null);
|
const clipObserver = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
|
const [mountedClips, setMountedClips] = useState<Set<string>>(new Set());
|
||||||
|
const mountObserver = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
const recordingTimeRange = useMemo(() => {
|
const recordingTimeRange = useMemo(() => {
|
||||||
if (!motionRanges.length) {
|
if (!motionRanges.length) {
|
||||||
return null;
|
return null;
|
||||||
@ -788,15 +791,56 @@ export default function MotionPreviewsPane({
|
|||||||
};
|
};
|
||||||
}, [scrollContainer]);
|
}, [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) => {
|
const clipRef = useCallback((node: HTMLElement | null) => {
|
||||||
if (!clipObserver.current) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (node) {
|
clipObserver.current?.observe(node);
|
||||||
clipObserver.current.observe(node);
|
mountObserver.current?.observe(node);
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// no op
|
// no op
|
||||||
}
|
}
|
||||||
@ -864,31 +908,38 @@ export default function MotionPreviewsPane({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
||||||
{clipData.map(
|
{clipData.map(
|
||||||
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => (
|
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
|
||||||
<div
|
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
|
||||||
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
const isMounted = mountedClips.has(clipId);
|
||||||
data-clip-id={`${camera.name}-${range.start_time}-${range.end_time}-${idx}`}
|
|
||||||
ref={clipRef}
|
return (
|
||||||
>
|
<div
|
||||||
<MotionPreviewClip
|
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
||||||
cameraName={camera.name}
|
data-clip-id={clipId}
|
||||||
range={range}
|
ref={clipRef}
|
||||||
playbackRate={playbackRate}
|
>
|
||||||
preview={preview}
|
{isMounted ? (
|
||||||
fallbackFrameTimes={fallbackFrameTimes}
|
<MotionPreviewClip
|
||||||
motionHeatmap={motionHeatmap}
|
cameraName={camera.name}
|
||||||
nonMotionAlpha={nonMotionAlpha}
|
range={range}
|
||||||
isVisible={
|
playbackRate={playbackRate}
|
||||||
windowVisible &&
|
preview={preview}
|
||||||
(visibleClips.includes(
|
fallbackFrameTimes={fallbackFrameTimes}
|
||||||
`${camera.name}-${range.start_time}-${range.end_time}-${idx}`,
|
motionHeatmap={motionHeatmap}
|
||||||
) ||
|
nonMotionAlpha={nonMotionAlpha}
|
||||||
(!hasVisibilityData && idx < 8))
|
isVisible={
|
||||||
}
|
windowVisible &&
|
||||||
onSeek={onSeek}
|
(visibleClips.includes(clipId) ||
|
||||||
/>
|
(!hasVisibilityData && idx < 8))
|
||||||
</div>
|
}
|
||||||
),
|
onSeek={onSeek}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user