mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-14 09:06:43 +03:00
Compare commits
No commits in common. "b02d45d3cb75c61840f117e120305ae8c77e0599" and "b05ac7430aa2c130d2084d05615002e717b16ad1" have entirely different histories.
b02d45d3cb
...
b05ac7430a
@ -85,8 +85,8 @@ PRESETS_HW_ACCEL_DECODE = {
|
|||||||
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
|
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
|
||||||
"preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m",
|
"preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m",
|
||||||
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
|
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
|
||||||
"preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
"preset-intel-qsv-h264": "-hwaccel qsv -qsv_device {3} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
||||||
"preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
"preset-intel-qsv-h265": "-load_plugin hevc_hw -hwaccel qsv -qsv_device {3} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
||||||
FFMPEG_HWACCEL_NVIDIA: "-hwaccel_device {3} -hwaccel cuda -hwaccel_output_format cuda",
|
FFMPEG_HWACCEL_NVIDIA: "-hwaccel_device {3} -hwaccel cuda -hwaccel_output_format cuda",
|
||||||
"preset-jetson-h264": "-c:v h264_nvmpi -resize {1}x{2}",
|
"preset-jetson-h264": "-c:v h264_nvmpi -resize {1}x{2}",
|
||||||
"preset-jetson-h265": "-c:v hevc_nvmpi -resize {1}x{2}",
|
"preset-jetson-h265": "-c:v hevc_nvmpi -resize {1}x{2}",
|
||||||
|
|||||||
@ -73,12 +73,6 @@
|
|||||||
"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"
|
||||||
@ -96,8 +90,8 @@
|
|||||||
"disable": "Hide Stream Stats"
|
"disable": "Hide Stream Stats"
|
||||||
},
|
},
|
||||||
"manualRecording": {
|
"manualRecording": {
|
||||||
"title": "On-Demand",
|
"title": "On-Demand Recording",
|
||||||
"tips": "Download an instant snapshot or start a manual event based on this camera's recording retention settings.",
|
"tips": "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,7 +6,6 @@ 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: {
|
||||||
@ -31,8 +30,7 @@ type CameraFeatureToggleProps = {
|
|||||||
Icon: IconType;
|
Icon: IconType;
|
||||||
title: string;
|
title: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean; // New prop for disabling
|
||||||
loading?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CameraFeatureToggle({
|
export default function CameraFeatureToggle({
|
||||||
@ -42,8 +40,7 @@ export default function CameraFeatureToggle({
|
|||||||
Icon,
|
Icon,
|
||||||
title,
|
title,
|
||||||
onClick,
|
onClick,
|
||||||
disabled = false,
|
disabled = false, // Default to false
|
||||||
loading = false,
|
|
||||||
}: CameraFeatureToggleProps) {
|
}: CameraFeatureToggleProps) {
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
@ -56,9 +53,6 @@ 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]",
|
||||||
@ -69,7 +63,6 @@ export default function CameraFeatureToggle({
|
|||||||
: "text-secondary-foreground",
|
: "text-secondary-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,163 +0,0 @@
|
|||||||
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,7 +57,6 @@ 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,
|
||||||
@ -113,14 +112,6 @@ 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;
|
||||||
@ -891,34 +882,6 @@ 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 () => {
|
||||||
@ -1053,16 +1016,6 @@ 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
|
||||||
@ -1631,28 +1584,16 @@ 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(
|
||||||
"h-auto w-full whitespace-normal",
|
"w-full",
|
||||||
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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user