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
This commit is contained in:
Nicolas Mowen 2024-03-26 15:37:45 -06:00 committed by GitHub
parent c82ed43c13
commit 8f69edeb33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 483 additions and 18 deletions

View File

@ -8,6 +8,7 @@ import re
import subprocess as sp import subprocess as sp
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import unquote from urllib.parse import unquote
import cv2 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 {} json: dict[str, any] = request.get_json(silent=True) or {}
playback_factor = json.get("playback", "realtime") playback_factor = json.get("playback", "realtime")
name: Optional[str] = json.get("name")
recordings_count = ( recordings_count = (
Recordings.select() Recordings.select()
@ -641,6 +643,7 @@ def export_recording(camera_name: str, start_time, end_time):
exporter = RecordingExporter( exporter = RecordingExporter(
current_app.frigate_config, current_app.frigate_config,
camera_name, camera_name,
secure_filename(name.replace(" ", "_")) if name else None,
int(start_time), int(start_time),
int(end_time), int(end_time),
( (

View File

@ -38,6 +38,7 @@ class RecordingExporter(threading.Thread):
self, self,
config: FrigateConfig, config: FrigateConfig,
camera: str, camera: str,
name: str,
start_time: int, start_time: int,
end_time: int, end_time: int,
playback_factor: PlaybackFactorEnum, playback_factor: PlaybackFactorEnum,
@ -45,6 +46,7 @@ class RecordingExporter(threading.Thread):
threading.Thread.__init__(self) threading.Thread.__init__(self)
self.config = config self.config = config
self.camera = camera self.camera = camera
self.user_provided_name = name
self.start_time = start_time self.start_time = start_time
self.end_time = end_time self.end_time = end_time
self.playback_factor = playback_factor self.playback_factor = playback_factor
@ -57,8 +59,12 @@ class RecordingExporter(threading.Thread):
logger.debug( logger.debug(
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" 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" file_name = (
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" 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: 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" 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: if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = ( ffmpeg_cmd = (
f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_name}" f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_path}"
).split(" ") ).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = ( ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode( parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.hwaccel_args, self.config.ffmpeg.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", 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, EncodeTypeEnum.timelapse,
) )
).split(" ") ).split(" ")
@ -122,9 +128,9 @@ class RecordingExporter(threading.Thread):
f"Failed to export recording for command {' '.join(ffmpeg_cmd)}" f"Failed to export recording for command {' '.join(ffmpeg_cmd)}"
) )
logger.error(p.stderr) logger.error(p.stderr)
Path(file_name).unlink(missing_ok=True) Path(file_path).unlink(missing_ok=True)
return return
logger.debug(f"Updating finalized export {file_name}") logger.debug(f"Updating finalized export {file_path}")
os.rename(file_name, final_file_name) os.rename(file_path, final_file_path)
logger.debug(f"Finished exporting {file_name}") logger.debug(f"Finished exporting {file_path}")

View File

@ -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<ExportOption>("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 (
<Dialog
open={mode == "select"}
onOpenChange={(open) => {
if (!open) {
setMode("none");
}
}}
>
<DialogTrigger asChild>
<Button
className="flex items-center gap-2"
variant="secondary"
size="sm"
onClick={() => {
if (mode == "none") {
setMode("select");
} else if (mode == "timeline") {
onStartExport();
setMode("none");
}
}}
>
<FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" />
{mode != "timeline" ? "Export" : "Save"}
</Button>
</DialogTrigger>
<DialogContent className="sm:rounded-2xl">
<DialogHeader>
<DialogTitle>Export</DialogTitle>
</DialogHeader>
<SelectSeparator className="bg-secondary" />
<RadioGroup
className="flex flex-col gap-3"
onValueChange={(value) => onSelectTime(value as ExportOption)}
>
{EXPORT_OPTIONS.map((opt) => {
return (
<div className="flex items-center gap-2">
<RadioGroupItem
className={
opt == selectedOption
? "from-selected/50 to-selected/90 text-selected bg-selected"
: "from-secondary/50 to-secondary/90 text-secondary bg-secondary"
}
key={opt}
id={opt}
value={opt}
/>
<Label className="cursor-pointer capitalize" htmlFor={opt}>
{isNaN(parseInt(opt))
? opt == "timeline"
? "Select from Timeline"
: `${opt}`
: `Last ${opt > "1" ? `${opt} Hours` : "Hour"}`}
</Label>
</div>
);
})}
</RadioGroup>
{selectedOption == "custom" && (
<CustomTimeSelector
latestTime={latestTime}
range={range}
setRange={setRange}
/>
)}
<Input
className="mt-2"
type="search"
placeholder="Name the Export"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<SelectSeparator className="bg-secondary" />
<DialogFooter>
<DialogClose onClick={() => setMode("none")}>Cancel</DialogClose>
<Button
variant="select"
size="sm"
onClick={() => {
if (selectedOption == "timeline") {
setRange({ before: currentTime + 30, after: currentTime - 30 });
setMode("timeline");
} else {
onStartExport();
setMode("none");
}
}}
>
{selectedOption == "timeline" ? "Select" : "Export"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
type CustomTimeSelectorProps = {
latestTime: number;
range?: TimeRange;
setRange: (range: TimeRange | undefined) => void;
};
function CustomTimeSelector({
latestTime,
range,
setRange,
}: CustomTimeSelectorProps) {
const { data: config } = useSWR<FrigateConfig>("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 (
<div className="mx-8 px-2 flex items-center gap-2 bg-secondary rounded-lg">
<FaCalendarAlt />
<Popover
open={startOpen}
onOpenChange={(open) => {
if (!open) {
setStartOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
variant={startOpen ? "select" : "secondary"}
onClick={() => {
setStartOpen(true);
setEndOpen(false);
}}
>
{formattedStart}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<ReviewActivityCalendar
selectedDay={new Date(startTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
before: endTime,
after: day.getTime() / 1000 + 1,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="w-full mx-4 p-1 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
step="1"
onChange={(e) => {
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,
});
}}
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4" />
<Popover
open={endOpen}
onOpenChange={(open) => {
if (!open) {
setEndOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
variant={endOpen ? "select" : "secondary"}
onClick={() => {
setEndOpen(true);
setStartOpen(false);
}}
>
{formattedEnd}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<ReviewActivityCalendar
selectedDay={new Date(endTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
after: startTime,
before: day.getTime() / 1000,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="w-full mx-4 p-1 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={endClock}
step="1"
onChange={(e) => {
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,
});
}}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -22,6 +22,7 @@ type HlsVideoPlayerProps = {
videoRef: MutableRefObject<HTMLVideoElement | null>; videoRef: MutableRefObject<HTMLVideoElement | null>;
visible: boolean; visible: boolean;
currentSource: string; currentSource: string;
hotKeys: boolean;
onClipEnded?: () => void; onClipEnded?: () => void;
onPlayerLoaded?: () => void; onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void; onTimeUpdate?: (time: number) => void;
@ -33,6 +34,7 @@ export default function HlsVideoPlayer({
videoRef, videoRef,
visible, visible,
currentSource, currentSource,
hotKeys,
onClipEnded, onClipEnded,
onPlayerLoaded, onPlayerLoaded,
onTimeUpdate, onTimeUpdate,
@ -161,6 +163,7 @@ export default function HlsVideoPlayer({
controlsOpen={controlsOpen} controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen} setControlsOpen={setControlsOpen}
playbackRate={videoRef.current?.playbackRate ?? 1} playbackRate={videoRef.current?.playbackRate ?? 1}
hotKeys={hotKeys}
onPlayPause={(play) => { onPlayPause={(play) => {
if (!videoRef.current) { if (!videoRef.current) {
return; return;

View File

@ -41,6 +41,7 @@ type VideoControlsProps = {
controlsOpen?: boolean; controlsOpen?: boolean;
playbackRates?: number[]; playbackRates?: number[];
playbackRate: number; playbackRate: number;
hotKeys?: boolean;
setControlsOpen?: (open: boolean) => void; setControlsOpen?: (open: boolean) => void;
onPlayPause: (play: boolean) => void; onPlayPause: (play: boolean) => void;
onSeek: (diff: number) => void; onSeek: (diff: number) => void;
@ -55,6 +56,7 @@ export default function VideoControls({
controlsOpen, controlsOpen,
playbackRates = PLAYBACK_RATE_DEFAULT, playbackRates = PLAYBACK_RATE_DEFAULT,
playbackRate, playbackRate,
hotKeys = true,
setControlsOpen, setControlsOpen,
onPlayPause, onPlayPause,
onSeek, onSeek,
@ -130,7 +132,7 @@ export default function VideoControls({
[video, isPlaying, onSeek], [video, isPlaying, onSeek],
); );
useKeyboardListener( useKeyboardListener(
["ArrowLeft", "ArrowRight", "m", " "], hotKeys ? ["ArrowLeft", "ArrowRight", "m", " "] : [],
onKeyboardShortcut, onKeyboardShortcut,
); );

View File

@ -20,6 +20,7 @@ type DynamicVideoPlayerProps = {
cameraPreviews: Preview[]; cameraPreviews: Preview[];
startTimestamp?: number; startTimestamp?: number;
isScrubbing: boolean; isScrubbing: boolean;
hotKeys: boolean;
onControllerReady: (controller: DynamicVideoController) => void; onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void; onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void; onClipEnded?: () => void;
@ -31,6 +32,7 @@ export default function DynamicVideoPlayer({
cameraPreviews, cameraPreviews,
startTimestamp, startTimestamp,
isScrubbing, isScrubbing,
hotKeys,
onControllerReady, onControllerReady,
onTimestampUpdate, onTimestampUpdate,
onClipEnded, onClipEnded,
@ -172,6 +174,7 @@ export default function DynamicVideoPlayer({
videoRef={playerRef} videoRef={playerRef}
visible={!(isScrubbing || isLoading)} visible={!(isScrubbing || isLoading)}
currentSource={source} currentSource={source}
hotKeys={hotKeys}
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded} onPlayerLoaded={onPlayerLoaded}
onClipEnded={onClipEnded} onClipEnded={onClipEnded}

View File

@ -10,7 +10,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Toaster } from "@/components/ui/sonner";
import axios from "axios"; import axios from "axios";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
@ -42,8 +41,6 @@ function Export() {
return ( return (
<div className="size-full p-2 overflow-hidden flex flex-col"> <div className="size-full p-2 overflow-hidden flex flex-col">
<Toaster />
<AlertDialog <AlertDialog
open={deleteClip != undefined} open={deleteClip != undefined}
onOpenChange={() => setDeleteClip(undefined)} onOpenChange={() => setDeleteClip(undefined)}

View File

@ -1,3 +1,5 @@
// allow any // allow any
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FilterType = { [searchKey: string]: any }; export type FilterType = { [searchKey: string]: any };
export type ExportMode = "select" | "timeline" | "none";

View File

@ -496,7 +496,7 @@ function DetectionReview({
> >
{filter?.before == undefined && ( {filter?.before == undefined && (
<NewReviewData <NewReviewData
className="absolute w-full z-30 pointer-events-none" className="absolute w-full z-50 pointer-events-none"
contentRef={contentRef} contentRef={contentRef}
reviewItems={currentItems} reviewItems={currentItems}
itemsToReview={itemsToReview} itemsToReview={itemsToReview}

View File

@ -1,6 +1,7 @@
import ReviewCard from "@/components/card/ReviewCard"; import ReviewCard from "@/components/card/ReviewCard";
import FilterCheckBox from "@/components/filter/FilterCheckBox"; import FilterCheckBox from "@/components/filter/FilterCheckBox";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import ExportDialog from "@/components/overlay/ExportDialog";
import PreviewPlayer, { import PreviewPlayer, {
PreviewController, PreviewController,
} from "@/components/player/PreviewPlayer"; } from "@/components/player/PreviewPlayer";
@ -11,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useOverlayState } from "@/hooks/use-overlay-state"; import { useOverlayState } from "@/hooks/use-overlay-state";
import { ExportMode } from "@/types/filter";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { import {
@ -32,7 +34,9 @@ import { isDesktop, isMobile } from "react-device-detect";
import { FaCircle, FaVideo } from "react-icons/fa"; import { FaCircle, FaVideo } from "react-icons/fa";
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 { Toaster } from "@/components/ui/sonner";
import useSWR from "swr"; import useSWR from "swr";
import { TimeRange } from "@/types/timeline";
const SEGMENT_DURATION = 30; const SEGMENT_DURATION = 30;
type TimelineType = "timeline" | "events"; type TimelineType = "timeline" | "events";
@ -92,6 +96,11 @@ export function RecordingView({
[selectedRangeIdx, timeRange], [selectedRangeIdx, timeRange],
); );
// export
const [exportMode, setExportMode] = useState<ExportMode>("none");
const [exportRange, setExportRange] = useState<TimeRange>();
// move to next clip // move to next clip
const onClipEnded = useCallback(() => { const onClipEnded = useCallback(() => {
@ -210,6 +219,7 @@ export function RecordingView({
return ( return (
<div ref={contentRef} className="size-full flex flex-col"> <div ref={contentRef} className="size-full flex flex-col">
<Toaster />
<div className={`w-full h-10 flex items-center justify-between pr-1`}> <div className={`w-full h-10 flex items-center justify-between pr-1`}>
<Button className="rounded-lg" onClick={() => navigate(-1)}> <Button className="rounded-lg" onClick={() => navigate(-1)}>
<IoMdArrowRoundBack className="size-5 mr-[10px]" /> <IoMdArrowRoundBack className="size-5 mr-[10px]" />
@ -245,6 +255,15 @@ export function RecordingView({
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
)} )}
<ExportDialog
camera={mainCamera}
currentTime={currentTime}
latestTime={timeRange.end}
mode={exportMode}
range={exportRange}
setRange={setExportRange}
setMode={setExportMode}
/>
<ReviewFilterGroup <ReviewFilterGroup
filters={["date", "general"]} filters={["date", "general"]}
reviewSummary={reviewSummary} reviewSummary={reviewSummary}
@ -283,7 +302,7 @@ export function RecordingView({
</div> </div>
<div <div
className={`flex h-full mb-2 justify-center overflow-hidden ${isDesktop ? "" : "flex-col"}`} className={`flex h-full my-2 justify-center overflow-hidden ${isDesktop ? "" : "flex-col"}`}
> >
<div className="flex flex-1 flex-wrap"> <div className="flex flex-1 flex-wrap">
<div <div
@ -303,6 +322,7 @@ export function RecordingView({
timeRange={currentTimeRange} timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []} cameraPreviews={allPreviews ?? []}
startTimestamp={playbackStart} startTimestamp={playbackStart}
hotKeys={exportMode != "select"}
onTimestampUpdate={(timestamp) => { onTimestampUpdate={(timestamp) => {
setPlayerTime(timestamp); setPlayerTime(timestamp);
setCurrentTime(timestamp); setCurrentTime(timestamp);
@ -314,7 +334,7 @@ export function RecordingView({
onControllerReady={(controller) => { onControllerReady={(controller) => {
mainControllerRef.current = controller; mainControllerRef.current = controller;
}} }}
isScrubbing={scrubbing} isScrubbing={scrubbing || exportMode == "timeline"}
/> />
</div> </div>
{isDesktop && ( {isDesktop && (
@ -382,8 +402,10 @@ export function RecordingView({
timeRange={timeRange} timeRange={timeRange}
mainCameraReviewItems={mainCameraReviewItems} mainCameraReviewItems={mainCameraReviewItems}
currentTime={currentTime} currentTime={currentTime}
exportRange={exportMode == "timeline" ? exportRange : undefined}
setCurrentTime={setCurrentTime} setCurrentTime={setCurrentTime}
setScrubbing={setScrubbing} setScrubbing={setScrubbing}
setExportRange={setExportRange}
/> />
</div> </div>
</div> </div>
@ -397,8 +419,10 @@ type TimelineProps = {
timeRange: { start: number; end: number }; timeRange: { start: number; end: number };
mainCameraReviewItems: ReviewSegment[]; mainCameraReviewItems: ReviewSegment[];
currentTime: number; currentTime: number;
exportRange?: TimeRange;
setCurrentTime: React.Dispatch<React.SetStateAction<number>>; setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>; setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
setExportRange: (range: TimeRange) => void;
}; };
function Timeline({ function Timeline({
contentRef, contentRef,
@ -407,8 +431,10 @@ function Timeline({
timeRange, timeRange,
mainCameraReviewItems, mainCameraReviewItems,
currentTime, currentTime,
exportRange,
setCurrentTime, setCurrentTime,
setScrubbing, setScrubbing,
setExportRange,
}: TimelineProps) { }: TimelineProps) {
const { data: motionData } = useSWR<MotionData[]>([ const { data: motionData } = useSWR<MotionData[]>([
"review/activity/motion", "review/activity/motion",
@ -420,7 +446,22 @@ function Timeline({
}, },
]); ]);
if (timelineType == "timeline") { const [exportStart, setExportStartTime] = useState<number>(0);
const [exportEnd, setExportEndTime] = useState<number>(0);
useEffect(() => {
if (exportRange && exportStart != 0 && exportEnd != 0) {
if (exportRange.after != exportStart) {
setCurrentTime(exportStart);
} else if (exportRange?.before != exportEnd) {
setCurrentTime(exportEnd);
}
setExportRange({ after: exportStart, before: exportEnd });
}
}, [exportRange, exportStart, exportEnd, setExportRange, setCurrentTime]);
if (exportRange != undefined || timelineType == "timeline") {
return ( return (
<div <div
className={ className={
@ -434,7 +475,12 @@ function Timeline({
timestampSpread={15} timestampSpread={15}
timelineStart={timeRange.end} timelineStart={timeRange.end}
timelineEnd={timeRange.start} timelineEnd={timeRange.start}
showHandlebar showHandlebar={exportRange == undefined}
showExportHandles={exportRange != undefined}
exportStartTime={exportRange?.after}
exportEndTime={exportRange?.before}
setExportStartTime={setExportStartTime}
setExportEndTime={setExportEndTime}
handlebarTime={currentTime} handlebarTime={currentTime}
setHandlebarTime={setCurrentTime} setHandlebarTime={setCurrentTime}
onlyInitialHandlebarScroll={true} onlyInitialHandlebarScroll={true}
@ -450,8 +496,10 @@ function Timeline({
return ( return (
<div <div
className={`${isDesktop ? "w-60" : "w-full"} h-full p-4 flex flex-col gap-4 bg-secondary overflow-auto`} className={`${isDesktop ? "w-60" : "w-full"} h-full relative p-4 flex flex-col gap-4 bg-secondary overflow-auto`}
> >
<div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
<div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
{mainCameraReviewItems.map((review) => { {mainCameraReviewItems.map((review) => {
if (review.severity == "significant_motion") { if (review.severity == "significant_motion") {
return; return;