add snapshot download to History player (#23395)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

This commit is contained in:
Josh Hawkins 2026-06-03 17:17:04 -05:00 committed by GitHub
parent a08e2d7529
commit 7e83d5de90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 100 additions and 8 deletions

View File

@ -54,6 +54,7 @@ type HlsVideoPlayerProps = {
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined; onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
getSnapshotUrl?: (playTime: number) => string | undefined; getSnapshotUrl?: (playTime: number) => string | undefined;
onSnapshot?: (playTime: number) => Promise<void> | void;
toggleFullscreen?: () => void; toggleFullscreen?: () => void;
onError?: (error: RecordingPlayerError) => void; onError?: (error: RecordingPlayerError) => void;
isDetailMode?: boolean; isDetailMode?: boolean;
@ -80,6 +81,7 @@ export default function HlsVideoPlayer({
setFullResolution, setFullResolution,
onUploadFrame, onUploadFrame,
getSnapshotUrl, getSnapshotUrl,
onSnapshot,
toggleFullscreen, toggleFullscreen,
onError, onError,
isDetailMode = false, isDetailMode = false,
@ -232,6 +234,7 @@ export default function HlsVideoPlayer({
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>(); const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
const [controls, setControls] = useState(isMobile); const [controls, setControls] = useState(isMobile);
const [controlsOpen, setControlsOpen] = useState(false); const [controlsOpen, setControlsOpen] = useState(false);
const [isSnapshotLoading, setIsSnapshotLoading] = useState(false);
const [zoomScale, setZoomScale] = useState(1.0); const [zoomScale, setZoomScale] = useState(1.0);
const [videoDimensions, setVideoDimensions] = useState<{ const [videoDimensions, setVideoDimensions] = useState<{
width: number; width: number;
@ -287,6 +290,21 @@ export default function HlsVideoPlayer({
return currentTime + inpointOffset; return currentTime + inpointOffset;
}, [videoRef, inpointOffset]); }, [videoRef, inpointOffset]);
const handleSnapshot = useCallback(async () => {
const frameTime = getVideoTime();
if (!frameTime || !onSnapshot) {
return;
}
setIsSnapshotLoading(true);
try {
await onSnapshot(frameTime);
} finally {
setIsSnapshotLoading(false);
}
}, [getVideoTime, onSnapshot]);
return ( return (
<TransformWrapper <TransformWrapper
minScale={1.0} minScale={1.0}
@ -310,6 +328,7 @@ export default function HlsVideoPlayer({
seek: true, seek: true,
playbackRate: true, playbackRate: true,
plusUpload: isAdmin && config?.plus?.enabled == true, plusUpload: isAdmin && config?.plus?.enabled == true,
snapshot: !!onSnapshot,
fullscreen: supportsFullscreen, fullscreen: supportsFullscreen,
}} }}
setControlsOpen={setControlsOpen} setControlsOpen={setControlsOpen}
@ -357,6 +376,8 @@ export default function HlsVideoPlayer({
} }
} }
}} }}
onSnapshot={onSnapshot ? handleSnapshot : undefined}
snapshotLoading={isSnapshotLoading}
fullscreen={fullscreen} fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
containerRef={containerRef} containerRef={containerRef}

View File

