From 8f69edeb333909b6893afd265ff7ecead919834b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 26 Mar 2024 15:37:45 -0600 Subject: [PATCH] Add ability to export from recordings page (#10692) * Add dialog to export recordings * Add export dialog functionality * Add ability to name exports * Add ability to choose custom time range on timeline * Add ability to choose custom time range on timeline * Add custom time selection * Make hot keys optional for typing name of export * Tweaks to dialog * Tweaks to dialog * round corners more * Final tweaks --- frigate/api/media.py | 3 + frigate/record/export.py | 22 +- web/src/components/overlay/ExportDialog.tsx | 401 ++++++++++++++++++ web/src/components/player/HlsVideoPlayer.tsx | 3 + web/src/components/player/VideoControls.tsx | 4 +- .../player/dynamic/DynamicVideoPlayer.tsx | 3 + web/src/pages/Export.tsx | 3 - web/src/types/filter.ts | 2 + web/src/views/events/EventView.tsx | 2 +- web/src/views/events/RecordingView.tsx | 58 ++- 10 files changed, 483 insertions(+), 18 deletions(-) create mode 100644 web/src/components/overlay/ExportDialog.tsx diff --git a/frigate/api/media.py b/frigate/api/media.py index 78e8c711e..c72d9b933 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -8,6 +8,7 @@ import re import subprocess as sp import time from datetime import datetime, timedelta, timezone +from typing import Optional from urllib.parse import unquote import cv2 @@ -618,6 +619,7 @@ def export_recording(camera_name: str, start_time, end_time): json: dict[str, any] = request.get_json(silent=True) or {} playback_factor = json.get("playback", "realtime") + name: Optional[str] = json.get("name") recordings_count = ( Recordings.select() @@ -641,6 +643,7 @@ def export_recording(camera_name: str, start_time, end_time): exporter = RecordingExporter( current_app.frigate_config, camera_name, + secure_filename(name.replace(" ", "_")) if name else None, int(start_time), int(end_time), ( diff --git a/frigate/record/export.py b/frigate/record/export.py index 65ebf13c9..f5861d4f7 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -38,6 +38,7 @@ class RecordingExporter(threading.Thread): self, config: FrigateConfig, camera: str, + name: str, start_time: int, end_time: int, playback_factor: PlaybackFactorEnum, @@ -45,6 +46,7 @@ class RecordingExporter(threading.Thread): threading.Thread.__init__(self) self.config = config self.camera = camera + self.user_provided_name = name self.start_time = start_time self.end_time = end_time self.playback_factor = playback_factor @@ -57,8 +59,12 @@ class RecordingExporter(threading.Thread): logger.debug( f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" ) - file_name = f"{EXPORT_DIR}/in_progress.{self.camera}@{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}.mp4" - final_file_name = f"{EXPORT_DIR}/{self.camera}_{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}.mp4" + file_name = ( + self.user_provided_name + or f"{self.camera}@{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}" + ) + file_path = f"{EXPORT_DIR}/in_progress.{file_name}.mp4" + final_file_path = f"{EXPORT_DIR}/{file_name}.mp4" if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" @@ -97,14 +103,14 @@ class RecordingExporter(threading.Thread): if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( - f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_name}" + f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_path}" ).split(" ") elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( parse_preset_hardware_acceleration_encode( self.config.ffmpeg.hwaccel_args, f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_name}", + f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_path}", EncodeTypeEnum.timelapse, ) ).split(" ") @@ -122,9 +128,9 @@ class RecordingExporter(threading.Thread): f"Failed to export recording for command {' '.join(ffmpeg_cmd)}" ) logger.error(p.stderr) - Path(file_name).unlink(missing_ok=True) + Path(file_path).unlink(missing_ok=True) return - logger.debug(f"Updating finalized export {file_name}") - os.rename(file_name, final_file_name) - logger.debug(f"Finished exporting {file_name}") + logger.debug(f"Updating finalized export {file_path}") + os.rename(file_path, final_file_path) + logger.debug(f"Finished exporting {file_path}") diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx new file mode 100644 index 000000000..ea55720e5 --- /dev/null +++ b/web/src/components/overlay/ExportDialog.tsx @@ -0,0 +1,401 @@ +import { useCallback, useMemo, useState } from "react"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Label } from "../ui/label"; +import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +import { Button } from "../ui/button"; +import { ExportMode } from "@/types/filter"; +import { FaArrowDown, FaArrowRight, FaCalendarAlt } from "react-icons/fa"; +import axios from "axios"; +import { toast } from "sonner"; +import { Input } from "../ui/input"; +import { TimeRange } from "@/types/timeline"; +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import ReviewActivityCalendar from "./ReviewActivityCalendar"; +import { SelectSeparator } from "../ui/select"; + +const EXPORT_OPTIONS = [ + "1", + "4", + "8", + "12", + "24", + "timeline", + "custom", +] as const; +type ExportOption = (typeof EXPORT_OPTIONS)[number]; + +type ExportDialogProps = { + camera: string; + latestTime: number; + currentTime: number; + range?: TimeRange; + mode: ExportMode; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; +}; +export default function ExportDialog({ + camera, + latestTime, + currentTime, + range, + mode, + setRange, + setMode, +}: ExportDialogProps) { + const [selectedOption, setSelectedOption] = useState("1"); + const [name, setName] = useState(""); + + const onSelectTime = useCallback( + (option: ExportOption) => { + setSelectedOption(option); + + const now = new Date(latestTime * 1000); + let start = 0; + switch (option) { + case "1": + now.setHours(now.getHours() - 1); + start = now.getTime() / 1000; + break; + case "4": + now.setHours(now.getHours() - 4); + start = now.getTime() / 1000; + break; + case "8": + now.setHours(now.getHours() - 8); + start = now.getTime() / 1000; + break; + case "12": + now.setHours(now.getHours() - 12); + start = now.getTime() / 1000; + break; + case "24": + now.setHours(now.getHours() - 24); + start = now.getTime() / 1000; + break; + } + + setRange({ + before: latestTime, + after: start, + }); + }, + [latestTime, setRange], + ); + + const onStartExport = useCallback(() => { + if (!range) { + toast.error("No valid time range selected", { position: "top-center" }); + return; + } + + axios + .post(`export/${camera}/start/${range.after}/end/${range.before}`, { + playback: "realtime", + name, + }) + .then((response) => { + if (response.status == 200) { + toast.success( + "Successfully started export. View the file in the /exports folder.", + { position: "top-center" }, + ); + setName(""); + setRange(undefined); + setSelectedOption("1"); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error( + `Failed to start export: ${error.response.data.message}`, + { position: "top-center" }, + ); + } else { + toast.error(`Failed to start export: ${error.message}`, { + position: "top-center", + }); + } + }); + }, [camera, name, range, setRange]); + + return ( + { + if (!open) { + setMode("none"); + } + }} + > + + + + + + Export + + + onSelectTime(value as ExportOption)} + > + {EXPORT_OPTIONS.map((opt) => { + return ( +
+ + +
+ ); + })} +
+ {selectedOption == "custom" && ( + + )} + setName(e.target.value)} + /> + + + setMode("none")}>Cancel + + +
+
+ ); +} + +type CustomTimeSelectorProps = { + latestTime: number; + range?: TimeRange; + setRange: (range: TimeRange | undefined) => void; +}; +function CustomTimeSelector({ + latestTime, + range, + setRange, +}: CustomTimeSelectorProps) { + const { data: config } = useSWR("config"); + + // times + + const startTime = useMemo( + () => range?.after || latestTime - 3600, + [range, latestTime], + ); + const endTime = useMemo( + () => range?.before || latestTime, + [range, latestTime], + ); + const formattedStart = useFormattedTimestamp( + startTime, + config?.ui.time_format == "24hour" + ? "%b %-d, %H:%M:%S" + : "%b %-d, %I:%M:%S %p", + ); + const formattedEnd = useFormattedTimestamp( + endTime, + config?.ui.time_format == "24hour" + ? "%b %-d, %H:%M:%S" + : "%b %-d, %I:%M:%S %p", + ); + + const startClock = useMemo(() => { + const date = new Date(startTime * 1000); + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; + }, [startTime]); + const endClock = useMemo(() => { + const date = new Date(endTime * 1000); + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; + }, [endTime]); + + // calendars + + const [startOpen, setStartOpen] = useState(false); + const [endOpen, setEndOpen] = useState(false); + + return ( +
+ + { + if (!open) { + setStartOpen(false); + } + }} + > + + + + + { + if (!day) { + return; + } + + setRange({ + before: endTime, + after: day.getTime() / 1000 + 1, + }); + }} + /> + + { + const clock = e.target.value; + const [hour, minute, second] = clock.split(":"); + const start = new Date(startTime * 1000); + start.setHours( + parseInt(hour), + parseInt(minute), + parseInt(second), + 0, + ); + setRange({ + before: endTime, + after: start.getTime() / 1000, + }); + }} + /> + + + + { + if (!open) { + setEndOpen(false); + } + }} + > + + + + + { + if (!day) { + return; + } + + setRange({ + after: startTime, + before: day.getTime() / 1000, + }); + }} + /> + + { + const clock = e.target.value; + const [hour, minute, second] = clock.split(":"); + const end = new Date(endTime * 1000); + end.setHours( + parseInt(hour), + parseInt(minute), + parseInt(second), + 0, + ); + setRange({ + before: end.getTime() / 1000, + after: startTime, + }); + }} + /> + + +
+ ); +} diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 9a7814798..e0d42d6e6 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -22,6 +22,7 @@ type HlsVideoPlayerProps = { videoRef: MutableRefObject; visible: boolean; currentSource: string; + hotKeys: boolean; onClipEnded?: () => void; onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; @@ -33,6 +34,7 @@ export default function HlsVideoPlayer({ videoRef, visible, currentSource, + hotKeys, onClipEnded, onPlayerLoaded, onTimeUpdate, @@ -161,6 +163,7 @@ export default function HlsVideoPlayer({ controlsOpen={controlsOpen} setControlsOpen={setControlsOpen} playbackRate={videoRef.current?.playbackRate ?? 1} + hotKeys={hotKeys} onPlayPause={(play) => { if (!videoRef.current) { return; diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index f6fa19d12..b4221c64b 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -41,6 +41,7 @@ type VideoControlsProps = { controlsOpen?: boolean; playbackRates?: number[]; playbackRate: number; + hotKeys?: boolean; setControlsOpen?: (open: boolean) => void; onPlayPause: (play: boolean) => void; onSeek: (diff: number) => void; @@ -55,6 +56,7 @@ export default function VideoControls({ controlsOpen, playbackRates = PLAYBACK_RATE_DEFAULT, playbackRate, + hotKeys = true, setControlsOpen, onPlayPause, onSeek, @@ -130,7 +132,7 @@ export default function VideoControls({ [video, isPlaying, onSeek], ); useKeyboardListener( - ["ArrowLeft", "ArrowRight", "m", " "], + hotKeys ? ["ArrowLeft", "ArrowRight", "m", " "] : [], onKeyboardShortcut, ); diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 3db528165..77e3b78a5 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -20,6 +20,7 @@ type DynamicVideoPlayerProps = { cameraPreviews: Preview[]; startTimestamp?: number; isScrubbing: boolean; + hotKeys: boolean; onControllerReady: (controller: DynamicVideoController) => void; onTimestampUpdate?: (timestamp: number) => void; onClipEnded?: () => void; @@ -31,6 +32,7 @@ export default function DynamicVideoPlayer({ cameraPreviews, startTimestamp, isScrubbing, + hotKeys, onControllerReady, onTimestampUpdate, onClipEnded, @@ -172,6 +174,7 @@ export default function DynamicVideoPlayer({ videoRef={playerRef} visible={!(isScrubbing || isLoading)} currentSource={source} + hotKeys={hotKeys} onTimeUpdate={onTimeUpdate} onPlayerLoaded={onPlayerLoaded} onClipEnded={onClipEnded} diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index 30148ba57..ff6f5765f 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -10,7 +10,6 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Toaster } from "@/components/ui/sonner"; import axios from "axios"; import { useCallback, useState } from "react"; import useSWR from "swr"; @@ -42,8 +41,6 @@ function Export() { return (
- - setDeleteClip(undefined)} diff --git a/web/src/types/filter.ts b/web/src/types/filter.ts index 722057fa1..228aea98f 100644 --- a/web/src/types/filter.ts +++ b/web/src/types/filter.ts @@ -1,3 +1,5 @@ // allow any // eslint-disable-next-line @typescript-eslint/no-explicit-any export type FilterType = { [searchKey: string]: any }; + +export type ExportMode = "select" | "timeline" | "none"; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 8c752b46a..3957cad82 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -496,7 +496,7 @@ function DetectionReview({ > {filter?.before == undefined && ( ("none"); + const [exportRange, setExportRange] = useState(); + // move to next clip const onClipEnded = useCallback(() => { @@ -210,6 +219,7 @@ export function RecordingView({ return (
+