Compare commits

...

3 Commits

Author SHA1 Message Date
Nicolas Mowen
3892f8c732
Update ROCm to 6.4.1 (#18364)
* Update rocm to 6.4.1

* Quick fix
2025-05-23 12:05:04 -05:00
Josh Hawkins
9392ffc300
Implement support for no recordings indicator on timeline (#18363)
* Indicate no recordings on the history timeline with gray hash marks

This commit includes a new backend API endpoint and the frontend changes needed to support this functionality

* don't show slashes for now
2025-05-23 08:55:48 -06:00
Nicolas Mowen
8a1da3a89f
Initial custom classification model config support (#18362)
* Add basic config for defining a teachable machine model

* Add model type

* Add basic config for teachable machine models

* Adjust config for state and object

* Use config to process

* Correctly check for objects

* Remove debug

* Rename to not be teachable machine specific

* Cleanup
2025-05-23 09:46:53 -05:00
14 changed files with 360 additions and 15 deletions

View File

@ -2,7 +2,7 @@
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable # https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG ROCM=6.4.0 ARG ROCM=1
ARG AMDGPU=gfx900 ARG AMDGPU=gfx900
ARG HSA_OVERRIDE_GFX_VERSION ARG HSA_OVERRIDE_GFX_VERSION
ARG HSA_OVERRIDE ARG HSA_OVERRIDE
@ -13,12 +13,12 @@ FROM wget AS rocm
ARG ROCM ARG ROCM
ARG AMDGPU ARG AMDGPU
RUN apt update && \ RUN apt update -qq && \
apt install -y wget gpg && \ apt install -y wget gpg && \
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/6.4/ubuntu/jammy/amdgpu-install_6.4.60400-1_all.deb && \ wget -O rocm.deb https://repo.radeon.com/amdgpu-install/6.4.1/ubuntu/jammy/amdgpu-install_6.4.60401-1_all.deb && \
apt install -y ./rocm.deb && \ apt install -y ./rocm.deb && \
apt update && \ apt update && \
apt install -y rocm apt install -qq -y rocm
RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib
RUN cd /opt/rocm-$ROCM/lib && \ RUN cd /opt/rocm-$ROCM/lib && \

View File

@ -1 +1 @@
onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v6.4.0/onnxruntime_rocm-1.21.1-cp311-cp311-linux_x86_64.whl onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v6.4.1/onnxruntime_rocm-1.21.1-cp311-cp311-linux_x86_64.whl

View File

@ -2,7 +2,7 @@ variable "AMDGPU" {
default = "gfx900" default = "gfx900"
} }
variable "ROCM" { variable "ROCM" {
default = "6.4.0" default = "6.4.1"
} }
variable "HSA_OVERRIDE_GFX_VERSION" { variable "HSA_OVERRIDE_GFX_VERSION" {
default = "" default = ""

View File

@ -1,7 +1,8 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional, Union
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema
class Extension(str, Enum): class Extension(str, Enum):
@ -46,3 +47,10 @@ class MediaMjpegFeedQueryParams(BaseModel):
class MediaRecordingsSummaryQueryParams(BaseModel): class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "utc" timezone: str = "utc"
cameras: Optional[str] = "all" cameras: Optional[str] = "all"
class MediaRecordingsAvailabilityQueryParams(BaseModel):
cameras: str = "all"
before: Union[float, SkipJsonSchema[None]] = None
after: Union[float, SkipJsonSchema[None]] = None
scale: int = 30

View File

@ -8,6 +8,7 @@ import os
import subprocess as sp import subprocess as sp
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import reduce
from pathlib import Path as FilePath from pathlib import Path as FilePath
from typing import Any from typing import Any
from urllib.parse import unquote from urllib.parse import unquote
@ -19,7 +20,7 @@ from fastapi import APIRouter, Path, Query, Request, Response
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from peewee import DoesNotExist, fn from peewee import DoesNotExist, fn, operator
from tzlocal import get_localzone_name from tzlocal import get_localzone_name
from frigate.api.defs.query.media_query_parameters import ( from frigate.api.defs.query.media_query_parameters import (
@ -27,6 +28,7 @@ from frigate.api.defs.query.media_query_parameters import (
MediaEventsSnapshotQueryParams, MediaEventsSnapshotQueryParams,
MediaLatestFrameQueryParams, MediaLatestFrameQueryParams,
MediaMjpegFeedQueryParams, MediaMjpegFeedQueryParams,
MediaRecordingsAvailabilityQueryParams,
MediaRecordingsSummaryQueryParams, MediaRecordingsSummaryQueryParams,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
@ -542,6 +544,66 @@ def recordings(
return JSONResponse(content=list(recordings)) return JSONResponse(content=list(recordings))
@router.get("/recordings/unavailable", response_model=list[dict])
def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
"""Get time ranges with no recordings."""
cameras = params.cameras
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
)
scale = params.scale
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list))
# Get recording start times
data: list[Recordings] = (
Recordings.select(Recordings.start_time, Recordings.end_time)
.where(reduce(operator.and_, clauses))
.order_by(Recordings.start_time.asc())
.dicts()
.iterator()
)
# Convert recordings to list of (start, end) tuples
recordings = [(r["start_time"], r["end_time"]) for r in data]
# Generate all time segments
current = after
no_recording_segments = []
current_start = None
while current < before:
segment_end = current + scale
# Check if segment overlaps with any recording
has_recording = any(
start <= segment_end and end >= current for start, end in recordings
)
if not has_recording:
if current_start is None:
current_start = current # Start a new gap
else:
if current_start is not None:
# End the current gap and append it
no_recording_segments.append(
{"start_time": int(current_start), "end_time": int(current)}
)
current_start = None
current = segment_end
# Append the last gap if it exists
if current_start is not None:
no_recording_segments.append(
{"start_time": int(current_start), "end_time": int(before)}
)
return JSONResponse(content=no_recording_segments)
@router.get( @router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4", "/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
description="For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.", description="For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.",

View File

@ -289,7 +289,10 @@ class Dispatcher:
logger.info(f"Turning off detection for {camera_name}") logger.info(f"Turning off detection for {camera_name}")
detect_settings.enabled = False detect_settings.enabled = False
self.config_updater.publish(f"config/detect/{camera_name}", detect_settings) self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name),
detect_settings,
)
self.publish(f"{camera_name}/detect/state", payload, retain=True) self.publish(f"{camera_name}/detect/state", payload, retain=True)
def _on_enabled_command(self, camera_name: str, payload: str) -> None: def _on_enabled_command(self, camera_name: str, payload: str) -> None:

View File

@ -34,10 +34,37 @@ class BirdClassificationConfig(FrigateBaseModel):
) )
class CustomClassificationStateCameraConfig(FrigateBaseModel):
crop: list[int, int, int, int] = Field(
title="Crop of image frame on this camera to run classification on."
)
class CustomClassificationStateConfig(FrigateBaseModel):
cameras: Dict[str, CustomClassificationStateCameraConfig] = Field(
title="Cameras to run classification on."
)
class CustomClassificationObjectConfig(FrigateBaseModel):
objects: list[str] = Field(title="Object types to classify.")
class CustomClassificationConfig(FrigateBaseModel):
enabled: bool = Field(default=True, title="Enable running the model.")
model_path: str = Field(title="Path to custom classification tflite model.")
labelmap_path: str = Field(title="Path to custom classification model labelmap.")
object_config: CustomClassificationObjectConfig | None = Field(default=None)
state_config: CustomClassificationStateConfig | None = Field(default=None)
class ClassificationConfig(FrigateBaseModel): class ClassificationConfig(FrigateBaseModel):
bird: BirdClassificationConfig = Field( bird: BirdClassificationConfig = Field(
default_factory=BirdClassificationConfig, title="Bird classification config." default_factory=BirdClassificationConfig, title="Bird classification config."
) )
custom: Dict[str, CustomClassificationConfig] = Field(
default={}, title="Custom Classification Model Configs."
)
class SemanticSearchConfig(FrigateBaseModel): class SemanticSearchConfig(FrigateBaseModel):

View File

@ -0,0 +1,178 @@
"""Real time processor that works with classification tflite models."""
import logging
from typing import Any
import cv2
import numpy as np
from frigate.comms.event_metadata_updater import (
EventMetadataPublisher,
EventMetadataTypeEnum,
)
from frigate.config import FrigateConfig
from frigate.config.classification import CustomClassificationConfig
from frigate.util.builtin import load_labels
from frigate.util.object import calculate_region
from ..types import DataProcessorMetrics
from .api import RealTimeProcessorApi
try:
from tflite_runtime.interpreter import Interpreter
except ModuleNotFoundError:
from tensorflow.lite.python.interpreter import Interpreter
logger = logging.getLogger(__name__)
class CustomStateClassificationProcessor(RealTimeProcessorApi):
def __init__(
self,
config: FrigateConfig,
model_config: CustomClassificationConfig,
metrics: DataProcessorMetrics,
):
super().__init__(config, metrics)
self.model_config = model_config
self.interpreter: Interpreter = None
self.tensor_input_details: dict[str, Any] = None
self.tensor_output_details: dict[str, Any] = None
self.labelmap: dict[int, str] = {}
self.__build_detector()
def __build_detector(self) -> None:
self.interpreter = Interpreter(
model_path=self.model_config.model_path,
num_threads=2,
)
self.interpreter.allocate_tensors()
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
self.labelmap = load_labels(self.model_config.labelmap_path, prefill=0)
def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray):
camera = frame_data.get("camera")
if camera not in self.model_config.state_config.cameras:
return
camera_config = self.model_config.state_config.cameras[camera]
x, y, x2, y2 = calculate_region(
frame.shape,
camera_config.crop[0],
camera_config.crop[1],
camera_config.crop[2],
camera_config.crop[3],
224,
1.0,
)
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
input = rgb[
y:y2,
x:x2,
]
if input.shape != (224, 224):
input = cv2.resize(input, (224, 224))
input = np.expand_dims(input, axis=0)
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input)
self.interpreter.invoke()
res: np.ndarray = self.interpreter.get_tensor(
self.tensor_output_details[0]["index"]
)[0]
print(f"the gate res is {res}")
probs = res / res.sum(axis=0)
best_id = np.argmax(probs)
score = round(probs[best_id], 2)
print(f"got {self.labelmap[best_id]} with score {score}")
def handle_request(self, topic, request_data):
return None
def expire_object(self, object_id, camera):
pass
class CustomObjectClassificationProcessor(RealTimeProcessorApi):
def __init__(
self,
config: FrigateConfig,
model_config: CustomClassificationConfig,
sub_label_publisher: EventMetadataPublisher,
metrics: DataProcessorMetrics,
):
super().__init__(config, metrics)
self.model_config = model_config
self.interpreter: Interpreter = None
self.sub_label_publisher = sub_label_publisher
self.tensor_input_details: dict[str, Any] = None
self.tensor_output_details: dict[str, Any] = None
self.detected_objects: dict[str, float] = {}
self.labelmap: dict[int, str] = {}
self.__build_detector()
def __build_detector(self) -> None:
self.interpreter = Interpreter(
model_path=self.model_config.model_path,
num_threads=2,
)
self.interpreter.allocate_tensors()
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
self.labelmap = load_labels(self.model_config.labelmap_path, prefill=0)
def process_frame(self, obj_data, frame):
if obj_data["label"] not in self.model_config.object_config.objects:
return
x, y, x2, y2 = calculate_region(
frame.shape,
obj_data["box"][0],
obj_data["box"][1],
obj_data["box"][2],
obj_data["box"][3],
224,
1.0,
)
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
input = rgb[
y:y2,
x:x2,
]
if input.shape != (224, 224):
input = cv2.resize(input, (224, 224))
input = np.expand_dims(input, axis=0)
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input)
self.interpreter.invoke()
res: np.ndarray = self.interpreter.get_tensor(
self.tensor_output_details[0]["index"]
)[0]
probs = res / res.sum(axis=0)
best_id = np.argmax(probs)
score = round(probs[best_id], 2)
previous_score = self.detected_objects.get(obj_data["id"], 0.0)
if score <= previous_score:
logger.debug(f"Score {score} is worse than previous score {previous_score}")
return
self.sub_label_publisher.publish(
EventMetadataTypeEnum.sub_label,
(obj_data["id"], self.labelmap[best_id], score),
)
self.detected_objects[obj_data["id"]] = score
def handle_request(self, topic, request_data):
return None
def expire_object(self, object_id, camera):
if object_id in self.detected_objects:
self.detected_objects.pop(object_id)

