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",
|
"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."
|
||||||
|
|||||||
@ -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,16 +56,20 @@ export default function CameraFeatureToggle({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
{loading ? (
|
||||||
className={cn(
|
<ActivityIndicator className="size-5 md:m-[6px]" />
|
||||||
"size-5 md:m-[6px]",
|
) : (
|
||||||
disabled
|
<Icon
|
||||||
? "text-gray-400"
|
className={cn(
|
||||||
: isActive
|
"size-5 md:m-[6px]",
|
||||||
? "text-white"
|
disabled
|
||||||
: "text-secondary-foreground",
|
? "text-gray-400"
|
||||||
)}
|
: isActive
|
||||||
/>
|
? "text-white"
|
||||||
|
: "text-secondary-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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";
|
} 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>
|
||||||
<Button
|
<div className="flex flex-row items-stretch gap-2">
|
||||||
onClick={handleEventButtonClick}
|
<Button
|
||||||
className={cn(
|
onClick={handleSnapshotClick}
|
||||||
"w-full",
|
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
||||||
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
className="h-auto w-full whitespace-normal"
|
||||||
)}
|
>
|
||||||
disabled={debug}
|
{isSnapshotLoading && (
|
||||||
>
|
<ActivityIndicator className="mr-2 size-4" />
|
||||||
{t("manualRecording." + (isRecording ? "end" : "start"))}
|
)}
|
||||||
</Button>
|
{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">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("manualRecording.tips")}
|
{t("manualRecording.tips")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user