Motion timeline data (#10245)

* Refactor activity api to send motion and audio data

* Prepare for using motion data timeline

* Get working

* reduce to 0

* fix

* Formatting

* fix typing

* add motion data to timelines and allow motion cameas to be selectable

* Fix tests

* cleanup

* Fix not loading preview when changing hours
This commit is contained in:
Nicolas Mowen 2024-03-05 12:55:44 -07:00 committed by GitHub
parent a174d82eb9
commit 9e8a42ca0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 269 additions and 197 deletions

View File

@ -5,12 +5,9 @@ import json
import logging import logging
import os import os
import traceback import traceback
from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import reduce from functools import reduce
import numpy as np
import pandas as pd
import requests import requests
from flask import ( from flask import (
Blueprint, Blueprint,
@ -31,7 +28,7 @@ from frigate.api.review import ReviewBp
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR from frigate.const import CONFIG_DIR
from frigate.events.external import ExternalEventProcessor from frigate.events.external import ExternalEventProcessor
from frigate.models import Event, Recordings, Timeline from frigate.models import Event, Timeline
from frigate.plus import PlusApi from frigate.plus import PlusApi
from frigate.ptz.onvif import OnvifController from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
@ -632,102 +629,3 @@ def hourly_timeline():
"hours": hours, "hours": hours,
} }
) )
@bp.route("/<camera_name>/recording/hourly/activity")
def hourly_timeline_activity(camera_name: str):
"""Get hourly summary for timeline."""
if camera_name not in current_app.frigate_config.cameras:
return make_response(
jsonify({"success": False, "message": "Camera not found"}),
404,
)
before = request.args.get("before", type=float, default=datetime.now())
after = request.args.get(
"after", type=float, default=datetime.now() - timedelta(hours=1)
)
tz_name = request.args.get("timezone", default="utc", type=str)
_, minute_modifier, _ = get_tz_modifiers(tz_name)
minute_offset = int(minute_modifier.split(" ")[0])
all_recordings: list[Recordings] = (
Recordings.select(
Recordings.start_time,
Recordings.duration,
Recordings.objects,
Recordings.motion,
)
.where(Recordings.camera == camera_name)
.where(Recordings.motion > 0)
.where((Recordings.start_time > after) & (Recordings.end_time < before))
.order_by(Recordings.start_time.asc())
.iterator()
)
# data format is ex:
# {timestamp: [{ date: 1, count: 1, type: motion }]}] }}
hours: dict[int, list[dict[str, any]]] = defaultdict(list)
key = datetime.fromtimestamp(after).replace(second=0, microsecond=0) + timedelta(
minutes=minute_offset
)
check = (key + timedelta(hours=1)).timestamp()
# set initial start so data is representative of full hour
hours[int(key.timestamp())].append(
[
key.timestamp(),
0,
False,
]
)
for recording in all_recordings:
if recording.start_time > check:
hours[int(key.timestamp())].append(
[
(key + timedelta(minutes=59, seconds=59)).timestamp(),
0,
False,
]
)
key = key + timedelta(hours=1)
check = (key + timedelta(hours=1)).timestamp()
hours[int(key.timestamp())].append(
[
key.timestamp(),
0,
False,
]
)
data_type = recording.objects > 0
count = recording.motion + recording.objects
hours[int(key.timestamp())].append(
[
recording.start_time + (recording.duration / 2),
0 if count == 0 else np.log2(count),
data_type,
]
)
# resample data using pandas to get activity on minute to minute basis
for key, data in hours.items():
df = pd.DataFrame(data, columns=["date", "count", "hasObjects"])
# set date as datetime index
df["date"] = pd.to_datetime(df["date"], unit="s")
df.set_index(["date"], inplace=True)
# normalize data
df = df.resample("T").mean().fillna(0)
# change types for output
df.index = df.index.astype(int) // (10**9)
df["count"] = df["count"].astype(int)
df["hasObjects"] = df["hasObjects"].astype(bool)
hours[key] = df.reset_index().to_dict("records")
return jsonify(hours)

View File