View File

@ -42,6 +42,10 @@ from frigate.data_processing.post.license_plate import (
) )
from frigate.data_processing.real_time.api import RealTimeProcessorApi from frigate.data_processing.real_time.api import RealTimeProcessorApi
from frigate.data_processing.real_time.bird import BirdRealTimeProcessor from frigate.data_processing.real_time.bird import BirdRealTimeProcessor
from frigate.data_processing.real_time.custom_classification import (
CustomObjectClassificationProcessor,
CustomStateClassificationProcessor,
)
from frigate.data_processing.real_time.face import FaceRealTimeProcessor from frigate.data_processing.real_time.face import FaceRealTimeProcessor
from frigate.data_processing.real_time.license_plate import ( from frigate.data_processing.real_time.license_plate import (
LicensePlateRealTimeProcessor, LicensePlateRealTimeProcessor,
@ -143,6 +147,18 @@ class EmbeddingMaintainer(threading.Thread):
) )
) )
for model in self.config.classification.custom.values():
self.realtime_processors.append(
CustomStateClassificationProcessor(self.config, model, self.metrics)
if model.state_config != None
else CustomObjectClassificationProcessor(
self.config,
model,
self.event_metadata_publisher,
self.metrics,
)
)
# post processors # post processors
self.post_processors: list[PostProcessorApi] = [] self.post_processors: list[PostProcessorApi] = []
@ -172,7 +188,7 @@ class EmbeddingMaintainer(threading.Thread):
self._process_requests() self._process_requests()
self._process_updates() self._process_updates()
self._process_recordings_updates() self._process_recordings_updates()
self._process_dedicated_lpr() self._process_frame_updates()
self._expire_dedicated_lpr() self._expire_dedicated_lpr()
self._process_finalized() self._process_finalized()
self._process_event_metadata() self._process_event_metadata()
@ -449,7 +465,7 @@ class EmbeddingMaintainer(threading.Thread):
event_id, RegenerateDescriptionEnum(source) event_id, RegenerateDescriptionEnum(source)
) )
def _process_dedicated_lpr(self) -> None: def _process_frame_updates(self) -> None:
"""Process event updates""" """Process event updates"""
(topic, data) = self.detection_subscriber.check_for_update() (topic, data) = self.detection_subscriber.check_for_update()
@ -458,7 +474,7 @@ class EmbeddingMaintainer(threading.Thread):
camera, frame_name, _, _, motion_boxes, _ = data camera, frame_name, _, _, motion_boxes, _ = data
if not camera or not self.config.lpr.enabled or len(motion_boxes) == 0: if not camera or len(motion_boxes) == 0:
return return
camera_config = self.config.cameras[camera] camera_config = self.config.cameras[camera]
@ -466,8 +482,8 @@ class EmbeddingMaintainer(threading.Thread):
if ( if (
camera_config.type != CameraTypeEnum.lpr camera_config.type != CameraTypeEnum.lpr
or "license_plate" in camera_config.objects.track or "license_plate" in camera_config.objects.track
): ) and len(self.config.classification.custom) == 0:
# we're not a dedicated lpr camera or we are one but we're using frigate+ # no active features that use this data
return return
try: try:
@ -487,6 +503,9 @@ class EmbeddingMaintainer(threading.Thread):
if isinstance(processor, LicensePlateRealTimeProcessor): if isinstance(processor, LicensePlateRealTimeProcessor):
processor.process_frame(camera, yuv_frame, True) processor.process_frame(camera, yuv_frame, True)
if isinstance(processor, CustomStateClassificationProcessor):
processor.process_frame({"camera": camera}, yuv_frame)
self.frame_manager.close(frame_name) self.frame_manager.close(frame_name)
def _create_thumbnail(self, yuv_frame, box, height=500) -> Optional[bytes]: def _create_thumbnail(self, yuv_frame, box, height=500) -> Optional[bytes]:

