From 8cdaef307a4a0359ab26976ca79fd916823a3adb Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 28 Sep 2025 10:31:59 -0600 Subject: [PATCH 001/220] Update face rec docs (#20256) * Update face rec docs * clarify Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- docs/docs/configuration/face_recognition.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index 3026615d4..d72b66639 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -158,6 +158,8 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod Accuracy is definitely a going to be improved with higher quality cameras / streams. It is important to look at the DORI (Detection Observation Recognition Identification) range of your camera, if that specification is posted. This specification explains the distance from the camera that a person can be detected, observed, recognized, and identified. The identification range is the most relevant here, and the distance listed by the camera is the furthest that face recognition will realistically work. +Some users have also noted that setting the stream in camera firmware to a constant bit rate (CBR) leads to better image clarity than with a variable bit rate (VBR). + ### Why can't I bulk upload photos? It is important to methodically add photos to the library, bulk importing photos (especially from a general photo library) will lead to over-fitting in that particular scenario and hurt recognition performance. From b94ebda9e51193948466fe218b0ce268f3ed74e1 Mon Sep 17 00:00:00 2001 From: AmirHossein_Omidi <151873319+AmirHoseinOmidi@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:48:47 +0330 Subject: [PATCH 002/220] Update license_plate_recognition.md (#20306) * Update license_plate_recognition.md Add PaddleOCR description for license plate recognition in Frigate docs * Update docs/docs/configuration/license_plate_recognition.md Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update docs/docs/configuration/license_plate_recognition.md Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- docs/docs/configuration/license_plate_recognition.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index 933fd72d3..36e8b7dad 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -30,8 +30,7 @@ In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle` ## Minimum System Requirements -License plate recognition works by running AI models locally on your system. The models are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required. - +License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required. ## Configuration License plate recognition is disabled by default. Enable it in your config file: From 20e5e3bdc067634a70a67d3a14a4237c627056d9 Mon Sep 17 00:00:00 2001 From: mpking828 Date: Fri, 3 Oct 2025 10:49:51 -0400 Subject: [PATCH 003/220] Update camera_specific.md to fix 2 way audio example for Reolink (#20343) Update camera_specific.md to fix 2 way audio example for Reolink --- docs/docs/configuration/camera_specific.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/camera_specific.md b/docs/docs/configuration/camera_specific.md index 3a3809605..ca31604c8 100644 --- a/docs/docs/configuration/camera_specific.md +++ b/docs/docs/configuration/camera_specific.md @@ -213,7 +213,7 @@ go2rtc: streams: your_reolink_doorbell: - "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password#video=copy#audio=copy#audio=opus" - - rtsp://reolink_ip/Preview_01_sub + - rtsp://username:password@reolink_ip/Preview_01_sub your_reolink_doorbell_sub: - "ffmpeg:http://reolink_ip/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=username&password=password" ``` From 59102794e879d6dfbb654547dc9088df0fbf7cc9 Mon Sep 17 00:00:00 2001 From: Sean Kelly Date: Sat, 11 Oct 2025 09:43:41 -0700 Subject: [PATCH 004/220] Add keyboard shortcut for switching to previous label (#20426) * Add keyboard shortcut for switching to previous label * Update docs/docs/plus/annotating.md Co-authored-by: Blake Blackshear --------- Co-authored-by: Blake Blackshear --- docs/docs/plus/annotating.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/plus/annotating.md b/docs/docs/plus/annotating.md index 102e4a489..dc8e571be 100644 --- a/docs/docs/plus/annotating.md +++ b/docs/docs/plus/annotating.md @@ -42,6 +42,7 @@ Misidentified objects should have a correct label added. For example, if a perso | `w` | Add box | | `d` | Toggle difficult | | `s` | Switch to the next label | +| `Shift + s` | Switch to the previous label | | `tab` | Select next largest box | | `del` | Delete current box | | `esc` | Deselect/Cancel | From 925bf78811d4d35c77fb90934eec94cd176429c2 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 12 Oct 2025 06:28:08 -0600 Subject: [PATCH 005/220] Update review topic description (#20445) --- docs/docs/integrations/mqtt.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 3ad435b81..78b4b849c 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -161,7 +161,14 @@ Message published for updates to tracked object metadata, for example: ### `frigate/reviews` -Message published for each changed review item. The first message is published when the `detection` or `alert` is initiated. When additional objects are detected or when a zone change occurs, it will publish a, `update` message with the same id. When the review activity has ended a final `end` message is published. +Message published for each changed review item. The first message is published when the `detection` or `alert` is initiated. + +An `update` with the same ID will be published when: +- The severity changes from `detection` to `alert` +- Additional objects are detected +- An object is recognized via face, lpr, etc. + +When the review activity has ended a final `end` message is published. ```json { From 2a271c0f5ec6c0aeafd89026b47039beea656bf1 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:00:21 -0500 Subject: [PATCH 006/220] Update GenAI docs for Gemini model deprecation (#20462) --- docs/docs/configuration/genai.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index f76c075b7..9279e459d 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -18,10 +18,10 @@ genai: enabled: True provider: gemini api_key: "{FRIGATE_GEMINI_API_KEY}" - model: gemini-1.5-flash + model: gemini-2.0-flash cameras: - front_camera: + front_camera: genai: enabled: True # <- enable GenAI for your front camera use_snapshot: True @@ -30,7 +30,7 @@ cameras: required_zones: - steps indoor_camera: - genai: + genai: enabled: False # <- disable GenAI for your indoor camera ``` @@ -78,7 +78,7 @@ Google Gemini has a free tier allowing [15 queries per minute](https://ai.google ### Supported Models -You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini). At the time of writing, this includes `gemini-1.5-pro` and `gemini-1.5-flash`. +You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini). ### Get API Key @@ -96,7 +96,7 @@ genai: enabled: True provider: gemini api_key: "{FRIGATE_GEMINI_API_KEY}" - model: gemini-1.5-flash + model: gemini-2.0-flash ``` :::note @@ -202,7 +202,7 @@ genai: car: "Observe the primary vehicle in these images. Focus on its movement, direction, or purpose (e.g., parking, approaching, circling). If it's a delivery vehicle, mention the company." ``` -Prompts can also be overriden at the camera level to provide a more detailed prompt to the model about your specific camera, if you desire. +Prompts can also be overriden at the camera level to provide a more detailed prompt to the model about your specific camera, if you desire. ```yaml cameras: From e0a8445bac5d95c8f355c4dd1b3b7a03e351665d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 14 Oct 2025 07:32:44 -0600 Subject: [PATCH 007/220] Improve rf-detr export (#20485) --- docs/docs/configuration/object_detectors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 1e68d6ff4..6d5ea07c8 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -1012,9 +1012,9 @@ FROM python:3.11 AS build RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/* COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ WORKDIR /rfdetr -RUN uv pip install --system rfdetr onnx onnxruntime onnxsim onnx-graphsurgeon +RUN uv pip install --system rfdetr[onnxexport] ARG MODEL_SIZE -RUN python3 -c "from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export()" +RUN python3 -c "from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export(simplify=True)" FROM scratch ARG MODEL_SIZE COPY --from=build /rfdetr/output/inference_model.onnx /rfdetr-${MODEL_SIZE}.onnx From 4d582062fba09f69fb40658fbe02b4f527dc46af Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 14 Oct 2025 15:29:20 -0600 Subject: [PATCH 008/220] Ensure that a user must provide an image in an expected location (#20491) * Ensure that a user must provide an image in an expected location * Use const --- frigate/api/export.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frigate/api/export.py b/frigate/api/export.py index 160434c68..44ec05c15 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -8,6 +8,7 @@ from pathlib import Path import psutil from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse +from pathvalidate import sanitize_filepath from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict @@ -15,7 +16,7 @@ from frigate.api.auth import require_role from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.tags import Tags -from frigate.const import EXPORT_DIR +from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.models import Export, Previews, Recordings from frigate.record.export import ( PlaybackFactorEnum, @@ -54,7 +55,14 @@ def export_recording( playback_factor = body.playback playback_source = body.source friendly_name = body.name - existing_image = body.image_path + existing_image = sanitize_filepath(body.image_path) if body.image_path else None + + # Ensure that existing_image is a valid path + if existing_image and not existing_image.startswith(CLIPS_DIR): + return JSONResponse( + content=({"success": False, "message": "Invalid image path"}), + status_code=400, + ) if playback_source == "recordings": recordings_count = ( From 942a61ddfbff715d9268b9c4373ba832ebcc993b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 15 Oct 2025 06:53:31 -0500 Subject: [PATCH 009/220] version bump in docs (#20501) --- docs/docs/frigate/updating.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/frigate/updating.md b/docs/docs/frigate/updating.md index fdfbf906b..d95ae83c5 100644 --- a/docs/docs/frigate/updating.md +++ b/docs/docs/frigate/updating.md @@ -5,7 +5,7 @@ title: Updating # Updating Frigate -The current stable version of Frigate is **0.16.1**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.16.1). +The current stable version of Frigate is **0.16.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.16.2). Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant Addon, etc.). Below are instructions for the most common setups. @@ -33,21 +33,21 @@ If you’re running Frigate via Docker (recommended method), follow these steps: 2. **Update and Pull the Latest Image**: - If using Docker Compose: - - Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.16.1` instead of `0.15.2`). For example: + - Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.16.2` instead of `0.15.2`). For example: ```yaml services: frigate: - image: ghcr.io/blakeblackshear/frigate:0.16.1 + image: ghcr.io/blakeblackshear/frigate:0.16.2 ``` - Then pull the image: ```bash - docker pull ghcr.io/blakeblackshear/frigate:0.16.1 + docker pull ghcr.io/blakeblackshear/frigate:0.16.2 ``` - **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you don’t need to update the tag manually. The `stable` tag always points to the latest stable release after pulling. - If using `docker run`: - - Pull the image with the appropriate tag (e.g., `0.16.1`, `0.16.1-tensorrt`, or `stable`): + - Pull the image with the appropriate tag (e.g., `0.16.2`, `0.16.2-tensorrt`, or `stable`): ```bash - docker pull ghcr.io/blakeblackshear/frigate:0.16.1 + docker pull ghcr.io/blakeblackshear/frigate:0.16.2 ``` 3. **Start the Container**: From a4764563a5e980289fcad714d236b0ce87e95875 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 16 Oct 2025 06:56:37 -0600 Subject: [PATCH 010/220] Fix YOLOv9 export script (#20514) --- docs/docs/configuration/object_detectors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 6d5ea07c8..088ffc46c 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -1062,7 +1062,7 @@ COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ WORKDIR /yolov9 ADD https://github.com/WongKinYiu/yolov9.git . RUN uv pip install --system -r requirements.txt -RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier>=0.4.1 +RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier>=0.4.1 onnxscript ARG MODEL_SIZE ARG IMG_SIZE ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt From 2e7a2fd780008647560ee2be3f83d4163a5071b4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 16 Oct 2025 07:00:38 -0600 Subject: [PATCH 011/220] Store and show boxes for attributes in timeline (#20513) * Store and show boxes for attributes in timeline * Simplify --- frigate/timeline.py | 5 + .../overlay/TimelineDataOverlay.tsx | 101 ------------------ .../overlay/detail/ObjectLifecycle.tsx | 25 ++++- web/src/types/timeline.ts | 1 + 4 files changed, 29 insertions(+), 103 deletions(-) delete mode 100644 web/src/components/overlay/TimelineDataOverlay.tsx diff --git a/frigate/timeline.py b/frigate/timeline.py index 4c3d0d457..f8d341660 100644 --- a/frigate/timeline.py +++ b/frigate/timeline.py @@ -142,6 +142,11 @@ class TimelineProcessor(threading.Thread): timeline_entry[Timeline.data]["attribute"] = list( event_data["attributes"].keys() )[0] + timeline_entry[Timeline.data]["attribute_box"] = to_relative_box( + camera_config.detect.width, + camera_config.detect.height, + event_data["current_attributes"][0]["box"], + ) save = True elif event_type == EventStateEnum.end: timeline_entry[Timeline.class_type] = "gone" diff --git a/web/src/components/overlay/TimelineDataOverlay.tsx b/web/src/components/overlay/TimelineDataOverlay.tsx deleted file mode 100644 index a0d6190f6..000000000 --- a/web/src/components/overlay/TimelineDataOverlay.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { ObjectLifecycleSequence } from "@/types/timeline"; -import { useState } from "react"; - -type TimelineEventOverlayProps = { - timeline: ObjectLifecycleSequence; - cameraConfig: { - detect: { - width: number; - height: number; - }; - }; -}; - -export default function TimelineEventOverlay({ - timeline, - cameraConfig, -}: TimelineEventOverlayProps) { - const [isHovering, setIsHovering] = useState(false); - const getHoverStyle = () => { - if (!timeline.data.box) { - return {}; - } - - if (boxLeftEdge < 15) { - // show object stats on right side - return { - left: `${boxLeftEdge + timeline.data.box[2] * 100 + 1}%`, - top: `${boxTopEdge}%`, - }; - } - - return { - right: `${boxRightEdge + timeline.data.box[2] * 100 + 1}%`, - top: `${boxTopEdge}%`, - }; - }; - - const getObjectArea = () => { - if (!timeline.data.box) { - return 0; - } - - const width = timeline.data.box[2] * cameraConfig.detect.width; - const height = timeline.data.box[3] * cameraConfig.detect.height; - return Math.round(width * height); - }; - - const getObjectRatio = () => { - if (!timeline.data.box) { - return 0.0; - } - - const width = timeline.data.box[2] * cameraConfig.detect.width; - const height = timeline.data.box[3] * cameraConfig.detect.height; - return Math.round(100 * (width / height)) / 100; - }; - - if (!timeline.data.box) { - return null; - } - - const boxLeftEdge = Math.round(timeline.data.box[0] * 100); - const boxTopEdge = Math.round(timeline.data.box[1] * 100); - const boxRightEdge = Math.round( - (1 - timeline.data.box[2] - timeline.data.box[0]) * 100, - ); - const boxBottomEdge = Math.round( - (1 - timeline.data.box[3] - timeline.data.box[1]) * 100, - ); - - return ( - <> -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - onTouchStart={() => setIsHovering(true)} - onTouchEnd={() => setIsHovering(false)} - style={{ - left: `${boxLeftEdge}%`, - top: `${boxTopEdge}%`, - right: `${boxRightEdge}%`, - bottom: `${boxBottomEdge}%`, - }} - > - {timeline.class_type == "entered_zone" ? ( -
- ) : null} -
- {isHovering && ( -
-
{`Area: ${getObjectArea()} px`}
-
{`Ratio: ${getObjectRatio()}`}
-
- )} - - ); -} diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index 3fc702854..c06afd1e9 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -151,6 +151,8 @@ export default function ObjectLifecycle({ ); const [boxStyle, setBoxStyle] = useState(null); + const [attributeBoxStyle, setAttributeBoxStyle] = + useState(null); const configAnnotationOffset = useMemo(() => { if (!config) { @@ -218,7 +220,7 @@ export default function ObjectLifecycle({ const [timeIndex, setTimeIndex] = useState(0); const handleSetBox = useCallback( - (box: number[]) => { + (box: number[], attrBox: number[] | undefined) => { if (imgRef.current && Array.isArray(box) && box.length === 4) { const imgElement = imgRef.current; const imgRect = imgElement.getBoundingClientRect(); @@ -231,6 +233,19 @@ export default function ObjectLifecycle({ borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`, }; + if (attrBox) { + const attrStyle = { + left: `${attrBox[0] * imgRect.width}px`, + top: `${attrBox[1] * imgRect.height}px`, + width: `${attrBox[2] * imgRect.width}px`, + height: `${attrBox[3] * imgRect.height}px`, + borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`, + }; + setAttributeBoxStyle(attrStyle); + } else { + setAttributeBoxStyle(null); + } + setBoxStyle(style); } }, @@ -292,7 +307,10 @@ export default function ObjectLifecycle({ } else { // lifecycle point setTimeIndex(eventSequence?.[current].timestamp); - handleSetBox(eventSequence?.[current].data.box ?? []); + handleSetBox( + eventSequence?.[current].data.box ?? [], + eventSequence?.[current].data?.attribute_box, + ); setLifecycleZones(eventSequence?.[current].data.zones); } setSelectedZone(""); @@ -448,6 +466,9 @@ export default function ObjectLifecycle({
)} + {attributeBoxStyle && ( +
+ )} {imgRef.current?.width && imgRef.current?.height && pathPoints && diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts index 45a0821ed..850b75dc5 100644 --- a/web/src/types/timeline.ts +++ b/web/src/types/timeline.ts @@ -20,6 +20,7 @@ export type ObjectLifecycleSequence = { box?: [number, number, number, number]; region: [number, number, number, number]; attribute: string; + attribute_box?: [number, number, number, number]; zones: string[]; }; class_type: LifecycleClassType; From b52044aecc77e7731a15ca6270c892064da92091 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 16 Oct 2025 08:24:14 -0500 Subject: [PATCH 012/220] Add Detail stream in History view (#20525) * new type * activity stream panel * use context provider for activity stream * new activity stream panel in history view * overlay for object tracking details in history view * use overlay in video player * don't refetch timeline * fix activity stream group from being highlighted prematurely * use annotation offset * fix scrolling and use custom hook for interaction * memoize to prevent unnecessary renders * i18n and timestamp formatting * add annotation offset slider * bg color * add collapsible component * refactor * rename activity to detail * fix merge conflicts * i18n * more i18n --- web/package-lock.json | 166 +++++ web/package.json | 1 + web/public/locales/en/views/events.json | 11 + .../components/overlay/ObjectTrackOverlay.tsx | 395 ++++++++++++ .../overlay/detail/AnnotationOffsetSlider.tsx | 95 +++ .../overlay/detail/AnnotationSettingsPane.tsx | 2 +- web/src/components/player/HlsVideoPlayer.tsx | 53 +- .../player/dynamic/DynamicVideoPlayer.tsx | 3 + web/src/components/timeline/DetailStream.tsx | 550 +++++++++++++++++ web/src/components/timeline/EventMenu.tsx | 87 +++ web/src/components/ui/collapsible.tsx | 9 + web/src/context/detail-stream-context.tsx | 82 +++ web/src/hooks/use-draggable-element.ts | 50 +- web/src/hooks/use-user-interaction.ts | 57 ++ web/src/types/timeline.ts | 2 +- web/src/views/recording/RecordingView.tsx | 581 ++++++++++-------- 16 files changed, 1818 insertions(+), 326 deletions(-) create mode 100644 web/src/components/overlay/ObjectTrackOverlay.tsx create mode 100644 web/src/components/overlay/detail/AnnotationOffsetSlider.tsx create mode 100644 web/src/components/timeline/DetailStream.tsx create mode 100644 web/src/components/timeline/EventMenu.tsx create mode 100644 web/src/components/ui/collapsible.tsx create mode 100644 web/src/context/detail-stream-context.tsx create mode 100644 web/src/hooks/use-user-interaction.ts diff --git a/web/package-lock.json b/web/package-lock.json index fe1bad521..371defaaa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.6", @@ -1380,6 +1381,171 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", diff --git a/web/package.json b/web/package.json index c00bd77dd..27256bd81 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.6", diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index 77c626adf..732533ef2 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -18,6 +18,17 @@ "aria": "Select events", "noFoundForTimePeriod": "No events found for this time period." }, + "detail": { + "noDataFound": "No detail data to review", + "aria": "Toggle detail view", + "trackedObject_one": "tracked object", + "trackedObject_other": "tracked objects", + "noObjectDetailData": "No object detail data available." + }, + "objectTrack": { + "trackedPoint": "Tracked point", + "clickToSeek": "Click to seek to this time" + }, "documentTitle": "Review - Frigate", "recordings": { "documentTitle": "Recordings - Frigate" diff --git a/web/src/components/overlay/ObjectTrackOverlay.tsx b/web/src/components/overlay/ObjectTrackOverlay.tsx new file mode 100644 index 000000000..17526bb09 --- /dev/null +++ b/web/src/components/overlay/ObjectTrackOverlay.tsx @@ -0,0 +1,395 @@ +import { useMemo, useCallback } from "react"; +import { ObjectLifecycleSequence, LifecycleClassType } from "@/types/timeline"; +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import { useDetailStream } from "@/context/detail-stream-context"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +type ObjectTrackOverlayProps = { + camera: string; + selectedObjectId: string; + currentTime: number; + videoWidth: number; + videoHeight: number; + className?: string; + onSeekToTime?: (timestamp: number) => void; + objectTimeline?: ObjectLifecycleSequence[]; +}; + +export default function ObjectTrackOverlay({ + camera, + selectedObjectId, + currentTime, + videoWidth, + videoHeight, + className, + onSeekToTime, + objectTimeline, +}: ObjectTrackOverlayProps) { + const { t } = useTranslation("views/events"); + const { data: config } = useSWR("config"); + const { annotationOffset } = useDetailStream(); + + const effectiveCurrentTime = currentTime - annotationOffset / 1000; + + // Fetch the full event data to get saved path points + const { data: eventData } = useSWR(["event_ids", { ids: selectedObjectId }]); + + const typeColorMap = useMemo( + () => ({ + [LifecycleClassType.VISIBLE]: [0, 255, 0], // Green + [LifecycleClassType.GONE]: [255, 0, 0], // Red + [LifecycleClassType.ENTERED_ZONE]: [255, 165, 0], // Orange + [LifecycleClassType.ATTRIBUTE]: [128, 0, 128], // Purple + [LifecycleClassType.ACTIVE]: [255, 255, 0], // Yellow + [LifecycleClassType.STATIONARY]: [128, 128, 128], // Gray + [LifecycleClassType.HEARD]: [0, 255, 255], // Cyan + [LifecycleClassType.EXTERNAL]: [165, 42, 42], // Brown + }), + [], + ); + + const getObjectColor = useMemo(() => { + return (label: string) => { + const objectColor = config?.model?.colormap[label]; + if (objectColor) { + const reversed = [...objectColor].reverse(); + return `rgb(${reversed.join(",")})`; + } + return "rgb(255, 0, 0)"; // fallback red + }; + }, [config]); + + const getZoneColor = useCallback( + (zoneName: string) => { + const zoneColor = config?.cameras?.[camera]?.zones?.[zoneName]?.color; + if (zoneColor) { + const reversed = [...zoneColor].reverse(); + return `rgb(${reversed.join(",")})`; + } + return "rgb(255, 0, 0)"; // fallback red + }, + [config, camera], + ); + + const currentObjectZones = useMemo(() => { + if (!objectTimeline) return []; + + // Find the most recent timeline event at or before effective current time + const relevantEvents = objectTimeline + .filter((event) => event.timestamp <= effectiveCurrentTime) + .sort((a, b) => b.timestamp - a.timestamp); // Most recent first + + // Get zones from the most recent event + return relevantEvents[0]?.data?.zones || []; + }, [objectTimeline, effectiveCurrentTime]); + + const zones = useMemo(() => { + if (!config?.cameras?.[camera]?.zones || !currentObjectZones.length) + return []; + + return Object.entries(config.cameras[camera].zones) + .filter(([name]) => currentObjectZones.includes(name)) + .map(([name, zone]) => ({ + name, + coordinates: zone.coordinates, + color: getZoneColor(name), + })); + }, [config, camera, getZoneColor, currentObjectZones]); + + // get saved path points from event + const savedPathPoints = useMemo(() => { + return ( + eventData?.[0].data?.path_data?.map( + ([coords, timestamp]: [number[], number]) => ({ + x: coords[0], + y: coords[1], + timestamp, + lifecycle_item: undefined, + }), + ) || [] + ); + }, [eventData]); + + // timeline points for selected event + const eventSequencePoints = useMemo(() => { + return ( + objectTimeline + ?.filter((event) => event.data.box !== undefined) + .map((event) => { + const [left, top, width, height] = event.data.box!; + + return { + x: left + width / 2, // Center x + y: top + height, // Bottom y + timestamp: event.timestamp, + lifecycle_item: event, + }; + }) || [] + ); + }, [objectTimeline]); + + // final object path with timeline points included + const pathPoints = useMemo(() => { + // don't display a path for autotracking cameras + if (config?.cameras[camera]?.onvif.autotracking.enabled_in_config) + return []; + + const combinedPoints = [...savedPathPoints, ...eventSequencePoints].sort( + (a, b) => a.timestamp - b.timestamp, + ); + + // Filter points around current time (within a reasonable window) + const timeWindow = 30; // 30 seconds window + return combinedPoints.filter( + (point) => + point.timestamp >= currentTime - timeWindow && + point.timestamp <= currentTime + timeWindow, + ); + }, [savedPathPoints, eventSequencePoints, config, camera, currentTime]); + + // get absolute positions on the svg canvas for each point + const absolutePositions = useMemo(() => { + if (!pathPoints) return []; + + return pathPoints.map((point) => { + // Find the corresponding timeline entry for this point + const timelineEntry = objectTimeline?.find( + (entry) => entry.timestamp == point.timestamp, + ); + return { + x: point.x * videoWidth, + y: point.y * videoHeight, + timestamp: point.timestamp, + lifecycle_item: + timelineEntry || + (point.box // normal path point + ? { + timestamp: point.timestamp, + camera: camera, + source: "tracked_object", + source_id: selectedObjectId, + class_type: "visible" as LifecycleClassType, + data: { + camera: camera, + label: point.label, + sub_label: "", + box: point.box, + region: [0, 0, 0, 0], // placeholder + attribute: "", + zones: [], + }, + } + : undefined), + }; + }); + }, [ + pathPoints, + videoWidth, + videoHeight, + objectTimeline, + camera, + selectedObjectId, + ]); + + const generateStraightPath = useCallback( + (points: { x: number; y: number }[]) => { + if (!points || points.length < 2) return ""; + let path = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + path += ` L ${points[i].x} ${points[i].y}`; + } + return path; + }, + [], + ); + + const getPointColor = useCallback( + (baseColor: number[], type?: string) => { + if (type && typeColorMap[type as keyof typeof typeColorMap]) { + const typeColor = typeColorMap[type as keyof typeof typeColorMap]; + if (typeColor) { + return `rgb(${typeColor.join(",")})`; + } + } + // normal path point + return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`; + }, + [typeColorMap], + ); + + const handlePointClick = useCallback( + (timestamp: number) => { + onSeekToTime?.(timestamp); + }, + [onSeekToTime], + ); + + // render bounding box for object at current time if we have a timeline entry + const currentBoundingBox = useMemo(() => { + if (!objectTimeline) return null; + + // Find the most recent timeline event at or before effective current time with a bounding box + const relevantEvents = objectTimeline + .filter( + (event) => event.timestamp <= effectiveCurrentTime && event.data.box, + ) + .sort((a, b) => b.timestamp - a.timestamp); // Most recent first + + const currentEvent = relevantEvents[0]; + + if (!currentEvent?.data.box) return null; + + const [left, top, width, height] = currentEvent.data.box; + return { + left, + top, + width, + height, + centerX: left + width / 2, + centerY: top + height, + }; + }, [objectTimeline, effectiveCurrentTime]); + + const objectColor = useMemo(() => { + return pathPoints[0]?.label + ? getObjectColor(pathPoints[0].label) + : "rgb(255, 0, 0)"; + }, [pathPoints, getObjectColor]); + + const objectColorArray = useMemo(() => { + return pathPoints[0]?.label + ? getObjectColor(pathPoints[0].label).match(/\d+/g)?.map(Number) || [ + 255, 0, 0, + ] + : [255, 0, 0]; + }, [pathPoints, getObjectColor]); + + // render any zones for object at current time + const zonePolygons = useMemo(() => { + return zones.map((zone) => { + // Convert zone coordinates from normalized (0-1) to pixel coordinates + const points = zone.coordinates + .split(",") + .map(Number.parseFloat) + .reduce((acc: string[], value, index) => { + const isXCoordinate = index % 2 === 0; + const coordinate = isXCoordinate + ? value * videoWidth + : value * videoHeight; + acc.push(coordinate.toString()); + return acc; + }, []) + .join(","); + + return { + key: zone.name, + points, + fill: `rgba(${zone.color.replace("rgb(", "").replace(")", "")}, 0.3)`, + stroke: zone.color, + }; + }); + }, [zones, videoWidth, videoHeight]); + + if (!pathPoints.length || !config) { + return null; + } + + return ( + + {zonePolygons.map((zone) => ( + + ))} + + {absolutePositions.length > 1 && ( + + )} + + {absolutePositions.map((pos, index) => ( + + + handlePointClick(pos.timestamp)} + /> + + + + {pos.lifecycle_item + ? `${pos.lifecycle_item.class_type.replace("_", " ")} at ${new Date(pos.timestamp * 1000).toLocaleTimeString()}` + : t("objectTrack.trackedPoint")} + {onSeekToTime && ( +
+ {t("objectTrack.clickToSeek")} +
+ )} + + + + ))} + + {currentBoundingBox && ( + + + + + + )} + + ); +} diff --git a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx new file mode 100644 index 000000000..03aad4d60 --- /dev/null +++ b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx @@ -0,0 +1,95 @@ +import { useCallback, useState } from "react"; +import { Slider } from "@/components/ui/slider"; +import { Button } from "@/components/ui/button"; +import { useDetailStream } from "@/context/detail-stream-context"; +import axios from "axios"; +import { useSWRConfig } from "swr"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; + +type Props = { + className?: string; +}; + +export default function AnnotationOffsetSlider({ className }: Props) { + const { annotationOffset, setAnnotationOffset, camera } = useDetailStream(); + const { mutate } = useSWRConfig(); + const { t } = useTranslation(["views/explore"]); + const [isSaving, setIsSaving] = useState(false); + + const handleChange = useCallback( + (values: number[]) => { + if (!values || values.length === 0) return; + const valueMs = values[0]; + setAnnotationOffset(valueMs); + }, + [setAnnotationOffset], + ); + + const reset = useCallback(() => { + setAnnotationOffset(0); + }, [setAnnotationOffset]); + + const save = useCallback(async () => { + setIsSaving(true); + try { + // save value in milliseconds to config + await axios.put( + `config/set?cameras.${camera}.detect.annotation_offset=${annotationOffset}`, + { requires_restart: 0 }, + ); + + toast.success( + t("objectLifecycle.annotationSettings.offset.toast.success", { + camera, + }), + { position: "top-center" }, + ); + + // refresh config + await mutate("config"); + } catch (e: unknown) { + const err = e as { + response?: { data?: { message?: string } }; + message?: string; + }; + const errorMessage = + err?.response?.data?.message || err?.message || "Unknown error"; + toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), { + position: "top-center", + }); + } finally { + setIsSaving(false); + } + }, [annotationOffset, camera, mutate, t]); + + return ( +
+
+ Annotation offset (ms): {annotationOffset} +
+
+ +
+
+ + +
+
+ ); +} diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index 9e92bc011..56214b99d 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -174,7 +174,7 @@ export function AnnotationSettingsPane({ {t("objectLifecycle.annotationSettings.offset.label")}
-
+
diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 4b1bfe5ef..881e702ff 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -19,6 +19,8 @@ import { usePersistence } from "@/hooks/use-persistence"; import { cn } from "@/lib/utils"; import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; import { useTranslation } from "react-i18next"; +import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay"; +import { useDetailStream } from "@/context/detail-stream-context"; // Android native hls does not seek correctly const USE_NATIVE_HLS = !isAndroid; @@ -47,6 +49,7 @@ type HlsVideoPlayerProps = { onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; onPlaying?: () => void; + onSeekToTime?: (timestamp: number) => void; setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; toggleFullscreen?: () => void; @@ -66,6 +69,7 @@ export default function HlsVideoPlayer({ onPlayerLoaded, onTimeUpdate, onPlaying, + onSeekToTime, setFullResolution, onUploadFrame, toggleFullscreen, @@ -73,6 +77,13 @@ export default function HlsVideoPlayer({ }: HlsVideoPlayerProps) { const { t } = useTranslation("components/player"); const { data: config } = useSWR("config"); + const { + selectedObjectId, + selectedObjectTimeline, + currentTime, + camera, + isDetailMode, + } = useDetailStream(); // playback @@ -84,17 +95,19 @@ export default function HlsVideoPlayer({ const handleLoadedMetadata = useCallback(() => { setLoadedMetadata(true); if (videoRef.current) { + const width = videoRef.current.videoWidth; + const height = videoRef.current.videoHeight; + if (setFullResolution) { setFullResolution({ - width: videoRef.current.videoWidth, - height: videoRef.current.videoHeight, + width, + height, }); } - setTallCamera( - videoRef.current.videoWidth / videoRef.current.videoHeight < - ASPECT_VERTICAL_LAYOUT, - ); + setVideoDimensions({ width, height }); + + setTallCamera(width / height < ASPECT_VERTICAL_LAYOUT); } }, [videoRef, setFullResolution]); @@ -174,6 +187,10 @@ export default function HlsVideoPlayer({ const [controls, setControls] = useState(isMobile); const [controlsOpen, setControlsOpen] = useState(false); const [zoomScale, setZoomScale] = useState(1.0); + const [videoDimensions, setVideoDimensions] = useState<{ + width: number; + height: number; + }>({ width: 0, height: 0 }); useEffect(() => { if (!isDesktop) { @@ -296,6 +313,30 @@ export default function HlsVideoPlayer({ height: isMobile ? "100%" : undefined, }} > + {isDetailMode && + selectedObjectId && + camera && + currentTime && + videoDimensions.width > 0 && + videoDimensions.height > 0 && ( +
+ { + if (onSeekToTime) { + onSeekToTime(timestamp); + } + }} + objectTimeline={selectedObjectTimeline} + /> +
+ )}