Add ability to download on demand snapshots (#20488)

* on demand snapshot utils

* add optional loading state to feature toggle buttons

* add on demand snapshot button to single camera live view

* i18n
This commit is contained in:
Josh Hawkins 2025-10-14 14:05:35 -05:00 committed by GitHub
parent b05ac7430a
commit dad5b72145
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 259 additions and 24 deletions

View File

@ -73,6 +73,12 @@
"enable": "Enable Snapshots", "enable": "Enable Snapshots",
"disable": "Disable Snapshots" "disable": "Disable Snapshots"
}, },
"snapshot": {
"takeSnapshot": "Download instant snapshot",
"noVideoSource": "No video source available for snapshot.",
"captureFailed": "Failed to capture snapshot.",
"downloadStarted": "Snapshot download started."
},
"audioDetect": { "audioDetect": {
"enable": "Enable Audio Detect", "enable": "Enable Audio Detect",
"disable": "Disable Audio Detect" "disable": "Disable Audio Detect"
@ -90,8 +96,8 @@
"disable": "Hide Stream Stats" "disable": "Hide Stream Stats"
}, },
"manualRecording": { "manualRecording": {
"title": "On-Demand Recording", "title": "On-Demand",
"tips": "Start a manual event based on this camera's recording retention settings.", "tips": "Download an instant snapshot or start a manual event based on this camera's recording retention settings.",
"playInBackground": { "playInBackground": {
"label": "Play in background", "label": "Play in background",
"desc": "Enable this option to continue streaming when the player is hidden." "desc": "Enable this option to continue streaming when the player is hidden."

View File

@ -6,6 +6,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import ActivityIndicator from "../indicators/activity-indicator";
const variants = { const variants = {
primary: { primary: {
@ -30,7 +31,8 @@ type CameraFeatureToggleProps = {
Icon: IconType; Icon: IconType;
title: string; title: string;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; // New prop for disabling disabled?: boolean;
loading?: boolean;
}; };
export default function CameraFeatureToggle({ export default function CameraFeatureToggle({
@ -40,7 +42,8 @@ export default function CameraFeatureToggle({
Icon, Icon,
title, title,
onClick, onClick,
disabled = false, // Default to false disabled = false,
loading = false,
}: CameraFeatureToggleProps) { }: CameraFeatureToggleProps) {
const content = ( const content = (
<div <div
@ -53,6 +56,9 @@ export default function CameraFeatureToggle({
className, className,
)} )}
> >
{loading ? (
<ActivityIndicator className="size-5 md:m-[6px]" />
) : (
<Icon <Icon
className={cn( className={cn(
"size-5 md:m-[6px]", "size-5 md:m-[6px]",
@ -63,6 +69,7 @@ export default function CameraFeatureToggle({
: "text-secondary-foreground", : "text-secondary-foreground",
)} )}
/> />
)}
</div> </div>
); );

View File

@ -0,0 +1,163 @@
import { baseUrl } from "@/api/baseUrl";
type SnapshotResponse = {
dataUrl: string;
blob: Blob;
contentType: string;
};
export type SnapshotResult =
| {
success: true;
data: SnapshotResponse;
}
| {
success: false;
error: string;
};
export async function fetchCameraSnapshot(
name: string,
): Promise<SnapshotResult> {
try {
const url = `${baseUrl}api/${encodeURIComponent(name)}/latest.jpg`;
const response = await fetch(url, {
method: "GET",
cache: "no-store",
credentials: "same-origin",
});
if (!response.ok) {
return {
success: false,
error: `Snapshot request failed with status ${response.status}`,
};
}
const blob = await response.blob();
if (!blob || blob.size === 0) {
return {
success: false,
error: "Snapshot response was empty",
};
}
const dataUrl = await blobToDataUrl(blob);
const contentType = response.headers.get("content-type") ?? `image/jpeg`;
return {
success: true,
data: {
dataUrl,
blob,
contentType,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
};
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to convert blob to data URL"));
}
};
reader.onerror = () => {
reject(reader.error ?? new Error("Failed to read snapshot blob"));
};
reader.readAsDataURL(blob);
});
}
export function downloadSnapshot(dataUrl: string, filename: string): void {
try {
const link = document.createElement("a");
link.href = dataUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Failed to download snapshot for ${filename}:`, error);
}
}
export function generateSnapshotFilename(cameraName: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
return `${cameraName}_snapshot_${timestamp}.jpg`;
}
export async function grabVideoSnapshot(): Promise<SnapshotResult> {
try {
// Find the video element in the player
const videoElement = document.querySelector(
"#player-container video",
) as HTMLVideoElement;
if (!videoElement) {
return {
success: false,
error: "Video element not found",
};
}
const width = videoElement.videoWidth;
const height = videoElement.videoHeight;
if (width === 0 || height === 0) {
return {
success: false,
error: "Video element has no dimensions",
};
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
return {
success: false,
error: "Failed to get canvas context",
};
}
context.drawImage(videoElement, 0, 0, width, height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
// Convert data URL to blob
const response = await fetch(dataUrl);
const blob = await response.blob();
return {
success: true,
data: {
dataUrl,
blob,
contentType: "image/jpeg",
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
};
}
}

View File

@ -57,6 +57,7 @@ import {
} from "react-icons/fa"; } from "react-icons/fa";
import { GiSpeaker, GiSpeakerOff } from "react-icons/gi"; import { GiSpeaker, GiSpeakerOff } from "react-icons/gi";
import { import {
TbCameraDown,
TbRecordMail, TbRecordMail,
TbRecordMailOff, TbRecordMailOff,
TbViewfinder, TbViewfinder,
@ -112,6 +113,14 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
import PtzControlPanel from "@/components/overlay/PtzControlPanel"; import PtzControlPanel from "@/components/overlay/PtzControlPanel";
import ObjectSettingsView from "../settings/ObjectSettingsView"; import ObjectSettingsView from "../settings/ObjectSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchEffect } from "@/hooks/use-overlay-state";
import {
downloadSnapshot,
fetchCameraSnapshot,
generateSnapshotFilename,
grabVideoSnapshot,
SnapshotResult,
} from "@/utils/snapshotUtil";
import ActivityIndicator from "@/components/indicators/activity-indicator";
type LiveCameraViewProps = { type LiveCameraViewProps = {
config?: FrigateConfig; config?: FrigateConfig;
@ -882,6 +891,34 @@ function FrigateCameraFeatures({
} }
}, [createEvent, endEvent, isRecording]); }, [createEvent, endEvent, isRecording]);
const [isSnapshotLoading, setIsSnapshotLoading] = useState(false);
const handleSnapshotClick = useCallback(async () => {
setIsSnapshotLoading(true);
try {
let result: SnapshotResult;
if (isRestreamed && preferredLiveMode !== "jsmpeg") {
// For restreamed streams with video elements (MSE/WebRTC), grab directly from video element
result = await grabVideoSnapshot();
} else {
// For detect stream or JSMpeg players, use the API endpoint
result = await fetchCameraSnapshot(camera.name);
}
if (result.success) {
const { dataUrl } = result.data;
const filename = generateSnapshotFilename(camera.name);
downloadSnapshot(dataUrl, filename);
toast.success(t("snapshot.downloadStarted"));
} else {
toast.error(t("snapshot.captureFailed"));
}
} finally {
setIsSnapshotLoading(false);
}
}, [camera.name, isRestreamed, preferredLiveMode, t]);
useEffect(() => { useEffect(() => {
// ensure manual event is stopped when component unmounts // ensure manual event is stopped when component unmounts
return () => { return () => {
@ -1016,6 +1053,16 @@ function FrigateCameraFeatures({
onClick={handleEventButtonClick} onClick={handleEventButtonClick}
disabled={!cameraEnabled || debug} disabled={!cameraEnabled || debug}
/> />
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
Icon={TbCameraDown}
isActive={false}
title={t("snapshot.takeSnapshot")}
onClick={handleSnapshotClick}
disabled={!cameraEnabled || debug || isSnapshotLoading}
loading={isSnapshotLoading}
/>
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<div <div
@ -1584,16 +1631,28 @@ function FrigateCameraFeatures({
<div className="mb-1 text-sm font-medium leading-none"> <div className="mb-1 text-sm font-medium leading-none">
{t("manualRecording.title")} {t("manualRecording.title")}
</div> </div>
<div className="flex flex-row items-stretch gap-2">
<Button
onClick={handleSnapshotClick}
disabled={!cameraEnabled || debug || isSnapshotLoading}
className="h-auto w-full whitespace-normal"
>
{isSnapshotLoading && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("snapshot.takeSnapshot")}
</Button>
<Button <Button
onClick={handleEventButtonClick} onClick={handleEventButtonClick}
className={cn( className={cn(
"w-full", "h-auto w-full whitespace-normal",
isRecording && "animate-pulse bg-red-500 hover:bg-red-600", isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
)} )}
disabled={debug} disabled={debug}
> >
{t("manualRecording." + (isRecording ? "end" : "start"))} {t("manualRecording." + (isRecording ? "end" : "start"))}
</Button> </Button>
</div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("manualRecording.tips")} {t("manualRecording.tips")}
</p> </p>