View File

@ -17,6 +17,7 @@ import {
VirtualizedMotionSegments, VirtualizedMotionSegments,
VirtualizedMotionSegmentsRef, VirtualizedMotionSegmentsRef,
} from "./VirtualizedMotionSegments"; } from "./VirtualizedMotionSegments";
import { RecordingSegment } from "@/types/record";
export type MotionReviewTimelineProps = { export type MotionReviewTimelineProps = {
segmentDuration: number; segmentDuration: number;
@ -38,6 +39,7 @@ export type MotionReviewTimelineProps = {
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>; setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
events: ReviewSegment[]; events: ReviewSegment[];
motion_events: MotionData[]; motion_events: MotionData[];
noRecordingRanges?: RecordingSegment[];
contentRef: RefObject<HTMLDivElement>; contentRef: RefObject<HTMLDivElement>;
timelineRef?: RefObject<HTMLDivElement>; timelineRef?: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void; onHandlebarDraggingChange?: (isDragging: boolean) => void;
@ -66,6 +68,7 @@ export function MotionReviewTimeline({
setExportEndTime, setExportEndTime,
events, events,
motion_events, motion_events,
noRecordingRanges,
contentRef, contentRef,
timelineRef, timelineRef,
onHandlebarDraggingChange, onHandlebarDraggingChange,
@ -97,6 +100,17 @@ export function MotionReviewTimeline({
motion_events, motion_events,
); );
const getRecordingAvailability = useCallback(
(time: number): boolean | undefined => {
if (!noRecordingRanges?.length) return undefined;
return !noRecordingRanges.some(
(range) => time >= range.start_time && time < range.end_time,
);
},
[noRecordingRanges],
);
const segmentTimes = useMemo(() => { const segmentTimes = useMemo(() => {
const segments = []; const segments = [];
let segmentTime = timelineStartAligned; let segmentTime = timelineStartAligned;
@ -206,6 +220,7 @@ export function MotionReviewTimeline({
dense={dense} dense={dense}
motionOnly={motionOnly} motionOnly={motionOnly}
getMotionSegmentValue={getMotionSegmentValue} getMotionSegmentValue={getMotionSegmentValue}
getRecordingAvailability={getRecordingAvailability}
/> />
</ReviewTimeline> </ReviewTimeline>
); );

View File

@ -15,6 +15,7 @@ type MotionSegmentProps = {
timestampSpread: number; timestampSpread: number;
firstHalfMotionValue: number; firstHalfMotionValue: number;
secondHalfMotionValue: number; secondHalfMotionValue: number;
hasRecording?: boolean;
motionOnly: boolean; motionOnly: boolean;
showMinimap: boolean; showMinimap: boolean;
minimapStartTime?: number; minimapStartTime?: number;
@ -31,6 +32,7 @@ export function MotionSegment({
timestampSpread, timestampSpread,
firstHalfMotionValue, firstHalfMotionValue,
secondHalfMotionValue, secondHalfMotionValue,
hasRecording,
motionOnly, motionOnly,
showMinimap, showMinimap,
minimapStartTime, minimapStartTime,
@ -176,6 +178,12 @@ export function MotionSegment({
segmentClasses, segmentClasses,
severity[0] && "bg-gradient-to-r", severity[0] && "bg-gradient-to-r",
severity[0] && severityColorsBg[severity[0]], severity[0] && severityColorsBg[severity[0]],
// TODO: will update this for 0.17
false &&
hasRecording == false &&
firstHalfMotionValue == 0 &&
secondHalfMotionValue == 0 &&
"bg-slashes",
)} )}
onClick={segmentClick} onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)} onTouchEnd={(event) => handleTouchStart(event, segmentClick)}

View File

@ -24,6 +24,7 @@ type VirtualizedMotionSegmentsProps = {
dense: boolean; dense: boolean;
motionOnly: boolean; motionOnly: boolean;
getMotionSegmentValue: (timestamp: number) => number; getMotionSegmentValue: (timestamp: number) => number;
getRecordingAvailability: (timestamp: number) => boolean | undefined;
}; };
export interface VirtualizedMotionSegmentsRef { export interface VirtualizedMotionSegmentsRef {
@ -55,6 +56,7 @@ export const VirtualizedMotionSegments = forwardRef<
dense, dense,
motionOnly, motionOnly,
getMotionSegmentValue, getMotionSegmentValue,
getRecordingAvailability,
}, },
ref, ref,
) => { ) => {
@ -154,6 +156,8 @@ export const VirtualizedMotionSegments = forwardRef<
(item.end_time ?? segmentTime) >= motionEnd), (item.end_time ?? segmentTime) >= motionEnd),
); );
const hasRecording = getRecordingAvailability(segmentTime);
if ((!segmentMotion || overlappingReviewItems) && motionOnly) { if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
return null; // Skip rendering this segment in motion only mode return null; // Skip rendering this segment in motion only mode
} }
@ -172,6 +176,7 @@ export const VirtualizedMotionSegments = forwardRef<
events={events} events={events}
firstHalfMotionValue={firstHalfMotionValue} firstHalfMotionValue={firstHalfMotionValue}
secondHalfMotionValue={secondHalfMotionValue} secondHalfMotionValue={secondHalfMotionValue}
hasRecording={hasRecording}
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
segmentTime={segmentTime} segmentTime={segmentTime}
timestampSpread={timestampSpread} timestampSpread={timestampSpread}
@ -189,6 +194,7 @@ export const VirtualizedMotionSegments = forwardRef<
[ [
events, events,
getMotionSegmentValue, getMotionSegmentValue,
getRecordingAvailability,
motionOnly, motionOnly,
segmentDuration, segmentDuration,
showMinimap, showMinimap,

View File

@ -43,7 +43,11 @@ import Logo from "@/components/Logo";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import { VideoResolutionType } from "@/types/live"; import { VideoResolutionType } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
RecordingSegment,
} from "@/types/record";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFullscreen } from "@/hooks/use-fullscreen"; import { useFullscreen } from "@/hooks/use-fullscreen";
@ -808,6 +812,16 @@ function Timeline({
}, },
]); ]);
const { data: noRecordings } = useSWR<RecordingSegment[]>([
"recordings/unavailable",
{
before: timeRange.before,
after: timeRange.after,
scale: Math.round(zoomSettings.segmentDuration / 2),
cameras: mainCamera,
},
]);
const [exportStart, setExportStartTime] = useState<number>(0); const [exportStart, setExportStartTime] = useState<number>(0);
const [exportEnd, setExportEndTime] = useState<number>(0); const [exportEnd, setExportEndTime] = useState<number>(0);
@ -853,6 +867,7 @@ function Timeline({
setHandlebarTime={setCurrentTime} setHandlebarTime={setCurrentTime}
events={mainCameraReviewItems} events={mainCameraReviewItems}
motion_events={motionData ?? []} motion_events={motionData ?? []}
noRecordingRanges={noRecordings ?? []}
contentRef={contentRef} contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)} onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
isZooming={isZooming} isZooming={isZooming}

View File

@ -42,6 +42,10 @@ module.exports = {
wide: "32 / 9", wide: "32 / 9",
tall: "8 / 9", tall: "8 / 9",
}, },
backgroundImage: {
slashes:
"repeating-linear-gradient(45deg, hsl(var(--primary-variant) / 0.2), hsl(var(--primary-variant) / 0.2) 2px, transparent 2px, transparent 8px)",
},
colors: { colors: {
border: "hsl(var(--border))", border: "hsl(var(--border))",
input: "hsl(var(--input))", input: "hsl(var(--input))",