Add more i18n key again

Merge branch 'dev' of https://github.com/ZhaiSoul/frigate into dev
This commit is contained in:
ZhaiSoul 2025-03-05 00:03:42 +08:00
commit d0d0756496
55 changed files with 1355 additions and 1412 deletions

View File

@ -175,6 +175,7 @@ jobs:
files: docker/rocm/rocm.hcl
set: |
rocm.tags=${{ steps.setup.outputs.image-name }}-rocm
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm,mode=max
*.cache-from=type=gha
arm64_extra_builds:
runs-on: ubuntu-22.04-arm

View File

@ -39,10 +39,7 @@ ARG DEBIAN_FRONTEND
ENV CCACHE_DIR /root/.ccache
ENV CCACHE_MAXSIZE 2G
# bind /var/cache/apt to tmpfs to speed up nginx build
RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
--mount=type=bind,source=docker/main/build_nginx.sh,target=/deps/build_nginx.sh \
--mount=type=cache,target=/root/.ccache \
RUN --mount=type=bind,source=docker/main/build_nginx.sh,target=/deps/build_nginx.sh \
/deps/build_nginx.sh
FROM wget AS sqlite-vec
@ -225,6 +222,9 @@ ENV TRANSFORMERS_NO_ADVISORY_WARNINGS=1
# Set OpenCV ffmpeg loglevel to fatal: https://ffmpeg.org/doxygen/trunk/log_8h.html
ENV OPENCV_FFMPEG_LOGLEVEL=8
# Set HailoRT to disable logging
ENV HAILORT_LOGGER_PATH=NONE
ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PATH}"
# Install dependencies

View File

@ -2,79 +2,49 @@
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive
ARG ROCM=5.7.3
ARG ROCM=6.3.3
ARG AMDGPU=gfx900
ARG HSA_OVERRIDE_GFX_VERSION
ARG HSA_OVERRIDE
#######################################################################
FROM ubuntu:focal as rocm
FROM wget AS rocm
ARG ROCM
ARG AMDGPU
RUN apt-get update && apt-get -y upgrade
RUN apt-get -y install gnupg wget
RUN mkdir --parents --mode=0755 /etc/apt/keyrings
RUN wget https://repo.radeon.com/rocm/rocm.gpg.key -O - | gpg --dearmor | tee /etc/apt/keyrings/rocm.gpg > /dev/null
COPY docker/rocm/rocm.list /etc/apt/sources.list.d/
COPY docker/rocm/rocm-pin-600 /etc/apt/preferences.d/
RUN apt-get update
RUN apt-get -y install --no-install-recommends migraphx hipfft roctracer
RUN apt-get -y install --no-install-recommends migraphx-dev
RUN apt update && \
apt install -y wget gpg && \
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/$ROCM/ubuntu/jammy/amdgpu-install_6.3.60303-1_all.deb && \
apt install -y ./rocm.deb && \
apt update && \
apt install -y rocm
RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib
RUN cd /opt/rocm-$ROCM/lib && cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocfft*.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/
RUN cd /opt/rocm-$ROCM/lib && \
cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \
mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \
cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib
RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm
RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/
RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
#######################################################################
FROM --platform=linux/amd64 debian:12 as debian-base
RUN apt-get update && apt-get -y upgrade
RUN apt-get -y install --no-install-recommends libelf1 libdrm2 libdrm-amdgpu1 libnuma1 kmod
RUN apt-get -y install python3
#######################################################################
# ROCm does not come with migraphx wrappers for python 3.9, so we build it here
FROM debian-base as debian-build
ARG ROCM
COPY --from=rocm /opt/rocm-$ROCM /opt/rocm-$ROCM
RUN ln -s /opt/rocm-$ROCM /opt/rocm
RUN apt-get -y install g++ cmake
RUN apt-get -y install python3-pybind11 python3-distutils python3-dev
WORKDIR /opt/build
COPY docker/rocm/migraphx .
RUN mkdir build && cd build && cmake .. && make install
#######################################################################
FROM deps AS deps-prelim
# need this to install libnuma1
RUN apt-get update
# no ugprade?!?!
RUN apt-get -y install libnuma1
RUN apt-get update && apt-get install -y libnuma1
WORKDIR /opt/frigate/
WORKDIR /opt/frigate
COPY --from=rootfs / /
# Temporarily disabled to see if a new wheel can be built to support py3.11
#COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt
#RUN python3 -m pip install --upgrade pip \
# && pip3 uninstall -y onnxruntime-openvino \
# && pip3 install -r /requirements.txt
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& python3 get-pip.py "pip" --break-system-packages
RUN python3 -m pip config set global.break-system-packages true
COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt
RUN pip3 uninstall -y onnxruntime-openvino \
&& pip3 install -r /requirements.txt
#######################################################################
FROM scratch AS rocm-dist
@ -87,12 +57,11 @@ COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*$AMDGPU* /opt/rocm-$ROCM/share
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx908* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*$AMDGPU* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-dist/ /
COPY --from=debian-build /opt/rocm/lib/migraphx.cpython-311-x86_64-linux-gnu.so /opt/rocm-$ROCM/lib/
#######################################################################
FROM deps-prelim AS rocm-prelim-hsa-override0
\
ENV HSA_ENABLE_SDMA=0
ENV HSA_ENABLE_SDMA=0
ENV MIGRAPHX_ENABLE_NHWC=1
COPY --from=rocm-dist / /

View File

@ -1,26 +0,0 @@
cmake_minimum_required(VERSION 3.1)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
SET(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)
project(migraphx_py)
include_directories(/opt/rocm/include)
find_package(pybind11 REQUIRED)
pybind11_add_module(migraphx migraphx_py.cpp)
target_link_libraries(migraphx PRIVATE /opt/rocm/lib/libmigraphx.so /opt/rocm/lib/libmigraphx_tf.so /opt/rocm/lib/libmigraphx_onnx.so)
install(TARGETS migraphx
COMPONENT python
LIBRARY DESTINATION /opt/rocm/lib
)

View File

@ -1,582 +0,0 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2015-2022 Advanced Micro Devices, Inc. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <pybind11/numpy.h>
#include <migraphx/program.hpp>
#include <migraphx/instruction_ref.hpp>
#include <migraphx/operation.hpp>
#include <migraphx/quantization.hpp>
#include <migraphx/generate.hpp>
#include <migraphx/instruction.hpp>
#include <migraphx/ref/target.hpp>
#include <migraphx/stringutils.hpp>
#include <migraphx/tf.hpp>
#include <migraphx/onnx.hpp>
#include <migraphx/load_save.hpp>
#include <migraphx/register_target.hpp>
#include <migraphx/json.hpp>
#include <migraphx/make_op.hpp>
#include <migraphx/op/common.hpp>
#ifdef HAVE_GPU
#include <migraphx/gpu/hip.hpp>
#endif
using half = half_float::half;
namespace py = pybind11;
#ifdef __clang__
#define MIGRAPHX_PUSH_UNUSED_WARNING \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wused-but-marked-unused\"")
#define MIGRAPHX_POP_WARNING _Pragma("clang diagnostic pop")
#else
#define MIGRAPHX_PUSH_UNUSED_WARNING
#define MIGRAPHX_POP_WARNING
#endif
#define MIGRAPHX_PYBIND11_MODULE(...) \
MIGRAPHX_PUSH_UNUSED_WARNING \
PYBIND11_MODULE(__VA_ARGS__) \
MIGRAPHX_POP_WARNING
#define MIGRAPHX_PYTHON_GENERATE_SHAPE_ENUM(x, t) .value(#x, migraphx::shape::type_t::x)
namespace migraphx {
migraphx::value to_value(py::kwargs kwargs);
migraphx::value to_value(py::list lst);
template <class T, class F>
void visit_py(T x, F f)
{
if(py::isinstance<py::kwargs>(x))
{
f(to_value(x.template cast<py::kwargs>()));
}
else if(py::isinstance<py::list>(x))
{
f(to_value(x.template cast<py::list>()));
}
else if(py::isinstance<py::bool_>(x))
{
f(x.template cast<bool>());
}
else if(py::isinstance<py::int_>(x) or py::hasattr(x, "__index__"))
{
f(x.template cast<int>());
}
else if(py::isinstance<py::float_>(x))
{
f(x.template cast<float>());
}
else if(py::isinstance<py::str>(x))
{
f(x.template cast<std::string>());
}
else if(py::isinstance<migraphx::shape::dynamic_dimension>(x))
{
f(migraphx::to_value(x.template cast<migraphx::shape::dynamic_dimension>()));
}
else
{
MIGRAPHX_THROW("VISIT_PY: Unsupported data type!");
}
}
migraphx::value to_value(py::list lst)
{
migraphx::value v = migraphx::value::array{};
for(auto val : lst)
{
visit_py(val, [&](auto py_val) { v.push_back(py_val); });
}
return v;
}
migraphx::value to_value(py::kwargs kwargs)
{
migraphx::value v = migraphx::value::object{};
for(auto arg : kwargs)
{
auto&& key = py::str(arg.first);
auto&& val = arg.second;
visit_py(val, [&](auto py_val) { v[key] = py_val; });
}
return v;
}
} // namespace migraphx
namespace pybind11 {
namespace detail {
template <>
struct npy_format_descriptor<half>
{
static std::string format()
{
// following: https://docs.python.org/3/library/struct.html#format-characters
return "e";
}
static constexpr auto name() { return _("half"); }
};
} // namespace detail
} // namespace pybind11
template <class F>
void visit_type(const migraphx::shape& s, F f)
{
s.visit_type(f);
}
template <class T, class F>
void visit(const migraphx::raw_data<T>& x, F f)
{
x.visit(f);
}
template <class F>
void visit_types(F f)
{
migraphx::shape::visit_types(f);
}
template <class T>
py::buffer_info to_buffer_info(T& x)
{
migraphx::shape s = x.get_shape();
assert(s.type() != migraphx::shape::tuple_type);
if(s.dynamic())
MIGRAPHX_THROW("MIGRAPHX PYTHON: dynamic shape argument passed to to_buffer_info");
auto strides = s.strides();
std::transform(
strides.begin(), strides.end(), strides.begin(), [&](auto i) { return i * s.type_size(); });
py::buffer_info b;
visit_type(s, [&](auto as) {
// migraphx use int8_t data to store bool type, we need to
// explicitly specify the data type as bool for python
if(s.type() == migraphx::shape::bool_type)
{
b = py::buffer_info(x.data(),
as.size(),
py::format_descriptor<bool>::format(),
s.ndim(),
s.lens(),
strides);
}
else
{
b = py::buffer_info(x.data(),
as.size(),
py::format_descriptor<decltype(as())>::format(),
s.ndim(),
s.lens(),
strides);
}
});
return b;
}
migraphx::shape to_shape(const py::buffer_info& info)
{
migraphx::shape::type_t t;
std::size_t n = 0;
visit_types([&](auto as) {
if(info.format == py::format_descriptor<decltype(as())>::format() or
(info.format == "l" and py::format_descriptor<decltype(as())>::format() == "q") or
(info.format == "L" and py::format_descriptor<decltype(as())>::format() == "Q"))
{
t = as.type_enum();
n = sizeof(as());
}
else if(info.format == "?" and py::format_descriptor<decltype(as())>::format() == "b")
{
t = migraphx::shape::bool_type;
n = sizeof(bool);
}
});
if(n == 0)
{
MIGRAPHX_THROW("MIGRAPHX PYTHON: Unsupported data type " + info.format);
}
auto strides = info.strides;
std::transform(strides.begin(), strides.end(), strides.begin(), [&](auto i) -> std::size_t {
return n > 0 ? i / n : 0;
});
// scalar support
if(info.shape.empty())
{
return migraphx::shape{t};
}
else
{
return migraphx::shape{t, info.shape, strides};
}
}
MIGRAPHX_PYBIND11_MODULE(migraphx, m)
{
py::class_<migraphx::shape> shape_cls(m, "shape");
shape_cls
.def(py::init([](py::kwargs kwargs) {
auto v = migraphx::to_value(kwargs);
auto t = migraphx::shape::parse_type(v.get("type", "float"));
if(v.contains("dyn_dims"))
{
auto dyn_dims =
migraphx::from_value<std::vector<migraphx::shape::dynamic_dimension>>(
v.at("dyn_dims"));
return migraphx::shape(t, dyn_dims);
}
auto lens = v.get<std::size_t>("lens", {1});
if(v.contains("strides"))
return migraphx::shape(t, lens, v.at("strides").to_vector<std::size_t>());
else
return migraphx::shape(t, lens);
}))
.def("type", &migraphx::shape::type)
.def("lens", &migraphx::shape::lens)
.def("strides", &migraphx::shape::strides)
.def("ndim", &migraphx::shape::ndim)
.def("elements", &migraphx::shape::elements)
.def("bytes", &migraphx::shape::bytes)
.def("type_string", &migraphx::shape::type_string)
.def("type_size", &migraphx::shape::type_size)
.def("dyn_dims", &migraphx::shape::dyn_dims)
.def("packed", &migraphx::shape::packed)
.def("transposed", &migraphx::shape::transposed)
.def("broadcasted", &migraphx::shape::broadcasted)
.def("standard", &migraphx::shape::standard)
.def("scalar", &migraphx::shape::scalar)
.def("dynamic", &migraphx::shape::dynamic)
.def("__eq__", std::equal_to<migraphx::shape>{})
.def("__ne__", std::not_equal_to<migraphx::shape>{})
.def("__repr__", [](const migraphx::shape& s) { return migraphx::to_string(s); });
py::enum_<migraphx::shape::type_t>(shape_cls, "type_t")
MIGRAPHX_SHAPE_VISIT_TYPES(MIGRAPHX_PYTHON_GENERATE_SHAPE_ENUM);
py::class_<migraphx::shape::dynamic_dimension>(shape_cls, "dynamic_dimension")
.def(py::init<>())
.def(py::init<std::size_t, std::size_t>())
.def(py::init<std::size_t, std::size_t, std::set<std::size_t>>())
.def_readwrite("min", &migraphx::shape::dynamic_dimension::min)
.def_readwrite("max", &migraphx::shape::dynamic_dimension::max)
.def_readwrite("optimals", &migraphx::shape::dynamic_dimension::optimals)
.def("is_fixed", &migraphx::shape::dynamic_dimension::is_fixed);
py::class_<migraphx::argument>(m, "argument", py::buffer_protocol())
.def_buffer([](migraphx::argument& x) -> py::buffer_info { return to_buffer_info(x); })
.def(py::init([](py::buffer b) {
py::buffer_info info = b.request();
return migraphx::argument(to_shape(info), info.ptr);
}))
.def("get_shape", &migraphx::argument::get_shape)
.def("data_ptr",
[](migraphx::argument& x) { return reinterpret_cast<std::uintptr_t>(x.data()); })
.def("tolist",
[](migraphx::argument& x) {
py::list l{x.get_shape().elements()};
visit(x, [&](auto data) { l = py::cast(data.to_vector()); });
return l;
})
.def("__eq__", std::equal_to<migraphx::argument>{})
.def("__ne__", std::not_equal_to<migraphx::argument>{})
.def("__repr__", [](const migraphx::argument& x) { return migraphx::to_string(x); });
py::class_<migraphx::target>(m, "target");
py::class_<migraphx::instruction_ref>(m, "instruction_ref")
.def("shape", [](migraphx::instruction_ref i) { return i->get_shape(); })
.def("op", [](migraphx::instruction_ref i) { return i->get_operator(); });
py::class_<migraphx::module, std::unique_ptr<migraphx::module, py::nodelete>>(m, "module")
.def("print", [](const migraphx::module& mm) { std::cout << mm << std::endl; })
.def(
"add_instruction",
[](migraphx::module& mm,
const migraphx::operation& op,
std::vector<migraphx::instruction_ref>& args,
std::vector<migraphx::module*>& mod_args) {
return mm.add_instruction(op, args, mod_args);
},
py::arg("op"),
py::arg("args"),
py::arg("mod_args") = std::vector<migraphx::module*>{})
.def(
"add_literal",
[](migraphx::module& mm, py::buffer data) {
py::buffer_info info = data.request();
auto literal_shape = to_shape(info);
return mm.add_literal(literal_shape, reinterpret_cast<char*>(info.ptr));
},
py::arg("data"))
.def(
"add_parameter",
[](migraphx::module& mm, const std::string& name, const migraphx::shape shape) {
return mm.add_parameter(name, shape);
},
py::arg("name"),
py::arg("shape"))
.def(
"add_return",
[](migraphx::module& mm, std::vector<migraphx::instruction_ref>& args) {
return mm.add_return(args);
},
py::arg("args"))
.def("__repr__", [](const migraphx::module& mm) { return migraphx::to_string(mm); });
py::class_<migraphx::program>(m, "program")
.def(py::init([]() { return migraphx::program(); }))
.def("get_parameter_names", &migraphx::program::get_parameter_names)
.def("get_parameter_shapes", &migraphx::program::get_parameter_shapes)
.def("get_output_shapes", &migraphx::program::get_output_shapes)
.def("is_compiled", &migraphx::program::is_compiled)
.def(
"compile",
[](migraphx::program& p,
const migraphx::target& t,
bool offload_copy,
bool fast_math,
bool exhaustive_tune) {
migraphx::compile_options options;
options.offload_copy = offload_copy;
options.fast_math = fast_math;
options.exhaustive_tune = exhaustive_tune;
p.compile(t, options);
},
py::arg("t"),
py::arg("offload_copy") = true,
py::arg("fast_math") = true,
py::arg("exhaustive_tune") = false)
.def("get_main_module", [](const migraphx::program& p) { return p.get_main_module(); })
.def(
"create_module",
[](migraphx::program& p, const std::string& name) { return p.create_module(name); },
py::arg("name"))
.def("run",
[](migraphx::program& p, py::dict params) {
migraphx::parameter_map pm;
for(auto x : params)
{
std::string key = x.first.cast<std::string>();
py::buffer b = x.second.cast<py::buffer>();
py::buffer_info info = b.request();
pm[key] = migraphx::argument(to_shape(info), info.ptr);
}
return p.eval(pm);
})
.def("run_async",
[](migraphx::program& p,
py::dict params,
std::uintptr_t stream,
std::string stream_name) {
migraphx::parameter_map pm;
for(auto x : params)
{
std::string key = x.first.cast<std::string>();
py::buffer b = x.second.cast<py::buffer>();
py::buffer_info info = b.request();
pm[key] = migraphx::argument(to_shape(info), info.ptr);
}
migraphx::execution_environment exec_env{
migraphx::any_ptr(reinterpret_cast<void*>(stream), stream_name), true};
return p.eval(pm, exec_env);
})
.def("sort", &migraphx::program::sort)
.def("print", [](const migraphx::program& p) { std::cout << p << std::endl; })
.def("__eq__", std::equal_to<migraphx::program>{})
.def("__ne__", std::not_equal_to<migraphx::program>{})
.def("__repr__", [](const migraphx::program& p) { return migraphx::to_string(p); });
py::class_<migraphx::operation> op(m, "op");
op.def(py::init([](const std::string& name, py::kwargs kwargs) {
migraphx::value v = migraphx::value::object{};
if(kwargs)
{
v = migraphx::to_value(kwargs);
}
return migraphx::make_op(name, v);
}))
.def("name", &migraphx::operation::name);
py::enum_<migraphx::op::pooling_mode>(op, "pooling_mode")
.value("average", migraphx::op::pooling_mode::average)
.value("max", migraphx::op::pooling_mode::max)
.value("lpnorm", migraphx::op::pooling_mode::lpnorm);
py::enum_<migraphx::op::rnn_direction>(op, "rnn_direction")
.value("forward", migraphx::op::rnn_direction::forward)
.value("reverse", migraphx::op::rnn_direction::reverse)
.value("bidirectional", migraphx::op::rnn_direction::bidirectional);
m.def(
"argument_from_pointer",
[](const migraphx::shape shape, const int64_t address) {
return migraphx::argument(shape, reinterpret_cast<void*>(address));
},
py::arg("shape"),
py::arg("address"));
m.def(
"parse_tf",
[](const std::string& filename,
bool is_nhwc,
unsigned int batch_size,
std::unordered_map<std::string, std::vector<std::size_t>> map_input_dims,
std::vector<std::string> output_names) {
return migraphx::parse_tf(
filename, migraphx::tf_options{is_nhwc, batch_size, map_input_dims, output_names});
},
"Parse tf protobuf (default format is nhwc)",
py::arg("filename"),
py::arg("is_nhwc") = true,
py::arg("batch_size") = 1,
py::arg("map_input_dims") = std::unordered_map<std::string, std::vector<std::size_t>>(),
py::arg("output_names") = std::vector<std::string>());
m.def(
"parse_onnx",
[](const std::string& filename,
unsigned int default_dim_value,
migraphx::shape::dynamic_dimension default_dyn_dim_value,
std::unordered_map<std::string, std::vector<std::size_t>> map_input_dims,
std::unordered_map<std::string, std::vector<migraphx::shape::dynamic_dimension>>
map_dyn_input_dims,
bool skip_unknown_operators,
bool print_program_on_error,
int64_t max_loop_iterations) {
migraphx::onnx_options options;
options.default_dim_value = default_dim_value;
options.default_dyn_dim_value = default_dyn_dim_value;
options.map_input_dims = map_input_dims;
options.map_dyn_input_dims = map_dyn_input_dims;
options.skip_unknown_operators = skip_unknown_operators;
options.print_program_on_error = print_program_on_error;
options.max_loop_iterations = max_loop_iterations;
return migraphx::parse_onnx(filename, options);
},
"Parse onnx file",
py::arg("filename"),
py::arg("default_dim_value") = 0,
py::arg("default_dyn_dim_value") = migraphx::shape::dynamic_dimension{1, 1},
py::arg("map_input_dims") = std::unordered_map<std::string, std::vector<std::size_t>>(),
py::arg("map_dyn_input_dims") =
std::unordered_map<std::string, std::vector<migraphx::shape::dynamic_dimension>>(),
py::arg("skip_unknown_operators") = false,
py::arg("print_program_on_error") = false,
py::arg("max_loop_iterations") = 10);
m.def(
"parse_onnx_buffer",
[](const std::string& onnx_buffer,
unsigned int default_dim_value,
migraphx::shape::dynamic_dimension default_dyn_dim_value,
std::unordered_map<std::string, std::vector<std::size_t>> map_input_dims,
std::unordered_map<std::string, std::vector<migraphx::shape::dynamic_dimension>>
map_dyn_input_dims,
bool skip_unknown_operators,
bool print_program_on_error) {
migraphx::onnx_options options;
options.default_dim_value = default_dim_value;
options.default_dyn_dim_value = default_dyn_dim_value;
options.map_input_dims = map_input_dims;
options.map_dyn_input_dims = map_dyn_input_dims;
options.skip_unknown_operators = skip_unknown_operators;
options.print_program_on_error = print_program_on_error;
return migraphx::parse_onnx_buffer(onnx_buffer, options);
},
"Parse onnx file",
py::arg("filename"),
py::arg("default_dim_value") = 0,
py::arg("default_dyn_dim_value") = migraphx::shape::dynamic_dimension{1, 1},
py::arg("map_input_dims") = std::unordered_map<std::string, std::vector<std::size_t>>(),
py::arg("map_dyn_input_dims") =
std::unordered_map<std::string, std::vector<migraphx::shape::dynamic_dimension>>(),
py::arg("skip_unknown_operators") = false,
py::arg("print_program_on_error") = false);
m.def(
"load",
[](const std::string& name, const std::string& format) {
migraphx::file_options options;
options.format = format;
return migraphx::load(name, options);
},
"Load MIGraphX program",
py::arg("filename"),
py::arg("format") = "msgpack");
m.def(
"save",
[](const migraphx::program& p, const std::string& name, const std::string& format) {
migraphx::file_options options;
options.format = format;
return migraphx::save(p, name, options);
},
"Save MIGraphX program",
py::arg("p"),
py::arg("filename"),
py::arg("format") = "msgpack");
m.def("get_target", &migraphx::make_target);
m.def("create_argument", [](const migraphx::shape& s, const std::vector<double>& values) {
if(values.size() != s.elements())
MIGRAPHX_THROW("Values and shape elements do not match");
migraphx::argument a{s};
a.fill(values.begin(), values.end());
return a;
});
m.def("generate_argument", &migraphx::generate_argument, py::arg("s"), py::arg("seed") = 0);
m.def("fill_argument", &migraphx::fill_argument, py::arg("s"), py::arg("value"));
m.def("quantize_fp16",
&migraphx::quantize_fp16,
py::arg("prog"),
py::arg("ins_names") = std::vector<std::string>{"all"});
m.def("quantize_int8",
&migraphx::quantize_int8,
py::arg("prog"),
py::arg("t"),
py::arg("calibration") = std::vector<migraphx::parameter_map>{},
py::arg("ins_names") = std::vector<std::string>{"dot", "convolution"});
#ifdef HAVE_GPU
m.def("allocate_gpu", &migraphx::gpu::allocate_gpu, py::arg("s"), py::arg("host") = false);
m.def("to_gpu", &migraphx::gpu::to_gpu, py::arg("arg"), py::arg("host") = false);
m.def("from_gpu", &migraphx::gpu::from_gpu);
m.def("gpu_sync", [] { migraphx::gpu::gpu_sync(); });
#endif
#ifdef VERSION_INFO
m.attr("__version__") = VERSION_INFO;
#else
m.attr("__version__") = "dev";
#endif
}

