mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Add ability to submit frames from recordings (#11212)
* add ability to parse and upload image from recording to frigate+ * Show dialog with current frame to be uploaded * Implement uploading image in frontend * Cleanup * Update title
This commit is contained in:
		
							parent
							
								
									b69c1828cb
								
							
						
					
					
						commit
						e7950abec3
					
				@ -26,6 +26,7 @@ from frigate.const import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
 | 
					from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
 | 
				
			||||||
from frigate.util.builtin import get_tz_modifiers
 | 
					from frigate.util.builtin import get_tz_modifiers
 | 
				
			||||||
 | 
					from frigate.util.image import get_image_from_recording
 | 
				
			||||||
 | 
					
 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -205,30 +206,20 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
 | 
				
			|||||||
    try:
 | 
					    try:
 | 
				
			||||||
        recording: Recordings = recording_query.get()
 | 
					        recording: Recordings = recording_query.get()
 | 
				
			||||||
        time_in_segment = frame_time - recording.start_time
 | 
					        time_in_segment = frame_time - recording.start_time
 | 
				
			||||||
 | 
					        image_data = get_image_from_recording(recording.path, time_in_segment)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        ffmpeg_cmd = [
 | 
					        if not image_data:
 | 
				
			||||||
            "ffmpeg",
 | 
					            return make_response(
 | 
				
			||||||
            "-hide_banner",
 | 
					                jsonify(
 | 
				
			||||||
            "-loglevel",
 | 
					                    {
 | 
				
			||||||
            "warning",
 | 
					                        "success": False,
 | 
				
			||||||
            "-ss",
 | 
					                        "message": f"Unable to parse frame at time {frame_time}",
 | 
				
			||||||
            f"00:00:{time_in_segment}",
 | 
					                    }
 | 
				
			||||||
            "-i",
 | 
					                ),
 | 
				
			||||||
            recording.path,
 | 
					                404,
 | 
				
			||||||
            "-frames:v",
 | 
					 | 
				
			||||||
            "1",
 | 
					 | 
				
			||||||
            "-c:v",
 | 
					 | 
				
			||||||
            "png",
 | 
					 | 
				
			||||||
            "-f",
 | 
					 | 
				
			||||||
            "image2pipe",
 | 
					 | 
				
			||||||
            "-",
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        process = sp.run(
 | 
					 | 
				
			||||||
            ffmpeg_cmd,
 | 
					 | 
				
			||||||
            capture_output=True,
 | 
					 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        response = make_response(process.stdout)
 | 
					
 | 
				
			||||||
 | 
					        response = make_response(image_data)
 | 
				
			||||||
        response.headers["Content-Type"] = "image/png"
 | 
					        response.headers["Content-Type"] = "image/png"
 | 
				
			||||||
        return response
 | 
					        return response
 | 
				
			||||||
    except DoesNotExist:
 | 
					    except DoesNotExist:
 | 
				
			||||||
@ -243,6 +234,71 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@MediaBp.route("/<camera_name>/plus/<frame_time>", methods=("POST",))
 | 
				
			||||||
 | 
					def submit_recording_snapshot_to_plus(camera_name: str, frame_time: str):
 | 
				
			||||||
 | 
					    if camera_name not in current_app.frigate_config.cameras:
 | 
				
			||||||
 | 
					        return make_response(
 | 
				
			||||||
 | 
					            jsonify({"success": False, "message": "Camera not found"}),
 | 
				
			||||||
 | 
					            404,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    frame_time = float(frame_time)
 | 
				
			||||||
 | 
					    recording_query = (
 | 
				
			||||||
 | 
					        Recordings.select(
 | 
				
			||||||
 | 
					            Recordings.path,
 | 
				
			||||||
 | 
					            Recordings.start_time,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .where(
 | 
				
			||||||
 | 
					            (
 | 
				
			||||||
 | 
					                (frame_time >= Recordings.start_time)
 | 
				
			||||||
 | 
					                & (frame_time <= Recordings.end_time)
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .where(Recordings.camera == camera_name)
 | 
				
			||||||
 | 
					        .order_by(Recordings.start_time.desc())
 | 
				
			||||||
 | 
					        .limit(1)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        recording: Recordings = recording_query.get()
 | 
				
			||||||
 | 
					        time_in_segment = frame_time - recording.start_time
 | 
				
			||||||
 | 
					        image_data = get_image_from_recording(recording.path, time_in_segment)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not image_data:
 | 
				
			||||||
 | 
					            return make_response(
 | 
				
			||||||
 | 
					                jsonify(
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        "success": False,
 | 
				
			||||||
 | 
					                        "message": f"Unable to parse frame at time {frame_time}",
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                404,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR)
 | 
				
			||||||
 | 
					        current_app.plus_api.upload_image(nd, camera_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return make_response(
 | 
				
			||||||
 | 
					            jsonify(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "success": True,
 | 
				
			||||||
 | 
					                    "message": "Successfully submitted image.",
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            200,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except DoesNotExist:
 | 
				
			||||||
 | 
					        return make_response(
 | 
				
			||||||
 | 
					            jsonify(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "success": False,
 | 
				
			||||||
 | 
					                    "message": "Recording not found at {}".format(frame_time),
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            404,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@MediaBp.route("/recordings/storage", methods=["GET"])
 | 
					@MediaBp.route("/recordings/storage", methods=["GET"])
 | 
				
			||||||
def get_recordings_storage_usage():
 | 
					def get_recordings_storage_usage():
 | 
				
			||||||
    recording_stats = current_app.stats_emitter.get_latest_stats()["service"][
 | 
					    recording_stats = current_app.stats_emitter.get_latest_stats()["service"][
 | 
				
			||||||
 | 
				
			|||||||
@ -2,6 +2,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import datetime
 | 
					import datetime
 | 
				
			||||||
import logging
 | 
					import logging
 | 
				
			||||||
 | 
					import subprocess as sp
 | 
				
			||||||
from abc import ABC, abstractmethod
 | 
					from abc import ABC, abstractmethod
 | 
				
			||||||
from multiprocessing import shared_memory
 | 
					from multiprocessing import shared_memory
 | 
				
			||||||
from string import printable
 | 
					from string import printable
 | 
				
			||||||
@ -746,3 +747,37 @@ def add_mask(mask: str, mask_img: np.ndarray):
 | 
				
			|||||||
        ]
 | 
					        ]
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    cv2.fillPoly(mask_img, pts=[contour], color=(0))
 | 
					    cv2.fillPoly(mask_img, pts=[contour], color=(0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_image_from_recording(
 | 
				
			||||||
 | 
					    file_path: str, relative_frame_time: float
 | 
				
			||||||
 | 
					) -> Optional[any]:
 | 
				
			||||||
 | 
					    """retrieve a frame from given time in recording file."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ffmpeg_cmd = [
 | 
				
			||||||
 | 
					        "ffmpeg",
 | 
				
			||||||
 | 
					        "-hide_banner",
 | 
				
			||||||
 | 
					        "-loglevel",
 | 
				
			||||||
 | 
					        "warning",
 | 
				
			||||||
 | 
					        "-ss",
 | 
				
			||||||
 | 
					        f"00:00:{relative_frame_time}",
 | 
				
			||||||
 | 
					        "-i",
 | 
				
			||||||
 | 
					        file_path,
 | 
				
			||||||
 | 
					        "-frames:v",
 | 
				
			||||||
 | 
					        "1",
 | 
				
			||||||
 | 
					        "-c:v",
 | 
				
			||||||
 | 
					        "png",
 | 
				
			||||||
 | 
					        "-f",
 | 
				
			||||||
 | 
					        "image2pipe",
 | 
				
			||||||
 | 
					        "-",
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    process = sp.run(
 | 
				
			||||||
 | 
					        ffmpeg_cmd,
 | 
				
			||||||
 | 
					        capture_output=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if process.returncode == 0:
 | 
				
			||||||
 | 
					        return process.stdout
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										21
									
								
								web/src/components/icons/FrigatePlusIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								web/src/components/icons/FrigatePlusIcon.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					import { LuPlus } from "react-icons/lu";
 | 
				
			||||||
 | 
					import Logo from "../Logo";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type FrigatePlusIconProps = {
 | 
				
			||||||
 | 
					  className?: string;
 | 
				
			||||||
 | 
					  onClick?: () => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export default function FrigatePlusIcon({
 | 
				
			||||||
 | 
					  className,
 | 
				
			||||||
 | 
					  onClick,
 | 
				
			||||||
 | 
					}: FrigatePlusIconProps) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      className={`relative flex items-center ${className ?? ""}`}
 | 
				
			||||||
 | 
					      onClick={onClick}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Logo className="size-full" />
 | 
				
			||||||
 | 
					      <LuPlus className="absolute size-2 translate-x-3 translate-y-3/4" />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -10,6 +10,10 @@ import { isAndroid, isDesktop, isMobile } from "react-device-detect";
 | 
				
			|||||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
 | 
					import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
 | 
				
			||||||
import VideoControls from "./VideoControls";
 | 
					import VideoControls from "./VideoControls";
 | 
				
			||||||
import { VideoResolutionType } from "@/types/live";
 | 
					import { VideoResolutionType } from "@/types/live";
 | 
				
			||||||
 | 
					import useSWR from "swr";
 | 
				
			||||||
 | 
					import { FrigateConfig } from "@/types/frigateConfig";
 | 
				
			||||||
 | 
					import { AxiosResponse } from "axios";
 | 
				
			||||||
 | 
					import { toast } from "sonner";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Android native hls does not seek correctly
 | 
					// Android native hls does not seek correctly
 | 
				
			||||||
const USE_NATIVE_HLS = !isAndroid;
 | 
					const USE_NATIVE_HLS = !isAndroid;
 | 
				
			||||||
@ -29,6 +33,7 @@ type HlsVideoPlayerProps = {
 | 
				
			|||||||
  onTimeUpdate?: (time: number) => void;
 | 
					  onTimeUpdate?: (time: number) => void;
 | 
				
			||||||
  onPlaying?: () => void;
 | 
					  onPlaying?: () => void;
 | 
				
			||||||
  setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
 | 
					  setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
 | 
				
			||||||
 | 
					  onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export default function HlsVideoPlayer({
 | 
					export default function HlsVideoPlayer({
 | 
				
			||||||
  videoRef,
 | 
					  videoRef,
 | 
				
			||||||
@ -40,7 +45,10 @@ export default function HlsVideoPlayer({
 | 
				
			|||||||
  onTimeUpdate,
 | 
					  onTimeUpdate,
 | 
				
			||||||
  onPlaying,
 | 
					  onPlaying,
 | 
				
			||||||
  setFullResolution,
 | 
					  setFullResolution,
 | 
				
			||||||
 | 
					  onUploadFrame,
 | 
				
			||||||
}: HlsVideoPlayerProps) {
 | 
					}: HlsVideoPlayerProps) {
 | 
				
			||||||
 | 
					  const { data: config } = useSWR<FrigateConfig>("config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // playback
 | 
					  // playback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const hlsRef = useRef<Hls>();
 | 
					  const hlsRef = useRef<Hls>();
 | 
				
			||||||
@ -137,10 +145,15 @@ export default function HlsVideoPlayer({
 | 
				
			|||||||
        className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
 | 
					        className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
 | 
				
			||||||
        video={videoRef.current}
 | 
					        video={videoRef.current}
 | 
				
			||||||
        isPlaying={isPlaying}
 | 
					        isPlaying={isPlaying}
 | 
				
			||||||
        show={visible && controls}
 | 
					        show={visible && (controls || controlsOpen)}
 | 
				
			||||||
        muted={muted}
 | 
					        muted={muted}
 | 
				
			||||||
        volume={volume}
 | 
					        volume={volume}
 | 
				
			||||||
        controlsOpen={controlsOpen}
 | 
					        features={{
 | 
				
			||||||
 | 
					          volume: true,
 | 
				
			||||||
 | 
					          seek: true,
 | 
				
			||||||
 | 
					          playbackRate: true,
 | 
				
			||||||
 | 
					          plusUpload: config?.plus?.enabled == true,
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
        setControlsOpen={setControlsOpen}
 | 
					        setControlsOpen={setControlsOpen}
 | 
				
			||||||
        setMuted={setMuted}
 | 
					        setMuted={setMuted}
 | 
				
			||||||
        playbackRate={videoRef.current?.playbackRate ?? 1}
 | 
					        playbackRate={videoRef.current?.playbackRate ?? 1}
 | 
				
			||||||
@ -168,6 +181,21 @@ export default function HlsVideoPlayer({
 | 
				
			|||||||
        onSetPlaybackRate={(rate) =>
 | 
					        onSetPlaybackRate={(rate) =>
 | 
				
			||||||
          videoRef.current ? (videoRef.current.playbackRate = rate) : null
 | 
					          videoRef.current ? (videoRef.current.playbackRate = rate) : null
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					        onUploadFrame={async () => {
 | 
				
			||||||
 | 
					          if (videoRef.current && onUploadFrame) {
 | 
				
			||||||
 | 
					            const resp = await onUploadFrame(videoRef.current.currentTime);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (resp && resp.status == 200) {
 | 
				
			||||||
 | 
					              toast.success("Successfully submitted frame to Frigate Plus", {
 | 
				
			||||||
 | 
					                position: "top-center",
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					              toast.success("Failed to submit frame to Frigate Plus", {
 | 
				
			||||||
 | 
					                position: "top-center",
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <TransformComponent
 | 
					      <TransformComponent
 | 
				
			||||||
        wrapperStyle={{
 | 
					        wrapperStyle={{
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { useCallback, useMemo } from "react";
 | 
					import { useCallback, useMemo, useState } from "react";
 | 
				
			||||||
import { isSafari } from "react-device-detect";
 | 
					import { isSafari } from "react-device-detect";
 | 
				
			||||||
import { LuPause, LuPlay } from "react-icons/lu";
 | 
					import { LuPause, LuPlay } from "react-icons/lu";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@ -18,17 +18,30 @@ import {
 | 
				
			|||||||
} from "react-icons/md";
 | 
					} from "react-icons/md";
 | 
				
			||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
 | 
					import useKeyboardListener from "@/hooks/use-keyboard-listener";
 | 
				
			||||||
import { VolumeSlider } from "../ui/slider";
 | 
					import { VolumeSlider } from "../ui/slider";
 | 
				
			||||||
 | 
					import FrigatePlusIcon from "../icons/FrigatePlusIcon";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  AlertDialog,
 | 
				
			||||||
 | 
					  AlertDialogAction,
 | 
				
			||||||
 | 
					  AlertDialogCancel,
 | 
				
			||||||
 | 
					  AlertDialogContent,
 | 
				
			||||||
 | 
					  AlertDialogFooter,
 | 
				
			||||||
 | 
					  AlertDialogHeader,
 | 
				
			||||||
 | 
					  AlertDialogTitle,
 | 
				
			||||||
 | 
					  AlertDialogTrigger,
 | 
				
			||||||
 | 
					} from "../ui/alert-dialog";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type VideoControls = {
 | 
					type VideoControls = {
 | 
				
			||||||
  volume?: boolean;
 | 
					  volume?: boolean;
 | 
				
			||||||
  seek?: boolean;
 | 
					  seek?: boolean;
 | 
				
			||||||
  playbackRate?: boolean;
 | 
					  playbackRate?: boolean;
 | 
				
			||||||
 | 
					  plusUpload?: boolean;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const CONTROLS_DEFAULT: VideoControls = {
 | 
					const CONTROLS_DEFAULT: VideoControls = {
 | 
				
			||||||
  volume: true,
 | 
					  volume: true,
 | 
				
			||||||
  seek: true,
 | 
					  seek: true,
 | 
				
			||||||
  playbackRate: true,
 | 
					  playbackRate: true,
 | 
				
			||||||
 | 
					  plusUpload: false,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
 | 
					const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -40,7 +53,6 @@ type VideoControlsProps = {
 | 
				
			|||||||
  show: boolean;
 | 
					  show: boolean;
 | 
				
			||||||
  muted?: boolean;
 | 
					  muted?: boolean;
 | 
				
			||||||
  volume?: number;
 | 
					  volume?: number;
 | 
				
			||||||
  controlsOpen?: boolean;
 | 
					 | 
				
			||||||
  playbackRates?: number[];
 | 
					  playbackRates?: number[];
 | 
				
			||||||
  playbackRate: number;
 | 
					  playbackRate: number;
 | 
				
			||||||
  hotKeys?: boolean;
 | 
					  hotKeys?: boolean;
 | 
				
			||||||
@ -49,6 +61,7 @@ type VideoControlsProps = {
 | 
				
			|||||||
  onPlayPause: (play: boolean) => void;
 | 
					  onPlayPause: (play: boolean) => void;
 | 
				
			||||||
  onSeek: (diff: number) => void;
 | 
					  onSeek: (diff: number) => void;
 | 
				
			||||||
  onSetPlaybackRate: (rate: number) => void;
 | 
					  onSetPlaybackRate: (rate: number) => void;
 | 
				
			||||||
 | 
					  onUploadFrame?: () => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export default function VideoControls({
 | 
					export default function VideoControls({
 | 
				
			||||||
  className,
 | 
					  className,
 | 
				
			||||||
@ -58,7 +71,6 @@ export default function VideoControls({
 | 
				
			|||||||
  show,
 | 
					  show,
 | 
				
			||||||
  muted,
 | 
					  muted,
 | 
				
			||||||
  volume,
 | 
					  volume,
 | 
				
			||||||
  controlsOpen,
 | 
					 | 
				
			||||||
  playbackRates = PLAYBACK_RATE_DEFAULT,
 | 
					  playbackRates = PLAYBACK_RATE_DEFAULT,
 | 
				
			||||||
  playbackRate,
 | 
					  playbackRate,
 | 
				
			||||||
  hotKeys = true,
 | 
					  hotKeys = true,
 | 
				
			||||||
@ -67,6 +79,7 @@ export default function VideoControls({
 | 
				
			|||||||
  onPlayPause,
 | 
					  onPlayPause,
 | 
				
			||||||
  onSeek,
 | 
					  onSeek,
 | 
				
			||||||
  onSetPlaybackRate,
 | 
					  onSetPlaybackRate,
 | 
				
			||||||
 | 
					  onUploadFrame,
 | 
				
			||||||
}: VideoControlsProps) {
 | 
					}: VideoControlsProps) {
 | 
				
			||||||
  const onReplay = useCallback(
 | 
					  const onReplay = useCallback(
 | 
				
			||||||
    (e: React.MouseEvent<SVGElement>) => {
 | 
					    (e: React.MouseEvent<SVGElement>) => {
 | 
				
			||||||
@ -189,7 +202,6 @@ export default function VideoControls({
 | 
				
			|||||||
      )}
 | 
					      )}
 | 
				
			||||||
      {features.playbackRate && (
 | 
					      {features.playbackRate && (
 | 
				
			||||||
        <DropdownMenu
 | 
					        <DropdownMenu
 | 
				
			||||||
          open={controlsOpen == true}
 | 
					 | 
				
			||||||
          onOpenChange={(open) => {
 | 
					          onOpenChange={(open) => {
 | 
				
			||||||
            if (setControlsOpen) {
 | 
					            if (setControlsOpen) {
 | 
				
			||||||
              setControlsOpen(open);
 | 
					              setControlsOpen(open);
 | 
				
			||||||
@ -214,6 +226,84 @@ export default function VideoControls({
 | 
				
			|||||||
          </DropdownMenuContent>
 | 
					          </DropdownMenuContent>
 | 
				
			||||||
        </DropdownMenu>
 | 
					        </DropdownMenu>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					      {features.plusUpload && onUploadFrame && (
 | 
				
			||||||
 | 
					        <FrigatePlusUploadButton
 | 
				
			||||||
 | 
					          video={video}
 | 
				
			||||||
 | 
					          onClose={() => {
 | 
				
			||||||
 | 
					            if (setControlsOpen) {
 | 
				
			||||||
 | 
					              setControlsOpen(false);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          onOpen={() => {
 | 
				
			||||||
 | 
					            onPlayPause(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (setControlsOpen) {
 | 
				
			||||||
 | 
					              setControlsOpen(true);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          onUploadFrame={onUploadFrame}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type FrigatePlusUploadButtonProps = {
 | 
				
			||||||
 | 
					  video?: HTMLVideoElement | null;
 | 
				
			||||||
 | 
					  onOpen: () => void;
 | 
				
			||||||
 | 
					  onClose: () => void;
 | 
				
			||||||
 | 
					  onUploadFrame: () => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					function FrigatePlusUploadButton({
 | 
				
			||||||
 | 
					  video,
 | 
				
			||||||
 | 
					  onOpen,
 | 
				
			||||||
 | 
					  onClose,
 | 
				
			||||||
 | 
					  onUploadFrame,
 | 
				
			||||||
 | 
					}: FrigatePlusUploadButtonProps) {
 | 
				
			||||||
 | 
					  const [videoImg, setVideoImg] = useState<string>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <AlertDialog
 | 
				
			||||||
 | 
					      onOpenChange={(open) => {
 | 
				
			||||||
 | 
					        if (!open) {
 | 
				
			||||||
 | 
					          onClose();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <AlertDialogTrigger asChild>
 | 
				
			||||||
 | 
					        <FrigatePlusIcon
 | 
				
			||||||
 | 
					          className="size-5 cursor-pointer"
 | 
				
			||||||
 | 
					          onClick={() => {
 | 
				
			||||||
 | 
					            onOpen();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (video) {
 | 
				
			||||||
 | 
					              const videoSize = [video.clientWidth, video.clientHeight];
 | 
				
			||||||
 | 
					              const canvas = document.createElement("canvas");
 | 
				
			||||||
 | 
					              canvas.width = videoSize[0];
 | 
				
			||||||
 | 
					              canvas.height = videoSize[1];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              const context = canvas?.getContext("2d");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              if (context) {
 | 
				
			||||||
 | 
					                context.drawImage(video, 0, 0, videoSize[0], videoSize[1]);
 | 
				
			||||||
 | 
					                setVideoImg(canvas.toDataURL("image/webp"));
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </AlertDialogTrigger>
 | 
				
			||||||
 | 
					      <AlertDialogContent className="md:max-w-[80%]">
 | 
				
			||||||
 | 
					        <AlertDialogHeader>
 | 
				
			||||||
 | 
					          <AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle>
 | 
				
			||||||
 | 
					        </AlertDialogHeader>
 | 
				
			||||||
 | 
					        <img className="w-full object-contain" src={videoImg} />
 | 
				
			||||||
 | 
					        <AlertDialogFooter>
 | 
				
			||||||
 | 
					          <AlertDialogAction className="bg-selected" onClick={onUploadFrame}>
 | 
				
			||||||
 | 
					            Submit
 | 
				
			||||||
 | 
					          </AlertDialogAction>
 | 
				
			||||||
 | 
					          <AlertDialogCancel>Cancel</AlertDialogCancel>
 | 
				
			||||||
 | 
					        </AlertDialogFooter>
 | 
				
			||||||
 | 
					      </AlertDialogContent>
 | 
				
			||||||
 | 
					    </AlertDialog>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -101,7 +101,7 @@ export class DynamicVideoController {
 | 
				
			|||||||
        this.playerController.pause();
 | 
					        this.playerController.pause();
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      console.log(`seek time is 0`);
 | 
					      // no op
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ import HlsVideoPlayer from "../HlsVideoPlayer";
 | 
				
			|||||||
import { TimeRange } from "@/types/timeline";
 | 
					import { TimeRange } from "@/types/timeline";
 | 
				
			||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
 | 
					import ActivityIndicator from "@/components/indicators/activity-indicator";
 | 
				
			||||||
import { VideoResolutionType } from "@/types/live";
 | 
					import { VideoResolutionType } from "@/types/live";
 | 
				
			||||||
 | 
					import axios from "axios";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Dynamically switches between video playback and scrubbing preview player.
 | 
					 * Dynamically switches between video playback and scrubbing preview player.
 | 
				
			||||||
@ -127,6 +128,18 @@ export default function DynamicVideoPlayer({
 | 
				
			|||||||
    [controller, onTimestampUpdate, isScrubbing, isLoading],
 | 
					    [controller, onTimestampUpdate, isScrubbing, isLoading],
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onUploadFrameToPlus = useCallback(
 | 
				
			||||||
 | 
					    (playTime: number) => {
 | 
				
			||||||
 | 
					      if (!controller) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const time = controller.getProgress(playTime);
 | 
				
			||||||
 | 
					      return axios.post(`/${camera}/plus/${time}`);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [camera, controller],
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // state of playback player
 | 
					  // state of playback player
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const recordingParams = useMemo(() => {
 | 
					  const recordingParams = useMemo(() => {
 | 
				
			||||||
@ -186,6 +199,7 @@ export default function DynamicVideoPlayer({
 | 
				
			|||||||
          setNoRecording(false);
 | 
					          setNoRecording(false);
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
        setFullResolution={setFullResolution}
 | 
					        setFullResolution={setFullResolution}
 | 
				
			||||||
 | 
					        onUploadFrame={onUploadFrameToPlus}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <PreviewPlayer
 | 
					      <PreviewPlayer
 | 
				
			||||||
        className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${className}`}
 | 
					        className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${className}`}
 | 
				
			||||||
 | 
				
			|||||||
@ -975,10 +975,9 @@ function MotionReview({
 | 
				
			|||||||
          playbackRate: true,
 | 
					          playbackRate: true,
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
        isPlaying={playing}
 | 
					        isPlaying={playing}
 | 
				
			||||||
        show={!scrubbing}
 | 
					        show={!scrubbing || controlsOpen}
 | 
				
			||||||
        playbackRates={[4, 8, 12, 16]}
 | 
					        playbackRates={[4, 8, 12, 16]}
 | 
				
			||||||
        playbackRate={playbackRate}
 | 
					        playbackRate={playbackRate}
 | 
				
			||||||
        controlsOpen={controlsOpen}
 | 
					 | 
				
			||||||
        setControlsOpen={setControlsOpen}
 | 
					        setControlsOpen={setControlsOpen}
 | 
				
			||||||
        onPlayPause={setPlaying}
 | 
					        onPlayPause={setPlaying}
 | 
				
			||||||
        onSeek={(diff) => {
 | 
					        onSeek={(diff) => {
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user