mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
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:
parent
b05ac7430a
commit
dad5b72145
@ -73,6 +73,12 @@
|
||||
"enable": "Enable 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": {
|
||||
"enable": "Enable Audio Detect",
|
||||
"disable": "Disable Audio Detect"
|
||||
@ -90,8 +96,8 @@
|
||||
"disable": "Hide Stream Stats"
|
||||
},
|
||||
"manualRecording": {
|
||||
"title": "On-Demand Recording",
|
||||
"tips": "Start a manual event based on this camera's recording retention settings.",
|
||||
"title": "On-Demand",
|
||||
"tips": "Download an instant snapshot or start a manual event based on this camera's recording retention settings.",
|
||||
"playInBackground": {
|
||||
"label": "Play in background",
|
||||
"desc": "Enable this option to continue streaming when the player is hidden."
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
|
||||
const variants = {
|
||||
primary: {
|
||||
@ -30,7 +31,8 @@ type CameraFeatureToggleProps = {
|
||||
Icon: IconType;
|
||||
title: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean; // New prop for disabling
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export default function CameraFeatureToggle({
|
||||
@ -40,7 +42,8 @@ export default function CameraFeatureToggle({
|
||||
Icon,
|
||||
title,
|
||||
onClick,
|
||||
disabled = false, // Default to false
|
||||
disabled = false,
|
||||
loading = false,
|
||||
}: CameraFeatureToggleProps) {
|
||||
const content = (
|
||||
<div
|
||||
@ -53,16 +56,20 @@ export default function CameraFeatureToggle({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-5 md:m-[6px]",
|
||||
disabled
|
||||
? "text-gray-400"
|
||||
: isActive
|
||||
? "text-white"
|
||||
: "text-secondary-foreground",
|
||||
)}
|
||||
/>
|
||||
{loading ? (
|
||||
<ActivityIndicator className="size-5 md:m-[6px]" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-5 md:m-[6px]",
|
||||
disabled
|
||||
? "text-gray-400"
|
||||
: isActive
|
||||
? "text-white"
|
||||
: "text-secondary-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
163
web/src/utils/snapshotUtil.ts
Normal file
163
web/src/utils/snapshotUtil.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -57,6 +57,7 @@ import {
|
||||
} from "react-icons/fa";
|
||||
import { GiSpeaker, GiSpeakerOff } from "react-icons/gi";
|
||||
import {
|
||||
TbCameraDown,
|
||||
TbRecordMail,
|
||||
TbRecordMailOff,
|
||||
TbViewfinder,
|
||||
@ -112,6 +113,14 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
|
||||
import ObjectSettingsView from "../settings/ObjectSettingsView";
|
||||
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 = {
|
||||
config?: FrigateConfig;
|
||||
@ -882,6 +891,34 @@ function FrigateCameraFeatures({
|
||||
}
|
||||
}, [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(() => {
|
||||
// ensure manual event is stopped when component unmounts
|
||||
return () => {
|
||||
@ -1016,6 +1053,16 @@ function FrigateCameraFeatures({
|
||||
onClick={handleEventButtonClick}
|
||||
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}>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
@ -1584,16 +1631,28 @@ function FrigateCameraFeatures({
|
||||
<div className="mb-1 text-sm font-medium leading-none">
|
||||
{t("manualRecording.title")}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleEventButtonClick}
|
||||
className={cn(
|
||||
"w-full",
|
||||
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
disabled={debug}
|
||||
>
|
||||
{t("manualRecording." + (isRecording ? "end" : "start"))}
|
||||
</Button>
|
||||
<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
|
||||
onClick={handleEventButtonClick}
|
||||
className={cn(
|
||||
"h-auto w-full whitespace-normal",
|
||||
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
disabled={debug}
|
||||
>
|
||||
{t("manualRecording." + (isRecording ? "end" : "start"))}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("manualRecording.tips")}
|
||||
</p>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user