@ -34,6 +34,7 @@ import {
} from "../ui/alert-dialog"; } from "../ui/alert-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FaCompress, FaExpand } from "react-icons/fa"; import { FaCompress, FaExpand } from "react-icons/fa";
import { TbCameraDown } from "react-icons/tb";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type VideoControls = { type VideoControls = {
@ -41,6 +42,7 @@ type VideoControls = {
seek?: boolean; seek?: boolean;
playbackRate?: boolean; playbackRate?: boolean;
plusUpload?: boolean; plusUpload?: boolean;
snapshot?: boolean;
fullscreen?: boolean; fullscreen?: boolean;
}; };
@ -49,6 +51,7 @@ const CONTROLS_DEFAULT: VideoControls = {
seek: true, seek: true,
playbackRate: true, playbackRate: true,
plusUpload: false, plusUpload: false,
snapshot: false,
fullscreen: false, fullscreen: 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];
@ -73,6 +76,8 @@ type VideoControlsProps = {
onSetPlaybackRate: (rate: number) => void; onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void; onUploadFrame?: () => void;
getSnapshotUrl?: () => string | undefined; getSnapshotUrl?: () => string | undefined;
onSnapshot?: () => void;
snapshotLoading?: boolean;
toggleFullscreen?: () => void; toggleFullscreen?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef?: React.MutableRefObject<HTMLDivElement | null>;
}; };
@ -95,6 +100,8 @@ export default function VideoControls({
onSetPlaybackRate, onSetPlaybackRate,
onUploadFrame, onUploadFrame,
getSnapshotUrl, getSnapshotUrl,
onSnapshot,
snapshotLoading = false,
toggleFullscreen, toggleFullscreen,
containerRef, containerRef,
}: VideoControlsProps) { }: VideoControlsProps) {
@ -295,6 +302,25 @@ export default function VideoControls({
fullscreen={fullscreen} fullscreen={fullscreen}
/> />
)} )}
{features.snapshot && onSnapshot && (
<TbCameraDown
className={cn(
"size-5",
snapshotLoading
? "cursor-not-allowed opacity-50"
: "cursor-pointer",
)}
onClick={(e: React.MouseEvent<SVGElement>) => {
e.stopPropagation();
if (snapshotLoading) {
return;
}
onSnapshot();
}}
/>
)}
{features.fullscreen && toggleFullscreen && ( {features.fullscreen && toggleFullscreen && (
<div className="cursor-pointer" onClick={toggleFullscreen}> <div className="cursor-pointer" onClick={toggleFullscreen}>
{fullscreen ? <FaCompress /> : <FaExpand />} {fullscreen ? <FaCompress /> : <FaExpand />}

View File

@ -19,12 +19,18 @@ 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"; import axios from "axios";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
calculateInpointOffset, calculateInpointOffset,
calculateSeekPosition, calculateSeekPosition,
} from "@/utils/videoUtil"; } from "@/utils/videoUtil";
import {
downloadSnapshot,
generateSnapshotFilename,
grabVideoSnapshot,
} from "@/utils/snapshotUtil";
import { isFirefox } from "react-device-detect"; import { isFirefox } from "react-device-detect";
/** /**
@ -68,7 +74,7 @@ export default function DynamicVideoPlayer({
containerRef, containerRef,
transformedOverlay, transformedOverlay,
}: DynamicVideoPlayerProps) { }: DynamicVideoPlayerProps) {
const { t } = useTranslation(["components/player"]); const { t } = useTranslation(["components/player", "views/live"]);
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -196,6 +202,34 @@ export default function DynamicVideoPlayer({
[apiHost, camera, controller], [apiHost, camera, controller],
); );
const onDownloadSnapshot = useCallback(
async (playTime: number) => {
if (!controller || !playerRef.current) {
return;
}
// map the player time back to the timeline timestamp so the filename
// reflects the moment being viewed rather than the current time
const frameTime = controller.getProgress(playTime);
const result = await grabVideoSnapshot(playerRef.current);
if (result.success) {
downloadSnapshot(
result.data.dataUrl,
generateSnapshotFilename(camera, frameTime),
);
toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), {
position: "top-center",
});
} else {
toast.error(t("snapshot.captureFailed", { ns: "views/live" }), {
position: "top-center",
});
}
},
[camera, controller, t],
);
// state of playback player // state of playback player
const recordingParams = useMemo( const recordingParams = useMemo(
@ -328,6 +362,7 @@ export default function DynamicVideoPlayer({
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus} onUploadFrame={onUploadFrameToPlus}
getSnapshotUrl={getSnapshotUrlForPlus} getSnapshotUrl={getSnapshotUrlForPlus}
onSnapshot={onDownloadSnapshot}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
onError={(error) => { onError={(error) => {
if (error == "stalled" && !isScrubbing) { if (error == "stalled" && !isScrubbing) {

View File

@ -97,17 +97,27 @@ export function downloadSnapshot(dataUrl: string, filename: string): void {
} }
} }
export function generateSnapshotFilename(cameraName: string): string { export function generateSnapshotFilename(
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5); cameraName: string,
timestampSeconds?: number,
): string {
// Live snapshots use the current time, while History snapshots pass the
// playback timestamp so the filename matches the moment being viewed.
const date =
typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds)
? new Date(timestampSeconds * 1000)
: new Date();
const timestamp = date.toISOString().replace(/[:.]/g, "-").slice(0, -5);
return `${cameraName}_snapshot_${timestamp}.jpg`; return `${cameraName}_snapshot_${timestamp}.jpg`;
} }
export async function grabVideoSnapshot(): Promise<SnapshotResult> { export async function grabVideoSnapshot(
targetVideo?: HTMLVideoElement | null,
): Promise<SnapshotResult> {
try { try {
// Find the video element in the player const videoElement =
const videoElement = document.querySelector( targetVideo ??
"#player-container video", (document.querySelector("#player-container video") as HTMLVideoElement);
) as HTMLVideoElement;
if (!videoElement) { if (!videoElement) {
return { return {