mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +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 os
 | 
			
		||||
import traceback
 | 
			
		||||
from collections import defaultdict
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
from functools import reduce
 | 
			
		||||
 | 
			
		||||
import numpy as np
 | 
			
		||||
import pandas as pd
 | 
			
		||||
import requests
 | 
			
		||||
from flask import (
 | 
			
		||||
    Blueprint,
 | 
			
		||||
@ -31,7 +28,7 @@ from frigate.api.review import ReviewBp
 | 
			
		||||
from frigate.config import FrigateConfig
 | 
			
		||||
from frigate.const import CONFIG_DIR
 | 
			
		||||
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.ptz.onvif import OnvifController
 | 
			
		||||
from frigate.stats.emitter import StatsEmitter
 | 
			
		||||
@ -632,102 +629,3 @@ def hourly_timeline():
 | 
			
		||||
            "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 functools import reduce
 | 
			
		||||
 | 
			
		||||
import pandas as pd
 | 
			
		||||
from flask import (
 | 
			
		||||
    Blueprint,
 | 
			
		||||
    jsonify,
 | 
			
		||||
@ -12,7 +13,7 @@ from flask import (
 | 
			
		||||
)
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
@ -258,3 +259,66 @@ def delete_reviews(ids: str):
 | 
			
		||||
    ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
 | 
			
		||||
 | 
			
		||||
    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]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!controller) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (onControllerReady) {
 | 
			
		||||
      onControllerReady(controller);
 | 
			
		||||
    }
 | 
			
		||||
  }, [controller, onControllerReady]);
 | 
			
		||||
 | 
			
		||||
  const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true);
 | 
			
		||||
 | 
			
		||||
  // keyboard control
 | 
			
		||||