@ -4,6 +4,7 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import reduce from functools import reduce
import pandas as pd
from flask import ( from flask import (
Blueprint, Blueprint,
jsonify, jsonify,
@ -12,7 +13,7 @@ from flask import (
) )
from peewee import Case, DoesNotExist, fn, operator from peewee import Case, DoesNotExist, fn, operator
from frigate.models import ReviewSegment from frigate.models import Recordings, ReviewSegment
from frigate.util.builtin import get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -258,3 +259,66 @@ def delete_reviews(ids: str):
ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute() ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
return make_response(jsonify({"success": True, "message": "Delete reviews"}), 200) return make_response(jsonify({"success": True, "message": "Delete reviews"}), 200)
@ReviewBp.route("/review/activity")
def review_activity():
"""Get motion and audio activity."""
before = request.args.get("before", type=float, default=datetime.now().timestamp())
after = request.args.get(
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
)
# get scale in seconds
scale = request.args.get("scale", type=int, default=30)
all_recordings: list[Recordings] = (
Recordings.select(
Recordings.start_time,
Recordings.duration,
Recordings.objects,
Recordings.motion,
Recordings.dBFS,
)
.where((Recordings.start_time > after) & (Recordings.end_time < before))
.order_by(Recordings.start_time.asc())
.iterator()
)
# format is: { timestamp: segment_start_ts, motion: [0-100], audio: [0 - -100] }
# periods where active objects / audio was detected will cause motion / audio to be scaled down
data: list[dict[str, float]] = []
for rec in all_recordings:
data.append(
{
"start_time": rec.start_time,
"motion": rec.motion if rec.objects == 0 else 0,
"audio": rec.dBFS if rec.objects == 0 else 0,
}
)
# resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "motion", "audio"])
# set date as datetime index
df["start_time"] = pd.to_datetime(df["start_time"], unit="s")
df.set_index(["start_time"], inplace=True)
# normalize data
df = df.resample(f"{scale}S").mean().fillna(0.0)
df["motion"] = (
(df["motion"] - df["motion"].min())
/ (df["motion"].max() - df["motion"].min())
* 100
)
df["audio"] = (
(df["audio"] - df["audio"].max())
/ (df["audio"].min() - df["audio"].max())
* -100
)
# change types for output
df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records")
return jsonify(normalized)

View File

@ -85,6 +85,16 @@ export default function DynamicVideoPlayer({
); );
}, [camera, config, previewOnly]); }, [camera, config, previewOnly]);
useEffect(() => {
if (!controller) {
return;
}
if (onControllerReady) {
onControllerReady(controller);
}
}, [controller, onControllerReady]);
const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true); const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true);
// keyboard control // keyboard control
@ -215,6 +225,10 @@ export default function DynamicVideoPlayer({
); );
setCurrentPreview(preview); setCurrentPreview(preview);
if (preview && previewRef.current) {
previewRef.current.load();
}
controller.newPlayback({ controller.newPlayback({
recordings: recordings ?? [], recordings: recordings ?? [],
playbackUri, playbackUri,
@ -283,27 +297,20 @@ export default function DynamicVideoPlayer({
)} )}
<video <video
ref={previewRef} ref={previewRef}
className={`size-full rounded-2xl ${currentPreview != undefined && isScrubbing ? "visible" : "hidden"} ${tallVideo ? "aspect-tall" : ""} bg-black`} className={`size-full rounded-2xl ${currentPreview != undefined && (previewOnly || isScrubbing) ? "visible" : "hidden"} ${tallVideo ? "aspect-tall" : ""} bg-black`}
preload="auto" preload="auto"
autoPlay autoPlay
playsInline playsInline
muted muted
onSeeked={onPreviewSeeked} onSeeked={onPreviewSeeked}
onLoadedData={() => controller.previewReady()} onLoadedData={() => controller.previewReady()}
onLoadStart={
previewOnly && onControllerReady
? () => {
onControllerReady(controller);
}
: undefined
}
> >
{currentPreview != undefined && ( {currentPreview != undefined && (
<source src={currentPreview.src} type={currentPreview.type} /> <source src={currentPreview.src} type={currentPreview.type} />
)} )}
</video> </video>
{onClick && !hasRecordingAtTime && ( {onClick && !hasRecordingAtTime && (
<div className="absolute inset-0 z-10 bg-black bg-opacity-60" /> <div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-2xl" />
)} )}
</div> </div>
); );

View File

