diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index c55a58562..0dc57a26f 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -210,7 +210,7 @@ http { include proxy.conf; } - location ~* /api/.*\.(jpg|jpeg|png)$ { + location ~* /api/.*\.(jpg|jpeg|png|webp)$ { rewrite ^/api/(.*)$ $1 break; proxy_pass http://frigate_api; include proxy.conf; diff --git a/frigate/api/media.py b/frigate/api/media.py index 6e986af0b..2c8f37dc8 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -30,6 +30,7 @@ from frigate.const import ( CLIPS_DIR, EXPORT_DIR, MAX_SEGMENT_DURATION, + PREVIEW_FRAME_TYPE, RECORD_DIR, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment @@ -1173,8 +1174,8 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000): # need to generate from existing images preview_dir = os.path.join(CACHE_DIR, "preview_frames") file_start = f"preview_{camera_name}" - start_file = f"{file_start}-{start_ts}.jpg" - end_file = f"{file_start}-{end_ts}.jpg" + start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" selected_previews = [] for file in sorted(os.listdir(preview_dir)): @@ -1258,8 +1259,9 @@ def review_preview(id: str): @MediaBp.route("/preview//thumbnail.jpg") +@MediaBp.route("/preview//thumbnail.webp") def preview_thumbnail(file_name: str): - """Get a thumbnail from the cached preview jpgs.""" + """Get a thumbnail from the cached preview frames.""" safe_file_name_current = secure_filename(file_name) preview_dir = os.path.join(CACHE_DIR, "preview_frames") diff --git a/frigate/api/preview.py b/frigate/api/preview.py index dd9e5d5a0..46d3d0e82 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -11,7 +11,7 @@ from flask import ( make_response, ) -from frigate.const import CACHE_DIR +from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.models import Previews logger = logging.getLogger(__name__) @@ -97,8 +97,8 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts): """Get list of cached preview frames""" preview_dir = os.path.join(CACHE_DIR, "preview_frames") file_start = f"preview_{camera_name}" - start_file = f"{file_start}-{start_ts}.jpg" - end_file = f"{file_start}-{end_ts}.jpg" + start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" selected_previews = [] for file in sorted(os.listdir(preview_dir)): diff --git a/frigate/const.py b/frigate/const.py index 62a202c37..9b65fe5c9 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -57,6 +57,10 @@ DRIVER_AMD = "radeonsi" DRIVER_INTEL_i965 = "i965" DRIVER_INTEL_iHD = "iHD" +# Preview Values + +PREVIEW_FRAME_TYPE = "webp" + # Record Values CACHE_SEGMENT_FORMAT = "%Y%m%d%H%M%S%z" diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 5fd7c5f29..47b17b188 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -12,7 +12,7 @@ import numpy as np from frigate.comms.inter_process import InterProcessRequestor from frigate.config import CameraConfig, RecordQualityEnum -from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW +from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE from frigate.ffmpeg_presets import ( FPS_VFR_PARAM, EncodeTypeEnum, @@ -42,12 +42,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str: """Get the image name in cache.""" return os.path.join( CACHE_DIR, - f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.jpg", + f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.{PREVIEW_FRAME_TYPE}", ) class FFMpegConverter(threading.Thread): - """Convert a list of jpg frames into a vfr mp4.""" + """Convert a list of still frames into a vfr mp4.""" def __init__( self, @@ -176,7 +176,7 @@ class PreviewRecorder: ) file_start = f"preview_{config.name}" - start_file = f"{file_start}-{start_ts}.jpg" + start_file = f"{file_start}-{start_ts}.webp" for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))): if not file.startswith(file_start): @@ -186,7 +186,7 @@ class PreviewRecorder: os.unlink(os.path.join(PREVIEW_CACHE_DIR, file)) continue - ts = float(file.split("-")[1][:-4]) + ts = float(file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)]) if self.start_time == 0: self.start_time = ts @@ -242,12 +242,11 @@ class PreviewRecorder: small_frame, cv2.COLOR_YUV2BGR_I420, ) - _, jpg = cv2.imencode(".jpg", small_frame) - with open( + cv2.imwrite( get_cache_image_name(self.config.name, frame_time), - "wb", - ) as j: - j.write(jpg.tobytes()) + small_frame, + [int(cv2.IMWRITE_WEBP_QUALITY), 80], + ) def write_data( self, diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 77eb51ee8..910c8cc79 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -9,9 +9,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; import { DynamicPlayback } from "@/types/playback"; -import PreviewVideoPlayer, { - PreviewVideoController, -} from "./PreviewVideoPlayer"; +import PreviewPlayer, { PreviewController } from "./PreviewPlayer"; type PlayerMode = "playback" | "scrubbing"; @@ -40,11 +38,6 @@ export default function DynamicVideoPlayer({ }: DynamicVideoPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); - const timezone = useMemo( - () => - config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, - [config], - ); // playback behavior const wideVideo = useMemo(() => { @@ -63,7 +56,7 @@ export default function DynamicVideoPlayer({ const [playerRef, setPlayerRef] = useState(null); const [previewController, setPreviewController] = - useState(null); + useState(null); const [isScrubbing, setIsScrubbing] = useState(previewOnly); const [focusedItem, setFocusedItem] = useState( undefined, @@ -154,14 +147,8 @@ export default function DynamicVideoPlayer({ // initial state const initialPlaybackSource = useMemo(() => { - const date = new Date(timeRange.start * 1000); return { - src: `${apiHost}vod/${date.getFullYear()}-${ - date.getMonth() + 1 - }/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll( - "/", - ",", - )}/master.m3u8`, + src: `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, type: "application/vnd.apple.mpegurl", }; // we only want to calculate this once @@ -224,13 +211,7 @@ export default function DynamicVideoPlayer({ return; } - const date = new Date(timeRange.start * 1000); - const playbackUri = `${apiHost}vod/${date.getFullYear()}-${ - date.getMonth() + 1 - }/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll( - "/", - ",", - )}/master.m3u8`; + const playbackUri = `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`; controller.newPlayback({ recordings: recordings ?? [], @@ -280,7 +261,7 @@ export default function DynamicVideoPlayer({ )} - void; private setFocusedItem: (timeline: Timeline) => void; private playerMode: PlayerMode = "playback"; @@ -315,7 +296,7 @@ export class DynamicVideoController { constructor( camera: string, playerController: Player, - previewController: PreviewVideoController, + previewController: PreviewController, annotationOffset: number, defaultMode: PlayerMode, setScrubbing: (isScrubbing: boolean) => void, diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx new file mode 100644 index 000000000..bf61698b5 --- /dev/null +++ b/web/src/components/player/PreviewPlayer.tsx @@ -0,0 +1,458 @@ +import { + MutableRefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { Preview } from "@/types/preview"; +import { PreviewPlayback } from "@/types/playback"; +import { isCurrentHour } from "@/utils/dateUtil"; +import { baseUrl } from "@/api/baseUrl"; + +type PreviewPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + cameraPreviews: Preview[]; + startTime?: number; + onControllerReady: (controller: PreviewController) => void; + onClick?: () => void; +}; +export default function PreviewPlayer({ + className, + camera, + timeRange, + cameraPreviews, + startTime, + onControllerReady, + onClick, +}: PreviewPlayerProps) { + if (isCurrentHour(timeRange.end)) { + return ( + + ); + } + + return ( + + ); +} + +export abstract class PreviewController { + public camera = ""; + + constructor(camera: string) { + this.camera = camera; + } + + abstract scrubToTimestamp(time: number): boolean; + + abstract finishedSeeking(): void; + + abstract setNewPreviewStartTime(time: number): void; +} + +type PreviewVideoPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + cameraPreviews: Preview[]; + startTime?: number; + onControllerReady: (controller: PreviewVideoController) => void; + onClick?: () => void; +}; +function PreviewVideoPlayer({ + className, + camera, + timeRange, + cameraPreviews, + startTime, + onControllerReady, + onClick, +}: PreviewVideoPlayerProps) { + const { data: config } = useSWR("config"); + + // controlling playback + + const previewRef = useRef(null); + const controller = useMemo(() => { + if (!config || !previewRef.current) { + return undefined; + } + + return new PreviewVideoController(camera, previewRef); + // we only care when preview is ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [camera, config, previewRef.current]); + + useEffect(() => { + if (!controller) { + return; + } + + if (controller) { + onControllerReady(controller); + } + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller]); + + // initial state + + const initialPreview = useMemo(() => { + return cameraPreviews.find( + (preview) => + preview.camera == camera && + Math.round(preview.start) >= timeRange.start && + Math.floor(preview.end) <= timeRange.end, + ); + + // we only want to calculate this once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [currentPreview, setCurrentPreview] = useState(initialPreview); + + const onPreviewSeeked = useCallback(() => { + if (!controller) { + return; + } + + controller.finishedSeeking(); + }, [controller]); + + useEffect(() => { + if (!controller) { + return; + } + + const preview = cameraPreviews.find( + (preview) => + preview.camera == camera && + Math.round(preview.start) >= timeRange.start && + Math.floor(preview.end) <= timeRange.end, + ); + setCurrentPreview(preview); + + controller.newPlayback({ + preview, + timeRange, + }); + + // we only want this to change when recordings update + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller, timeRange]); + + useEffect(() => { + if (!currentPreview || !previewRef.current) { + return; + } + + previewRef.current.load(); + }, [currentPreview, previewRef]); + + return ( +
+ + {cameraPreviews && !currentPreview && ( +
+ No Preview Found +
+ )} +
+ ); +} + +class PreviewVideoController extends PreviewController { + // main state + private previewRef: MutableRefObject; + private timeRange: { start: number; end: number } | undefined = undefined; + + // preview + private preview: Preview | undefined = undefined; + private timeToSeek: number | undefined = undefined; + private seeking = false; + + constructor( + camera: string, + previewRef: MutableRefObject, + ) { + super(camera); + this.previewRef = previewRef; + } + + newPlayback(newPlayback: PreviewPlayback) { + this.preview = newPlayback.preview; + this.seeking = false; + + this.timeRange = newPlayback.timeRange; + } + + override scrubToTimestamp(time: number): boolean { + if (!this.preview || !this.timeRange) { + return false; + } + + if (time < this.preview.start || time > this.preview.end) { + return false; + } + + if (this.seeking) { + this.timeToSeek = time; + } else { + if (this.previewRef.current) { + this.previewRef.current.currentTime = Math.max( + 0, + time - this.preview.start, + ); + this.seeking = true; + } + } + + return true; + } + + override finishedSeeking() { + if (!this.previewRef.current || !this.preview) { + return; + } + + if ( + this.timeToSeek && + this.timeToSeek != this.previewRef.current?.currentTime + ) { + this.previewRef.current.currentTime = + this.timeToSeek - this.preview.start; + } else { + this.seeking = false; + } + } + + override setNewPreviewStartTime(time: number) { + this.timeToSeek = time; + } + + previewReady() { + this.seeking = false; + this.previewRef.current?.pause(); + + if (this.timeToSeek) { + this.finishedSeeking(); + } + } +} + +type PreviewFramesPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + startTime?: number; + onControllerReady: (controller: PreviewController) => void; + onClick?: () => void; +}; +function PreviewFramesPlayer({ + className, + camera, + timeRange, + startTime, + onControllerReady, + onClick, +}: PreviewFramesPlayerProps) { + // frames data + + const { data: previewFrames } = useSWR( + `preview/${camera}/start/${Math.floor(timeRange.start)}/end/${Math.ceil( + timeRange.end, + )}/frames`, + { revalidateOnFocus: false }, + ); + const frameTimes = useMemo(() => { + if (!previewFrames) { + return undefined; + } + + return previewFrames.map((frame) => + parseFloat(frame.split("-")[1].slice(undefined, -5)), + ); + }, [previewFrames]); + + // controlling frames + + const imgRef = useRef(null); + const controller = useMemo(() => { + if (!frameTimes || !imgRef.current) { + return undefined; + } + + return new PreviewFramesController(camera, imgRef, frameTimes); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [imgRef, frameTimes, imgRef.current]); + + // initial state + + useEffect(() => { + if (!controller) { + return; + } + + if (controller) { + onControllerReady(controller); + } + + // we only want to fire once when players are ready + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller]); + + const onImageLoaded = useCallback(() => { + if (!controller) { + return; + } + + controller.finishedSeeking(); + }, [controller]); + + useEffect(() => { + if (!controller) { + return; + } + + if (!startTime) { + controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.start); + } else { + controller.scrubToTimestamp(startTime); + } + // we only want to calculate this once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller]); + + return ( +
+ + {previewFrames?.length === 0 && ( +
+ No Preview Found +
+ )} +
+ ); +} + +class PreviewFramesController extends PreviewController { + imgController: MutableRefObject; + frameTimes: number[]; + seeking: boolean = false; + private timeToSeek: number | undefined = undefined; + + constructor( + camera: string, + imgController: MutableRefObject, + frameTimes: number[], + ) { + super(camera); + this.imgController = imgController; + this.frameTimes = frameTimes; + } + + override scrubToTimestamp(time: number): boolean { + if (!this.imgController.current) { + return false; + } + + const frame = this.frameTimes.find((p) => { + return time <= p; + }); + + if (!frame) { + return false; + } + + if (this.seeking) { + this.timeToSeek = frame; + } else { + const newSrc = `${baseUrl}api/preview/preview_${this.camera}-${frame}.webp/thumbnail.webp`; + + if (this.imgController.current.src != newSrc) { + this.imgController.current.src = newSrc; + this.seeking = true; + } + } + + return true; + } + + override finishedSeeking() { + if (!this.imgController.current) { + return false; + } + + if (this.timeToSeek) { + const newSrc = `${baseUrl}api/preview/preview_${this.camera}-${this.timeToSeek}.webp/thumbnail.webp`; + + if (this.imgController.current.src != newSrc) { + this.imgController.current.src = newSrc; + } else { + this.timeToSeek = undefined; + this.seeking = false; + } + } else { + this.seeking = false; + } + } + + override setNewPreviewStartTime(time: number) { + this.timeToSeek = time; + } +} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 4cf7f29b5..12e869877 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -527,6 +527,7 @@ function InProgressPreview({ `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${ Math.ceil(review.end_time) + PREVIEW_PADDING }/frames`, + { revalidateOnFocus: false }, ); const [manualFrame, setManualFrame] = useState(false); const [hoverTimeout, setHoverTimeout] = useState(); @@ -642,7 +643,7 @@ function InProgressPreview({
void; - onClick?: () => void; -}; -export default function PreviewVideoPlayer({ - className, - camera, - timeRange, - cameraPreviews, - startTime, - onControllerReady, - onClick, -}: PreviewVideoPlayerProps) { - const { data: config } = useSWR("config"); - - // controlling playback - - const previewRef = useRef(null); - const controller = useMemo(() => { - if (!config || !previewRef.current) { - return undefined; - } - - return new PreviewVideoController(camera, previewRef); - // we only care when preview is ready - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [camera, config, previewRef.current]); - - useEffect(() => { - if (!controller) { - return; - } - - if (controller) { - onControllerReady(controller); - } - // we only want to fire once when players are ready - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [controller]); - - // initial state - - const initialPreview = useMemo(() => { - return cameraPreviews.find( - (preview) => - preview.camera == camera && - Math.round(preview.start) >= timeRange.start && - Math.floor(preview.end) <= timeRange.end, - ); - - // we only want to calculate this once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [currentPreview, setCurrentPreview] = useState(initialPreview); - - const onPreviewSeeked = useCallback(() => { - if (!controller) { - return; - } - - controller.finishedSeeking(); - }, [controller]); - - useEffect(() => { - if (!controller) { - return; - } - - const preview = cameraPreviews.find( - (preview) => - preview.camera == camera && - Math.round(preview.start) >= timeRange.start && - Math.floor(preview.end) <= timeRange.end, - ); - setCurrentPreview(preview); - - controller.newPlayback({ - preview, - timeRange, - }); - - // we only want this to change when recordings update - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [controller, timeRange]); - - useEffect(() => { - if (!currentPreview || !previewRef.current) { - return; - } - - previewRef.current.load(); - }, [currentPreview, previewRef]); - - return ( -
- - {cameraPreviews && !currentPreview && ( -
- No Preview Found -
- )} -
- ); -} - -export class PreviewVideoController { - // main state - public camera = ""; - private previewRef: MutableRefObject; - private timeRange: { start: number; end: number } | undefined = undefined; - - // preview - private preview: Preview | undefined = undefined; - private timeToSeek: number | undefined = undefined; - private seeking = false; - - constructor( - camera: string, - previewRef: MutableRefObject, - ) { - this.camera = camera; - this.previewRef = previewRef; - } - - newPlayback(newPlayback: PreviewPlayback) { - this.preview = newPlayback.preview; - this.seeking = false; - - this.timeRange = newPlayback.timeRange; - } - - scrubToTimestamp(time: number): boolean { - if (!this.preview || !this.timeRange) { - return false; - } - - if (time < this.preview.start || time > this.preview.end) { - return false; - } - - if (this.seeking) { - this.timeToSeek = time; - } else { - if (this.previewRef.current) { - this.previewRef.current.currentTime = Math.max( - 0, - time - this.preview.start, - ); - this.seeking = true; - } - } - - return true; - } - - setNewPreviewStartTime(time: number) { - this.timeToSeek = time; - } - - finishedSeeking() { - if (!this.previewRef.current || !this.preview) { - return; - } - - if ( - this.timeToSeek && - this.timeToSeek != this.previewRef.current?.currentTime - ) { - this.previewRef.current.currentTime = - this.timeToSeek - this.preview.start; - } else { - this.seeking = false; - } - } - - previewReady() { - this.seeking = false; - this.previewRef.current?.pause(); - - if (this.timeToSeek) { - this.finishedSeeking(); - } - } -} diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index 291a3fb52..0e0b1d104 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -163,7 +163,7 @@ export function getChunkedTimeRange( while (end < endTimestamp) { startDay.setHours(startDay.getHours() + 1); - if (startDay > endOfThisHour || startDay.getTime() / 1000 > endTimestamp) { + if (startDay > endOfThisHour) { break; } diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index b78a45703..7ed8cea99 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -33,9 +33,9 @@ import { MdCircle } from "react-icons/md"; import useSWR from "swr"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import { Button } from "@/components/ui/button"; -import PreviewVideoPlayer, { - PreviewVideoController, -} from "@/components/player/PreviewVideoPlayer"; +import PreviewPlayer, { + PreviewController, +} from "@/components/player/PreviewPlayer"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -578,9 +578,7 @@ function MotionReview({ return cameras.sort((a, b) => a.ui.order - b.ui.order); }, [config, filter]); - const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>( - {}, - ); + const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({}); // motion data @@ -596,14 +594,9 @@ function MotionReview({ // timeline time - const lastFullHour = useMemo(() => { - const end = new Date(timeRange.before * 1000); - end.setMinutes(0, 0, 0); - return end.getTime() / 1000; - }, [timeRange]); const timeRangeSegments = useMemo( - () => getChunkedTimeRange(timeRange.after, lastFullHour), - [lastFullHour, timeRange], + () => getChunkedTimeRange(timeRange.after, timeRange.before), + [timeRange], ); const initialIndex = useMemo(() => { @@ -620,7 +613,7 @@ function MotionReview({ const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex); const [currentTime, setCurrentTime] = useState( - startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.start, + startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.end, ); const currentTimeRange = useMemo( () => timeRangeSegments.ranges[selectedRangeIdx], @@ -674,7 +667,7 @@ function MotionReview({ grow = "aspect-video"; } return ( - ("config"); const navigate = useNavigate(); const contentRef = useRef(null); // controller state const [mainCamera, setMainCamera] = useState(startCamera); - const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>( - {}, - ); + const mainControllerRef = useRef(null); + const previewRefs = useRef<{ [camera: string]: PreviewController }>({}); const [playbackStart, setPlaybackStart] = useState(startTime); @@ -64,27 +68,20 @@ export function DesktopRecordingView({ // move to next clip useEffect(() => { - if ( - !videoPlayersRef.current && - Object.values(videoPlayersRef.current).length > 0 - ) { + if (!mainControllerRef.current) { return; } - const mainController = videoPlayersRef.current[mainCamera]; - - if (mainController) { - mainController.onClipChangedEvent((dir) => { - if (dir == "forward") { - if (selectedRangeIdx < timeRange.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } + mainControllerRef.current.onClipChangedEvent((dir) => { + if (dir == "forward") { + if (selectedRangeIdx < timeRange.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); } - }); - } + } + }); // we only want to fire once when players are ready // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedRangeIdx, timeRange, videoPlayersRef.current, mainCamera]); + }, [selectedRangeIdx, timeRange, mainControllerRef.current, mainCamera]); // scrubbing and timeline state @@ -107,7 +104,9 @@ export function DesktopRecordingView({ return; } - Object.values(videoPlayersRef.current).forEach((controller) => { + mainControllerRef.current?.scrubToTimestamp(currentTime); + + Object.values(previewRefs.current).forEach((controller) => { controller.scrubToTimestamp(currentTime); }); } @@ -115,7 +114,7 @@ export function DesktopRecordingView({ useEffect(() => { if (!scrubbing) { - videoPlayersRef.current[mainCamera]?.seekToTimestamp(currentTime, true); + mainControllerRef.current?.seekToTimestamp(currentTime, true); } // we only want to seek when user stops scrubbing @@ -124,27 +123,10 @@ export function DesktopRecordingView({ const onSelectCamera = useCallback( (newCam: string) => { - const lastController = videoPlayersRef.current[mainCamera]; - const newController = videoPlayersRef.current[newCam]; - lastController.onPlayerTimeUpdate(null); - lastController.onClipChangedEvent(null); - lastController.scrubToTimestamp(currentTime); - newController.onPlayerTimeUpdate((timestamp: number) => { - setCurrentTime(timestamp); - - allCameras.forEach((cam) => { - if (cam != newCam) { - videoPlayersRef.current[cam]?.scrubToTimestamp( - Math.floor(timestamp), - ); - } - }); - }); - newController.seekToTimestamp(currentTime, true); - setPlaybackStart(currentTime); setMainCamera(newCam); + setPlaybackStart(currentTime); }, - [allCameras, currentTime, mainCamera], + [currentTime], ); // motion timeline data @@ -162,6 +144,21 @@ export function DesktopRecordingView({ : null, ); + const grow = useMemo(() => { + if (!config) { + return "aspect-video"; + } + + const aspectRatio = + config.cameras[mainCamera].detect.width / + config.cameras[mainCamera].detect.height; + if (aspectRatio > 2) { + return "aspect-wide"; + } else { + return "aspect-video"; + } + }, [config, mainCamera]); + return (