mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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:
parent
a174d82eb9
commit
9e8a42ca0e
@ -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)
|
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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],
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
|
};
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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,6 +200,7 @@ 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">
|
||||||
|
{severity != "significant_motion" ? (
|
||||||
<EventReviewTimeline
|
<EventReviewTimeline
|
||||||
segmentDuration={30}
|
segmentDuration={30}
|
||||||
timestampSpread={15}
|
timestampSpread={15}
|
||||||
@ -194,18 +214,38 @@ export function DesktopRecordingView({
|
|||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
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,15 +337,13 @@ 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">
|
||||||
|
{severity != "significant_motion" ? (
|
||||||
<EventReviewTimeline
|
<EventReviewTimeline
|
||||||
segmentDuration={30}
|
segmentDuration={30}
|
||||||
timestampSpread={15}
|
timestampSpread={15}
|
||||||
@ -306,10 +353,26 @@ export function MobileRecordingView({
|
|||||||
handlebarTime={currentTime}
|
handlebarTime={currentTime}
|
||||||
setHandlebarTime={setCurrentTime}
|
setHandlebarTime={setCurrentTime}
|
||||||
events={reviewItems}
|
events={reviewItems}
|
||||||
severityType={selectedReview.severity}
|
severityType={severity}
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
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>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user