@ -9,9 +9,8 @@ import {
} from "react"; } from "react";
import MotionSegment from "./MotionSegment"; import MotionSegment from "./MotionSegment";
import { useEventUtils } from "@/hooks/use-event-utils"; import { useEventUtils } from "@/hooks/use-event-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
import ReviewTimeline from "./ReviewTimeline"; import ReviewTimeline from "./ReviewTimeline";
import { MockMotionData } from "@/pages/UIPlayground";
export type MotionReviewTimelineProps = { export type MotionReviewTimelineProps = {
segmentDuration: number; segmentDuration: number;
@ -25,7 +24,7 @@ export type MotionReviewTimelineProps = {
minimapStartTime?: number; minimapStartTime?: number;
minimapEndTime?: number; minimapEndTime?: number;
events: ReviewSegment[]; events: ReviewSegment[];
motion_events: MockMotionData[]; motion_events: MotionData[];
severityType: ReviewSeverity; severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>; contentRef: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void; onHandlebarDraggingChange?: (isDragging: boolean) => void;

View File

@ -1,6 +1,6 @@
import { useEventUtils } from "@/hooks/use-event-utils"; import { useEventUtils } from "@/hooks/use-event-utils";
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { ReviewSegment } from "@/types/review"; import { MotionData, ReviewSegment } from "@/types/review";
import React, { import React, {
RefObject, RefObject,
useCallback, useCallback,
@ -10,13 +10,12 @@ import React, {
} from "react"; } from "react";
import scrollIntoView from "scroll-into-view-if-needed"; import scrollIntoView from "scroll-into-view-if-needed";
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import { MockMotionData } from "@/pages/UIPlayground";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
type MotionSegmentProps = { type MotionSegmentProps = {
events: ReviewSegment[]; events: ReviewSegment[];
motion_events: MockMotionData[]; motion_events: MotionData[];
segmentTime: number; segmentTime: number;
segmentDuration: number; segmentDuration: number;
timestampSpread: number; timestampSpread: number;

View File

@ -1,9 +1,9 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { MockMotionData } from "@/pages/UIPlayground"; import { MotionData } from "@/types/review";
export const useMotionSegmentUtils = ( export const useMotionSegmentUtils = (
segmentDuration: number, segmentDuration: number,
motion_events: MockMotionData[], motion_events: MotionData[],
) => { ) => {
const halfSegmentDuration = useMemo( const halfSegmentDuration = useMemo(
() => segmentDuration / 2, () => segmentDuration / 2,
@ -47,7 +47,7 @@ export const useMotionSegmentUtils = (
); );
}); });
return matchingEvent?.motionValue ?? 0; return matchingEvent?.motion ?? 0;
}, },
[motion_events, getSegmentStart, getSegmentEnd], [motion_events, getSegmentStart, getSegmentEnd],
); );
@ -61,7 +61,7 @@ export const useMotionSegmentUtils = (
); );
}); });
return matchingEvent?.audioValue ?? 0; return matchingEvent?.audio ?? 0;
}, },
[motion_events, getSegmentStart, getSegmentEnd], [motion_events, getSegmentStart, getSegmentEnd],
); );

View File