View File

@ -1 +1 @@
onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v1.0.0/onnxruntime_rocm-1.17.3-cp39-cp39-linux_x86_64.whl
onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v6.3.3/onnxruntime_rocm-1.20.1-cp311-cp311-linux_x86_64.whl

View File

@ -1,3 +0,0 @@
Package: *
Pin: release o=repo.radeon.com
Pin-Priority: 600

View File

@ -2,7 +2,7 @@ variable "AMDGPU" {
default = "gfx900"
}
variable "ROCM" {
default = "5.7.3"
default = "6.3.3"
}
variable "HSA_OVERRIDE_GFX_VERSION" {
default = ""
@ -10,6 +10,13 @@ variable "HSA_OVERRIDE_GFX_VERSION" {
variable "HSA_OVERRIDE" {
default = "1"
}
target wget {
dockerfile = "docker/main/Dockerfile"
platforms = ["linux/amd64"]
target = "wget"
}
target deps {
dockerfile = "docker/main/Dockerfile"
platforms = ["linux/amd64"]
@ -26,6 +33,7 @@ target rocm {
dockerfile = "docker/rocm/Dockerfile"
contexts = {
deps = "target:deps",
wget = "target:wget",
rootfs = "target:rootfs"
}
platforms = ["linux/amd64"]

View File

@ -1 +0,0 @@
deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/5.7.3 focal main

View File

@ -49,7 +49,7 @@ This does not affect using hardware for accelerating other tasks such as [semant
# Officially Supported Detectors
Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `hailo8l`, `onnx`, `openvino`, `rknn`, `rocm`, and `tensorrt`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `hailo8l`, `onnx`, `openvino`, `rknn`, and `tensorrt`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
## Edge TPU Detector
@ -367,7 +367,7 @@ model:
### Setup
The `rocm` detector supports running YOLO-NAS models on AMD GPUs. Use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`.
Support for AMD GPUs is provided using the [ONNX detector](#ONNX). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`.
### Docker settings for GPU access
@ -446,29 +446,9 @@ $ docker exec -it frigate /bin/bash -c '(unset HSA_OVERRIDE_GFX_VERSION && /opt/
### Supported Models
There is no default model provided, the following formats are supported:
#### YOLO-NAS
[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. See [the models section](#downloading-yolo-nas-model) for more information on downloading the YOLO-NAS model for use in Frigate.
After placing the downloaded onnx model in your config folder, you can use the following configuration:
```yaml
detectors:
rocm:
type: rocm
model:
model_type: yolonas
width: 320 # <--- should match whatever was set in notebook
height: 320 # <--- should match whatever was set in notebook
input_pixel_format: bgr
path: /config/yolo_nas_s.onnx
labelmap_path: /labelmap/coco-80.txt
```
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
See [ONNX supported models](#supported-models) for supported models, there are some caveats:
- D-FINE models are not supported
- YOLO-NAS models are known to not run well on integrated GPUs
## ONNX
@ -562,30 +542,15 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
#### D-FINE
[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default.
[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
To export as ONNX:
:::warning
1. Clone: https://github.com/Peterande/D-FINE and install all dependencies.
2. Select and download a checkpoint from the [readme](https://github.com/Peterande/D-FINE).
3. Modify line 58 of `tools/deployment/export_onnx.py` and change batch size to 1: `data = torch.rand(1, 3, 640, 640)`
4. Run the export, making sure you select the right config, for your checkpoint.
Example:
```
python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_m_obj2coco.yml -r output/dfine_m_obj2coco.pth
```
:::tip
Model export has only been tested on Linux (or WSL2). Not all dependencies are in `requirements.txt`. Some live in the deployment folder, and some are still missing entirely and must be installed manually.
Make sure you change the batch size to 1 before exporting.
D-FINE is currently not supported on OpenVINO
:::
After placing the downloaded onnx model in your config folder, you can use the following configuration:
After placing the downloaded onnx model in your config/model_cache folder, you can use the following configuration:
```yaml
detectors:
@ -784,6 +749,29 @@ Some model types are not included in Frigate by default.
Here are some tips for getting different model types
### Downloading D-FINE Model
To export as ONNX:
1. Clone: https://github.com/Peterande/D-FINE and install all dependencies.
2. Select and download a checkpoint from the [readme](https://github.com/Peterande/D-FINE).
3. Modify line 58 of `tools/deployment/export_onnx.py` and change batch size to 1: `data = torch.rand(1, 3, 640, 640)`
4. Run the export, making sure you select the right config, for your checkpoint.
Example:
```
python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_m_obj2coco.yml -r output/dfine_m_obj2coco.pth
```
:::tip
Model export has only been tested on Linux (or WSL2). Not all dependencies are in `requirements.txt`. Some live in the deployment folder, and some are still missing entirely and must be installed manually.
Make sure you change the batch size to 1 before exporting.
:::
### Downloading YOLO-NAS Model
You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb).

View File

@ -222,6 +222,14 @@ Publishes the rms value for audio detected on this camera.
**NOTE:** Requires audio detection to be enabled
### `frigate/<camera_name>/enabled/set`
Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/enabled/state`
Topic with current state of processing for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/detect/set`
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.

View File

@ -28,11 +28,11 @@ Not all model types are supported by all detectors, so it's important to choose
## Supported detector types
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), and ROCm (`rocm`) detectors.
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), and ONNX (`onnx`) detectors.
:::warning
Using Frigate+ models with `onnx` and `rocm` is only available with Frigate 0.15 and later.
Using Frigate+ models with `onnx` is only available with Frigate 0.15 and later.
:::
@ -42,7 +42,7 @@ Using Frigate+ models with `onnx` and `rocm` is only available with Frigate 0.15
| [Coral (all form factors)](/configuration/object_detectors.md#edge-tpu-detector) | `edgetpu` | `mobiledet` |
| [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolonas` |
| [NVidia GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#onnx)\* | `onnx` | `yolonas` |
| [AMD ROCm GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#amdrocm-gpu-detector)\* | `rocm` | `yolonas` |
| [AMD ROCm GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#amdrocm-gpu-detector)\* | `onnx` | `yolonas` |
_\* Requires Frigate 0.15_

View File

@ -20,7 +20,7 @@ class CameraActivityManager:
self.all_zone_labels: dict[str, set[str]] = {}
for camera_config in config.cameras.values():
if not camera_config.enabled:
if not camera_config.enabled_in_config:
continue
self.last_camera_activity[camera_config.name] = {}

View File

@ -55,6 +55,7 @@ class Dispatcher:
self._camera_settings_handlers: dict[str, Callable] = {
"audio": self._on_audio_command,
"detect": self._on_detect_command,
"enabled": self._on_enabled_command,
"improve_contrast": self._on_motion_improve_contrast_command,
"ptz_autotracker": self._on_ptz_autotracker_command,
"motion": self._on_motion_command,
@ -167,6 +168,7 @@ class Dispatcher:
for camera in camera_status.keys():
camera_status[camera]["config"] = {
"detect": self.config.cameras[camera].detect.enabled,
"enabled": self.config.cameras[camera].enabled,
"snapshots": self.config.cameras[camera].snapshots.enabled,
"record": self.config.cameras[camera].record.enabled,
"audio": self.config.cameras[camera].audio.enabled,
@ -278,6 +280,27 @@ class Dispatcher:
self.config_updater.publish(f"config/detect/{camera_name}", detect_settings)
self.publish(f"{camera_name}/detect/state", payload, retain=True)
def _on_enabled_command(self, camera_name: str, payload: str) -> None:
"""Callback for camera topic."""
camera_settings = self.config.cameras[camera_name]
if payload == "ON":
if not self.config.cameras[camera_name].enabled_in_config:
logger.error(
"Camera must be enabled in the config to be turned on via MQTT."
)
return
if not camera_settings.enabled:
logger.info(f"Turning on camera {camera_name}")
camera_settings.enabled = True
elif payload == "OFF":
if camera_settings.enabled:
logger.info(f"Turning off camera {camera_name}")
camera_settings.enabled = False
self.config_updater.publish(f"config/enabled/{camera_name}", camera_settings)
self.publish(f"{camera_name}/enabled/state", payload, retain=True)
def _on_motion_command(self, camera_name: str, payload: str) -> None:
"""Callback for motion topic."""
detect_settings = self.config.cameras[camera_name].detect

View File

@ -43,6 +43,11 @@ class MqttClient(Communicator): # type: ignore[misc]
def _set_initial_topics(self) -> None:
"""Set initial state topics."""
for camera_name, camera in self.config.cameras.items():
self.publish(
f"{camera_name}/enabled/state",
"ON" if camera.enabled_in_config else "OFF",
retain=True,
)
self.publish(
f"{camera_name}/recordings/state",
"ON" if camera.record.enabled_in_config else "OFF",
@ -196,6 +201,7 @@ class MqttClient(Communicator): # type: ignore[misc]
# register callbacks
callback_types = [
"enabled",
"recordings",
"snapshots",
"detect",

View File

@ -102,6 +102,9 @@ class CameraConfig(FrigateBaseModel):
zones: dict[str, ZoneConfig] = Field(
default_factory=dict, title="Zone configuration."
)
enabled_in_config: Optional[bool] = Field(
default=None, title="Keep track of original state of camera."
)
_ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr()

View File

@ -516,6 +516,7 @@ class FrigateConfig(FrigateBaseModel):
camera_config.detect.stationary.interval = stationary_threshold
# set config pre-value
camera_config.enabled_in_config = camera_config.enabled
camera_config.audio.enabled_in_config = camera_config.audio.enabled
camera_config.record.enabled_in_config = camera_config.record.enabled
camera_config.notifications.enabled_in_config = (

View File

@ -99,5 +99,5 @@ class ONNXDetector(DetectionApi):
return post_process_yolov9(predictions, self.w, self.h)
else:
raise Exception(
f"{self.onnx_model_type} is currently not supported for rocm. See the docs for more info on supported models."
f"{self.onnx_model_type} is currently not supported for onnx. See the docs for more info on supported models."
)

View File

@ -1,170 +0,0 @@
import ctypes
import logging
import os
import subprocess
import sys
import cv2
import numpy as np
from pydantic import Field
from typing_extensions import Literal
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import (
BaseDetectorConfig,
ModelTypeEnum,
PixelFormatEnum,
)
logger = logging.getLogger(__name__)
DETECTOR_KEY = "rocm"
def detect_gfx_version():
return subprocess.getoutput(
"unset HSA_OVERRIDE_GFX_VERSION && /opt/rocm/bin/rocminfo | grep gfx |head -1|awk '{print $2}'"
)
def auto_override_gfx_version():
# If environment variable already in place, do not override
gfx_version = detect_gfx_version()
old_override = os.getenv("HSA_OVERRIDE_GFX_VERSION")
if old_override not in (None, ""):
logger.warning(
f"AMD/ROCm: detected {gfx_version} but HSA_OVERRIDE_GFX_VERSION already present ({old_override}), not overriding!"
)
return old_override
mapping = {
"gfx90c": "9.0.0",
"gfx1031": "10.3.0",
"gfx1103": "11.0.0",
}
override = mapping.get(gfx_version)
if override is not None:
logger.warning(
f"AMD/ROCm: detected {gfx_version}, overriding HSA_OVERRIDE_GFX_VERSION={override}"
)
os.putenv("HSA_OVERRIDE_GFX_VERSION", override)
return override
return ""
class ROCmDetectorConfig(BaseDetectorConfig):
type: Literal[DETECTOR_KEY]
conserve_cpu: bool = Field(
default=True,
title="Conserve CPU at the expense of latency (and reduced max throughput)",
)
auto_override_gfx: bool = Field(
default=True, title="Automatically detect and override gfx version"
)
class ROCmDetector(DetectionApi):
type_key = DETECTOR_KEY
def __init__(self, detector_config: ROCmDetectorConfig):
if detector_config.auto_override_gfx:
auto_override_gfx_version()
try:
sys.path.append("/opt/rocm/lib")
import migraphx
logger.info("AMD/ROCm: loaded migraphx module")
except ModuleNotFoundError:
logger.error("AMD/ROCm: module loading failed, missing ROCm environment?")
raise
if detector_config.conserve_cpu:
logger.info("AMD/ROCm: switching HIP to blocking mode to conserve CPU")
ctypes.CDLL("/opt/rocm/lib/libamdhip64.so").hipSetDeviceFlags(4)
self.h = detector_config.model.height
self.w = detector_config.model.width
self.rocm_model_type = detector_config.model.model_type
self.rocm_model_px = detector_config.model.input_pixel_format
path = detector_config.model.path
mxr_path = os.path.splitext(path)[0] + ".mxr"
if path.endswith(".mxr"):
logger.info(f"AMD/ROCm: loading parsed model from {mxr_path}")
self.model = migraphx.load(mxr_path)
elif os.path.exists(mxr_path):
logger.info(f"AMD/ROCm: loading parsed model from {mxr_path}")
self.model = migraphx.load(mxr_path)
else:
logger.info(f"AMD/ROCm: loading model from {path}")
if (
path.endswith(".tf")
or path.endswith(".tf2")
or path.endswith(".tflite")
):
# untested
self.model = migraphx.parse_tf(path)
else:
self.model = migraphx.parse_onnx(path)
logger.info("AMD/ROCm: compiling the model")
self.model.compile(
migraphx.get_target("gpu"), offload_copy=True, fast_math=True
)
logger.info(f"AMD/ROCm: saving parsed model into {mxr_path}")
os.makedirs(os.path.join(MODEL_CACHE_DIR, "rocm"), exist_ok=True)
migraphx.save(self.model, mxr_path)
logger.info("AMD/ROCm: model loaded")
def detect_raw(self, tensor_input):
model_input_name = self.model.get_parameter_names()[0]
model_input_shape = tuple(
self.model.get_parameter_shapes()[model_input_name].lens()
)
tensor_input = cv2.dnn.blobFromImage(
tensor_input[0],
1.0,
(model_input_shape[3], model_input_shape[2]),
None,
swapRB=self.rocm_model_px == PixelFormatEnum.bgr,
).astype(np.uint8)
detector_result = self.model.run({model_input_name: tensor_input})[0]
addr = ctypes.cast(detector_result.data_ptr(), ctypes.POINTER(ctypes.c_float))
tensor_output = np.ctypeslib.as_array(
addr, shape=detector_result.get_shape().lens()
)
if self.rocm_model_type == ModelTypeEnum.yolonas:
predictions = tensor_output
detections = np.zeros((20, 6), np.float32)
for i, prediction in enumerate(predictions):
if i == 20:
break
(_, x_min, y_min, x_max, y_max, confidence, class_id) = prediction
# when running in GPU mode, empty predictions in the output have class_id of -1
if class_id < 0:
break
detections[i] = [
class_id,
confidence,
y_min / self.h,
x_min / self.w,
y_max / self.h,
x_max / self.w,
]
return detections
else:
raise Exception(
f"{self.rocm_model_type} is currently not supported for rocm. See the docs for more info on supported models."
)

View File

@ -293,6 +293,7 @@ class EmbeddingMaintainer(threading.Thread):
# Embed the thumbnail
self._embed_thumbnail(event_id, thumbnail)
# Run GenAI
if (
camera_config.genai.enabled
and self.genai_client is not None
@ -306,82 +307,7 @@ class EmbeddingMaintainer(threading.Thread):
or set(event.zones) & set(camera_config.genai.required_zones)
)
):
if event.has_snapshot and camera_config.genai.use_snapshot:
with open(
os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"),
"rb",
) as image_file:
snapshot_image = image_file.read()
img = cv2.imdecode(
np.frombuffer(snapshot_image, dtype=np.int8),
cv2.IMREAD_COLOR,
)
# crop snapshot based on region before sending off to genai
height, width = img.shape[:2]
x1_rel, y1_rel, width_rel, height_rel = event.data["region"]
x1, y1 = int(x1_rel * width), int(y1_rel * height)
cropped_image = img[
y1 : y1 + int(height_rel * height),
x1 : x1 + int(width_rel * width),
]
_, buffer = cv2.imencode(".jpg", cropped_image)
snapshot_image = buffer.tobytes()
num_thumbnails = len(self.tracked_events.get(event_id, []))
embed_image = (
[snapshot_image]
if event.has_snapshot and camera_config.genai.use_snapshot
else (
[
data["thumbnail"]
for data in self.tracked_events[event_id]
]
if num_thumbnails > 0
else [thumbnail]
)
)
if camera_config.genai.debug_save_thumbnails and num_thumbnails > 0:
logger.debug(
f"Saving {num_thumbnails} thumbnails for event {event.id}"
)
Path(
os.path.join(CLIPS_DIR, f"genai-requests/{event.id}")
).mkdir(parents=True, exist_ok=True)
for idx, data in enumerate(self.tracked_events[event_id], 1):
jpg_bytes: bytes = data["thumbnail"]
if jpg_bytes is None:
logger.warning(
f"Unable to save thumbnail {idx} for {event.id}."
)
else:
with open(
os.path.join(
CLIPS_DIR,
f"genai-requests/{event.id}/{idx}.jpg",
),
"wb",
) as j:
j.write(jpg_bytes)
# Generate the description. Call happens in a thread since it is network bound.
threading.Thread(
target=self._embed_description,
name=f"_embed_description_{event.id}",
daemon=True,
args=(
event,
embed_image,
),
).start()
self._process_genai_description(event, camera_config, thumbnail)
# Delete tracked events based on the event_id
if event_id in self.tracked_events:
@ -440,7 +366,58 @@ class EmbeddingMaintainer(threading.Thread):
self.embeddings.embed_thumbnail(event_id, thumbnail)
def _embed_description(self, event: Event, thumbnails: list[bytes]) -> None:
def _process_genai_description(self, event, camera_config, thumbnail) -> None:
if event.has_snapshot and camera_config.genai.use_snapshot:
snapshot_image = self._read_and_crop_snapshot(event, camera_config)
if not snapshot_image:
return
num_thumbnails = len(self.tracked_events.get(event.id, []))
embed_image = (
[snapshot_image]
if event.has_snapshot and camera_config.genai.use_snapshot
else (
[data["thumbnail"] for data in self.tracked_events[event.id]]
if num_thumbnails > 0
else [thumbnail]
)
)
if camera_config.genai.debug_save_thumbnails and num_thumbnails > 0:
logger.debug(f"Saving {num_thumbnails} thumbnails for event {event.id}")
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event.id}")).mkdir(
parents=True, exist_ok=True
)
for idx, data in enumerate(self.tracked_events[event.id], 1):
jpg_bytes: bytes = data["thumbnail"]
if jpg_bytes is None:
logger.warning(f"Unable to save thumbnail {idx} for {event.id}.")
else:
with open(
os.path.join(
CLIPS_DIR,
f"genai-requests/{event.id}/{idx}.jpg",
),
"wb",
) as j:
j.write(jpg_bytes)
# Generate the description. Call happens in a thread since it is network bound.
threading.Thread(
target=self._genai_embed_description,
name=f"_genai_embed_description_{event.id}",
daemon=True,
args=(
event,
embed_image,
),
).start()
def _genai_embed_description(self, event: Event, thumbnails: list[bytes]) -> None:
"""Embed the description for an event."""
camera_config = self.config.cameras[event.camera]
@ -473,6 +450,45 @@ class EmbeddingMaintainer(threading.Thread):
description,
)
def _read_and_crop_snapshot(self, event: Event, camera_config) -> bytes | None:
"""Read, decode, and crop the snapshot image."""
snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg")
if not os.path.isfile(snapshot_file):
logger.error(
f"Cannot load snapshot for {event.id}, file not found: {snapshot_file}"
)
return None
try:
with open(snapshot_file, "rb") as image_file:
snapshot_image = image_file.read()
img = cv2.imdecode(
np.frombuffer(snapshot_image, dtype=np.int8),
cv2.IMREAD_COLOR,
)
# Crop snapshot based on region
# provide full image if region doesn't exist (manual events)
height, width = img.shape[:2]
x1_rel, y1_rel, width_rel, height_rel = event.data.get(
"region", [0, 0, 1, 1]
)
x1, y1 = int(x1_rel * width), int(y1_rel * height)
cropped_image = img[
y1 : y1 + int(height_rel * height),
x1 : x1 + int(width_rel * width),
]
_, buffer = cv2.imencode(".jpg", cropped_image)
return buffer.tobytes()
except Exception:
return None
def handle_regenerate_description(self, event_id: str, source: str) -> None:
try:
event: Event = Event.get(Event.id == event_id)
@ -492,34 +508,10 @@ class EmbeddingMaintainer(threading.Thread):
)
if event.has_snapshot and source == "snapshot":
snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg")
if not os.path.isfile(snapshot_file):
logger.error(
f"Cannot regenerate description for {event.id}, snapshot file not found: {snapshot_file}"
)
snapshot_image = self._read_and_crop_snapshot(event, camera_config)
if not snapshot_image:
return
with open(snapshot_file, "rb") as image_file:
snapshot_image = image_file.read()
img = cv2.imdecode(
np.frombuffer(snapshot_image, dtype=np.int8), cv2.IMREAD_COLOR
)
# crop snapshot based on region before sending off to genai
# provide full image if region doesn't exist (manual events)
region = event.data.get("region", [0, 0, 1, 1])
height, width = img.shape[:2]
x1_rel, y1_rel, width_rel, height_rel = region
x1, y1 = int(x1_rel * width), int(y1_rel * height)
cropped_image = img[
y1 : y1 + int(height_rel * height), x1 : x1 + int(width_rel * width)
]
_, buffer = cv2.imencode(".jpg", cropped_image)
snapshot_image = buffer.tobytes()
embed_image = (
[snapshot_image]
if event.has_snapshot and source == "snapshot"
@ -530,4 +522,4 @@ class EmbeddingMaintainer(threading.Thread):
)
)
self._embed_description(event, embed_image)
self._genai_embed_description(event, embed_image)

View File

@ -26,23 +26,30 @@ class OpenAIClient(GenAIClient):
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
"""Submit a request to OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
messages_content = []
for image in encoded_images:
messages_content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
)
messages_content.append(
{
"type": "text",
"text": prompt,
}
)
try:
result = self.provider.chat.completions.create(
model=self.genai_config.model,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
for image in encoded_images
]
+ [prompt],
"content": messages_content,
},
],
timeout=self.timeout,

View File

@ -17,7 +17,6 @@ from frigate.detectors.detector_config import (
InputDTypeEnum,
InputTensorEnum,
)
from frigate.detectors.plugins.rocm import DETECTOR_KEY as ROCM_DETECTOR_KEY
from frigate.util.builtin import EventsPerSecond, load_labels
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
from frigate.util.services import listen
@ -52,13 +51,7 @@ class LocalObjectDetector(ObjectDetector):
self.labels = load_labels(labels)
if detector_config:
if detector_config.type == ROCM_DETECTOR_KEY:
# ROCm requires NHWC as input
self.input_transform = None
else:
self.input_transform = tensor_transform(
detector_config.model.input_tensor
)
self.input_transform = tensor_transform(detector_config.model.input_tensor)
self.dtype = detector_config.model.input_dtype
else:

View File

@ -10,6 +10,7 @@ from typing import Callable, Optional
import cv2
import numpy as np
from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
@ -61,6 +62,7 @@ class CameraState:
self.previous_frame_id = None
self.callbacks = defaultdict(list)
self.ptz_autotracker_thread = ptz_autotracker_thread
self.prev_enabled = self.camera_config.enabled
def get_current_frame(self, draw_options={}):
with self.current_frame_lock:
@ -310,6 +312,7 @@ class CameraState:
# TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects
camera_activity: dict[str, list[any]] = {
"enabled": True,
"motion": len(motion_boxes) > 0,
"objects": [],
}
@ -437,6 +440,11 @@ class TrackedObjectProcessor(threading.Thread):
self.last_motion_detected: dict[str, float] = {}
self.ptz_autotracker_thread = ptz_autotracker_thread
self.enabled_subscribers = {
camera: ConfigSubscriber(f"config/enabled/{camera}", True)
for camera in config.cameras.keys()
}
self.requestor = InterProcessRequestor()
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
self.event_sender = EventUpdatePublisher()
@ -679,8 +687,53 @@ class TrackedObjectProcessor(threading.Thread):
"""Returns the latest frame time for a given camera."""
return self.camera_states[camera].current_frame_time
def force_end_all_events(self, camera: str, camera_state: CameraState):
"""Ends all active events on camera when disabling."""
last_frame_name = camera_state.previous_frame_id
for obj_id, obj in list(camera_state.tracked_objects.items()):
if "end_time" not in obj.obj_data:
logger.debug(f"Camera {camera} disabled, ending active event {obj_id}")
obj.obj_data["end_time"] = datetime.datetime.now().timestamp()
# end callbacks
for callback in camera_state.callbacks["end"]:
callback(camera, obj, last_frame_name)
# camera activity callbacks
for callback in camera_state.callbacks["camera_activity"]:
callback(
camera,
{"enabled": False, "motion": 0, "objects": []},
)
def _get_enabled_state(self, camera: str) -> bool:
_, config_data = self.enabled_subscribers[camera].check_for_update()
if config_data:
self.config.cameras[camera].enabled = config_data.enabled
if self.camera_states[camera].prev_enabled is None:
self.camera_states[camera].prev_enabled = config_data.enabled
return self.config.cameras[camera].enabled
def run(self):
while not self.stop_event.is_set():
for camera, config in self.config.cameras.items():
if not config.enabled_in_config:
continue
current_enabled = self._get_enabled_state(camera)
camera_state = self.camera_states[camera]
if camera_state.prev_enabled and not current_enabled:
logger.debug(f"Not processing objects for disabled camera {camera}")
self.force_end_all_events(camera, camera_state)
camera_state.prev_enabled = current_enabled
if not current_enabled:
continue
try:
(
camera,
@ -693,6 +746,10 @@ class TrackedObjectProcessor(threading.Thread):
except queue.Empty:
continue
if not self._get_enabled_state(camera):
logger.debug(f"Camera {camera} disabled, skipping update")
continue
camera_state = self.camera_states[camera]
camera_state.update(
@ -735,4 +792,7 @@ class TrackedObjectProcessor(threading.Thread):
self.detection_publisher.stop()
self.event_sender.stop()
self.event_end_subscriber.stop()
for subscriber in self.enabled_subscribers.values():
subscriber.stop()
logger.info("Exiting object processor...")

View File

@ -10,6 +10,7 @@ import queue
import subprocess as sp
import threading
import traceback
from typing import Optional
import cv2
import numpy as np
@ -280,6 +281,12 @@ class BirdsEyeFrameManager:
self.stop_event = stop_event
self.inactivity_threshold = config.birdseye.inactivity_threshold
self.enabled_subscribers = {
cam: ConfigSubscriber(f"config/enabled/{cam}", True)
for cam in config.cameras.keys()
if config.cameras[cam].enabled_in_config
}
if config.birdseye.layout.max_cameras:
self.last_refresh_time = 0
@ -380,8 +387,21 @@ class BirdsEyeFrameManager:
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
return True
def update_frame(self, frame: np.ndarray):
"""Update to a new frame for birdseye."""
def _get_enabled_state(self, camera: str) -> bool:
"""Fetch the latest enabled state for a camera from ZMQ."""
_, config_data = self.enabled_subscribers[camera].check_for_update()
if config_data:
self.config.cameras[camera].enabled = config_data.enabled
return config_data.enabled
return self.config.cameras[camera].enabled
def update_frame(self, frame: Optional[np.ndarray] = None) -> bool:
"""
Update birdseye, optionally with a new frame.
When no frame is passed, check the layout and update for any disabled cameras.
"""
# determine how many cameras are tracking objects within the last inactivity_threshold seconds
active_cameras: set[str] = set(
@ -389,11 +409,14 @@ class BirdsEyeFrameManager:
cam
for cam, cam_data in self.cameras.items()
if self.config.cameras[cam].birdseye.enabled
and self.config.cameras[cam].enabled_in_config
and self._get_enabled_state(cam)
and cam_data["last_active_frame"] > 0
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
< self.inactivity_threshold
]
)
logger.debug(f"Active cameras: {active_cameras}")
max_cameras = self.config.birdseye.layout.max_cameras
max_camera_refresh = False
@ -411,118 +434,125 @@ class BirdsEyeFrameManager:
- self.cameras[active_camera]["last_active_frame"]
),
)
active_cameras = limited_active_cameras[
: self.config.birdseye.layout.max_cameras
]
active_cameras = limited_active_cameras[:max_cameras]
max_camera_refresh = True
self.last_refresh_time = now
# if there are no active cameras
# Track if the frame changes
frame_changed = False
# If no active cameras and layout is already empty, no update needed
if len(active_cameras) == 0:
# if the layout is already cleared
if len(self.camera_layout) == 0:
return False
# if the layout needs to be cleared
else:
self.camera_layout = []
self.active_cameras = set()
self.clear_frame()
return True
# check if we need to reset the layout because there is a different number of cameras
if len(self.active_cameras) - len(active_cameras) == 0:
if len(self.active_cameras) == 1 and self.active_cameras != active_cameras:
reset_layout = True
elif max_camera_refresh:
reset_layout = True
else:
reset_layout = False
else:
reset_layout = True
# reset the layout if it needs to be different
if reset_layout:
logger.debug("Added new cameras, resetting layout...")
self.camera_layout = []
self.active_cameras = set()
self.clear_frame()
self.active_cameras = active_cameras
# this also converts added_cameras from a set to a list since we need
# to pop elements in order
active_cameras_to_add = sorted(
active_cameras,
# sort cameras by order and by name if the order is the same
key=lambda active_camera: (
self.config.cameras[active_camera].birdseye.order,
active_camera,
),
)
if len(active_cameras) == 1:
# show single camera as fullscreen
camera = active_cameras_to_add[0]
camera_dims = self.cameras[camera]["dimensions"].copy()
scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1])
# center camera view in canvas and ensure that it fits
if scaled_width < self.canvas.width:
coefficient = 1
x_offset = int((self.canvas.width - scaled_width) / 2)
frame_changed = True
else:
# Determine if layout needs resetting
if len(self.active_cameras) - len(active_cameras) == 0:
if (
len(self.active_cameras) == 1
and self.active_cameras != active_cameras
):
reset_layout = True
elif max_camera_refresh:
reset_layout = True
else:
coefficient = self.canvas.width / scaled_width
x_offset = int(
(self.canvas.width - (scaled_width * coefficient)) / 2
)
self.camera_layout = [
[
(
camera,
(
x_offset,
0,
int(scaled_width * coefficient),
int(self.canvas.height * coefficient),
),
)
]
]
reset_layout = False
else:
# calculate optimal layout
coefficient = self.canvas.get_coefficient(len(active_cameras))
calculating = True
reset_layout = True
# decrease scaling coefficient until height of all cameras can fit into the birdseye canvas
while calculating:
if self.stop_event.is_set():
return
if reset_layout:
logger.debug("Resetting Birdseye layout...")
self.clear_frame()
self.active_cameras = active_cameras
layout_candidate = self.calculate_layout(
active_cameras_to_add,
coefficient,
# this also converts added_cameras from a set to a list since we need
# to pop elements in order
active_cameras_to_add = sorted(
active_cameras,
# sort cameras by order and by name if the order is the same
key=lambda active_camera: (
self.config.cameras[active_camera].birdseye.order,
active_camera,
),
)
if len(active_cameras) == 1:
# show single camera as fullscreen
camera = active_cameras_to_add[0]
camera_dims = self.cameras[camera]["dimensions"].copy()
scaled_width = int(
self.canvas.height * camera_dims[0] / camera_dims[1]
)
if not layout_candidate:
if coefficient < 10:
coefficient += 1
continue
else:
logger.error("Error finding appropriate birdseye layout")
# center camera view in canvas and ensure that it fits
if scaled_width < self.canvas.width:
coefficient = 1
x_offset = int((self.canvas.width - scaled_width) / 2)
else:
coefficient = self.canvas.width / scaled_width
x_offset = int(
(self.canvas.width - (scaled_width * coefficient)) / 2
)
self.camera_layout = [
[
(
camera,
(
x_offset,
0,
int(scaled_width * coefficient),
int(self.canvas.height * coefficient),
),
)
]
]
else:
# calculate optimal layout
coefficient = self.canvas.get_coefficient(len(active_cameras))
calculating = True
# decrease scaling coefficient until height of all cameras can fit into the birdseye canvas
while calculating:
if self.stop_event.is_set():
return
calculating = False
self.canvas.set_coefficient(len(active_cameras), coefficient)
layout_candidate = self.calculate_layout(
active_cameras_to_add, coefficient
)
self.camera_layout = layout_candidate
if not layout_candidate:
if coefficient < 10:
coefficient += 1
continue
else:
logger.error(
"Error finding appropriate birdseye layout"
)
return
calculating = False
self.canvas.set_coefficient(len(active_cameras), coefficient)
for row in self.camera_layout:
for position in row:
self.copy_to_position(
position[1],
position[0],
self.cameras[position[0]]["current_frame"],
)
self.camera_layout = layout_candidate
frame_changed = True
return True
# Draw the layout
for row in self.camera_layout:
for position in row:
src_frame = self.cameras[position[0]]["current_frame"]
if src_frame is None or src_frame.size == 0:
logger.debug(f"Skipping invalid frame for {position[0]}")
continue
self.copy_to_position(position[1], position[0], src_frame)
if frame is not None: # Frame presence indicates a potential change
frame_changed = True
return frame_changed
def calculate_layout(
self,
@ -677,18 +707,17 @@ class BirdsEyeFrameManager:
) -> bool:
# don't process if birdseye is disabled for this camera
camera_config = self.config.cameras[camera].birdseye
if not camera_config.enabled:
return False
force_update = False
# disabling birdseye is a little tricky
if not camera_config.enabled:
if not self._get_enabled_state(camera):
# if we've rendered a frame (we have a value for last_active_frame)
# then we need to set it to zero
if self.cameras[camera]["last_active_frame"] > 0:
self.cameras[camera]["last_active_frame"] = 0
return False
force_update = True
else:
return False
# update the last active frame for the camera
self.cameras[camera]["current_frame"] = frame.copy()
@ -699,7 +728,7 @@ class BirdsEyeFrameManager:
now = datetime.datetime.now().timestamp()
# limit output to 10 fps
if (now - self.last_output_time) < 1 / 10:
if not force_update and (now - self.last_output_time) < 1 / 10:
return False
try:
@ -711,11 +740,16 @@ class BirdsEyeFrameManager:
print(traceback.format_exc())
# if the frame was updated or the fps is too low, send frame
if updated_frame or (now - self.last_output_time) > 1:
if force_update or updated_frame or (now - self.last_output_time) > 1:
self.last_output_time = now
return True
return False
def stop(self):
"""Clean up subscribers when stopping."""
for subscriber in self.enabled_subscribers.values():
subscriber.stop()
class Birdseye:
def __init__(
@ -743,6 +777,7 @@ class Birdseye:
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
self.config_subscriber = ConfigSubscriber("config/birdseye/")
self.frame_manager = SharedMemoryFrameManager()
self.stop_event = stop_event
if config.birdseye.restream:
self.birdseye_buffer = self.frame_manager.create(
@ -753,6 +788,22 @@ class Birdseye:
self.converter.start()
self.broadcaster.start()
def __send_new_frame(self) -> None:
frame_bytes = self.birdseye_manager.frame.tobytes()
if self.config.birdseye.restream:
self.birdseye_buffer[:] = frame_bytes
try:
self.input.put_nowait(frame_bytes)
except queue.Full:
# drop frames if queue is full
pass
def all_cameras_disabled(self) -> None:
self.birdseye_manager.clear_frame()
self.__send_new_frame()
def write_data(
self,
camera: str,
@ -781,18 +832,10 @@ class Birdseye:
frame_time,
frame,
):
frame_bytes = self.birdseye_manager.frame.tobytes()
if self.config.birdseye.restream:
self.birdseye_buffer[:] = frame_bytes
try:
self.input.put_nowait(frame_bytes)
except queue.Full:
# drop frames if queue is full
pass
self.__send_new_frame()
def stop(self) -> None:
self.config_subscriber.stop()
self.birdseye_manager.stop()
self.converter.join()
self.broadcaster.join()

View File

@ -1,12 +1,12 @@
"""Handle outputting raw frigate frames"""
import datetime
import logging
import multiprocessing as mp
import os
import shutil
import signal
import threading
from typing import Optional
from wsgiref.simple_server import make_server
from setproctitle import setproctitle
@ -17,6 +17,7 @@ from ws4py.server.wsgirefserver import (
)
from ws4py.server.wsgiutils import WebSocketWSGIApplication
from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.ws import WebSocket
from frigate.config import FrigateConfig
@ -24,11 +25,43 @@ from frigate.const import CACHE_DIR, CLIPS_DIR
from frigate.output.birdseye import Birdseye
from frigate.output.camera import JsmpegCamera
from frigate.output.preview import PreviewRecorder
from frigate.util.image import SharedMemoryFrameManager
from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame
logger = logging.getLogger(__name__)
def check_disabled_camera_update(
config: FrigateConfig,
birdseye: Birdseye | None,
previews: dict[str, PreviewRecorder],
write_times: dict[str, float],
) -> None:
"""Check if camera is disabled / offline and needs an update."""
now = datetime.datetime.now().timestamp()
has_enabled_camera = False
for camera, last_update in write_times.items():
if config.cameras[camera].enabled:
has_enabled_camera = True
if now - last_update > 1:
# last camera update was more than one second ago
# need to send empty data to updaters because current
# frame is now out of date
frame = get_blank_yuv_frame(
config.cameras[camera].detect.width,
config.cameras[camera].detect.height,
)
if birdseye:
birdseye.write_data(camera, [], [], now, frame)
previews[camera].write_data([], [], now, frame)
if not has_enabled_camera and birdseye:
birdseye.all_cameras_disabled()
def output_frames(
config: FrigateConfig,
):
@ -59,11 +92,18 @@ def output_frames(
detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video)
enabled_subscribers = {
camera: ConfigSubscriber(f"config/enabled/{camera}", True)
for camera in config.cameras.keys()
if config.cameras[camera].enabled_in_config
}
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
birdseye: Optional[Birdseye] = None
birdseye: Birdseye | None = None
preview_recorders: dict[str, PreviewRecorder] = {}
preview_write_times: dict[str, float] = {}
failed_frame_requests: dict[str, int] = {}
last_disabled_cam_check = datetime.datetime.now().timestamp()
move_preview_frames("cache")
@ -80,8 +120,25 @@ def output_frames(
websocket_thread.start()
def get_enabled_state(camera: str) -> bool:
_, config_data = enabled_subscribers[camera].check_for_update()
if config_data:
config.cameras[camera].enabled = config_data.enabled
return config_data.enabled
return config.cameras[camera].enabled
while not stop_event.is_set():
(topic, data) = detection_subscriber.check_for_update(timeout=1)
now = datetime.datetime.now().timestamp()
if now - last_disabled_cam_check > 5:
# check disabled cameras every 5 seconds
last_disabled_cam_check = now
check_disabled_camera_update(
config, birdseye, preview_recorders, preview_write_times
)
if not topic:
continue
@ -95,6 +152,9 @@ def output_frames(
_,
) = data
if not get_enabled_state(camera):
continue
frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv)
if frame is None:
@ -134,23 +194,10 @@ def output_frames(
)
# send frames for low fps recording
generated_preview = preview_recorders[camera].write_data(
preview_recorders[camera].write_data(
current_tracked_objects, motion_boxes, frame_time, frame
)
preview_write_times[camera] = frame_time
# if another camera generated a preview,
# check for any cameras that are currently offline
# and need to generate a preview
if generated_preview:
logger.debug(
"Checking for offline cameras because another camera generated a preview."
)
for camera, time in preview_write_times.copy().items():
if time != 0 and frame_time - time > 10:
preview_recorders[camera].flag_offline(frame_time)
preview_write_times[camera] = frame_time
frame_manager.close(frame_name)
move_preview_frames("clips")
@ -184,6 +231,9 @@ def output_frames(
if birdseye is not None:
birdseye.stop()
for subscriber in enabled_subscribers.values():
subscriber.stop()
websocket_server.manager.close_all()
websocket_server.manager.stop()
websocket_server.manager.join()

View File

@ -632,6 +632,22 @@ def copy_yuv_to_position(
)
def get_blank_yuv_frame(width: int, height: int) -> np.ndarray:
"""Creates a black YUV 4:2:0 frame."""
yuv_height = height * 3 // 2
yuv_frame = np.zeros((yuv_height, width), dtype=np.uint8)
uv_height = height // 2
# The U and V planes are stored after the Y plane.
u_start = height # U plane starts right after Y plane
v_start = u_start + uv_height // 2 # V plane starts after U plane
yuv_frame[u_start : u_start + uv_height, :width] = 128
yuv_frame[v_start : v_start + uv_height, :width] = 128
return yuv_frame
def yuv_region_2_yuv(frame, region):
try:
# TODO: does this copy the numpy array?

View File

@ -108,8 +108,20 @@ def capture_frames(
frame_rate.start()
skipped_eps = EventsPerSecond()
skipped_eps.start()
config_subscriber = ConfigSubscriber(f"config/enabled/{config.name}", True)
def get_enabled_state():
"""Fetch the latest enabled state from ZMQ."""
_, config_data = config_subscriber.check_for_update()
if config_data:
return config_data.enabled
return config.enabled
while not stop_event.is_set():
if not get_enabled_state():
logger.debug(f"Stopping capture thread for disabled {config.name}")
break
while True:
fps.value = frame_rate.eps()
skipped_fps.value = skipped_eps.eps()
current_frame.value = datetime.datetime.now().timestamp()
@ -178,26 +190,37 @@ class CameraWatchdog(threading.Thread):
self.stop_event = stop_event
self.sleeptime = self.config.ffmpeg.retry_interval
def run(self):
self.start_ffmpeg_detect()
self.config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True)
self.was_enabled = self.config.enabled
for c in self.config.ffmpeg_cmds:
if "detect" in c["roles"]:
continue
logpipe = LogPipe(
f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}"
)
self.ffmpeg_other_processes.append(
{
"cmd": c["cmd"],
"roles": c["roles"],
"logpipe": logpipe,
"process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
}
)
def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state."""
_, config_data = self.config_subscriber.check_for_update()
if config_data:
enabled = config_data.enabled
return enabled
return self.was_enabled if self.was_enabled is not None else self.config.enabled
def run(self):
if self._update_enabled_state():
self.start_all_ffmpeg()
time.sleep(self.sleeptime)
while not self.stop_event.wait(self.sleeptime):
enabled = self._update_enabled_state()
if enabled != self.was_enabled:
if enabled:
self.logger.debug(f"Enabling camera {self.camera_name}")
self.start_all_ffmpeg()
else:
self.logger.debug(f"Disabling camera {self.camera_name}")
self.stop_all_ffmpeg()
self.was_enabled = enabled
continue
if not enabled:
continue
now = datetime.datetime.now().timestamp()
if not self.capture_thread.is_alive():
@ -279,11 +302,9 @@ class CameraWatchdog(threading.Thread):
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
)
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
for p in self.ffmpeg_other_processes:
stop_ffmpeg(p["process"], self.logger)
p["logpipe"].close()
self.stop_all_ffmpeg()
self.logpipe.close()
self.config_subscriber.stop()
def start_ffmpeg_detect(self):
ffmpeg_cmd = [
@ -306,6 +327,43 @@ class CameraWatchdog(threading.Thread):
)
self.capture_thread.start()
def start_all_ffmpeg(self):
"""Start all ffmpeg processes (detection and others)."""
logger.debug(f"Starting all ffmpeg processes for {self.camera_name}")
self.start_ffmpeg_detect()
for c in self.config.ffmpeg_cmds:
if "detect" in c["roles"]:
continue
logpipe = LogPipe(
f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}"
)
self.ffmpeg_other_processes.append(
{
"cmd": c["cmd"],
"roles": c["roles"],
"logpipe": logpipe,
"process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
}
)
def stop_all_ffmpeg(self):
"""Stop all ffmpeg processes (detection and others)."""
logger.debug(f"Stopping all ffmpeg processes for {self.camera_name}")
if self.capture_thread is not None and self.capture_thread.is_alive():
self.capture_thread.join(timeout=5)
if self.capture_thread.is_alive():
self.logger.warning(
f"Capture thread for {self.camera_name} did not stop gracefully."
)
if self.ffmpeg_detect_process is not None:
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
self.ffmpeg_detect_process = None
for p in self.ffmpeg_other_processes[:]:
if p["process"] is not None:
stop_ffmpeg(p["process"], self.logger)
p["logpipe"].close()
self.ffmpeg_other_processes.clear()
def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int:
"""Checks if ffmpeg is still writing recording segments to cache."""
cache_files = sorted(
@ -539,7 +597,8 @@ def process_frames(
exit_on_empty: bool = False,
):
next_region_update = get_tomorrow_at_time(2)
config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True)
detect_config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True)
enabled_config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True)
fps_tracker = EventsPerSecond()
fps_tracker.start()
@ -549,9 +608,43 @@ def process_frames(
region_min_size = get_min_region_size(model_config)
prev_enabled = None
while not stop_event.is_set():
_, enabled_config = enabled_config_subscriber.check_for_update()
current_enabled = (
enabled_config.enabled
if enabled_config
else (prev_enabled if prev_enabled is not None else True)
)
if prev_enabled is None:
prev_enabled = current_enabled
if prev_enabled and not current_enabled and camera_metrics.frame_queue.empty():
logger.debug(f"Camera {camera_name} disabled, clearing tracked objects")
# Clear norfair's dictionaries
object_tracker.tracked_objects.clear()
object_tracker.disappeared.clear()
object_tracker.stationary_box_history.clear()
object_tracker.positions.clear()
object_tracker.track_id_map.clear()
# Clear internal norfair states
for trackers_by_type in object_tracker.trackers.values():
for tracker in trackers_by_type.values():
tracker.tracked_objects = []
for tracker in object_tracker.default_tracker.values():
tracker.tracked_objects = []
prev_enabled = current_enabled
if not current_enabled:
time.sleep(0.1)
continue
# check for updated detect config
_, updated_detect_config = config_subscriber.check_for_update()
_, updated_detect_config = detect_config_subscriber.check_for_update()
if updated_detect_config:
detect_config = updated_detect_config
@ -845,4 +938,5 @@ def process_frames(
motion_detector.stop()
requestor.stop()
config_subscriber.stop()
detect_config_subscriber.stop()
enabled_config_subscriber.stop()

View File

@ -181,6 +181,13 @@
"ui.dialog.streaming.debugView": "Debug View",
"ui.dialog.search.saveSearch": "Save Search",
"ui.dialog.search.saveSearch.desc": "Provide a name for this saved search.",
"ui.dialog.search.saveSearch.placeholder": "Enter a name for your search",
"ui.dialog.search.saveSearch.overwrite": "{{searchName}} already exists. Saving will overwrite the existing value.",
"ui.dialog.search.saveSearch.success": "Search ({{searchName}}) has been saved.",
"ui.stats.ffmpegHighCpuUsage": "{{camera}} has high FFMPEG CPU usage ({{ffmpegAvg}}%)",
"ui.stats.detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
"ui.stats.healthy": "System is healthy",
@ -289,7 +296,6 @@
"ui.cameraGroup.cameras.desc": "Select cameras for this group.",
"ui.cameraGroup.icon": "Icon",
"ui.cameraGroup.success": "Camera group ({{name}}) has been saved.",
"ui.cameraGroup.toast.error": "Failed to save config changes: {{error}}",
"ui.cameraGroup.camera.setting": "{{cameraName}} Streaming Settings",
"ui.cameraGroup.camera.setting.desc": "Change the live streaming options for this camera group's dashboard. <em>These settings are device/browser-specific.</em>",
"ui.cameraGroup.camera.setting.audioIsAvailable": "Audio is available for this stream",
@ -398,12 +404,27 @@
"ui.on": "ON",
"ui.off": "OFF",
"ui.edit": "Edit",
"ui.copyCoordinates": "Copy coordinates",
"ui.delete": "Delete",
"ui.yes": "Yes",
"ui.no": "No",
"ui.download": "Download",
"ui.info": "Info",
"ui.toast.save.error": "Failed to save config changes: {{errorMessage}}",
"ui.toast.save.error.noMessage": "Failed to save config changes",
"ui.form.message.polygonDrawing.error.mustBeFinished": "The polygon drawing must be finished before saving.",
"ui.form.message.zoneName.error.mustBeAtLeastTwoCharacters": "Zone name must be at least 2 characters.",
"ui.form.message.zoneName.error.mustNotBeSameWithCamera": "Zone name must not be the name of a camera.",
"ui.form.message.zoneName.error.alreadyExists": "Zone name already exists on this camera.",
"ui.form.message.zoneName.error.mustNotContainPeriod": "Zone name must not contain a period.",
"ui.form.message.zoneName.error.hasIllegalCharacter": "Zone name has an illegal character.",
"ui.form.message.distance.error": "Distance must be greater than or equal to 0.1",
"ui.form.message.distance.error.mustBeFilled": "All distance fields must be filled to use speed estimation.",
"ui.form.message.inertia.error.mustBeAboveZero": "Inertia must be above 0.",
"ui.form.message.loiteringTime.error.mustBeGreaterOrEqualZero": "Loitering time must be greater than or equal to 0.",
"ui.live.documentTitle": "Live - Frigate",
"ui.live.documentTitle.withCamera": "{{camera}} - Live - Frigate",
"ui.live.twoWayTalk.enable": "Enable Two Way Talk",
@ -501,12 +522,16 @@
"ui.settingView.exploreSettings.semanticSearch.modelSize.large": "large",
"ui.settingView.exploreSettings.semanticSearch.modelSize.small.desc": "Using <em>small</em> employs a quantized version of the model that uses less RAM and runs faster on CPU with a very negligible difference in embedding quality.",
"ui.settingView.exploreSettings.semanticSearch.modelSize.large.desc": "Using <em>large</em> employs the full Jina model and will automatically run on the GPU if applicable.",
"ui.settingView.exploreSettings.toast.success": "Explore settings have been saved.",
"ui.settingView.cameraSettings": "Camera Settings",
"ui.settingView.cameraSettings.streams": "Streams",
"ui.settingView.cameraSettings.streams.desc": "Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
"ui.settingView.cameraSettings.review": "Review",
"ui.settingView.cameraSettings.review.desc": "Enable/disable alerts and detections for this camera. When disabled, no new review items will be generated.",
"ui.settingView.cameraSettings.review.alerts": "Alerts",
"ui.settingView.cameraSettings.review.detections": "Detections",
"ui.settingView.cameraSettings.review.alerts": "Alerts ",
"ui.settingView.cameraSettings.review.detections": "Detections ",
"ui.settingView.cameraSettings.reviewClassification": "Review Classification",
"ui.settingView.cameraSettings.reviewClassification.desc": "Frigate categorizes review items as Alerts and Detections. By default, all <em>person</em> and <em>car</em> objects are considered Alerts. You can refine categorization of your review items by configuring required zones for them.",
"ui.settingView.cameraSettings.reviewClassification.readTheDocumentation": "Read the Documentation",
@ -514,6 +539,9 @@
"ui.settingView.cameraSettings.reviewClassification.objectAlertsTips": "All {{alertsLabels}} objects on {{cameraName}} will be shown as Alerts.",
"ui.settingView.cameraSettings.reviewClassification.zoneObjectAlertsTips": "All {{alertsLabels}} objects detected in {{zone}} on {{cameraName}} will be shown as Alerts.",
"ui.settingView.cameraSettings.reviewClassification.selectAlertsZones": "Select zones for Alerts",
"ui.settingView.cameraSettings.reviewClassification.selectDetectionsZones": "Select zones for Detections",
"ui.settingView.cameraSettings.reviewClassification.limitDetections": "Limit detections to specific zones",
"ui.settingView.cameraSettings.reviewClassification.toast.success": "Review classification configuration has been saved. Restart Frigate to apply changes.",
"ui.settingView.cameraSettings.reviewClassification.objectDetectionsTips": "All {{detectionsLabels}} objects <em>not classified as Alerts</em> on {{cameraName}} will be shown as Detections.",
"ui.settingView.cameraSettings.reviewClassification.zoneObjectDetectionsTips": "All {{detectionsLabels}} objects <em>not classified as Alerts</em> that are detected in {{zone}} on {{cameraName}} will be shown as Detections.",
@ -521,29 +549,34 @@
"ui.settingView.cameraSettings.reviewClassification.zoneObjectDetectionsTips.regardlessOfZoneObjectDetectionsTips": "All {{detectionsLabels}} objects <em>not classified as Alerts</em> on {{cameraName}} will be shown as Detections, regardless of zone.",
"ui.settingView.masksAndZonesSettings": "Masks / Zones",
"ui.settingView.masksAndZonesSettings.zone": "Zones",
"ui.settingView.masksAndZonesSettings.zone.documentTitle": "Edit Zone - Frigate",
"ui.settingView.masksAndZonesSettings.zone.desc": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.",
"ui.settingView.masksAndZonesSettings.zone.desc.documentation": "Documentation",
"ui.settingView.masksAndZonesSettings.zone.add": "Add Zone",
"ui.settingView.masksAndZonesSettings.zone.edit": "Edit Zone",
"ui.settingView.masksAndZonesSettings.zone.point_one": "{{count}} point",
"ui.settingView.masksAndZonesSettings.zone.point_other": "{{count}} points",
"ui.settingView.masksAndZonesSettings.zone.clickDrawPolygon": "Click to draw a polygon on the image.",
"ui.settingView.masksAndZonesSettings.zone.name": "Name",
"ui.settingView.masksAndZonesSettings.zone.name.inputPlaceHolder": "Enter a name...",
"ui.settingView.masksAndZonesSettings.zone.name.tips": "Name must be at least 2 characters and must not be the name of a camera or another zone.",
"ui.settingView.masksAndZonesSettings.zone.inertia": "Inertia",
"ui.settingView.masksAndZonesSettings.zone.inertia.desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. <em>Default: 3</em>",
"ui.settingView.masksAndZonesSettings.zone.loiteringTime": "Loitering Time",
"ui.settingView.masksAndZonesSettings.zone.loiteringTime.desc": "Sets a minimum amount of time in seconds that the object must be in the zone for it to activate. <em>Default: 0</em>",
"ui.settingView.masksAndZonesSettings.zone.objects": "Objects",
"ui.settingView.masksAndZonesSettings.zone.objects.desc": "List of objects that apply to this zone.",
"ui.settingView.masksAndZonesSettings.zone.allObjects": "All Objects",
"ui.settingView.masksAndZonesSettings.zone.speedEstimation": "Speed Estimation",
"ui.settingView.masksAndZonesSettings.zone.speedEstimation.desc": "Enable speed estimation for objects in this zone. The zone must have exactly 4 points.",
"ui.settingView.masksAndZonesSettings.zone.speedEstimation.pointLengthError": "Zones with speed estimation must have exactly 4 points.",
"ui.settingView.masksAndZonesSettings.zone.speedEstimation.loiteringTimeError": "Zones with loitering times greater than 0 should not be used with speed estimation.",
"ui.settingView.masksAndZonesSettings.filter.all": "All Masks and Zones",
"ui.settingView.masksAndZonesSettings.zones": "Zones",
"ui.settingView.masksAndZonesSettings.zones.documentTitle": "Edit Zone - Frigate",
"ui.settingView.masksAndZonesSettings.zones.desc": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.",
"ui.settingView.masksAndZonesSettings.zones.desc.documentation": "Documentation",
"ui.settingView.masksAndZonesSettings.zones.add": "Add Zone",
"ui.settingView.masksAndZonesSettings.zones.edit": "Edit Zone",
"ui.settingView.masksAndZonesSettings.zones.point_one": "{{count}} point",
"ui.settingView.masksAndZonesSettings.zones.point_other": "{{count}} points",
"ui.settingView.masksAndZonesSettings.zones.clickDrawPolygon": "Click to draw a polygon on the image.",
"ui.settingView.masksAndZonesSettings.zones.name": "Name",
"ui.settingView.masksAndZonesSettings.zones.name.inputPlaceHolder": "Enter a name...",
"ui.settingView.masksAndZonesSettings.zones.name.tips": "Name must be at least 2 characters and must not be the name of a camera or another zone.",
"ui.settingView.masksAndZonesSettings.zones.inertia": "Inertia",
"ui.settingView.masksAndZonesSettings.zones.inertia.desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. <em>Default: 3</em>",
"ui.settingView.masksAndZonesSettings.zones.loiteringTime": "Loitering Time",
"ui.settingView.masksAndZonesSettings.zones.loiteringTime.desc": "Sets a minimum amount of time in seconds that the object must be in the zone for it to activate. <em>Default: 0</em>",
"ui.settingView.masksAndZonesSettings.zones.objects": "Objects",
"ui.settingView.masksAndZonesSettings.zones.objects.desc": "List of objects that apply to this zone.",
"ui.settingView.masksAndZonesSettings.zones.allObjects": "All Objects",
"ui.settingView.masksAndZonesSettings.zones.speedEstimation": "Speed Estimation",
"ui.settingView.masksAndZonesSettings.zones.speedEstimation.desc": "Enable speed estimation for objects in this zone. The zone must have exactly 4 points.",
"ui.settingView.masksAndZonesSettings.zones.speedThreshold": "Speed Threshold ({{unit}})",
"ui.settingView.masksAndZonesSettings.zones.speedThreshold.desc": "Specifies a minimum speed for objects to be considered in this zone.",
"ui.settingView.masksAndZonesSettings.zones.speedThreshold.toast.error.pointLengthError": "Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
"ui.settingView.masksAndZonesSettings.zones.speedThreshold.toast.error.loiteringTimeError": "Zones with loitering times greater than 0 should not be used with speed estimation.",
"ui.settingView.masksAndZonesSettings.zones.toast.success": "Zone ({{zoneName}}) has been saved. Restart Frigate to apply changes.",
"ui.settingView.masksAndZonesSettings.motionMasks": "Motion Mask",
"ui.settingView.masksAndZonesSettings.motionMasks.documentTitle": "Edit Motion Mask - Frigate",
@ -559,6 +592,8 @@
"ui.settingView.masksAndZonesSettings.motionMasks.polygonAreaTooLarge": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.",
"ui.settingView.masksAndZonesSettings.motionMasks.polygonAreaTooLarge.tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead.",
"ui.settingView.masksAndZonesSettings.motionMasks.polygonAreaTooLarge.documentation": "Read the documentation",
"ui.settingView.masksAndZonesSettings.motionMasks.toast.success": "{{polygonName}} has been saved. Restart Frigate to apply changes.",
"ui.settingView.masksAndZonesSettings.motionMasks.toast.success.noName": "Motion Mask has been saved. Restart Frigate to apply changes.",
"ui.settingView.masksAndZonesSettings.objectMasks": "Object Masks",
"ui.settingView.masksAndZonesSettings.objectMasks.documentTitle": "Edit Object Mask - Frigate",
@ -573,6 +608,8 @@
"ui.settingView.masksAndZonesSettings.objectMasks.objects": "Objects",
"ui.settingView.masksAndZonesSettings.objectMasks.objects.desc": "The object type that that applies to this object mask.",
"ui.settingView.masksAndZonesSettings.objectMasks.objects.allObjectTypes": "All object types",
"ui.settingView.masksAndZonesSettings.objectMasks.toast.success": "{{polygonName}} has been saved. Restart Frigate to apply changes.",
"ui.settingView.masksAndZonesSettings.objectMasks.toast.success.noName": "Object Mask has been saved. Restart Frigate to apply changes.",
"ui.settingView.motionDetectionTuner": "Motion Detection Tuner",
@ -584,6 +621,7 @@
"ui.settingView.motionDetectionTuner.contourArea.desc": "The contour area value is used to decide which groups of changed pixels qualify as motion. <em>Default: 10</em>",
"ui.settingView.motionDetectionTuner.improveContrast": "Improve Contrast",
"ui.settingView.motionDetectionTuner.improveContrast.desc": "Improve contrast for darker scenes. <em>Default: ON</em>",
"ui.settingView.motionDetectionTuner.toast.success": "Motion settings have been saved.",
"ui.settingView.debug": "Debug",
"ui.settingView.debug.detectorDesc": "Frigate uses your detectors ({{detectors}}) to detect objects in your camera's video stream.",

View File

@ -181,6 +181,11 @@
"ui.dialog.streaming.debugView": "调试界面",
"ui.dialog.search.saveSearch": "保存搜索",
"ui.dialog.search.saveSearch.desc": "请为此已保存的搜索提供一个名称。",
"ui.dialog.search.saveSearch.placeholder": "请输入搜索名称",
"ui.dialog.search.saveSearch.overwrite": "{{searchName}} 已存在。保存将覆盖现有值。",
"ui.dialog.search.saveSearch.success": "搜索 ({{searchName}}) 已保存。",
"ui.stats.ffmpegHighCpuUsage": "{{camera}} 的 FFMPEG CPU 使用率较高({{ffmpegAvg}}%",
"ui.stats.detectHighCpuUsage": "{{camera}} 的 探测 CPU 使用率较高({{detectAvg}}%",
@ -291,7 +296,6 @@
"ui.cameraGroup.cameras.desc": "选择添加至该组的摄像头。",
"ui.cameraGroup.icon": "图标",
"ui.cameraGroup.toast.success": "摄像头组({{name}})保存成功。",
"ui.cameraGroup.toast.error": "保存设置失败: {{error}}",
"ui.cameraGroup.camera.setting": "{{cameraName}} 视频流设置",
"ui.cameraGroup.camera.setting.desc": "更改此摄像头组仪表板的实时视频流选项。<em>这些设置特定于设备/浏览器。</em>",
"ui.cameraGroup.camera.setting.audioIsAvailable": "此视频流支持音频",
@ -400,11 +404,28 @@
"ui.on": "开",
"ui.off": "关",
"ui.edit": "编辑",
"ui.copyCoordinates": "复制坐标",
"ui.delete": "删除",
"ui.yes": "是",
"ui.no": "否",
"ui.download": "下载",
"ui.info": "信息",
"ui.toast.save.error": "保存配置信息失败: {{errorMessage}}",
"ui.toast.save.error.noMessage": "保存配置信息失败",
"ui.form.message.polygonDrawing.error.mustBeFinished": "多边形绘制必须完成闭合后才能保存。",
"ui.form.message.zoneName.error.mustBeAtLeastTwoCharacters": "区域名称必须至少包含 2 个字符。",
"ui.form.message.zoneName.error.mustNotBeSameWithCamera": "区域名称不能与摄像头名称相同。",
"ui.form.message.zoneName.error.alreadyExists": "该摄像头已有相同的区域名称。",
"ui.form.message.zoneName.error.mustNotContainPeriod": "区域名称不能包含句点。",
"ui.form.message.zoneName.error.hasIllegalCharacter": "区域名称包含非法字符。",
"ui.form.message.distance.error": "距离必须大于或等于 0.1。",
"ui.form.message.distance.error.mustBeFilled": "所有距离字段必须填写才能使用速度估算。",
"ui.form.message.inertia.error.mustBeAboveZero": "惯性必须大于 0。",
"ui.form.message.loiteringTime.error.mustBeGreaterOrEqualZero": "徘徊时间必须大于或等于 0。",
"ui.live.documentTitle": "实时监控 - Frigate",
"ui.live.documentTitle.withCamera": "{{camera}} - 实时监控 - Frigate",
"ui.live.twoWayTalk.enable": "开启双向对话",
@ -503,12 +524,15 @@
"ui.settingView.exploreSettings.semanticSearch.modelSize.large": "大",
"ui.settingView.exploreSettings.semanticSearch.modelSize.small.desc": "使用 <strong>小</strong>模型。该模型将使用较少的内存在CPU上也能较快的运行。质量较好。",
"ui.settingView.exploreSettings.semanticSearch.modelSize.large.desc": "使用 <strong>大</strong>模型。该模型采用了完整的Jina模型并在适用的情况下使用GPU。",
"ui.settingView.exploreSettings.toast.success": "探测设置已保存。",
"ui.settingView.cameraSettings": "摄像头设置",
"ui.settingView.cameraSettings.streams": "视频流",
"ui.settingView.cameraSettings.streams.desc": "禁用摄像头将完全停止 Frigate 对该摄像头视频流的处理。检测、录制和调试功能都将不可用。<br /><em>注意:该选项不会禁用 go2rtc 转播。</em>",
"ui.settingView.cameraSettings.review": "预览",
"ui.settingView.cameraSettings.review.desc": "启用/禁用摄像头的警报和检测。禁用后,不会生成新的预览项。",
"ui.settingView.cameraSettings.review.alerts": "警告",
"ui.settingView.cameraSettings.review.detections": "检测",
"ui.settingView.cameraSettings.review.alerts": "警告 ",
"ui.settingView.cameraSettings.review.detections": "检测 ",
"ui.settingView.cameraSettings.reviewClassification": "预览分级",
"ui.settingView.cameraSettings.reviewClassification.desc": "Frigate 将回放项目分为“警告”和“检测”。默认情况下,所有的 <em>人</em>、<em>汽车</em> 的对象都视为警告。你可以通过修改配置文件配置区域来细分。",
"ui.settingView.cameraSettings.reviewClassification.readTheDocumentation": "阅读文档(英文)",
@ -516,32 +540,45 @@
"ui.settingView.cameraSettings.reviewClassification.objectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 都将显示为警告。",
"ui.settingView.cameraSettings.reviewClassification.zoneObjectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 的 {{zone}} 区域都将显示为警告。",
"ui.settingView.cameraSettings.reviewClassification.selectAlertsZones": "选择要显示为警告的区域",
"ui.settingView.cameraSettings.reviewClassification.selectDetectionsZones": "选择检测区域",
"ui.settingView.cameraSettings.reviewClassification.limitDetections": "限制仅在特定区域内进行检测",
"ui.settingView.cameraSettings.reviewClassification.toast.success": "预览分级配置已保存。请重启 Frigate 以应用更改。",
"ui.settingView.cameraSettings.reviewClassification.objectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"ui.settingView.cameraSettings.reviewClassification.zoneObjectDetectionsTips": "所有未在 {{cameraName}} 上归类为 {{detectionsLabels}} 的对象在 {{zone}} 区域都将显示为检测。",
"ui.settingView.cameraSettings.reviewClassification.zoneObjectDetectionsTips.notSelectDetections": "所有在 {{cameraName}} 的 {{zone}} 上检测到的未归类为警告的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"ui.settingView.cameraSettings.reviewClassification.zoneObjectDetectionsTips.regardlessOfZoneObjectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"ui.settingView.masksAndZonesSettings": "屏罩 / 区域",
"ui.settingView.masksAndZonesSettings.zone": "区域",
"ui.settingView.masksAndZonesSettings.zone.documentTitle": "编辑区域 - Frigate",
"ui.settingView.masksAndZonesSettings.zone.desc": "该功能允许你定义特定区域,以便你可以确定特定对象是否在该区域内。",
"ui.settingView.masksAndZonesSettings.zone.desc.documentation": "文档(英文)",
"ui.settingView.masksAndZonesSettings.zone.add": "添加区域",
"ui.settingView.masksAndZonesSettings.zone.edit": "编辑区域",
"ui.settingView.masksAndZonesSettings.zone.point_one": "{{count}} 点",
"ui.settingView.masksAndZonesSettings.zone.point_other": "{{count}} 点",
"ui.settingView.masksAndZonesSettings.zone.clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
"ui.settingView.masksAndZonesSettings.zone.name": "区域名称",
"ui.settingView.masksAndZonesSettings.zone.name.inputPlaceHolder": "请输入名称",
"ui.settingView.masksAndZonesSettings.zone.name.tips": "名称至少包含两个字符,且不能和摄像头或其他区域同名。",
"ui.settingView.masksAndZonesSettings.zone.inertia": "区域名称",
"ui.settingView.masksAndZonesSettings.zone.inertia.desc": "识别指定对象前该对象必须在这个区域内出现了多少帧。<em>默认值3</em>",
"ui.settingView.masksAndZonesSettings.zone.loiteringTime": "停留时间",
"ui.settingView.masksAndZonesSettings.zone.loiteringTime.desc": "设置对象必须在区域中活动的最小时间(单位为秒)。<em>默认值0</em>",
"ui.settingView.masksAndZonesSettings.zone.objects": "对象",
"ui.settingView.masksAndZonesSettings.zone.objects.desc": "将在此区域应用的对象列表。",
"ui.settingView.masksAndZonesSettings.zone.allObjects": "所有对象",
"ui.settingView.masksAndZonesSettings": "遮罩/ 区域",
"ui.settingView.masksAndZonesSettings.filter.all": "所有遮罩和区域",
"ui.settingView.masksAndZonesSettings.zones": "区域",
"ui.settingView.masksAndZonesSettings.zones.documentTitle": "编辑区域 - Frigate",
"ui.settingView.masksAndZonesSettings.zones.desc": "该功能允许你定义特定区域,以便你可以确定特定对象是否在该区域内。",
"ui.settingView.masksAndZonesSettings.zones.desc.documentation": "文档(英文)",
"ui.settingView.masksAndZonesSettings.zones.add": "添加区域",
"ui.settingView.masksAndZonesSettings.zones.edit": "编辑区域",
"ui.settingView.masksAndZonesSettings.zones.point_one": "{{count}} 点",
"ui.settingView.masksAndZonesSettings.zones.point_other": "{{count}} 点",
"ui.settingView.masksAndZonesSettings.zones.clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
"ui.settingView.masksAndZonesSettings.zones.name": "区域名称",
"ui.settingView.masksAndZonesSettings.zones.name.inputPlaceHolder": "请输入名称",
"ui.settingView.masksAndZonesSettings.zones.name.tips": "名称至少包含两个字符,且不能和摄像头或其他区域同名。<br>当前仅支持英文与数字组合",
"ui.settingView.masksAndZonesSettings.zones.inertia": "惯性",
"ui.settingView.masksAndZonesSettings.zones.inertia.desc": "识别指定对象前该对象必须在这个区域内出现了多少帧。<em>默认值3</em>",
"ui.settingView.masksAndZonesSettings.zones.loiteringTime": "停留时间",
"ui.settingView.masksAndZonesSettings.zones.loiteringTime.desc": "设置对象必须在区域中活动的最小时间(单位为秒)。<em>默认值0</em>",
"ui.settingView.masksAndZonesSettings.zones.objects": "对象",
"ui.settingView.masksAndZonesSettings.zones.objects.desc": "将在此区域应用的对象列表。",
"ui.settingView.masksAndZonesSettings.zones.allObjects": "所有对象",
"ui.settingView.masksAndZonesSettings.zones.speedEstimation": "速度估算",
"ui.settingView.masksAndZonesSettings.zones.speedEstimation.desc": "启用此区域内物体的速度估算。该区域必须恰好包含 4 个点。",
"ui.settingView.masksAndZonesSettings.zones.speedThreshold": "速度阈值 ({{unit}})",
"ui.settingView.masksAndZonesSettings.zones.speedThreshold.desc": "指定物体在此区域内被视为有效的最低速度。",
"ui.settingView.masksAndZonesSettings.zones.speedThreshold.toast.error.pointLengthError": "此区域的速度估算已禁用。启用速度估算的区域必须恰好包含 4 个点。",
"ui.settingView.masksAndZonesSettings.zones.speedThreshold.toast.error.loiteringTimeError": "徘徊时间大于 0 的区域不应与速度估算一起使用。",
"ui.settingView.masksAndZonesSettings.zones.toast.success": "区域 ({{zoneName}}) 已保存。请重启 Frigate 以应用更改。",
"ui.settingView.masksAndZonesSettings.motionMasks": "运动遮罩",
"ui.settingView.masksAndZonesSettings.motionMasks.documentTitle": "编辑运动遮罩 - Frigate",
@ -557,7 +594,8 @@
"ui.settingView.masksAndZonesSettings.motionMasks.polygonAreaTooLarge": "运动遮罩的大小达到了摄像头画面的{{polygonArea}}%。不建议设置太大的运动遮罩。",
"ui.settingView.masksAndZonesSettings.motionMasks.polygonAreaTooLarge.tips": "运动遮罩不会阻止检测到对象,你应该使用区域来限制检测对象。",
"ui.settingView.masksAndZonesSettings.motionMasks.polygonAreaTooLarge.documentation": "阅读文档(英文)",
"ui.settingView.masksAndZonesSettings.motionMasks.toast.success": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
"ui.settingView.masksAndZonesSettings.motionMasks.toast.success.noName": "运动遮罩已保存。请重启 Frigate 以应用更改。",
"ui.settingView.masksAndZonesSettings.objectMasks": "对象遮罩",
"ui.settingView.masksAndZonesSettings.objectMasks.documentTitle": "编辑对象遮罩 - Frigate",
@ -572,6 +610,8 @@
"ui.settingView.masksAndZonesSettings.objectMasks.objects": "对象",
"ui.settingView.masksAndZonesSettings.objectMasks.objects.desc": "将应用于此对象遮罩的对象列表。",
"ui.settingView.masksAndZonesSettings.objectMasks.objects.allObjectTypes": "所有对象类型",
"ui.settingView.masksAndZonesSettings.objectMasks.toast.success": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
"ui.settingView.masksAndZonesSettings.objectMasks.toast.success.noName": "对象遮罩已保存。请重启 Frigate 以应用更改。",
"ui.settingView.motionDetectionTuner": "运动检测调整器",

View File

@ -56,6 +56,7 @@ function useValue(): useValueReturn {
const {
record,
detect,
enabled,
snapshots,
audio,
notifications,
@ -67,6 +68,7 @@ function useValue(): useValueReturn {
// @ts-expect-error we know this is correct
state["config"];
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
@ -164,6 +166,17 @@ export function useWs(watchTopic: string, publishTopic: string) {
return { value, send };
}
export function useEnabledState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`);
return { payload: (payload ?? "ON") as ToggleableSetting, send };
}
export function useDetectState(camera: string): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;

View File

@ -5,6 +5,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer";
import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils";
import { useEnabledState } from "@/api/ws";
type CameraImageProps = {
className?: string;
@ -26,7 +27,8 @@ export default function CameraImage({
const imgRef = useRef<HTMLImageElement | null>(null);
const { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True";
const { payload: enabledState } = useEnabledState(camera);
const enabled = enabledState === "ON" || enabledState === undefined;
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
@ -96,9 +98,7 @@ export default function CameraImage({
loading="lazy"
/>
) : (
<div className="pt-6 text-center">
Camera is disabled in config, no stream or snapshot available!
</div>
<div className="size-full rounded-lg border-2 border-muted bg-background_alt text-center md:rounded-2xl" />
)}
{!imageLoaded && enabled ? (
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">

View File

@ -108,9 +108,7 @@ export default function CameraImage({
width={scaledWidth}
/>
) : (
<div className="pt-6 text-center">
Camera is disabled in config, no stream or snapshot available!
</div>
<div className="pt-6 text-center">Camera is disabled.</div>
)}
{!hasLoaded && enabled ? (
<div

View File

@ -11,11 +11,15 @@ const variants = {
primary: {
active: "font-bold text-white bg-selected rounded-lg",
inactive: "text-secondary-foreground bg-secondary rounded-lg",
disabled:
"text-secondary-foreground bg-secondary rounded-lg cursor-not-allowed opacity-50",
},
overlay: {
active: "font-bold text-white bg-selected rounded-full",
inactive:
"text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
disabled:
"bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-full cursor-not-allowed opacity-50",
},
};
@ -26,6 +30,7 @@ type CameraFeatureToggleProps = {
Icon: IconType;
title: string;
onClick?: () => void;
disabled?: boolean; // New prop for disabling
};
export default function CameraFeatureToggle({
@ -35,18 +40,28 @@ export default function CameraFeatureToggle({
Icon,
title,
onClick,
disabled = false, // Default to false
}: CameraFeatureToggleProps) {
const content = (
<div
onClick={onClick}
onClick={disabled ? undefined : onClick}
className={cn(
"flex flex-col items-center justify-center",
variants[variant][isActive ? "active" : "inactive"],
disabled
? variants[variant].disabled
: variants[variant][isActive ? "active" : "inactive"],
className,
)}
>
<Icon
className={`size-5 md:m-[6px] ${isActive ? "text-white" : "text-secondary-foreground"}`}
className={cn(
"size-5 md:m-[6px]",
disabled
? "text-gray-400"
: isActive
? "text-white"
: "text-secondary-foreground",
)}
/>
</div>
);
@ -54,7 +69,7 @@ export default function CameraFeatureToggle({
if (isDesktop) {
return (
<Tooltip>
<TooltipTrigger>{content}</TooltipTrigger>
<TooltipTrigger disabled={disabled}>{content}</TooltipTrigger>
<TooltipContent side="bottom">
<p>{title}</p>
</TooltipContent>

View File

@ -744,7 +744,7 @@ export function CameraGroupEdit({
setAllGroupsStreamingSettings(updatedSettings);
} else {
toast.error(
t("ui.cameraGroup.toast.error", { error: res.statusText }),
t("ui.toast.save.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
@ -753,8 +753,8 @@ export function CameraGroupEdit({
})
.catch((error) => {
toast.error(
t("ui.cameraGroup.toast.error", {
error: error.response.data.message,
t("ui.toast.save.error", {
errorMessage: error.response.data.message,
}),
{ position: "top-center" },
);

View File

@ -7,6 +7,7 @@ import { PolygonType } from "@/types/canvas";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { Trans } from "react-i18next";
type ZoneMaskFilterButtonProps = {
selectedZoneMask?: PolygonType[];
@ -29,7 +30,7 @@ export function ZoneMaskFilterButton({
<div
className={`hidden md:block ${selectedZoneMask?.length ? "text-selected-foreground" : "text-primary"}`}
>
Filter
<Trans>ui.filter</Trans>
</div>
</Button>
);
@ -75,7 +76,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
All Masks and Zones
<Trans>ui.settingView.masksAndZonesSettings.filter.all</Trans>
</Label>
<Switch
className="ml-1"
@ -96,9 +97,12 @@ export function GeneralFilterContent({
className="mx-2 w-full cursor-pointer capitalize text-primary"
htmlFor={item}
>
{item
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase()) + "s"}
<Trans>
ui.settingView.masksAndZonesSettings.
{item.replace(/_([a-z])/g, (match, letter) =>
letter.toUpperCase(),
) + "s"}
</Trans>
</Label>
<Switch
key={item}

View File

@ -12,6 +12,8 @@ import { Input } from "@/components/ui/input";
import { useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import { toast } from "sonner";
import { Trans } from "react-i18next";
import { t } from "i18next";
type SaveSearchDialogProps = {
existingNames: string[];
@ -32,9 +34,14 @@ export function SaveSearchDialog({
if (searchName.trim()) {
onSave(searchName.trim());
setSearchName("");
toast.success(`Search (${searchName.trim()}) has been saved.`, {
position: "top-center",
});
toast.success(
t("ui.dialog.search.saveSearch.success", {
searchName: searchName.trim(),
}),
{
position: "top-center",
},
);
onClose();
}
};
@ -54,26 +61,29 @@ export function SaveSearchDialog({
}}
>
<DialogHeader>
<DialogTitle>Save Search</DialogTitle>
<DialogTitle>
<Trans>ui.dialog.search.saveSearch</Trans>
</DialogTitle>
<DialogDescription className="sr-only">
Provide a name for this saved search.
<Trans>ui.dialog.search.saveSearch.desc</Trans>
</DialogDescription>
</DialogHeader>
<Input
value={searchName}
className="text-md"
onChange={(e) => setSearchName(e.target.value)}
placeholder="Enter a name for your search"
placeholder={t("ui.dialog.search.saveSearch.placeholder")}
/>
{overwrite && (
<div className="ml-1 text-sm text-danger">
{searchName} already exists. Saving will overwrite the existing
value.
<Trans values={{ searchName }}>
ui.dialog.search.saveSearch.overwrite
</Trans>
</div>
)}
<DialogFooter>
<Button aria-label="Cancel" onClick={onClose}>
Cancel
<Trans>ui.cancel</Trans>
</Button>
<Button
onClick={handleSave}
@ -81,7 +91,7 @@ export function SaveSearchDialog({
className="mb-2 md:mb-0"
aria-label="Save this search"
>
Save
<Trans>ui.save</Trans>
</Button>
</DialogFooter>
</DialogContent>

View File

@ -39,7 +39,11 @@ import {
import { cn } from "@/lib/utils";
import { useNavigate } from "react-router-dom";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useNotifications, useNotificationSuspend } from "@/api/ws";
import {
useEnabledState,
useNotifications,
useNotificationSuspend,
} from "@/api/ws";
type LiveContextMenuProps = {
className?: string;
@ -83,6 +87,11 @@ export default function LiveContextMenu({
}: LiveContextMenuProps) {
const [showSettings, setShowSettings] = useState(false);
// camera enabled
const { payload: enabledState, send: sendEnabled } = useEnabledState(camera);
const isEnabled = enabledState === "ON";
// streaming settings
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
@ -263,7 +272,7 @@ export default function LiveContextMenu({
onClick={handleVolumeIconClick}
/>
<VolumeSlider
disabled={!audioState}
disabled={!audioState || !isEnabled}
className="my-3 ml-0.5 rounded-lg bg-background/60"
value={[volumeState ?? 0]}
min={0}
@ -280,34 +289,49 @@ export default function LiveContextMenu({
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={muteAll}
onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")}
>
<div className="text-primary">
{isEnabled ? "Disable" : "Enable"} Camera
</div>
</div>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? muteAll : undefined}
>
<div className="text-primary">Mute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={unmuteAll}
onClick={isEnabled ? unmuteAll : undefined}
>
<div className="text-primary">Unmute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={toggleStats}
onClick={isEnabled ? toggleStats : undefined}
>
<div className="text-primary">
{statsState ? "Hide" : "Show"} Stream Stats
</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => navigate(`/settings?page=debug&camera=${camera}`)}
onClick={
isEnabled
? () => navigate(`/settings?page=debug&camera=${camera}`)
: undefined
}
>
<div className="text-primary">Debug View</div>
</div>
@ -315,10 +339,10 @@ export default function LiveContextMenu({
{cameraGroup && cameraGroup !== "default" && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => setShowSettings(true)}
onClick={isEnabled ? () => setShowSettings(true) : undefined}
>
<div className="text-primary">Streaming Settings</div>
</div>
@ -328,10 +352,10 @@ export default function LiveContextMenu({
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={resetPreferredLiveMode}
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">Reset</div>
</div>
@ -342,7 +366,7 @@ export default function LiveContextMenu({
<>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<ContextMenuSubTrigger disabled={!isEnabled}>
<div className="flex items-center gap-2">
<span>Notifications</span>
</div>
@ -382,10 +406,15 @@ export default function LiveContextMenu({
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
sendNotification("ON");
sendNotificationSuspend(0);
}}
disabled={!isEnabled}
onClick={
isEnabled
? () => {
sendNotification("ON");
sendNotificationSuspend(0);
}
: undefined
}
>
<div className="flex w-full flex-col gap-2">
{notificationState === "ON" ? (
@ -405,36 +434,71 @@ export default function LiveContextMenu({
Suspend for:
</p>
<div className="space-y-1">
<ContextMenuItem onClick={() => handleSuspend("5")}>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled ? () => handleSuspend("5") : undefined
}
>
5 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("10")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("10")
: undefined
}
>
10 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("30")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("30")
: undefined
}
>
30 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("60")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("60")
: undefined
}
>
1 hour
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("840")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("840")
: undefined
}
>
12 hours
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("1440")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("1440")
: undefined
}
>
24 hours
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("off")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("off")
: undefined
}
>
Until restart
</ContextMenuItem>

View File

@ -22,6 +22,7 @@ import { TbExclamationCircle } from "react-icons/tb";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats";
import { LuVideoOff } from "react-icons/lu";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
@ -86,8 +87,13 @@ export default function LivePlayer({
// camera activity
const { activeMotion, activeTracking, objects, offline } =
useCameraActivity(cameraConfig);
const {
enabled: cameraEnabled,
activeMotion,
activeTracking,
objects,
offline,
} = useCameraActivity(cameraConfig);
const cameraActive = useMemo(
() =>
@ -191,12 +197,40 @@ export default function LivePlayer({
setLiveReady(true);
}, []);
// enabled states
const [isReEnabling, setIsReEnabling] = useState(false);
const prevCameraEnabledRef = useRef(cameraEnabled ?? true);
useEffect(() => {
if (cameraEnabled == undefined) {
return;
}
if (!prevCameraEnabledRef.current && cameraEnabled) {
// Camera enabled
setLiveReady(false);
setIsReEnabling(true);
setKey((prevKey) => prevKey + 1);
} else if (prevCameraEnabledRef.current && !cameraEnabled) {
// Camera disabled
setLiveReady(false);
setKey((prevKey) => prevKey + 1);
}
prevCameraEnabledRef.current = cameraEnabled;
}, [cameraEnabled]);
useEffect(() => {
if (liveReady && isReEnabling) {
setIsReEnabling(false);
}
}, [liveReady, isReEnabling]);
if (!cameraConfig) {
return <ActivityIndicator />;
}
let player;
if (!autoLive || !streamName) {
if (!autoLive || !streamName || !cameraEnabled) {
player = null;
} else if (preferredLiveMode == "webrtc") {
player = (
@ -267,6 +301,22 @@ export default function LivePlayer({
player = <ActivityIndicator />;
}
// if (cameraConfig.name == "lpr")
// console.log(
// cameraConfig.name,
// "enabled",
// cameraEnabled,
// "prev enabled",
// prevCameraEnabledRef.current,
// "offline",
// offline,
// "show still",
// showStillWithoutActivity,
// "live ready",
// liveReady,
// player,
// );
return (
<div
ref={cameraRef ?? internalContainerRef}
@ -287,16 +337,18 @@ export default function LivePlayer({
}
}}
>
{((showStillWithoutActivity && !liveReady) || liveReady) && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
{cameraEnabled &&
((showStillWithoutActivity && !liveReady) || liveReady) && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
{player}
{!offline && !showStillWithoutActivity && !liveReady && (
<ActivityIndicator />
)}
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />}
{((showStillWithoutActivity && !liveReady) || liveReady) &&
objects.length > 0 && (
@ -344,7 +396,9 @@ export default function LivePlayer({
<div
className={cn(
"absolute inset-0 w-full",
showStillWithoutActivity && !liveReady ? "visible" : "invisible",
showStillWithoutActivity && !liveReady && !isReEnabling
? "visible"
: "invisible",
)}
>
<AutoUpdatingCameraImage
@ -371,6 +425,17 @@ export default function LivePlayer({
</div>
)}
{!cameraEnabled && (
<div className="relative flex h-full w-full items-center justify-center">
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
<LuVideoOff className="mb-2 size-8 md:size-10" />
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
Camera is disabled
</p>
</div>
</div>
)}
<div className="absolute right-2 top-2">
{autoLive &&
!offline &&
@ -378,7 +443,7 @@ export default function LivePlayer({
((showStillWithoutActivity && !liveReady) || liveReady) && (
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
)}
{offline && showStillWithoutActivity && (
{((offline && showStillWithoutActivity) || !cameraEnabled) && (
<Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
>

View File

@ -107,7 +107,7 @@ export default function MotionMaskEditPane({
polygon: z.object({ name: z.string(), isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
message: t("ui.form.message.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
@ -165,7 +165,16 @@ export default function MotionMaskEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
`${polygon.name || "Motion Mask"} has been saved. Restart Frigate to apply changes.`,
polygon.name
? t(
"ui.settingView.masksAndZonesSettings.motionMasks.toast.success",
{
polygonName: polygon.name,
},
)
: t(
"ui.settingView.masksAndZonesSettings.motionMasks.toast.success.noName",
),
{
position: "top-center",
},

View File

@ -109,7 +109,7 @@ export default function ObjectMaskEditPane({
polygon: z.object({ isFinished: z.boolean(), name: z.string() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
message: t("ui.form.message.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
@ -197,21 +197,37 @@ export default function ObjectMaskEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
`${polygon.name || "Object Mask"} has been saved. Restart Frigate to apply changes.`,
polygon.name
? t(
"ui.settingView.masksAndZonesSettings.objectMasks.toast.success",
{
polygonName: polygon.name,
},
)
: t(
"ui.settingView.masksAndZonesSettings.objectMasks.toast.success.noName",
),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("ui.toast.save.error", {
errorMessage: res.statusText,
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
t("ui.toast.save.error", {
errorMessage: error.response.data.message,
}),
{ position: "top-center" },
);
})
@ -290,7 +306,6 @@ export default function ObjectMaskEditPane({
<Trans>
ui.settingView.masksAndZonesSettings.objectMasks.clickDrawPolygon
</Trans>
Click to draw a polygon on the image.
</div>
<Separator className="my-3 bg-secondary" />
@ -360,7 +375,7 @@ export default function ObjectMaskEditPane({
aria-label="Cancel"
onClick={onCancel}
>
Cancel
<Trans>ui.cancel</Trans>
</Button>
<Button
variant="select"
@ -372,10 +387,12 @@ export default function ObjectMaskEditPane({
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>
<Trans>ui.saving</Trans>
</span>
</div>
) : (
"Save"
<Trans>ui.save</Trans>
)}
</Button>
</div>

View File

@ -36,6 +36,7 @@ import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { buttonVariants } from "../ui/button";
import { Trans } from "react-i18next";
type PolygonItemProps = {
polygon: Polygon;
@ -314,7 +315,9 @@ export default function PolygonItem({
}}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
<TooltipContent>
<Trans>ui.edit</Trans>
</TooltipContent>
</Tooltip>
<Tooltip>
@ -327,7 +330,9 @@ export default function PolygonItem({
onClick={() => handleCopyCoordinates(index)}
/>
</TooltipTrigger>
<TooltipContent>Copy coordinates</TooltipContent>
<TooltipContent>
<Trans>ui.copyCoordinates</Trans>
</TooltipContent>
</Tooltip>
<Tooltip>
@ -341,7 +346,9 @@ export default function PolygonItem({
onClick={() => !isLoading && setDeleteDialogOpen(true)}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
<TooltipContent>
<Trans>ui.delete</Trans>
</TooltipContent>
</Tooltip>
</div>
)}

View File

@ -70,7 +70,7 @@ export default function ZoneEditPane({
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
@ -104,7 +104,9 @@ export default function ZoneEditPane({
name: z
.string()
.min(2, {
message: "Zone name must be at least 2 characters.",
message: t(
"ui.form.message.zoneName.error.mustBeAtLeastTwoCharacters",
),
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
@ -112,7 +114,9 @@ export default function ZoneEditPane({
return !cameras.map((cam) => cam.name).includes(value);
},
{
message: "Zone name must not be the name of a camera.",
message: t(
"ui.form.message.zoneName.error.mustNotBeSameWithCamera",
),
},
)
.refine(
@ -125,7 +129,7 @@ export default function ZoneEditPane({
return !otherPolygonNames.includes(value);
},
{
message: "Zone name already exists on this camera.",
message: t("ui.form.message.zoneName.error.alreadyExists"),
},
)
.refine(
@ -133,27 +137,29 @@ export default function ZoneEditPane({
return !value.includes(".");
},
{
message: "Zone name must not contain a period.",
message: t("ui.form.message.zoneName.error.mustNotContainPeriod"),
},
)
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
message: "Zone name has an illegal character.",
message: t("ui.form.message.zoneName.error.hasIllegalCharacter"),
}),
inertia: z.coerce
.number()
.min(1, {
message: "Inertia must be above 0.",
message: t("ui.form.message.inertia.error.mustBeAboveZero"),
})
.or(z.literal("")),
loitering_time: z.coerce
.number()
.min(0, {
message: "Loitering time must be greater than or equal to 0.",
message: t(
"ui.form.message.loiteringTime.error.mustBeGreaterOrEqualZero",
),
})
.optional()
.or(z.literal("")),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
message: t("ui.form.message.polygonDrawing.error.mustBeFinished"),
}),
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
@ -162,28 +168,28 @@ export default function ZoneEditPane({
lineA: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
message: t("ui.form.message.distance.error"),
})
.optional()
.or(z.literal("")),
lineB: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
message: t("ui.form.message.distance.error"),
})
.optional()
.or(z.literal("")),
lineC: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
message: t("ui.form.message.distance.error"),
})
.optional()
.or(z.literal("")),
lineD: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
message: t("ui.form.message.distance.error"),
})
.optional()
.or(z.literal("")),
@ -203,7 +209,7 @@ export default function ZoneEditPane({
return true;
},
{
message: "All distance fields must be filled to use speed estimation.",
message: t("ui.form.message.distance.error.mustBeFilled"),
path: ["speedEstimation"],
},
)
@ -217,8 +223,9 @@ export default function ZoneEditPane({
);
},
{
message:
"Zones with loitering times greater than 0 should not be used with speed estimation.",
message: t(
"ui.settingView.masksAndZonesSettings.zones.speedThreshold.toast.error.loiteringTimeError",
),
path: ["loitering_time"],
},
);
@ -257,7 +264,9 @@ export default function ZoneEditPane({
polygon.points.length !== 4
) {
toast.error(
"Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
t(
"ui.settingView.masksAndZonesSettings.zones.speedThreshold.toast.error.pointLengthError",
),
);
form.setValue("speedEstimation", false);
}
@ -321,7 +330,7 @@ export default function ZoneEditPane({
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch (error) {
toast.error(`Failed to save config changes.`, {
toast.error(t("ui.toast.save.error.noMessage"), {
position: "top-center",
});
return;
@ -403,21 +412,28 @@ export default function ZoneEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
`Zone (${zoneName}) has been saved. Restart Frigate to apply changes.`,
t("ui.settingView.masksAndZonesSettings.zones.toast.success", {
zoneName,
}),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("ui.toast.save.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
t("ui.toast.save.error", {
errorMessage: error.response.data.message,
}),
{ position: "top-center" },
);
})
@ -454,7 +470,7 @@ export default function ZoneEditPane({
useEffect(() => {
document.title = t(
"ui.settingView.masksAndZonesSettings.zone.documentTitle",
"ui.settingView.masksAndZonesSettings.zones.documentTitle",
);
}, []);
@ -467,19 +483,19 @@ export default function ZoneEditPane({
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length
? t("ui.settingView.masksAndZonesSettings.zone.edit")
: t("ui.settingView.masksAndZonesSettings.zone.add")}
? t("ui.settingView.masksAndZonesSettings.zones.edit")
: t("ui.settingView.masksAndZonesSettings.zones.add")}
</Heading>
<div className="my-2 text-sm text-muted-foreground">
<p>
<Trans>ui.settingView.masksAndZonesSettings.zone.desc</Trans>
<Trans>ui.settingView.masksAndZonesSettings.zones.desc</Trans>
</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="my-2 flex w-full flex-row justify-between text-sm">
<div className="my-1 inline-flex">
{t("ui.settingView.masksAndZonesSettings.zone.point", {
{t("ui.settingView.masksAndZonesSettings.zones.point", {
count: polygons[activePolygonIndex].points.length,
})}
@ -498,7 +514,7 @@ export default function ZoneEditPane({
)}
<div className="mb-3 text-sm text-muted-foreground">
<Trans>
ui.settingView.masksAndZonesSettings.zone.clickDrawPolygon
ui.settingView.masksAndZonesSettings.zones.clickDrawPolygon
</Trans>
</div>
@ -512,20 +528,20 @@ export default function ZoneEditPane({
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>ui.settingView.masksAndZonesSettings.zone.name</Trans>
<Trans>ui.settingView.masksAndZonesSettings.zones.name</Trans>
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder={t(
"ui.settingView.masksAndZonesSettings.zone.name.inputPlaceHolder",
"ui.settingView.masksAndZonesSettings.zones.name.inputPlaceHolder",
)}
{...field}
/>
</FormControl>
<FormDescription>
<Trans>
ui.settingView.masksAndZonesSettings.zone.name.tips
ui.settingView.masksAndZonesSettings.zones.name.tips
</Trans>
</FormDescription>
<FormMessage />
@ -540,7 +556,7 @@ export default function ZoneEditPane({
<FormItem>
<FormLabel>
<Trans>
ui.settingView.masksAndZonesSettings.zone.inertia
ui.settingView.masksAndZonesSettings.zones.inertia
</Trans>
</FormLabel>
<FormControl>
@ -552,7 +568,7 @@ export default function ZoneEditPane({
</FormControl>
<FormDescription>
<Trans>
ui.settingView.masksAndZonesSettings.zone.inertia.desc
ui.settingView.masksAndZonesSettings.zones.inertia.desc
</Trans>
</FormDescription>
<FormMessage />
@ -567,7 +583,7 @@ export default function ZoneEditPane({
<FormItem>
<FormLabel>
<Trans>
ui.settingView.masksAndZonesSettings.zone.loiteringTime
ui.settingView.masksAndZonesSettings.zones.loiteringTime
</Trans>
</FormLabel>
<FormControl>
@ -579,7 +595,7 @@ export default function ZoneEditPane({
</FormControl>
<FormDescription>
<Trans>
ui.settingView.masksAndZonesSettings.zone.loiteringTime.desc
ui.settingView.masksAndZonesSettings.zones.loiteringTime.desc
</Trans>
</FormDescription>
<FormMessage />
@ -589,11 +605,11 @@ export default function ZoneEditPane({
<Separator className="my-2 flex bg-secondary" />
<FormItem>
<FormLabel>
<Trans>ui.settingView.masksAndZonesSettings.zone.objects</Trans>
<Trans>ui.settingView.masksAndZonesSettings.zones.objects</Trans>
</FormLabel>
<FormDescription>
<Trans>
ui.settingView.masksAndZonesSettings.zone.objects.desc
ui.settingView.masksAndZonesSettings.zones.objects.desc
</Trans>
</FormDescription>
<ZoneObjectSelector
@ -629,7 +645,7 @@ export default function ZoneEditPane({
htmlFor="allLabels"
>
<Trans>
ui.settingView.masksAndZonesSettings.zone.speedEstimation
ui.settingView.masksAndZonesSettings.zones.speedEstimation
</Trans>
</FormLabel>
<Switch
@ -643,7 +659,7 @@ export default function ZoneEditPane({
) {
toast.error(
t(
"ui.settingView.masksAndZonesSettings.zone.speedEstimation.pointLengthError",
"ui.settingView.masksAndZonesSettings.zones.speedEstimation.pointLengthError",
),
);
return;
@ -654,7 +670,7 @@ export default function ZoneEditPane({
if (checked && loiteringTime && loiteringTime > 0) {
toast.error(
t(
"ui.settingView.masksAndZonesSettings.zone.speedEstimation.loiteringTimeError",
"ui.settingView.masksAndZonesSettings.zones.speedEstimation.loiteringTimeError",
),
);
}
@ -666,7 +682,7 @@ export default function ZoneEditPane({
</div>
<FormDescription>
<Trans>
ui.settingView.masksAndZonesSettings.zone.speedEstimation.desc
ui.settingView.masksAndZonesSettings.zones.speedEstimation.desc
</Trans>
</FormDescription>
<FormMessage />
@ -779,8 +795,16 @@ export default function ZoneEditPane({
render={({ field }) => (
<FormItem>
<FormLabel>
Speed Threshold (
{config?.ui.unit_system == "imperial" ? "mph" : "kph"})
<Trans
values={{
unit:
config?.ui.unit_system == "imperial"
? t("ui.unit.speed.mph")
: t("ui.unit.speed.kph"),
}}
>
ui.settingView.masksAndZonesSettings.zones.speedThreshold
</Trans>
</FormLabel>
<FormControl>
<Input
@ -789,8 +813,9 @@ export default function ZoneEditPane({
/>
</FormControl>
<FormDescription>
Specifies a minimum speed for objects to be considered
in this zone.
<Trans>
ui.settingView.masksAndZonesSettings.zones.speedThreshold.desc
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
@ -814,7 +839,7 @@ export default function ZoneEditPane({
aria-label="Cancel"
onClick={onCancel}
>
Cancel
<Trans>ui.cancel</Trans>
</Button>
<Button
variant="select"
@ -826,10 +851,12 @@ export default function ZoneEditPane({
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>
<Trans>ui.saving</Trans>
</span>
</div>
) : (
"Save"
<Trans>ui.save</Trans>
)}
</Button>
</div>
@ -909,7 +936,7 @@ export function ZoneObjectSelector({
<div className="scrollbar-container h-auto overflow-y-auto overflow-x-hidden">
<div className="my-2.5 flex items-center justify-between">
<Label className="cursor-pointer text-primary" htmlFor="allLabels">
<Trans>ui.settingView.masksAndZonesSettings.zone.allObjects</Trans>
<Trans>ui.settingView.masksAndZonesSettings.zones.allObjects</Trans>
</Label>
<Switch
className="ml-1"

View File

@ -1,4 +1,5 @@
import {
useEnabledState,
useFrigateEvents,
useInitialCameraState,
useMotionActivity,
@ -15,6 +16,7 @@ import useSWR from "swr";
import { getAttributeLabels } from "@/utils/iconUtil";
type useCameraActivityReturn = {
enabled?: boolean;
activeTracking: boolean;
activeMotion: boolean;
objects: ObjectType[];
@ -56,6 +58,7 @@ export function useCameraActivity(
[objects],
);
const { payload: cameraEnabled } = useEnabledState(camera.name);
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: event } = useFrigateEvents();
const updatedEvent = useDeepMemo(event);
@ -145,12 +148,17 @@ export function useCameraActivity(
return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60;
}, [camera, stats]);
const isCameraEnabled = cameraEnabled ? cameraEnabled === "ON" : undefined;
return {
activeTracking: hasActiveObjects,
activeMotion: detectingMotion
? detectingMotion === "ON"
: updatedCameraState?.motion === true,
objects,
enabled: isCameraEnabled,
activeTracking: isCameraEnabled ? hasActiveObjects : false,
activeMotion: isCameraEnabled
? detectingMotion
? detectingMotion === "ON"
: updatedCameraState?.motion === true
: false,
objects: isCameraEnabled ? objects : [],
offline,
};
}

View File

@ -106,12 +106,14 @@ function Live() {
) {
const group = config.camera_groups[cameraGroup];
return Object.values(config.cameras)
.filter((conf) => conf.enabled && group.cameras.includes(conf.name))
.filter(
(conf) => conf.enabled_in_config && group.cameras.includes(conf.name),
)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config, cameraGroup]);

View File

@ -40,6 +40,7 @@ import UiSettingsView from "@/views/settings/UiSettingsView";
import { t } from "i18next";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
const allSettingsViews = [
"uiSettings",
@ -72,12 +73,33 @@ export default function Settings() {
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const { payload: allCameraStates } = useInitialCameraState(
cameras.length > 0 ? cameras[0].name : "",
true,
);
const cameraEnabledStates = useMemo(() => {
const states: Record<string, boolean> = {};
if (allCameraStates) {
Object.entries(allCameraStates).forEach(([camName, state]) => {
states[camName] = state.config?.enabled ?? false;
});
}
// fallback to config if ws data isnt available yet
cameras.forEach((cam) => {
if (!(cam.name in states)) {
states[cam.name] = cam.enabled;
}
});
return states;
}, [allCameraStates, cameras]);
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
const handleDialog = useCallback(
@ -92,10 +114,25 @@ export default function Settings() {
);
useEffect(() => {
if (cameras.length > 0 && selectedCamera === "") {
setSelectedCamera(cameras[0].name);
if (cameras.length > 0) {
if (!selectedCamera) {
// Set to first enabled camera initially if no selection
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
setSelectedCamera(firstEnabledCamera.name);
} else if (
!cameraEnabledStates[selectedCamera] &&
page !== "camera settings"
) {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
if (firstEnabledCamera.name !== selectedCamera) {
setSelectedCamera(firstEnabledCamera.name);
}
}
}
}, [cameras, selectedCamera]);
}, [cameras, selectedCamera, cameraEnabledStates, page]);
useEffect(() => {
if (tabsRef.current) {
@ -180,6 +217,8 @@ export default function Settings() {
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</div>
)}
@ -247,17 +286,21 @@ type CameraSelectButtonProps = {
allCameras: CameraConfig[];
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
cameraEnabledStates: Record<string, boolean>;
currentPage: SettingsType;
};
function CameraSelectButton({
allCameras,
selectedCamera,
setSelectedCamera,
cameraEnabledStates,
currentPage,
}: CameraSelectButtonProps) {
const [open, setOpen] = useState(false);
if (!allCameras.length) {
return;
return null;
}
const trigger = (
@ -286,19 +329,24 @@ function CameraSelectButton({
)}
<div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1">
<div className="flex flex-col gap-2.5">
{allCameras.map((item) => (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
/>
))}
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "camera settings";
return (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
disabled={!isEnabled && !isCameraSettingsPage}
/>
);
})}
</div>
</div>
</>

View File

@ -57,6 +57,7 @@ export interface CameraConfig {
width: number;
};
enabled: boolean;
enabled_in_config: boolean;
ffmpeg: {
global_args: string[];
hwaccel_args: string;

View File

@ -52,6 +52,7 @@ export type ObjectType = {
};
export interface FrigateCameraState {
enabled: boolean;
motion: boolean;
objects: ObjectType[];
}

View File

@ -397,10 +397,12 @@ export default function DraggableGridLayout({
const initialVolumeStates: VolumeState = {};
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
if (groupSettings) {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
});
setAudioStates(initialAudioStates);

View File

@ -2,6 +2,7 @@ import {
useAudioState,
useAutotrackingState,
useDetectState,
useEnabledState,
usePtzCommand,
useRecordingsState,
useSnapshotsState,
@ -82,6 +83,8 @@ import {
LuHistory,
LuInfo,
LuPictureInPicture,
LuPower,
LuPowerOff,
LuVideo,
LuVideoOff,
LuX,
@ -187,6 +190,10 @@ export default function LiveCameraView({
);
}, [cameraMetadata]);
// camera enabled state
const { payload: enabledState } = useEnabledState(camera.name);
const cameraEnabled = enabledState === "ON";
// click overlay for ptzs
const [clickOverlay, setClickOverlay] = useState(false);
@ -482,6 +489,7 @@ export default function LiveCameraView({
setPip(false);
}
}}
disabled={!cameraEnabled}
/>
)}
{supports2WayTalk && (
@ -493,11 +501,11 @@ export default function LiveCameraView({
title={`${mic ? "Disable" : "Enable"} Two Way Talk`}
onClick={() => {
setMic(!mic);
// Turn on audio when enabling the mic if audio is currently off
if (!mic && !audio) {
setAudio(true);
}
}}
disabled={!cameraEnabled}
/>
)}
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
@ -508,6 +516,7 @@ export default function LiveCameraView({
isActive={audio ?? false}
title={`${audio ? "Disable" : "Enable"} Camera Audio`}
onClick={() => setAudio(!audio)}
disabled={!cameraEnabled}
/>
)}
<FrigateCameraFeatures
@ -529,6 +538,7 @@ export default function LiveCameraView({
setLowBandwidth={setLowBandwidth}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
cameraEnabled={cameraEnabled}
/>
</div>
</TooltipProvider>
@ -925,6 +935,7 @@ type FrigateCameraFeaturesProps = {
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
cameraEnabled: boolean;
};
function FrigateCameraFeatures({
camera,
@ -943,10 +954,14 @@ function FrigateCameraFeatures({
setLowBandwidth,
supportsAudioOutput,
supports2WayTalk,
cameraEnabled,
}: FrigateCameraFeaturesProps) {
const { payload: detectState, send: sendDetect } = useDetectState(
camera.name,
);
const { payload: enabledState, send: sendEnabled } = useEnabledState(
camera.name,
);
const { payload: recordState, send: sendRecord } = useRecordingsState(
camera.name,
);
@ -1054,6 +1069,15 @@ function FrigateCameraFeatures({
if (isDesktop || isTablet) {
return (
<>
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={false}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
@ -1065,6 +1089,7 @@ function FrigateCameraFeatures({
: t("ui.live.detect.enable")
}
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
@ -1077,6 +1102,7 @@ function FrigateCameraFeatures({
: t("ui.live.recording.enable")
}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
@ -1089,6 +1115,7 @@ function FrigateCameraFeatures({
: t("ui.live.snapshots.enable")
}
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
{audioDetectEnabled && (
<CameraFeatureToggle
@ -1102,6 +1129,7 @@ function FrigateCameraFeatures({
: t("ui.live.audioDetect.enable")
}
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
)}
{autotrackingEnabled && (
@ -1118,6 +1146,7 @@ function FrigateCameraFeatures({
onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
disabled={!cameraEnabled}
/>
)}
<CameraFeatureToggle
@ -1132,6 +1161,7 @@ function FrigateCameraFeatures({
"ui.live.manualRecording." + (isRecording ? "stop" : "start"),
)}
onClick={handleEventButtonClick}
disabled={!cameraEnabled}
/>
<DropdownMenu modal={false}>
@ -1406,6 +1436,13 @@ function FrigateCameraFeatures({
</DrawerTrigger>
<DrawerContent className="rounded-2xl px-2 py-4">
<div className="mt-2 flex flex-col gap-2">
<FilterSwitch
label="Camera Enabled"
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label="Object Detection"
isChecked={detectState == "ON"}

View File

@ -269,10 +269,12 @@ export default function LiveDashboardView({
const initialVolumeStates: VolumeState = {};
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
if (groupSettings) {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
});
setAudioStates(initialAudioStates);

View File

@ -31,7 +31,7 @@ import { Trans } from "react-i18next";
import { t } from "i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useAlertsState, useDetectionsState } from "@/api/ws";
import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
type CameraSettingsViewProps = {
selectedCamera: string;
@ -110,6 +110,8 @@ export default function CameraSettingsView({
const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones");
const { payload: enabledState, send: sendEnabled } =
useEnabledState(selectedCamera);
const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } =
@ -158,21 +160,28 @@ export default function CameraSettingsView({
.then((res) => {
if (res.status === 200) {
toast.success(
`Review classification configuration has been saved. Restart Frigate to apply changes.`,
t(
"ui.settingView.cameraSettings.reviewClassification.toast.success",
),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("ui.toast.save.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
t("ui.toast.save.error", {
errorMessage: error.response.data.message,
}),
{ position: "top-center" },
);
})
@ -254,6 +263,30 @@ export default function CameraSettingsView({
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans>ui.settingView.cameraSettings.streams</Trans>
</Heading>
<div className="flex flex-row items-center">
<Switch
id="camera-enabled"
className="mr-3"
checked={enabledState === "ON"}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="camera-enabled">
<Trans>ui.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans>ui.settingView.cameraSettings.streams.desc</Trans>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans>ui.settingView.cameraSettings.review</Trans>
</Heading>
@ -349,7 +382,9 @@ export default function CameraSettingsView({
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
Alerts{" "}
<Trans>
ui.settingView.cameraSettings.review.alerts
</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</FormLabel>
<FormDescription>
@ -452,12 +487,16 @@ export default function CameraSettingsView({
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
Detections{" "}
<Trans>
ui.settingView.cameraSettings.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</FormLabel>
{selectDetections && (
<FormDescription>
Select zones for Detections
<Trans>
ui.settingView.cameraSettings.reviewClassification.selectDetectionsZones
</Trans>
</FormDescription>
)}
</div>
@ -520,7 +559,9 @@ export default function CameraSettingsView({
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Limit detections to specific zones
<Trans>
ui.settingView.cameraSettings.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>

View File

@ -492,7 +492,7 @@ export default function MasksAndZonesView({
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
<Trans>
ui.settingView.masksAndZonesSettings.zone
ui.settingView.masksAndZonesSettings.zones
</Trans>
</div>
</HoverCardTrigger>
@ -500,7 +500,7 @@ export default function MasksAndZonesView({
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans>
ui.settingView.masksAndZonesSettings.zone.desc
ui.settingView.masksAndZonesSettings.zones.desc
</Trans>
</p>
<div className="flex items-center text-primary">
@ -511,7 +511,7 @@ export default function MasksAndZonesView({
className="inline"
>
<Trans>
ui.settingView.masksAndZonesSettings.zone.desc.documentation
ui.settingView.masksAndZonesSettings.zones.desc.documentation
</Trans>{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
@ -535,7 +535,7 @@ export default function MasksAndZonesView({
</TooltipTrigger>
<TooltipContent>
<Trans>
ui.settingView.masksAndZonesSettings.zone.add
ui.settingView.masksAndZonesSettings.zones.add
</Trans>
</TooltipContent>
</Tooltip>

View File

@ -22,6 +22,7 @@ import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { Trans } from "react-i18next";
import { t } from "i18next";
type MotionTunerViewProps = {
selectedCamera: string;
@ -118,20 +119,28 @@ export default function MotionTunerView({
)
.then((res) => {
if (res.status === 200) {
toast.success("Motion settings have been saved.", {
position: "top-center",
});
toast.success(
t("ui.settingView.motionDetectionTuner.toast.success"),
{
position: "top-center",
},
);
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("ui.toast.save.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
t("ui.toast.save.error", {
errorMessage: error.response.data.message,
}),
{ position: "top-center" },
);
})

View File

@ -82,7 +82,7 @@ export default function NotificationView({
return Object.values(config.cameras)
.filter(
(conf) =>
conf.enabled &&
conf.enabled_in_config &&
conf.notifications &&
conf.notifications.enabled_in_config,
)

View File

@ -94,20 +94,25 @@ export default function ExploreSettingsView({
)
.then((res) => {
if (res.status === 200) {
toast.success("Explore settings have been saved.", {
toast.success(t("ui.settingView.exploreSettings.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("ui.toast.save.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
t("ui.toast.save.error", {
errorMessage: error.response.data.message,
}),
{ position: "top-center" },
);
})