mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
4 Commits
f6f555387e
...
c5fec3271f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5fec3271f | ||
|
|
0743cb57c2 | ||
|
|
4319118e94 | ||
|
|
4c689dde8e |
@ -240,6 +240,8 @@ birdseye:
|
|||||||
scaling_factor: 2.0
|
scaling_factor: 2.0
|
||||||
# Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras)
|
# Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras)
|
||||||
max_cameras: 1
|
max_cameras: 1
|
||||||
|
# Optional: Frames-per-second to re-send the last composed Birdseye frame when idle (no motion or active updates). (default: shown below)
|
||||||
|
idle_heartbeat_fps: 0.0
|
||||||
|
|
||||||
# Optional: ffmpeg configuration
|
# Optional: ffmpeg configuration
|
||||||
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
|
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
|
||||||
|
|||||||
@ -24,6 +24,11 @@ birdseye:
|
|||||||
restream: True
|
restream: True
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
To improve connection speed when using Birdseye via restream you can enable a small idle heartbeat by setting `birdseye.idle_heartbeat_fps` to a low value (e.g. `1–2`). This makes Frigate periodically push the last frame even when no motion is detected, reducing initial connection latency.
|
||||||
|
|
||||||
|
:::
|
||||||
### Securing Restream With Authentication
|
### Securing Restream With Authentication
|
||||||
|
|
||||||
The go2rtc restream can be secured with RTSP based username / password authentication. Ex:
|
The go2rtc restream can be secured with RTSP based username / password authentication. Ex:
|
||||||
|
|||||||
@ -55,6 +55,12 @@ class BirdseyeConfig(FrigateBaseModel):
|
|||||||
layout: BirdseyeLayoutConfig = Field(
|
layout: BirdseyeLayoutConfig = Field(
|
||||||
default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config"
|
default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config"
|
||||||
)
|
)
|
||||||
|
idle_heartbeat_fps: float = Field(
|
||||||
|
default=0.0,
|
||||||
|
ge=0.0,
|
||||||
|
le=10.0,
|
||||||
|
title="Idle heartbeat FPS (0 disables, max 10)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# uses BaseModel because some global attributes are not available at the camera level
|
# uses BaseModel because some global attributes are not available at the camera level
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import os
|
|||||||
import queue
|
import queue
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@ -791,6 +792,10 @@ class Birdseye:
|
|||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
|
self.idle_fps: float = self.config.birdseye.idle_heartbeat_fps
|
||||||
|
self._idle_interval: Optional[float] = (
|
||||||
|
(1.0 / self.idle_fps) if self.idle_fps > 0 else None
|
||||||
|
)
|
||||||
|
|
||||||
if config.birdseye.restream:
|
if config.birdseye.restream:
|
||||||
self.birdseye_buffer = self.frame_manager.create(
|
self.birdseye_buffer = self.frame_manager.create(
|
||||||
@ -848,6 +853,15 @@ class Birdseye:
|
|||||||
if frame_layout_changed:
|
if frame_layout_changed:
|
||||||
coordinates = self.birdseye_manager.get_camera_coordinates()
|
coordinates = self.birdseye_manager.get_camera_coordinates()
|
||||||
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
|
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
|
||||||
|
if self._idle_interval:
|
||||||
|
now = time.monotonic()
|
||||||
|
is_idle = len(self.birdseye_manager.camera_layout) == 0
|
||||||
|
if (
|
||||||
|
is_idle
|
||||||
|
and (now - self.birdseye_manager.last_output_time)
|
||||||
|
>= self._idle_interval
|
||||||
|
):
|
||||||
|
self.__send_new_frame()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.converter.join()
|
self.converter.join()
|
||||||
|
|||||||
@ -175,8 +175,8 @@
|
|||||||
"exitEdit": "Exit Editing"
|
"exitEdit": "Exit Editing"
|
||||||
},
|
},
|
||||||
"noCameras": {
|
"noCameras": {
|
||||||
"title": "No Cameras Set Up",
|
"title": "No Cameras Configured",
|
||||||
"description": "Get started by connecting a camera.",
|
"description": "Get started by connecting a camera to Frigate.",
|
||||||
"buttonText": "Add Camera"
|
"buttonText": "Add Camera"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -385,7 +385,8 @@
|
|||||||
"mustNotBeSameWithCamera": "Zone name must not be the same as camera name.",
|
"mustNotBeSameWithCamera": "Zone name must not be the same as camera name.",
|
||||||
"alreadyExists": "A zone with this name already exists for this camera.",
|
"alreadyExists": "A zone with this name already exists for this camera.",
|
||||||
"mustNotContainPeriod": "Zone name must not contain periods.",
|
"mustNotContainPeriod": "Zone name must not contain periods.",
|
||||||
"hasIllegalCharacter": "Zone name contains illegal characters."
|
"hasIllegalCharacter": "Zone name contains illegal characters.",
|
||||||
|
"mustHaveAtLeastOneLetter": "Zone name must have at least one letter."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"distance": {
|
"distance": {
|
||||||
@ -443,7 +444,7 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"title": "Name",
|
"title": "Name",
|
||||||
"inputPlaceHolder": "Enter a name…",
|
"inputPlaceHolder": "Enter a name…",
|
||||||
"tips": "Name must be at least 2 characters and must not be the name of a camera or another zone."
|
"tips": "Name must be at least 2 characters, must have at least one letter, and must not be the name of a camera or another zone."
|
||||||
},
|
},
|
||||||
"inertia": {
|
"inertia": {
|
||||||
"title": "Inertia",
|
"title": "Inertia",
|
||||||
|
|||||||
@ -1,27 +1,30 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import Heading from "../ui/heading";
|
import Heading from "../ui/heading";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
type EmptyCardProps = {
|
type EmptyCardProps = {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
|
link?: string;
|
||||||
};
|
};
|
||||||
export function EmptyCard({
|
export function EmptyCard({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
buttonText,
|
buttonText,
|
||||||
|
link,
|
||||||
}: EmptyCardProps) {
|
}: EmptyCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<Heading as="h4">{title}</Heading>
|
<Heading as="h4">{title}</Heading>
|
||||||
<div className="text-secondary-foreground">{description}</div>
|
<div className="mb-3 text-secondary-foreground">{description}</div>
|
||||||
{buttonText?.length && (
|
{buttonText?.length && (
|
||||||
<Button size="sm" variant="select">
|
<Button size="sm" variant="select">
|
||||||
{buttonText}
|
<Link to={link ?? "#"}>{buttonText}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -150,7 +150,9 @@ export default function SearchThumbnail({
|
|||||||
.filter(
|
.filter(
|
||||||
(item) => item !== undefined && !item.includes("-verified"),
|
(item) => item !== undefined && !item.includes("-verified"),
|
||||||
)
|
)
|
||||||
.map((text) => getTranslatedLabel(text, searchResult.data.type))
|
.map((text) =>
|
||||||
|
getTranslatedLabel(text, searchResult.data.type),
|
||||||
|
)
|
||||||
.sort()
|
.sort()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
.replaceAll("-verified", "")}
|
.replaceAll("-verified", "")}
|
||||||
|
|||||||
@ -152,36 +152,38 @@ export default function CameraEditForm({
|
|||||||
}))
|
}))
|
||||||
: defaultValues.ffmpeg.inputs;
|
: defaultValues.ffmpeg.inputs;
|
||||||
|
|
||||||
// Load go2rtc streams for this camera
|
|
||||||
const go2rtcStreams = config.go2rtc?.streams || {};
|
const go2rtcStreams = config.go2rtc?.streams || {};
|
||||||
const cameraStreams: Record<string, string[]> = {};
|
const cameraStreams: Record<string, string[]> = {};
|
||||||
|
|
||||||
// Find streams that match this camera's name pattern
|
// get candidate stream names for this camera. could be the camera's own name,
|
||||||
Object.entries(go2rtcStreams).forEach(([streamName, urls]) => {
|
// any restream names referenced by this camera, or any keys under live --> streams
|
||||||
if (streamName.startsWith(cameraName) || streamName === cameraName) {
|
const validNames = new Set<string>();
|
||||||
cameraStreams[streamName] = Array.isArray(urls) ? urls : [urls];
|
validNames.add(cameraName);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also deduce go2rtc streams from restream URLs in camera inputs
|
// deduce go2rtc stream names from rtsp restream inputs
|
||||||
camera.ffmpeg?.inputs?.forEach((input, index) => {
|
camera.ffmpeg?.inputs?.forEach((input) => {
|
||||||
|
// exclude any query strings or trailing slashes from the stream name
|
||||||
const restreamMatch = input.path.match(
|
const restreamMatch = input.path.match(
|
||||||
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
|
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
|
||||||
);
|
);
|
||||||
if (restreamMatch) {
|
if (restreamMatch) {
|
||||||
const streamName = restreamMatch[1];
|
const streamName = restreamMatch[1];
|
||||||
// Find the corresponding go2rtc stream
|
validNames.add(streamName);
|
||||||
const go2rtcStream = Object.entries(go2rtcStreams).find(
|
}
|
||||||
([name]) =>
|
});
|
||||||
name === streamName ||
|
|
||||||
name === `${cameraName}_${index + 1}` ||
|
// Include live --> streams keys
|
||||||
name === cameraName,
|
const liveStreams = camera?.live?.streams;
|
||||||
);
|
if (liveStreams) {
|
||||||
if (go2rtcStream) {
|
Object.keys(liveStreams).forEach((key) => {
|
||||||
cameraStreams[go2rtcStream[0]] = Array.isArray(go2rtcStream[1])
|
validNames.add(key);
|
||||||
? go2rtcStream[1]
|
});
|
||||||
: [go2rtcStream[1]];
|
}
|
||||||
}
|
|
||||||
|
// Map only go2rtc entries that match the collected names
|
||||||
|
Object.entries(go2rtcStreams).forEach(([name, urls]) => {
|
||||||
|
if (validNames.has(name)) {
|
||||||
|
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -149,6 +149,11 @@ export default function ZoneEditPane({
|
|||||||
)
|
)
|
||||||
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
|
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
|
||||||
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
|
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
|
||||||
|
})
|
||||||
|
.refine((value: string) => /[a-zA-Z]/.test(value), {
|
||||||
|
message: t(
|
||||||
|
"masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
inertia: z.coerce
|
inertia: z.coerce
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@ -354,7 +354,7 @@ export default function LiveDashboardView({
|
|||||||
onSaveMuting(true);
|
onSaveMuting(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cameras.length == 0) {
|
if (cameras.length == 0 && !includeBirdseye) {
|
||||||
return <NoCameraView />;
|
return <NoCameraView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -625,6 +625,7 @@ function NoCameraView() {
|
|||||||
title={t("noCameras.title")}
|
title={t("noCameras.title")}
|
||||||
description={t("noCameras.description")}
|
description={t("noCameras.description")}
|
||||||
buttonText={t("noCameras.buttonText")}
|
buttonText={t("noCameras.buttonText")}
|
||||||
|
link="/settings?page=cameraManagement"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user