@ -240,22 +240,37 @@ export default function Events() {
// selected items // selected items
const selectedData = useMemo(() => { const selectedReviewData = useMemo(() => {
if (!config) { if (!config) {
return undefined; return undefined;
} }
if (!selectedReviewId) {
return undefined;
}
if (!reviewPages) { if (!reviewPages) {
return undefined; return undefined;
} }
if (!selectedReviewId) {
return undefined;
}
const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras); const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras);
const allReviews = reviewPages.flat(); const allReviews = reviewPages.flat();
if (selectedReviewId.startsWith("motion")) {
const motionData = selectedReviewId.split(",");
// format is motion,camera,start_time
return {
camera: motionData[1],
severity: "significant_motion" as ReviewSeverity,
start_time: parseFloat(motionData[2]),
allCameras: allCameras,
cameraSegments: allReviews.filter((seg) =>
allCameras.includes(seg.camera),
),
};
}
const selectedReview = allReviews.find( const selectedReview = allReviews.find(
(item) => item.id == selectedReviewId, (item) => item.id == selectedReviewId,
); );
@ -265,7 +280,9 @@ export default function Events() {
} }
return { return {
selected: selectedReview, camera: selectedReview.camera,
severity: selectedReview.severity,
start_time: selectedReview.start_time,
allCameras: allCameras, allCameras: allCameras,
cameraSegments: allReviews.filter((seg) => cameraSegments: allReviews.filter((seg) =>
allCameras.includes(seg.camera), allCameras.includes(seg.camera),
@ -280,12 +297,14 @@ export default function Events() {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
if (selectedData) { if (selectedReviewData) {
if (isMobile) { if (isMobile) {
return ( return (
<MobileRecordingView <MobileRecordingView
reviewItems={selectedData.cameraSegments} reviewItems={selectedReviewData.cameraSegments}
selectedReview={selectedData.selected} startCamera={selectedReviewData.camera}
startTime={selectedReviewData.start_time}
severity={selectedReviewData.severity}
relevantPreviews={allPreviews} relevantPreviews={allPreviews}
/> />
); );
@ -293,11 +312,11 @@ export default function Events() {
return ( return (
<DesktopRecordingView <DesktopRecordingView
startCamera={selectedData.selected.camera} startCamera={selectedReviewData.camera}
startTime={selectedData.selected.start_time} startTime={selectedReviewData.start_time}
allCameras={selectedData.allCameras} allCameras={selectedReviewData.allCameras}
severity={selectedData.selected.severity} severity={selectedReviewData.severity}
reviewItems={selectedData.cameraSegments} reviewItems={selectedReviewData.cameraSegments}
allPreviews={allPreviews} allPreviews={allPreviews}
/> />
); );

View File

@ -4,7 +4,12 @@ import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import { ReviewData, ReviewSegment, ReviewSeverity } from "@/types/review"; import {
MotionData,
ReviewData,
ReviewSegment,
ReviewSeverity,
} from "@/types/review";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import CameraActivityIndicator from "@/components/indicators/CameraActivityIndicator"; import CameraActivityIndicator from "@/components/indicators/CameraActivityIndicator";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
@ -53,14 +58,7 @@ function ColorSwatch({ name, value }: { name: string; value: string }) {
); );
} }
export type MockMotionData = { function generateRandomMotionAudioData(): MotionData[] {
start_time: number;
end_time: number;
motionValue: number;
audioValue: number;
};
function generateRandomMotionAudioData(): MockMotionData[] {
const now = new Date(); const now = new Date();
const endTime = now.getTime() / 1000; const endTime = now.getTime() / 1000;
const startTime = endTime - 24 * 60 * 60; // 24 hours ago const startTime = endTime - 24 * 60 * 60; // 24 hours ago
@ -72,14 +70,12 @@ function generateRandomMotionAudioData(): MockMotionData[] {
startTimestamp < endTime; startTimestamp < endTime;
startTimestamp += interval startTimestamp += interval
) { ) {
const endTimestamp = startTimestamp + interval; const motion = Math.floor(Math.random() * 101); // Random number between 0 and 100
const motionValue = Math.floor(Math.random() * 101); // Random number between 0 and 100 const audio = Math.random() * -100; // Random negative value between -100 and 0
const audioValue = Math.random() * -100; // Random negative value between -100 and 0
data.push({ data.push({
start_time: startTimestamp, start_time: startTimestamp,
end_time: endTimestamp, motion,
motionValue, audio,
audioValue,
}); });
} }
@ -126,7 +122,7 @@ function UIPlayground() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]); const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]);
const [mockMotionData, setMockMotionData] = useState<MockMotionData[]>([]); const [mockMotionData, setMockMotionData] = useState<MotionData[]>([]);
const [handlebarTime, setHandlebarTime] = useState( const [handlebarTime, setHandlebarTime] = useState(
Math.floor(Date.now() / 1000) - 15 * 60, Math.floor(Date.now() / 1000) - 15 * 60,
); );

View File

@ -37,3 +37,9 @@ export type ReviewSummary = {
total_detection: number; total_detection: number;
total_motion: number; total_motion: number;
}; };
export type MotionData = {
start_time: number;
motion: number;
audio: number;
};

View File

@ -14,6 +14,7 @@ import { useScrollLockout } from "@/hooks/use-mouse-listener";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { import {
MotionData,
ReviewFilter, ReviewFilter,
ReviewSegment, ReviewSegment,
ReviewSeverity, ReviewSeverity,
@ -33,6 +34,7 @@ import { isDesktop, isMobile } from "react-device-detect";
import { LuFolderCheck } from "react-icons/lu"; import { LuFolderCheck } from "react-icons/lu";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
type EventViewProps = { type EventViewProps = {
reviewPages?: ReviewSegment[][]; reviewPages?: ReviewSegment[][];
@ -284,6 +286,7 @@ export default function EventView({
relevantPreviews={relevantPreviews} relevantPreviews={relevantPreviews}
timeRange={timeRange} timeRange={timeRange}
filter={filter} filter={filter}
onSelectReview={onSelectReview}
/> />
)} )}
</div> </div>
@ -526,6 +529,7 @@ type MotionReviewProps = {
relevantPreviews?: Preview[]; relevantPreviews?: Preview[];
timeRange: { before: number; after: number }; timeRange: { before: number; after: number };
filter?: ReviewFilter; filter?: ReviewFilter;
onSelectReview: (data: string, ctrl: boolean) => void;
}; };
function MotionReview({ function MotionReview({
contentRef, contentRef,
@ -533,6 +537,7 @@ function MotionReview({
relevantPreviews, relevantPreviews,
timeRange, timeRange,
filter, filter,
onSelectReview,
}: MotionReviewProps) { }: MotionReviewProps) {
const segmentDuration = 30; const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -561,6 +566,17 @@ function MotionReview({
{}, {},
); );
// motion data
const { data: motionData } = useSWR<MotionData[]>([
"review/activity",
{
before: timeRange.before,
after: timeRange.after,
scale: segmentDuration / 2,
},
]);
// timeline time // timeline time
const lastFullHour = useMemo(() => { const lastFullHour = useMemo(() => {
@ -580,6 +596,7 @@ function MotionReview({
); );
// move to next clip // move to next clip
useEffect(() => { useEffect(() => {
if ( if (
!videoPlayersRef.current && !videoPlayersRef.current &&
@ -638,12 +655,15 @@ function MotionReview({
videoPlayersRef.current[camera.name] = controller; videoPlayersRef.current[camera.name] = controller;
setPlayerReady(true); setPlayerReady(true);
}} }}
onClick={() =>
onSelectReview(`motion,${camera.name},${currentTime}`, false)
}
/> />
); );
})} })}
</div> </div>
<div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar"> <div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
<EventReviewTimeline <MotionReviewTimeline
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
timestampSpread={15} timestampSpread={15}
timelineStart={timeRangeSegments.end} timelineStart={timeRangeSegments.end}
@ -652,6 +672,7 @@ function MotionReview({
handlebarTime={currentTime} handlebarTime={currentTime}
setHandlebarTime={setCurrentTime} setHandlebarTime={setCurrentTime}
events={reviewItems.all} events={reviewItems.all}
motion_events={motionData ?? []}
severityType="significant_motion" severityType="significant_motion"
contentRef={contentRef} contentRef={contentRef}
/> />

View File

@ -2,13 +2,17 @@ import DynamicVideoPlayer, {
DynamicVideoController, DynamicVideoController,
} from "@/components/player/DynamicVideoPlayer"; } from "@/components/player/DynamicVideoPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
import { getChunkedTimeDay } from "@/utils/timelineUtil"; import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import useSWR from "swr";
const SEGMENT_DURATION = 30;
type DesktopRecordingViewProps = { type DesktopRecordingViewProps = {
startCamera: string; startCamera: string;
@ -116,6 +120,21 @@ export function DesktopRecordingView({
[allCameras, currentTime, mainCamera], [allCameras, currentTime, mainCamera],
); );
// motion timeline data
const { data: motionData } = useSWR<MotionData[]>(
severity == "significant_motion"
? [
"review/activity",
{
before: timeRange.end,
after: timeRange.start,
scale: SEGMENT_DURATION / 2,
},
]
: null,
);
return ( return (
<div ref={contentRef} className="relative size-full"> <div ref={contentRef} className="relative size-full">
<Button <Button
@ -181,31 +200,52 @@ export function DesktopRecordingView({
</div> </div>
<div className="absolute overflow-hidden w-56 inset-y-0 right-0"> <div className="absolute overflow-hidden w-56 inset-y-0 right-0">
<EventReviewTimeline {severity != "significant_motion" ? (
segmentDuration={30} <EventReviewTimeline
timestampSpread={15} segmentDuration={30}
timelineStart={timeRange.end} timestampSpread={15}
timelineEnd={timeRange.start} timelineStart={timeRange.end}
showHandlebar timelineEnd={timeRange.start}
handlebarTime={currentTime} showHandlebar
setHandlebarTime={setCurrentTime} handlebarTime={currentTime}
events={reviewItems} setHandlebarTime={setCurrentTime}
severityType={severity} events={reviewItems}
contentRef={contentRef} severityType={severity}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)} contentRef={contentRef}
/> onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
) : (
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineStart={timeRange.end}
timelineEnd={timeRange.start}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems}
motion_events={motionData ?? []}
severityType={severity}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
)}
</div> </div>
</div> </div>
); );
} }
type MobileRecordingViewProps = { type MobileRecordingViewProps = {
selectedReview: ReviewSegment; startCamera: string;
startTime: number;
severity: ReviewSeverity;
reviewItems: ReviewSegment[]; reviewItems: ReviewSegment[];
relevantPreviews?: Preview[]; relevantPreviews?: Preview[];
}; };
export function MobileRecordingView({ export function MobileRecordingView({
selectedReview, startCamera,
startTime,
severity,
reviewItems, reviewItems,
relevantPreviews, relevantPreviews,
}: MobileRecordingViewProps) { }: MobileRecordingViewProps) {
@ -219,16 +259,10 @@ export function MobileRecordingView({
// timeline time // timeline time
const timeRange = useMemo( const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]);
() => getChunkedTimeDay(selectedReview.start_time),
[selectedReview],
);
const [selectedRangeIdx, setSelectedRangeIdx] = useState( const [selectedRangeIdx, setSelectedRangeIdx] = useState(
timeRange.ranges.findIndex((chunk) => { timeRange.ranges.findIndex((chunk) => {
return ( return chunk.start <= startTime && chunk.end >= startTime;
chunk.start <= selectedReview.start_time &&
chunk.end >= selectedReview.start_time
);
}), }),
); );
@ -251,7 +285,7 @@ export function MobileRecordingView({
const [scrubbing, setScrubbing] = useState(false); const [scrubbing, setScrubbing] = useState(false);
const [currentTime, setCurrentTime] = useState<number>( const [currentTime, setCurrentTime] = useState<number>(
selectedReview?.start_time || Date.now() / 1000, startTime || Date.now() / 1000,
); );
useEffect(() => { useEffect(() => {
@ -269,6 +303,21 @@ export function MobileRecordingView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrubbing]); }, [scrubbing]);
// motion timeline data
const { data: motionData } = useSWR<MotionData[]>(
severity == "significant_motion"
? [
"review/activity",
{
before: timeRange.end,
after: timeRange.start,
scale: SEGMENT_DURATION / 2,
},
]
: null,
);
return ( return (
<div ref={contentRef} className="flex flex-col relative w-full h-full"> <div ref={contentRef} className="flex flex-col relative w-full h-full">
<Button className="rounded-lg" onClick={() => navigate(-1)}> <Button className="rounded-lg" onClick={() => navigate(-1)}>
@ -278,7 +327,7 @@ export function MobileRecordingView({
<div> <div>
<DynamicVideoPlayer <DynamicVideoPlayer
camera={selectedReview.camera} camera={startCamera}
timeRange={timeRange.ranges[selectedRangeIdx]} timeRange={timeRange.ranges[selectedRangeIdx]}
cameraPreviews={relevantPreviews || []} cameraPreviews={relevantPreviews || []}
onControllerReady={(controller) => { onControllerReady={(controller) => {
@ -288,28 +337,42 @@ export function MobileRecordingView({
setCurrentTime(timestamp); setCurrentTime(timestamp);
}); });
controllerRef.current?.seekToTimestamp( controllerRef.current?.seekToTimestamp(startTime, true);
selectedReview.start_time,
true,
);
}} }}
/> />
</div> </div>
<div className="flex-grow overflow-hidden"> <div className="flex-grow overflow-hidden">
<EventReviewTimeline {severity != "significant_motion" ? (
segmentDuration={30} <EventReviewTimeline
timestampSpread={15} segmentDuration={30}
timelineStart={timeRange.end} timestampSpread={15}
timelineEnd={timeRange.start} timelineStart={timeRange.end}
showHandlebar timelineEnd={timeRange.start}
handlebarTime={currentTime} showHandlebar
setHandlebarTime={setCurrentTime} handlebarTime={currentTime}
events={reviewItems} setHandlebarTime={setCurrentTime}
severityType={selectedReview.severity} events={reviewItems}
contentRef={contentRef} severityType={severity}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)} contentRef={contentRef}
/> onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
) : (
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineStart={timeRange.end}
timelineEnd={timeRange.start}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems}
motion_events={motionData ?? []}
severityType={severity}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
)}
</div> </div>
</div> </div>
); );