From 1c48fe6b940090377309cc4976b63d6858656302 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:29:42 -0500 Subject: [PATCH] use recording snapshot for frigate+ frame submission from VideoControls rather than a canvas grab/paint, which may not always align with an ffmpeg snapshot due to keyframes --- web/public/locales/en/components/player.json | 3 +- .../overlay/detail/TrackingDetails.tsx | 8 ++++ web/src/components/player/HlsVideoPlayer.tsx | 9 ++++ web/src/components/player/VideoControls.tsx | 41 ++++++++++++++++--- .../player/dynamic/DynamicVideoPlayer.tsx | 16 ++++++++ 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json index 3b50ff5ed..6ceef7e0c 100644 --- a/web/public/locales/en/components/player.json +++ b/web/public/locales/en/components/player.json @@ -4,7 +4,8 @@ "noPreviewFoundFor": "No Preview Found for {{cameraName}}", "submitFrigatePlus": { "title": "Submit this frame to Frigate+?", - "submit": "Submit" + "submit": "Submit", + "previewError": "Could not load snapshot preview. The recording may not be available at this time." }, "livePlayerRequiredIOSVersion": "iOS 17.1 or greater is required for this live stream type.", "streamOffline": { diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 8b0793746..3cb95077b 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -636,6 +636,13 @@ export function TrackingDetails({ return axios.post(`/${event.camera}/plus/${currentTime}`); }, [event.camera, currentTime]); + const getSnapshotUrlForPlus = useCallback(() => { + if (!currentTime) { + return undefined; + } + return `${apiHost}api/${event.camera}/recordings/${currentTime}/snapshot.jpg?height=500`; + }, [apiHost, event.camera, currentTime]); + if (!config) { return ; } @@ -683,6 +690,7 @@ export function TrackingDetails({ onTimeUpdate={handleTimeUpdate} onSeekToTime={handleSeekToTime} onUploadFrame={onUploadFrameToPlus} + getSnapshotUrl={getSnapshotUrlForPlus} onPlaying={() => setIsVideoLoading(false)} setFullResolution={setFullResolution} toggleFullscreen={toggleFullscreen} diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 7d762912c..598a3bfe7 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -53,6 +53,7 @@ type HlsVideoPlayerProps = { onSeekToTime?: (timestamp: number, play?: boolean) => void; setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; + getSnapshotUrl?: (playTime: number) => string | undefined; toggleFullscreen?: () => void; onError?: (error: RecordingPlayerError) => void; isDetailMode?: boolean; @@ -78,6 +79,7 @@ export default function HlsVideoPlayer({ onSeekToTime, setFullResolution, onUploadFrame, + getSnapshotUrl, toggleFullscreen, onError, isDetailMode = false, @@ -317,6 +319,13 @@ export default function HlsVideoPlayer({ videoRef.current.playbackRate = rate; } }} + getSnapshotUrl={() => { + const frameTime = getVideoTime(); + if (!frameTime || !getSnapshotUrl) { + return undefined; + } + return getSnapshotUrl(frameTime); + }} onUploadFrame={async () => { const frameTime = getVideoTime(); diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index 020c54d7b..a92819a31 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; +import { LuFolderX } from "react-icons/lu"; import { isDesktop, isMobileOnly, isSafari } from "react-device-detect"; import { LuPause, LuPlay } from "react-icons/lu"; import { @@ -71,6 +72,7 @@ type VideoControlsProps = { onSeek: (diff: number) => void; onSetPlaybackRate: (rate: number) => void; onUploadFrame?: () => void; + getSnapshotUrl?: () => string | undefined; toggleFullscreen?: () => void; containerRef?: React.MutableRefObject; }; @@ -92,6 +94,7 @@ export default function VideoControls({ onSeek, onSetPlaybackRate, onUploadFrame, + getSnapshotUrl, toggleFullscreen, containerRef, }: VideoControlsProps) { @@ -288,6 +291,7 @@ export default function VideoControls({ } }} onUploadFrame={onUploadFrame} + getSnapshotUrl={getSnapshotUrl} containerRef={containerRef} fullscreen={fullscreen} /> @@ -306,6 +310,7 @@ type FrigatePlusUploadButtonProps = { onOpen: () => void; onClose: () => void; onUploadFrame: () => void; + getSnapshotUrl?: () => string | undefined; containerRef?: React.MutableRefObject; fullscreen?: boolean; }; @@ -314,12 +319,14 @@ function FrigatePlusUploadButton({ onOpen, onClose, onUploadFrame, + getSnapshotUrl, containerRef, fullscreen, }: FrigatePlusUploadButtonProps) { const { t } = useTranslation(["components/player"]); - const [videoImg, setVideoImg] = useState(); + const [previewUrl, setPreviewUrl] = useState(); + const [previewError, setPreviewError] = useState(false); return ( { onOpen(); + setPreviewError(false); + + const snapshotUrl = getSnapshotUrl?.(); + if (snapshotUrl) { + setPreviewUrl(snapshotUrl); + return; + } if (video) { const videoSize = [video.clientWidth, video.clientHeight]; @@ -345,7 +359,7 @@ function FrigatePlusUploadButton({ if (context) { context.drawImage(video, 0, 0, videoSize[0], videoSize[1]); - setVideoImg(canvas.toDataURL("image/webp")); + setPreviewUrl(canvas.toDataURL("image/webp")); } } }} @@ -362,14 +376,29 @@ function FrigatePlusUploadButton({ {t("submitFrigatePlus.title")} - + {previewError ? ( +
+ + {t("submitFrigatePlus.previewError")} +
+ ) : ( + setPreviewError(true)} + /> + )} - - {t("submitFrigatePlus.submit")} - {t("button.cancel", { ns: "common" })} + + {t("submitFrigatePlus.submit")} +
diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index c8d95090d..5b864aea5 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -181,6 +181,21 @@ export default function DynamicVideoPlayer({ [camera, controller], ); + const getSnapshotUrlForPlus = useCallback( + (playTime: number) => { + if (!controller) { + return undefined; + } + + const time = controller.getProgress(playTime); + if (!time) { + return undefined; + } + return `${apiHost}api/${camera}/recordings/${time}/snapshot.jpg?height=500`; + }, + [apiHost, camera, controller], + ); + // state of playback player const recordingParams = useMemo( @@ -312,6 +327,7 @@ export default function DynamicVideoPlayer({ }} setFullResolution={setFullResolution} onUploadFrame={onUploadFrameToPlus} + getSnapshotUrl={getSnapshotUrlForPlus} toggleFullscreen={toggleFullscreen} onError={(error) => { if (error == "stalled" && !isScrubbing) {