@ -215,6 +225,10 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
    );
 | 
			
		||||
    setCurrentPreview(preview);
 | 
			
		||||
 | 
			
		||||
    if (preview && previewRef.current) {
 | 
			
		||||
      previewRef.current.load();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    controller.newPlayback({
 | 
			
		||||
      recordings: recordings ?? [],
 | 
			
		||||
      playbackUri,
 | 
			
		||||
@ -283,27 +297,20 @@ export default function DynamicVideoPlayer({
 | 
			
		||||
      )}
 | 
			
		||||
      <video
 | 
			
		||||
        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"
 | 
			
		||||
        autoPlay
 | 
			
		||||
        playsInline
 | 
			
		||||
        muted
 | 
			
		||||
        onSeeked={onPreviewSeeked}
 | 
			
		||||
        onLoadedData={() => controller.previewReady()}
 | 
			
		||||
        onLoadStart={
 | 
			
		||||
          previewOnly && onControllerReady
 | 
			
		||||
            ? () => {
 | 
			
		||||
                onControllerReady(controller);
 | 
			
		||||
              }
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        {currentPreview != undefined && (
 | 
			
		||||
          <source src={currentPreview.src} type={currentPreview.type} />
 | 
			
		||||
        )}
 | 
			
		||||
      </video>
 | 
			
		||||
      {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>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
@ -9,9 +9,8 @@ import {
 | 
			
		||||
} from "react";
 | 
			
		||||
import MotionSegment from "./MotionSegment";
 | 
			
		||||
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 { MockMotionData } from "@/pages/UIPlayground";
 | 
			
		||||
 | 
			
		||||
export type MotionReviewTimelineProps = {
 | 
			
		||||
  segmentDuration: number;
 | 
			
		||||
@ -25,7 +24,7 @@ export type MotionReviewTimelineProps = {
 | 
			
		||||
  minimapStartTime?: number;
 | 
			
		||||
  minimapEndTime?: number;
 | 
			
		||||
  events: ReviewSegment[];
 | 
			
		||||
  motion_events: MockMotionData[];
 | 
			
		||||
  motion_events: MotionData[];
 | 
			
		||||
  severityType: ReviewSeverity;
 | 
			
		||||
  contentRef: RefObject<HTMLDivElement>;
 | 
			
		||||
  onHandlebarDraggingChange?: (isDragging: boolean) => void;
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { useEventUtils } from "@/hooks/use-event-utils";
 | 
			
		||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
 | 
			
		||||
import { ReviewSegment } from "@/types/review";
 | 
			
		||||
import { MotionData, ReviewSegment } from "@/types/review";
 | 
			
		||||
import React, {
 | 
			
		||||
  RefObject,
 | 
			
		||||
  useCallback,
 | 
			
		||||
@ -10,13 +10,12 @@ import React, {
 | 
			
		||||
} from "react";
 | 
			
		||||
import scrollIntoView from "scroll-into-view-if-needed";
 | 
			
		||||
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
 | 
			
		||||
import { MockMotionData } from "@/pages/UIPlayground";
 | 
			
		||||
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
 | 
			
		||||
import { isMobile } from "react-device-detect";
 | 
			
		||||
 | 
			
		||||
type MotionSegmentProps = {
 | 
			
		||||
  events: ReviewSegment[];
 | 
			
		||||
  motion_events: MockMotionData[];
 | 
			
		||||
  motion_events: MotionData[];
 | 
			
		||||
  segmentTime: number;
 | 
			
		||||
  segmentDuration: number;
 | 
			
		||||
  timestampSpread: number;
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
import { useCallback, useMemo } from "react";
 | 
			
		||||
import { MockMotionData } from "@/pages/UIPlayground";
 | 
			
		||||
import { MotionData } from "@/types/review";
 | 
			
		||||
 | 
			
		||||
export const useMotionSegmentUtils = (
 | 
			
		||||
  segmentDuration: number,
 | 
			
		||||
  motion_events: MockMotionData[],
 | 
			
		||||
  motion_events: MotionData[],
 | 
			
		||||
) => {
 | 
			
		||||
  const halfSegmentDuration = useMemo(
 | 
			
		||||
    () => segmentDuration / 2,
 | 
			
		||||
@ -47,7 +47,7 @@ export const useMotionSegmentUtils = (
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return matchingEvent?.motionValue ?? 0;
 | 
			
		||||
      return matchingEvent?.motion ?? 0;
 | 
			
		||||
    },
 | 
			
		||||
    [motion_events, getSegmentStart, getSegmentEnd],
 | 
			
		||||
  );
 | 
			
		||||
@ -61,7 +61,7 @@ export const useMotionSegmentUtils = (
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return matchingEvent?.audioValue ?? 0;
 | 
			
		||||
      return matchingEvent?.audio ?? 0;
 | 
			
		||||
    },
 | 
			
		||||
    [motion_events, getSegmentStart, getSegmentEnd],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
@ -240,22 +240,37 @@ export default function Events() {
 | 
			
		||||
 | 
			
		||||
  // selected items
 | 
			
		||||
 | 
			
		||||
  const selectedData = useMemo(() => {
 | 
			
		||||
  const selectedReviewData = useMemo(() => {
 | 
			
		||||
    if (!config) {
 | 
			
		||||
      return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!selectedReviewId) {
 | 
			
		||||
      return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!reviewPages) {
 | 
			
		||||
      return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!selectedReviewId) {
 | 
			
		||||
      return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras);
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
      (item) => item.id == selectedReviewId,
 | 
			
		||||
    );
 | 
			
		||||
@ -265,7 +280,9 @@ export default function Events() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      selected: selectedReview,
 | 
			
		||||
      camera: selectedReview.camera,
 | 
			
		||||
      severity: selectedReview.severity,
 | 
			
		||||
      start_time: selectedReview.start_time,
 | 
			
		||||
      allCameras: allCameras,
 | 
			
		||||
      cameraSegments: allReviews.filter((seg) =>
 | 
			
		||||
        allCameras.includes(seg.camera),
 | 
			
		||||
@ -280,12 +297,14 @@ export default function Events() {
 | 
			
		||||
    return <ActivityIndicator />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedData) {
 | 
			
		||||
  if (selectedReviewData) {
 | 
			
		||||
    if (isMobile) {
 | 
			
		||||
      return (
 | 
			
		||||
        <MobileRecordingView
 | 
			
		||||
          reviewItems={selectedData.cameraSegments}
 | 
			
		||||
          selectedReview={selectedData.selected}
 | 
			
		||||
          reviewItems={selectedReviewData.cameraSegments}
 | 
			
		||||
          startCamera={selectedReviewData.camera}
 | 
			
		||||
          startTime={selectedReviewData.start_time}
 | 
			
		||||
          severity={selectedReviewData.severity}
 | 
			
		||||
          relevantPreviews={allPreviews}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
@ -293,11 +312,11 @@ export default function Events() {
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <DesktopRecordingView
 | 
			
		||||
        startCamera={selectedData.selected.camera}
 | 
			
		||||
        startTime={selectedData.selected.start_time}
 | 
			
		||||
        allCameras={selectedData.allCameras}
 | 
			
		||||
        severity={selectedData.selected.severity}
 | 
			
		||||
        reviewItems={selectedData.cameraSegments}
 | 
			
		||||
        startCamera={selectedReviewData.camera}
 | 
			
		||||
        startTime={selectedReviewData.start_time}
 | 
			
		||||
        allCameras={selectedReviewData.allCameras}
 | 
			
		||||
        severity={selectedReviewData.severity}
 | 
			
		||||
        reviewItems={selectedReviewData.cameraSegments}
 | 
			
		||||
        allPreviews={allPreviews}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,12 @@ import useSWR from "swr";
 | 
			
		||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
			
		||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
 | 
			
		||||
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 CameraActivityIndicator from "@/components/indicators/CameraActivityIndicator";
 | 
			
		||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
 | 
			
		||||
@ -53,14 +58,7 @@ function ColorSwatch({ name, value }: { name: string; value: string }) {
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type MockMotionData = {
 | 
			
		||||
  start_time: number;
 | 
			
		||||
  end_time: number;
 | 
			
		||||
  motionValue: number;
 | 
			
		||||
  audioValue: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function generateRandomMotionAudioData(): MockMotionData[] {
 | 
			
		||||
function generateRandomMotionAudioData(): MotionData[] {
 | 
			
		||||
  const now = new Date();
 | 
			
		||||
  const endTime = now.getTime() / 1000;
 | 
			
		||||
  const startTime = endTime - 24 * 60 * 60; // 24 hours ago
 | 
			
		||||
@ -72,14 +70,12 @@ function generateRandomMotionAudioData(): MockMotionData[] {
 | 
			
		||||
    startTimestamp < endTime;
 | 
			
		||||
    startTimestamp += interval
 | 
			
		||||
  ) {
 | 
			
		||||
    const endTimestamp = startTimestamp + interval;
 | 
			
		||||
    const motionValue = Math.floor(Math.random() * 101); // Random number between 0 and 100
 | 
			
		||||
    const audioValue = Math.random() * -100; // Random negative value between -100 and 0
 | 
			
		||||
    const motion = Math.floor(Math.random() * 101); // Random number between 0 and 100
 | 
			
		||||
    const audio = Math.random() * -100; // Random negative value between -100 and 0
 | 
			
		||||
    data.push({
 | 
			
		||||
      start_time: startTimestamp,
 | 
			
		||||
      end_time: endTimestamp,
 | 
			
		||||
      motionValue,
 | 
			
		||||
      audioValue,
 | 
			
		||||
      motion,
 | 
			
		||||
      audio,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -126,7 +122,7 @@ function UIPlayground() {
 | 
			
		||||
  const { data: config } = useSWR<FrigateConfig>("config");
 | 
			
		||||
  const contentRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]);
 | 
			
		||||
  const [mockMotionData, setMockMotionData] = useState<MockMotionData[]>([]);
 | 
			
		||||
  const [mockMotionData, setMockMotionData] = useState<MotionData[]>([]);
 | 
			
		||||
  const [handlebarTime, setHandlebarTime] = useState(
 | 
			
		||||
    Math.floor(Date.now() / 1000) - 15 * 60,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
@ -37,3 +37,9 @@ export type ReviewSummary = {
 | 
			
		||||
  total_detection: 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 { Preview } from "@/types/preview";
 | 
			
		||||
import {
 | 
			
		||||
  MotionData,
 | 
			
		||||
  ReviewFilter,
 | 
			
		||||
  ReviewSegment,
 | 
			
		||||
  ReviewSeverity,
 | 
			
		||||
@ -33,6 +34,7 @@ import { isDesktop, isMobile } from "react-device-detect";
 | 
			
		||||
import { LuFolderCheck } from "react-icons/lu";
 | 
			
		||||
import { MdCircle } from "react-icons/md";
 | 
			
		||||
import useSWR from "swr";
 | 
			
		||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
 | 
			
		||||
 | 
			
		||||
type EventViewProps = {
 | 
			
		||||
  reviewPages?: ReviewSegment[][];
 | 
			
		||||
@ -284,6 +286,7 @@ export default function EventView({
 | 
			
		||||
            relevantPreviews={relevantPreviews}
 | 
			
		||||
            timeRange={timeRange}
 | 
			
		||||
            filter={filter}
 | 
			
		||||
            onSelectReview={onSelectReview}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
@ -526,6 +529,7 @@ type MotionReviewProps = {
 | 
			
		||||
  relevantPreviews?: Preview[];
 | 
			
		||||
  timeRange: { before: number; after: number };
 | 
			
		||||
  filter?: ReviewFilter;
 | 
			
		||||
  onSelectReview: (data: string, ctrl: boolean) => void;
 | 
			
		||||
};
 | 
			
		||||
function MotionReview({
 | 
			
		||||
  contentRef,
 | 
			
		||||
@ -533,6 +537,7 @@ function MotionReview({
 | 
			
		||||
  relevantPreviews,
 | 
			
		||||
  timeRange,
 | 
			
		||||
  filter,
 | 
			
		||||
  onSelectReview,
 | 
			
		||||
}: MotionReviewProps) {
 | 
			
		||||
  const segmentDuration = 30;
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
  const lastFullHour = useMemo(() => {
 | 
			
		||||
@ -580,6 +596,7 @@ function MotionReview({
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // move to next clip
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (
 | 
			
		||||
      !videoPlayersRef.current &&
 | 
			
		||||
@ -638,12 +655,15 @@ function MotionReview({
 | 
			
		||||
                videoPlayersRef.current[camera.name] = controller;
 | 
			
		||||
                setPlayerReady(true);
 | 
			
		||||
              }}
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                onSelectReview(`motion,${camera.name},${currentTime}`, false)
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
 | 
			
		||||
        <EventReviewTimeline
 | 
			
		||||
        <MotionReviewTimeline
 | 
			
		||||
          segmentDuration={segmentDuration}
 | 
			
		||||
          timestampSpread={15}
 | 
			
		||||
          timelineStart={timeRangeSegments.end}
 | 
			
		||||
@ -652,6 +672,7 @@ function MotionReview({
 | 
			
		||||
          handlebarTime={currentTime}
 | 
			
		||||
          setHandlebarTime={setCurrentTime}
 | 
			
		||||
          events={reviewItems.all}
 | 
			
		||||
          motion_events={motionData ?? []}
 | 
			
		||||
          severityType="significant_motion"
 | 
			
		||||
          contentRef={contentRef}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
@ -2,13 +2,17 @@ import DynamicVideoPlayer, {
 | 
			
		||||
  DynamicVideoController,
 | 
			
		||||
} from "@/components/player/DynamicVideoPlayer";
 | 
			
		||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
 | 
			
		||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Preview } from "@/types/preview";
 | 
			
		||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
 | 
			
		||||
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
 | 
			
		||||
import { getChunkedTimeDay } from "@/utils/timelineUtil";
 | 
			
		||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 | 
			
		||||
import { IoMdArrowRoundBack } from "react-icons/io";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import useSWR from "swr";
 | 
			
		||||
 | 
			
		||||
const SEGMENT_DURATION = 30;
 | 
			
		||||
 | 
			
		||||
type DesktopRecordingViewProps = {
 | 
			
		||||
  startCamera: string;
 | 
			
		||||
@ -116,6 +120,21 @@ export function DesktopRecordingView({
 | 
			
		||||
    [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 (
 | 
			
		||||
    <div ref={contentRef} className="relative size-full">
 | 
			
		||||
      <Button
 | 
			
		||||
@ -181,6 +200,7 @@ export function DesktopRecordingView({
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="absolute overflow-hidden w-56 inset-y-0 right-0">
 | 
			
		||||
        {severity != "significant_motion" ? (
 | 
			
		||||
          <EventReviewTimeline
 | 
			
		||||
            segmentDuration={30}
 | 
			
		||||
            timestampSpread={15}
 | 
			
		||||
@ -194,18 +214,38 @@ export function DesktopRecordingView({
 | 
			
		||||
            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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type MobileRecordingViewProps = {
 | 
			
		||||
  selectedReview: ReviewSegment;
 | 
			
		||||
  startCamera: string;
 | 
			
		||||
  startTime: number;
 | 
			
		||||
  severity: ReviewSeverity;
 | 
			
		||||
  reviewItems: ReviewSegment[];
 | 
			
		||||
  relevantPreviews?: Preview[];
 | 
			
		||||
};
 | 
			
		||||
export function MobileRecordingView({
 | 
			
		||||
  selectedReview,
 | 
			
		||||
  startCamera,
 | 
			
		||||
  startTime,
 | 
			
		||||
  severity,
 | 
			
		||||
  reviewItems,
 | 
			
		||||
  relevantPreviews,
 | 
			
		||||
}: MobileRecordingViewProps) {
 | 
			
		||||
@ -219,16 +259,10 @@ export function MobileRecordingView({
 | 
			
		||||
 | 
			
		||||
  // timeline time
 | 
			
		||||
 | 
			
		||||
  const timeRange = useMemo(
 | 
			
		||||
    () => getChunkedTimeDay(selectedReview.start_time),
 | 
			
		||||
    [selectedReview],
 | 
			
		||||
  );
 | 
			
		||||
  const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]);
 | 
			
		||||
  const [selectedRangeIdx, setSelectedRangeIdx] = useState(
 | 
			
		||||
    timeRange.ranges.findIndex((chunk) => {
 | 
			
		||||
      return (
 | 
			
		||||
        chunk.start <= selectedReview.start_time &&
 | 
			
		||||
        chunk.end >= selectedReview.start_time
 | 
			
		||||
      );
 | 
			
		||||
      return chunk.start <= startTime && chunk.end >= startTime;
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
@ -251,7 +285,7 @@ export function MobileRecordingView({
 | 
			
		||||
 | 
			
		||||
  const [scrubbing, setScrubbing] = useState(false);
 | 
			
		||||
  const [currentTime, setCurrentTime] = useState<number>(
 | 
			
		||||
    selectedReview?.start_time || Date.now() / 1000,
 | 
			
		||||
    startTime || Date.now() / 1000,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
@ -269,6 +303,21 @@ export function MobileRecordingView({
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [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 (
 | 
			
		||||
    <div ref={contentRef} className="flex flex-col relative w-full h-full">
 | 
			
		||||
      <Button className="rounded-lg" onClick={() => navigate(-1)}>
 | 
			
		||||
@ -278,7 +327,7 @@ export function MobileRecordingView({
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <DynamicVideoPlayer
 | 
			
		||||
          camera={selectedReview.camera}
 | 
			
		||||
          camera={startCamera}
 | 
			
		||||
          timeRange={timeRange.ranges[selectedRangeIdx]}
 | 
			
		||||
          cameraPreviews={relevantPreviews || []}
 | 
			
		||||
          onControllerReady={(controller) => {
 | 
			
		||||
@ -288,15 +337,13 @@ export function MobileRecordingView({
 | 
			
		||||
              setCurrentTime(timestamp);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            controllerRef.current?.seekToTimestamp(
 | 
			
		||||
              selectedReview.start_time,
 | 
			
		||||
              true,
 | 
			
		||||
            );
 | 
			
		||||
            controllerRef.current?.seekToTimestamp(startTime, true);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="flex-grow overflow-hidden">
 | 
			
		||||
        {severity != "significant_motion" ? (
 | 
			
		||||
          <EventReviewTimeline
 | 
			
		||||
            segmentDuration={30}
 | 
			
		||||
            timestampSpread={15}
 | 
			
		||||
@ -306,10 +353,26 @@ export function MobileRecordingView({
 | 
			
		||||
            handlebarTime={currentTime}
 | 
			
		||||
            setHandlebarTime={setCurrentTime}
 | 
			
		||||
            events={reviewItems}
 | 
			
		||||
          severityType={selectedReview.severity}
 | 
			
		||||
            severityType={severity}
 | 
			
		||||
            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>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user