mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-14 01:23:01 +03:00
Compare commits
2
Commits
improve-live
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e3dca5915 | ||
|
|
9276502e4f |
+17
-1
@@ -45,7 +45,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL, REPLAY_CAMERA_PREFIX
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
from frigate.jobs.media_sync import (
|
||||
@@ -979,6 +979,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# an unresolvable scene would crash the threads that look up the model
|
||||
detect_update = sections.get("detect")
|
||||
if isinstance(detect_update, dict) and "scene" in detect_update:
|
||||
try:
|
||||
config._resolve_camera_model(cam_name, detect_update["scene"])
|
||||
except (TypeError, ValueError):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Invalid detect scene"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for section_name, update in sections.items():
|
||||
err = apply_section_update(camera_config, section_name, update)
|
||||
if err is not None:
|
||||
@@ -987,6 +998,11 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# the model is resolved again from the scene when the replay
|
||||
# camera is recycled by the refresh below
|
||||
if "detect" in sections and cam_name.startswith(REPLAY_CAMERA_PREFIX):
|
||||
config.drop_camera_model(cam_name)
|
||||
|
||||
# Publish ZMQ updates so processing threads pick up changes
|
||||
if body.update_topic and body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
|
||||
+2
-9
@@ -76,7 +76,7 @@ from frigate.models import (
|
||||
from frigate.notices import install_registry
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.object_detection.util import create_detection_input_shm
|
||||
from frigate.output.output import OutputProcess
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
@@ -399,14 +399,7 @@ class FrigateApp:
|
||||
model = self.config.model_for_camera(name)
|
||||
model_cameras[model.scene].append(name)
|
||||
|
||||
try:
|
||||
shm_in = UntrackedSharedMemory(
|
||||
name=name,
|
||||
create=True,
|
||||
size=detection_frame_size(model),
|
||||
)
|
||||
except FileExistsError:
|
||||
shm_in = UntrackedSharedMemory(name=name)
|
||||
shm_in = create_detection_input_shm(name, self.config.models)
|
||||
|
||||
try:
|
||||
shm_out = UntrackedSharedMemory(
|
||||
|
||||
@@ -16,7 +16,7 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Regions
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.object_detection.util import create_detection_input_shm
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
@@ -127,13 +127,10 @@ class CameraMaintainer(threading.Thread):
|
||||
max(model.width, model.height),
|
||||
)
|
||||
|
||||
create_detection_input_shm(name, self.config.models)
|
||||
|
||||
try:
|
||||
UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4)
|
||||
UntrackedSharedMemory(
|
||||
name=name,
|
||||
create=True,
|
||||
size=detection_frame_size(model),
|
||||
)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -296,7 +296,8 @@ class CameraState:
|
||||
return frame_copy
|
||||
|
||||
def finished(self, obj_id: str) -> None:
|
||||
del self.tracked_objects[obj_id]
|
||||
# state rebuilt after a resolution or scene change no longer has the object
|
||||
self.tracked_objects.pop(obj_id, None)
|
||||
|
||||
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
|
||||
self.callbacks[event_type].append(callback)
|
||||
|
||||
@@ -113,6 +113,23 @@ class AsyncLocalObjectDetector(BaseLocalDetector):
|
||||
return self.detect_api.receive_output()
|
||||
|
||||
|
||||
def map_input_frame(
|
||||
frame_manager: SharedMemoryFrameManager, camera: str, model: ModelConfig
|
||||
) -> np.ndarray | None:
|
||||
"""Map a camera's detection input segment as this model's input tensor.
|
||||
|
||||
The segment is sized for the largest configured model, so it can be larger
|
||||
than this model's input.
|
||||
"""
|
||||
buf = frame_manager.write(camera)
|
||||
shape = (1, model.height, model.width, 3)
|
||||
|
||||
if buf is None or len(buf) < model.height * model.width * 3:
|
||||
return None
|
||||
|
||||
return np.ndarray(shape, dtype=np.uint8, buffer=buf)
|
||||
|
||||
|
||||
class DetectorRunner(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -154,14 +171,10 @@ class DetectorRunner(FrigateProcess):
|
||||
connection_id = self.detection_queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
input_frame = frame_manager.get(
|
||||
input_frame = map_input_frame(
|
||||
frame_manager,
|
||||
connection_id,
|
||||
(
|
||||
1,
|
||||
self.detector_config.model.height, # type: ignore[union-attr]
|
||||
self.detector_config.model.width, # type: ignore[union-attr]
|
||||
3,
|
||||
),
|
||||
self.detector_config.model, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
if input_frame is None:
|
||||
@@ -227,14 +240,10 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
continue
|
||||
|
||||
assert self._frame_manager is not None
|
||||
input_frame = self._frame_manager.get(
|
||||
input_frame = map_input_frame(
|
||||
self._frame_manager,
|
||||
connection_id,
|
||||
(
|
||||
1,
|
||||
self.detector_config.model.height, # type: ignore[union-attr]
|
||||
self.detector_config.model.width, # type: ignore[union-attr]
|
||||
3,
|
||||
),
|
||||
self.detector_config.model, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
if input_frame is None:
|
||||
|
||||
@@ -6,18 +6,39 @@ import threading
|
||||
from numpy import ndarray
|
||||
|
||||
from frigate.detectors.detector_config import InputTensorEnum, ModelConfig
|
||||
from frigate.util.image import UntrackedSharedMemory
|
||||
|
||||
|
||||
def detection_frame_size(model: ModelConfig) -> int:
|
||||
"""Get the shared memory size a camera needs to hand frames to a model.
|
||||
def create_detection_input_shm(
|
||||
camera: str, models: list[ModelConfig]
|
||||
) -> UntrackedSharedMemory:
|
||||
"""Create the shared memory a camera hands detection frames through.
|
||||
|
||||
It is sized for the largest model so a camera re-added under the same name
|
||||
with another scene can reuse it. Detectors keep mappings of it, so it is
|
||||
never unlinked while they run.
|
||||
|
||||
Args:
|
||||
model: The model the camera runs on
|
||||
camera: Name of the camera
|
||||
models: Every configured model
|
||||
|
||||
Returns:
|
||||
Size in bytes of one model input frame
|
||||
The camera's detection input segment
|
||||
"""
|
||||
return model.height * model.width * 3
|
||||
size = max(model.height * model.width * 3 for model in models)
|
||||
|
||||
try:
|
||||
return UntrackedSharedMemory(name=camera, create=True, size=size)
|
||||
except FileExistsError:
|
||||
shm = UntrackedSharedMemory(name=camera)
|
||||
|
||||
if shm.size >= size:
|
||||
return shm
|
||||
|
||||
# left by an earlier run with smaller models, so no detector maps it yet
|
||||
shm.close()
|
||||
shm.unlink()
|
||||
return UntrackedSharedMemory(name=camera, create=True, size=size)
|
||||
|
||||
|
||||
class RequestStore:
|
||||
|
||||
@@ -98,6 +98,46 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_config_set_in_memory_rebinds_replay_model_on_scene_change(
|
||||
self, mock_labels
|
||||
):
|
||||
mock_labels.return_value = {}
|
||||
replay = "_replay_front_door"
|
||||
self.minimal_config["models"] = [
|
||||
{"devices": ["cpu"]},
|
||||
{"scene": "thermal", "devices": ["openvino:CPU"], "width": 300},
|
||||
]
|
||||
self.minimal_config["cameras"][replay] = self.minimal_config["cameras"].pop(
|
||||
"front_door"
|
||||
)
|
||||
app = super().create_app()
|
||||
app.config_publisher = Mock()
|
||||
assert app.frigate_config.model_for_camera(replay).scene == "default"
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"requires_restart": 0,
|
||||
"skip_save": True,
|
||||
"update_topic": f"config/cameras/{replay}/detect",
|
||||
"config_data": {
|
||||
"cameras": {replay: {"detect": {"scene": "thermal"}}}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras[replay].detect.scene == "thermal"
|
||||
assert app.frigate_config.model_for_camera(replay).scene == "thermal"
|
||||
topics = [
|
||||
call.args[0].update_type.name
|
||||
for call in app.config_publisher.publish_update.call_args_list
|
||||
]
|
||||
assert topics[0] == "detect"
|
||||
assert topics[-1] == "refresh"
|
||||
|
||||
####################################################################################################################
|
||||
################################### Credential redaction sentinel ################################################
|
||||
####################################################################################################################
|
||||
|
||||
@@ -91,6 +91,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.refresh,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.timestamp_style,
|
||||
CameraConfigUpdateEnum.zones,
|
||||
@@ -784,6 +785,21 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
|
||||
|
||||
# a replay camera recycled onto another scene's model needs new state
|
||||
for camera in updated_topics.get("refresh", []):
|
||||
camera_state = self.camera_states.get(camera)
|
||||
|
||||
if (
|
||||
camera_state is None
|
||||
or camera_state.model is self.config.model_for_camera(camera)
|
||||
):
|
||||
continue
|
||||
|
||||
self.force_end_all_events(camera, camera_state)
|
||||
camera_state.shutdown()
|
||||
self.frame_manager.close_camera(camera)
|
||||
self.create_camera_state(camera)
|
||||
|
||||
# manage camera disabled state
|
||||
for camera, config in self.config.cameras.items():
|
||||
if not config.enabled_in_config:
|
||||
|
||||
@@ -173,6 +173,9 @@
|
||||
"label": "Play in background",
|
||||
"tips": "Enable this option to continue streaming when the player is hidden."
|
||||
},
|
||||
"metadata": {
|
||||
"loading": "Fetching stream metadata…"
|
||||
},
|
||||
"mode": "Streaming Technology",
|
||||
"technology": {
|
||||
"description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.",
|
||||
|
||||
@@ -240,14 +240,14 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: ["width", "height", "fps"],
|
||||
fieldOrder: ["scene", "width", "height", "fps"],
|
||||
fieldGroups: {
|
||||
resolution: ["width", "height", "fps"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled",
|
||||
"enabled_in_config",
|
||||
"scene",
|
||||
hideSceneWithoutChoice,
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
|
||||
@@ -248,10 +248,21 @@ export default function LivePlayer({
|
||||
// remounts them in the same render and the new player is hidden until it
|
||||
// plays. jsmpeg plays the camera, not the stream, and keeps playing
|
||||
const [renderedStream, setRenderedStream] = useState(streamName);
|
||||
const [switchFrame, setSwitchFrame] = useState<HTMLCanvasElement>();
|
||||
if (renderedStream !== streamName) {
|
||||
setRenderedStream(streamName);
|
||||
if (preferredLiveMode !== "jsmpeg") {
|
||||
setLiveReady(false);
|
||||
|
||||
// the old video is still in the DOM until this render commits, so its
|
||||
// last frame can cover the gap until the new stream plays. Tiles that
|
||||
// show the camera still already cover it
|
||||
if (!showStillWithoutActivity) {
|
||||
const frame = captureVideoFrame(overlayRef.current);
|
||||
if (frame) {
|
||||
setSwitchFrame(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +274,7 @@ export default function LivePlayer({
|
||||
|
||||
const playerIsPlaying = useCallback(() => {
|
||||
setLiveReady(true);
|
||||
setSwitchFrame(undefined);
|
||||
}, []);
|
||||
|
||||
// enabled states
|
||||
@@ -416,6 +428,12 @@ export default function LivePlayer({
|
||||
</div>
|
||||
)}
|
||||
{player}
|
||||
{switchFrame && !liveReady && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
ref={(node) => node?.replaceChildren(switchFrame)}
|
||||
/>
|
||||
)}
|
||||
{cameraEnabled &&
|
||||
!offline &&
|
||||
(!showStillWithoutActivity || isReEnabling) &&
|
||||
@@ -582,3 +600,19 @@ export default function LivePlayer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function captureVideoFrame(
|
||||
container: HTMLElement | null,
|
||||
): HTMLCanvasElement | undefined {
|
||||
const video = container?.querySelector("video");
|
||||
if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.className = "size-full object-contain";
|
||||
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { MdOutlineRestartAlt } from "react-icons/md";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
|
||||
// Radix Select needs a non-empty value. Auto itself is stored as the
|
||||
// absence of a pinned stream
|
||||
const AUTO_VALUE = "__frigate_auto__";
|
||||
|
||||
const GRID_ITEM =
|
||||
"h-auto min-h-10 min-w-[calc((100%-1rem)/3)] grow whitespace-normal bg-secondary [overflow-wrap:anywhere] px-2 py-2 text-secondary-foreground hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-selected data-[state=on]:text-selected-foreground data-[state=on]:hover:bg-selected data-[state=on]:hover:text-selected-foreground";
|
||||
|
||||
type LiveStreamSelectProps = {
|
||||
// friendly name to go2rtc stream name, in ladder order
|
||||
streams: Record<string, string>;
|
||||
@@ -29,6 +33,7 @@ type LiveStreamSelectProps = {
|
||||
// sends auto back to the top of the ladder
|
||||
onRetry: () => void;
|
||||
disabled: boolean;
|
||||
variant?: "select" | "grid";
|
||||
};
|
||||
|
||||
export default function LiveStreamSelect({
|
||||
@@ -40,6 +45,7 @@ export default function LiveStreamSelect({
|
||||
onSelect,
|
||||
onRetry,
|
||||
disabled,
|
||||
variant = "select",
|
||||
}: LiveStreamSelectProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
|
||||
@@ -52,48 +58,78 @@ export default function LiveStreamSelect({
|
||||
const auto = autoAvailable && pinnedStream == undefined;
|
||||
const value = auto ? AUTO_VALUE : (pinnedStream ?? playingStream);
|
||||
|
||||
const autoLabel = auto
|
||||
? t("stream.auto.selected", {
|
||||
stream:
|
||||
autoReason === "floor"
|
||||
? t("stream.auto.floorLabel")
|
||||
: labelFor(playingStream),
|
||||
})
|
||||
: t("stream.auto.label");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) =>
|
||||
onSelect(next === AUTO_VALUE ? undefined : next)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{auto
|
||||
? t("stream.auto.selected", {
|
||||
stream:
|
||||
autoReason === "floor"
|
||||
? t("stream.auto.floorLabel")
|
||||
: labelFor(playingStream),
|
||||
})
|
||||
: labelFor(value)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
{variant === "grid" &&
|
||||
!autoAvailable &&
|
||||
Object.keys(streams).length <= 1 ? (
|
||||
<div className="text-sm">{labelFor(playingStream)}</div>
|
||||
) : variant === "grid" ? (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
className="flex flex-wrap items-stretch gap-2"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
// radix sends an empty value when the active item is tapped again
|
||||
if (next) {
|
||||
onSelect(next === AUTO_VALUE ? undefined : next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{autoAvailable && (
|
||||
<ToggleGroupItem value={AUTO_VALUE} className={GRID_ITEM}>
|
||||
{autoLabel}
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
{Object.entries(streams).map(([label, name]) => (
|
||||
<ToggleGroupItem key={label} value={name} className={GRID_ITEM}>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
) : (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) =>
|
||||
onSelect(next === AUTO_VALUE ? undefined : next)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{auto ? autoLabel : labelFor(value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{autoAvailable && (
|
||||
<SelectItem className="cursor-pointer" value={AUTO_VALUE}>
|
||||
<span className="flex flex-col gap-0.5 whitespace-normal">
|
||||
<span>{t("stream.auto.label")}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("stream.auto.tips")}
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{autoAvailable && (
|
||||
<SelectItem className="cursor-pointer" value={AUTO_VALUE}>
|
||||
<span className="flex flex-col gap-0.5 whitespace-normal">
|
||||
<span>{t("stream.auto.label")}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("stream.auto.tips")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{Object.entries(streams).map(([label, name]) => (
|
||||
<SelectItem key={label} className="cursor-pointer" value={name}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectItem>
|
||||
)}
|
||||
{Object.entries(streams).map(([label, name]) => (
|
||||
<SelectItem key={label} className="cursor-pointer" value={name}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{auto && autoReason && !disabled && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
|
||||
@@ -230,15 +230,16 @@ export default function LiveCameraView({
|
||||
}
|
||||
}, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]);
|
||||
|
||||
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
},
|
||||
);
|
||||
const { data: cameraMetadata, isLoading: cameraMetadataLoading } =
|
||||
useSWR<LiveStreamMetadata>(
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
},
|
||||
);
|
||||
|
||||
const webRTCAvailability = useWebRTCAvailableForStream(
|
||||
cameraMetadata,
|
||||
@@ -847,6 +848,7 @@ export default function LiveCameraView({
|
||||
onResetStream={resetStream}
|
||||
supportsAudioOutput={supportsAudioOutput}
|
||||
supports2WayTalk={supports2WayTalk}
|
||||
streamMetadataLoading={cameraMetadataLoading}
|
||||
cameraEnabled={cameraEnabled}
|
||||
debug={debug}
|
||||
setDebug={setDebug}
|
||||
@@ -873,9 +875,12 @@ export default function LiveCameraView({
|
||||
growClassName,
|
||||
)}
|
||||
ref={clickOverlayRef}
|
||||
style={{
|
||||
aspectRatio: constrainedAspectRatio,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
aspectRatio: constrainedAspectRatio,
|
||||
"--camera-aspect": cameraAspectRatio,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{clickOverlay && overlaySize.width > 0 && (
|
||||
<div
|
||||
@@ -914,7 +919,10 @@ export default function LiveCameraView({
|
||||
)}
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
className={fullscreen ? "rounded-none" : ""}
|
||||
className={cn(
|
||||
"aspect-[var(--camera-aspect)]",
|
||||
fullscreen && "rounded-none",
|
||||
)}
|
||||
windowVisible
|
||||
showStillWithoutActivity={false}
|
||||
alwaysShowCameraName={false}
|
||||
@@ -1007,6 +1015,7 @@ type FrigateCameraFeaturesProps = {
|
||||
onResetStream: () => void;
|
||||
supportsAudioOutput: boolean;
|
||||
supports2WayTalk: boolean;
|
||||
streamMetadataLoading: boolean;
|
||||
cameraEnabled: boolean;
|
||||
debug: boolean;
|
||||
setDebug: (debug: boolean) => void;
|
||||
@@ -1038,6 +1047,7 @@ function FrigateCameraFeatures({
|
||||
onResetStream,
|
||||
supportsAudioOutput,
|
||||
supports2WayTalk,
|
||||
streamMetadataLoading,
|
||||
cameraEnabled,
|
||||
debug,
|
||||
setDebug,
|
||||
@@ -1434,7 +1444,12 @@ function FrigateCameraFeatures({
|
||||
!debug &&
|
||||
isRestreamed && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
{streamMetadataLoading ? (
|
||||
<>
|
||||
<ActivityIndicator className="size-4" />
|
||||
<div>{t("stream.metadata.loading")}</div>
|
||||
</>
|
||||
) : supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
@@ -1693,72 +1708,7 @@ function FrigateCameraFeatures({
|
||||
<DrawerContent className="max-h-[75dvh] overflow-hidden rounded-2xl">
|
||||
<div className="scrollbar-container mt-2 flex h-auto flex-col gap-2 overflow-y-auto px-2 py-4">
|
||||
<>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.camera")}
|
||||
isChecked={enabledState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.objectDetection")}
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{recordingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.recording")}
|
||||
isChecked={recordState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.snapshots")}
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{audioDetectEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.audioDetection")}
|
||||
isChecked={audioState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{audioDetectEnabled && transcriptionEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.transcription")}
|
||||
disabled={audioState == "OFF"}
|
||||
isChecked={transcriptionState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendTranscription(
|
||||
transcriptionState == "ON" ? "OFF" : "ON",
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.autotracking")}
|
||||
isChecked={autotrackingState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
<div className="mb-3 flex flex-col gap-5">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Label>{t("stream.title")}</Label>
|
||||
@@ -1811,6 +1761,7 @@ function FrigateCameraFeatures({
|
||||
onSelect={onSelectStream}
|
||||
onRetry={onResetStream}
|
||||
disabled={debug || forceLowBandwidth}
|
||||
variant="grid"
|
||||
/>
|
||||
|
||||
{debug && (
|
||||
@@ -1826,7 +1777,12 @@ function FrigateCameraFeatures({
|
||||
!debug &&
|
||||
isRestreamed && (
|
||||
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
{streamMetadataLoading ? (
|
||||
<>
|
||||
<ActivityIndicator className="size-4" size={14} />
|
||||
<div>{t("stream.metadata.loading")}</div>
|
||||
</>
|
||||
) : supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
@@ -1955,6 +1911,73 @@ function FrigateCameraFeatures({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.camera")}
|
||||
isChecked={enabledState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.objectDetection")}
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{recordingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.recording")}
|
||||
isChecked={recordState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.snapshots")}
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{audioDetectEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.audioDetection")}
|
||||
isChecked={audioState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{audioDetectEnabled && transcriptionEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.transcription")}
|
||||
disabled={audioState == "OFF"}
|
||||
isChecked={transcriptionState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendTranscription(
|
||||
transcriptionState == "ON" ? "OFF" : "ON",
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.autotracking")}
|
||||
isChecked={autotrackingState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
{isRestreamed &&
|
||||
Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
Reference in New Issue
Block a user