diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f472beb1..dcf3070b54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: name: AMD64 Build steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Set up QEMU and Buildx @@ -47,7 +47,7 @@ jobs: name: ARM Build steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Set up QEMU and Buildx @@ -77,42 +77,12 @@ jobs: rpi.tags=${{ steps.setup.outputs.image-name }}-rpi *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64 *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max - jetson_jp5_build: - if: false - runs-on: ubuntu-22.04 - name: Jetson Jetpack 5 - steps: - - name: Check out code - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Set up QEMU and Buildx - id: setup - uses: ./.github/actions/setup - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push TensorRT (Jetson, Jetpack 5) - env: - ARCH: arm64 - BASE_IMAGE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime - SLIM_BASE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime - TRT_BASE: nvcr.io/nvidia/l4t-tensorrt:r8.5.2-runtime - uses: docker/bake-action@v6 - with: - source: . - push: true - targets: tensorrt - files: docker/tensorrt/trt.hcl - set: | - tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp5 - *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5 - *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp5,mode=max jetson_jp6_build: runs-on: ubuntu-22.04-arm name: Jetson Jetpack 6 steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Set up QEMU and Buildx @@ -143,7 +113,7 @@ jobs: - amd64_build steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Set up QEMU and Buildx @@ -185,7 +155,7 @@ jobs: - arm64_build steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Set up QEMU and Buildx @@ -203,6 +173,31 @@ jobs: set: | rk.tags=${{ steps.setup.outputs.image-name }}-rk *.cache-from=type=gha + synaptics_build: + runs-on: ubuntu-22.04-arm + name: Synaptics Build + needs: + - arm64_build + steps: + - name: Check out code + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Set up QEMU and Buildx + id: setup + uses: ./.github/actions/setup + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Synaptics build + uses: docker/bake-action@v6 + with: + source: . + push: true + targets: synaptics + files: docker/synaptics/synaptics.hcl + set: | + synaptics.tags=${{ steps.setup.outputs.image-name }}-synaptics + *.cache-from=type=gha # The majority of users running arm64 are rpi users, so the rpi # build should be the primary arm64 image assemble_default_build: @@ -217,7 +212,7 @@ jobs: with: string: ${{ github.repository }} - name: Log in to the Container registry - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 02fde5861e..9f6f53523d 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -4,43 +4,19 @@ on: pull_request: paths-ignore: - "docs/**" - - ".github/**" + - ".github/*.yml" + - ".github/DISCUSSION_TEMPLATE/**" + - ".github/ISSUE_TEMPLATE/**" env: DEFAULT_PYTHON: 3.11 jobs: - build_devcontainer: - runs-on: ubuntu-latest - name: Build Devcontainer - # The Dockerfile contains features that requires buildkit, and since the - # devcontainer cli uses docker-compose to build the image, the only way to - # ensure docker-compose uses buildkit is to explicitly enable it. - env: - DOCKER_BUILDKIT: "1" - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: actions/setup-node@master - with: - node-version: 20.x - - name: Install devcontainer cli - run: npm install --global @devcontainers/cli - - name: Build devcontainer - run: devcontainer build --workspace-folder . - # It would be nice to also test the following commands, but for some - # reason they don't work even though in VS Code devcontainer works. - # - name: Start devcontainer - # run: devcontainer up --workspace-folder . - # - name: Run devcontainer scripts - # run: devcontainer run-user-commands --workspace-folder . - web_lint: name: Web - Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-node@master @@ -56,7 +32,7 @@ jobs: name: Web - Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-node@master @@ -76,7 +52,7 @@ jobs: name: Python Checks steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -99,16 +75,21 @@ jobs: name: Python Tests steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build - run: make - - name: Run mypy - run: docker run --rm --entrypoint=python3 frigate:latest -u -m mypy --config-file frigate/mypy.ini frigate - - name: Run tests - run: docker run --rm --entrypoint=python3 frigate:latest -u -m unittest + - uses: actions/setup-node@master + with: + node-version: 20.x + - name: Install devcontainer cli + run: npm install --global @devcontainers/cli + - name: Build devcontainer + env: + DOCKER_BUILDKIT: "1" + run: devcontainer build --workspace-folder . + - name: Start devcontainer + run: devcontainer up --workspace-folder . + - name: Run mypy in devcontainer + run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate" + - name: Run unit tests in devcontainer + run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c19ca6cb2..cf1079d22a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - id: lowercaseRepo @@ -18,7 +18,7 @@ jobs: with: string: ${{ github.repository }} - name: Log in to the Container registry - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/Makefile b/Makefile index fa692b681b..2baac5aada 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ default_target: local COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) -VERSION = 0.16.2 +VERSION = 0.17.0 IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) BOARDS= #Initialized empty @@ -20,6 +20,12 @@ local: version --tag frigate:latest \ --load +debug: version + docker buildx build --target=frigate --file docker/main/Dockerfile . \ + --build-arg DEBUG=true \ + --tag frigate:latest \ + --load + amd64: docker buildx build --target=frigate --file docker/main/Dockerfile . \ --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) \ diff --git a/benchmark.py b/benchmark.py index 1f39302a7a..46adc59df9 100755 --- a/benchmark.py +++ b/benchmark.py @@ -4,13 +4,13 @@ from statistics import mean import numpy as np -import frigate.util as util from frigate.config import DetectorTypeEnum from frigate.object_detection.base import ( ObjectDetectProcess, RemoteObjectDetector, load_labels, ) +from frigate.util.process import FrigateProcess my_frame = np.expand_dims(np.full((300, 300, 3), 1, np.uint8), axis=0) labels = load_labels("/labelmap.txt") @@ -91,7 +91,7 @@ edgetpu_process_2 = ObjectDetectProcess( ) for x in range(0, 10): - camera_process = util.Process( + camera_process = FrigateProcess( target=start, args=(x, 300, detection_queue, events[str(x)]) ) camera_process.daemon = True diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 1cf752ed54..8dbf5ff869 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -55,7 +55,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \ FROM scratch AS go2rtc ARG TARGETARCH WORKDIR /rootfs/usr/local/go2rtc/bin -ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${TARGETARCH}" go2rtc +ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.10/go2rtc_linux_${TARGETARCH}" go2rtc FROM wget AS tempio ARG TARGETARCH @@ -148,6 +148,7 @@ RUN --mount=type=bind,source=docker/main/install_s6_overlay.sh,target=/deps/inst FROM base AS wheels ARG DEBIAN_FRONTEND ARG TARGETARCH +ARG DEBUG=false # Use a separate container to build wheels to prevent build dependencies in final image RUN apt-get -qq update \ @@ -177,6 +178,8 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ && python3 get-pip.py "pip" COPY docker/main/requirements.txt /requirements.txt +COPY docker/main/requirements-dev.txt /requirements-dev.txt + RUN pip3 install -r /requirements.txt # Build pysqlite3 from source @@ -184,7 +187,10 @@ COPY docker/main/build_pysqlite3.sh /build_pysqlite3.sh RUN /build_pysqlite3.sh COPY docker/main/requirements-wheels.txt /requirements-wheels.txt -RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt +RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt && \ + if [ "$DEBUG" = "true" ]; then \ + pip3 wheel --wheel-dir=/wheels -r /requirements-dev.txt; \ + fi # Install HailoRT & Wheels RUN --mount=type=bind,source=docker/main/install_hailort.sh,target=/deps/install_hailort.sh \ @@ -206,6 +212,7 @@ COPY docker/main/rootfs/ / # Frigate deps (ffmpeg, python, nginx, go2rtc, s6-overlay, etc) FROM slim-base AS deps ARG TARGETARCH +ARG BASE_IMAGE ARG DEBIAN_FRONTEND # http://stackoverflow.com/questions/48162574/ddg#49462622 @@ -224,9 +231,15 @@ 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 NumPy to ignore getlimits warning +ENV PYTHONWARNINGS="ignore:::numpy.core.getlimits" + # Set HailoRT to disable logging ENV HAILORT_LOGGER_PATH=NONE +# TensorFlow error only +ENV TF_CPP_MIN_LOG_LEVEL=3 + ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PATH}" # Install dependencies @@ -243,6 +256,10 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \ pip3 install -U /deps/wheels/*.whl +# Install MemryX runtime (requires libgomp (OpenMP) in the final docker image) +RUN --mount=type=bind,source=docker/main/install_memryx.sh,target=/deps/install_memryx.sh \ + bash -c "bash /deps/install_memryx.sh" + COPY --from=deps-rootfs / / RUN ldconfig diff --git a/docker/main/install_deps.sh b/docker/main/install_deps.sh index 5dea3c8741..c4fe56f03a 100755 --- a/docker/main/install_deps.sh +++ b/docker/main/install_deps.sh @@ -19,7 +19,8 @@ apt-get -qq install --no-install-recommends -y \ nethogs \ libgl1 \ libglib2.0-0 \ - libusb-1.0.0 + libusb-1.0.0 \ + libgomp1 # memryx detector update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 @@ -31,6 +32,18 @@ unset DEBIAN_FRONTEND yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive rm /tmp/libedgetpu1-max.deb +# install mesa-teflon-delegate from bookworm-backports +# Only available for arm64 at the moment +if [[ "${TARGETARCH}" == "arm64" ]]; then + if [[ "${BASE_IMAGE}" == *"nvcr.io/nvidia/tensorrt"* ]]; then + echo "Info: Skipping apt-get commands because BASE_IMAGE includes 'nvcr.io/nvidia/tensorrt' for arm64." + else + echo "deb http://deb.debian.org/debian bookworm-backports main" | tee /etc/apt/sources.list.d/bookworm-backbacks.list + apt-get -qq update + apt-get -qq install --no-install-recommends --no-install-suggests -y mesa-teflon-delegate/bookworm-backports + fi +fi + # ffmpeg -> amd64 if [[ "${TARGETARCH}" == "amd64" ]]; then mkdir -p /usr/lib/ffmpeg/5.0 @@ -78,11 +91,33 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu jammy client" | tee /etc/apt/sources.list.d/intel-gpu-jammy.list apt-get -qq update apt-get -qq install --no-install-recommends --no-install-suggests -y \ - intel-opencl-icd=24.35.30872.31-996~22.04 intel-level-zero-gpu=1.3.29735.27-914~22.04 intel-media-va-driver-non-free=24.3.3-996~22.04 \ - libmfx1=23.2.2-880~22.04 libmfxgen1=24.2.4-914~22.04 libvpl2=1:2.13.0.0-996~22.04 + intel-media-va-driver-non-free libmfx1 libmfxgen1 libvpl2 + + apt-get -qq install -y ocl-icd-libopencl1 rm -f /usr/share/keyrings/intel-graphics.gpg rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list + + # install legacy and standard intel icd and level-zero-gpu + # see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info + # needed core package + wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/libigdgmm12_22.5.5_amd64.deb + dpkg -i libigdgmm12_22.5.5_amd64.deb + rm libigdgmm12_22.5.5_amd64.deb + + # legacy packages + wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb + wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb + # standard packages + wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/intel-opencl-icd_24.52.32224.5_amd64.deb + wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/intel-level-zero-gpu_1.6.32224.5_amd64.deb + wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.5.6/intel-igc-opencl-2_2.5.6+18417_amd64.deb + wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.5.6/intel-igc-core-2_2.5.6+18417_amd64.deb + + dpkg -i *.deb + rm *.deb fi if [[ "${TARGETARCH}" == "arm64" ]]; then diff --git a/docker/main/install_memryx.sh b/docker/main/install_memryx.sh new file mode 100644 index 0000000000..f96181ae02 --- /dev/null +++ b/docker/main/install_memryx.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -e + +# Download the MxAccl for Frigate github release +wget https://github.com/memryx/mx_accl_frigate/archive/refs/heads/main.zip -O /tmp/mxaccl.zip +unzip /tmp/mxaccl.zip -d /tmp +mv /tmp/mx_accl_frigate-main /opt/mx_accl_frigate +rm /tmp/mxaccl.zip + +# Install Python dependencies +pip3 install -r /opt/mx_accl_frigate/freeze + +# Link the Python package dynamically +SITE_PACKAGES=$(python3 -c "import site; print(site.getsitepackages()[0])") +ln -s /opt/mx_accl_frigate/memryx "$SITE_PACKAGES/memryx" + +# Copy architecture-specific shared libraries +ARCH=$(uname -m) +if [[ "$ARCH" == "x86_64" ]]; then + cp /opt/mx_accl_frigate/memryx/x86/libmemx.so* /usr/lib/x86_64-linux-gnu/ + cp /opt/mx_accl_frigate/memryx/x86/libmx_accl.so* /usr/lib/x86_64-linux-gnu/ +elif [[ "$ARCH" == "aarch64" ]]; then + cp /opt/mx_accl_frigate/memryx/arm/libmemx.so* /usr/lib/aarch64-linux-gnu/ + cp /opt/mx_accl_frigate/memryx/arm/libmx_accl.so* /usr/lib/aarch64-linux-gnu/ +else + echo "Unsupported architecture: $ARCH" + exit 1 +fi + +# Refresh linker cache +ldconfig diff --git a/docker/main/requirements-dev.txt b/docker/main/requirements-dev.txt index af3ee57638..ac9d357583 100644 --- a/docker/main/requirements-dev.txt +++ b/docker/main/requirements-dev.txt @@ -1 +1,4 @@ ruff + +# types +types-peewee == 3.17.* diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 2764eca43a..7c0dc18434 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -1,24 +1,28 @@ aiofiles == 24.1.* click == 8.1.* # FastAPI -aiohttp == 3.11.3 -starlette == 0.41.2 -starlette-context == 0.3.6 -fastapi == 0.115.* -uvicorn == 0.30.* +aiohttp == 3.12.* +starlette == 0.47.* +starlette-context == 0.4.* +fastapi[standard-no-fastapi-cloud-cli] == 0.116.* +uvicorn == 0.35.* slowapi == 0.1.* -joserfc == 1.0.* -pathvalidate == 3.2.* +joserfc == 1.2.* +cryptography == 44.0.* +pathvalidate == 3.3.* markupsafe == 3.0.* -python-multipart == 0.0.12 +python-multipart == 0.0.20 +# Classification Model Training +tensorflow == 2.19.* ; platform_machine == 'aarch64' +tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64' # General mypy == 1.6.1 -onvif-zeep-async == 3.1.* +onvif-zeep-async == 4.0.* paho-mqtt == 2.1.* pandas == 2.2.* peewee == 3.17.* peewee_migrate == 1.13.* -psutil == 6.1.* +psutil == 7.1.* pydantic == 2.10.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml pytz == 2025.* @@ -27,7 +31,7 @@ ruamel.yaml == 0.18.* tzlocal == 5.2 requests == 2.32.* types-requests == 2.32.* -norfair == 2.2.* +norfair == 2.3.* setproctitle == 1.3.* ws4py == 0.5.* unidecode == 1.3.* @@ -36,16 +40,15 @@ titlecase == 2.4.* numpy == 1.26.* opencv-python-headless == 4.11.0.* opencv-contrib-python == 4.11.0.* -scipy == 1.14.* +scipy == 1.16.* # OpenVino & ONNX -openvino == 2024.4.* -onnxruntime-openvino == 1.20.* ; platform_machine == 'x86_64' -onnxruntime == 1.20.* ; platform_machine == 'aarch64' +openvino == 2025.3.* +onnxruntime == 1.22.* # Embeddings transformers == 4.45.* # Generative AI google-generativeai == 0.8.* -ollama == 0.3.* +ollama == 0.5.* openai == 1.65.* # push notifications py-vapid == 1.9.* @@ -71,3 +74,10 @@ prometheus-client == 0.21.* # TFLite tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64' tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64' +# audio transcription +sherpa-onnx==1.12.* +faster-whisper==1.1.* +librosa==0.11.* +soundfile==0.13.* +# DeGirum detector +degirum == 0.16.* diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run index af3bc04de4..4ce1c133f5 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run @@ -10,7 +10,7 @@ echo "[INFO] Starting certsync..." lefile="/etc/letsencrypt/live/frigate/fullchain.pem" -tls_enabled=`python3 /usr/local/nginx/get_tls_settings.py | jq -r .enabled` +tls_enabled=`python3 /usr/local/nginx/get_listen_settings.py | jq -r .tls.enabled` while true do diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index 273182930d..8bd9b5250f 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -85,7 +85,7 @@ python3 /usr/local/nginx/get_base_path.py | \ -out /usr/local/nginx/conf/base_path.conf # build templates for optional TLS support -python3 /usr/local/nginx/get_tls_settings.py | \ +python3 /usr/local/nginx/get_listen_settings.py | \ tempio -template /usr/local/nginx/templates/listen.gotmpl \ -out /usr/local/nginx/conf/listen.conf diff --git a/docker/main/rootfs/usr/local/nginx/get_tls_settings.py b/docker/main/rootfs/usr/local/nginx/get_listen_settings.py similarity index 71% rename from docker/main/rootfs/usr/local/nginx/get_tls_settings.py rename to docker/main/rootfs/usr/local/nginx/get_listen_settings.py index d2e7040564..d879db56e4 100644 --- a/docker/main/rootfs/usr/local/nginx/get_tls_settings.py +++ b/docker/main/rootfs/usr/local/nginx/get_listen_settings.py @@ -26,6 +26,10 @@ try: except FileNotFoundError: config: dict[str, Any] = {} -tls_config: dict[str, Any] = config.get("tls", {"enabled": True}) +tls_config: dict[str, any] = config.get("tls", {"enabled": True}) +networking_config = config.get("networking", {}) +ipv6_config = networking_config.get("ipv6", {"enabled": False}) -print(json.dumps(tls_config)) +output = {"tls": tls_config, "ipv6": ipv6_config} + +print(json.dumps(output)) diff --git a/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl b/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl index 093d5f68e7..066f872cb9 100644 --- a/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl +++ b/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl @@ -1,33 +1,45 @@ -# intended for internal traffic, not protected by auth + +# Internal (IPv4 always; IPv6 optional) listen 5000; +{{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:5000;{{ end }}{{ end }} + -{{ if not .enabled }} # intended for external traffic, protected by auth -listen 8971; +{{ if .tls }} + {{ if .tls.enabled }} + # external HTTPS (IPv4 always; IPv6 optional) + listen 8971 ssl; + {{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971 ssl;{{ end }}{{ end }} + + ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem; + + # generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP + # https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7 + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + + # modern configuration + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; + + # HSTS (ngx_http_headers_module is required) (63072000 seconds) + add_header Strict-Transport-Security "max-age=63072000" always; + + # ACME challenge location + location /.well-known/acme-challenge/ { + default_type "text/plain"; + root /etc/letsencrypt/www; + } + {{ else }} + # external HTTP (IPv4 always; IPv6 optional) + listen 8971; + {{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971;{{ end }}{{ end }} + {{ end }} {{ else }} -# intended for external traffic, protected by auth -listen 8971 ssl; - -ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem; -ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem; - -# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP -# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7 -ssl_session_timeout 1d; -ssl_session_cache shared:MozSSL:10m; # about 40000 sessions -ssl_session_tickets off; - -# modern configuration -ssl_protocols TLSv1.3; -ssl_prefer_server_ciphers off; - -# HSTS (ngx_http_headers_module is required) (63072000 seconds) -add_header Strict-Transport-Security "max-age=63072000" always; - -# ACME challenge location -location /.well-known/acme-challenge/ { - default_type "text/plain"; - root /etc/letsencrypt/www; -} + # (No tls section) default to HTTP (IPv4 always; IPv6 optional) + listen 8971; + {{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971;{{ end }}{{ end }} {{ end }} diff --git a/docker/memryx/user_installation.sh b/docker/memryx/user_installation.sh new file mode 100644 index 0000000000..20c9b8ece1 --- /dev/null +++ b/docker/memryx/user_installation.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -e # Exit immediately if any command fails +set -o pipefail + +echo "Starting MemryX driver and runtime installation..." + +# Detect architecture +arch=$(uname -m) + +# Purge existing packages and repo +echo "Removing old MemryX installations..." +# Remove any holds on MemryX packages (if they exist) +sudo apt-mark unhold memx-* mxa-manager || true +sudo apt purge -y memx-* mxa-manager || true +sudo rm -f /etc/apt/sources.list.d/memryx.list /etc/apt/trusted.gpg.d/memryx.asc + +# Install kernel headers +echo "Installing kernel headers for: $(uname -r)" +sudo apt update +sudo apt install -y dkms linux-headers-$(uname -r) + +# Add MemryX key and repo +echo "Adding MemryX GPG key and repository..." +wget -qO- https://developer.memryx.com/deb/memryx.asc | sudo tee /etc/apt/trusted.gpg.d/memryx.asc >/dev/null +echo 'deb https://developer.memryx.com/deb stable main' | sudo tee /etc/apt/sources.list.d/memryx.list >/dev/null + +# Update and install memx-drivers +echo "Installing memx-drivers..." +sudo apt update +sudo apt install -y memx-drivers + +# ARM-specific board setup +if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then + echo "Running ARM board setup..." + sudo mx_arm_setup +fi + +echo -e "\n\n\033[1;31mYOU MUST RESTART YOUR COMPUTER NOW\033[0m\n\n" + +# Install other runtime packages +packages=("memx-accl" "mxa-manager") +for pkg in "${packages[@]}"; do + echo "Installing $pkg..." + sudo apt install -y "$pkg" +done + +echo "MemryX installation complete!" diff --git a/docker/rockchip/Dockerfile b/docker/rockchip/Dockerfile index 6682504393..70309f02ed 100644 --- a/docker/rockchip/Dockerfile +++ b/docker/rockchip/Dockerfile @@ -11,7 +11,8 @@ COPY docker/main/requirements-wheels.txt /requirements-wheels.txt COPY docker/rockchip/requirements-wheels-rk.txt /requirements-wheels-rk.txt RUN sed -i "/https:\/\//d" /requirements-wheels.txt RUN sed -i "/onnxruntime/d" /requirements-wheels.txt -RUN pip3 wheel --wheel-dir=/rk-wheels -c /requirements-wheels.txt -r /requirements-wheels-rk.txt +RUN sed -i '/\[.*\]/d' /requirements-wheels.txt \ + && pip3 wheel --wheel-dir=/rk-wheels -c /requirements-wheels.txt -r /requirements-wheels-rk.txt RUN rm -rf /rk-wheels/opencv_python-* RUN rm -rf /rk-wheels/torch-* diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 7cac69eef1..86bc1c2298 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -2,7 +2,7 @@ # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND=noninteractive -ARG ROCM=6.3.3 +ARG ROCM=1 ARG AMDGPU=gfx900 ARG HSA_OVERRIDE_GFX_VERSION ARG HSA_OVERRIDE @@ -13,12 +13,12 @@ FROM wget AS rocm ARG ROCM ARG AMDGPU -RUN apt update && \ +RUN apt update -qq && \ 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 && \ + wget -O rocm.deb https://repo.radeon.com/amdgpu-install/6.4.1/ubuntu/jammy/amdgpu-install_6.4.60401-1_all.deb && \ apt install -y ./rocm.deb && \ apt update && \ - apt install -y rocm + apt install -qq -y rocm RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib RUN cd /opt/rocm-$ROCM/lib && \ @@ -33,7 +33,10 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf ####################################################################### FROM deps AS deps-prelim -RUN apt-get update && apt-get install -y libnuma1 +COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources +RUN apt-get update && \ + apt-get install -y libnuma1 && \ + apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers WORKDIR /opt/frigate COPY --from=rootfs / / @@ -44,7 +47,7 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ 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 \ +RUN pip3 uninstall -y onnxruntime \ && pip3 install -r /requirements.txt ####################################################################### @@ -62,9 +65,11 @@ COPY --from=rocm /opt/rocm-dist/ / ####################################################################### FROM deps-prelim AS rocm-prelim-hsa-override0 ENV HSA_ENABLE_SDMA=0 -ENV MIGRAPHX_ENABLE_NHWC=1 ENV TF_ROCM_USE_IMMEDIATE_MODE=1 +# avoid kernel crashes +ENV HIP_FORCE_DEV_KERNARG=1 + COPY --from=rocm-dist / / RUN ldconfig diff --git a/docker/rocm/debian-backports.sources b/docker/rocm/debian-backports.sources new file mode 100644 index 0000000000..fc51f4eeb7 --- /dev/null +++ b/docker/rocm/debian-backports.sources @@ -0,0 +1,6 @@ +Types: deb +URIs: http://deb.debian.org/debian +Suites: bookworm-backports +Components: main +Enabled: yes +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg diff --git a/docker/rocm/requirements-wheels-rocm.txt b/docker/rocm/requirements-wheels-rocm.txt index 85450768e6..21aebf4bda 100644 --- a/docker/rocm/requirements-wheels-rocm.txt +++ b/docker/rocm/requirements-wheels-rocm.txt @@ -1 +1 @@ -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 \ No newline at end of file +onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v6.4.1/onnxruntime_rocm-1.21.1-cp311-cp311-linux_x86_64.whl \ No newline at end of file diff --git a/docker/rocm/rocm.hcl b/docker/rocm/rocm.hcl index 6a84b350d7..0745a9f3d3 100644 --- a/docker/rocm/rocm.hcl +++ b/docker/rocm/rocm.hcl @@ -2,7 +2,7 @@ variable "AMDGPU" { default = "gfx900" } variable "ROCM" { - default = "6.3.3" + default = "6.4.1" } variable "HSA_OVERRIDE_GFX_VERSION" { default = "" diff --git a/docker/synaptics/Dockerfile b/docker/synaptics/Dockerfile new file mode 100644 index 0000000000..6a60fe43b1 --- /dev/null +++ b/docker/synaptics/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.6 + +# https://askubuntu.com/questions/972516/debian-frontend-environment-variable +ARG DEBIAN_FRONTEND=noninteractive + +# Globally set pip break-system-packages option to avoid having to specify it every time +ARG PIP_BREAK_SYSTEM_PACKAGES=1 + +FROM wheels AS synap1680-wheels +ARG TARGETARCH + +# Install dependencies +RUN wget -qO- "https://github.com/GaryHuang-ASUS/synaptics_astra_sdk/releases/download/v1.5.0/Synaptics-SL1680-v1.5.0-rt.tar" | tar -C / -xzf - +RUN wget -P /wheels/ "https://github.com/synaptics-synap/synap-python/releases/download/v0.0.4-preview/synap_python-0.0.4-cp311-cp311-manylinux_2_35_aarch64.whl" + +FROM deps AS synap1680-deps +ARG TARGETARCH +ARG PIP_BREAK_SYSTEM_PACKAGES + +RUN --mount=type=bind,from=synap1680-wheels,source=/wheels,target=/deps/synap-wheels \ +pip3 install --no-deps -U /deps/synap-wheels/*.whl + +WORKDIR /opt/frigate/ +COPY --from=rootfs / / + +COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib + +ADD https://raw.githubusercontent.com/synaptics-astra/synap-release/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80/model.synap /synaptics/mobilenet.synap diff --git a/docker/synaptics/synaptics.hcl b/docker/synaptics/synaptics.hcl new file mode 100644 index 0000000000..a22fb446a4 --- /dev/null +++ b/docker/synaptics/synaptics.hcl @@ -0,0 +1,27 @@ +target wheels { + dockerfile = "docker/main/Dockerfile" + platforms = ["linux/arm64"] + target = "wheels" +} + +target deps { + dockerfile = "docker/main/Dockerfile" + platforms = ["linux/arm64"] + target = "deps" +} + +target rootfs { + dockerfile = "docker/main/Dockerfile" + platforms = ["linux/arm64"] + target = "rootfs" +} + +target synaptics { + dockerfile = "docker/synaptics/Dockerfile" + contexts = { + wheels = "target:wheels", + deps = "target:deps", + rootfs = "target:rootfs" + } + platforms = ["linux/arm64"] +} diff --git a/docker/synaptics/synaptics.mk b/docker/synaptics/synaptics.mk new file mode 100644 index 0000000000..64cb8586b1 --- /dev/null +++ b/docker/synaptics/synaptics.mk @@ -0,0 +1,15 @@ +BOARDS += synaptics + +local-synaptics: version + docker buildx bake --file=docker/synaptics/synaptics.hcl synaptics \ + --set synaptics.tags=frigate:latest-synaptics \ + --load + +build-synaptics: version + docker buildx bake --file=docker/synaptics/synaptics.hcl synaptics \ + --set synaptics.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-synaptics + +push-synaptics: build-synaptics + docker buildx bake --file=docker/synaptics/synaptics.hcl synaptics \ + --set synaptics.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-synaptics \ + --push diff --git a/docker/tensorrt/Dockerfile.amd64 b/docker/tensorrt/Dockerfile.amd64 index 906e113a88..ef0295a96a 100644 --- a/docker/tensorrt/Dockerfile.amd64 +++ b/docker/tensorrt/Dockerfile.amd64 @@ -12,13 +12,16 @@ ARG PIP_BREAK_SYSTEM_PACKAGES # Install TensorRT wheels COPY docker/tensorrt/requirements-amd64.txt /requirements-tensorrt.txt COPY docker/main/requirements-wheels.txt /requirements-wheels.txt -RUN pip3 wheel --wheel-dir=/trt-wheels -c /requirements-wheels.txt -r /requirements-tensorrt.txt + +# remove dependencies from the requirements that have type constraints +RUN sed -i '/\[.*\]/d' /requirements-wheels.txt \ + && pip3 wheel --wheel-dir=/trt-wheels -c /requirements-wheels.txt -r /requirements-tensorrt.txt FROM deps AS frigate-tensorrt ARG PIP_BREAK_SYSTEM_PACKAGES RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \ - pip3 uninstall -y onnxruntime-openvino tensorflow-cpu \ + pip3 uninstall -y onnxruntime tensorflow-cpu \ && pip3 install -U /deps/trt-wheels/*.whl COPY --from=rootfs / / diff --git a/docker/tensorrt/requirements-amd64.txt b/docker/tensorrt/requirements-amd64.txt index be4aaa0668..a7853aeecf 100644 --- a/docker/tensorrt/requirements-amd64.txt +++ b/docker/tensorrt/requirements-amd64.txt @@ -13,6 +13,7 @@ nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64' nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64' nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64' nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64' +tensorflow==2.19.*; platform_machine == 'x86_64' onnx==1.16.*; platform_machine == 'x86_64' -onnxruntime-gpu==1.20.*; platform_machine == 'x86_64' +onnxruntime-gpu==1.22.*; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64' diff --git a/docker/tensorrt/requirements-models-arm64.txt b/docker/tensorrt/requirements-models-arm64.txt index 3490a7897e..fe89b47547 100644 --- a/docker/tensorrt/requirements-models-arm64.txt +++ b/docker/tensorrt/requirements-models-arm64.txt @@ -1,3 +1,2 @@ onnx == 1.14.0; platform_machine == 'aarch64' protobuf == 3.20.3; platform_machine == 'aarch64' -numpy == 1.23.*; platform_machine == 'aarch64' # required by python-tensorrt 8.2.1 (Jetpack 4.6) diff --git a/docs/docs/configuration/advanced.md b/docs/docs/configuration/advanced.md index 818440fae3..02482e792a 100644 --- a/docs/docs/configuration/advanced.md +++ b/docs/docs/configuration/advanced.md @@ -177,9 +177,11 @@ listen [::]:5000 ipv6only=off; By default, Frigate runs at the root path (`/`). However some setups require to run Frigate under a custom path prefix (e.g. `/frigate`), especially when Frigate is located behind a reverse proxy that requires path-based routing. ### Set Base Path via HTTP Header + The preferred way to configure the base path is through the `X-Ingress-Path` HTTP header, which needs to be set to the desired base path in an upstream reverse proxy. For example, in Nginx: + ``` location /frigate { proxy_set_header X-Ingress-Path /frigate; @@ -188,9 +190,11 @@ location /frigate { ``` ### Set Base Path via Environment Variable + When it is not feasible to set the base path via a HTTP header, it can also be set via the `FRIGATE_BASE_PATH` environment variable in the Docker Compose file. For example: + ``` services: frigate: @@ -200,6 +204,7 @@ services: ``` This can be used for example to access Frigate via a Tailscale agent (https), by simply forwarding all requests to the base path (http): + ``` tailscale serve --https=443 --bg --set-path /frigate http://localhost:5000/frigate ``` @@ -218,7 +223,7 @@ To do this: ### Custom go2rtc version -Frigate currently includes go2rtc v1.9.9, there may be certain cases where you want to run a different version of go2rtc. +Frigate currently includes go2rtc v1.9.10, there may be certain cases where you want to run a different version of go2rtc. To do this: diff --git a/docs/docs/configuration/audio_detectors.md b/docs/docs/configuration/audio_detectors.md index b783daa695..71ad5c3356 100644 --- a/docs/docs/configuration/audio_detectors.md +++ b/docs/docs/configuration/audio_detectors.md @@ -50,7 +50,7 @@ cameras: ### Configuring Minimum Volume -The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widely between camera models so it is important to run tests to see what volume levels are. MQTT explorer can be used on the audio topic to see what volume level is being detected. +The audio detector uses volume levels in the same way that motion in a camera feed is used for object detection. This means that frigate will not run audio detection unless the audio volume is above the configured level in order to reduce resource usage. Audio levels can vary widely between camera models so it is important to run tests to see what volume levels are. The Debug view in the Frigate UI has an Audio tab for cameras that have the `audio` role assigned where a graph and the current levels are is displayed. The `min_volume` parameter should be set to the minimum the `RMS` level required to run audio detection. :::tip @@ -72,3 +72,77 @@ audio: - speech - yell ``` + +### Audio Transcription + +Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI’s open-source Whisper models via `faster-whisper`. To enable transcription, it is recommended to only configure the features at the global level, and enable it at the individual camera level. + +```yaml +audio_transcription: + enabled: False + device: ... + model_size: ... +``` + +Enable audio transcription for select cameras at the camera level: + +```yaml +cameras: + back_yard: + ... + audio_transcription: + enabled: True +``` + +:::note + +Audio detection must be enabled and configured as described above in order to use audio transcription features. + +::: + +The optional config parameters that can be set at the global level include: + +- **`enabled`**: Enable or disable the audio transcription feature. + - Default: `False` + - It is recommended to only configure the features at the global level, and enable it at the individual camera level. +- **`device`**: Device to use to run transcription and translation models. + - Default: `CPU` + - This can be `CPU` or `GPU`. The `sherpa-onnx` models are lightweight and run on the CPU only. The `whisper` models can run on GPU but are only supported on CUDA hardware. +- **`model_size`**: The size of the model used for live transcription. + - Default: `small` + - This can be `small` or `large`. The `small` setting uses `sherpa-onnx` models that are fast, lightweight, and always run on the CPU but are not as accurate as the `whisper` model. + - The + - This config option applies to **live transcription only**. Recorded `speech` events will always use a different `whisper` model (and can be accelerated for CUDA hardware if available with `device: GPU`). +- **`language`**: Defines the language used by `whisper` to translate `speech` audio events (and live audio only if using the `large` model). + - Default: `en` + - You must use a valid [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10). + - Transcriptions for `speech` events are translated. + - Live audio is translated only if you are using the `large` model. The `small` `sherpa-onnx` model is English-only. + +The only field that is valid at the camera level is `enabled`. + +#### Live transcription + +The single camera Live view in the Frigate UI supports live transcription of audio for streams defined with the `audio` role. Use the Enable/Disable Live Audio Transcription button/switch to toggle transcription processing. When speech is heard, the UI will display a black box over the top of the camera stream with text. The MQTT topic `frigate//audio/transcription` will also be updated in real-time with transcribed text. + +Results can be error-prone due to a number of factors, including: + +- Poor quality camera microphone +- Distance of the audio source to the camera microphone +- Low audio bitrate setting in the camera +- Background noise +- Using the `small` model - it's fast, but not accurate for poor quality audio + +For speech sources close to the camera with minimal background noise, use the `small` model. + +If you have CUDA hardware, you can experiment with the `large` `whisper` model on GPU. Performance is not quite as fast as the `sherpa-onnx` `small` model, but live transcription is far more accurate. Using the `large` model with CPU will likely be too slow for real-time transcription. + +#### Transcription and translation of `speech` audio events + +Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button in the Tracked Object Details pane. + +In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for in your config. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10). + +The transcribed/translated speech will appear in the description box in the Tracked Object Details pane. If Semantic Search is enabled, embeddings are generated for the transcription text and are fully searchable using the description search type. + +Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient. diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index bf878d6bd5..1d1581b2c3 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -59,6 +59,7 @@ The default session length for user authentication in Frigate is 24 hours. This While the default provides a balance of security and convenience, you can customize this duration to suit your specific security requirements and user experience preferences. The session length is configured in seconds. The default value of `86400` will expire the authentication session after 24 hours. Some other examples: + - `0`: Setting the session length to 0 will require a user to log in every time they access the application or after a very short, immediate timeout. - `604800`: Setting the session length to 604800 will require a user to log in if the token is not refreshed for 7 days. @@ -80,7 +81,7 @@ python3 -c 'import secrets; print(secrets.token_hex(64))' Frigate looks for a JWT token secret in the following order: 1. An environment variable named `FRIGATE_JWT_SECRET` -2. A docker secret named `FRIGATE_JWT_SECRET` in `/run/secrets/` +2. A file named `FRIGATE_JWT_SECRET` in the directory specified by the `CREDENTIALS_DIRECTORY` environment variable (defaults to the Docker Secrets directory: `/run/secrets/`) 3. A `jwt_secret` option from the Home Assistant Add-on options 4. A `.jwt_secret` file in the config directory @@ -123,7 +124,7 @@ proxy: role: x-forwarded-groups ``` -Frigate supports both `admin` and `viewer` roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization. +Frigate supports `admin`, `viewer`, and custom roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization. A default role can be provided. Any value in the mapped `role` header will override the default. @@ -133,6 +134,34 @@ proxy: default_role: viewer ``` +## Role mapping + +In some environments, upstream identity providers (OIDC, SAML, LDAP, etc.) do not pass a Frigate-compatible role directly, but instead pass one or more group claims. To handle this, Frigate supports a `role_map` that translates upstream group names into Frigate’s internal roles (`admin`, `viewer`, or custom). + +```yaml +proxy: + ... + header_map: + user: x-forwarded-user + role: x-forwarded-groups + role_map: + admin: + - sysadmins + - access-level-security + viewer: + - camera-viewer + operator: # Custom role mapping + - operators +``` + +In this example: + +- If the proxy passes a role header containing `sysadmins` or `access-level-security`, the user is assigned the `admin` role. +- If the proxy passes a role header containing `camera-viewer`, the user is assigned the `viewer` role. +- If the proxy passes a role header containing `operators`, the user is assigned the `operator` custom role. +- If no mapping matches, Frigate falls back to `default_role` if configured. +- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name. + #### Port Considerations **Authenticated Port (8971)** @@ -141,6 +170,7 @@ proxy: - The `remote-role` header determines the user’s privileges: - **admin** → Full access (user management, configuration changes). - **viewer** → Read-only access. + - **Custom roles** → Read-only access limited to the cameras defined in `auth.roles[role]`. - Ensure your **proxy sends both user and role headers** for proper role enforcement. **Unauthenticated Port (5000)** @@ -186,6 +216,41 @@ Frigate supports user roles to control access to certain features in the UI and - **admin**: Full access to all features, including user management and configuration. - **viewer**: Read-only access to the UI and API, including viewing cameras, review items, and historical footage. Configuration editor and settings in the UI are inaccessible. +- **Custom Roles**: Arbitrary role names (alphanumeric, dots/underscores) with specific camera permissions. These extend the system for granular access (e.g., "operator" for select cameras). + +### Custom Roles and Camera Access + +The viewer role provides read-only access to all cameras in the UI and API. Custom roles allow admins to limit read-only access to specific cameras. Each role specifies an array of allowed camera names. If a user is assigned a custom role, their account is like the **viewer** role - they can only view Live, Review/History, Explore, and Export for the designated cameras. Backend API endpoints enforce this server-side (e.g., returning 403 for unauthorized cameras), and the frontend UI filters content accordingly (e.g., camera dropdowns show only permitted options). + +### Role Configuration Example + +```yaml +cameras: + front_door: + # ... camera config + side_yard: + # ... camera config + garage: + # ... camera config + +auth: + enabled: true + roles: + operator: # Custom role + - front_door + - garage # Operator can access front and garage + neighbor: + - side_yard +``` + +If you want to provide access to all cameras to a specific user, just use the **viewer** role. + +### Managing User Roles + +1. Log in as an **admin** user via port `8971` (preferred), or unauthenticated via port `5000`. +2. Navigate to **Settings**. +3. In the **Users** section, edit a user’s role by selecting from available roles (admin, viewer, or custom). +4. In the **Roles** section, add/edit/delete custom roles (select cameras via switches). Deleting a role auto-reassigns users to "viewer". ### Role Enforcement diff --git a/docs/docs/configuration/camera_specific.md b/docs/docs/configuration/camera_specific.md index 3a3809605e..b7a817ff87 100644 --- a/docs/docs/configuration/camera_specific.md +++ b/docs/docs/configuration/camera_specific.md @@ -147,7 +147,7 @@ WEB Digest Algorithm - MD5 Reolink has many different camera models with inconsistently supported features and behavior. The below table shows a summary of various features and recommendations. | Camera Resolution | Camera Generation | Recommended Stream Type | Additional Notes | -| ---------------- | ------------------------- | -------------------------------- | ----------------------------------------------------------------------- | +| ----------------- | ------------------------- | --------------------------------- | ----------------------------------------------------------------------- | | 5MP or lower | All | http-flv | Stream is h264 | | 6MP or higher | Latest (ex: Duo3, CX-8##) | http-flv with ffmpeg 8.0, or rtsp | This uses the new http-flv-enhanced over H265 which requires ffmpeg 8.0 | | 6MP or higher | Older (ex: RLC-8##) | rtsp | | @@ -231,7 +231,7 @@ go2rtc: - rtspx://192.168.1.1:7441/abcdefghijk ``` -[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-rtsp) +[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-rtsp) In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect. @@ -250,6 +250,7 @@ TP-Link VIGI cameras need some adjustments to the main stream settings on the ca To use a USB camera (webcam) with Frigate, the recommendation is to use go2rtc's [FFmpeg Device](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg-device) support: - Preparation outside of Frigate: + - Get USB camera path. Run `v4l2-ctl --list-devices` to get a listing of locally-connected cameras available. (You may need to install `v4l-utils` in a way appropriate for your Linux distribution). In the sample configuration below, we use `video=0` to correlate with a detected device path of `/dev/video0` - Get USB camera formats & resolutions. Run `ffmpeg -f v4l2 -list_formats all -i /dev/video0` to get an idea of what formats and resolutions the USB Camera supports. In the sample configuration below, we use a width of 1024 and height of 576 in the stream and detection settings based on what was reported back. - If using Frigate in a container (e.g. Docker on TrueNAS), ensure you have USB Passthrough support enabled, along with a specific Host Device (`/dev/video0`) + Container Device (`/dev/video0`) listed. @@ -277,5 +278,3 @@ cameras: width: 1024 height: 576 ``` - - diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index d6a8915c31..57b3be1d4c 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -89,7 +89,9 @@ An ONVIF-capable camera that supports relative movement within the field of view ## ONVIF PTZ camera recommendations -This list of working and non-working PTZ cameras is based on user feedback. +This list of working and non-working PTZ cameras is based on user feedback. If you'd like to report specific quirks or issues with a manufacturer or camera that would be helpful for other users, open a pull request to add to this list. + +The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/) can provide a starting point to determine a camera's compatibility with Frigate's autotracking. Look to see if a camera lists `PTZRelative`, `PTZRelativePanTilt` and/or `PTZRelativeZoom`, plus `PTZAuxiliary`. These features are required for autotracking, but some cameras still fail to respond even if they claim support. If they are missing, autotracking will not work (though basic PTZ in the WebUI might). Avoid cameras with no database entry unless they are confirmed as working below. | Brand or specific camera | PTZ Controls | Autotracking | Notes | | ---------------------------- | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------- | @@ -99,11 +101,13 @@ This list of working and non-working PTZ cameras is based on user feedback. | Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. | | Annke CZ504 | ✅ | ✅ | Annke support provide specific firmware ([V5.7.1 build 250227](https://github.com/pierrepinon/annke_cz504/raw/refs/heads/main/digicap_V5-7-1_build_250227.dav)) to fix issue with ONVIF "TranslationSpaceFov" | | Ctronics PTZ | ✅ | ❌ | | -| Dahua | ✅ | ✅ | Some low-end Dahuas (lite series, among others) have been reported to not support autotracking | +| Dahua | ✅ | ✅ | Some low-end Dahuas (lite series, picoo series (commonly), among others) have been reported to not support autotracking. These models usually don't have a four digit model number with chassis prefix and options postfix (e.g. DH-P5AE-PV vs DH-SD49825GB-HNR). | | Dahua DH-SD2A500HB | ✅ | ❌ | | | Dahua DH-SD49825GB-HNR | ✅ | ✅ | | | Dahua DH-P5AE-PV | ❌ | ❌ | | +| Foscam | ✅ | ❌ | In general support PTZ, but not relative move. There are no official ONVIF certifications and tests available on the ONVIF Conformant Products Database | | | Foscam R5 | ✅ | ❌ | | +| Foscam SD4 | ✅ | ❌ | | | Hanwha XNP-6550RH | ✅ | ❌ | | | Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others | | Hikvision DS-2DE3A404IWG-E/W | ✅ | ✅ | | @@ -134,3 +138,6 @@ camera_groups: icon: LuCar order: 0 ``` + +## Two-Way Audio +See the guide [here](/configuration/live/#two-way-talk) diff --git a/docs/docs/configuration/custom_classification/object_classification.md b/docs/docs/configuration/custom_classification/object_classification.md new file mode 100644 index 0000000000..a75aae31ad --- /dev/null +++ b/docs/docs/configuration/custom_classification/object_classification.md @@ -0,0 +1,73 @@ +--- +id: object_classification +title: Object Classification +--- + +Object classification allows you to train a custom MobileNetV2 classification model to run on tracked objects (persons, cars, animals, etc.) to identify a finer category or attribute for that object. + +## Minimum System Requirements + +Object classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate. + +Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer. +When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training. + +### Sub label vs Attribute + +- **Sub label**: + + - Applied to the object’s `sub_label` field. + - Ideal for a single, more specific identity or type. + - Example: `cat` → `Leo`, `Charlie`, `None`. + +- **Attribute**: + - Added as metadata to the object (visible in /events): `: `. + - Ideal when multiple attributes can coexist independently. + - Example: Detecting if a `person` in a construction yard is wearing a helmet or not. + +## Example use cases + +### Sub label + +- **Known pet vs unknown**: For `dog` objects, set sub label to your pet’s name (e.g., `buddy`) or `none` for others. +- **Mail truck vs normal car**: For `car`, classify as `mail_truck` vs `car` to filter important arrivals. +- **Delivery vs non-delivery person**: For `person`, classify `delivery` vs `visitor` based on uniform/props. + +### Attributes + +- **Backpack**: For `person`, add attribute `backpack: yes/no`. +- **Helmet**: For `person` (worksite), add `helmet: yes/no`. +- **Leash**: For `dog`, add `leash: yes/no` (useful for park or yard rules). +- **Ladder rack**: For `truck`, add `ladder_rack: yes/no` to flag service vehicles. + +## Configuration + +Object classification is configured as a custom classification model. Each model has its own name and settings. You must list which object labels should be classified. + +```yaml +classification: + custom: + dog: + threshold: 0.8 + object_config: + objects: [dog] # object labels to classify + classification_type: sub_label # or: attribute +``` + +## Training the model + +Creating and training the model is done within the Frigate UI using the `Classification` page. + +### Getting Started + +When choosing which objects to classify, start with a small number of visually distinct classes and ensure your training samples match camera viewpoints and distances typical for those objects. + +// TODO add this section once UI is implemented. Explain process of selecting objects and curating training examples. + +### Improving the Model + +- **Problem framing**: Keep classes visually distinct and relevant to the chosen object types. +- **Data collection**: Use the model’s Train tab to gather balanced examples across times of day, weather, and distances. +- **Preprocessing**: Ensure examples reflect object crops similar to Frigate’s boxes; keep the subject centered. +- **Labels**: Keep label names short and consistent; include a `none` class if you plan to ignore uncertain predictions for sub labels. +- **Threshold**: Tune `threshold` per model to reduce false assignments. Start at `0.8` and adjust based on validation. diff --git a/docs/docs/configuration/custom_classification/state_classification.md b/docs/docs/configuration/custom_classification/state_classification.md new file mode 100644 index 0000000000..ec38ea6963 --- /dev/null +++ b/docs/docs/configuration/custom_classification/state_classification.md @@ -0,0 +1,52 @@ +--- +id: state_classification +title: State Classification +--- + +State classification allows you to train a custom MobileNetV2 classification model on a fixed region of your camera frame(s) to determine a current state. The model can be configured to run on a schedule and/or when motion is detected in that region. + +## Minimum System Requirements + +State classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate. + +Training the model does briefly use a high amount of system resources for about 1–3 minutes per training run. On lower-power devices, training may take longer. +When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training. + +## Example use cases + +- **Door state**: Detect if a garage or front door is open vs closed. +- **Gate state**: Track if a driveway gate is open or closed. +- **Trash day**: Bins at curb vs no bins present. +- **Pool cover**: Cover on vs off. + +## Configuration + +State classification is configured as a custom classification model. Each model has its own name and settings. You must provide at least one camera crop under `state_config.cameras`. + +```yaml +classification: + custom: + front_door: + threshold: 0.8 + state_config: + motion: true # run when motion overlaps the crop + interval: 10 # also run every N seconds (optional) + cameras: + front: + crop: [0, 180, 220, 400] +``` + +## Training the model + +Creating and training the model is done within the Frigate UI using the `Classification` page. + +### Getting Started + +When choosing a portion of the camera frame for state classification, it is important to make the crop tight around the area of interest to avoid extra signals unrelated to what is being classified. + +// TODO add this section once UI is implemented. Explain process of selecting a crop. + +### Improving the Model + +- **Problem framing**: Keep classes visually distinct and state-focused (e.g., `open`, `closed`, `unknown`). Avoid combining object identity with state in a single model unless necessary. +- **Data collection**: Use the model’s Train tab to gather balanced examples across times of day and weather. diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index 3026615d45..d14946eaf0 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -24,7 +24,7 @@ Frigate needs to first detect a `person` before it can detect and recognize a fa Frigate has support for two face recognition model types: - **small**: Frigate will run a FaceNet embedding model to recognize faces, which runs locally on the CPU. This model is optimized for efficiency and is not as accurate. -- **large**: Frigate will run a large ArcFace embedding model that is optimized for accuracy. It is only recommended to be run when an integrated or dedicated GPU is available. +- **large**: Frigate will run a large ArcFace embedding model that is optimized for accuracy. It is only recommended to be run when an integrated or dedicated GPU / NPU is available. In both cases, a lightweight face landmark detection model is also used to align faces before running recognition. @@ -34,7 +34,7 @@ All of these features run locally on your system. The `small` model is optimized for efficiency and runs on the CPU, most CPUs should run the model efficiently. -The `large` model is optimized for accuracy, an integrated or discrete GPU is required. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. +The `large` model is optimized for accuracy, an integrated or discrete GPU / NPU is required. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. ## Configuration @@ -73,6 +73,9 @@ Fine-tune face recognition with these optional parameters at the global level of - Default: `100`. - `blur_confidence_filter`: Enables a filter that calculates how blurry the face is and adjusts the confidence based on this. - Default: `True`. +- `device`: Target a specific device to run the face recognition model on (multi-GPU installation). + - Default: `None`. + - Note: This setting is only applicable when using the `large` model. See [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/) ## Usage diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index f76c075b7e..957922dbdd 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -9,35 +9,38 @@ Requests for a description are sent off automatically to your AI provider at the ## Configuration -Generative AI can be enabled for all cameras or only for specific cameras. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below. +Generative AI can be enabled for all cameras or only for specific cameras. If GenAI is disabled for a camera, you can still manually generate descriptions for events using the HTTP API. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below. To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`. ```yaml genai: - enabled: True provider: gemini api_key: "{FRIGATE_GEMINI_API_KEY}" model: gemini-1.5-flash cameras: - front_camera: + front_camera: + objects: genai: - enabled: True # <- enable GenAI for your front camera - use_snapshot: True - objects: - - person - required_zones: - - steps + enabled: True # <- enable GenAI for your front camera + use_snapshot: True + objects: + - person + required_zones: + - steps indoor_camera: - genai: - enabled: False # <- disable GenAI for your indoor camera + objects: + genai: + enabled: False # <- disable GenAI for your indoor camera ``` By default, descriptions will be generated for all tracked objects and all zones. But you can also optionally specify `objects` and `required_zones` to only generate descriptions for certain tracked objects or zones. Optionally, you can generate the description using a snapshot (if enabled) by setting `use_snapshot` to `True`. By default, this is set to `False`, which sends the uncompressed images from the `detect` stream collected over the object's lifetime to the model. Once the object lifecycle ends, only a single compressed and cropped thumbnail is saved with the tracked object. Using a snapshot might be useful when you want to _regenerate_ a tracked object's description as it will provide the AI with a higher-quality image (typically downscaled by the AI itself) than the cropped/compressed thumbnail. Using a snapshot otherwise has a trade-off in that only a single image is sent to your provider, which will limit the model's ability to determine object movement or direction. +Generative AI can also be toggled dynamically for a camera via MQTT with the topic `frigate//object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt/#frigatecamera_nameobjectdescriptionsset). + ## Ollama :::warning @@ -66,7 +69,6 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru ```yaml genai: - enabled: True provider: ollama base_url: http://localhost:11434 model: llava:7b @@ -93,7 +95,6 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt ```yaml genai: - enabled: True provider: gemini api_key: "{FRIGATE_GEMINI_API_KEY}" model: gemini-1.5-flash @@ -121,7 +122,6 @@ To start using OpenAI, you must first [create an API key](https://platform.opena ```yaml genai: - enabled: True provider: openai api_key: "{FRIGATE_OPENAI_API_KEY}" model: gpt-4o @@ -149,7 +149,6 @@ To start using Azure OpenAI, you must first [create a resource](https://learn.mi ```yaml genai: - enabled: True provider: azure_openai base_url: https://example-endpoint.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2023-03-15-preview api_key: "{FRIGATE_OPENAI_API_KEY}" @@ -192,32 +191,35 @@ You are also able to define custom prompts in your configuration. ```yaml genai: - enabled: True provider: ollama base_url: http://localhost:11434 model: llava - prompt: "Analyze the {label} in these images from the {camera} security camera. Focus on the actions, behavior, and potential intent of the {label}, rather than just describing its appearance." - object_prompts: - person: "Examine the main person in these images. What are they doing and what might their actions suggest about their intent (e.g., approaching a door, leaving an area, standing still)? Do not describe the surroundings or static details." - car: "Observe the primary vehicle in these images. Focus on its movement, direction, or purpose (e.g., parking, approaching, circling). If it's a delivery vehicle, mention the company." + +objects: + prompt: "Analyze the {label} in these images from the {camera} security camera. Focus on the actions, behavior, and potential intent of the {label}, rather than just describing its appearance." + object_prompts: + person: "Examine the main person in these images. What are they doing and what might their actions suggest about their intent (e.g., approaching a door, leaving an area, standing still)? Do not describe the surroundings or static details." + car: "Observe the primary vehicle in these images. Focus on its movement, direction, or purpose (e.g., parking, approaching, circling). If it's a delivery vehicle, mention the company." ``` -Prompts can also be overriden at the camera level to provide a more detailed prompt to the model about your specific camera, if you desire. +Prompts can also be overridden at the camera level to provide a more detailed prompt to the model about your specific camera, if you desire. ```yaml cameras: front_door: - genai: - use_snapshot: True - prompt: "Analyze the {label} in these images from the {camera} security camera at the front door. Focus on the actions and potential intent of the {label}." - object_prompts: - person: "Examine the person in these images. What are they doing, and how might their actions suggest their purpose (e.g., delivering something, approaching, leaving)? If they are carrying or interacting with a package, include details about its source or destination." - cat: "Observe the cat in these images. Focus on its movement and intent (e.g., wandering, hunting, interacting with objects). If the cat is near the flower pots or engaging in any specific actions, mention it." - objects: - - person - - cat - required_zones: - - steps + objects: + genai: + enabled: True + use_snapshot: True + prompt: "Analyze the {label} in these images from the {camera} security camera at the front door. Focus on the actions and potential intent of the {label}." + object_prompts: + person: "Examine the person in these images. What are they doing, and how might their actions suggest their purpose (e.g., delivering something, approaching, leaving)? If they are carrying or interacting with a package, include details about its source or destination." + cat: "Observe the cat in these images. Focus on its movement and intent (e.g., wandering, hunting, interacting with objects). If the cat is near the flower pots or engaging in any specific actions, mention it." + objects: + - person + - cat + required_zones: + - steps ``` ### Experiment with prompts diff --git a/docs/docs/configuration/genai/config.md b/docs/docs/configuration/genai/config.md new file mode 100644 index 0000000000..bf72db4ea7 --- /dev/null +++ b/docs/docs/configuration/genai/config.md @@ -0,0 +1,142 @@ +--- +id: genai_config +title: Configuring Generative AI +--- + +## Configuration + +A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below. + +To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`. + +## Ollama + +:::warning + +Using Ollama on CPU is not recommended, high inference times make using Generative AI impractical. + +::: + +[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It provides a nice API over [llama.cpp](https://github.com/ggerganov/llama.cpp). It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. + +Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [Docker container](https://hub.docker.com/r/ollama/ollama) available. + +Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-does-ollama-handle-concurrent-requests). + +### Supported Models + +You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/library). Note that Frigate will not automatically download the model you specify in your config, Ollama will try to download the model but it may take longer than the timeout, it is recommended to pull the model beforehand by running `ollama pull your_model` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag. + +:::info + +Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger sizes are more capable of complex tasks and understanding of situations, but requires more memory and computational resources. It is recommended to try multiple models and experiment to see which performs best. + +::: + +:::tip + +If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. https://github.com/skye-harris/ollama-modelfiles contains optimized model configs for this task. + +::: + +The following models are recommended: + +| Model | Notes | +| ----------------- | ----------------------------------------------------------- | +| `Intern3.5VL` | Relatively fast with good vision comprehension +| `gemma3` | Strong frame-to-frame understanding, slower inference times | +| `qwen2.5vl` | Fast but capable model with good vision comprehension | +| `llava-phi3` | Lightweight and fast model with vision comprehension | + +:::note + +You should have at least 8 GB of RAM available (or VRAM if running on GPU) to run the 7B models, 16 GB to run the 13B models, and 32 GB to run the 33B models. + +::: + +### Configuration + +```yaml +genai: + provider: ollama + base_url: http://localhost:11434 + model: minicpm-v:8b + provider_options: # other Ollama client options can be defined + keep_alive: -1 + options: + num_ctx: 8192 # make sure the context matches other services that are using ollama +``` + +## Google Gemini + +Google Gemini has a free tier allowing [15 queries per minute](https://ai.google.dev/pricing) to the API, which is more than sufficient for standard Frigate usage. + +### Supported Models + +You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini). At the time of writing, this includes `gemini-1.5-pro` and `gemini-1.5-flash`. + +### Get API Key + +To start using Gemini, you must first get an API key from [Google AI Studio](https://aistudio.google.com). + +1. Accept the Terms of Service +2. Click "Get API Key" from the right hand navigation +3. Click "Create API key in new project" +4. Copy the API key for use in your config + +### Configuration + +```yaml +genai: + provider: gemini + api_key: "{FRIGATE_GEMINI_API_KEY}" + model: gemini-1.5-flash +``` + +## OpenAI + +OpenAI does not have a free tier for their API. With the release of gpt-4o, pricing has been reduced and each generation should cost fractions of a cent if you choose to go this route. + +### Supported Models + +You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://platform.openai.com/docs/models). At the time of writing, this includes `gpt-4o` and `gpt-4-turbo`. + +### Get API Key + +To start using OpenAI, you must first [create an API key](https://platform.openai.com/api-keys) and [configure billing](https://platform.openai.com/settings/organization/billing/overview). + +### Configuration + +```yaml +genai: + provider: openai + api_key: "{FRIGATE_OPENAI_API_KEY}" + model: gpt-4o +``` + +:::note + +To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` environment variable to your provider's API URL. + +::: + +## Azure OpenAI + +Microsoft offers several vision models through Azure OpenAI. A subscription is required. + +### Supported Models + +You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models). At the time of writing, this includes `gpt-4o` and `gpt-4-turbo`. + +### Create Resource and Get API Key + +To start using Azure OpenAI, you must first [create a resource](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource). You'll need your API key and resource URL, which must include the `api-version` parameter (see the example below). The model field is not required in your configuration as the model is part of the deployment name you chose when deploying the resource. + +### Configuration + +```yaml +genai: + provider: azure_openai + base_url: https://example-endpoint.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2023-03-15-preview + api_key: "{FRIGATE_OPENAI_API_KEY}" +``` diff --git a/docs/docs/configuration/genai/objects.md b/docs/docs/configuration/genai/objects.md new file mode 100644 index 0000000000..e5aa92cc0c --- /dev/null +++ b/docs/docs/configuration/genai/objects.md @@ -0,0 +1,77 @@ +--- +id: genai_objects +title: Object Descriptions +--- + +Generative AI can be used to automatically generate descriptive text based on the thumbnails of your tracked objects. This helps with [Semantic Search](/configuration/semantic_search) in Frigate to provide more context about your tracked objects. Descriptions are accessed via the _Explore_ view in the Frigate UI by clicking on a tracked object's thumbnail. + +Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle, or can optionally be sent earlier after a number of significantly changed frames, for example in use in more real-time notifications. Descriptions can also be regenerated manually via the Frigate UI. Note that if you are manually entering a description for tracked objects prior to its end, this will be overwritten by the generated response. + +By default, descriptions will be generated for all tracked objects and all zones. But you can also optionally specify `objects` and `required_zones` to only generate descriptions for certain tracked objects or zones. + +Optionally, you can generate the description using a snapshot (if enabled) by setting `use_snapshot` to `True`. By default, this is set to `False`, which sends the uncompressed images from the `detect` stream collected over the object's lifetime to the model. Once the object lifecycle ends, only a single compressed and cropped thumbnail is saved with the tracked object. Using a snapshot might be useful when you want to _regenerate_ a tracked object's description as it will provide the AI with a higher-quality image (typically downscaled by the AI itself) than the cropped/compressed thumbnail. Using a snapshot otherwise has a trade-off in that only a single image is sent to your provider, which will limit the model's ability to determine object movement or direction. + +Generative AI object descriptions can also be toggled dynamically for a camera via MQTT with the topic `frigate//object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt/#frigatecamera_nameobjectdescriptionsset). + +## Usage and Best Practices + +Frigate's thumbnail search excels at identifying specific details about tracked objects – for example, using an "image caption" approach to find a "person wearing a yellow vest," "a white dog running across the lawn," or "a red car on a residential street." To enhance this further, Frigate’s default prompts are designed to ask your AI provider about the intent behind the object's actions, rather than just describing its appearance. + +While generating simple descriptions of detected objects is useful, understanding intent provides a deeper layer of insight. Instead of just recognizing "what" is in a scene, Frigate’s default prompts aim to infer "why" it might be there or "what" it could do next. Descriptions tell you what’s happening, but intent gives context. For instance, a person walking toward a door might seem like a visitor, but if they’re moving quickly after hours, you can infer a potential break-in attempt. Detecting a person loitering near a door at night can trigger an alert sooner than simply noting "a person standing by the door," helping you respond based on the situation’s context. + +## Custom Prompts + +Frigate sends multiple frames from the tracked object along with a prompt to your Generative AI provider asking it to generate a description. The default prompt is as follows: + +``` +Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next. +``` + +:::tip + +Prompts can use variable replacements `{label}`, `{sub_label}`, and `{camera}` to substitute information from the tracked object as part of the prompt. + +::: + +You are also able to define custom prompts in your configuration. + +```yaml +genai: + provider: ollama + base_url: http://localhost:11434 + model: llava + +objects: + prompt: "Analyze the {label} in these images from the {camera} security camera. Focus on the actions, behavior, and potential intent of the {label}, rather than just describing its appearance." + object_prompts: + person: "Examine the main person in these images. What are they doing and what might their actions suggest about their intent (e.g., approaching a door, leaving an area, standing still)? Do not describe the surroundings or static details." + car: "Observe the primary vehicle in these images. Focus on its movement, direction, or purpose (e.g., parking, approaching, circling). If it's a delivery vehicle, mention the company." +``` + +Prompts can also be overridden at the camera level to provide a more detailed prompt to the model about your specific camera, if you desire. + +```yaml +cameras: + front_door: + objects: + genai: + enabled: True + use_snapshot: True + prompt: "Analyze the {label} in these images from the {camera} security camera at the front door. Focus on the actions and potential intent of the {label}." + object_prompts: + person: "Examine the person in these images. What are they doing, and how might their actions suggest their purpose (e.g., delivering something, approaching, leaving)? If they are carrying or interacting with a package, include details about its source or destination." + cat: "Observe the cat in these images. Focus on its movement and intent (e.g., wandering, hunting, interacting with objects). If the cat is near the flower pots or engaging in any specific actions, mention it." + objects: + - person + - cat + required_zones: + - steps +``` + +### Experiment with prompts + +Many providers also have a public facing chat interface for their models. Download a couple of different thumbnails or snapshots from Frigate and try new things in the playground to get descriptions to your liking before updating the prompt in Frigate. + +- OpenAI - [ChatGPT](https://chatgpt.com) +- Gemini - [Google AI Studio](https://aistudio.google.com) +- Ollama - [Open WebUI](https://docs.openwebui.com/) diff --git a/docs/docs/configuration/genai/review_summaries.md b/docs/docs/configuration/genai/review_summaries.md new file mode 100644 index 0000000000..4acb5a70c1 --- /dev/null +++ b/docs/docs/configuration/genai/review_summaries.md @@ -0,0 +1,44 @@ +--- +id: genai_review +title: Review Summaries +--- + +Generative AI can be used to automatically generate structured summaries of review items. These summaries will show up in Frigate's native notifications as well as in the UI. Generative AI can also be used to take a collection of summaries over a period of time and provide a report, which may be useful to get a quick report of everything that happened while out for some amount of time. + +Requests for a summary are requested automatically to your AI provider for alert review items when the activity has ended, they can also be optionally enabled for detections as well. + +Generative AI review summaries can also be toggled dynamically for a camera via MQTT with the topic `frigate//review_descriptions/set`. See the [MQTT documentation](/integrations/mqtt/#frigatecamera_namereviewdescriptionsset). + +## Review Summary Usage and Best Practices + +Review summaries provide structured JSON responses that are saved for each review item: + +``` +- `scene` (string): A full description including setting, entities, actions, and any plausible supported inferences. +- `confidence` (float): 0-1 confidence in the analysis. +- `other_concerns` (list): List of user-defined concerns that may need additional investigation. +- `potential_threat_level` (integer): 0, 1, or 2 as defined below. + +Threat-level definitions: +- 0 — Typical or expected activity for this location/time (includes residents, guests, or known animals engaged in normal activities, even if they glance around or scan surroundings). +- 1 — Unusual or suspicious activity: At least one security-relevant behavior is present **and not explainable by a normal residential activity**. +- 2 — Active or immediate threat: Breaking in, vandalism, aggression, weapon display. +``` + +This will show in the UI as a list of concerns that each review item has along with the general description. + +### Additional Concerns + +Along with the concern of suspicious activity or immediate threat, you may have concerns such as animals in your garden or a gate being left open. These concerns can be configured so that the review summaries will make note of them if the activity requires additional review. For example: + +```yaml +review: + genai: + enabled: true + additional_concerns: + - animals in the garden +``` + +## Review Reports + +Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review. \ No newline at end of file diff --git a/docs/docs/configuration/hardware_acceleration_enrichments.md b/docs/docs/configuration/hardware_acceleration_enrichments.md index 1f894d3454..84688b8b43 100644 --- a/docs/docs/configuration/hardware_acceleration_enrichments.md +++ b/docs/docs/configuration/hardware_acceleration_enrichments.md @@ -5,11 +5,11 @@ title: Enrichments # Enrichments -Some of Frigate's enrichments can use a discrete GPU for accelerated processing. +Some of Frigate's enrichments can use a discrete GPU / NPU for accelerated processing. ## Requirements -Object detection and enrichments (like Semantic Search, Face Recognition, and License Plate Recognition) are independent features. To use a GPU for object detection, see the [Object Detectors](/configuration/object_detectors.md) documentation. If you want to use your GPU for any supported enrichments, you must choose the appropriate Frigate Docker image for your GPU and configure the enrichment according to its specific documentation. +Object detection and enrichments (like Semantic Search, Face Recognition, and License Plate Recognition) are independent features. To use a GPU / NPU for object detection, see the [Object Detectors](/configuration/object_detectors.md) documentation. If you want to use your GPU for any supported enrichments, you must choose the appropriate Frigate Docker image for your GPU / NPU and configure the enrichment according to its specific documentation. - **AMD** @@ -23,6 +23,9 @@ Object detection and enrichments (like Semantic Search, Face Recognition, and Li - Nvidia GPUs will automatically be detected and used for enrichments in the `-tensorrt` Frigate image. - Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image. +- **RockChip** + - RockChip NPU will automatically be detected and used for semantic search v1 and face recognition in the `-rk` Frigate image. + Utilizing a GPU for enrichments does not require you to use the same GPU for object detection. For example, you can run the `tensorrt` Docker image for enrichments and still use other dedicated hardware like a Coral or Hailo for object detection. However, one combination that is not supported is TensorRT for object detection and OpenVINO for enrichments. :::note diff --git a/docs/docs/configuration/hardware_acceleration_video.md b/docs/docs/configuration/hardware_acceleration_video.md index cb8d7007b9..b7cb794aeb 100644 --- a/docs/docs/configuration/hardware_acceleration_video.md +++ b/docs/docs/configuration/hardware_acceleration_video.md @@ -427,3 +427,29 @@ cameras: ``` ::: + +## Synaptics + +Hardware accelerated video de-/encoding is supported on Synpatics SL-series SoC. + +### Prerequisites + +Make sure to follow the [Synaptics specific installation instructions](/frigate/installation#synaptics). + +### Configuration + +Add one of the following FFmpeg presets to your `config.yml` to enable hardware video processing: + +```yaml +ffmpeg: + hwaccel_args: -c:v h264_v4l2m2m + input_args: preset-rtsp-restream +output_args: + record: preset-record-generic-audio-aac +``` + +:::warning + +Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet. + +::: diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index 933fd72d36..e1095ff082 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -67,12 +67,15 @@ Fine-tune the LPR feature using these optional parameters at the global level of - **`min_area`**: Defines the minimum area (in pixels) a license plate must be before recognition runs. - Default: `1000` pixels. Note: this is intentionally set very low as it is an _area_ measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image. - Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates. -- **`device`**: Device to use to run license plate recognition models. +- **`device`**: Device to use to run license plate detection _and_ recognition models. - Default: `CPU` - - This can be `CPU` or `GPU`. For users without a model that detects license plates natively, using a GPU may increase performance of the models, especially the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. -- **`model_size`**: The size of the model used to detect text on plates. + - This can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. However, for users who run a model that detects `license_plate` natively, there is little to no performance gain reported with running LPR on GPU compared to the CPU. +- **`model_size`**: The size of the model used to identify regions of text on plates. - Default: `small` - - This can be `small` or `large`. The `large` model uses an enhanced text detector and is more accurate at finding text on plates but slower than the `small` model. For most users, the small model is recommended. For users in countries with multiple lines of text on plates, the large model is recommended. Note that using the large model does not improve _text recognition_, but it may improve _text detection_. + - This can be `small` or `large`. + - The `small` model is fast and identifies groups of Latin and Chinese characters. + - The `large` model identifies Latin characters only, but uses an enhanced text detector and is more capable at finding characters on multi-line plates. It is significantly slower than the `small` model. Note that using the `large` model does not improve _text recognition_, but it may improve _text detection_. + - For most users, the `small` model is recommended. ### Recognition @@ -102,6 +105,32 @@ Fine-tune the LPR feature using these optional parameters at the global level of - This setting is best adjusted at the camera level if running LPR on multiple cameras. - If Frigate is already recognizing plates correctly, leave this setting at the default of `0`. However, if you're experiencing frequent character issues or incomplete plates and you can already easily read the plates yourself, try increasing the value gradually, starting at 5 and adjusting as needed. You should see how different enhancement levels affect your plates. Use the `debug_save_plates` configuration option (see below). +### Normalization Rules + +- **`replace_rules`**: List of regex replacement rules to normalize detected plates. These rules are applied sequentially. Each rule must have a `pattern` (which can be a string or a regex, prepended by `r`) and `replacement` (a string, which also supports [backrefs](https://docs.python.org/3/library/re.html#re.sub) like `\1`). These rules are useful for dealing with common OCR issues like noise characters, separators, or confusions (e.g., 'O'→'0'). + +These rules must be defined at the global level of your `lpr` config. + +```yaml +lpr: + replace_rules: + - pattern: r'[%#*?]' # Remove noise symbols + replacement: "" + - pattern: r'[= ]' # Normalize = or space to dash + replacement: "-" + - pattern: "O" # Swap 'O' to '0' (common OCR error) + replacement: "0" + - pattern: r'I' # Swap 'I' to '1' + replacement: "1" + - pattern: r'(\w{3})(\w{3})' # Split 6 chars into groups (e.g., ABC123 → ABC-123) + replacement: r'\1-\2' +``` + +- Rules fire in order: In the example above: clean noise first, then separators, then swaps, then splits. +- Backrefs (`\1`, `\2`) allow dynamic replacements (e.g., capture groups). +- Any changes made by the rules are printed to the LPR debug log. +- Tip: You can test patterns with tools like regex101.com. + ### Debugging - **`debug_save_plates`**: Set to `True` to save captured text on plates for debugging. These images are stored in `/media/frigate/clips/lpr`, organized into subdirectories by `/`, and named based on the capture timestamp. @@ -136,6 +165,9 @@ lpr: recognition_threshold: 0.85 format: "^[A-Z]{2} [A-Z][0-9]{4}$" # Only recognize plates that are two letters, followed by a space, followed by a single letter and 4 numbers match_distance: 1 # Allow one character variation in plate matching + replace_rules: + - pattern: "O" + replacement: "0" # Replace the letter O with the number 0 in every plate known_plates: Delivery Van: - "RJ K5678" diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index d9bc107f2a..3131375918 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -176,6 +176,8 @@ For devices that support two way talk, Frigate can be configured to use the feat To use the Reolink Doorbell with two way talk, you should use the [recommended Reolink configuration](/configuration/camera_specific#reolink-doorbell) +As a starting point to check compatibility for your camera, view the list of cameras supported for two-way talk on the [go2rtc repository](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#two-way-audio). For cameras in the category `ONVIF Profile T`, you can use the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/)'s FeatureList to check for the presence of `AudioOutput`. A camera that supports `ONVIF Profile T` _usually_ supports this, but due to inconsistent support, a camera that explicitly lists this feature may still not work. If no entry for your camera exists on the database, it is recommended not to buy it or to consult with the manufacturer's support on the feature availability. + ### Streaming options on camera group dashboards Frigate provides a dialog in the Camera Group Edit pane with several options for streaming on a camera group's dashboard. These settings are _per device_ and are saved in your device's local storage. @@ -228,7 +230,26 @@ Note that disabling a camera through the config file (`enabled: False`) removes If you are using continuous streaming or you are loading more than a few high resolution streams at once on the dashboard, your browser may struggle to begin playback of your streams before the timeout. Frigate always prioritizes showing a live stream as quickly as possible, even if it is a lower quality jsmpeg stream. You can use the "Reset" link/button to try loading your high resolution stream again. - If you are still experiencing Frigate falling back to low bandwidth mode, you may need to adjust your camera's settings per the [recommendations above](#camera_settings_recommendations). + Errors in stream playback (e.g., connection failures, codec issues, or buffering timeouts) that cause the fallback to low bandwidth mode (jsmpeg) are logged to the browser console for easier debugging. These errors may include: + + - Network issues (e.g., MSE or WebRTC network connection problems). + - Unsupported codecs or stream formats (e.g., H.265 in WebRTC, which is not supported in some browsers). + - Buffering timeouts or low bandwidth conditions causing fallback to jsmpeg. + - Browser compatibility problems (e.g., iOS Safari limitations with MSE). + + To view browser console logs: + + 1. Open the Frigate Live View in your browser. + 2. Open the browser's Developer Tools (F12 or right-click > Inspect > Console tab). + 3. Reproduce the error (e.g., load a problematic stream or simulate network issues). + 4. Look for messages prefixed with the camera name. + + These logs help identify if the issue is player-specific (MSE vs. WebRTC) or related to camera configuration (e.g., go2rtc streams, codecs). If you see frequent errors: + + - Verify your camera's H.264/AAC settings (see [Frigate's camera settings recommendations](#camera_settings_recommendations)). + - Check go2rtc configuration for transcoding (e.g., audio to AAC/OPUS). + - Test with a different stream via the UI dropdown (if `live -> streams` is configured). + - For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see (WebRTC Extra Configuration)(#webrtc-extra-configuration)). 3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?** diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 1e68d6ff44..25c30d0f1f 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -13,12 +13,18 @@ Frigate supports multiple different detectors that work on different types of ha - [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices. - [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices. +- [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms. +- [DeGirum](#degirum): Service for using hardware devices in the cloud or locally. Hardware and models provided on the cloud on [their website](https://hub.degirum.com). **AMD** - [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection. - [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured. +**Apple Silicon** + +- [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices. + **Intel** - [OpenVino](#openvino-detector): OpenVino can run on Intel Arc GPUs, Intel integrated GPUs, and Intel CPUs to provide efficient object detection. @@ -37,6 +43,10 @@ Frigate supports multiple different detectors that work on different types of ha - [RKNN](#rockchip-platform): RKNN models can run on Rockchip devices with included NPUs. +**Synaptics** + +- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs. + **For Testing** - [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results. @@ -53,7 +63,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`, 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`, `memryx`, `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 @@ -265,7 +275,7 @@ detectors: ::: -### Supported Models +### OpenVINO Supported Models #### SSDLite MobileNet v2 @@ -409,6 +419,60 @@ model: Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. +## Apple Silicon detector + +The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`. + +### Setup + +1. Setup the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) and run the client +2. Configure the detector in Frigate and startup Frigate + +### Configuration + +Using the detector config below will connect to the client: + +```yaml +detectors: + apple-silicon: + type: zmq + endpoint: tcp://host.docker.internal:5555 +``` + +### Apple Silicon Supported Models + +There is no default model provided, the following formats are supported: + +#### YOLO (v3, v4, v7, v9) + +YOLOv3, YOLOv4, YOLOv7, and [YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default. + +:::tip + +The YOLO detector has been designed to support YOLOv3, YOLOv4, YOLOv7, and YOLOv9 models, but may support other YOLO model architectures as well. See [the models section](#downloading-yolo-models) for more information on downloading YOLO models for use in Frigate. + +::: + +When Frigate is started with the following config it will connect to the detector client and transfer the model automatically: + +```yaml +detectors: + apple-silicon: + type: zmq + endpoint: tcp://host.docker.internal:5555 + +model: + model_type: yolo-generic + width: 320 # <--- should match the imgsize set during model export + height: 320 # <--- should match the imgsize set during model export + input_tensor: nchw + input_dtype: float + path: /config/model_cache/yolo.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. + ## AMD/ROCm GPU detector ### Setup @@ -489,7 +553,7 @@ We unset the `HSA_OVERRIDE_GFX_VERSION` to prevent an existing override from mes $ docker exec -it frigate /bin/bash -c '(unset HSA_OVERRIDE_GFX_VERSION && /opt/rocm/bin/rocminfo |grep gfx)' ``` -### Supported Models +### ROCm Supported Models See [ONNX supported models](#supported-models) for supported models, there are some caveats: @@ -532,7 +596,7 @@ detectors: ::: -### Supported Models +### ONNX Supported Models There is no default model provided, the following formats are supported: @@ -717,6 +781,196 @@ To verify that the integration is working correctly, start Frigate and observe t # Community Supported Detectors +## MemryX MX3 + +This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection. + +See the [installation docs](../frigate/installation.md#memryx-mx3) for information on configuring the MemryX hardware. + +To configure a MemryX detector, simply set the `type` attribute to `memryx` and follow the configuration guide below. + +### Configuration + +To configure the MemryX detector, use the following example configuration: + +#### Single PCIe MemryX MX3 + +```yaml +detectors: + memx0: + type: memryx + device: PCIe:0 +``` + +#### Multiple PCIe MemryX MX3 Modules + +```yaml +detectors: + memx0: + type: memryx + device: PCIe:0 + + memx1: + type: memryx + device: PCIe:1 + + memx2: + type: memryx + device: PCIe:2 +``` + +### Supported Models + +MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`. + +#### YOLO-NAS + +The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage). + +**Note:** The default model for the MemryX detector is YOLO-NAS 320x320. + +The input size for **YOLO-NAS** can be set to either **320x320** (default) or **640x640**. + +- The default size of **320x320** is optimized for lower CPU usage and faster inference times. + +##### Configuration + +Below is the recommended configuration for using the **YOLO-NAS** (small) model with the MemryX detector: + +```yaml +detectors: + memx0: + type: memryx + device: PCIe:0 + +model: + model_type: yolonas + width: 320 # (Can be set to 640 for higher resolution) + height: 320 # (Can be set to 640 for higher resolution) + input_tensor: nchw + input_dtype: float + labelmap_path: /labelmap/coco-80.txt + # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. + # path: /config/yolonas.zip + # The .zip file must contain: + # ├── yolonas.dfp (a file ending with .dfp) + # └── yolonas_post.onnx (optional; only if the model includes a cropped post-processing network) +``` + +#### YOLOv9 + +The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage). + +##### Configuration + +Below is the recommended configuration for using the **YOLOv9** (small) model with the MemryX detector: + +```yaml +detectors: + memx0: + type: memryx + device: PCIe:0 + +model: + model_type: yolo-generic + width: 320 # (Can be set to 640 for higher resolution) + height: 320 # (Can be set to 640 for higher resolution) + input_tensor: nchw + input_dtype: float + labelmap_path: /labelmap/coco-80.txt + # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. + # path: /config/yolov9.zip + # The .zip file must contain: + # ├── yolov9.dfp (a file ending with .dfp) + # └── yolov9_post.onnx (optional; only if the model includes a cropped post-processing network) +``` + +#### YOLOX + +The model is sourced from the [OpenCV Model Zoo](https://github.com/opencv/opencv_zoo) and precompiled to DFP. + +##### Configuration + +Below is the recommended configuration for using the **YOLOX** (small) model with the MemryX detector: + +```yaml +detectors: + memx0: + type: memryx + device: PCIe:0 + +model: + model_type: yolox + width: 640 + height: 640 + input_tensor: nchw + input_dtype: float_denorm + labelmap_path: /labelmap/coco-80.txt + # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. + # path: /config/yolox.zip + # The .zip file must contain: + # ├── yolox.dfp (a file ending with .dfp) +``` + +#### SSDLite MobileNet v2 + +The model is sourced from the [OpenMMLab Model Zoo](https://mmdeploy-oss.openmmlab.com/model/mmdet-det/ssdlite-e8679f.onnx) and has been converted to DFP. + +##### Configuration + +Below is the recommended configuration for using the **SSDLite MobileNet v2** model with the MemryX detector: + +```yaml +detectors: + memx0: + type: memryx + device: PCIe:0 + +model: + model_type: ssd + width: 320 + height: 320 + input_tensor: nchw + input_dtype: float + labelmap_path: /labelmap/coco-80.txt + # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model. + # path: /config/ssdlite_mobilenet.zip + # The .zip file must contain: + # ├── ssdlite_mobilenet.dfp (a file ending with .dfp) + # └── ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network) +``` + +#### Using a Custom Model + +To use your own model: + +1. Package your compiled model into a `.zip` file. + +2. The `.zip` must contain the compiled `.dfp` file. + +3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`. + +4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config. + +5. Update the `labelmap_path` to match your custom model's labels. + +For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/tutorials/tutorials.html). + +```yaml + # The detector automatically selects the default model if nothing is provided in the config. + # + # Optionally, you can specify a local model path as a .zip file to override the default. + # If a local path is provided and the file exists, it will be used instead of downloading. + # + # Example: + # path: /config/yolonas.zip + # + # The .zip file must contain: + # ├── yolonas.dfp (a file ending with .dfp) + # └── yolonas_post.onnx (optional; only if the model includes a cropped post-processing network) +``` +--- + ## NVidia TensorRT Detector Nvidia Jetson devices may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt-jp6` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt-jp6`. This detector is designed to work with Yolo models for object detection. @@ -799,6 +1053,41 @@ model: height: 320 # MUST match the chosen model i.e yolov7-320 -> 320 yolov4-416 -> 416 ``` +## Synaptics + +Hardware accelerated object detection is supported on the following SoCs: + +- SL1680 + +This implementation uses the [Synaptics model conversion](https://synaptics-synap.github.io/doc/v/latest/docs/manual/introduction.html#offline-model-conversion), version v3.1.0. + +This implementation is based on sdk `v1.5.0`. + +See the [installation docs](../frigate/installation.md#synaptics) for information on configuring the SL-series NPU hardware. + +### Configuration + +When configuring the Synap detector, you have to specify the model: a local **path**. + +#### SSD Mobilenet + +A synap model is provided in the container at /mobilenet.synap and is used by this detector type by default. The model comes from [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80). + +Use the model configuration shown below when using the synaptics detector with the default synap model: + +```yaml +detectors: # required + synap_npu: # required + type: synaptics # required + +model: # required + path: /synaptics/mobilenet.synap # required + width: 224 # required + height: 224 # required + tensor_format: nhwc # default value (optional. If you change the model, it is required) + labelmap_path: /labelmap/coco-80.txt # required +``` + ## Rockchip platform Hardware accelerated object detection is supported on the following SoCs: @@ -842,7 +1131,7 @@ $ cat /sys/kernel/debug/rknpu/load ::: -### Supported Models +### RockChip Supported Models This `config.yml` shows all relevant options to configure the detector and explains them. All values shown are the default values (except for two). Lines that are required at least to use the detector are labeled as required, all other lines are optional. @@ -968,6 +1257,101 @@ Explanation of the paramters: - **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`. - `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.2/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.2_EN.pdf). +## DeGirum + +DeGirum is a detector that can use any type of hardware listed on [their website](https://hub.degirum.com). DeGirum can be used with local hardware through a DeGirum AI Server, or through the use of `@local`. You can also connect directly to DeGirum's AI Hub to run inferences. **Please Note:** This detector *cannot* be used for commercial purposes. + +### Configuration + +#### AI Server Inference + +Before starting with the config file for this section, you must first launch an AI server. DeGirum has an AI server ready to use as a docker container. Add this to your `docker-compose.yml` to get started: +```yaml +degirum_detector: + container_name: degirum + image: degirum/aiserver:latest + privileged: true + ports: + - "8778:8778" +``` +All supported hardware will automatically be found on your AI server host as long as relevant runtimes and drivers are properly installed on your machine. Refer to [DeGirum's docs site](https://docs.degirum.com/pysdk/runtimes-and-drivers) if you have any trouble. + +Once completed, changing the `config.yml` file is simple. +```yaml +degirum_detector: + type: degirum + location: degirum # Set to service name (degirum_detector), container_name (degirum), or a host:port (192.168.29.4:8778) + zoo: degirum/public # DeGirum's public model zoo. Zoo name should be in format "workspace/zoo_name". degirum/public is available to everyone, so feel free to use it if you don't know where to start. If you aren't pulling a model from the AI Hub, leave this and 'token' blank. + token: dg_example_token # For authentication with the AI Hub. Get this token through the "tokens" section on the main page of the [AI Hub](https://hub.degirum.com). This can be left blank if you're pulling a model from the public zoo and running inferences on your local hardware using @local or a local DeGirum AI Server +``` +Setting up a model in the `config.yml` is similar to setting up an AI server. +You can set it to: +- A model listed on the [AI Hub](https://hub.degirum.com), given that the correct zoo name is listed in your detector + - If this is what you choose to do, the correct model will be downloaded onto your machine before running. +- A local directory acting as a zoo. See DeGirum's docs site [for more information](https://docs.degirum.com/pysdk/user-guide-pysdk/organizing-models#model-zoo-directory-structure). +- A path to some model.json. +```yaml +model: + path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 # directory to model .json and file + width: 300 # width is in the model name as the first number in the "int"x"int" section + height: 300 # height is in the model name as the second number in the "int"x"int" section + input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here +``` + + +#### Local Inference + +It is also possible to eliminate the need for an AI server and run the hardware directly. The benefit of this approach is that you eliminate any bottlenecks that occur when transferring prediction results from the AI server docker container to the frigate one. However, the method of implementing local inference is different for every device and hardware combination, so it's usually more trouble than it's worth. A general guideline to achieve this would be: +1. Ensuring that the frigate docker container has the runtime you want to use. So for instance, running `@local` for Hailo means making sure the container you're using has the Hailo runtime installed. +2. To double check the runtime is detected by the DeGirum detector, make sure the `degirum sys-info` command properly shows whatever runtimes you mean to install. +3. Create a DeGirum detector in your `config.yml` file. + +```yaml +degirum_detector: + type: degirum + location: "@local" # For accessing AI Hub devices and models + zoo: degirum/public # DeGirum's public model zoo. Zoo name should be in format "workspace/zoo_name". degirum/public is available to everyone, so feel free to use it if you don't know where to start. + token: dg_example_token # For authentication with the AI Hub. Get this token through the "tokens" section on the main page of the [AI Hub](https://hub.degirum.com). This can be left blank if you're pulling a model from the public zoo and running inferences on your local hardware using @local or a local DeGirum AI Server + +``` + +Once `degirum_detector` is setup, you can choose a model through 'model' section in the `config.yml` file. + +```yaml +model: + path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 + width: 300 # width is in the model name as the first number in the "int"x"int" section + height: 300 # height is in the model name as the second number in the "int"x"int" section + input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here +``` + + +#### AI Hub Cloud Inference + +If you do not possess whatever hardware you want to run, there's also the option to run cloud inferences. Do note that your detection fps might need to be lowered as network latency does significantly slow down this method of detection. For use with Frigate, we highly recommend using a local AI server as described above. To set up cloud inferences, +1. Sign up at [DeGirum's AI Hub](https://hub.degirum.com). +2. Get an access token. +3. Create a DeGirum detector in your `config.yml` file. + +```yaml +degirum_detector: + type: degirum + location: "@cloud" # For accessing AI Hub devices and models + zoo: degirum/public # DeGirum's public model zoo. Zoo name should be in format "workspace/zoo_name". degirum/public is available to everyone, so feel free to use it if you don't know where to start. + token: dg_example_token # For authentication with the AI Hub. Get this token through the "tokens" section on the main page of the (AI Hub)[https://hub.degirum.com). + +``` + +Once `degirum_detector` is setup, you can choose a model through 'model' section in the `config.yml` file. + +```yaml +model: + path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 + width: 300 # width is in the model name as the first number in the "int"x"int" section + height: 300 # height is in the model name as the second number in the "int"x"int" section + input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here +``` + # Models Some model types are not included in Frigate by default. diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index 52c0f0c889..2745ef27d0 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -13,14 +13,15 @@ H265 recordings can be viewed in Chrome 108+, Edge and Safari only. All other br ### Most conservative: Ensure all video is saved -For users deploying Frigate in environments where it is important to have contiguous video stored even if there was no detectable motion, the following config will store all video for 3 days. After 3 days, only video containing motion and overlapping with alerts or detections will be retained until 30 days have passed. +For users deploying Frigate in environments where it is important to have contiguous video stored even if there was no detectable motion, the following config will store all video for 3 days. After 3 days, only video containing motion will be saved for 7 days. After 7 days, only video containing motion and overlapping with alerts or detections will be retained until 30 days have passed. ```yaml record: enabled: True - retain: + continuous: days: 3 - mode: all + motion: + days: 7 alerts: retain: days: 30 @@ -38,9 +39,8 @@ In order to reduce storage requirements, you can adjust your config to only reta ```yaml record: enabled: True - retain: + motion: days: 3 - mode: motion alerts: retain: days: 30 @@ -58,7 +58,7 @@ If you only want to retain video that occurs during a tracked object, this confi ```yaml record: enabled: True - retain: + continuous: days: 0 alerts: retain: @@ -80,15 +80,17 @@ Retention configs support decimals meaning they can be configured to retain `0.5 ::: -### Continuous Recording +### Continuous and Motion Recording -The number of days to retain continuous recordings can be set via the following config where X is a number, by default continuous recording is disabled. +The number of days to retain continuous and motion recordings can be set via the following config where X is a number, by default continuous recording is disabled. ```yaml record: enabled: True - retain: + continuous: days: 1 # <- number of days to keep continuous recordings + motion: + days: 2 # <- number of days to keep motion recordings ``` Continuous recording supports different retention modes [which are described below](#what-do-the-different-retain-modes-mean) @@ -112,38 +114,6 @@ This configuration will retain recording segments that overlap with alerts and d **WARNING**: Recordings still must be enabled in the config. If a camera has recordings disabled in the config, enabling via the methods listed above will have no effect. -## What do the different retain modes mean? - -Frigate saves from the stream with the `record` role in 10 second segments. These options determine which recording segments are kept for continuous recording (but can also affect tracked objects). - -Let's say you have Frigate configured so that your doorbell camera would retain the last **2** days of continuous recording. - -- With the `all` option all 48 hours of those two days would be kept and viewable. -- With the `motion` option the only parts of those 48 hours would be segments that Frigate detected motion. This is the middle ground option that won't keep all 48 hours, but will likely keep all segments of interest along with the potential for some extra segments. -- With the `active_objects` option the only segments that would be kept are those where there was a true positive object that was not considered stationary. - -The same options are available with alerts and detections, except it will only save the recordings when it overlaps with a review item of that type. - -A configuration example of the above retain modes where all `motion` segments are stored for 7 days and `active objects` are stored for 14 days would be as follows: - -```yaml -record: - enabled: True - retain: - days: 7 - mode: motion - alerts: - retain: - days: 14 - mode: active_objects - detections: - retain: - days: 14 - mode: active_objects -``` - -The above configuration example can be added globally or on a per camera basis. - ## Can I have "continuous" recordings, but only at certain times? Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times. diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 858b6e9352..5f4cff4e71 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -73,6 +73,12 @@ tls: # Optional: Enable TLS for port 8971 (default: shown below) enabled: True +# Optional: IPv6 configuration +networking: + # Optional: Enable IPv6 on 5000, and 8971 if tls is configured (default: shown below) + ipv6: + enabled: False + # Optional: Proxy configuration proxy: # Optional: Mapping for headers from upstream proxies. Only used if Frigate's auth @@ -82,7 +88,13 @@ proxy: # See the docs for more info. header_map: user: x-forwarded-user - role: x-forwarded-role + role: x-forwarded-groups + role_map: + admin: + - sysadmins + - access-level-security + viewer: + - camera-viewer # Optional: Url for logging out a user. This sets the location of the logout url in # the UI. logout_url: /api/logout @@ -275,6 +287,9 @@ detect: max_disappeared: 25 # Optional: Configuration for stationary object tracking stationary: + # Optional: Stationary classifier that uses visual characteristics to determine if an object + # is stationary even if the box changes enough to be considered motion (default: shown below). + classifier: True # Optional: Frequency for confirming stationary objects (default: same as threshold) # When set to 1, object detection will run to confirm the object still exists on every frame. # If set to 10, object detection will run to confirm the object still exists on every 10th frame. @@ -339,6 +354,33 @@ objects: # Optional: mask to prevent this object type from being detected in certain areas (default: no mask) # Checks based on the bottom center of the bounding box of the object mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278 + # Optional: Configuration for AI generated tracked object descriptions + genai: + # Optional: Enable AI object description generation (default: shown below) + enabled: False + # Optional: Use the object snapshot instead of thumbnails for description generation (default: shown below) + use_snapshot: False + # Optional: The default prompt for generating descriptions. Can use replacement + # variables like "label", "sub_label", "camera" to make more dynamic. (default: shown below) + prompt: "Describe the {label} in the sequence of images with as much detail as possible. Do not describe the background." + # Optional: Object specific prompts to customize description results + # Format: {label}: {prompt} + object_prompts: + person: "My special person prompt." + # Optional: objects to generate descriptions for (default: all objects that are tracked) + objects: + - person + - cat + # Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify) + required_zones: [] + # Optional: What triggers to use to send frames for a tracked object to generative AI (default: shown below) + send_triggers: + # Once the object is no longer tracked + tracked_object_end: True + # Optional: After X many significant updates are received (default: shown below) + after_significant_updates: None + # Optional: Save thumbnails sent to generative AI for review/debugging purposes (default: shown below) + debug_save_thumbnails: False # Optional: Review configuration # NOTE: Can be overridden at the camera level @@ -351,6 +393,8 @@ review: labels: - car - person + # Time to cutoff alerts after no alert-causing activity has occurred (default: shown below) + cutoff_time: 40 # Optional: required zones for an object to be marked as an alert (default: none) # NOTE: when settings required zones globally, this zone must exist on all cameras # or the config will be considered invalid. In that case the required_zones @@ -365,12 +409,27 @@ review: labels: - car - person + # Time to cutoff detections after no detection-causing activity has occurred (default: shown below) + cutoff_time: 30 # Optional: required zones for an object to be marked as a detection (default: none) # NOTE: when settings required zones globally, this zone must exist on all cameras # or the config will be considered invalid. In that case the required_zones # should be configured at the camera level. required_zones: - driveway + # Optional: GenAI Review Summary Configuration + genai: + # Optional: Enable the GenAI review summary feature (default: shown below) + enabled: False + # Optional: Enable GenAI review summaries for alerts (default: shown below) + alerts: True + # Optional: Enable GenAI review summaries for detections (default: shown below) + detections: False + # Optional: Additional concerns that the GenAI should make note of (default: None) + additional_concerns: + - Animals in the garden + # Optional: Preferred response language (default: English) + preferred_language: English # Optional: Motion configuration # NOTE: Can be overridden at the camera level @@ -440,18 +499,18 @@ record: expire_interval: 60 # Optional: Two-way sync recordings database with disk on startup and once a day (default: shown below). sync_recordings: False - # Optional: Retention settings for recording - retain: + # Optional: Continuous retention settings + continuous: + # Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below) + # NOTE: This should be set to 0 and retention should be defined in alerts and detections section below + # if you only want to retain recordings of alerts and detections. + days: 0 + # Optional: Motion retention settings + motion: # Optional: Number of days to retain recordings regardless of tracked objects (default: shown below) # NOTE: This should be set to 0 and retention should be defined in alerts and detections section below # if you only want to retain recordings of alerts and detections. days: 0 - # Optional: Mode for retention. Available options are: all, motion, and active_objects - # all - save all recording segments regardless of activity - # motion - save all recordings segments with any detected motion - # active_objects - save all recording segments with active/moving objects - # NOTE: this mode only applies when the days setting above is greater than 0 - mode: all # Optional: Recording Export Settings export: # Optional: Timelapse Output Args (default: shown below). @@ -546,6 +605,9 @@ semantic_search: # Optional: Set the model size used for embeddings. (default: shown below) # NOTE: small model runs on CPU and large model runs on GPU model_size: "small" + # Optional: Target a specific device to run the model (default: shown below) + # NOTE: See https://onnxruntime.ai/docs/execution-providers/ for more information + device: None # Optional: Configuration for face recognition capability # NOTE: enabled, min_area can be overridden at the camera level @@ -569,6 +631,9 @@ face_recognition: blur_confidence_filter: True # Optional: Set the model size used face recognition. (default: shown below) model_size: small + # Optional: Target a specific device to run the model (default: shown below) + # NOTE: See https://onnxruntime.ai/docs/execution-providers/ for more information + device: None # Optional: Configuration for license plate recognition capability # NOTE: enabled, min_area, and enhancement can be overridden at the camera level @@ -576,6 +641,7 @@ lpr: # Optional: Enable license plate recognition (default: shown below) enabled: False # Optional: The device to run the models on (default: shown below) + # NOTE: See https://onnxruntime.ai/docs/execution-providers/ for more information device: CPU # Optional: Set the model size used for text detection. (default: shown below) model_size: small @@ -598,6 +664,8 @@ lpr: enhancement: 0 # Optional: Save plate images to /media/frigate/clips/lpr for debugging purposes (default: shown below) debug_save_plates: False + # Optional: List of regex replacement rules to normalize detected plates (default: shown below) + replace_rules: {} # Optional: Configuration for AI generated tracked object descriptions # WARNING: Depending on the provider, this will send thumbnails over the internet @@ -612,16 +680,27 @@ genai: base_url: http://localhost::11434 # Required if gemini or openai api_key: "{FRIGATE_GENAI_API_KEY}" - # Optional: The default prompt for generating descriptions. Can use replacement - # variables like "label", "sub_label", "camera" to make more dynamic. (default: shown below) - prompt: "Describe the {label} in the sequence of images with as much detail as possible. Do not describe the background." - # Optional: Object specific prompts to customize description results - # Format: {label}: {prompt} - object_prompts: - person: "My special person prompt." + # Required if enabled: The model to use with the provider. + model: gemini-1.5-flash + # Optional additional args to pass to the GenAI Provider (default: None) + provider_options: + keep_alive: -1 + +# Optional: Configuration for audio transcription +# NOTE: only the enabled option can be overridden at the camera level +audio_transcription: + # Optional: Enable license plate recognition (default: shown below) + enabled: False + # Optional: The device to run the models on (default: shown below) + device: CPU + # Optional: Set the model size used for transcription. (default: shown below) + model_size: small + # Optional: Set the language used for transcription translation. (default: shown below) + # List of language codes: https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10 + language: en # Optional: Restream configuration -# Uses https://github.com/AlexxIT/go2rtc (v1.9.9) +# Uses https://github.com/AlexxIT/go2rtc (v1.9.10) # NOTE: The default go2rtc API port (1984) must be used, # changing this port for the integrated go2rtc instance is not supported. go2rtc: @@ -827,33 +906,22 @@ cameras: # By default the cameras are sorted alphabetically. order: 0 - # Optional: Configuration for AI generated tracked object descriptions - genai: - # Optional: Enable AI description generation (default: shown below) - enabled: False - # Optional: Use the object snapshot instead of thumbnails for description generation (default: shown below) - use_snapshot: False - # Optional: The default prompt for generating descriptions. Can use replacement - # variables like "label", "sub_label", "camera" to make more dynamic. (default: shown below) - prompt: "Describe the {label} in the sequence of images with as much detail as possible. Do not describe the background." - # Optional: Object specific prompts to customize description results - # Format: {label}: {prompt} - object_prompts: - person: "My special person prompt." - # Optional: objects to generate descriptions for (default: all objects that are tracked) - objects: - - person - - cat - # Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify) - required_zones: [] - # Optional: What triggers to use to send frames for a tracked object to generative AI (default: shown below) - send_triggers: - # Once the object is no longer tracked - tracked_object_end: True - # Optional: After X many significant updates are received (default: shown below) - after_significant_updates: None - # Optional: Save thumbnails sent to generative AI for review/debugging purposes (default: shown below) - debug_save_thumbnails: False + # Optional: Configuration for triggers to automate actions based on semantic search results. + triggers: + # Required: Unique identifier for the trigger (generated automatically from friendly_name if not specified). + trigger_name: + # Required: Enable or disable the trigger. (default: shown below) + enabled: true + # Type of trigger, either `thumbnail` for image-based matching or `description` for text-based matching. (default: none) + type: thumbnail + # Reference data for matching, either an event ID for `thumbnail` or a text string for `description`. (default: none) + data: 1751565549.853251-b69j73 + # Similarity threshold for triggering. (default: none) + threshold: 0.7 + # List of actions to perform when the trigger fires. (default: none) + # Available options: `notification` (send a webpush notification) + actions: + - notification # Optional ui: diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md index 4564595cc1..43c421dd3e 100644 --- a/docs/docs/configuration/restream.md +++ b/docs/docs/configuration/restream.md @@ -7,7 +7,7 @@ title: Restream Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://:8554/`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate. -Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.9) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#configuration) for more advanced configurations and features. +Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.10) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#configuration) for more advanced configurations and features. :::note @@ -156,7 +156,7 @@ See [this comment](https://github.com/AlexxIT/go2rtc/issues/1217#issuecomment-22 ## Advanced Restream Configurations -The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below: +The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below: NOTE: The output will need to be passed with two curly braces `{{output}}` diff --git a/docs/docs/configuration/semantic_search.md b/docs/docs/configuration/semantic_search.md index d9fcb50064..48a830e8e0 100644 --- a/docs/docs/configuration/semantic_search.md +++ b/docs/docs/configuration/semantic_search.md @@ -39,7 +39,7 @@ If you are enabling Semantic Search for the first time, be advised that Frigate The [V1 model from Jina](https://huggingface.co/jinaai/jina-clip-v1) has a vision model which is able to embed both images and text into the same vector space, which allows `image -> image` and `text -> image` similarity searches. Frigate uses this model on tracked objects to encode the thumbnail image and store it in the database. When searching for tracked objects via text in the search box, Frigate will perform a `text -> image` similarity search against this embedding. When clicking "Find Similar" in the tracked object detail pane, Frigate will perform an `image -> image` similarity search to retrieve the closest matching thumbnails. -The V1 text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Explore page when clicking on thumbnail of a tracked object. See [the Generative AI docs](/configuration/genai.md) for more information on how to automatically generate tracked object descriptions. +The V1 text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Explore page when clicking on thumbnail of a tracked object. See [the object description docs](/configuration/genai/objects.md) for more information on how to automatically generate tracked object descriptions. Differently weighted versions of the Jina models are available and can be selected by setting the `model_size` config option as `small` or `large`: @@ -78,17 +78,21 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings ### GPU Acceleration -The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. +The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU / NPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation. ```yaml semantic_search: enabled: True model_size: large + # Optional, if using the 'large' model in a multi-GPU installation + device: 0 ``` :::info -If the correct build is used for your GPU and the `large` model is configured, then the GPU will be detected and used automatically. +If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU / NPU will be detected and used automatically. +Specify the `device` option to target a specific GPU in a multi-GPU system (see [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/)). +If you do not specify a device, the first available GPU will be used. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. @@ -102,3 +106,49 @@ See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_ 4. Make your search language and tone closely match exactly what you're looking for. If you are using thumbnail search, **phrase your query as an image caption**. Searching for "red car" may not work as well as "red sedan driving down a residential street on a sunny day". 5. Semantic search on thumbnails tends to return better results when matching large subjects that take up most of the frame. Small things like "cat" tend to not work well. 6. Experiment! Find a tracked object you want to test and start typing keywords and phrases to see what works for you. + +## Triggers + +Triggers utilize semantic search to automate actions when a tracked object matches a specified image or description. Triggers can be configured so that Frigate executes a specific actions when a tracked object's image or description matches a predefined image or text, based on a similarity threshold. Triggers are managed per camera and can be configured via the Frigate UI in the Settings page under the Triggers tab. + +### Configuration + +Triggers are defined within the `semantic_search` configuration for each camera in your Frigate configuration file or through the UI. Each trigger consists of a `type` (either `thumbnail` or `description`), a `data` field (the reference image event ID or text), a `threshold` for similarity matching, and a list of `actions` to perform when the trigger fires. + +#### Managing Triggers in the UI + +1. Navigate to the **Settings** page and select the **Triggers** tab. +2. Choose a camera from the dropdown menu to view or manage its triggers. +3. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one. +4. In the **Create Trigger** dialog: + - Enter a **Name** for the trigger (e.g., "red_car_alert"). + - Select the **Type** (`Thumbnail` or `Description`). + - For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold. + - For `Description`, enter text to trigger this action when a similar tracked object description is detected. + - Set the **Threshold** for similarity matching. + - Select **Actions** to perform when the trigger fires. +5. Save the trigger to update the configuration and store the embedding in the database. + +When a trigger fires, the UI highlights the trigger with a blue outline for 3 seconds for easy identification. + +### Usage and Best Practices + +1. **Thumbnail Triggers**: Select a representative image (event ID) from the Explore page that closely matches the object you want to detect. For best results, choose images where the object is prominent and fills most of the frame. +2. **Description Triggers**: Write concise, specific text descriptions (e.g., "Person in a red jacket") that align with the tracked object’s description. Avoid vague terms to improve matching accuracy. +3. **Threshold Tuning**: Adjust the threshold to balance sensitivity and specificity. A higher threshold (e.g., 0.8) requires closer matches, reducing false positives but potentially missing similar objects. A lower threshold (e.g., 0.6) is more inclusive but may trigger more often. +4. **Using Explore**: Use the context menu or right-click / long-press on a tracked object in the Grid View in Explore to quickly add a trigger based on the tracked object's thumbnail. +5. **Editing triggers**: For the best experience, triggers should be edited via the UI. However, Frigate will ensure triggers edited in the config will be synced with triggers created and edited in the UI. + +### Notes + +- Triggers rely on the same Jina AI CLIP models (V1 or V2) used for semantic search. Ensure `semantic_search` is enabled and properly configured. +- Reindexing embeddings (via the UI or `reindex: True`) does not affect trigger configurations but may update the embeddings used for matching. +- For optimal performance, use a system with sufficient RAM (8GB minimum, 16GB recommended) and a GPU for `large` model configurations, as described in the Semantic Search requirements. + +### FAQ + +#### Why can't I create a trigger on thumbnails for some text, like "person with a blue shirt" and have it trigger when a person with a blue shirt is detected? + +TL;DR: Text-to-image triggers aren’t supported because CLIP can confuse similar images and give inconsistent scores, making automation unreliable. + +Text-to-image triggers are not supported due to fundamental limitations of CLIP-based similarity search. While CLIP works well for exploratory, manual queries, it is unreliable for automated triggers based on a threshold. Issues include embedding drift (the same text–image pair can yield different cosine distances over time), lack of true semantic grounding (visually similar but incorrect matches), and unstable thresholding (distance distributions are dataset-dependent and often too tightly clustered to separate relevant from irrelevant results). Instead, it is recommended to set up a workflow with thumbnail triggers: first use text search to manually select 3–5 representative reference tracked objects, then configure thumbnail triggers based on that visual similarity. This provides robust automation without the semantic ambiguity of text to image matching. diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index d2a1083e65..025384a8bb 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -88,7 +88,9 @@ Sometimes objects are expected to be passing through a zone, but an object loite :::note -When using loitering zones, a review item will remain active until the object leaves. Loitering zones are only meant to be used in areas where loitering is not expected behavior. +When using loitering zones, a review item will behave in the following way: +- When a person is in a loitering zone, the review item will remain active until the person leaves the loitering zone, regardless of if they are stationary. +- When any other object is in a loitering zone, the review item will remain active until the loitering time is met. Then if the object is stationary the review item will end. ::: diff --git a/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index fcb98573f8..f06f8ac7d0 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -56,24 +56,36 @@ Frigate supports multiple different detectors that work on different types of ha - Runs best with tiny or small size models - [Google Coral EdgeTPU](#google-coral-tpu): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices. + - [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#edge-tpu-detector) +- [MemryX](#memryx-mx3): The MX3 M.2 accelerator module is available in m.2 format allowing for a wide range of compatibility with devices. + - [Supports many model architectures](../../configuration/object_detectors#memryx-mx3) + - Runs best with tiny, small, or medium-size models + **AMD** - [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection - - [Supports limited model architectures](../../configuration/object_detectors#supported-models-1) + - [Supports limited model architectures](../../configuration/object_detectors#rocm-supported-models) - Runs best on discrete AMD GPUs +**Apple Silicon** + +- [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection + - [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-supported-models) + - Runs well with any size models including large + - Runs via ZMQ proxy which adds some latency, only recommended for local connection + **Intel** - [OpenVino](#openvino---intel): OpenVino can run on Intel Arc GPUs, Intel integrated GPUs, and Intel CPUs to provide efficient object detection. - - [Supports majority of model architectures](../../configuration/object_detectors#supported-models) + - [Supports majority of model architectures](../../configuration/object_detectors#openvino-supported-models) - Runs best with tiny, small, or medium models **Nvidia** - [TensortRT](#tensorrt---nvidia-gpu): TensorRT can run on Nvidia GPUs and Jetson devices. - - [Supports majority of model architectures via ONNX](../../configuration/object_detectors#supported-models-2) + - [Supports majority of model architectures via ONNX](../../configuration/object_detectors#onnx-supported-models) - Runs well with any size models including large **Rockchip** @@ -83,8 +95,21 @@ Frigate supports multiple different detectors that work on different types of ha - Runs best with tiny or small size models - Runs efficiently on low power hardware +**Synaptics** + +- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs to provide efficient object detection. + ::: +### Synaptics + +- **Synaptics** Default model is **mobilenet** + +| Name | Synaptics SL1680 Inference Time | +| ---------------- | ------------------------------- | +| ssd mobilenet | ~ 25 ms | +| yolov5m | ~ 118 ms | + ### Hailo-8 Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms—including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided. @@ -142,7 +167,7 @@ Inference speeds vary greatly depending on the CPU or GPU used, some known examp | Intel N100 | ~ 15 ms | s-320: 30 ms | 320: ~ 25 ms | | Can only run one detector instance | | Intel N150 | ~ 15 ms | t-320: 16 ms s-320: 24 ms | | | | | Intel Iris XE | ~ 10 ms | s-320: 12 ms s-640: 30 ms | 320: ~ 18 ms 640: ~ 50 ms | | | -| Intel Arc A310 | ~ 5 ms | t-320: 7 ms t-640: 11 ms s-320: 8 ms s-640: 15 ms | 320: ~ 8 ms 640: ~ 14 ms | | | +| Intel Arc A310 | ~ 5 ms | t-320: 7 ms t-640: 11 ms s-320: 8 ms s-640: 15 ms | 320: ~ 8 ms 640: ~ 14 ms | | | | Intel Arc A380 | ~ 6 ms | | 320: ~ 10 ms 640: ~ 22 ms | 336: 20 ms 448: 27 ms | | | Intel Arc A750 | ~ 4 ms | | 320: ~ 8 ms | | | @@ -152,7 +177,7 @@ Frigate is able to utilize an Nvidia GPU which supports the 12.x series of CUDA #### Minimum Hardware Support - 12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below. +12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below. Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU. @@ -167,27 +192,71 @@ There are improved capabilities in newer GPU architectures that TensorRT can ben [NVIDIA GPU Compute Capability](https://developer.nvidia.com/cuda-gpus) Inference speeds will vary greatly depending on the GPU and the model used. -`tiny` variants are faster than the equivalent non-tiny model, some known examples are below: +`tiny (t)` variants are faster than the equivalent non-tiny model, some known examples are below: -| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time | -| --------------- | ------------------------- | ------------------------- | ---------------------- | -| GTX 1070 | s-320: 16 ms | 320: 14 ms | | -| RTX 3050 | t-320: 15 ms s-320: 17 ms | 320: ~ 10 ms 640: ~ 16 ms | Nano-320: ~ 12 ms | -| RTX 3070 | t-320: 11 ms s-320: 13 ms | 320: ~ 8 ms 640: ~ 14 ms | Nano-320: ~ 9 ms | -| RTX A4000 | | 320: ~ 15 ms | | -| Tesla P40 | | 320: ~ 105 ms | | +✅ - Accelerated with CUDA Graphs +❌ - Not accelerated with CUDA Graphs + +| Name | ✅ YOLOv9 Inference Time | ✅ RF-DETR Inference Time | ❌ YOLO-NAS Inference Time | +| --------- | ------------------------------------- | ------------------------- | -------------------------- | +| GTX 1070 | s-320: 16 ms | | 320: 14 ms | +| RTX 3050 | t-320: 8 ms s-320: 10 ms s-640: 28 ms | Nano-320: ~ 12 ms | 320: ~ 10 ms 640: ~ 16 ms | +| RTX 3070 | t-320: 6 ms s-320: 8 ms s-640: 25 ms | Nano-320: ~ 9 ms | 320: ~ 8 ms 640: ~ 14 ms | +| RTX A4000 | | | 320: ~ 15 ms | +| Tesla P40 | | | 320: ~ 105 ms | + +### Apple Silicon + +With the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon. + +:::warning + +Apple Silicon can not run within a container, so a ZMQ proxy is utilized to communicate with [the Apple Silicon Frigate detector](https://github.com/frigate-nvr/apple-silicon-detector) which runs on the host. This should add minimal latency when run on the same device. + +::: + +| Name | YOLOv9 Inference Time | +| ------ | ------------------------------------ | +| M4 | s-320: 10 ms | +| M3 Pro | t-320: 6 ms s-320: 8 ms s-640: 20 ms | +| M1 | s-320: 9ms | ### ROCm - AMD GPU -With the [rocm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs. +With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs. -| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | -| --------- | --------------------- | ------------------------- | -| AMD 780M | 320: ~ 14 ms | 320: ~ 25 ms 640: ~ 50 ms | -| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms | +| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | +| --------- | --------------------------- | ------------------------- | +| AMD 780M | t-320: ~ 14 ms s-320: 20 ms | 320: ~ 25 ms 640: ~ 50 ms | +| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms | ## Community Supported Detectors +### MemryX MX3 + +Frigate supports the MemryX MX3 M.2 AI Acceleration Module on compatible hardware platforms, including both x86 (Intel/AMD) and ARM-based SBCs such as Raspberry Pi 5. + +A single MemryX MX3 module is capable of handling multiple camera streams using the default models, making it sufficient for most users. For larger deployments with more cameras or bigger models, multiple MX3 modules can be used. Frigate supports multi-detector configurations, allowing you to connect multiple MX3 modules to scale inference capacity. + +Detailed information is available [in the detector docs](/configuration/object_detectors#memryx-mx3). + +**Default Model Configuration:** + +- Default model is **YOLO-NAS-Small**. + +The MX3 is a pipelined architecture, where the maximum frames per second supported (and thus supported number of cameras) cannot be calculated as `1/latency` (1/"Inference Time") and is measured separately. When estimating how many camera streams you may support with your configuration, use the **MX3 Total FPS** column to approximate of the detector's limit, not the Inference Time. + +| Model | Input Size | MX3 Inference Time | MX3 Total FPS | +| -------------------- | ---------- | ------------------ | ------------- | +| YOLO-NAS-Small | 320 | ~ 9 ms | ~ 378 | +| YOLO-NAS-Small | 640 | ~ 21 ms | ~ 138 | +| YOLOv9s | 320 | ~ 16 ms | ~ 382 | +| YOLOv9s | 640 | ~ 41 ms | ~ 110 | +| YOLOX-Small | 640 | ~ 16 ms | ~ 263 | +| SSDlite MobileNet v2 | 320 | ~ 5 ms | ~ 1056 | + +Inference speeds may vary depending on the host platform. The above data was measured on an **Intel 13700 CPU**. Platforms like Raspberry Pi, Orange Pi, and other ARM-based SBCs have different levels of processing capability, which may limit total FPS. + ### Nvidia Jetson Frigate supports all Jetson boards, from the inexpensive Jetson Nano to the powerful Jetson Orin AGX. It will [make use of the Jetson's hardware media engine](/configuration/hardware_acceleration_video#nvidia-jetson-orin-agx-orin-nx-orin-nano-xavier-agx-xavier-nx-tx2-tx1-nano) when configured with the [appropriate presets](/configuration/ffmpeg_presets#hwaccel-presets), and will make use of the Jetson's GPU and DLA for object detection when configured with the [TensorRT detector](/configuration/object_detectors#nvidia-tensorrt-detector). diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 88decf7c98..a4fd14d3c4 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -132,6 +132,77 @@ If you are using `docker run`, add this option to your command `--device /dev/ha Finally, configure [hardware object detection](/configuration/object_detectors#hailo-8l) to complete the setup. +### MemryX MX3 + +The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVMe SSD), and supports a variety of configurations: +- x86 (Intel/AMD) PCs +- Raspberry Pi 5 +- Orange Pi 5 Plus/Max +- Multi-M.2 PCIe carrier cards + +#### Configuration + + +#### Installation + +To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/get_started/hardware_setup.html). + +Then follow these steps for installing the correct driver/runtime configuration: + +1. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/memryx/user_installation.sh). +2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh` +3. Run the script with `./user_installation.sh` +4. **Restart your computer** to complete driver installation. + +#### Setup + +To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable` + +Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file: + +```yaml +devices: + - /dev/memx0 +``` + +During configuration, you must run Docker in privileged mode and ensure the container can access the max-manager. + +In your `docker-compose.yml`, also add: + +```yaml +privileged: true + +volumes: + /run/mxa_manager:/run/mxa_manager +``` + +If you can't use Docker Compose, you can run the container with something similar to this: + +```bash + docker run -d \ + --name frigate-memx \ + --restart=unless-stopped \ + --mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \ + --shm-size=256m \ + -v /path/to/your/storage:/media/frigate \ + -v /path/to/your/config:/config \ + -v /etc/localtime:/etc/localtime:ro \ + -v /run/mxa_manager:/run/mxa_manager \ + -e FRIGATE_RTSP_PASSWORD='password' \ + --privileged=true \ + -p 8971:8971 \ + -p 8554:8554 \ + -p 5000:5000 \ + -p 8555:8555/tcp \ + -p 8555:8555/udp \ + --device /dev/memx0 \ + ghcr.io/blakeblackshear/frigate:stable +``` + +#### Configuration + +Finally, configure [hardware object detection](/configuration/object_detectors#memryx-mx3) to complete the setup. + ### Rockchip platform Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands: @@ -185,6 +256,37 @@ or add these options to your `docker run` command: Next, you should configure [hardware object detection](/configuration/object_detectors#rockchip-platform) and [hardware video processing](/configuration/hardware_acceleration_video#rockchip-platform). +### Synaptics + +- SL1680 + +#### Setup + +Follow Frigate's default installation instructions, but use a docker image with `-synaptics` suffix for example `ghcr.io/blakeblackshear/frigate:stable-synaptics`. + +Next, you need to grant docker permissions to access your hardware: + +- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command. + +```yaml +devices: + - /dev/synap + - /dev/video0 + - /dev/video1 +``` + +or add these options to your `docker run` command: + +``` +--device /dev/synap \ +--device /dev/video0 \ +--device /dev/video1 +``` + +#### Configuration + +Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics). + ## Docker Running through Docker with Docker Compose is the recommended install method. diff --git a/docs/docs/guides/configuring_go2rtc.md b/docs/docs/guides/configuring_go2rtc.md index 474dde0a27..e5f3b892f2 100644 --- a/docs/docs/guides/configuring_go2rtc.md +++ b/docs/docs/guides/configuring_go2rtc.md @@ -13,7 +13,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect # Setup a go2rtc stream -First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#module-streams), not just rtsp. +First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#module-streams), not just rtsp. :::tip @@ -49,8 +49,8 @@ After adding this to the config, restart Frigate and try to watch the live strea - Check Video Codec: - If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported. - - If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#codecs-madness) in go2rtc documentation. - - If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. + - If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#codecs-madness) in go2rtc documentation. + - If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. ```yaml go2rtc: streams: diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 3ad435b81d..0300e1d496 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -208,6 +208,20 @@ Message published for each changed review item. The first message is published w } ``` +### `frigate/triggers` + +Message published when a trigger defined in a camera's `semantic_search` configuration fires. + +```json +{ + "name": "car_trigger", + "camera": "driveway", + "event_id": "1751565549.853251-b69j73", + "type": "thumbnail", + "score": 0.85 +} +``` + ### `frigate/stats` Same data available at `/api/stats` published at a configurable interval. @@ -226,6 +240,14 @@ Topic with current state of notifications. Published values are `ON` and `OFF`. ## Frigate Camera Topics +### `frigate///status` + +Publishes the current health status of each role that is enabled (`audio`, `detect`, `record`). Possible values are: + +- `online`: Stream is running and being processed +- `offline`: Stream is offline and is being restarted +- `disabled`: Camera is currently disabled + ### `frigate//` Publishes the count of objects for the camera for use as a sensor in Home Assistant. @@ -259,6 +281,8 @@ The height and crop of snapshots can be configured in the config. Publishes "ON" when a type of audio is detected and "OFF" when it is not for the camera for use as a sensor in Home Assistant. +`all` can be used as the audio_type for the status of all audio types. + ### `frigate//audio/dBFS` Publishes the dBFS value for audio detected on this camera. @@ -271,6 +295,12 @@ Publishes the rms value for audio detected on this camera. **NOTE:** Requires audio detection to be enabled +### `frigate//audio/transcription` + +Publishes transcribed text for audio detected on this camera. + +**NOTE:** Requires audio detection and transcription to be enabled + ### `frigate//enabled/set` Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`. @@ -393,6 +423,22 @@ Topic to turn review detections for a camera on or off. Expected values are `ON` Topic with current state of review detections for a camera. Published values are `ON` and `OFF`. +### `frigate//object_descriptions/set` + +Topic to turn generative AI object descriptions for a camera on or off. Expected values are `ON` and `OFF`. + +### `frigate//object_descriptions/state` + +Topic with current state of generative AI object descriptions for a camera. Published values are `ON` and `OFF`. + +### `frigate//review_descriptions/set` + +Topic to turn generative AI review descriptions for a camera on or off. Expected values are `ON` and `OFF`. + +### `frigate//review_descriptions/state` + +Topic with current state of generative AI review descriptions for a camera. Published values are `ON` and `OFF`. + ### `frigate//birdseye/set` Topic to turn Birdseye for a camera on and off. Expected values are `ON` and `OFF`. Birdseye mode diff --git a/docs/package-lock.json b/docs/package-lock.json index 3f00a21f97..6155625ca3 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -31,6 +31,21 @@ "node": ">=18.0" } }, + "node_modules/@algolia/abtesting": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.3.0.tgz", + "integrity": "sha512-KqPVLdVNfoJzX5BKNGM9bsW8saHeyax8kmPFXul5gejrSPN3qss7PgsFH5mMem7oR8tvjvNkia97ljEYPYCN8Q==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/@algolia/autocomplete-core": { "version": "1.17.9", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.9.tgz", @@ -77,99 +92,99 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.24.0.tgz", - "integrity": "sha512-pNTIB5YqVVwu6UogvdX8TqsRZENaflqMMjdY7/XIPMNGrBoNH9tewINLI7+qc9tIaOLcAp3ZldqoEwAihZZ3ig==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.37.0.tgz", + "integrity": "sha512-Dp2Zq+x9qQFnuiQhVe91EeaaPxWBhzwQ6QnznZQnH9C1/ei3dvtmAFfFeaTxM6FzfJXDLvVnaQagTYFTQz3R5g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.24.0.tgz", - "integrity": "sha512-IF+r9RRQsIf0ylIBNFxo7c6hDxxuhIfIbffhBXEF1HD13rjhP5AVfiaea9RzbsAZoySkm318plDpH/nlGIjbRA==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.37.0.tgz", + "integrity": "sha512-wyXODDOluKogTuZxRII6mtqhAq4+qUR3zIUJEKTiHLe8HMZFxfUEI4NO2qSu04noXZHbv/sRVdQQqzKh12SZuQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.24.0.tgz", - "integrity": "sha512-p8K6tiXQTebRBxbrzWIfGCvfkT+Umml+2lzI92acZjHsvl6KYH6igOfVstKqXJRei9pvRzEEvVDNDLXDVleGTA==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.37.0.tgz", + "integrity": "sha512-GylIFlPvLy9OMgFG8JkonIagv3zF+Dx3H401Uo2KpmfMVBBJiGfAb9oYfXtplpRMZnZPxF5FnkWaI/NpVJMC+g==", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.24.0.tgz", - "integrity": "sha512-jOHF0+tixR3IZJMhZPquFNdCVPzwzzXoiqVsbTvfKojeaY6ZXybgUiTSB8JNX+YpsUT8Ebhu3UvRy4mw2PbEzw==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.37.0.tgz", + "integrity": "sha512-T63afO2O69XHKw2+F7mfRoIbmXWGzgpZxgOFAdP3fR4laid7pWBt20P4eJ+Zn23wXS5kC9P2K7Bo3+rVjqnYiw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.24.0.tgz", - "integrity": "sha512-Fx/Fp6d8UmDBHecTt0XYF8C9TAaA3qeCQortfGSZzWp4gVmtrUCFNZ1SUwb8ULREnO9DanVrM5hGE8R8C4zZTQ==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.37.0.tgz", + "integrity": "sha512-1zOIXM98O9zD8bYDCJiUJRC/qNUydGHK/zRK+WbLXrW1SqLFRXECsKZa5KoG166+o5q5upk96qguOtE8FTXDWQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.24.0.tgz", - "integrity": "sha512-F8ypOedSMhz6W7zuT5O1SXXsdXSOVhY2U6GkRbYk/mzrhs3jWFR3uQIfeQVWmsJjUwIGZmPoAr9E+T/Zm2M4wA==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.37.0.tgz", + "integrity": "sha512-31Nr2xOLBCYVal+OMZn1rp1H4lPs1914Tfr3a34wU/nsWJ+TB3vWjfkUUuuYhWoWBEArwuRzt3YNLn0F/KRVkg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.24.0.tgz", - "integrity": "sha512-k+nuciQuq7WERNNE+hsx3DX636zIy+9R4xdtvW3PANT2a2BDGOv3fv2mta8+QUMcVTVcGe/Mo3QCb4pc1HNoxA==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.37.0.tgz", + "integrity": "sha512-DAFVUvEg+u7jUs6BZiVz9zdaUebYULPiQ4LM2R4n8Nujzyj7BZzGr2DCd85ip4p/cx7nAZWKM8pLcGtkTRTdsg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" @@ -182,116 +197,103 @@ "license": "MIT" }, "node_modules/@algolia/ingestion": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.24.0.tgz", - "integrity": "sha512-/lqVxmrvwoA+OyVK4XLMdz/PJaCTW4qYchX1AZ+98fdnH3K6XM/kMydQLfP0bUNGBQbmVrF88MqhqZRnZEn/MA==", + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.37.0.tgz", + "integrity": "sha512-pkCepBRRdcdd7dTLbFddnu886NyyxmhgqiRcHHaDunvX03Ij4WzvouWrQq7B7iYBjkMQrLS8wQqSP0REfA4W8g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.24.0.tgz", - "integrity": "sha512-cRisDXQJhvfZCXL4hD22qca2CmW52TniOx6L7pvkaBDx0oQk1k9o+3w11fgfcCG+47OndMeNx5CMpu+K+COMzg==", + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.37.0.tgz", + "integrity": "sha512-fNw7pVdyZAAQQCJf1cc/ih4fwrRdQSgKwgor4gchsI/Q/ss9inmC6bl/69jvoRSzgZS9BX4elwHKdo0EfTli3w==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.24.0.tgz", - "integrity": "sha512-JTMz0JqN2gidvKa2QCF/rMe8LNtdHaght03px2cluZaZfBRYy8TgHgkCeBspKKvV/abWJwl7J0FzWThCshqT3w==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.37.0.tgz", + "integrity": "sha512-U+FL5gzN2ldx3TYfQO5OAta2TBuIdabEdFwD5UVfWPsZE5nvOKkc/6BBqP54Z/adW/34c5ZrvvZhlhNTZujJXQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/client-common": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.24.0.tgz", - "integrity": "sha512-B2Gc+iSxct1WSza5CF6AgfNgmLvVb61d5bqmIWUZixtJIhyAC6lSQZuF+nvt+lmKhQwuY2gYjGGClil8onQvKQ==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.37.0.tgz", + "integrity": "sha512-Ao8GZo8WgWFABrU7iq+JAftXV0t+UcOtCDL4mzHHZ+rQeTTf1TZssr4d0vIuoqkVNnKt9iyZ7T4lQff4ydcTrw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0" + "@algolia/client-common": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.24.0.tgz", - "integrity": "sha512-6E5+hliqGc5w8ZbyTAQ+C3IGLZ/GiX623Jl2bgHA974RPyFWzVSj4rKqkboUAxQmrFY7Z02ybJWVZS5OhPQocA==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.37.0.tgz", + "integrity": "sha512-H7OJOXrFg5dLcGJ22uxx8eiFId0aB9b0UBhoOi4SMSuDBe6vjJJ/LeZyY25zPaSvkXNBN3vAM+ad6M0h6ha3AA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0" + "@algolia/client-common": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.24.0.tgz", - "integrity": "sha512-zM+nnqZpiQj20PyAh6uvgdSz+hD7Rj7UfAZwizqNP+bLvcbGXZwABERobuilkCQqyDBBH4uv0yqIcPRl8dSBEg==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.37.0.tgz", + "integrity": "sha512-npZ9aeag4SGTx677eqPL3rkSPlQrnzx/8wNrl1P7GpWq9w/eTmRbOq+wKrJ2r78idlY0MMgmY/mld2tq6dc44g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.24.0" + "@algolia/client-common": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@antfu/install-pkg": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.0.0.tgz", - "integrity": "sha512-xvX6P/lo1B3ej0OsaErAjqgFYzYVcJpamjLAFLYh9vRJngBrMoUG7aVnrGTeqM7yxbyTD5p3F2+0/QUEh8Vzhw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", "license": "MIT", "dependencies": { - "package-manager-detector": "^0.2.8", - "tinyexec": "^0.3.2" + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@antfu/utils": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz", - "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-9.2.0.tgz", + "integrity": "sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -329,30 +331,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.1.tgz", - "integrity": "sha512-Q+E+rd/yBzNQhXkG+zQnF58e4zoZfBedaxwzPmicKsiK3nt8iJYrSrDbjwFFDGC4f+rPafqRaPH6TsDoSvMf7A==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", - "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helpers": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -377,15 +379,15 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -393,24 +395,24 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.1.tgz", - "integrity": "sha512-2YaDd/Rd9E598B5+WIc8wJPmWETiiJXFYVE60oX8FDohv7rAUU3CQj+A1MgeEmcsk2+dQuEjIe/GDvig0SqL4g==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.1", + "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -430,17 +432,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", + "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", + "@babel/traverse": "^7.28.3", "semver": "^6.3.1" }, "engines": { @@ -486,21 +488,30 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", - "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "resolve": "^1.22.10" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", @@ -528,14 +539,14 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", - "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -640,39 +651,39 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", - "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", + "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", "license": "MIT", "dependencies": { - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", - "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "license": "MIT", "dependencies": { - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz", - "integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -745,13 +756,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", - "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", + "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -876,14 +887,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.27.1.tgz", - "integrity": "sha512-eST9RrwlpaoJBDHShc+DS2SG4ATTi2MYNb4OxYkf3n+7eb49LWpnS+HSpVfW4x927qQwgk8A2hGNVaajAEw0EA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -925,9 +936,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.1.tgz", - "integrity": "sha512-QEcFlMl9nGTgh1rn2nIeU5bkfb9BAjaQcWbiP4LvKxUot52ABcTkpcyJ7f2Q2U2RuQ84BNLgts3jRme2dTx6Fw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.4.tgz", + "integrity": "sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -956,12 +967,12 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", - "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", + "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { @@ -972,17 +983,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", - "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "globals": "^11.1.0" + "@babel/traverse": "^7.28.4" }, "engines": { "node": ">=6.9.0" @@ -1008,12 +1019,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.1.tgz", - "integrity": "sha512-ttDCqhfvpE9emVkXbPD8vyxxh4TWYACVybGkDj+oReOGwnp066ITEivDlLwe0b1R0+evJ13IXQuLNB5w1fhC5Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", + "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1084,6 +1096,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", @@ -1335,14 +1363,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.1.tgz", - "integrity": "sha512-/sSliVc9gHE20/7D5qsdGlq7RG5NCDTWsAhyqzGuq174EtWJoGzIu1BQ7G56eDsTcy1jseBZwv50olSdXOlGuA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.1" + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.4" }, "engines": { "node": ">=6.9.0" @@ -1399,9 +1429,9 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", - "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1477,9 +1507,9 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.27.1.tgz", - "integrity": "sha512-p9+Vl3yuHPmkirRrg021XiP+EETmPMQTLr6Ayjj85RLNEbb3Eya/4VI0vAdzQG9SEAl2Lnt7fy5lZyMzjYoZQQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1542,9 +1572,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz", - "integrity": "sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", + "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1588,16 +1618,16 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.1.tgz", - "integrity": "sha512-TqGF3desVsTcp3WrJGj4HfKokfCXCLcHpt4PJF0D8/iT6LPd9RS82Upw3KPeyr6B22Lfd3DO8MVrmp0oRkUDdw==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.3.tgz", + "integrity": "sha512-Y6ab1kGqZ0u42Zv/4a7l0l72n9DKP/MKoKWaUSBylrhNZO2prYuqFOLbn5aW5SIFXwSH93yfjbgllL8lxuGKLg==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "engines": { @@ -1693,12 +1723,12 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz", - "integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", + "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", @@ -1775,38 +1805,39 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.27.1.tgz", - "integrity": "sha512-TZ5USxFpLgKDpdEt8YWBR7p6g+bZo6sHaXLqP2BY/U0acaoI8FTVflcYCr/v94twM1C5IWFdZ/hscq9WjUeLXA==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.3.tgz", + "integrity": "sha512-ROiDcM+GbYVPYBOeCR6uBXKkQpBExLl8k9HO1ygXEyds39j+vCCsjmj7S8GOniZQlEs81QlkdJZe76IpLSiqpg==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", + "@babel/compat-data": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.27.1", "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", "@babel/plugin-transform-async-to-generator": "^7.27.1", "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.0", "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-classes": "^7.28.3", "@babel/plugin-transform-computed-properties": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", "@babel/plugin-transform-dotall-regex": "^7.27.1", "@babel/plugin-transform-duplicate-keys": "^7.27.1", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", "@babel/plugin-transform-exponentiation-operator": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/plugin-transform-for-of": "^7.27.1", @@ -1823,15 +1854,15 @@ "@babel/plugin-transform-new-target": "^7.27.1", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-numeric-separator": "^7.27.1", - "@babel/plugin-transform-object-rest-spread": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.0", "@babel/plugin-transform-object-super": "^7.27.1", "@babel/plugin-transform-optional-catch-binding": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.7", "@babel/plugin-transform-private-methods": "^7.27.1", "@babel/plugin-transform-private-property-in-object": "^7.27.1", "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.3", "@babel/plugin-transform-regexp-modifiers": "^7.27.1", "@babel/plugin-transform-reserved-words": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", @@ -1844,10 +1875,10 @@ "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.40.0", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", "semver": "^6.3.1" }, "engines": { @@ -1920,34 +1951,34 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.1.tgz", - "integrity": "sha512-909rVuj3phpjW6y0MCXAZ5iNeORePa6ldJvp2baWGcTjwqbBDDz6xoS5JHJ7lS88NlwLYj07ImL/8IUMtDZzTA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz", + "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==", "license": "MIT", "dependencies": { - "core-js-pure": "^3.30.2" + "core-js-pure": "^3.43.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", - "integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.1", + "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" }, "engines": { @@ -1955,27 +1986,27 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", - "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2041,9 +2072,9 @@ } }, "node_modules/@csstools/cascade-layer-name-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.4.tgz", - "integrity": "sha512-7DFHlPuIxviKYZrOiwVU/PiHLm3lLUR23OMuEEtfEOQTOp9hzQ2JjdY6X5H18RVuUPJqSCI+qNnD5iOLMVE0bA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz", + "integrity": "sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A==", "funding": [ { "type": "github", @@ -2059,14 +2090,14 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/color-helpers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", - "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "funding": [ { "type": "github", @@ -2083,9 +2114,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.3.tgz", - "integrity": "sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "funding": [ { "type": "github", @@ -2101,14 +2132,14 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.9.tgz", - "integrity": "sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "funding": [ { "type": "github", @@ -2121,21 +2152,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.0.2", - "@csstools/css-calc": "^2.1.3" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" }, "engines": { "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", - "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "funding": [ { "type": "github", @@ -2151,13 +2182,13 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", - "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "funding": [ { "type": "github", @@ -2174,9 +2205,9 @@ } }, "node_modules/@csstools/media-query-list-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.2.tgz", - "integrity": "sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", "funding": [ { "type": "github", @@ -2192,14 +2223,43 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/postcss-alpha-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-alpha-function/-/postcss-alpha-function-1.0.0.tgz", + "integrity": "sha512-r2L8KNg5Wriq5n8IUQcjzy2Rh37J5YjzP9iOyHZL5fxdWYHB08vqykHQa4wAzN/tXwDuCHnhQDGCtxfS76xn7g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, "node_modules/@csstools/postcss-cascade-layers": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.1.tgz", - "integrity": "sha512-XOfhI7GShVcKiKwmPAnWSqd2tBR0uxt+runAxttbSp/LY2U16yAVPmAf7e9q4JJ0d+xMNmpwNDLBXnmRCl3HMQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.2.tgz", + "integrity": "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==", "funding": [ { "type": "github", @@ -2258,9 +2318,9 @@ } }, "node_modules/@csstools/postcss-color-function": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.9.tgz", - "integrity": "sha512-2UeQCGMO5+EeQsPQK2DqXp0dad+P6nIz6G2dI06APpBuYBKxZEq7CTH+UiztFQ8cB1f89dnO9+D/Kfr+JfI2hw==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.11.tgz", + "integrity": "sha512-AtH22zLHTLm64HLdpv5EedT/zmYTm1MtdQbQhRZXxEB6iYtS6SrS1jLX3TcmUWMFzpumK/OVylCm3HcLms4slw==", "funding": [ { "type": "github", @@ -2273,10 +2333,39 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-function-display-p3-linear": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function-display-p3-linear/-/postcss-color-function-display-p3-linear-1.0.0.tgz", + "integrity": "sha512-7q+OuUqfowRrP84m/Jl0wv3pfCQyUTCW5MxDIux+/yty5IkUUHOTigCjrC0Fjy3OT0ncGLudHbfLWmP7E1arNA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2287,9 +2376,9 @@ } }, "node_modules/@csstools/postcss-color-mix-function": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-3.0.9.tgz", - "integrity": "sha512-Enj7ZIIkLD7zkGCN31SZFx4H1gKiCs2Y4taBo/v/cqaHN7p1qGrf5UTMNSjQFZ7MgClGufHx4pddwFTGL+ipug==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-3.0.11.tgz", + "integrity": "sha512-cQpXBelpTx0YhScZM5Ve0jDCA4RzwFc7oNafzZOGgCHt/GQVYiU8Vevz9QJcwy/W0Pyi/BneY+KMjz23lI9r+Q==", "funding": [ { "type": "github", @@ -2302,10 +2391,39 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-mix-variadic-function-arguments": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-variadic-function-arguments/-/postcss-color-mix-variadic-function-arguments-1.0.1.tgz", + "integrity": "sha512-c7hyBtbF+jlHIcUGVdWY06bHICgguV9ypfcELU3eU3W/9fiz2dxM8PqxQk2ndXYTzLnwPvNNqu1yCmQ++N6Dcg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2316,9 +2434,9 @@ } }, "node_modules/@csstools/postcss-content-alt-text": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-2.0.5.tgz", - "integrity": "sha512-9BOS535v6YmyOYk32jAHXeddRV+iyd4vRcbrEekpwxmueAXX5J8WgbceFnE4E4Pmw/ysnB9v+n/vSWoFmcLMcA==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-2.0.7.tgz", + "integrity": "sha512-cq/zWaEkpcg3RttJ5+GdNwk26NwxY5KgqgtNL777Fdd28AVGHxuBvqmK4Jq4oKhW1NX4M2LbgYAVVN0NZ+/XYQ==", "funding": [ { "type": "github", @@ -2331,9 +2449,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2344,9 +2462,9 @@ } }, "node_modules/@csstools/postcss-exponential-functions": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-2.0.8.tgz", - "integrity": "sha512-vHgDXtGIBPpFQnFNDftMQg4MOuXcWnK91L/7REjBNYzQ/p2Fa/6RcnehTqCRrNtQ46PNIolbRsiDdDuxiHolwQ==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-2.0.9.tgz", + "integrity": "sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw==", "funding": [ { "type": "github", @@ -2359,9 +2477,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -2397,9 +2515,9 @@ } }, "node_modules/@csstools/postcss-gamut-mapping": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-2.0.9.tgz", - "integrity": "sha512-quksIsFm3DGsf8Qbr9KiSGBF2w3RwxSfOfma5wbORDB1AFF15r4EVW7sUuWw3s5IAEGMqzel/dE2rQsI7Yb8mA==", + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-2.0.11.tgz", + "integrity": "sha512-fCpCUgZNE2piVJKC76zFsgVW1apF6dpYsqGyH8SIeCcM4pTEsRTWTLCaJIMKFEundsCKwY1rwfhtrio04RJ4Dw==", "funding": [ { "type": "github", @@ -2412,9 +2530,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -2424,9 +2542,9 @@ } }, "node_modules/@csstools/postcss-gradients-interpolation-method": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-5.0.9.tgz", - "integrity": "sha512-duqTeUHF4ambUybAmhX9KonkicLM/WNp2JjMUbegRD4O8A/tb6fdZ7jUNdp/UUiO1FIdDkMwmNw6856bT0XF8Q==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-5.0.11.tgz", + "integrity": "sha512-8M3mcNTL3cGIJXDnvrJ2oWEcKi3zyw7NeYheFKePUlBmLYm1gkw9Rr/BA7lFONrOPeQA3yeMPldrrws6lqHrug==", "funding": [ { "type": "github", @@ -2439,10 +2557,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2453,9 +2571,9 @@ } }, "node_modules/@csstools/postcss-hwb-function": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-4.0.9.tgz", - "integrity": "sha512-sDpdPsoGAhYl/PMSYfu5Ez82wXb2bVkg1Cb8vsRLhpXhAk4OSlsJN+GodAql6tqc1B2G/WToxsFU6G74vkhPvA==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-4.0.11.tgz", + "integrity": "sha512-9meZbsVWTZkWsSBazQips3cHUOT29a/UAwFz0AMEXukvpIGGDR9+GMl3nIckWO5sPImsadu4F5Zy+zjt8QgCdA==", "funding": [ { "type": "github", @@ -2468,10 +2586,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2482,9 +2600,9 @@ } }, "node_modules/@csstools/postcss-ic-unit": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-4.0.1.tgz", - "integrity": "sha512-lECc38i1w3qU9nhrUhP6F8y4BfcQJkR1cb8N6tZNf2llM6zPkxnqt04jRCwsUgNcB3UGKDy+zLenhOYGHqCV+Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-4.0.3.tgz", + "integrity": "sha512-RtYYm2qUIu9vAaHB0cC8rQGlOCQAUgEc2tMr7ewlGXYipBQKjoWmyVArqsk7SEr8N3tErq6P6UOJT3amaVof5Q==", "funding": [ { "type": "github", @@ -2497,7 +2615,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, @@ -2531,9 +2649,9 @@ } }, "node_modules/@csstools/postcss-is-pseudo-class": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.1.tgz", - "integrity": "sha512-JLp3POui4S1auhDR0n8wHd/zTOWmMsmK3nQd3hhL6FhWPaox5W7j1se6zXOG/aP07wV2ww0lxbKYGwbBszOtfQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.3.tgz", + "integrity": "sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ==", "funding": [ { "type": "github", @@ -2592,9 +2710,9 @@ } }, "node_modules/@csstools/postcss-light-dark-function": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.8.tgz", - "integrity": "sha512-v8VU5WtrZIyEtk88WB4fkG22TGd8HyAfSFfZZQ1uNN0+arMJdZc++H3KYTfbYDpJRGy8GwADYH8ySXiILn+OyA==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.10.tgz", + "integrity": "sha512-g7Lwb294lSoNnyrwcqoooh9fTAp47rRNo+ILg7SLRSMU3K9ePIwRt566sNx+pehiCelv4E1ICaU1EwLQuyF2qw==", "funding": [ { "type": "github", @@ -2607,9 +2725,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2711,9 +2829,9 @@ } }, "node_modules/@csstools/postcss-logical-viewport-units": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-3.0.3.tgz", - "integrity": "sha512-OC1IlG/yoGJdi0Y+7duz/kU/beCwO+Gua01sD6GtOtLi7ByQUpcIqs7UE/xuRPay4cHgOMatWdnDdsIDjnWpPw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-3.0.4.tgz", + "integrity": "sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ==", "funding": [ { "type": "github", @@ -2726,7 +2844,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-tokenizer": "^3.0.3", + "@csstools/css-tokenizer": "^3.0.4", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2737,9 +2855,9 @@ } }, "node_modules/@csstools/postcss-media-minmax": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-2.0.8.tgz", - "integrity": "sha512-Skum5wIXw2+NyCQWUyfstN3c1mfSh39DRAo+Uh2zzXOglBG8xB9hnArhYFScuMZkzeM+THVa//mrByKAfumc7w==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-2.0.9.tgz", + "integrity": "sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig==", "funding": [ { "type": "github", @@ -2752,10 +2870,10 @@ ], "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/media-query-list-parser": "^4.0.2" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" }, "engines": { "node": ">=18" @@ -2765,9 +2883,9 @@ } }, "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-3.0.4.tgz", - "integrity": "sha512-AnGjVslHMm5xw9keusQYvjVWvuS7KWK+OJagaG0+m9QnIjZsrysD2kJP/tr/UJIyYtMCtu8OkUd+Rajb4DqtIQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-3.0.5.tgz", + "integrity": "sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg==", "funding": [ { "type": "github", @@ -2780,9 +2898,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/media-query-list-parser": "^4.0.2" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" }, "engines": { "node": ">=18" @@ -2843,9 +2961,9 @@ } }, "node_modules/@csstools/postcss-oklab-function": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-4.0.9.tgz", - "integrity": "sha512-UHrnujimwtdDw8BYDcWJtBXuJ13uc/BjAddPdfMc/RsWxhg8gG8UbvTF0tnMtHrZ4i7lwy85fPEzK1AiykMyRA==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-4.0.11.tgz", + "integrity": "sha512-9f03ZGxZ2VmSCrM4SDXlAYP+Xpu4VFzemfQUQFL9OYxAbpvDy0FjDipZ0i8So1pgs8VIbQI0bNjFWgfdpGw8ig==", "funding": [ { "type": "github", @@ -2858,10 +2976,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2872,9 +2990,9 @@ } }, "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.0.1.tgz", - "integrity": "sha512-Ofz81HaY8mmbP8/Qr3PZlUzjsyV5WuxWmvtYn+jhYGvvjFazTmN9R2io5W5znY1tyk2CA9uM0IPWyY4ygDytCw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.2.0.tgz", + "integrity": "sha512-fWCXRasX17N1NCPTCuwC3FJDV+Wc031f16cFuuMEfIsYJ1q5ABCa59W0C6VeMGqjNv6ldf37vvwXXAeaZjD9PA==", "funding": [ { "type": "github", @@ -2897,9 +3015,9 @@ } }, "node_modules/@csstools/postcss-random-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-random-function/-/postcss-random-function-2.0.0.tgz", - "integrity": "sha512-MYZKxSr4AKfjECL8vg49BbfNNzK+t3p2OWX+Xf7rXgMaTP44oy/e8VGWu4MLnJ3NUd9tFVkisLO/sg+5wMTNsg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-random-function/-/postcss-random-function-2.0.1.tgz", + "integrity": "sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w==", "funding": [ { "type": "github", @@ -2912,9 +3030,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -2924,9 +3042,9 @@ } }, "node_modules/@csstools/postcss-relative-color-syntax": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-3.0.9.tgz", - "integrity": "sha512-+AGOcLF5PmMnTRPnOdCvY7AwvD5veIOhTWbJV6vC3hB1tt0ii/k6QOwhWfsGGg1ZPQ0JY15u+wqLR4ZTtB0luA==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-3.0.11.tgz", + "integrity": "sha512-oQ5fZvkcBrWR+k6arHXk0F8FlkmD4IxM+rcGDLWrF2f31tWyEM3lSraeWAV0f7BGH6LIrqmyU3+Qo/1acfoJng==", "funding": [ { "type": "github", @@ -2939,10 +3057,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -2991,9 +3109,9 @@ } }, "node_modules/@csstools/postcss-sign-functions": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.3.tgz", - "integrity": "sha512-4F4GRhj8xNkBtLZ+3ycIhReaDfKJByXI+cQGIps3AzCO8/CJOeoDPxpMnL5vqZrWKOceSATHEQJUO/Q/r2y7OQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.4.tgz", + "integrity": "sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg==", "funding": [ { "type": "github", @@ -3006,9 +3124,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -3018,9 +3136,9 @@ } }, "node_modules/@csstools/postcss-stepped-value-functions": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-4.0.8.tgz", - "integrity": "sha512-6Y4yhL4fNhgzbZ/wUMQ4EjFUfoNNMpEXZnDw1JrlcEBHUT15gplchtFsZGk7FNi8PhLHJfCUwVKrEHzhfhKK+g==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-4.0.9.tgz", + "integrity": "sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA==", "funding": [ { "type": "github", @@ -3033,9 +3151,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -3045,9 +3163,9 @@ } }, "node_modules/@csstools/postcss-text-decoration-shorthand": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-4.0.2.tgz", - "integrity": "sha512-8XvCRrFNseBSAGxeaVTaNijAu+FzUvjwFXtcrynmazGb/9WUdsPCpBX+mHEHShVRq47Gy4peYAoxYs8ltUnmzA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-4.0.3.tgz", + "integrity": "sha512-KSkGgZfx0kQjRIYnpsD7X2Om9BUXX/Kii77VBifQW9Ih929hK0KNjVngHDH0bFB9GmfWcR9vJYJJRvw/NQjkrA==", "funding": [ { "type": "github", @@ -3060,7 +3178,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/color-helpers": "^5.0.2", + "@csstools/color-helpers": "^5.1.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -3071,9 +3189,9 @@ } }, "node_modules/@csstools/postcss-trigonometric-functions": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-4.0.8.tgz", - "integrity": "sha512-YcDvYTRu7f78/91B6bX+mE1WoAO91Su7/8KSRpuWbIGUB8hmaNSRu9wziaWSLJ1lOB1aQe+bvo9BIaLKqPOo/g==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-4.0.9.tgz", + "integrity": "sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A==", "funding": [ { "type": "github", @@ -3086,9 +3204,9 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" }, "engines": { "node": ">=18" @@ -3189,9 +3307,9 @@ } }, "node_modules/@docusaurus/babel": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.7.0.tgz", - "integrity": "sha512-0H5uoJLm14S/oKV3Keihxvh8RV+vrid+6Gv+2qhuzbqHanawga8tYnsdpjEyt36ucJjqlby2/Md2ObWjA02UXQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.8.1.tgz", + "integrity": "sha512-3brkJrml8vUbn9aeoZUlJfsI/GqyFcDgQJwQkmBtclJgWDEQBKKeagZfOgx0WfUQhagL1sQLNW0iBdxnI863Uw==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", @@ -3204,8 +3322,8 @@ "@babel/runtime": "^7.25.9", "@babel/runtime-corejs3": "^7.25.9", "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.7.0", - "@docusaurus/utils": "3.7.0", + "@docusaurus/logger": "3.8.1", + "@docusaurus/utils": "3.8.1", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -3215,31 +3333,30 @@ } }, "node_modules/@docusaurus/bundler": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.7.0.tgz", - "integrity": "sha512-CUUT9VlSGukrCU5ctZucykvgCISivct+cby28wJwCC/fkQFgAHRp/GKv2tx38ZmXb7nacrKzFTcp++f9txUYGg==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.8.1.tgz", + "integrity": "sha512-/z4V0FRoQ0GuSLToNjOSGsk6m2lQUG4FRn8goOVoZSRsTrU8YR2aJacX5K3RG18EaX9b+52pN4m1sL3MQZVsQA==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.7.0", - "@docusaurus/cssnano-preset": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", + "@docusaurus/babel": "3.8.1", + "@docusaurus/cssnano-preset": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", "babel-loader": "^9.2.1", - "clean-css": "^5.3.2", + "clean-css": "^5.3.3", "copy-webpack-plugin": "^11.0.0", - "css-loader": "^6.8.1", + "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", "cssnano": "^6.1.2", "file-loader": "^6.2.0", "html-minifier-terser": "^7.2.0", - "mini-css-extract-plugin": "^2.9.1", + "mini-css-extract-plugin": "^2.9.2", "null-loader": "^4.0.1", - "postcss": "^8.4.26", - "postcss-loader": "^7.3.3", - "postcss-preset-env": "^10.1.0", - "react-dev-utils": "^12.0.1", + "postcss": "^8.5.4", + "postcss-loader": "^7.3.4", + "postcss-preset-env": "^10.2.1", "terser-webpack-plugin": "^5.3.9", "tslib": "^2.6.0", "url-loader": "^4.1.1", @@ -3259,18 +3376,18 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.7.0.tgz", - "integrity": "sha512-b0fUmaL+JbzDIQaamzpAFpTviiaU4cX3Qz8cuo14+HGBCwa0evEK0UYCBFY3n4cLzL8Op1BueeroUD2LYAIHbQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.8.1.tgz", + "integrity": "sha512-ENB01IyQSqI2FLtOzqSI3qxG2B/jP4gQPahl2C3XReiLebcVh5B5cB9KYFvdoOqOWPyr5gXK4sjgTKv7peXCrA==", "license": "MIT", "dependencies": { - "@docusaurus/babel": "3.7.0", - "@docusaurus/bundler": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/babel": "3.8.1", + "@docusaurus/bundler": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -3278,19 +3395,19 @@ "combine-promises": "^1.1.0", "commander": "^5.1.0", "core-js": "^3.31.1", - "del": "^6.1.1", "detect-port": "^1.5.1", "escape-html": "^1.0.3", "eta": "^2.2.0", "eval": "^0.1.8", + "execa": "5.1.1", "fs-extra": "^11.1.1", "html-tags": "^3.3.1", "html-webpack-plugin": "^5.6.0", "leven": "^3.1.0", "lodash": "^4.17.21", + "open": "^8.4.0", "p-map": "^4.0.0", "prompts": "^2.4.2", - "react-dev-utils": "^12.0.1", "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", "react-loadable": "npm:@docusaurus/react-loadable@6.0.0", "react-loadable-ssr-addon-v5-slorber": "^1.0.1", @@ -3299,7 +3416,7 @@ "react-router-dom": "^5.3.4", "semver": "^7.5.4", "serve-handler": "^6.1.6", - "shelljs": "^0.8.5", + "tinypool": "^1.0.2", "tslib": "^2.6.0", "update-notifier": "^6.0.2", "webpack": "^5.95.0", @@ -3320,13 +3437,13 @@ } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.7.0.tgz", - "integrity": "sha512-X9GYgruZBSOozg4w4dzv9uOz8oK/EpPVQXkp0MM6Tsgp/nRIU9hJzJ0Pxg1aRa3xCeEQTOimZHcocQFlLwYajQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.1.tgz", + "integrity": "sha512-G7WyR2N6SpyUotqhGznERBK+x84uyhfMQM2MmDLs88bw4Flom6TY46HzkRkSEzaP9j80MbTN8naiL1fR17WQug==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", - "postcss": "^8.4.38", + "postcss": "^8.5.4", "postcss-sort-media-queries": "^5.2.0", "tslib": "^2.6.0" }, @@ -3335,9 +3452,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.7.0.tgz", - "integrity": "sha512-z7g62X7bYxCYmeNNuO9jmzxLQG95q9QxINCwpboVcNff3SJiHJbGrarxxOVMVmAh1MsrSfxWkVGv4P41ktnFsA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.8.1.tgz", + "integrity": "sha512-2wjeGDhKcExEmjX8k1N/MRDiPKXGF2Pg+df/bDDPnnJWHXnVEZxXj80d6jcxp1Gpnksl0hF8t/ZQw9elqj2+ww==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -3348,21 +3465,21 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.7.0.tgz", - "integrity": "sha512-OFBG6oMjZzc78/U3WNPSHs2W9ZJ723ewAcvVJaqS0VgyeUfmzUV8f1sv+iUHA0DtwiR5T5FjOxj6nzEE8LY6VA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.8.1.tgz", + "integrity": "sha512-DZRhagSFRcEq1cUtBMo4TKxSNo/W6/s44yhr8X+eoXqCLycFQUylebOMPseHi5tc4fkGJqwqpWJLz6JStU9L4w==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/logger": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", "estree-util-value-to-estree": "^3.0.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", - "image-size": "^1.0.2", + "image-size": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-to-string": "^4.0.0", "rehype-raw": "^7.0.0", @@ -3387,17 +3504,17 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.7.0.tgz", - "integrity": "sha512-g7WdPqDNaqA60CmBrr0cORTrsOit77hbsTj7xE2l71YhBn79sxdm7WMK7wfhcaafkbpIh7jv5ef5TOpf1Xv9Lg==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.1.tgz", + "integrity": "sha512-6xhvAJiXzsaq3JdosS7wbRt/PwEPWHr9eM4YNYqVlbgG1hSK3uQDXTVvQktasp3VO6BmfYWPozueLWuj4gB+vg==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.7.0", + "@docusaurus/types": "3.8.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", "@types/react-router-dom": "*", - "react-helmet-async": "npm:@slorber/react-helmet-async@*", + "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", "react-loadable": "npm:@docusaurus/react-loadable@6.0.0" }, "peerDependencies": { @@ -3406,24 +3523,24 @@ } }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.7.0.tgz", - "integrity": "sha512-EFLgEz6tGHYWdPU0rK8tSscZwx+AsyuBW/r+tNig2kbccHYGUJmZtYN38GjAa3Fda4NU+6wqUO5kTXQSRBQD3g==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.1.tgz", + "integrity": "sha512-vNTpMmlvNP9n3hGEcgPaXyvTljanAKIUkuG9URQ1DeuDup0OR7Ltvoc8yrmH+iMZJbcQGhUJF+WjHLwuk8HSdw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "cheerio": "1.0.0-rc.12", "feed": "^4.2.2", "fs-extra": "^11.1.1", "lodash": "^4.17.21", - "reading-time": "^1.5.0", + "schema-dts": "^1.1.2", "srcset": "^4.0.0", "tslib": "^2.6.0", "unist-util-visit": "^5.0.0", @@ -3440,25 +3557,26 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.7.0.tgz", - "integrity": "sha512-GXg5V7kC9FZE4FkUZA8oo/NrlRb06UwuICzI6tcbzj0+TVgjq/mpUXXzSgKzMS82YByi4dY2Q808njcBCyy6tQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz", + "integrity": "sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", "js-yaml": "^4.1.0", "lodash": "^4.17.21", + "schema-dts": "^1.1.2", "tslib": "^2.6.0", "utility-types": "^3.10.0", "webpack": "^5.88.1" @@ -3472,16 +3590,16 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.7.0.tgz", - "integrity": "sha512-YJSU3tjIJf032/Aeao8SZjFOrXJbz/FACMveSMjLyMH4itQyZ2XgUIzt4y+1ISvvk5zrW4DABVT2awTCqBkx0Q==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.1.tgz", + "integrity": "sha512-a+V6MS2cIu37E/m7nDJn3dcxpvXb6TvgdNI22vJX8iUTp8eoMoPa0VArEbWvCxMY/xdC26WzNv4wZ6y0iIni/w==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" @@ -3494,17 +3612,33 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@docusaurus/plugin-debug": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.7.0.tgz", - "integrity": "sha512-Qgg+IjG/z4svtbCNyTocjIwvNTNEwgRjSXXSJkKVG0oWoH0eX/HAPiu+TS1HBwRPQV+tTYPWLrUypYFepfujZA==", + "node_modules/@docusaurus/plugin-css-cascade-layers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.1.tgz", + "integrity": "sha512-VQ47xRxfNKjHS5ItzaVXpxeTm7/wJLFMOPo1BkmoMG4Cuz4nuI+Hs62+RMk1OqVog68Swz66xVPK8g9XTrBKRw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@docusaurus/plugin-debug": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.8.1.tgz", + "integrity": "sha512-nT3lN7TV5bi5hKMB7FK8gCffFTBSsBsAfV84/v293qAmnHOyg1nr9okEw8AiwcO3bl9vije5nsUvP0aRl2lpaw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", "fs-extra": "^11.1.1", - "react-json-view-lite": "^1.2.0", + "react-json-view-lite": "^2.3.0", "tslib": "^2.6.0" }, "engines": { @@ -3516,14 +3650,14 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.7.0.tgz", - "integrity": "sha512-otIqiRV/jka6Snjf+AqB360XCeSv7lQC+DKYW+EUZf6XbuE8utz5PeUQ8VuOcD8Bk5zvT1MC4JKcd5zPfDuMWA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.1.tgz", + "integrity": "sha512-Hrb/PurOJsmwHAsfMDH6oVpahkEGsx7F8CWMjyP/dw1qjqmdS9rcV1nYCGlM8nOtD3Wk/eaThzUB5TSZsGz+7Q==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "tslib": "^2.6.0" }, "engines": { @@ -3535,14 +3669,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.7.0.tgz", - "integrity": "sha512-M3vrMct1tY65ModbyeDaMoA+fNJTSPe5qmchhAbtqhDD/iALri0g9LrEpIOwNaoLmm6lO88sfBUADQrSRSGSWA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.1.tgz", + "integrity": "sha512-tKE8j1cEZCh8KZa4aa80zpSTxsC2/ZYqjx6AAfd8uA8VHZVw79+7OTEP2PoWi0uL5/1Is0LF5Vwxd+1fz5HlKg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@types/gtag.js": "^0.0.12", "tslib": "^2.6.0" }, @@ -3555,14 +3689,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.7.0.tgz", - "integrity": "sha512-X8U78nb8eiMiPNg3jb9zDIVuuo/rE1LjGDGu+5m5CX4UBZzjMy+klOY2fNya6x8ACyE/L3K2erO1ErheP55W/w==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.1.tgz", + "integrity": "sha512-iqe3XKITBquZq+6UAXdb1vI0fPY5iIOitVjPQ581R1ZKpHr0qe+V6gVOrrcOHixPDD/BUKdYwkxFjpNiEN+vBw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "tslib": "^2.6.0" }, "engines": { @@ -3574,17 +3708,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.7.0.tgz", - "integrity": "sha512-bTRT9YLZ/8I/wYWKMQke18+PF9MV8Qub34Sku6aw/vlZ/U+kuEuRpQ8bTcNOjaTSfYsWkK4tTwDMHK2p5S86cA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.1.tgz", + "integrity": "sha512-+9YV/7VLbGTq8qNkjiugIelmfUEVkTyLe6X8bWq7K5qPvGXAjno27QAfFq63mYfFFbJc7z+pudL63acprbqGzw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -3598,15 +3732,15 @@ } }, "node_modules/@docusaurus/plugin-svgr": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.7.0.tgz", - "integrity": "sha512-HByXIZTbc4GV5VAUkZ2DXtXv1Qdlnpk3IpuImwSnEzCDBkUMYcec5282hPjn6skZqB25M1TYCmWS91UbhBGxQg==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.1.tgz", + "integrity": "sha512-rW0LWMDsdlsgowVwqiMb/7tANDodpy1wWPwCcamvhY7OECReN3feoFwLjd/U4tKjNY3encj0AJSTxJA+Fpe+Gw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", @@ -3621,25 +3755,26 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.7.0.tgz", - "integrity": "sha512-nPHj8AxDLAaQXs+O6+BwILFuhiWbjfQWrdw2tifOClQoNfuXDjfjogee6zfx6NGHWqshR23LrcN115DmkHC91Q==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.8.1.tgz", + "integrity": "sha512-yJSjYNHXD8POMGc2mKQuj3ApPrN+eG0rO1UPgSx7jySpYU+n4WjBikbrA2ue5ad9A7aouEtMWUoiSRXTH/g7KQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/plugin-content-blog": "3.7.0", - "@docusaurus/plugin-content-docs": "3.7.0", - "@docusaurus/plugin-content-pages": "3.7.0", - "@docusaurus/plugin-debug": "3.7.0", - "@docusaurus/plugin-google-analytics": "3.7.0", - "@docusaurus/plugin-google-gtag": "3.7.0", - "@docusaurus/plugin-google-tag-manager": "3.7.0", - "@docusaurus/plugin-sitemap": "3.7.0", - "@docusaurus/plugin-svgr": "3.7.0", - "@docusaurus/theme-classic": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/theme-search-algolia": "3.7.0", - "@docusaurus/types": "3.7.0" + "@docusaurus/core": "3.8.1", + "@docusaurus/plugin-content-blog": "3.8.1", + "@docusaurus/plugin-content-docs": "3.8.1", + "@docusaurus/plugin-content-pages": "3.8.1", + "@docusaurus/plugin-css-cascade-layers": "3.8.1", + "@docusaurus/plugin-debug": "3.8.1", + "@docusaurus/plugin-google-analytics": "3.8.1", + "@docusaurus/plugin-google-gtag": "3.8.1", + "@docusaurus/plugin-google-tag-manager": "3.8.1", + "@docusaurus/plugin-sitemap": "3.8.1", + "@docusaurus/plugin-svgr": "3.8.1", + "@docusaurus/theme-classic": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/theme-search-algolia": "3.8.1", + "@docusaurus/types": "3.8.1" }, "engines": { "node": ">=18.0" @@ -3650,31 +3785,31 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.7.0.tgz", - "integrity": "sha512-MnLxG39WcvLCl4eUzHr0gNcpHQfWoGqzADCly54aqCofQX6UozOS9Th4RK3ARbM9m7zIRv3qbhggI53dQtx/hQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.8.1.tgz", + "integrity": "sha512-bqDUCNqXeYypMCsE1VcTXSI1QuO4KXfx8Cvl6rYfY0bhhqN6d2WZlRkyLg/p6pm+DzvanqHOyYlqdPyP0iz+iw==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/plugin-content-blog": "3.7.0", - "@docusaurus/plugin-content-docs": "3.7.0", - "@docusaurus/plugin-content-pages": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/theme-translations": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/plugin-content-blog": "3.8.1", + "@docusaurus/plugin-content-docs": "3.8.1", + "@docusaurus/plugin-content-pages": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/theme-translations": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", "infima": "0.2.0-alpha.45", "lodash": "^4.17.21", "nprogress": "^0.2.0", - "postcss": "^8.4.26", + "postcss": "^8.5.4", "prism-react-renderer": "^2.3.0", "prismjs": "^1.29.0", "react-router-dom": "^5.3.4", @@ -3691,15 +3826,15 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.7.0.tgz", - "integrity": "sha512-8eJ5X0y+gWDsURZnBfH0WabdNm8XMCXHv8ENy/3Z/oQKwaB/EHt5lP9VsTDTf36lKEp0V6DjzjFyFIB+CetL0A==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.8.1.tgz", + "integrity": "sha512-UswMOyTnPEVRvN5Qzbo+l8k4xrd5fTFu2VPPfD6FcW/6qUtVLmJTQCktbAL3KJ0BVXGm5aJXz/ZrzqFuZERGPw==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.7.0", - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", + "@docusaurus/mdx-loader": "3.8.1", + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3719,17 +3854,17 @@ } }, "node_modules/@docusaurus/theme-mermaid": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.7.0.tgz", - "integrity": "sha512-7kNDvL7hm+tshjxSxIqYMtsLUPsEBYnkevej/ext6ru9xyLgCed+zkvTfGzTWNeq8rJIEe2YSS8/OV5gCVaPCw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.8.1.tgz", + "integrity": "sha512-IWYqjyTPjkNnHsFFu9+4YkeXS7PD1xI3Bn2shOhBq+f95mgDfWInkpfBN4aYvx4fTT67Am6cPtohRdwh4Tidtg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.7.0", - "@docusaurus/module-type-aliases": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", - "mermaid": ">=10.4", + "@docusaurus/core": "3.8.1", + "@docusaurus/module-type-aliases": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", + "mermaid": ">=11.6.0", "tslib": "^2.6.0" }, "engines": { @@ -3741,19 +3876,19 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.7.0.tgz", - "integrity": "sha512-Al/j5OdzwRU1m3falm+sYy9AaB93S1XF1Lgk9Yc6amp80dNxJVplQdQTR4cYdzkGtuQqbzUA8+kaoYYO0RbK6g==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.1.tgz", + "integrity": "sha512-NBFH5rZVQRAQM087aYSRKQ9yGEK9eHd+xOxQjqNpxMiV85OhJDD4ZGz6YJIod26Fbooy54UWVdzNU0TFeUUUzQ==", "license": "MIT", "dependencies": { - "@docsearch/react": "^3.8.1", - "@docusaurus/core": "3.7.0", - "@docusaurus/logger": "3.7.0", - "@docusaurus/plugin-content-docs": "3.7.0", - "@docusaurus/theme-common": "3.7.0", - "@docusaurus/theme-translations": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-validation": "3.7.0", + "@docsearch/react": "^3.9.0", + "@docusaurus/core": "3.8.1", + "@docusaurus/logger": "3.8.1", + "@docusaurus/plugin-content-docs": "3.8.1", + "@docusaurus/theme-common": "3.8.1", + "@docusaurus/theme-translations": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-validation": "3.8.1", "algoliasearch": "^5.17.1", "algoliasearch-helper": "^3.22.6", "clsx": "^2.0.0", @@ -3772,9 +3907,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.7.0.tgz", - "integrity": "sha512-Ewq3bEraWDmienM6eaNK7fx+/lHMtGDHQyd1O+4+3EsDxxUmrzPkV7Ct3nBWTuE0MsoZr3yNwQVKjllzCMuU3g==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.8.1.tgz", + "integrity": "sha512-OTp6eebuMcf2rJt4bqnvuwmm3NVXfzfYejL+u/Y1qwKhZPrjPoKWfk1CbOP5xH5ZOPkiAsx4dHdQBRJszK3z2g==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -3785,9 +3920,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.7.0.tgz", - "integrity": "sha512-kOmZg5RRqJfH31m+6ZpnwVbkqMJrPOG5t0IOl4i/+3ruXyNfWzZ0lVtVrD0u4ONc/0NOsS9sWYaxxWNkH1LdLQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.8.1.tgz", + "integrity": "sha512-ZPdW5AB+pBjiVrcLuw3dOS6BFlrG0XkS2lDGsj8TizcnREQg3J8cjsgfDviszOk4CweNfwo1AEELJkYaMUuOPg==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -3820,15 +3955,16 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.7.0.tgz", - "integrity": "sha512-e7zcB6TPnVzyUaHMJyLSArKa2AG3h9+4CfvKXKKWNx6hRs+p0a+u7HHTJBgo6KW2m+vqDnuIHK4X+bhmoghAFA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.8.1.tgz", + "integrity": "sha512-P1ml0nvOmEFdmu0smSXOqTS1sxU5tqvnc0dA4MTKV39kye+bhQnjkIKEE18fNOvxjyB86k8esoCIFM3x4RykOQ==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.7.0", - "@docusaurus/types": "3.7.0", - "@docusaurus/utils-common": "3.7.0", + "@docusaurus/logger": "3.8.1", + "@docusaurus/types": "3.8.1", + "@docusaurus/utils-common": "3.8.1", "escape-string-regexp": "^4.0.0", + "execa": "5.1.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", "github-slugger": "^1.5.0", @@ -3838,9 +3974,9 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "micromatch": "^4.0.5", + "p-queue": "^6.6.2", "prompts": "^2.4.2", "resolve-pathname": "^3.0.0", - "shelljs": "^0.8.5", "tslib": "^2.6.0", "url-loader": "^4.1.1", "utility-types": "^3.10.0", @@ -3851,12 +3987,12 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.7.0.tgz", - "integrity": "sha512-IZeyIfCfXy0Mevj6bWNg7DG7B8G+S6o6JVpddikZtWyxJguiQ7JYr0SIZ0qWd8pGNuMyVwriWmbWqMnK7Y5PwA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.8.1.tgz", + "integrity": "sha512-zTZiDlvpvoJIrQEEd71c154DkcriBecm4z94OzEE9kz7ikS3J+iSlABhFXM45mZ0eN5pVqqr7cs60+ZlYLewtg==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.7.0", + "@docusaurus/types": "3.8.1", "tslib": "^2.6.0" }, "engines": { @@ -3864,14 +4000,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.7.0.tgz", - "integrity": "sha512-w8eiKk8mRdN+bNfeZqC4nyFoxNyI1/VExMKAzD9tqpJfLLbsa46Wfn5wcKH761g9WkKh36RtFV49iL9lh1DYBA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.8.1.tgz", + "integrity": "sha512-gs5bXIccxzEbyVecvxg6upTwaUbfa0KMmTj7HhHzc016AGyxH2o73k1/aOD0IFrdCsfJNt37MqNI47s2MgRZMA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.7.0", - "@docusaurus/utils": "3.7.0", - "@docusaurus/utils-common": "3.7.0", + "@docusaurus/logger": "3.8.1", + "@docusaurus/utils": "3.8.1", + "@docusaurus/utils-common": "3.8.1", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -3928,33 +4064,21 @@ "license": "MIT" }, "node_modules/@iconify/utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz", - "integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.0.2.tgz", + "integrity": "sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==", "license": "MIT", "dependencies": { - "@antfu/install-pkg": "^1.0.0", - "@antfu/utils": "^8.1.0", + "@antfu/install-pkg": "^1.1.0", + "@antfu/utils": "^9.2.0", "@iconify/types": "^2.0.0", - "debug": "^4.4.0", - "globals": "^15.14.0", + "debug": "^4.4.1", + "globals": "^15.15.0", "kolorist": "^1.8.0", - "local-pkg": "^1.0.0", + "local-pkg": "^1.1.1", "mlly": "^1.7.4" } }, - "node_modules/@iconify/utils/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@inkeep/docusaurus": { "version": "2.0.16", "resolved": "https://registry.npmjs.org/@inkeep/docusaurus/-/docusaurus-2.0.16.tgz", @@ -3979,9 +4103,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -3991,9 +4115,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -4035,17 +4159,23 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -4057,19 +4187,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -4077,15 +4198,15 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4105,15 +4226,16 @@ "license": "MIT" }, "node_modules/@mdx-js/mdx": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.0.tgz", - "integrity": "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", @@ -4141,9 +4263,9 @@ } }, "node_modules/@mdx-js/react": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", - "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", "dependencies": { "@types/mdx": "^2.0.0" @@ -4158,9 +4280,9 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.4.0.tgz", - "integrity": "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz", + "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==", "license": "MIT", "dependencies": { "langium": "3.3.1" @@ -4555,9 +4677,9 @@ "license": "MIT" }, "node_modules/@redocly/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.3.tgz", + "integrity": "sha512-4P3iZse91TkBiY+Dx5DUgxQ9GXkVJf++cmI0MOyLDxV9b5MUBI4II6ES8zA5JCbO72nKAJxWrw4PUPW+YP3ZDQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4577,9 +4699,9 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core": { - "version": "1.34.2", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.2.tgz", - "integrity": "sha512-glfkQFJizLdq2fBkNvc2FJW0sxDb5exd0wIXhFk+WHaFLMREBC3CxRo2Zq7uJIdfV9U3YTceMbXJklpDfmmwFQ==", + "version": "1.34.5", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.5.tgz", + "integrity": "sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==", "license": "MIT", "dependencies": { "@redocly/ajv": "^8.11.2", @@ -4950,9 +5072,9 @@ } }, "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "license": "MIT", "dependencies": { "@types/connect": "*", @@ -5026,9 +5148,9 @@ } }, "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, "node_modules/@types/d3-axis": { @@ -5078,9 +5200,9 @@ "license": "MIT" }, "node_modules/@types/d3-dispatch": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", - "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", "license": "MIT" }, "node_modules/@types/d3-drag": { @@ -5270,9 +5392,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -5285,9 +5407,9 @@ } }, "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -5297,9 +5419,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", - "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -5348,13 +5470,15 @@ "license": "MIT" }, "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", - "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", "dependencies": { - "@types/react": "*", "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" } }, "node_modules/@types/html-minifier-terser": { @@ -5370,9 +5494,9 @@ "license": "MIT" }, "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "license": "MIT" }, "node_modules/@types/http-proxy": { @@ -5442,29 +5566,23 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "version": "24.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", + "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.12.0" } }, "node_modules/@types/node-forge": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", - "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", "license": "MIT", "dependencies": { "@types/node": "*" } }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, "node_modules/@types/parse5": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", @@ -5478,15 +5596,15 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "license": "MIT" }, "node_modules/@types/range-parser": { @@ -5496,9 +5614,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.20", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", - "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", + "version": "18.3.24", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", + "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -5565,9 +5683,9 @@ } }, "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -5584,9 +5702,9 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -5827,9 +5945,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5838,6 +5956,18 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -5869,9 +5999,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -5950,33 +6080,34 @@ } }, "node_modules/algoliasearch": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.24.0.tgz", - "integrity": "sha512-CkaUygzZ91Xbw11s0CsHMawrK3tl+Ue57725HGRgRzKgt2Z4wvXVXRCtQfvzh8K7Tp4Zp7f1pyHAtMROtTJHxg==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.37.0.tgz", + "integrity": "sha512-y7gau/ZOQDqoInTQp0IwTOjkrHc4Aq4R8JgpmCleFwiLl+PbN2DMWoDUWZnrK8AhNJwT++dn28Bt4NZYNLAmuA==", "license": "MIT", "dependencies": { - "@algolia/client-abtesting": "5.24.0", - "@algolia/client-analytics": "5.24.0", - "@algolia/client-common": "5.24.0", - "@algolia/client-insights": "5.24.0", - "@algolia/client-personalization": "5.24.0", - "@algolia/client-query-suggestions": "5.24.0", - "@algolia/client-search": "5.24.0", - "@algolia/ingestion": "1.24.0", - "@algolia/monitoring": "1.24.0", - "@algolia/recommend": "5.24.0", - "@algolia/requester-browser-xhr": "5.24.0", - "@algolia/requester-fetch": "5.24.0", - "@algolia/requester-node-http": "5.24.0" + "@algolia/abtesting": "1.3.0", + "@algolia/client-abtesting": "5.37.0", + "@algolia/client-analytics": "5.37.0", + "@algolia/client-common": "5.37.0", + "@algolia/client-insights": "5.37.0", + "@algolia/client-personalization": "5.37.0", + "@algolia/client-query-suggestions": "5.37.0", + "@algolia/client-search": "5.37.0", + "@algolia/ingestion": "1.37.0", + "@algolia/monitoring": "1.37.0", + "@algolia/recommend": "5.37.0", + "@algolia/requester-browser-xhr": "5.37.0", + "@algolia/requester-fetch": "5.37.0", + "@algolia/requester-node-http": "5.37.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/algoliasearch-helper": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz", - "integrity": "sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw==", + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.26.0.tgz", + "integrity": "sha512-Rv2x3GXleQ3ygwhkhJubhhYGsICmShLAiqtUuJTUkr9uOCOXyF2E71LVT4XDnVffbknv8XgScP4U0Oxtgm+hIw==", "license": "MIT", "dependencies": { "@algolia/events": "^4.0.1" @@ -5986,9 +6117,9 @@ } }, "node_modules/allof-merge": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/allof-merge/-/allof-merge-0.6.6.tgz", - "integrity": "sha512-116eZBf2he0/J4Tl7EYMz96I5Anaeio+VL0j/H2yxW9CoYQAMMv8gYcwkVRoO7XfIOv/qzSTfVzDVGAYxKFi3g==", + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/allof-merge/-/allof-merge-0.6.7.tgz", + "integrity": "sha512-slvjkM56OdeVkm1tllrnaumtSHwqyHrepXkAe6Am+CW4WdbHkNqdOKPF6cvY3/IouzvXk1BoLICT5LY7sCoFGw==", "license": "MIT", "dependencies": { "json-crawl": "^0.5.3" @@ -6220,13 +6351,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", - "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.4", + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { @@ -6243,25 +6374,25 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", - "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.4" + "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -6303,6 +6434,15 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -6444,9 +6584,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -6465,9 +6605,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", "funding": [ { "type": "opencollective", @@ -6484,10 +6624,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -6659,9 +6800,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001716", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001716.tgz", - "integrity": "sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==", + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", "funding": [ { "type": "opencollective", @@ -7112,16 +7253,16 @@ } }, "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -7196,6 +7337,12 @@ "proto-list": "~1.2.1" } }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/configstore": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", @@ -7273,9 +7420,9 @@ "license": "MIT" }, "node_modules/copy-text-to-clipboard": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", - "integrity": "sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.1.tgz", + "integrity": "sha512-3am6cw+WOicd0+HyzhC4kYS02wHJUiVQXmAADxfUARKsHBkWl1Vl3QQEiILlSs8YcPS/C0+y/urCNEYQk+byWA==", "license": "MIT", "engines": { "node": ">=12" @@ -7352,9 +7499,9 @@ } }, "node_modules/core-js": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz", - "integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==", + "version": "3.45.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz", + "integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -7363,12 +7510,12 @@ } }, "node_modules/core-js-compat": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", - "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", + "version": "3.45.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz", + "integrity": "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.4" + "browserslist": "^4.25.3" }, "funding": { "type": "opencollective", @@ -7376,9 +7523,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.42.0.tgz", - "integrity": "sha512-007bM04u91fF4kMgwom2I5cQxAFIy8jVulgr9eozILl/SZE53QOqnW/+vviC+wQWLv+AunBG+8Q0TLoeSsSxRQ==", + "version": "3.45.1", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.45.1.tgz", + "integrity": "sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -7513,9 +7660,9 @@ } }, "node_modules/css-declaration-sorter": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz", - "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.0.tgz", + "integrity": "sha512-LQF6N/3vkAMYF4xoHLJfG718HRJh34Z8BnNhd6bosOMIVjMlhuZK5++oZa3uYAgrI5+7x2o27gUqTR2U/KjUOQ==", "license": "ISC", "engines": { "node": "^14 || ^16 || >=18" @@ -7525,9 +7672,9 @@ } }, "node_modules/css-has-pseudo": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-7.0.2.tgz", - "integrity": "sha512-nzol/h+E0bId46Kn2dQH5VElaknX2Sr0hFuB/1EomdC7j+OISt2ZzK7EHX9DZDY53WbIVAR7FYKSO2XnSf07MQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-7.0.3.tgz", + "integrity": "sha512-oG+vKuGyqe/xvEMoxAQrhi7uY16deJR3i7wwhBerVrGQKSqUC5GiOVxTpM9F9B9hw0J+eKeOWLH7E9gZ1Dr5rA==", "funding": [ { "type": "github", @@ -7688,9 +7835,9 @@ } }, "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -7717,9 +7864,9 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -7729,9 +7876,9 @@ } }, "node_modules/cssdb": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.2.5.tgz", - "integrity": "sha512-leAt8/hdTCtzql9ZZi86uYAmCLzVKpJMMdjbvOGVnXFXz/BWFpBmM1MHEHU/RqtPyRYmabVmEW1DtX3YGLuuLA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.4.0.tgz", + "integrity": "sha512-lyATYGyvXwQ8h55WeQeEHXhI+47rl52pXSYkFK/ZrCbAJSgVIaPFjYc3RM8TpRHKk7W3wsAZImmLps+P5VyN9g==", "funding": [ { "type": "opencollective", @@ -7893,9 +8040,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.31.4", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.31.4.tgz", - "integrity": "sha512-JfUX/esCfnBGP+uNqRSkAr8jDr1HDSEm6jUNG+BToi43zwLisWrArZjIboB3NfCF5yKu2eG6sbPYaefEEaufyQ==", + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", "engines": { "node": ">=0.10" @@ -8401,9 +8548,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "license": "MIT" }, "node_modules/debounce": { @@ -8413,9 +8560,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8430,9 +8577,9 @@ } }, "node_modules/decode-named-character-reference": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", - "integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -8551,28 +8698,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/del": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", - "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", - "license": "MIT", - "dependencies": { - "globby": "^11.0.1", - "graceful-fs": "^4.2.4", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.2", - "p-map": "^4.0.0", - "rimraf": "^3.0.2", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/delaunator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", @@ -8658,38 +8783,6 @@ "node": ">= 4.0.0" } }, - "node_modules/detect-port-alt": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", - "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", - "license": "MIT", - "dependencies": { - "address": "^1.0.1", - "debug": "^2.6.0" - }, - "bin": { - "detect": "bin/detect-port", - "detect-port": "bin/detect-port" - }, - "engines": { - "node": ">= 4.2.1" - } - }, - "node_modules/detect-port-alt/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/detect-port-alt/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -8737,9 +8830,9 @@ } }, "node_modules/docusaurus-plugin-openapi-docs": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-4.3.7.tgz", - "integrity": "sha512-wCXuHniG108OGCj6qKtTOFLgyhnlztMegj63BbEyHC/OgM7PDL2Yj2VFkWsU3eCmJKI+czahanztFMhVLFD67w==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-4.5.1.tgz", + "integrity": "sha512-3I6Sjz19D/eM86a24/nVkYfqNkl/zuXSP04XVo7qm/vlPeCpHVM4li2DLj7PzElr6dlS9RbaS4HVIQhEOPGBRQ==", "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.5.4", @@ -8807,9 +8900,9 @@ } }, "node_modules/docusaurus-theme-openapi-docs": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-4.3.7.tgz", - "integrity": "sha512-VRKA8gFVIlSBUu7EAYOY3JDF2WetCSVsYx5WeFo8g6/7LJWHhX7/A7Wo2fJ0B61VE/c53BSdbmvVWSJoUqnkoA==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-4.5.1.tgz", + "integrity": "sha512-C7mYh9JC3l9jjRtqJVu0EIyOgxHB08jE0Tp5NSkNkrrBak4A13SrXCisNjvt1eaNjS+tsz7qD0bT3aI5hsRvWA==", "license": "MIT", "dependencies": { "@hookform/error-message": "^2.0.1", @@ -9928,9 +10021,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", - "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -10017,9 +10110,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.148", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.148.tgz", - "integrity": "sha512-8uc1QXwwqayD4mblcsQYZqoi+cOc97A2XmKSBOIRbEAvbp6vrqmSYs4dHD2qVygUgn7Mi0qdKgPaJ9WC8cv63A==", + "version": "1.5.222", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -10063,9 +10156,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -10088,9 +10181,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -10334,9 +10427,9 @@ } }, "node_modules/estree-util-value-to-estree": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.3.3.tgz", - "integrity": "sha512-Db+m1WSD4+mUO7UgMeKkAwdbfNWwIxLt48XF2oFU9emPfXkIu+k5/nlOj313v7wqtAPo0f9REhUvznFrPkG8CQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.4.0.tgz", + "integrity": "sha512-Zlp+gxis+gCfK12d3Srl2PdX2ybsEA8ZYy6vQGVQTNNYLEGRQQ56XB64bjemN8kxIKXP1nC9ip4Z+ILy9LGzvQ==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -10558,9 +10651,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz", - "integrity": "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", "license": "MIT" }, "node_modules/extend": { @@ -10769,15 +10862,6 @@ "node": ">=0.10.0" } }, - "node_modules/filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -10865,9 +10949,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -10918,156 +11002,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fork-ts-checker-webpack-plugin": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", - "integrity": "sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "chokidar": "^3.4.2", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "glob": "^7.1.6", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - }, - "engines": { - "node": ">=10", - "yarn": ">=1.0.0" - }, - "peerDependencies": { - "eslint": ">= 6", - "typescript": ">= 2.7", - "vue-template-compiler": "*", - "webpack": ">= 4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - }, - "vue-template-compiler": { - "optional": true - } - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", - "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.1.0", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.7.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", - "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/form-data-encoder": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", @@ -11117,9 +11051,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -11131,9 +11065,9 @@ } }, "node_modules/fs-monkey": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", - "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", "license": "Unlicense" }, "node_modules/fs.realpath": { @@ -11284,9 +11218,9 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -11320,60 +11254,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/global-dirs/node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/global-modules": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", - "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", - "license": "MIT", - "dependencies": { - "global-prefix": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/global-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", - "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", - "license": "MIT", - "dependencies": { - "ini": "^1.3.5", - "kind-of": "^6.0.2", - "which": "^1.3.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globby": { @@ -11908,9 +11798,9 @@ } }, "node_modules/html-webpack-plugin": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", - "integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.4.tgz", + "integrity": "sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw==", "license": "MIT", "dependencies": { "@types/html-minifier-terser": "^6.0.0", @@ -11989,9 +11879,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, "node_modules/http-deceiver": { @@ -12173,13 +12063,10 @@ } }, "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", "license": "MIT", - "dependencies": { - "queue": "6.0.2" - }, "bin": { "image-size": "bin/image-size.js" }, @@ -12198,9 +12085,9 @@ } }, "node_modules/immutable": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz", - "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "license": "MIT" }, "node_modules/import-fresh": { @@ -12273,10 +12160,13 @@ "license": "ISC" }, "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "license": "ISC", + "engines": { + "node": ">=10" + } }, "node_modules/inline-style-parser": { "version": "0.1.1", @@ -12503,9 +12393,9 @@ } }, "node_modules/is-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", - "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -12532,15 +12422,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-path-cwd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", - "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -12583,15 +12464,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-root": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", - "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -12847,9 +12719,9 @@ } }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -12953,13 +12825,13 @@ } }, "node_modules/launch-editor": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", - "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz", + "integrity": "sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==", "license": "MIT", "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" + "picocolors": "^1.1.1", + "shell-quote": "^1.8.3" } }, "node_modules/layout-base": { @@ -13028,14 +12900,14 @@ } }, "node_modules/local-pkg": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz", - "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", "license": "MIT", "dependencies": { "mlly": "^1.7.4", - "pkg-types": "^2.0.1", - "quansync": "^0.2.8" + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, "engines": { "node": ">=14" @@ -13164,15 +13036,15 @@ } }, "node_modules/marked": { - "version": "15.0.11", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.11.tgz", - "integrity": "sha512-1BEXAU2euRCG3xwgLVT1y0xbJEld1XOrmRJpUwRCcy7rxhSCwMrmEu9LXoPhHSCJG41V7YcQ2mjKRr5BA3ITIA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", + "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/math-intrinsics": { @@ -13710,14 +13582,14 @@ } }, "node_modules/mermaid": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.6.0.tgz", - "integrity": "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.0.tgz", + "integrity": "sha512-ZudVx73BwrMJfCFmSSJT84y6u5brEoV8DOItdHomNLz32uBjNrelm7mg95X7g+C6UoQH/W6mBLGDEDv73JdxBg==", "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.0.4", - "@iconify/utils": "^2.1.33", - "@mermaid-js/parser": "^0.4.0", + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^0.6.2", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", @@ -13725,12 +13597,12 @@ "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", - "dayjs": "^1.11.13", - "dompurify": "^3.2.4", - "katex": "^0.16.9", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", - "marked": "^15.0.7", + "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -15607,9 +15479,9 @@ } }, "node_modules/mini-css-extract-plugin": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", - "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz", + "integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==", "license": "MIT", "dependencies": { "schema-utils": "^4.0.0", @@ -15663,15 +15535,15 @@ } }, "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", "license": "MIT", "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" } }, "node_modules/mlly/node_modules/confbox": { @@ -15873,9 +15745,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", "license": "MIT" }, "node_modules/normalize-path": { @@ -15897,9 +15769,9 @@ } }, "node_modules/normalize-url": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", - "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.0.tgz", + "integrity": "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==", "license": "MIT", "engines": { "node": ">=14.16" @@ -16175,9 +16047,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -16279,6 +16151,15 @@ "node": ">=12.20" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -16324,6 +16205,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -16337,13 +16234,16 @@ "node": ">=8" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, "engines": { - "node": ">=6" + "node": ">=8" } }, "node_modules/package-json": { @@ -16371,13 +16271,10 @@ "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", - "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", - "license": "MIT", - "dependencies": { - "quansync": "^0.2.7" - } + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", + "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", + "license": "MIT" }, "node_modules/pako": { "version": "2.1.0", @@ -16482,9 +16379,9 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -16662,87 +16559,14 @@ } }, "node_modules/pkg-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz", - "integrity": "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==", - "license": "MIT", - "dependencies": { - "confbox": "^0.2.1", - "exsolve": "^1.0.1", - "pathe": "^2.0.3" - } - }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/p-limit": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "license": "MIT", "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "license": "MIT", - "engines": { - "node": ">=4" + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" } }, "node_modules/pluralize": { @@ -16771,9 +16595,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -16790,7 +16614,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -16868,9 +16692,9 @@ } }, "node_modules/postcss-color-functional-notation": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.9.tgz", - "integrity": "sha512-WScwD3pSsIz+QP97sPkGCeJm7xUH0J18k6zV5o8O2a4cQJyv15vLUx/WFQajuJVgZhmJL5awDu8zHnqzAzm4lw==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.11.tgz", + "integrity": "sha512-zfqoUSaHMko/k2PA9xnaydVTHqYv5vphq5Q2AHcG/dCdv/OkHYWcVWfVTBKZ526uzT8L7NghuvSw3C9PxlKnLg==", "funding": [ { "type": "github", @@ -16883,10 +16707,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -16983,9 +16807,9 @@ } }, "node_modules/postcss-custom-media": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-11.0.5.tgz", - "integrity": "sha512-SQHhayVNgDvSAdX9NQ/ygcDQGEY+aSF4b/96z7QUX6mqL5yl/JgG/DywcF6fW9XbnCRE+aVYk+9/nqGuzOPWeQ==", + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-11.0.6.tgz", + "integrity": "sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw==", "funding": [ { "type": "github", @@ -16998,10 +16822,10 @@ ], "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.4", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/media-query-list-parser": "^4.0.2" + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" }, "engines": { "node": ">=18" @@ -17011,9 +16835,9 @@ } }, "node_modules/postcss-custom-properties": { - "version": "14.0.4", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.4.tgz", - "integrity": "sha512-QnW8FCCK6q+4ierwjnmXF9Y9KF8q0JkbgVfvQEMa93x1GT8FvOiUevWCN2YLaOWyByeDX8S6VFbZEeWoAoXs2A==", + "version": "14.0.6", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.6.tgz", + "integrity": "sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ==", "funding": [ { "type": "github", @@ -17026,9 +16850,9 @@ ], "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.4", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, @@ -17040,9 +16864,9 @@ } }, "node_modules/postcss-custom-selectors": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-8.0.4.tgz", - "integrity": "sha512-ASOXqNvDCE0dAJ/5qixxPeL1aOVGHGW2JwSy7HyjWNbnWTQCl+fDc968HY1jCmZI0+BaYT5CxsOiUhavpG/7eg==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-8.0.5.tgz", + "integrity": "sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg==", "funding": [ { "type": "github", @@ -17055,9 +16879,9 @@ ], "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^2.0.4", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", "postcss-selector-parser": "^7.0.0" }, "engines": { @@ -17182,9 +17006,9 @@ } }, "node_modules/postcss-double-position-gradients": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.1.tgz", - "integrity": "sha512-ZitCwmvOR4JzXmKw6sZblTgwV1dcfLvClcyjADuqZ5hU0Uk4SVNpvSN9w8NcJ7XuxhRYxVA8m8AB3gy+HNBQOA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.3.tgz", + "integrity": "sha512-Dl0Z9sdbMwrPslgOaGBZRGo3TASmmgTcqcUODr82MTYyJk6devXZM6MlQjpQKMJqlLJ6oL1w78U7IXFdPA5+ug==", "funding": [ { "type": "github", @@ -17197,7 +17021,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0", "postcss-value-parser": "^4.2.0" }, @@ -17342,9 +17166,9 @@ } }, "node_modules/postcss-lab-function": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-7.0.9.tgz", - "integrity": "sha512-IGbsIXbqMDusymJAKYX+f9oakPo89wL9Pzd/qRBQOVf3EIQWT9hgvqC4Me6Dkzxp3KPuIBf6LPkjrLHe/6ZMIQ==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-7.0.11.tgz", + "integrity": "sha512-BEA4jId8uQe1gyjZZ6Bunb6ZsH2izks+v25AxQJDBtigXCjTLmCPWECwQpLTtcxH589MVxhs/9TAmRC6lUEmXQ==", "funding": [ { "type": "github", @@ -17357,10 +17181,10 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", "@csstools/utilities": "^2.0.0" }, "engines": { @@ -17617,9 +17441,9 @@ } }, "node_modules/postcss-nesting": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.1.tgz", - "integrity": "sha512-VbqqHkOBOt4Uu3G8Dm8n6lU5+9cJFxiuty9+4rcoyRPO9zZS1JIs6td49VIoix3qYqELHlJIn46Oih9SAKo+yQ==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.2.tgz", + "integrity": "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==", "funding": [ { "type": "github", @@ -17632,7 +17456,7 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/selector-resolve-nested": "^3.0.0", + "@csstools/selector-resolve-nested": "^3.1.0", "@csstools/selector-specificity": "^5.0.0", "postcss-selector-parser": "^7.0.0" }, @@ -17644,9 +17468,9 @@ } }, "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.0.0.tgz", - "integrity": "sha512-ZoK24Yku6VJU1gS79a5PFmC8yn3wIapiKmPgun0hZgEI5AOqgH2kiPRsPz1qkGv4HL+wuDLH83yQyk6inMYrJQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", + "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", "funding": [ { "type": "github", @@ -17931,9 +17755,9 @@ } }, "node_modules/postcss-preset-env": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.1.6.tgz", - "integrity": "sha512-1jRD7vttKLJ7o0mcmmYWKRLm7W14rI8K1I7Y41OeXUPEVc/CAzfTssNUeJ0zKbR+zMk4boqct/gwS/poIFF5Lg==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.3.1.tgz", + "integrity": "sha512-8ZOOWVwQ0iMpfEYkYo+U6W7fE2dJ/tP6dtEFwPJ66eB5JjnFupfYh+y6zo+vWDO72nGhKOVdxwhTjfzcSNRg4Q==", "funding": [ { "type": "github", @@ -17946,62 +17770,65 @@ ], "license": "MIT-0", "dependencies": { - "@csstools/postcss-cascade-layers": "^5.0.1", - "@csstools/postcss-color-function": "^4.0.9", - "@csstools/postcss-color-mix-function": "^3.0.9", - "@csstools/postcss-content-alt-text": "^2.0.5", - "@csstools/postcss-exponential-functions": "^2.0.8", + "@csstools/postcss-alpha-function": "^1.0.0", + "@csstools/postcss-cascade-layers": "^5.0.2", + "@csstools/postcss-color-function": "^4.0.11", + "@csstools/postcss-color-function-display-p3-linear": "^1.0.0", + "@csstools/postcss-color-mix-function": "^3.0.11", + "@csstools/postcss-color-mix-variadic-function-arguments": "^1.0.1", + "@csstools/postcss-content-alt-text": "^2.0.7", + "@csstools/postcss-exponential-functions": "^2.0.9", "@csstools/postcss-font-format-keywords": "^4.0.0", - "@csstools/postcss-gamut-mapping": "^2.0.9", - "@csstools/postcss-gradients-interpolation-method": "^5.0.9", - "@csstools/postcss-hwb-function": "^4.0.9", - "@csstools/postcss-ic-unit": "^4.0.1", + "@csstools/postcss-gamut-mapping": "^2.0.11", + "@csstools/postcss-gradients-interpolation-method": "^5.0.11", + "@csstools/postcss-hwb-function": "^4.0.11", + "@csstools/postcss-ic-unit": "^4.0.3", "@csstools/postcss-initial": "^2.0.1", - "@csstools/postcss-is-pseudo-class": "^5.0.1", - "@csstools/postcss-light-dark-function": "^2.0.8", + "@csstools/postcss-is-pseudo-class": "^5.0.3", + "@csstools/postcss-light-dark-function": "^2.0.10", "@csstools/postcss-logical-float-and-clear": "^3.0.0", "@csstools/postcss-logical-overflow": "^2.0.0", "@csstools/postcss-logical-overscroll-behavior": "^2.0.0", "@csstools/postcss-logical-resize": "^3.0.0", - "@csstools/postcss-logical-viewport-units": "^3.0.3", - "@csstools/postcss-media-minmax": "^2.0.8", - "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.4", + "@csstools/postcss-logical-viewport-units": "^3.0.4", + "@csstools/postcss-media-minmax": "^2.0.9", + "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.5", "@csstools/postcss-nested-calc": "^4.0.0", "@csstools/postcss-normalize-display-values": "^4.0.0", - "@csstools/postcss-oklab-function": "^4.0.9", - "@csstools/postcss-progressive-custom-properties": "^4.0.1", - "@csstools/postcss-random-function": "^2.0.0", - "@csstools/postcss-relative-color-syntax": "^3.0.9", + "@csstools/postcss-oklab-function": "^4.0.11", + "@csstools/postcss-progressive-custom-properties": "^4.2.0", + "@csstools/postcss-random-function": "^2.0.1", + "@csstools/postcss-relative-color-syntax": "^3.0.11", "@csstools/postcss-scope-pseudo-class": "^4.0.1", - "@csstools/postcss-sign-functions": "^1.1.3", - "@csstools/postcss-stepped-value-functions": "^4.0.8", - "@csstools/postcss-text-decoration-shorthand": "^4.0.2", - "@csstools/postcss-trigonometric-functions": "^4.0.8", + "@csstools/postcss-sign-functions": "^1.1.4", + "@csstools/postcss-stepped-value-functions": "^4.0.9", + "@csstools/postcss-text-decoration-shorthand": "^4.0.3", + "@csstools/postcss-trigonometric-functions": "^4.0.9", "@csstools/postcss-unset-value": "^4.0.0", "autoprefixer": "^10.4.21", - "browserslist": "^4.24.4", + "browserslist": "^4.25.1", "css-blank-pseudo": "^7.0.1", - "css-has-pseudo": "^7.0.2", + "css-has-pseudo": "^7.0.3", "css-prefers-color-scheme": "^10.0.0", - "cssdb": "^8.2.5", + "cssdb": "^8.4.0", "postcss-attribute-case-insensitive": "^7.0.1", "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^7.0.9", + "postcss-color-functional-notation": "^7.0.11", "postcss-color-hex-alpha": "^10.0.0", "postcss-color-rebeccapurple": "^10.0.0", - "postcss-custom-media": "^11.0.5", - "postcss-custom-properties": "^14.0.4", - "postcss-custom-selectors": "^8.0.4", + "postcss-custom-media": "^11.0.6", + "postcss-custom-properties": "^14.0.6", + "postcss-custom-selectors": "^8.0.5", "postcss-dir-pseudo-class": "^9.0.1", - "postcss-double-position-gradients": "^6.0.1", + "postcss-double-position-gradients": "^6.0.3", "postcss-focus-visible": "^10.0.1", "postcss-focus-within": "^9.0.1", "postcss-font-variant": "^5.0.0", "postcss-gap-properties": "^6.0.0", "postcss-image-set-function": "^7.0.0", - "postcss-lab-function": "^7.0.9", + "postcss-lab-function": "^7.0.11", "postcss-logical": "^8.1.0", - "postcss-nesting": "^13.0.1", + "postcss-nesting": "^13.0.2", "postcss-opacity-percentage": "^3.0.0", "postcss-overflow-shorthand": "^6.0.0", "postcss-page-break": "^3.0.4", @@ -18385,9 +18212,9 @@ } }, "node_modules/property-information": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", - "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", "license": "MIT", "funding": { "type": "github", @@ -18432,9 +18259,9 @@ } }, "node_modules/pupa": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", - "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", "license": "MIT", "dependencies": { "escape-goat": "^4.0.0" @@ -18462,9 +18289,9 @@ } }, "node_modules/quansync": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", - "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", "funding": [ { "type": "individual", @@ -18477,15 +18304,6 @@ ], "license": "MIT" }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.3" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -18656,6 +18474,12 @@ "rc": "cli.js" } }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -18677,132 +18501,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-dev-utils": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", - "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.16.0", - "address": "^1.1.2", - "browserslist": "^4.18.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", - "detect-port-alt": "^1.1.6", - "escape-string-regexp": "^4.0.0", - "filesize": "^8.0.6", - "find-up": "^5.0.0", - "fork-ts-checker-webpack-plugin": "^6.5.0", - "global-modules": "^2.0.0", - "globby": "^11.0.4", - "gzip-size": "^6.0.0", - "immer": "^9.0.7", - "is-root": "^2.1.0", - "loader-utils": "^3.2.0", - "open": "^8.4.0", - "pkg-up": "^3.1.0", - "prompts": "^2.4.2", - "react-error-overlay": "^6.0.11", - "recursive-readdir": "^2.2.2", - "shell-quote": "^1.7.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/react-dev-utils/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-dev-utils/node_modules/loader-utils": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", - "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/react-dev-utils/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-dev-utils/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-dev-utils/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-dev-utils/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/react-dev-utils/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -18816,12 +18514,6 @@ "react": "^18.3.1" } }, - "node_modules/react-error-overlay": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz", - "integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==", - "license": "MIT" - }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -18847,9 +18539,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.56.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz", - "integrity": "sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==", + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -18869,15 +18561,15 @@ "license": "MIT" }, "node_modules/react-json-view-lite": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-1.5.0.tgz", - "integrity": "sha512-nWqA1E4jKPklL2jvHWs6s+7Na0qNgw9HCP6xehdQJeg6nPBTFZgGwyko9Q0oj+jQWKTTVRS30u0toM5wiuL3iw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz", + "integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==", "license": "MIT", "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + "react": "^18.0.0 || ^19.0.0" } }, "node_modules/react-lifecycles-compat": { @@ -19736,12 +19428,6 @@ "node": ">=8.10.0" } }, - "node_modules/reading-time": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", - "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==", - "license": "MIT" - }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -19769,9 +19455,9 @@ } }, "node_modules/recma-jsx": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.0.tgz", - "integrity": "sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", "license": "MIT", "dependencies": { "acorn-jsx": "^5.0.0", @@ -19783,6 +19469,9 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/recma-parse": { @@ -19817,40 +19506,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/recursive-readdir": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", - "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", - "license": "MIT", - "dependencies": { - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/recursive-readdir/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/recursive-readdir/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/redux": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", @@ -19885,9 +19540,9 @@ "license": "MIT" }, "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -19897,17 +19552,17 @@ } }, "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.3.1.tgz", + "integrity": "sha512-DzcswPr252wEr7Qz8AyAVbfyBDKLoYp6eRA1We2Fa9qirRFSdtkP5sHr3yglDKy2BbA0fd2T+j/CUSKes3FeVQ==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", + "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.12.0", "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" + "unicode-match-property-value-ecmascript": "^2.2.1" }, "engines": { "node": ">=4" @@ -20076,9 +19731,9 @@ } }, "node_modules/remark-mdx": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", - "integrity": "sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", "license": "MIT", "dependencies": { "mdast-util-mdx": "^3.0.0", @@ -20479,9 +20134,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.87.0.tgz", - "integrity": "sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==", + "version": "1.92.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.92.1.tgz", + "integrity": "sha512-ffmsdbwqb3XeyR8jJR6KelIXARM9bFQe8A6Q3W4Klmwy5Ckd5gz7jgUNHo4UOqutU5Sk1DtKLbpDP0nLCg1xqQ==", "license": "MIT", "dependencies": { "chokidar": "^4.0.0", @@ -20581,6 +20236,12 @@ "loose-envify": "^1.1.0" } }, + "node_modules/schema-dts": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz", + "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==", + "license": "Apache-2.0" + }, "node_modules/schema-utils": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", @@ -20640,9 +20301,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -20748,9 +20409,9 @@ } }, "node_modules/serve-handler/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -20952,9 +20613,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -21227,12 +20888,12 @@ } }, "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "license": "BSD-3-Clause", "engines": { - "node": ">= 8" + "node": ">= 12" } }, "node_modules/source-map-js": { @@ -21384,9 +21045,9 @@ "license": "MIT" }, "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -21396,9 +21057,9 @@ } }, "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -21494,12 +21155,12 @@ } }, "node_modules/style-to-js": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", - "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", + "integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==", "license": "MIT", "dependencies": { - "style-to-object": "1.0.8" + "style-to-object": "1.0.9" } }, "node_modules/style-to-js/node_modules/inline-style-parser": { @@ -21509,9 +21170,9 @@ "license": "MIT" }, "node_modules/style-to-js/node_modules/style-to-object": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", - "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz", + "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==", "license": "MIT", "dependencies": { "inline-style-parser": "0.2.4" @@ -21706,22 +21367,26 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -21801,12 +21466,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "license": "MIT" - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -21847,11 +21506,20 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", "license": "MIT" }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -21963,20 +21631,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", @@ -21984,9 +21638,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", + "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -22021,18 +21675,18 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "license": "MIT", "engines": { "node": ">=4" @@ -22274,9 +21928,9 @@ } }, "node_modules/update-notifier/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -22566,9 +22220,9 @@ } }, "node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -22638,9 +22292,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", @@ -22676,21 +22330,22 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.99.7", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.7.tgz", - "integrity": "sha512-CNqKBRMQjwcmKR0idID5va1qlhrqVUKpovi+Ec79ksW8ux7iS1+A6VqzfZXgVYCFRKl7XL5ap3ZoMpwBJxcg0w==", + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -22704,7 +22359,7 @@ "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -22861,9 +22516,9 @@ "license": "MIT" }, "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -22896,9 +22551,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "license": "MIT", "engines": { "node": ">=10.13.0" @@ -23101,9 +22756,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -23113,9 +22768,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -23125,9 +22780,9 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" diff --git a/docs/sidebars.ts b/docs/sidebars.ts index e3de4d4785..df32f2efd9 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -5,14 +5,14 @@ import frigateHttpApiSidebar from "./docs/integrations/api/sidebar"; const sidebars: SidebarsConfig = { docs: { Frigate: [ - 'frigate/index', - 'frigate/hardware', - 'frigate/planning_setup', - 'frigate/installation', - 'frigate/updating', - 'frigate/camera_setup', - 'frigate/video_pipeline', - 'frigate/glossary', + "frigate/index", + "frigate/hardware", + "frigate/planning_setup", + "frigate/installation", + "frigate/updating", + "frigate/camera_setup", + "frigate/video_pipeline", + "frigate/glossary", ], Guides: [ "guides/getting_started", @@ -28,7 +28,7 @@ const sidebars: SidebarsConfig = { { type: "link", label: "Go2RTC Configuration Reference", - href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.9#configuration", + href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.10#configuration", } as PropSidebarItemLink, ], Detectors: [ @@ -37,10 +37,36 @@ const sidebars: SidebarsConfig = { ], Enrichments: [ "configuration/semantic_search", - "configuration/genai", "configuration/face_recognition", "configuration/license_plate_recognition", "configuration/bird_classification", + { + type: "category", + label: "Custom Classification", + link: { + type: "generated-index", + title: "Custom Classification", + description: "Configuration for custom classification models", + }, + items: [ + "configuration/custom_classification/state_classification", + "configuration/custom_classification/object_classification", + ], + }, + { + type: "category", + label: "Generative AI", + link: { + type: "generated-index", + title: "Generative AI", + description: "Generative AI Features", + }, + items: [ + "configuration/genai/genai_config", + "configuration/genai/genai_review", + "configuration/genai/genai_objects", + ], + }, ], Cameras: [ "configuration/cameras", @@ -93,11 +119,11 @@ const sidebars: SidebarsConfig = { "configuration/metrics", "integrations/third_party_extensions", ], - 'Frigate+': [ - 'plus/index', - 'plus/annotating', - 'plus/first_model', - 'plus/faq', + "Frigate+": [ + "plus/index", + "plus/annotating", + "plus/first_model", + "plus/faq", ], Troubleshooting: [ "troubleshooting/faqs", diff --git a/frigate/__main__.py b/frigate/__main__.py index 4143f7ae6d..f3181e4946 100644 --- a/frigate/__main__.py +++ b/frigate/__main__.py @@ -1,5 +1,6 @@ import argparse import faulthandler +import multiprocessing as mp import signal import sys import threading @@ -15,12 +16,17 @@ from frigate.util.config import find_config_file def main() -> None: + manager = mp.Manager() faulthandler.enable() # Setup the logging thread - setup_logging() + setup_logging(manager) threading.current_thread().name = "frigate" + stop_event = mp.Event() + + # send stop event on SIGINT + signal.signal(signal.SIGINT, lambda sig, frame: stop_event.set()) # Make sure we exit cleanly on SIGTERM. signal.signal(signal.SIGTERM, lambda sig, frame: sys.exit()) @@ -93,7 +99,14 @@ def main() -> None: print("*************************************************************") print("*** End Config Validation Errors ***") print("*************************************************************") - sys.exit(1) + + # attempt to start Frigate in recovery mode + try: + config = FrigateConfig.load(install=True, safe_load=True) + print("Starting Frigate in safe mode.") + except ValidationError: + print("Unable to start Frigate in safe mode.") + sys.exit(1) if args.validate_config: print("*************************************************************") print("*** Your config file is valid. ***") @@ -101,8 +114,23 @@ def main() -> None: sys.exit(0) # Run the main application. - FrigateApp(config).start() + FrigateApp(config, manager, stop_event).start() if __name__ == "__main__": + mp.set_forkserver_preload( + [ + # Standard library and core dependencies + "sqlite3", + # Third-party libraries commonly used in Frigate + "numpy", + "cv2", + "peewee", + "zmq", + "ruamel.yaml", + # Frigate core modules + "frigate.camera.maintainer", + ] + ) + mp.set_start_method("forkserver", force=True) main() diff --git a/frigate/api/app.py b/frigate/api/app.py index f6e9471f27..5c2c132fd6 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -6,11 +6,12 @@ import json import logging import os import traceback +import urllib from datetime import datetime, timedelta from functools import reduce from io import StringIO from pathlib import Path as FilePath -from typing import Any, Optional +from typing import Any, Dict, List, Optional import aiofiles import requests @@ -20,7 +21,7 @@ from fastapi.encoders import jsonable_encoder from fastapi.params import Depends from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse from markupsafe import escape -from peewee import SQL, operator +from peewee import SQL, fn, operator from pydantic import ValidationError from frigate.api.auth import require_role @@ -28,12 +29,18 @@ from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryPa from frigate.api.defs.request.app_body import AppConfigSetBody from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, +) from frigate.models import Event, Timeline from frigate.stats.prometheus import get_metrics, update_metrics from frigate.util.builtin import ( clean_camera_user_pass, + flatten_config_data, get_tz_modifiers, - update_yaml_from_url, + process_config_query_string, + update_yaml_file_bulk, ) from frigate.util.config import find_config_file from frigate.util.services import ( @@ -123,7 +130,14 @@ def metrics(request: Request): """Expose Prometheus metrics endpoint and update metrics with latest stats""" # Retrieve the latest statistics and update the Prometheus metrics stats = request.app.stats_emitter.get_latest_stats() - update_metrics(stats) + # query DB for count of events by camera, label + event_counts: List[Dict[str, Any]] = ( + Event.select(Event.camera, Event.label, fn.Count()) + .group_by(Event.camera, Event.label) + .dicts() + ) + + update_metrics(stats=stats, event_counts=event_counts) content, content_type = get_metrics() return Response(content=content, media_type=content_type) @@ -354,14 +368,37 @@ def config_set(request: Request, body: AppConfigSetBody): with open(config_file, "r") as f: old_raw_config = f.read() - f.close() try: - update_yaml_from_url(config_file, str(request.url)) + updates = {} + + # process query string parameters (takes precedence over body.config_data) + parsed_url = urllib.parse.urlparse(str(request.url)) + query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True) + + # Filter out empty keys but keep blank values for non-empty keys + query_string = {k: v for k, v in query_string.items() if k} + + if query_string: + updates = process_config_query_string(query_string) + elif body.config_data: + updates = flatten_config_data(body.config_data) + + if not updates: + return JSONResponse( + content=( + {"success": False, "message": "No configuration data provided"} + ), + status_code=400, + ) + + # apply all updates in a single operation + update_yaml_file_bulk(config_file, updates) + + # validate the updated config with open(config_file, "r") as f: new_raw_config = f.read() - f.close() - # Validate the config schema + try: config = FrigateConfig.parse(new_raw_config) except Exception: @@ -385,8 +422,25 @@ def config_set(request: Request, body: AppConfigSetBody): status_code=500, ) - if body.requires_restart == 0: + if body.requires_restart == 0 or body.update_topic: + old_config: FrigateConfig = request.app.frigate_config request.app.frigate_config = config + + if body.update_topic and body.update_topic.startswith("config/cameras/"): + _, _, camera, field = body.update_topic.split("/") + + if field == "add": + settings = config.cameras[camera] + elif field == "remove": + settings = old_config.cameras[camera] + else: + settings = config.get_nested_object(body.update_topic) + + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera), + settings, + ) + return JSONResponse( content=( { diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 9459c4ac88..14fd804f7e 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -11,7 +11,7 @@ import secrets import time from datetime import datetime from pathlib import Path -from typing import List +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse, RedirectResponse @@ -33,7 +33,6 @@ from frigate.models import User logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.auth]) -VALID_ROLES = ["admin", "viewer"] class RateLimiter: @@ -204,6 +203,7 @@ async def get_current_user(request: Request): def require_role(required_roles: List[str]): async def role_checker(request: Request): proxy_config: ProxyConfig = request.app.frigate_config.proxy + config_roles = list(request.app.frigate_config.auth.roles.keys()) # Get role from header (could be comma-separated) role_header = request.headers.get("remote-role") @@ -217,19 +217,123 @@ def require_role(required_roles: List[str]): if not roles: raise HTTPException(status_code=403, detail="Role not provided") - # Check if any role matches required_roles - if not any(role in required_roles for role in roles): + # enforce config roles + valid_roles = [r for r in roles if r in config_roles] + if not valid_roles: raise HTTPException( status_code=403, - detail=f"Role {', '.join(roles)} not authorized. Required: {', '.join(required_roles)}", + detail=f"No valid roles found in {roles}. Required: {', '.join(required_roles)}. Available: {', '.join(config_roles)}", ) - # Return the first matching role - return next((role for role in roles if role in required_roles), roles[0]) + if not any(role in required_roles for role in valid_roles): + raise HTTPException( + status_code=403, + detail=f"Role {', '.join(valid_roles)} not authorized. Required: {', '.join(required_roles)}", + ) + + return next( + (role for role in valid_roles if role in required_roles), valid_roles[0] + ) return role_checker +def resolve_role( + headers: dict, proxy_config: ProxyConfig, config_roles: set[str] +) -> str: + """ + Determine the effective role for a request based on proxy headers and configuration. + + Order of resolution: + 1. If a role header is defined in proxy_config.header_map.role: + - If a role_map is configured, treat the header as group claims + (split by proxy_config.separator) and map to roles. + - If no role_map is configured, treat the header as role names directly. + 2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'. + + Args: + headers (dict): Incoming request headers (case-insensitive). + proxy_config (ProxyConfig): Proxy configuration. + config_roles (set[str]): Set of valid roles from config. + + Returns: + str: Resolved role (one of config_roles or validated default). + """ + default_role = proxy_config.default_role + role_header = proxy_config.header_map.role + + # Validate default_role against config; fallback to 'viewer' if invalid + validated_default = default_role if default_role in config_roles else "viewer" + if not config_roles: + validated_default = "viewer" # Edge case: no roles defined + + if not role_header: + logger.debug( + "No role header configured in proxy_config.header_map. Returning validated default role '%s'.", + validated_default, + ) + return validated_default + + raw_value = headers.get(role_header, "") + logger.debug("Raw role header value from '%s': %r", role_header, raw_value) + + if not raw_value: + logger.debug( + "Role header missing or empty. Returning validated default role '%s'.", + validated_default, + ) + return validated_default + + # role_map configured, treat header as group claims + if proxy_config.header_map.role_map: + groups = [ + g.strip() for g in raw_value.split(proxy_config.separator) if g.strip() + ] + logger.debug("Parsed groups from role header: %s", groups) + + matched_roles = { + role_name + for role_name, required_groups in proxy_config.header_map.role_map.items() + if any(group in groups for group in required_groups) + } + logger.debug("Matched roles from role_map: %s", matched_roles) + + if matched_roles: + resolved = next( + (r for r in config_roles if r in matched_roles), validated_default + ) + logger.debug("Resolved role (with role_map) to '%s'.", resolved) + return resolved + + logger.debug( + "No role_map match for groups '%s'. Using validated default role '%s'.", + raw_value, + validated_default, + ) + return validated_default + + # no role_map, treat as role names directly + roles_from_header = [ + r.strip().lower() for r in raw_value.split(proxy_config.separator) if r.strip() + ] + logger.debug("Parsed roles directly from header: %s", roles_from_header) + + resolved = next( + (r for r in config_roles if r in roles_from_header), + validated_default, + ) + if resolved == validated_default and roles_from_header: + logger.debug( + "Provided proxy role header values '%s' did not contain a valid role. Using validated default role '%s'.", + raw_value, + validated_default, + ) + else: + logger.debug("Resolved role (direct header) to '%s'.", resolved) + + return resolved + + # Endpoints @router.get("/auth") def auth(request: Request): @@ -266,22 +370,11 @@ def auth(request: Request): else "anonymous" ) - role_header = proxy_config.header_map.role - role = ( - request.headers.get(role_header, default=proxy_config.default_role) - if role_header - else proxy_config.default_role - ) - - # if comma-separated with "admin", use "admin", - # if comma-separated with "viewer", use "viewer", - # else use default role - - roles = [r.strip() for r in role.split(proxy_config.separator)] if role else [] - success_response.headers["remote-role"] = next( - (r for r in VALID_ROLES if r in roles), proxy_config.default_role - ) + # parse header and resolve a valid role + config_roles_set = set(auth_config.roles.keys()) + role = resolve_role(request.headers, proxy_config, config_roles_set) + success_response.headers["remote-role"] = role return success_response # now apply authentication @@ -373,7 +466,13 @@ def profile(request: Request): username = request.headers.get("remote-user", "anonymous") role = request.headers.get("remote-role", "viewer") - return JSONResponse(content={"username": username, "role": role}) + all_camera_names = set(request.app.frigate_config.cameras.keys()) + roles_dict = request.app.frigate_config.auth.roles + allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + + return JSONResponse( + content={"username": username, "role": role, "allowed_cameras": allowed_cameras} + ) @router.get("/logout") @@ -404,8 +503,12 @@ def login(request: Request, body: AppPostLoginBody): password_hash = db_user.password_hash if verify_password(password, password_hash): role = getattr(db_user, "role", "viewer") - if role not in VALID_ROLES: - role = "viewer" # Enforce valid roles + config_roles_set = set(request.app.frigate_config.auth.roles.keys()) + if role not in config_roles_set: + logger.warning( + f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'." + ) + role = "viewer" expiration = int(time.time()) + JWT_SESSION_LENGTH encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token) response = Response("", 200) @@ -430,11 +533,17 @@ def create_user( body: AppPostUsersBody, ): HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations + config_roles = list(request.app.frigate_config.auth.roles.keys()) if not re.match("^[A-Za-z0-9._]+$", body.username): return JSONResponse(content={"message": "Invalid username"}, status_code=400) - role = body.role if body.role in VALID_ROLES else "viewer" + if body.role not in config_roles: + return JSONResponse( + content={"message": f"Role must be one of: {', '.join(config_roles)}"}, + status_code=400, + ) + role = body.role or "viewer" password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) User.insert( { @@ -505,10 +614,52 @@ async def update_role( return JSONResponse( content={"message": "Cannot modify admin user's role"}, status_code=403 ) - if body.role not in VALID_ROLES: + config_roles = list(request.app.frigate_config.auth.roles.keys()) + if body.role not in config_roles: return JSONResponse( - content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400 + content={"message": f"Role must be one of: {', '.join(config_roles)}"}, + status_code=400, ) User.set_by_id(username, {User.role: body.role}) return JSONResponse(content={"success": True}) + + +async def require_camera_access( + camera_name: Optional[str] = None, + request: Request = None, +): + """Dependency to enforce camera access based on user role.""" + if camera_name is None: + return # For lists, filter later + + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + return current_user + + role = current_user["role"] + all_camera_names = set(request.app.frigate_config.cameras.keys()) + roles_dict = request.app.frigate_config.auth.roles + allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + + # Admin or full access bypasses + if role == "admin" or not roles_dict.get(role): + return + + if camera_name not in allowed_cameras: + raise HTTPException( + status_code=403, + detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}", + ) + + +async def get_allowed_cameras_for_filter(request: Request): + """Dependency to get allowed_cameras for filtering lists.""" + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + return [] # Unauthorized: no cameras + + role = current_user["role"] + all_camera_names = set(request.app.frigate_config.cameras.keys()) + roles_dict = request.app.frigate_config.auth.roles + return User.get_allowed_cameras(role, roles_dict, all_camera_names) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index 21ee59fb64..22e0a30f04 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -14,10 +14,14 @@ from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict from frigate.api.auth import require_role -from frigate.api.defs.request.classification_body import RenameFaceBody +from frigate.api.defs.request.classification_body import ( + AudioTranscriptionBody, + RenameFaceBody, +) from frigate.api.defs.tags import Tags +from frigate.config import FrigateConfig from frigate.config.camera import DetectConfig -from frigate.const import FACE_DIR +from frigate.const import CLIPS_DIR, FACE_DIR from frigate.embeddings import EmbeddingsContext from frigate.models import Event from frigate.util.path import get_event_snapshot @@ -384,3 +388,255 @@ def reindex_embeddings(request: Request): }, status_code=500, ) + + +@router.put("/audio/transcribe") +def transcribe_audio(request: Request, body: AudioTranscriptionBody): + event_id = body.event_id + + try: + event = Event.get(Event.id == event_id) + except DoesNotExist: + message = f"Event {event_id} not found" + logger.error(message) + return JSONResponse( + content=({"success": False, "message": message}), status_code=404 + ) + + if not request.app.frigate_config.cameras[event.camera].audio_transcription.enabled: + message = f"Audio transcription is not enabled for {event.camera}." + logger.error(message) + return JSONResponse( + content=( + { + "success": False, + "message": message, + } + ), + status_code=400, + ) + + context: EmbeddingsContext = request.app.embeddings + response = context.transcribe_audio(model_to_dict(event)) + + if response == "started": + return JSONResponse( + content={ + "success": True, + "message": "Audio transcription has started.", + }, + status_code=202, # 202 Accepted + ) + elif response == "in_progress": + return JSONResponse( + content={ + "success": False, + "message": "Audio transcription for a speech event is currently in progress. Try again later.", + }, + status_code=409, # 409 Conflict + ) + else: + return JSONResponse( + content={ + "success": False, + "message": "Failed to transcribe audio.", + }, + status_code=500, + ) + + +# custom classification training + + +@router.get("/classification/{name}/dataset") +def get_classification_dataset(name: str): + dataset_dict: dict[str, list[str]] = {} + + dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset") + + if not os.path.exists(dataset_dir): + return JSONResponse(status_code=200, content={}) + + for name in os.listdir(dataset_dir): + category_dir = os.path.join(dataset_dir, name) + + if not os.path.isdir(category_dir): + continue + + dataset_dict[name] = [] + + for file in filter( + lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))), + os.listdir(category_dir), + ): + dataset_dict[name].append(file) + + return JSONResponse(status_code=200, content=dataset_dict) + + +@router.get("/classification/{name}/train") +def get_classification_images(name: str): + train_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "train") + + if not os.path.exists(train_dir): + return JSONResponse(status_code=200, content=[]) + + return JSONResponse( + status_code=200, + content=list( + filter( + lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))), + os.listdir(train_dir), + ) + ), + ) + + +@router.post("/classification/{name}/train") +async def train_configured_model(request: Request, name: str): + config: FrigateConfig = request.app.frigate_config + + if name not in config.classification.custom: + return JSONResponse( + content=( + { + "success": False, + "message": f"{name} is not a known classification model.", + } + ), + status_code=404, + ) + + context: EmbeddingsContext = request.app.embeddings + context.start_classification_training(name) + return JSONResponse( + content={"success": True, "message": "Started classification model training."}, + status_code=200, + ) + + +@router.post( + "/classification/{name}/dataset/{category}/delete", + dependencies=[Depends(require_role(["admin"]))], +) +def delete_classification_dataset_images( + request: Request, name: str, category: str, body: dict = None +): + config: FrigateConfig = request.app.frigate_config + + if name not in config.classification.custom: + return JSONResponse( + content=( + { + "success": False, + "message": f"{name} is not a known classification model.", + } + ), + status_code=404, + ) + + json: dict[str, Any] = body or {} + list_of_ids = json.get("ids", "") + folder = os.path.join( + CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category) + ) + + for id in list_of_ids: + file_path = os.path.join(folder, sanitize_filename(id)) + + if os.path.isfile(file_path): + os.unlink(file_path) + + return JSONResponse( + content=({"success": True, "message": "Successfully deleted faces."}), + status_code=200, + ) + + +@router.post( + "/classification/{name}/dataset/categorize", + dependencies=[Depends(require_role(["admin"]))], +) +def categorize_classification_image(request: Request, name: str, body: dict = None): + config: FrigateConfig = request.app.frigate_config + + if name not in config.classification.custom: + return JSONResponse( + content=( + { + "success": False, + "message": f"{name} is not a known classification model.", + } + ), + status_code=404, + ) + + json: dict[str, Any] = body or {} + category = sanitize_filename(json.get("category", "")) + training_file_name = sanitize_filename(json.get("training_file", "")) + training_file = os.path.join( + CLIPS_DIR, sanitize_filename(name), "train", training_file_name + ) + + if training_file_name and not os.path.isfile(training_file): + return JSONResponse( + content=( + { + "success": False, + "message": f"Invalid filename or no file exists: {training_file_name}", + } + ), + status_code=404, + ) + + new_name = f"{category}-{datetime.datetime.now().timestamp()}.png" + new_file_folder = os.path.join( + CLIPS_DIR, sanitize_filename(name), "dataset", category + ) + + if not os.path.exists(new_file_folder): + os.mkdir(new_file_folder) + + # use opencv because webp images can not be used to train + img = cv2.imread(training_file) + cv2.imwrite(os.path.join(new_file_folder, new_name), img) + os.unlink(training_file) + + return JSONResponse( + content=({"success": True, "message": "Successfully deleted faces."}), + status_code=200, + ) + + +@router.post( + "/classification/{name}/train/delete", + dependencies=[Depends(require_role(["admin"]))], +) +def delete_classification_train_images(request: Request, name: str, body: dict = None): + config: FrigateConfig = request.app.frigate_config + + if name not in config.classification.custom: + return JSONResponse( + content=( + { + "success": False, + "message": f"{name} is not a known classification model.", + } + ), + status_code=404, + ) + + json: dict[str, Any] = body or {} + list_of_ids = json.get("ids", "") + folder = os.path.join(CLIPS_DIR, sanitize_filename(name), "train") + + for id in list_of_ids: + file_path = os.path.join(folder, sanitize_filename(id)) + + if os.path.isfile(file_path): + os.unlink(file_path) + + return JSONResponse( + content=({"success": True, "message": "Successfully deleted faces."}), + status_code=200, + ) diff --git a/frigate/api/defs/query/media_query_parameters.py b/frigate/api/defs/query/media_query_parameters.py index 8ab799a561..a16f0d53fd 100644 --- a/frigate/api/defs/query/media_query_parameters.py +++ b/frigate/api/defs/query/media_query_parameters.py @@ -1,7 +1,8 @@ from enum import Enum -from typing import Optional +from typing import Optional, Union from pydantic import BaseModel +from pydantic.json_schema import SkipJsonSchema class Extension(str, Enum): @@ -22,6 +23,7 @@ class MediaLatestFrameQueryParams(BaseModel): zones: Optional[int] = None mask: Optional[int] = None motion: Optional[int] = None + paths: Optional[int] = None regions: Optional[int] = None quality: Optional[int] = 70 height: Optional[int] = None @@ -51,3 +53,10 @@ class MediaMjpegFeedQueryParams(BaseModel): class MediaRecordingsSummaryQueryParams(BaseModel): timezone: str = "utc" cameras: Optional[str] = "all" + + +class MediaRecordingsAvailabilityQueryParams(BaseModel): + cameras: str = "all" + before: Union[float, SkipJsonSchema[None]] = None + after: Union[float, SkipJsonSchema[None]] = None + scale: int = 30 diff --git a/frigate/api/defs/query/regenerate_query_parameters.py b/frigate/api/defs/query/regenerate_query_parameters.py index bcce47b1b1..af50ada2c4 100644 --- a/frigate/api/defs/query/regenerate_query_parameters.py +++ b/frigate/api/defs/query/regenerate_query_parameters.py @@ -1,9 +1,13 @@ from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field from frigate.events.types import RegenerateDescriptionEnum class RegenerateQueryParameters(BaseModel): source: Optional[RegenerateDescriptionEnum] = RegenerateDescriptionEnum.thumbnails + force: Optional[bool] = Field( + default=False, + description="Force (re)generating the description even if GenAI is disabled for this camera.", + ) diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 1fc05db2f5..7f8ca40ec0 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -1,10 +1,12 @@ -from typing import Optional +from typing import Any, Dict, Optional from pydantic import BaseModel class AppConfigSetBody(BaseModel): requires_restart: int = 1 + update_topic: str | None = None + config_data: Optional[Dict[str, Any]] = None class AppPutPasswordBody(BaseModel): diff --git a/frigate/api/defs/request/classification_body.py b/frigate/api/defs/request/classification_body.py index c4a32c3326..31c5688bfa 100644 --- a/frigate/api/defs/request/classification_body.py +++ b/frigate/api/defs/request/classification_body.py @@ -3,3 +3,7 @@ from pydantic import BaseModel class RenameFaceBody(BaseModel): new_name: str + + +class AudioTranscriptionBody(BaseModel): + event_id: str diff --git a/frigate/api/defs/request/events_body.py b/frigate/api/defs/request/events_body.py index 0883d066f3..dd18ff8f7c 100644 --- a/frigate/api/defs/request/events_body.py +++ b/frigate/api/defs/request/events_body.py @@ -2,6 +2,8 @@ from typing import List, Optional, Union from pydantic import BaseModel, Field +from frigate.config.classification import TriggerType + class EventsSubLabelBody(BaseModel): subLabel: str = Field(title="Sub label", max_length=100) @@ -45,3 +47,9 @@ class EventsDeleteBody(BaseModel): class SubmitPlusBody(BaseModel): include_annotation: int = Field(default=1) + + +class TriggerEmbeddingBody(BaseModel): + type: TriggerType + data: str + threshold: float = Field(default=0.5, ge=0.0, le=1.0) diff --git a/frigate/api/event.py b/frigate/api/event.py index 27353e4b57..5f426c83fc 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -1,5 +1,6 @@ """Event apis.""" +import base64 import datetime import logging import os @@ -7,16 +8,23 @@ import random import string from functools import reduce from pathlib import Path +from typing import List from urllib.parse import unquote import cv2 +import numpy as np from fastapi import APIRouter, Request from fastapi.params import Depends from fastapi.responses import JSONResponse +from pathvalidate import sanitize_filename from peewee import JOIN, DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict -from frigate.api.auth import require_role +from frigate.api.auth import ( + get_allowed_cameras_for_filter, + require_camera_access, + require_role, +) from frigate.api.defs.query.events_query_parameters import ( DEFAULT_TIME_RANGE, EventsQueryParams, @@ -34,6 +42,7 @@ from frigate.api.defs.request.events_body import ( EventsLPRBody, EventsSubLabelBody, SubmitPlusBody, + TriggerEmbeddingBody, ) from frigate.api.defs.response.event_response import ( EventCreateResponse, @@ -44,11 +53,12 @@ from frigate.api.defs.response.event_response import ( from frigate.api.defs.response.generic_response import GenericResponse from frigate.api.defs.tags import Tags from frigate.comms.event_metadata_updater import EventMetadataTypeEnum -from frigate.const import CLIPS_DIR +from frigate.const import CLIPS_DIR, TRIGGER_DIR from frigate.embeddings import EmbeddingsContext -from frigate.models import Event, ReviewSegment, Timeline +from frigate.models import Event, ReviewSegment, Timeline, Trigger from frigate.track.object_processing import TrackedObject from frigate.util.builtin import get_tz_modifiers +from frigate.util.path import get_event_thumbnail_bytes logger = logging.getLogger(__name__) @@ -56,7 +66,10 @@ router = APIRouter(tags=[Tags.events]) @router.get("/events", response_model=list[EventResponse]) -def events(params: EventsQueryParams = Depends()): +def events( + params: EventsQueryParams = Depends(), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): camera = params.camera cameras = params.cameras @@ -130,8 +143,14 @@ def events(params: EventsQueryParams = Depends()): clauses.append((Event.camera == camera)) if cameras != "all": - camera_list = cameras.split(",") - clauses.append((Event.camera << camera_list)) + requested = set(cameras.split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content=[]) + camera_list = list(filtered) + else: + camera_list = allowed_cameras + clauses.append((Event.camera << camera_list)) if labels != "all": label_list = labels.split(",") @@ -161,43 +180,32 @@ def events(params: EventsQueryParams = Depends()): clauses.append((sub_label_clause)) if recognized_license_plate != "all": - # use matching so joined recognized_license_plates are included - # for example a recognized license plate 'ABC123' would get events - # with recognized license plates 'ABC123' and 'ABC123, XYZ789' - recognized_license_plate_clauses = [] filtered_recognized_license_plates = recognized_license_plate.split(",") + clauses_for_plates = [] + if "None" in filtered_recognized_license_plates: filtered_recognized_license_plates.remove("None") - recognized_license_plate_clauses.append( - (Event.data["recognized_license_plate"].is_null()) + clauses_for_plates.append(Event.data["recognized_license_plate"].is_null()) + + # regex vs exact matching + normal_plates = [] + for plate in filtered_recognized_license_plates: + if plate.startswith("^") or any(ch in plate for ch in ".[]?+*"): + clauses_for_plates.append( + Event.data["recognized_license_plate"].cast("text").regexp(plate) + ) + else: + normal_plates.append(plate) + + # if there are any plain string plates, match them with IN + if normal_plates: + clauses_for_plates.append( + Event.data["recognized_license_plate"].cast("text").in_(normal_plates) ) - for recognized_license_plate in filtered_recognized_license_plates: - # Exact matching plus list inclusion - recognized_license_plate_clauses.append( - ( - Event.data["recognized_license_plate"].cast("text") - == recognized_license_plate - ) - ) - recognized_license_plate_clauses.append( - ( - Event.data["recognized_license_plate"].cast("text") - % f"*{recognized_license_plate},*" - ) - ) - recognized_license_plate_clauses.append( - ( - Event.data["recognized_license_plate"].cast("text") - % f"*, {recognized_license_plate}*" - ) - ) - - recognized_license_plate_clause = reduce( - operator.or_, recognized_license_plate_clauses - ) - clauses.append((recognized_license_plate_clause)) + recognized_license_plate_clause = reduce(operator.or_, clauses_for_plates) + clauses.append(recognized_license_plate_clause) if zones != "all": # use matching so events with multiple zones @@ -327,9 +335,17 @@ def events(params: EventsQueryParams = Depends()): @router.get("/events/explore", response_model=list[EventResponse]) -def events_explore(limit: int = 10): +def events_explore( + limit: int = 10, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): # get distinct labels for all events - distinct_labels = Event.select(Event.label).distinct().order_by(Event.label) + distinct_labels = ( + Event.select(Event.label) + .where(Event.camera << allowed_cameras) + .distinct() + .order_by(Event.label) + ) label_counts = {} @@ -340,14 +356,18 @@ def events_explore(limit: int = 10): # get most recent events for this label label_events = ( Event.select() - .where(Event.label == label) + .where((Event.label == label) & (Event.camera << allowed_cameras)) .order_by(Event.start_time.desc()) .limit(limit) .iterator() ) # count total events for this label - label_counts[label] = Event.select().where(Event.label == label).count() + label_counts[label] = ( + Event.select() + .where((Event.label == label) & (Event.camera << allowed_cameras)) + .count() + ) yield from label_events @@ -400,7 +420,7 @@ def events_explore(limit: int = 10): @router.get("/event_ids", response_model=list[EventResponse]) -def event_ids(ids: str): +async def event_ids(ids: str, request: Request): ids = ids.split(",") if not ids: @@ -409,6 +429,16 @@ def event_ids(ids: str): status_code=400, ) + for event_id in ids: + try: + event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) + except DoesNotExist: + return JSONResponse( + content=({"success": False, "message": f"Event {event_id} not found"}), + status_code=404, + ) + try: events = Event.select().where(Event.id << ids).dicts().iterator() return JSONResponse(list(events)) @@ -419,7 +449,11 @@ def event_ids(ids: str): @router.get("/events/search") -def events_search(request: Request, params: EventsSearchQueryParams = Depends()): +def events_search( + request: Request, + params: EventsSearchQueryParams = Depends(), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): query = params.query search_type = params.search_type include_thumbnails = params.include_thumbnails @@ -492,7 +526,13 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) event_filters = [] if cameras != "all": - event_filters.append((Event.camera << cameras.split(","))) + requested = set(cameras.split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content=[]) + event_filters.append((Event.camera << list(filtered))) + else: + event_filters.append((Event.camera << allowed_cameras)) if labels != "all": event_filters.append((Event.label << labels.split(","))) @@ -511,42 +551,31 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) event_filters.append((reduce(operator.or_, zone_clauses))) if recognized_license_plate != "all": - # use matching so joined recognized_license_plates are included - # for example an recognized_license_plate 'ABC123' would get events - # with recognized_license_plates 'ABC123' and 'ABC123, XYZ789' - recognized_license_plate_clauses = [] filtered_recognized_license_plates = recognized_license_plate.split(",") + clauses_for_plates = [] + if "None" in filtered_recognized_license_plates: filtered_recognized_license_plates.remove("None") - recognized_license_plate_clauses.append( - (Event.data["recognized_license_plate"].is_null()) + clauses_for_plates.append(Event.data["recognized_license_plate"].is_null()) + + # regex vs exact matching + normal_plates = [] + for plate in filtered_recognized_license_plates: + if plate.startswith("^") or any(ch in plate for ch in ".[]?+*"): + clauses_for_plates.append( + Event.data["recognized_license_plate"].cast("text").regexp(plate) + ) + else: + normal_plates.append(plate) + + # if there are any plain string plates, match them with IN + if normal_plates: + clauses_for_plates.append( + Event.data["recognized_license_plate"].cast("text").in_(normal_plates) ) - for recognized_license_plate in filtered_recognized_license_plates: - # Exact matching plus list inclusion - recognized_license_plate_clauses.append( - ( - Event.data["recognized_license_plate"].cast("text") - == recognized_license_plate - ) - ) - recognized_license_plate_clauses.append( - ( - Event.data["recognized_license_plate"].cast("text") - % f"*{recognized_license_plate},*" - ) - ) - recognized_license_plate_clauses.append( - ( - Event.data["recognized_license_plate"].cast("text") - % f"*, {recognized_license_plate}*" - ) - ) - - recognized_license_plate_clause = reduce( - operator.or_, recognized_license_plate_clauses - ) + recognized_license_plate_clause = reduce(operator.or_, clauses_for_plates) event_filters.append((recognized_license_plate_clause)) if after: @@ -756,7 +785,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) @router.get("/events/summary") -def events_summary(params: EventsSummaryQueryParams = Depends()): +def events_summary( + params: EventsSummaryQueryParams = Depends(), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): tz_name = params.timezone hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) has_clip = params.has_clip @@ -788,7 +820,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()): Event.zones, fn.COUNT(Event.id).alias("count"), ) - .where(reduce(operator.and_, clauses)) + .where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras)) .group_by( Event.camera, Event.label, @@ -803,9 +835,11 @@ def events_summary(params: EventsSummaryQueryParams = Depends()): @router.get("/events/{event_id}", response_model=EventResponse) -def event(event_id: str): +async def event(event_id: str, request: Request): try: - return model_to_dict(Event.get(Event.id == event_id)) + event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) + return model_to_dict(event) except DoesNotExist: return JSONResponse(content="Event not found", status_code=404) @@ -834,7 +868,7 @@ def set_retain(event_id: str): @router.post("/events/{event_id}/plus", response_model=EventUploadPlusResponse) -def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None): +async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None): if not request.app.frigate_config.plus_api.is_active(): message = "PLUS_API_KEY environment variable is not set" logger.error(message) @@ -852,6 +886,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None): try: event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) except DoesNotExist: message = f"Event {event_id} not found" logger.error(message) @@ -946,7 +981,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None): @router.put("/events/{event_id}/false_positive", response_model=EventUploadPlusResponse) -def false_positive(request: Request, event_id: str): +async def false_positive(request: Request, event_id: str): if not request.app.frigate_config.plus_api.is_active(): message = "PLUS_API_KEY environment variable is not set" logger.error(message) @@ -962,6 +997,7 @@ def false_positive(request: Request, event_id: str): try: event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) except DoesNotExist: message = f"Event {event_id} not found" logger.error(message) @@ -985,7 +1021,7 @@ def false_positive(request: Request, event_id: str): ) if not event.plus_id: - plus_response = send_to_plus(request, event_id) + plus_response = await send_to_plus(request, event_id) if plus_response.status_code != 200: return plus_response # need to refetch the event now that it has a plus_id @@ -1039,9 +1075,10 @@ def false_positive(request: Request, event_id: str): response_model=GenericResponse, dependencies=[Depends(require_role(["admin"]))], ) -def delete_retain(event_id: str): +async def delete_retain(event_id: str, request: Request): try: event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) except DoesNotExist: return JSONResponse( content=({"success": False, "message": "Event " + event_id + " not found"}), @@ -1062,13 +1099,14 @@ def delete_retain(event_id: str): response_model=GenericResponse, dependencies=[Depends(require_role(["admin"]))], ) -def set_sub_label( +async def set_sub_label( request: Request, event_id: str, body: EventsSubLabelBody, ): try: event: Event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) except DoesNotExist: event = None @@ -1099,7 +1137,7 @@ def set_sub_label( new_score = None request.app.event_metadata_updater.publish( - EventMetadataTypeEnum.sub_label, (event_id, new_sub_label, new_score) + (event_id, new_sub_label, new_score), EventMetadataTypeEnum.sub_label.value ) return JSONResponse( @@ -1116,13 +1154,14 @@ def set_sub_label( response_model=GenericResponse, dependencies=[Depends(require_role(["admin"]))], ) -def set_plate( +async def set_plate( request: Request, event_id: str, body: EventsLPRBody, ): try: event: Event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) except DoesNotExist: event = None @@ -1153,7 +1192,8 @@ def set_plate( new_score = None request.app.event_metadata_updater.publish( - EventMetadataTypeEnum.recognized_license_plate, (event_id, new_plate, new_score) + (event_id, "recognized_license_plate", new_plate, new_score), + EventMetadataTypeEnum.attribute.value, ) return JSONResponse( @@ -1170,13 +1210,14 @@ def set_plate( response_model=GenericResponse, dependencies=[Depends(require_role(["admin"]))], ) -def set_description( +async def set_description( request: Request, event_id: str, body: EventsDescriptionBody, ): try: event: Event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) except DoesNotExist: return JSONResponse( content=({"success": False, "message": "Event " + event_id + " not found"}), @@ -1221,11 +1262,12 @@ def set_description( response_model=GenericResponse, dependencies=[Depends(require_role(["admin"]))], ) -def regenerate_description( +async def regenerate_description( request: Request, event_id: str, params: RegenerateQueryParameters = Depends() ): try: event: Event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) except DoesNotExist: return JSONResponse( content=({"success": False, "message": "Event " + event_id + " not found"}), @@ -1234,9 +1276,10 @@ def regenerate_description( camera_config = request.app.frigate_config.cameras[event.camera] - if camera_config.genai.enabled: + if camera_config.objects.genai.enabled or params.force: request.app.event_metadata_updater.publish( - EventMetadataTypeEnum.regenerate_description, (event.id, params.source) + (event.id, params.source, params.force), + EventMetadataTypeEnum.regenerate_description.value, ) return JSONResponse( @@ -1263,9 +1306,42 @@ def regenerate_description( ) -def delete_single_event(event_id: str, request: Request) -> dict: +@router.post( + "/description/generate", + response_model=GenericResponse, + # dependencies=[Depends(require_role(["admin"]))], +) +def generate_description_embedding( + request: Request, + body: EventsDescriptionBody, +): + new_description = body.description + + # If semantic search is enabled, update the index + if request.app.frigate_config.semantic_search.enabled: + context: EmbeddingsContext = request.app.embeddings + if len(new_description) > 0: + result = context.generate_description_embedding( + new_description, + ) + + return JSONResponse( + content=( + { + "success": True, + "message": f"Embedding for description is {result}" + if result + else "Failed to generate embedding", + } + ), + status_code=200, + ) + + +async def delete_single_event(event_id: str, request: Request) -> dict: try: event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) except DoesNotExist: return {"success": False, "message": f"Event {event_id} not found"} @@ -1295,8 +1371,8 @@ def delete_single_event(event_id: str, request: Request) -> dict: response_model=GenericResponse, dependencies=[Depends(require_role(["admin"]))], ) -def delete_event(request: Request, event_id: str): - result = delete_single_event(event_id, request) +async def delete_event(request: Request, event_id: str): + result = await delete_single_event(event_id, request) status_code = 200 if result["success"] else 404 return JSONResponse(content=result, status_code=status_code) @@ -1306,7 +1382,7 @@ def delete_event(request: Request, event_id: str): response_model=EventMultiDeleteResponse, dependencies=[Depends(require_role(["admin"]))], ) -def delete_events(request: Request, body: EventsDeleteBody): +async def delete_events(request: Request, body: EventsDeleteBody): if not body.event_ids: return JSONResponse( content=({"success": False, "message": "No event IDs provided."}), @@ -1317,7 +1393,7 @@ def delete_events(request: Request, body: EventsDeleteBody): not_found_events = [] for event_id in body.event_ids: - result = delete_single_event(event_id, request) + result = await delete_single_event(event_id, request) if result["success"]: deleted_events.append(event_id) else: @@ -1361,7 +1437,6 @@ def create_event( event_id = f"{now}-{rand_id}" request.app.event_metadata_updater.publish( - EventMetadataTypeEnum.manual_event_create, ( now, camera_name, @@ -1374,6 +1449,7 @@ def create_event( body.source_type, body.draw, ), + EventMetadataTypeEnum.manual_event_create.value, ) return JSONResponse( @@ -1393,11 +1469,13 @@ def create_event( response_model=GenericResponse, dependencies=[Depends(require_role(["admin"]))], ) -def end_event(request: Request, event_id: str, body: EventsEndBody): +async def end_event(request: Request, event_id: str, body: EventsEndBody): try: + event: Event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) end_time = body.end_time or datetime.datetime.now().timestamp() request.app.event_metadata_updater.publish( - EventMetadataTypeEnum.manual_event_end, (event_id, end_time) + (event_id, end_time), EventMetadataTypeEnum.manual_event_end.value ) except Exception: return JSONResponse( @@ -1411,3 +1489,430 @@ def end_event(request: Request, event_id: str, body: EventsEndBody): content=({"success": True, "message": "Event successfully ended."}), status_code=200, ) + + +@router.post( + "/trigger/embedding", + response_model=dict, + dependencies=[Depends(require_role(["admin"]))], +) +def create_trigger_embedding( + request: Request, + body: TriggerEmbeddingBody, + camera_name: str, + name: str, +): + try: + if not request.app.frigate_config.semantic_search.enabled: + return JSONResponse( + content={ + "success": False, + "message": "Semantic search is not enabled", + }, + status_code=400, + ) + + # Check if trigger already exists + if ( + Trigger.select() + .where(Trigger.camera == camera_name, Trigger.name == name) + .exists() + ): + return JSONResponse( + content={ + "success": False, + "message": f"Trigger {camera_name}:{name} already exists", + }, + status_code=400, + ) + + context: EmbeddingsContext = request.app.embeddings + # Generate embedding based on type + embedding = None + if body.type == "description": + embedding = context.generate_description_embedding(body.data) + elif body.type == "thumbnail": + try: + event: Event = Event.get(Event.id == body.data) + except DoesNotExist: + # TODO: check triggers directory for image + return JSONResponse( + content={ + "success": False, + "message": f"Failed to fetch event for {body.type} trigger", + }, + status_code=400, + ) + + # Skip the event if not an object + if event.data.get("type") != "object": + return + + if thumbnail := get_event_thumbnail_bytes(event): + cursor = context.db.execute_sql( + """ + SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ? + """, + [body.data], + ) + + row = cursor.fetchone() if cursor else None + + if row: + query_embedding = row[0] + embedding = np.frombuffer(query_embedding, dtype=np.float32) + else: + # Extract valid thumbnail + thumbnail = get_event_thumbnail_bytes(event) + + if thumbnail is None: + return JSONResponse( + content={ + "success": False, + "message": f"Failed to get thumbnail for {body.data} for {body.type} trigger", + }, + status_code=400, + ) + + embedding = context.generate_image_embedding( + body.data, (base64.b64encode(thumbnail).decode("ASCII")) + ) + + if embedding is None: + return JSONResponse( + content={ + "success": False, + "message": f"Failed to generate embedding for {body.type} trigger", + }, + status_code=400, + ) + + if body.type == "thumbnail": + # Save image to the triggers directory + try: + os.makedirs( + os.path.join(TRIGGER_DIR, sanitize_filename(camera_name)), + exist_ok=True, + ) + with open( + os.path.join( + TRIGGER_DIR, + sanitize_filename(camera_name), + f"{sanitize_filename(body.data)}.webp", + ), + "wb", + ) as f: + f.write(thumbnail) + logger.debug( + f"Writing thumbnail for trigger with data {body.data} in {camera_name}." + ) + except Exception as e: + logger.error(e.with_traceback()) + logger.error( + f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}" + ) + + Trigger.create( + camera=camera_name, + name=name, + type=body.type, + data=body.data, + threshold=body.threshold, + model=request.app.frigate_config.semantic_search.model, + embedding=np.array(embedding, dtype=np.float32).tobytes(), + triggering_event_id="", + last_triggered=None, + ) + + return JSONResponse( + content={ + "success": True, + "message": f"Trigger created successfully for {camera_name}:{name}", + }, + status_code=200, + ) + + except Exception as e: + logger.error(e.with_traceback()) + return JSONResponse( + content={ + "success": False, + "message": "Error creating trigger embedding", + }, + status_code=500, + ) + + +@router.put( + "/trigger/embedding/{camera_name}/{name}", + response_model=dict, + dependencies=[Depends(require_role(["admin"]))], +) +def update_trigger_embedding( + request: Request, + camera_name: str, + name: str, + body: TriggerEmbeddingBody, +): + try: + if not request.app.frigate_config.semantic_search.enabled: + return JSONResponse( + content={ + "success": False, + "message": "Semantic search is not enabled", + }, + status_code=400, + ) + + context: EmbeddingsContext = request.app.embeddings + # Generate embedding based on type + embedding = None + if body.type == "description": + embedding = context.generate_description_embedding(body.data) + elif body.type == "thumbnail": + webp_file = sanitize_filename(body.data) + ".webp" + webp_path = os.path.join( + TRIGGER_DIR, sanitize_filename(camera_name), webp_file + ) + + try: + event: Event = Event.get(Event.id == body.data) + # Skip the event if not an object + if event.data.get("type") != "object": + return JSONResponse( + content={ + "success": False, + "message": f"Event {body.data} is not a tracked object for {body.type} trigger", + }, + status_code=400, + ) + # Extract valid thumbnail + thumbnail = get_event_thumbnail_bytes(event) + + with open(webp_path, "wb") as f: + f.write(thumbnail) + except DoesNotExist: + # check triggers directory for image + if not os.path.exists(webp_path): + return JSONResponse( + content={ + "success": False, + "message": f"Failed to fetch event for {body.type} trigger", + }, + status_code=400, + ) + else: + # Load the image from the triggers directory + with open(webp_path, "rb") as f: + thumbnail = f.read() + + embedding = context.generate_image_embedding( + body.data, (base64.b64encode(thumbnail).decode("ASCII")) + ) + + if embedding is None: + return JSONResponse( + content={ + "success": False, + "message": f"Failed to generate embedding for {body.type} trigger", + }, + status_code=400, + ) + + # Check if trigger exists for upsert + trigger = Trigger.get_or_none( + Trigger.camera == camera_name, Trigger.name == name + ) + + if trigger: + # Update existing trigger + if trigger.data != body.data: # Delete old thumbnail only if data changes + try: + os.remove( + os.path.join( + TRIGGER_DIR, + sanitize_filename(camera_name), + f"{trigger.data}.webp", + ) + ) + logger.debug( + f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}." + ) + except Exception as e: + logger.error(e.with_traceback()) + logger.error( + f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}" + ) + + Trigger.update( + data=body.data, + model=request.app.frigate_config.semantic_search.model, + embedding=np.array(embedding, dtype=np.float32).tobytes(), + threshold=body.threshold, + triggering_event_id="", + last_triggered=None, + ).where(Trigger.camera == camera_name, Trigger.name == name).execute() + else: + # Create new trigger (for rename case) + Trigger.create( + camera=camera_name, + name=name, + type=body.type, + data=body.data, + threshold=body.threshold, + model=request.app.frigate_config.semantic_search.model, + embedding=np.array(embedding, dtype=np.float32).tobytes(), + triggering_event_id="", + last_triggered=None, + ) + + if body.type == "thumbnail": + # Save image to the triggers directory + try: + camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera_name)) + os.makedirs(camera_path, exist_ok=True) + with open( + os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"), + "wb", + ) as f: + f.write(thumbnail) + logger.debug( + f"Writing thumbnail for trigger with data {body.data} in {camera_name}." + ) + except Exception as e: + logger.error(e.with_traceback()) + logger.error( + f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}" + ) + + return JSONResponse( + content={ + "success": True, + "message": f"Trigger updated successfully for {camera_name}:{name}", + }, + status_code=200, + ) + + except Exception as e: + logger.error(e.with_traceback()) + return JSONResponse( + content={ + "success": False, + "message": "Error updating trigger embedding", + }, + status_code=500, + ) + + +@router.delete( + "/trigger/embedding/{camera_name}/{name}", + response_model=dict, + dependencies=[Depends(require_role(["admin"]))], +) +def delete_trigger_embedding( + request: Request, + camera_name: str, + name: str, +): + try: + trigger = Trigger.get_or_none( + Trigger.camera == camera_name, Trigger.name == name + ) + if trigger is None: + return JSONResponse( + content={ + "success": False, + "message": f"Trigger {camera_name}:{name} not found", + }, + status_code=500, + ) + + deleted = ( + Trigger.delete() + .where(Trigger.camera == camera_name, Trigger.name == name) + .execute() + ) + if deleted == 0: + return JSONResponse( + content={ + "success": False, + "message": f"Error deleting trigger {camera_name}:{name}", + }, + status_code=401, + ) + + try: + os.remove( + os.path.join( + TRIGGER_DIR, sanitize_filename(camera_name), f"{trigger.data}.webp" + ) + ) + logger.debug( + f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}." + ) + except Exception as e: + logger.error(e.with_traceback()) + logger.error( + f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}" + ) + + return JSONResponse( + content={ + "success": True, + "message": f"Trigger deleted successfully for {camera_name}:{name}", + }, + status_code=200, + ) + + except Exception as e: + logger.error(e.with_traceback()) + return JSONResponse( + content={ + "success": False, + "message": "Error deleting trigger embedding", + }, + status_code=500, + ) + + +@router.get( + "/triggers/status/{camera_name}", + response_model=dict, + dependencies=[Depends(require_role(["admin"]))], +) +def get_triggers_status( + camera_name: str, +): + try: + # Fetch all triggers for the specified camera + triggers = Trigger.select().where(Trigger.camera == camera_name) + + # Prepare the response with trigger status + status = { + trigger.name: { + "last_triggered": trigger.last_triggered.timestamp() + if trigger.last_triggered + else None, + "triggering_event_id": trigger.triggering_event_id + if trigger.triggering_event_id + else None, + } + for trigger in triggers + } + + if not status: + return JSONResponse( + content={ + "success": False, + "message": f"No triggers found for camera {camera_name}", + }, + status_code=404, + ) + + return {"success": True, "triggers": status} + except Exception as ex: + logger.exception(ex) + return JSONResponse( + content=({"success": False, "message": "Error fetching trigger status"}), + status_code=400, + ) diff --git a/frigate/api/export.py b/frigate/api/export.py index 160434c687..08fc6b1c54 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -4,6 +4,7 @@ import logging import random import string from pathlib import Path +from typing import List import psutil from fastapi import APIRouter, Depends, Request @@ -11,7 +12,11 @@ from fastapi.responses import JSONResponse from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict -from frigate.api.auth import require_role +from frigate.api.auth import ( + get_allowed_cameras_for_filter, + require_camera_access, + require_role, +) from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.tags import Tags @@ -30,12 +35,23 @@ router = APIRouter(tags=[Tags.export]) @router.get("/exports") -def get_exports(): - exports = Export.select().order_by(Export.date.desc()).dicts().iterator() +def get_exports( + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): + exports = ( + Export.select() + .where(Export.camera << allowed_cameras) + .order_by(Export.date.desc()) + .dicts() + .iterator() + ) return JSONResponse(content=[e for e in exports]) -@router.post("/export/{camera_name}/start/{start_time}/end/{end_time}") +@router.post( + "/export/{camera_name}/start/{start_time}/end/{end_time}", + dependencies=[Depends(require_camera_access)], +) def export_recording( request: Request, camera_name: str, @@ -134,9 +150,10 @@ def export_recording( @router.patch( "/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))] ) -def export_rename(event_id: str, body: ExportRenameBody): +async def export_rename(event_id: str, body: ExportRenameBody, request: Request): try: export: Export = Export.get(Export.id == event_id) + await require_camera_access(export.camera, request=request) except DoesNotExist: return JSONResponse( content=( @@ -162,9 +179,10 @@ def export_rename(event_id: str, body: ExportRenameBody): @router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))]) -def export_delete(event_id: str): +async def export_delete(event_id: str, request: Request): try: export: Export = Export.get(Export.id == event_id) + await require_camera_access(export.camera, request=request) except DoesNotExist: return JSONResponse( content=( @@ -215,9 +233,11 @@ def export_delete(event_id: str): @router.get("/exports/{export_id}") -def get_export(export_id: str): +async def get_export(export_id: str, request: Request): try: - return JSONResponse(content=model_to_dict(Export.get(Export.id == export_id))) + export = Export.get(Export.id == export_id) + await require_camera_access(export.camera, request=request) + return JSONResponse(content=model_to_dict(export)) except DoesNotExist: return JSONResponse( content={"success": False, "message": "Export not found"}, diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 0657752dc9..d56f6919d7 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -1,8 +1,10 @@ import logging +import re from typing import Optional from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from joserfc.jwk import OctKey from playhouse.sqliteq import SqliteQueueDatabase from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded @@ -26,6 +28,7 @@ from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, ) from frigate.config import FrigateConfig +from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.embeddings import EmbeddingsContext from frigate.ptz.onvif import OnvifController from frigate.stats.emitter import StatsEmitter @@ -57,6 +60,7 @@ def create_fastapi_app( onvif: OnvifController, stats_emitter: StatsEmitter, event_metadata_updater: EventMetadataPublisher, + config_publisher: CameraConfigUpdatePublisher, ): logger.info("Starting FastAPI app") app = FastAPI( @@ -127,6 +131,27 @@ def create_fastapi_app( app.onvif = onvif app.stats_emitter = stats_emitter app.event_metadata_updater = event_metadata_updater - app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None + app.config_publisher = config_publisher + + if frigate_config.auth.enabled: + secret = get_jwt_secret() + key_bytes = None + if isinstance(secret, str): + # If the secret looks like hex (e.g., generated by secrets.token_hex), use raw bytes + if len(secret) % 2 == 0 and re.fullmatch(r"[0-9a-fA-F]+", secret or ""): + try: + key_bytes = bytes.fromhex(secret) + except ValueError: + key_bytes = secret.encode("utf-8") + else: + key_bytes = secret.encode("utf-8") + elif isinstance(secret, (bytes, bytearray)): + key_bytes = bytes(secret) + else: + key_bytes = str(secret).encode("utf-8") + + app.jwt_token = OctKey.import_key(key_bytes) + else: + app.jwt_token = None return app diff --git a/frigate/api/media.py b/frigate/api/media.py index e3de57cd31..19042a8aac 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -8,25 +8,27 @@ import os import subprocess as sp import time from datetime import datetime, timedelta, timezone +from functools import reduce from pathlib import Path as FilePath -from typing import Any +from typing import Any, List from urllib.parse import unquote import cv2 import numpy as np import pytz -from fastapi import APIRouter, Path, Query, Request, Response -from fastapi.params import Depends +from fastapi import APIRouter, Depends, Path, Query, Request, Response from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pathvalidate import sanitize_filename -from peewee import DoesNotExist, fn +from peewee import DoesNotExist, fn, operator from tzlocal import get_localzone_name +from frigate.api.auth import get_allowed_cameras_for_filter, require_camera_access from frigate.api.defs.query.media_query_parameters import ( Extension, MediaEventsSnapshotQueryParams, MediaLatestFrameQueryParams, MediaMjpegFeedQueryParams, + MediaRecordingsAvailabilityQueryParams, MediaRecordingsSummaryQueryParams, ) from frigate.api.defs.tags import Tags @@ -48,12 +50,11 @@ from frigate.util.path import get_event_thumbnail_bytes logger = logging.getLogger(__name__) - router = APIRouter(tags=[Tags.media]) -@router.get("/{camera_name}") -def mjpeg_feed( +@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)]) +async def mjpeg_feed( request: Request, camera_name: str, params: MediaMjpegFeedQueryParams = Depends(), @@ -109,7 +110,7 @@ def imagestream( ) -@router.get("/{camera_name}/ptz/info") +@router.get("/{camera_name}/ptz/info", dependencies=[Depends(require_camera_access)]) async def camera_ptz_info(request: Request, camera_name: str): if camera_name in request.app.frigate_config.cameras: # Schedule get_camera_info in the OnvifController's event loop @@ -125,8 +126,10 @@ async def camera_ptz_info(request: Request, camera_name: str): ) -@router.get("/{camera_name}/latest.{extension}") -def latest_frame( +@router.get( + "/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)] +) +async def latest_frame( request: Request, camera_name: str, extension: Extension, @@ -139,6 +142,7 @@ def latest_frame( "zones": params.zones, "mask": params.mask, "motion_boxes": params.motion, + "paths": params.paths, "regions": params.regions, } quality = params.quality @@ -233,8 +237,11 @@ def latest_frame( ) -@router.get("/{camera_name}/recordings/{frame_time}/snapshot.{format}") -def get_snapshot_from_recording( +@router.get( + "/{camera_name}/recordings/{frame_time}/snapshot.{format}", + dependencies=[Depends(require_camera_access)], +) +async def get_snapshot_from_recording( request: Request, camera_name: str, frame_time: float, @@ -320,8 +327,10 @@ def get_snapshot_from_recording( ) -@router.post("/{camera_name}/plus/{frame_time}") -def submit_recording_snapshot_to_plus( +@router.post( + "/{camera_name}/plus/{frame_time}", dependencies=[Depends(require_camera_access)] +) +async def submit_recording_snapshot_to_plus( request: Request, camera_name: str, frame_time: str ): if camera_name not in request.app.frigate_config.cameras: @@ -409,11 +418,23 @@ def get_recordings_storage_usage(request: Request): @router.get("/recordings/summary") -def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()): +def all_recordings_summary( + request: Request, + params: MediaRecordingsSummaryQueryParams = Depends(), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): """Returns true/false by day indicating if recordings exist""" hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) cameras = params.cameras + if cameras != "all": + requested = set(unquote(cameras).split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content={}) + cameras = ",".join(filtered) + else: + cameras = allowed_cameras query = ( Recordings.select( @@ -441,7 +462,7 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends() .order_by(Recordings.start_time.desc()) ) - if cameras != "all": + if params.cameras != "all": query = query.where(Recordings.camera << cameras.split(",")) recording_days = query.namedtuples() @@ -450,8 +471,10 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends() return JSONResponse(content=days) -@router.get("/{camera_name}/recordings/summary") -def recordings_summary(camera_name: str, timezone: str = "utc"): +@router.get( + "/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)] +) +async def recordings_summary(camera_name: str, timezone: str = "utc"): """Returns hourly summary for recordings of given camera""" hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone) recording_groups = ( @@ -512,8 +535,8 @@ def recordings_summary(camera_name: str, timezone: str = "utc"): return JSONResponse(content=list(days.values())) -@router.get("/{camera_name}/recordings") -def recordings( +@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)]) +async def recordings( camera_name: str, after: float = (datetime.now() - timedelta(hours=1)).timestamp(), before: float = datetime.now().timestamp(), @@ -542,11 +565,87 @@ def recordings( return JSONResponse(content=list(recordings)) +@router.get("/recordings/unavailable", response_model=list[dict]) +async def no_recordings( + request: Request, + params: MediaRecordingsAvailabilityQueryParams = Depends(), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): + """Get time ranges with no recordings.""" + cameras = params.cameras + if cameras != "all": + requested = set(unquote(cameras).split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content=[]) + cameras = ",".join(filtered) + else: + cameras = allowed_cameras + + before = params.before or datetime.datetime.now().timestamp() + after = ( + params.after + or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp() + ) + scale = params.scale + + clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] + if cameras != "all": + camera_list = cameras.split(",") + clauses.append((Recordings.camera << camera_list)) + else: + camera_list = allowed_cameras + + # Get recording start times + data: list[Recordings] = ( + Recordings.select(Recordings.start_time, Recordings.end_time) + .where(reduce(operator.and_, clauses)) + .order_by(Recordings.start_time.asc()) + .dicts() + .iterator() + ) + + # Convert recordings to list of (start, end) tuples + recordings = [(r["start_time"], r["end_time"]) for r in data] + + # Generate all time segments + current = after + no_recording_segments = [] + current_start = None + + while current < before: + segment_end = current + scale + # Check if segment overlaps with any recording + has_recording = any( + start <= segment_end and end >= current for start, end in recordings + ) + if not has_recording: + if current_start is None: + current_start = current # Start a new gap + else: + if current_start is not None: + # End the current gap and append it + no_recording_segments.append( + {"start_time": int(current_start), "end_time": int(current)} + ) + current_start = None + current = segment_end + + # Append the last gap if it exists + if current_start is not None: + no_recording_segments.append( + {"start_time": int(current_start), "end_time": int(before)} + ) + + return JSONResponse(content=no_recording_segments) + + @router.get( "/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4", + dependencies=[Depends(require_camera_access)], description="For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.", ) -def recording_clip( +async def recording_clip( request: Request, camera_name: str, start_ts: float, @@ -642,9 +741,10 @@ def recording_clip( @router.get( "/vod/{camera_name}/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) -def vod_ts(camera_name: str, start_ts: float, end_ts: float): +async def vod_ts(camera_name: str, start_ts: float, end_ts: float): recordings = ( Recordings.select( Recordings.path, @@ -719,6 +819,7 @@ def vod_ts(camera_name: str, start_ts: float, end_ts: float): @router.get( "/vod/{year_month}/{day}/{hour}/{camera_name}", + dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str): @@ -730,6 +831,7 @@ def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str) @router.get( "/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}", + dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str): @@ -749,7 +851,8 @@ def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: st "/vod/event/{event_id}", description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) -def vod_event( +async def vod_event( + request: Request, event_id: str, padding: int = Query(0, description="Padding to apply to the vod."), ): @@ -765,15 +868,7 @@ def vod_event( status_code=404, ) - if not event.has_clip: - logger.error(f"Event does not have recordings: {event_id}") - return JSONResponse( - content={ - "success": False, - "message": "Recordings not available.", - }, - status_code=404, - ) + await require_camera_access(event.camera, request=request) end_ts = ( datetime.now().timestamp() @@ -798,7 +893,7 @@ def vod_event( "/events/{event_id}/snapshot.jpg", description="Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used.", ) -def event_snapshot( +async def event_snapshot( request: Request, event_id: str, params: MediaEventsSnapshotQueryParams = Depends(), @@ -808,6 +903,7 @@ def event_snapshot( try: event = Event.get(Event.id == event_id, Event.end_time != None) event_complete = True + await require_camera_access(event.camera, request=request) if not event.has_snapshot: return JSONResponse( content={"success": False, "message": "Snapshot not available"}, @@ -836,6 +932,7 @@ def event_snapshot( height=params.height, quality=params.quality, ) + await require_camera_access(camera_state.name, request=request) except Exception: return JSONResponse( content={"success": False, "message": "Ongoing event not found"}, @@ -869,7 +966,7 @@ def event_snapshot( @router.get("/events/{event_id}/thumbnail.{extension}") -def event_thumbnail( +async def event_thumbnail( request: Request, event_id: str, extension: Extension, @@ -882,6 +979,7 @@ def event_thumbnail( event_complete = False try: event: Event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) if event.end_time is not None: event_complete = True @@ -944,7 +1042,7 @@ def event_thumbnail( ) -@router.get("/{camera_name}/grid.jpg") +@router.get("/{camera_name}/grid.jpg", dependencies=[Depends(require_camera_access)]) def grid_snapshot( request: Request, camera_name: str, color: str = "green", font_scale: float = 0.5 ): @@ -1191,7 +1289,10 @@ def event_preview(request: Request, event_id: str): return preview_gif(request, event.camera, start_ts, end_ts) -@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif") +@router.get( + "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif", + dependencies=[Depends(require_camera_access)], +) def preview_gif( request: Request, camera_name: str, @@ -1347,7 +1448,10 @@ def preview_gif( ) -@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4") +@router.get( + "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4", + dependencies=[Depends(require_camera_access)], +) def preview_mp4( request: Request, camera_name: str, @@ -1587,8 +1691,13 @@ def preview_thumbnail(file_name: str): ####################### dynamic routes ########################### -@router.get("/{camera_name}/{label}/best.jpg") -@router.get("/{camera_name}/{label}/thumbnail.jpg") +@router.get( + "/{camera_name}/{label}/best.jpg", dependencies=[Depends(require_camera_access)] +) +@router.get( + "/{camera_name}/{label}/thumbnail.jpg", + dependencies=[Depends(require_camera_access)], +) def label_thumbnail(request: Request, camera_name: str, label: str): label = unquote(label) event_query = Event.select(fn.MAX(Event.id)).where(Event.camera == camera_name) @@ -1610,7 +1719,9 @@ def label_thumbnail(request: Request, camera_name: str, label: str): ) -@router.get("/{camera_name}/{label}/clip.mp4") +@router.get( + "/{camera_name}/{label}/clip.mp4", dependencies=[Depends(require_camera_access)] +) def label_clip(request: Request, camera_name: str, label: str): label = unquote(label) event_query = Event.select(fn.MAX(Event.id)).where( @@ -1629,7 +1740,9 @@ def label_clip(request: Request, camera_name: str, label: str): ) -@router.get("/{camera_name}/{label}/snapshot.jpg") +@router.get( + "/{camera_name}/{label}/snapshot.jpg", dependencies=[Depends(require_camera_access)] +) def label_snapshot(request: Request, camera_name: str, label: str): """Returns the snapshot image from the latest event for the given camera and label combo""" label = unquote(label) diff --git a/frigate/api/preview.py b/frigate/api/preview.py index 2db2326aba..531c1e09e5 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -5,9 +5,10 @@ import os from datetime import datetime, timedelta, timezone import pytz -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse +from frigate.api.auth import require_camera_access from frigate.api.defs.tags import Tags from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.models import Previews @@ -18,7 +19,10 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.preview]) -@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}") +@router.get( + "/preview/{camera_name}/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(require_camera_access)], +) def preview_ts(camera_name: str, start_ts: float, end_ts: float): """Get all mp4 previews relevant for time period.""" if camera_name != "all": @@ -71,7 +75,10 @@ def preview_ts(camera_name: str, start_ts: float, end_ts: float): return JSONResponse(content=clips, status_code=200) -@router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}") +@router.get( + "/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}", + dependencies=[Depends(require_camera_access)], +) def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str): """Get all mp4 previews relevant for time period given the timezone""" parts = year_month.split("-") @@ -86,7 +93,10 @@ def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name return preview_ts(camera_name, start_ts, end_ts) -@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames") +@router.get( + "/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames", + dependencies=[Depends(require_camera_access)], +) def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float): """Get list of cached preview frames""" preview_dir = os.path.join(CACHE_DIR, "preview_frames") diff --git a/frigate/api/review.py b/frigate/api/review.py index e6d010db72..bddc1c0b76 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -4,15 +4,21 @@ import datetime import logging from functools import reduce from pathlib import Path +from typing import List import pandas as pd -from fastapi import APIRouter +from fastapi import APIRouter, Request from fastapi.params import Depends from fastapi.responses import JSONResponse from peewee import Case, DoesNotExist, IntegrityError, fn, operator from playhouse.shortcuts import model_to_dict -from frigate.api.auth import get_current_user, require_role +from frigate.api.auth import ( + get_allowed_cameras_for_filter, + get_current_user, + require_camera_access, + require_role, +) from frigate.api.defs.query.review_query_parameters import ( ReviewActivityMotionQueryParams, ReviewQueryParams, @@ -26,6 +32,8 @@ from frigate.api.defs.response.review_response import ( ReviewSummaryResponse, ) from frigate.api.defs.tags import Tags +from frigate.config import FrigateConfig +from frigate.embeddings import EmbeddingsContext from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.review.types import SeverityEnum from frigate.util.builtin import get_tz_modifiers @@ -39,6 +47,7 @@ router = APIRouter(tags=[Tags.review]) async def review( params: ReviewQueryParams = Depends(), current_user: dict = Depends(get_current_user), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), ): if isinstance(current_user, JSONResponse): return current_user @@ -63,8 +72,14 @@ async def review( ] if cameras != "all": - camera_list = cameras.split(",") - clauses.append((ReviewSegment.camera << camera_list)) + requested = set(cameras.split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content=[]) + camera_list = list(filtered) + else: + camera_list = allowed_cameras + clauses.append((ReviewSegment.camera << camera_list)) if labels != "all": # use matching so segments with multiple labels @@ -138,7 +153,7 @@ async def review( @router.get("/review_ids", response_model=list[ReviewSegmentResponse]) -def review_ids(ids: str): +async def review_ids(request: Request, ids: str): ids = ids.split(",") if not ids: @@ -147,6 +162,18 @@ def review_ids(ids: str): status_code=400, ) + for review_id in ids: + try: + review = ReviewSegment.get(ReviewSegment.id == review_id) + await require_camera_access(review.camera, request=request) + except DoesNotExist: + return JSONResponse( + content=( + {"success": False, "message": f"Review {review_id} not found"} + ), + status_code=404, + ) + try: reviews = ( ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator() @@ -163,6 +190,7 @@ def review_ids(ids: str): async def review_summary( params: ReviewSummaryQueryParams = Depends(), current_user: dict = Depends(get_current_user), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), ): if isinstance(current_user, JSONResponse): return current_user @@ -179,8 +207,14 @@ async def review_summary( clauses = [(ReviewSegment.start_time > day_ago)] if cameras != "all": - camera_list = cameras.split(",") - clauses.append((ReviewSegment.camera << camera_list)) + requested = set(cameras.split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content={}) + camera_list = list(filtered) + else: + camera_list = allowed_cameras + clauses.append((ReviewSegment.camera << camera_list)) if labels != "all": # use matching so segments with multiple labels @@ -274,8 +308,14 @@ async def review_summary( clauses = [] if cameras != "all": - camera_list = cameras.split(",") - clauses.append((ReviewSegment.camera << camera_list)) + requested = set(cameras.split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content={}) + camera_list = list(filtered) + else: + camera_list = allowed_cameras + clauses.append((ReviewSegment.camera << camera_list)) if labels != "all": # use matching so segments with multiple labels @@ -378,6 +418,7 @@ async def review_summary( @router.post("/reviews/viewed", response_model=GenericResponse) async def set_multiple_reviewed( + request: Request, body: ReviewModifyMultipleBody, current_user: dict = Depends(get_current_user), ): @@ -388,6 +429,8 @@ async def set_multiple_reviewed( for review_id in body.ids: try: + review = ReviewSegment.get(ReviewSegment.id == review_id) + await require_camera_access(review.camera, request=request) review_status = UserReviewStatus.get( UserReviewStatus.user_id == user_id, UserReviewStatus.review_segment == review_id, @@ -469,7 +512,10 @@ def delete_reviews(body: ReviewModifyMultipleBody): @router.get( "/review/activity/motion", response_model=list[ReviewActivityMotionResponse] ) -def motion_activity(params: ReviewActivityMotionQueryParams = Depends()): +def motion_activity( + params: ReviewActivityMotionQueryParams = Depends(), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): """Get motion and audio activity.""" cameras = params.cameras before = params.before or datetime.datetime.now().timestamp() @@ -484,8 +530,14 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()): clauses.append((Recordings.motion > 0)) if cameras != "all": - camera_list = cameras.split(",") + requested = set(cameras.split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content=[]) + camera_list = list(filtered) clauses.append((Recordings.camera << camera_list)) + else: + clauses.append((Recordings.camera << allowed_cameras)) data: list[Recordings] = ( Recordings.select( @@ -543,15 +595,13 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()): @router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse) -def get_review_from_event(event_id: str): +async def get_review_from_event(request: Request, event_id: str): try: - return JSONResponse( - model_to_dict( - ReviewSegment.get( - ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*' - ) - ) + review = ReviewSegment.get( + ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*' ) + await require_camera_access(review.camera, request=request) + return JSONResponse(model_to_dict(review)) except DoesNotExist: return JSONResponse( content={"success": False, "message": "Review item not found"}, @@ -560,11 +610,11 @@ def get_review_from_event(event_id: str): @router.get("/review/{review_id}", response_model=ReviewSegmentResponse) -def get_review(review_id: str): +async def get_review(request: Request, review_id: str): try: - return JSONResponse( - content=model_to_dict(ReviewSegment.get(ReviewSegment.id == review_id)) - ) + review = ReviewSegment.get(ReviewSegment.id == review_id) + await require_camera_access(review.camera, request=request) + return JSONResponse(content=model_to_dict(review)) except DoesNotExist: return JSONResponse( content={"success": False, "message": "Review item not found"}, @@ -606,3 +656,35 @@ async def set_not_reviewed( content=({"success": True, "message": f"Set Review {review_id} as not viewed"}), status_code=200, ) + + +@router.post( + "/review/summarize/start/{start_ts}/end/{end_ts}", + description="Use GenAI to summarize review items over a period of time.", +) +def generate_review_summary(request: Request, start_ts: float, end_ts: float): + config: FrigateConfig = request.app.frigate_config + + if not config.genai.provider: + return JSONResponse( + content=( + { + "success": False, + "message": "GenAI must be configured to use this feature.", + } + ), + status_code=400, + ) + + context: EmbeddingsContext = request.app.embeddings + summary = context.generate_review_summary(start_ts, end_ts) + + if summary: + return JSONResponse( + content=({"success": True, "summary": summary}), status_code=200 + ) + else: + return JSONResponse( + content=({"success": False, "message": "Failed to create summary."}), + status_code=500, + ) diff --git a/frigate/app.py b/frigate/app.py index abcefdc568..8582478669 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -5,6 +5,7 @@ import os import secrets import shutil from multiprocessing import Queue +from multiprocessing.managers import DictProxy, SyncManager from multiprocessing.synchronize import Event as MpEvent from pathlib import Path from typing import Optional @@ -14,19 +15,20 @@ import uvicorn from peewee_migrate import Router from playhouse.sqlite_ext import SqliteExtDatabase -import frigate.util as util from frigate.api.auth import hash_password from frigate.api.fastapi_app import create_fastapi_app from frigate.camera import CameraMetrics, PTZMetrics +from frigate.camera.maintainer import CameraMaintainer from frigate.comms.base_communicator import Communicator -from frigate.comms.config_updater import ConfigPublisher from frigate.comms.dispatcher import Dispatcher from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.comms.inter_process import InterProcessCommunicator from frigate.comms.mqtt import MqttClient +from frigate.comms.object_detector_signaler import DetectorProxy from frigate.comms.webpush import WebPushClient from frigate.comms.ws import WebSocketClient from frigate.comms.zmq_proxy import ZmqProxy +from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.config import FrigateConfig from frigate.const import ( CACHE_DIR, @@ -36,12 +38,12 @@ from frigate.const import ( FACE_DIR, MODEL_CACHE_DIR, RECORD_DIR, - SHM_FRAMES_VAR, THUMB_DIR, + TRIGGER_DIR, ) from frigate.data_processing.types import DataProcessorMetrics from frigate.db.sqlitevecq import SqliteVecQueueDatabase -from frigate.embeddings import EmbeddingsContext, manage_embeddings +from frigate.embeddings import EmbeddingProcess, EmbeddingsContext from frigate.events.audio import AudioProcessor from frigate.events.cleanup import EventCleanup from frigate.events.maintainer import EventProcessor @@ -55,56 +57,58 @@ from frigate.models import ( Regions, ReviewSegment, Timeline, + Trigger, User, ) from frigate.object_detection.base import ObjectDetectProcess -from frigate.output.output import output_frames +from frigate.output.output import OutputProcess from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.ptz.onvif import OnvifController from frigate.record.cleanup import RecordingCleanup from frigate.record.export import migrate_exports -from frigate.record.record import manage_recordings -from frigate.review.review import manage_review_segments +from frigate.record.record import RecordProcess +from frigate.review.review import ReviewProcess from frigate.stats.emitter import StatsEmitter from frigate.stats.util import stats_init from frigate.storage import StorageMaintainer from frigate.timeline import TimelineProcessor from frigate.track.object_processing import TrackedObjectProcessor from frigate.util.builtin import empty_and_close_queue -from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory -from frigate.util.object import get_camera_regions_grid +from frigate.util.image import UntrackedSharedMemory from frigate.util.services import set_file_limit from frigate.version import VERSION -from frigate.video import capture_camera, track_camera from frigate.watchdog import FrigateWatchdog logger = logging.getLogger(__name__) class FrigateApp: - def __init__(self, config: FrigateConfig) -> None: + def __init__( + self, config: FrigateConfig, manager: SyncManager, stop_event: MpEvent + ) -> None: + self.metrics_manager = manager self.audio_process: Optional[mp.Process] = None - self.stop_event: MpEvent = mp.Event() + self.stop_event = stop_event self.detection_queue: Queue = mp.Queue() self.detectors: dict[str, ObjectDetectProcess] = {} - self.detection_out_events: dict[str, MpEvent] = {} self.detection_shms: list[mp.shared_memory.SharedMemory] = [] self.log_queue: Queue = mp.Queue() - self.camera_metrics: dict[str, CameraMetrics] = {} + self.camera_metrics: DictProxy = self.metrics_manager.dict() self.embeddings_metrics: DataProcessorMetrics | None = ( - DataProcessorMetrics() + DataProcessorMetrics( + self.metrics_manager, list(config.classification.custom.keys()) + ) if ( config.semantic_search.enabled or config.lpr.enabled or config.face_recognition.enabled + or len(config.classification.custom) > 0 ) else None ) self.ptz_metrics: dict[str, PTZMetrics] = {} self.processes: dict[str, int] = {} self.embeddings: Optional[EmbeddingsContext] = None - self.region_grids: dict[str, list[list[dict[str, int]]]] = {} - self.frame_manager = SharedMemoryFrameManager() self.config = config def ensure_dirs(self) -> None: @@ -121,6 +125,9 @@ class FrigateApp: if self.config.face_recognition.enabled: dirs.append(FACE_DIR) + if self.config.semantic_search.enabled: + dirs.append(TRIGGER_DIR) + for d in dirs: if not os.path.exists(d) and not os.path.islink(d): logger.info(f"Creating directory: {d}") @@ -131,7 +138,7 @@ class FrigateApp: def init_camera_metrics(self) -> None: # create camera_metrics for camera_name in self.config.cameras.keys(): - self.camera_metrics[camera_name] = CameraMetrics() + self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager) self.ptz_metrics[camera_name] = PTZMetrics( autotracker_enabled=self.config.cameras[ camera_name @@ -140,8 +147,16 @@ class FrigateApp: def init_queues(self) -> None: # Queue for cameras to push tracked objects to + # leaving room for 2 extra cameras to be added self.detected_frames_queue: Queue = mp.Queue( - maxsize=sum(camera.enabled for camera in self.config.cameras.values()) * 2 + maxsize=( + sum( + camera.enabled_in_config == True + for camera in self.config.cameras.values() + ) + + 2 + ) + * 2 ) # Queue for timeline events @@ -217,52 +232,24 @@ class FrigateApp: self.processes["go2rtc"] = proc.info["pid"] def init_recording_manager(self) -> None: - recording_process = util.Process( - target=manage_recordings, - name="recording_manager", - args=(self.config,), - ) - recording_process.daemon = True + recording_process = RecordProcess(self.config, self.stop_event) self.recording_process = recording_process recording_process.start() self.processes["recording"] = recording_process.pid or 0 logger.info(f"Recording process started: {recording_process.pid}") def init_review_segment_manager(self) -> None: - review_segment_process = util.Process( - target=manage_review_segments, - name="review_segment_manager", - args=(self.config,), - ) - review_segment_process.daemon = True + review_segment_process = ReviewProcess(self.config, self.stop_event) self.review_segment_process = review_segment_process review_segment_process.start() self.processes["review_segment"] = review_segment_process.pid or 0 logger.info(f"Review process started: {review_segment_process.pid}") def init_embeddings_manager(self) -> None: - genai_cameras = [ - c for c in self.config.cameras.values() if c.enabled and c.genai.enabled - ] - - if ( - not self.config.semantic_search.enabled - and not genai_cameras - and not self.config.lpr.enabled - and not self.config.face_recognition.enabled - and not self.config.classification.bird.enabled - ): - return - - embedding_process = util.Process( - target=manage_embeddings, - name="embeddings_manager", - args=( - self.config, - self.embeddings_metrics, - ), + # always start the embeddings process + embedding_process = EmbeddingProcess( + self.config, self.embeddings_metrics, self.stop_event ) - embedding_process.daemon = True self.embedding_process = embedding_process embedding_process.start() self.processes["embeddings"] = embedding_process.pid or 0 @@ -279,7 +266,9 @@ class FrigateApp: "synchronous": "NORMAL", # Safe when using WAL https://www.sqlite.org/pragma.html#pragma_synchronous }, timeout=max( - 60, 10 * len([c for c in self.config.cameras.values() if c.enabled]) + 60, + 10 + * len([c for c in self.config.cameras.values() if c.enabled_in_config]), ), load_vec_extension=self.config.semantic_search.enabled, ) @@ -293,6 +282,7 @@ class FrigateApp: ReviewSegment, Timeline, User, + Trigger, ] self.db.bind(models) @@ -308,24 +298,15 @@ class FrigateApp: migrate_exports(self.config.ffmpeg, list(self.config.cameras.keys())) def init_embeddings_client(self) -> None: - genai_cameras = [ - c for c in self.config.cameras.values() if c.enabled and c.genai.enabled - ] - - if ( - self.config.semantic_search.enabled - or self.config.lpr.enabled - or genai_cameras - or self.config.face_recognition.enabled - ): - # Create a client for other processes to use - self.embeddings = EmbeddingsContext(self.db) + # Create a client for other processes to use + self.embeddings = EmbeddingsContext(self.db) def init_inter_process_communicator(self) -> None: self.inter_process_communicator = InterProcessCommunicator() - self.inter_config_updater = ConfigPublisher() + self.inter_config_updater = CameraConfigUpdatePublisher() self.event_metadata_updater = EventMetadataPublisher() self.inter_zmq_proxy = ZmqProxy() + self.detection_proxy = DetectorProxy() def init_onvif(self) -> None: self.onvif_controller = OnvifController(self.config, self.ptz_metrics) @@ -358,8 +339,6 @@ class FrigateApp: def start_detectors(self) -> None: for name in self.config.cameras.keys(): - self.detection_out_events[name] = mp.Event() - try: largest_frame = max( [ @@ -391,8 +370,10 @@ class FrigateApp: self.detectors[name] = ObjectDetectProcess( name, self.detection_queue, - self.detection_out_events, + list(self.config.cameras.keys()), + self.config, detector_config, + self.stop_event, ) def start_ptz_autotracker(self) -> None: @@ -416,79 +397,22 @@ class FrigateApp: self.detected_frames_processor.start() def start_video_output_processor(self) -> None: - output_processor = util.Process( - target=output_frames, - name="output_processor", - args=(self.config,), - ) - output_processor.daemon = True + output_processor = OutputProcess(self.config, self.stop_event) self.output_processor = output_processor output_processor.start() logger.info(f"Output process started: {output_processor.pid}") - def init_historical_regions(self) -> None: - # delete region grids for removed or renamed cameras - cameras = list(self.config.cameras.keys()) - Regions.delete().where(~(Regions.camera << cameras)).execute() - - # create or update region grids for each camera - for camera in self.config.cameras.values(): - assert camera.name is not None - self.region_grids[camera.name] = get_camera_regions_grid( - camera.name, - camera.detect, - max(self.config.model.width, self.config.model.height), - ) - - def start_camera_processors(self) -> None: - for name, config in self.config.cameras.items(): - if not self.config.cameras[name].enabled_in_config: - logger.info(f"Camera processor not started for disabled camera {name}") - continue - - camera_process = util.Process( - target=track_camera, - name=f"camera_processor:{name}", - args=( - name, - config, - self.config.model, - self.config.model.merged_labelmap, - self.detection_queue, - self.detection_out_events[name], - self.detected_frames_queue, - self.camera_metrics[name], - self.ptz_metrics[name], - self.region_grids[name], - ), - daemon=True, - ) - self.camera_metrics[name].process = camera_process - camera_process.start() - logger.info(f"Camera processor started for {name}: {camera_process.pid}") - - def start_camera_capture_processes(self) -> None: - shm_frame_count = self.shm_frame_count() - - for name, config in self.config.cameras.items(): - if not self.config.cameras[name].enabled_in_config: - logger.info(f"Capture process not started for disabled camera {name}") - continue - - # pre-create shms - for i in range(shm_frame_count): - frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] - self.frame_manager.create(f"{config.name}_frame{i}", frame_size) - - capture_process = util.Process( - target=capture_camera, - name=f"camera_capture:{name}", - args=(name, config, shm_frame_count, self.camera_metrics[name]), - ) - capture_process.daemon = True - self.camera_metrics[name].capture_process = capture_process - capture_process.start() - logger.info(f"Capture process started for {name}: {capture_process.pid}") + def start_camera_processor(self) -> None: + self.camera_maintainer = CameraMaintainer( + self.config, + self.detection_queue, + self.detected_frames_queue, + self.camera_metrics, + self.ptz_metrics, + self.stop_event, + self.metrics_manager, + ) + self.camera_maintainer.start() def start_audio_processor(self) -> None: audio_cameras = [ @@ -498,7 +422,9 @@ class FrigateApp: ] if audio_cameras: - self.audio_process = AudioProcessor(audio_cameras, self.camera_metrics) + self.audio_process = AudioProcessor( + self.config, audio_cameras, self.camera_metrics, self.stop_event + ) self.audio_process.start() self.processes["audio_detector"] = self.audio_process.pid or 0 @@ -546,45 +472,6 @@ class FrigateApp: self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event) self.frigate_watchdog.start() - def shm_frame_count(self) -> int: - total_shm = round(shutil.disk_usage("/dev/shm").total / pow(2, 20), 1) - - # required for log files + nginx cache - min_req_shm = 40 + 10 - - if self.config.birdseye.restream: - min_req_shm += 8 - - available_shm = total_shm - min_req_shm - cam_total_frame_size = 0.0 - - for camera in self.config.cameras.values(): - if camera.enabled and camera.detect.width and camera.detect.height: - cam_total_frame_size += round( - (camera.detect.width * camera.detect.height * 1.5 + 270480) - / 1048576, - 1, - ) - - if cam_total_frame_size == 0.0: - return 0 - - shm_frame_count = min( - int(os.environ.get(SHM_FRAMES_VAR, "50")), - int(available_shm / (cam_total_frame_size)), - ) - - logger.debug( - f"Calculated total camera size {available_shm} / {cam_total_frame_size} :: {shm_frame_count} frames for each camera in SHM" - ) - - if shm_frame_count < 20: - logger.warning( - f"The current SHM size of {total_shm}MB is too small, recommend increasing it to at least {round(min_req_shm + cam_total_frame_size * 20)}MB." - ) - - return shm_frame_count - def init_auth(self) -> None: if self.config.auth.enabled: if User.select().count() == 0: @@ -645,19 +532,17 @@ class FrigateApp: self.init_recording_manager() self.init_review_segment_manager() self.init_go2rtc() - self.start_detectors() self.init_embeddings_manager() self.bind_database() self.check_db_data_migrations() self.init_inter_process_communicator() + self.start_detectors() self.init_dispatcher() self.init_embeddings_client() self.start_video_output_processor() self.start_ptz_autotracker() - self.init_historical_regions() self.start_detected_frames_processor() - self.start_camera_processors() - self.start_camera_capture_processes() + self.start_camera_processor() self.start_audio_processor() self.start_storage_maintainer() self.start_stats_emitter() @@ -680,6 +565,7 @@ class FrigateApp: self.onvif_controller, self.stats_emitter, self.event_metadata_updater, + self.inter_config_updater, ), host="127.0.0.1", port=5001, @@ -713,24 +599,6 @@ class FrigateApp: if self.onvif_controller: self.onvif_controller.close() - # ensure the capture processes are done - for camera, metrics in self.camera_metrics.items(): - capture_process = metrics.capture_process - if capture_process is not None: - logger.info(f"Waiting for capture process for {camera} to stop") - capture_process.terminate() - capture_process.join() - - # ensure the camera processors are done - for camera, metrics in self.camera_metrics.items(): - camera_process = metrics.process - if camera_process is not None: - logger.info(f"Waiting for process for {camera} to stop") - camera_process.terminate() - camera_process.join() - logger.info(f"Closing frame queue for {camera}") - empty_and_close_queue(metrics.frame_queue) - # ensure the detectors are done for detector in self.detectors.values(): detector.stop() @@ -774,14 +642,12 @@ class FrigateApp: self.inter_config_updater.stop() self.event_metadata_updater.stop() self.inter_zmq_proxy.stop() + self.detection_proxy.stop() - self.frame_manager.cleanup() while len(self.detection_shms) > 0: shm = self.detection_shms.pop() shm.close() shm.unlink() - # exit the mp Manager process _stop_logging() - - os._exit(os.EX_OK) + self.metrics_manager.shutdown() diff --git a/frigate/camera/__init__.py b/frigate/camera/__init__.py index 456751c523..77b1fd4246 100644 --- a/frigate/camera/__init__.py +++ b/frigate/camera/__init__.py @@ -1,7 +1,7 @@ import multiprocessing as mp +from multiprocessing.managers import SyncManager from multiprocessing.sharedctypes import Synchronized from multiprocessing.synchronize import Event -from typing import Optional class CameraMetrics: @@ -16,25 +16,25 @@ class CameraMetrics: frame_queue: mp.Queue - process: Optional[mp.Process] - capture_process: Optional[mp.Process] + process_pid: Synchronized + capture_process_pid: Synchronized ffmpeg_pid: Synchronized - def __init__(self): - self.camera_fps = mp.Value("d", 0) - self.detection_fps = mp.Value("d", 0) - self.detection_frame = mp.Value("d", 0) - self.process_fps = mp.Value("d", 0) - self.skipped_fps = mp.Value("d", 0) - self.read_start = mp.Value("d", 0) - self.audio_rms = mp.Value("d", 0) - self.audio_dBFS = mp.Value("d", 0) + def __init__(self, manager: SyncManager): + self.camera_fps = manager.Value("d", 0) + self.detection_fps = manager.Value("d", 0) + self.detection_frame = manager.Value("d", 0) + self.process_fps = manager.Value("d", 0) + self.skipped_fps = manager.Value("d", 0) + self.read_start = manager.Value("d", 0) + self.audio_rms = manager.Value("d", 0) + self.audio_dBFS = manager.Value("d", 0) - self.frame_queue = mp.Queue(maxsize=2) + self.frame_queue = manager.Queue(maxsize=2) - self.process = None - self.capture_process = None - self.ffmpeg_pid = mp.Value("i", 0) + self.process_pid = manager.Value("i", 0) + self.capture_process_pid = manager.Value("i", 0) + self.ffmpeg_pid = manager.Value("i", 0) class PTZMetrics: diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py index 6039a07f6b..c2dfa891da 100644 --- a/frigate/camera/activity_manager.py +++ b/frigate/camera/activity_manager.py @@ -1,9 +1,20 @@ """Manage camera activity and updating listeners.""" +import datetime +import json +import logging +import random +import string from collections import Counter from typing import Any, Callable -from frigate.config.config import FrigateConfig +from frigate.comms.event_metadata_updater import ( + EventMetadataPublisher, + EventMetadataTypeEnum, +) +from frigate.config import CameraConfig, FrigateConfig + +logger = logging.getLogger(__name__) class CameraActivityManager: @@ -23,26 +34,33 @@ class CameraActivityManager: if not camera_config.enabled_in_config: continue - self.last_camera_activity[camera_config.name] = {} - self.camera_all_object_counts[camera_config.name] = Counter() - self.camera_active_object_counts[camera_config.name] = Counter() + self.__init_camera(camera_config) - for zone, zone_config in camera_config.zones.items(): - if zone not in self.all_zone_labels: - self.zone_all_object_counts[zone] = Counter() - self.zone_active_object_counts[zone] = Counter() - self.all_zone_labels[zone] = set() + def __init_camera(self, camera_config: CameraConfig) -> None: + self.last_camera_activity[camera_config.name] = {} + self.camera_all_object_counts[camera_config.name] = Counter() + self.camera_active_object_counts[camera_config.name] = Counter() - self.all_zone_labels[zone].update( - zone_config.objects - if zone_config.objects - else camera_config.objects.track - ) + for zone, zone_config in camera_config.zones.items(): + if zone not in self.all_zone_labels: + self.zone_all_object_counts[zone] = Counter() + self.zone_active_object_counts[zone] = Counter() + self.all_zone_labels[zone] = set() + + self.all_zone_labels[zone].update( + zone_config.objects + if zone_config.objects + else camera_config.objects.track + ) def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None: all_objects: list[dict[str, Any]] = [] for camera in new_activity.keys(): + # handle cameras that were added dynamically + if camera not in self.camera_all_object_counts: + self.__init_camera(self.config.cameras[camera]) + new_objects = new_activity[camera].get("objects", []) all_objects.extend(new_objects) @@ -132,3 +150,110 @@ class CameraActivityManager: if any_changed: self.publish(f"{camera}/all", sum(list(all_objects.values()))) self.publish(f"{camera}/all/active", sum(list(active_objects.values()))) + + +class AudioActivityManager: + def __init__( + self, config: FrigateConfig, publish: Callable[[str, Any], None] + ) -> None: + self.config = config + self.publish = publish + self.current_audio_detections: dict[str, dict[str, dict[str, Any]]] = {} + self.event_metadata_publisher = EventMetadataPublisher() + + for camera_config in config.cameras.values(): + if not camera_config.audio.enabled_in_config: + continue + + self.__init_camera(camera_config) + + def __init_camera(self, camera_config: CameraConfig) -> None: + self.current_audio_detections[camera_config.name] = {} + + def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None: + now = datetime.datetime.now().timestamp() + + for camera in new_activity.keys(): + # handle cameras that were added dynamically + if camera not in self.current_audio_detections: + self.__init_camera(self.config.cameras[camera]) + + new_detections = new_activity[camera].get("detections", []) + if self.compare_audio_activity(camera, new_detections, now): + logger.debug(f"Audio detections for {camera}: {new_activity}") + self.publish( + f"{camera}/audio/all", + "ON" if len(self.current_audio_detections[camera]) > 0 else "OFF", + ) + self.publish( + "audio_detections", + json.dumps(self.current_audio_detections), + ) + + def compare_audio_activity( + self, camera: str, new_detections: list[tuple[str, float]], now: float + ) -> None: + max_not_heard = self.config.cameras[camera].audio.max_not_heard + current = self.current_audio_detections[camera] + + any_changed = False + + for label, score in new_detections: + any_changed = True + if label in current: + current[label]["last_detection"] = now + current[label]["score"] = score + else: + rand_id = "".join( + random.choices(string.ascii_lowercase + string.digits, k=6) + ) + event_id = f"{now}-{rand_id}" + self.publish(f"{camera}/audio/{label}", "ON") + + self.event_metadata_publisher.publish( + ( + now, + camera, + label, + event_id, + True, + score, + None, + None, + "audio", + {}, + ), + EventMetadataTypeEnum.manual_event_create.value, + ) + current[label] = { + "id": event_id, + "score": score, + "last_detection": now, + } + + # expire detections + for label in list(current.keys()): + if now - current[label]["last_detection"] > max_not_heard: + any_changed = True + self.publish(f"{camera}/audio/{label}", "OFF") + + self.event_metadata_publisher.publish( + (current[label]["id"], now), + EventMetadataTypeEnum.manual_event_end.value, + ) + del current[label] + + return any_changed + + def expire_all(self, camera: str) -> None: + now = datetime.datetime.now().timestamp() + current = self.current_audio_detections.get(camera, {}) + + for label in list(current.keys()): + self.publish(f"{camera}/audio/{label}", "OFF") + + self.event_metadata_publisher.publish( + (current[label]["id"], now), + EventMetadataTypeEnum.manual_event_end.value, + ) + del current[label] diff --git a/frigate/camera/maintainer.py b/frigate/camera/maintainer.py new file mode 100644 index 0000000000..865fe47257 --- /dev/null +++ b/frigate/camera/maintainer.py @@ -0,0 +1,220 @@ +"""Create and maintain camera processes / management.""" + +import logging +import multiprocessing as mp +import threading +from multiprocessing import Queue +from multiprocessing.managers import DictProxy, SyncManager +from multiprocessing.synchronize import Event as MpEvent + +from frigate.camera import CameraMetrics, PTZMetrics +from frigate.config import FrigateConfig +from frigate.config.camera import CameraConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) +from frigate.models import Regions +from frigate.util.builtin import empty_and_close_queue +from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory +from frigate.util.object import get_camera_regions_grid +from frigate.util.services import calculate_shm_requirements +from frigate.video import CameraCapture, CameraTracker + +logger = logging.getLogger(__name__) + + +class CameraMaintainer(threading.Thread): + def __init__( + self, + config: FrigateConfig, + detection_queue: Queue, + detected_frames_queue: Queue, + camera_metrics: DictProxy, + ptz_metrics: dict[str, PTZMetrics], + stop_event: MpEvent, + metrics_manager: SyncManager, + ): + super().__init__(name="camera_processor") + self.config = config + self.detection_queue = detection_queue + self.detected_frames_queue = detected_frames_queue + self.stop_event = stop_event + self.camera_metrics = camera_metrics + self.ptz_metrics = ptz_metrics + self.frame_manager = SharedMemoryFrameManager() + self.region_grids: dict[str, list[list[dict[str, int]]]] = {} + self.update_subscriber = CameraConfigUpdateSubscriber( + self.config, + {}, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.remove, + ], + ) + self.shm_count = self.__calculate_shm_frame_count() + self.camera_processes: dict[str, mp.Process] = {} + self.capture_processes: dict[str, mp.Process] = {} + self.metrics_manager = metrics_manager + + def __init_historical_regions(self) -> None: + # delete region grids for removed or renamed cameras + cameras = list(self.config.cameras.keys()) + Regions.delete().where(~(Regions.camera << cameras)).execute() + + # create or update region grids for each camera + for camera in self.config.cameras.values(): + assert camera.name is not None + self.region_grids[camera.name] = get_camera_regions_grid( + camera.name, + camera.detect, + max(self.config.model.width, self.config.model.height), + ) + + def __calculate_shm_frame_count(self) -> int: + shm_stats = calculate_shm_requirements(self.config) + + if not shm_stats: + # /dev/shm not available + return 0 + + logger.debug( + f"Calculated total camera size {shm_stats['available']} / " + f"{shm_stats['camera_frame_size']} :: {shm_stats['shm_frame_count']} " + f"frames for each camera in SHM" + ) + + if shm_stats["shm_frame_count"] < 20: + logger.warning( + f"The current SHM size of {shm_stats['total']}MB is too small, " + f"recommend increasing it to at least {shm_stats['min_shm']}MB." + ) + + return shm_stats["shm_frame_count"] + + def __start_camera_processor( + self, name: str, config: CameraConfig, runtime: bool = False + ) -> None: + if not config.enabled_in_config: + logger.info(f"Camera processor not started for disabled camera {name}") + return + + if runtime: + self.camera_metrics[name] = CameraMetrics(self.metrics_manager) + self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False) + self.region_grids[name] = get_camera_regions_grid( + name, + config.detect, + max(self.config.model.width, self.config.model.height), + ) + + try: + largest_frame = max( + [ + det.model.height * det.model.width * 3 + if det.model is not None + else 320 + for det in self.config.detectors.values() + ] + ) + UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4) + UntrackedSharedMemory( + name=name, + create=True, + size=largest_frame, + ) + except FileExistsError: + pass + + camera_process = CameraTracker( + config, + self.config.model, + self.config.model.merged_labelmap, + self.detection_queue, + self.detected_frames_queue, + self.camera_metrics[name], + self.ptz_metrics[name], + self.region_grids[name], + self.stop_event, + ) + self.camera_processes[config.name] = camera_process + camera_process.start() + self.camera_metrics[config.name].process_pid.value = camera_process.pid + logger.info(f"Camera processor started for {config.name}: {camera_process.pid}") + + def __start_camera_capture( + self, name: str, config: CameraConfig, runtime: bool = False + ) -> None: + if not config.enabled_in_config: + logger.info(f"Capture process not started for disabled camera {name}") + return + + # pre-create shms + count = 10 if runtime else self.shm_count + for i in range(count): + frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] + self.frame_manager.create(f"{config.name}_frame{i}", frame_size) + + capture_process = CameraCapture( + config, count, self.camera_metrics[name], self.stop_event + ) + capture_process.daemon = True + self.capture_processes[name] = capture_process + capture_process.start() + self.camera_metrics[name].capture_process_pid.value = capture_process.pid + logger.info(f"Capture process started for {name}: {capture_process.pid}") + + def __stop_camera_capture_process(self, camera: str) -> None: + capture_process = self.capture_processes[camera] + if capture_process is not None: + logger.info(f"Waiting for capture process for {camera} to stop") + capture_process.terminate() + capture_process.join() + + def __stop_camera_process(self, camera: str) -> None: + camera_process = self.camera_processes[camera] + if camera_process is not None: + logger.info(f"Waiting for process for {camera} to stop") + camera_process.terminate() + camera_process.join() + logger.info(f"Closing frame queue for {camera}") + empty_and_close_queue(self.camera_metrics[camera].frame_queue) + + def run(self): + self.__init_historical_regions() + + # start camera processes + for camera, config in self.config.cameras.items(): + self.__start_camera_processor(camera, config) + self.__start_camera_capture(camera, config) + + while not self.stop_event.wait(1): + updates = self.update_subscriber.check_for_updates() + + for update_type, updated_cameras in updates.items(): + if update_type == CameraConfigUpdateEnum.add.name: + for camera in updated_cameras: + self.__start_camera_processor( + camera, + self.update_subscriber.camera_configs[camera], + runtime=True, + ) + self.__start_camera_capture( + camera, + self.update_subscriber.camera_configs[camera], + runtime=True, + ) + elif update_type == CameraConfigUpdateEnum.remove.name: + self.__stop_camera_capture_process(camera) + self.__stop_camera_process(camera) + + # ensure the capture processes are done + for camera in self.camera_processes.keys(): + self.__stop_camera_capture_process(camera) + + # ensure the camera processors are done + for camera in self.capture_processes.keys(): + self.__stop_camera_process(camera) + + self.update_subscriber.stop() + self.frame_manager.cleanup() diff --git a/frigate/camera/state.py b/frigate/camera/state.py index 06564bce27..328c7bd234 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -54,7 +54,7 @@ class CameraState: self.ptz_autotracker_thread = ptz_autotracker_thread self.prev_enabled = self.camera_config.enabled - def get_current_frame(self, draw_options: dict[str, Any] = {}): + def get_current_frame(self, draw_options: dict[str, Any] = {}) -> np.ndarray: with self.current_frame_lock: frame_copy = np.copy(self._current_frame) frame_time = self.current_frame_time @@ -228,12 +228,51 @@ class CameraState: position=self.camera_config.timestamp_style.position, ) + if draw_options.get("paths"): + for obj in tracked_objects.values(): + if obj["frame_time"] == frame_time and obj["path_data"]: + color = self.config.model.colormap.get( + obj["label"], (255, 255, 255) + ) + + path_points = [ + ( + int(point[0][0] * self.camera_config.detect.width), + int(point[0][1] * self.camera_config.detect.height), + ) + for point in obj["path_data"] + ] + + for point in path_points: + cv2.circle(frame_copy, point, 5, color, -1) + + for i in range(1, len(path_points)): + cv2.line( + frame_copy, + path_points[i - 1], + path_points[i], + color, + 2, + ) + + bottom_center = ( + int((obj["box"][0] + obj["box"][2]) / 2), + int(obj["box"][3]), + ) + cv2.line( + frame_copy, + path_points[-1], + bottom_center, + color, + 2, + ) + return frame_copy def finished(self, obj_id): del self.tracked_objects[obj_id] - def on(self, event_type: str, callback: Callable[[dict], None]): + def on(self, event_type: str, callback: Callable): self.callbacks[event_type].append(callback) def update( diff --git a/frigate/comms/config_updater.py b/frigate/comms/config_updater.py index 06b870c627..447089a949 100644 --- a/frigate/comms/config_updater.py +++ b/frigate/comms/config_updater.py @@ -1,8 +1,9 @@ """Facilitates communication between processes.""" import multiprocessing as mp +from _pickle import UnpicklingError from multiprocessing.synchronize import Event as MpEvent -from typing import Any, Optional +from typing import Any import zmq @@ -32,7 +33,7 @@ class ConfigPublisher: class ConfigSubscriber: """Simplifies receiving an updated config.""" - def __init__(self, topic: str, exact=False) -> None: + def __init__(self, topic: str, exact: bool = False) -> None: self.topic = topic self.exact = exact self.context = zmq.Context() @@ -40,7 +41,7 @@ class ConfigSubscriber: self.socket.setsockopt_string(zmq.SUBSCRIBE, topic) self.socket.connect(SOCKET_PUB_SUB) - def check_for_update(self) -> Optional[tuple[str, Any]]: + def check_for_update(self) -> tuple[str, Any] | tuple[None, None]: """Returns updated config or None if no update.""" try: topic = self.socket.recv_string(flags=zmq.NOBLOCK) @@ -50,7 +51,7 @@ class ConfigSubscriber: return (topic, obj) else: return (None, None) - except zmq.ZMQError: + except (zmq.ZMQError, UnicodeDecodeError, UnpicklingError): return (None, None) def stop(self) -> None: diff --git a/frigate/comms/detections_updater.py b/frigate/comms/detections_updater.py index 1718d13478..dff61c8a2a 100644 --- a/frigate/comms/detections_updater.py +++ b/frigate/comms/detections_updater.py @@ -1,7 +1,7 @@ """Facilitates communication between processes.""" from enum import Enum -from typing import Any, Optional +from typing import Any from .zmq_proxy import Publisher, Subscriber @@ -19,8 +19,7 @@ class DetectionPublisher(Publisher): topic_base = "detection/" - def __init__(self, topic: DetectionTypeEnum) -> None: - topic = topic.value + def __init__(self, topic: str) -> None: super().__init__(topic) @@ -29,16 +28,15 @@ class DetectionSubscriber(Subscriber): topic_base = "detection/" - def __init__(self, topic: DetectionTypeEnum) -> None: - topic = topic.value + def __init__(self, topic: str) -> None: super().__init__(topic) def check_for_update( - self, timeout: float = None - ) -> Optional[tuple[DetectionTypeEnum, Any]]: + self, timeout: float | None = None + ) -> tuple[str, Any] | tuple[None, None] | None: return super().check_for_update(timeout) def _return_object(self, topic: str, payload: Any) -> Any: if payload is None: return (None, None) - return (DetectionTypeEnum[topic[len(self.topic_base) :]], payload) + return (topic[len(self.topic_base) :], payload) diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 87891ec88d..235693c8c5 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -3,24 +3,32 @@ import datetime import json import logging -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, cast from frigate.camera import PTZMetrics -from frigate.camera.activity_manager import CameraActivityManager +from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager from frigate.comms.base_communicator import Communicator -from frigate.comms.config_updater import ConfigPublisher from frigate.comms.webpush import WebPushClient from frigate.config import BirdseyeModeEnum, FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdatePublisher, + CameraConfigUpdateTopic, +) from frigate.const import ( CLEAR_ONGOING_REVIEW_SEGMENTS, + EXPIRE_AUDIO_ACTIVITY, INSERT_MANY_RECORDINGS, INSERT_PREVIEW, NOTIFICATION_TEST, REQUEST_REGION_GRID, + UPDATE_AUDIO_ACTIVITY, + UPDATE_BIRDSEYE_LAYOUT, UPDATE_CAMERA_ACTIVITY, UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_EVENT_DESCRIPTION, UPDATE_MODEL_STATE, + UPDATE_REVIEW_DESCRIPTION, UPSERT_REVIEW_SEGMENT, ) from frigate.models import Event, Previews, Recordings, ReviewSegment @@ -38,7 +46,7 @@ class Dispatcher: def __init__( self, config: FrigateConfig, - config_updater: ConfigPublisher, + config_updater: CameraConfigUpdatePublisher, onvif: OnvifController, ptz_metrics: dict[str, PTZMetrics], communicators: list[Communicator], @@ -49,11 +57,13 @@ class Dispatcher: self.ptz_metrics = ptz_metrics self.comms = communicators self.camera_activity = CameraActivityManager(config, self.publish) - self.model_state = {} - self.embeddings_reindex = {} - + self.audio_activity = AudioActivityManager(config, self.publish) + self.model_state: dict[str, ModelStatusTypesEnum] = {} + self.embeddings_reindex: dict[str, Any] = {} + self.birdseye_layout: dict[str, Any] = {} self._camera_settings_handlers: dict[str, Callable] = { "audio": self._on_audio_command, + "audio_transcription": self._on_audio_transcription_command, "detect": self._on_detect_command, "enabled": self._on_enabled_command, "improve_contrast": self._on_motion_improve_contrast_command, @@ -68,6 +78,8 @@ class Dispatcher: "birdseye_mode": self._on_birdseye_mode_command, "review_alerts": self._on_alerts_command, "review_detections": self._on_detections_command, + "object_descriptions": self._on_object_description_command, + "review_descriptions": self._on_review_description_command, } self._global_settings_handlers: dict[str, Callable] = { "notifications": self._on_global_notification_command, @@ -80,10 +92,12 @@ class Dispatcher: (comm for comm in communicators if isinstance(comm, WebPushClient)), None ) - def _receive(self, topic: str, payload: str) -> Optional[Any]: + def _receive(self, topic: str, payload: Any) -> Optional[Any]: """Handle receiving of payload from communicators.""" - def handle_camera_command(command_type, camera_name, command, payload): + def handle_camera_command( + command_type: str, camera_name: str, command: str, payload: str + ) -> None: try: if command_type == "set": self._camera_settings_handlers[command](camera_name, payload) @@ -92,13 +106,13 @@ class Dispatcher: except KeyError: logger.error(f"Invalid command type or handler: {command_type}") - def handle_restart(): + def handle_restart() -> None: restart_frigate() - def handle_insert_many_recordings(): + def handle_insert_many_recordings() -> None: Recordings.insert_many(payload).execute() - def handle_request_region_grid(): + def handle_request_region_grid() -> Any: camera = payload grid = get_camera_regions_grid( camera, @@ -107,26 +121,32 @@ class Dispatcher: ) return grid - def handle_insert_preview(): + def handle_insert_preview() -> None: Previews.insert(payload).execute() - def handle_upsert_review_segment(): + def handle_upsert_review_segment() -> None: ReviewSegment.insert(payload).on_conflict( conflict_target=[ReviewSegment.id], update=payload, ).execute() - def handle_clear_ongoing_review_segments(): + def handle_clear_ongoing_review_segments() -> None: ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where( ReviewSegment.end_time.is_null(True) ).execute() - def handle_update_camera_activity(): + def handle_update_camera_activity() -> None: self.camera_activity.update_activity(payload) - def handle_update_event_description(): + def handle_update_audio_activity() -> None: + self.audio_activity.update_activity(payload) + + def handle_expire_audio_activity() -> None: + self.audio_activity.expire_all(payload) + + def handle_update_event_description() -> None: event: Event = Event.get(Event.id == payload["id"]) - event.data["description"] = payload["description"] + cast(dict, event.data)["description"] = payload["description"] event.save() self.publish( "tracked_object_update", @@ -140,31 +160,48 @@ class Dispatcher: ), ) - def handle_update_model_state(): + def handle_update_review_description() -> None: + final_data = payload["after"] + ReviewSegment.insert(final_data).on_conflict( + conflict_target=[ReviewSegment.id], + update=final_data, + ).execute() + self.publish("reviews", json.dumps(payload)) + + def handle_update_model_state() -> None: if payload: model = payload["model"] state = payload["state"] self.model_state[model] = ModelStatusTypesEnum[state] self.publish("model_state", json.dumps(self.model_state)) - def handle_model_state(): + def handle_model_state() -> None: self.publish("model_state", json.dumps(self.model_state.copy())) - def handle_update_embeddings_reindex_progress(): + def handle_update_embeddings_reindex_progress() -> None: self.embeddings_reindex = payload self.publish( "embeddings_reindex_progress", json.dumps(payload), ) - def handle_embeddings_reindex_progress(): + def handle_embeddings_reindex_progress() -> None: self.publish( "embeddings_reindex_progress", json.dumps(self.embeddings_reindex.copy()), ) - def handle_on_connect(): + def handle_update_birdseye_layout() -> None: + if payload: + self.birdseye_layout = payload + self.publish("birdseye_layout", json.dumps(self.birdseye_layout)) + + def handle_birdseye_layout() -> None: + self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy())) + + def handle_on_connect() -> None: camera_status = self.camera_activity.last_camera_activity.copy() + audio_detections = self.audio_activity.current_audio_detections.copy() cameras_with_status = camera_status.keys() for camera in self.config.cameras.keys(): @@ -177,6 +214,9 @@ class Dispatcher: "snapshots": self.config.cameras[camera].snapshots.enabled, "record": self.config.cameras[camera].record.enabled, "audio": self.config.cameras[camera].audio.enabled, + "audio_transcription": self.config.cameras[ + camera + ].audio_transcription.live_enabled, "notifications": self.config.cameras[camera].notifications.enabled, "notifications_suspended": int( self.web_push_client.suspended_cameras.get(camera, 0) @@ -189,6 +229,12 @@ class Dispatcher: ].onvif.autotracking.enabled, "alerts": self.config.cameras[camera].review.alerts.enabled, "detections": self.config.cameras[camera].review.detections.enabled, + "object_descriptions": self.config.cameras[ + camera + ].objects.genai.enabled, + "review_descriptions": self.config.cameras[ + camera + ].review.genai.enabled, } self.publish("camera_activity", json.dumps(camera_status)) @@ -197,8 +243,10 @@ class Dispatcher: "embeddings_reindex_progress", json.dumps(self.embeddings_reindex.copy()), ) + self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy())) + self.publish("audio_detections", json.dumps(audio_detections)) - def handle_notification_test(): + def handle_notification_test() -> None: self.publish("notification_test", "Test notification") # Dictionary mapping topic to handlers @@ -209,13 +257,18 @@ class Dispatcher: UPSERT_REVIEW_SEGMENT: handle_upsert_review_segment, CLEAR_ONGOING_REVIEW_SEGMENTS: handle_clear_ongoing_review_segments, UPDATE_CAMERA_ACTIVITY: handle_update_camera_activity, + UPDATE_AUDIO_ACTIVITY: handle_update_audio_activity, + EXPIRE_AUDIO_ACTIVITY: handle_expire_audio_activity, UPDATE_EVENT_DESCRIPTION: handle_update_event_description, + UPDATE_REVIEW_DESCRIPTION: handle_update_review_description, UPDATE_MODEL_STATE: handle_update_model_state, UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress, + UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout, NOTIFICATION_TEST: handle_notification_test, "restart": handle_restart, "embeddingsReindexProgress": handle_embeddings_reindex_progress, "modelState": handle_model_state, + "birdseyeLayout": handle_birdseye_layout, "onConnect": handle_on_connect, } @@ -243,11 +296,12 @@ class Dispatcher: logger.error( f"Received invalid {topic.split('/')[-1]} command: {topic}" ) - return + return None elif topic in topic_handlers: return topic_handlers[topic]() else: self.publish(topic, payload, retain=False) + return None def publish(self, topic: str, payload: Any, retain: bool = False) -> None: """Handle publishing to communicators.""" @@ -273,8 +327,11 @@ class Dispatcher: f"Turning on motion for {camera_name} due to detection being enabled." ) motion_settings.enabled = True - self.config_updater.publish( - f"config/motion/{camera_name}", motion_settings + self.config_updater.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.motion, camera_name + ), + motion_settings, ) self.publish(f"{camera_name}/motion/state", payload, retain=True) elif payload == "OFF": @@ -282,7 +339,10 @@ class Dispatcher: logger.info(f"Turning off detection for {camera_name}") detect_settings.enabled = False - self.config_updater.publish(f"config/detect/{camera_name}", detect_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.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: @@ -303,7 +363,10 @@ class Dispatcher: logger.info(f"Turning off camera {camera_name}") camera_settings.enabled = False - self.config_updater.publish(f"config/enabled/{camera_name}", camera_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name), + camera_settings.enabled, + ) self.publish(f"{camera_name}/enabled/state", payload, retain=True) def _on_motion_command(self, camera_name: str, payload: str) -> None: @@ -326,7 +389,10 @@ class Dispatcher: logger.info(f"Turning off motion for {camera_name}") motion_settings.enabled = False - self.config_updater.publish(f"config/motion/{camera_name}", motion_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name), + motion_settings, + ) self.publish(f"{camera_name}/motion/state", payload, retain=True) def _on_motion_improve_contrast_command( @@ -338,13 +404,16 @@ class Dispatcher: if payload == "ON": if not motion_settings.improve_contrast: logger.info(f"Turning on improve contrast for {camera_name}") - motion_settings.improve_contrast = True # type: ignore[union-attr] + motion_settings.improve_contrast = True elif payload == "OFF": if motion_settings.improve_contrast: logger.info(f"Turning off improve contrast for {camera_name}") - motion_settings.improve_contrast = False # type: ignore[union-attr] + motion_settings.improve_contrast = False - self.config_updater.publish(f"config/motion/{camera_name}", motion_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name), + motion_settings, + ) self.publish(f"{camera_name}/improve_contrast/state", payload, retain=True) def _on_ptz_autotracker_command(self, camera_name: str, payload: str) -> None: @@ -383,8 +452,11 @@ class Dispatcher: motion_settings = self.config.cameras[camera_name].motion logger.info(f"Setting motion contour area for {camera_name}: {payload}") - motion_settings.contour_area = payload # type: ignore[union-attr] - self.config_updater.publish(f"config/motion/{camera_name}", motion_settings) + motion_settings.contour_area = payload + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name), + motion_settings, + ) self.publish(f"{camera_name}/motion_contour_area/state", payload, retain=True) def _on_motion_threshold_command(self, camera_name: str, payload: int) -> None: @@ -397,8 +469,11 @@ class Dispatcher: motion_settings = self.config.cameras[camera_name].motion logger.info(f"Setting motion threshold for {camera_name}: {payload}") - motion_settings.threshold = payload # type: ignore[union-attr] - self.config_updater.publish(f"config/motion/{camera_name}", motion_settings) + motion_settings.threshold = payload + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name), + motion_settings, + ) self.publish(f"{camera_name}/motion_threshold/state", payload, retain=True) def _on_global_notification_command(self, payload: str) -> None: @@ -409,9 +484,9 @@ class Dispatcher: notification_settings = self.config.notifications logger.info(f"Setting all notifications: {payload}") - notification_settings.enabled = payload == "ON" # type: ignore[union-attr] - self.config_updater.publish( - "config/notifications", {"_global_notifications": notification_settings} + notification_settings.enabled = payload == "ON" + self.config_updater.publisher.publish( + "config/notifications", notification_settings ) self.publish("notifications/state", payload, retain=True) @@ -434,9 +509,43 @@ class Dispatcher: logger.info(f"Turning off audio detection for {camera_name}") audio_settings.enabled = False - self.config_updater.publish(f"config/audio/{camera_name}", audio_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name), + audio_settings, + ) self.publish(f"{camera_name}/audio/state", payload, retain=True) + def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None: + """Callback for live audio transcription topic.""" + audio_transcription_settings = self.config.cameras[ + camera_name + ].audio_transcription + + if payload == "ON": + if not self.config.cameras[ + camera_name + ].audio_transcription.enabled_in_config: + logger.error( + "Audio transcription must be enabled in the config to be turned on via MQTT." + ) + return + + if not audio_transcription_settings.live_enabled: + logger.info(f"Turning on live audio transcription for {camera_name}") + audio_transcription_settings.live_enabled = True + elif payload == "OFF": + if audio_transcription_settings.live_enabled: + logger.info(f"Turning off live audio transcription for {camera_name}") + audio_transcription_settings.live_enabled = False + + self.config_updater.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.audio_transcription, camera_name + ), + audio_transcription_settings, + ) + self.publish(f"{camera_name}/audio_transcription/state", payload, retain=True) + def _on_recordings_command(self, camera_name: str, payload: str) -> None: """Callback for recordings topic.""" record_settings = self.config.cameras[camera_name].record @@ -456,7 +565,10 @@ class Dispatcher: logger.info(f"Turning off recordings for {camera_name}") record_settings.enabled = False - self.config_updater.publish(f"config/record/{camera_name}", record_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name), + record_settings, + ) self.publish(f"{camera_name}/recordings/state", payload, retain=True) def _on_snapshots_command(self, camera_name: str, payload: str) -> None: @@ -472,6 +584,10 @@ class Dispatcher: logger.info(f"Turning off snapshots for {camera_name}") snapshots_settings.enabled = False + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name), + snapshots_settings, + ) self.publish(f"{camera_name}/snapshots/state", payload, retain=True) def _on_ptz_command(self, camera_name: str, payload: str) -> None: @@ -506,7 +622,10 @@ class Dispatcher: logger.info(f"Turning off birdseye for {camera_name}") birdseye_settings.enabled = False - self.config_updater.publish(f"config/birdseye/{camera_name}", birdseye_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.birdseye, camera_name), + birdseye_settings, + ) self.publish(f"{camera_name}/birdseye/state", payload, retain=True) def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None: @@ -527,7 +646,10 @@ class Dispatcher: f"Setting birdseye mode for {camera_name} to {birdseye_settings.mode}" ) - self.config_updater.publish(f"config/birdseye/{camera_name}", birdseye_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.birdseye, camera_name), + birdseye_settings, + ) self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True) def _on_camera_notification_command(self, camera_name: str, payload: str) -> None: @@ -559,8 +681,9 @@ class Dispatcher: ): self.web_push_client.suspended_cameras[camera_name] = 0 - self.config_updater.publish( - "config/notifications", {camera_name: notification_settings} + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.notifications, camera_name), + notification_settings, ) self.publish(f"{camera_name}/notifications/state", payload, retain=True) self.publish(f"{camera_name}/notifications/suspended", "0", retain=True) @@ -617,7 +740,10 @@ class Dispatcher: logger.info(f"Turning off alerts for {camera_name}") review_settings.alerts.enabled = False - self.config_updater.publish(f"config/review/{camera_name}", review_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.review, camera_name), + review_settings, + ) self.publish(f"{camera_name}/review_alerts/state", payload, retain=True) def _on_detections_command(self, camera_name: str, payload: str) -> None: @@ -639,5 +765,58 @@ class Dispatcher: logger.info(f"Turning off detections for {camera_name}") review_settings.detections.enabled = False - self.config_updater.publish(f"config/review/{camera_name}", review_settings) + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.review, camera_name), + review_settings, + ) self.publish(f"{camera_name}/review_detections/state", payload, retain=True) + + def _on_object_description_command(self, camera_name: str, payload: str) -> None: + """Callback for object description topic.""" + genai_settings = self.config.cameras[camera_name].objects.genai + + if payload == "ON": + if not self.config.cameras[camera_name].objects.genai.enabled_in_config: + logger.error( + "GenAI must be enabled in the config to be turned on via MQTT." + ) + return + + if not genai_settings.enabled: + logger.info(f"Turning on object descriptions for {camera_name}") + genai_settings.enabled = True + elif payload == "OFF": + if genai_settings.enabled: + logger.info(f"Turning off object descriptions for {camera_name}") + genai_settings.enabled = False + + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.object_genai, camera_name), + genai_settings, + ) + self.publish(f"{camera_name}/object_descriptions/state", payload, retain=True) + + def _on_review_description_command(self, camera_name: str, payload: str) -> None: + """Callback for review description topic.""" + genai_settings = self.config.cameras[camera_name].review.genai + + if payload == "ON": + if not self.config.cameras[camera_name].review.genai.enabled_in_config: + logger.error( + "GenAI Alerts or Detections must be enabled in the config to be turned on via MQTT." + ) + return + + if not genai_settings.enabled: + logger.info(f"Turning on review descriptions for {camera_name}") + genai_settings.enabled = True + elif payload == "OFF": + if genai_settings.enabled: + logger.info(f"Turning off review descriptions for {camera_name}") + genai_settings.enabled = False + + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.review_genai, camera_name), + genai_settings, + ) + self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True) diff --git a/frigate/comms/embeddings_updater.py b/frigate/comms/embeddings_updater.py index 74a87e60f8..f7fd9c2bf3 100644 --- a/frigate/comms/embeddings_updater.py +++ b/frigate/comms/embeddings_updater.py @@ -1,23 +1,36 @@ """Facilitates communication between processes.""" +import logging from enum import Enum from typing import Any, Callable import zmq +logger = logging.getLogger(__name__) + + SOCKET_REP_REQ = "ipc:///tmp/cache/embeddings" class EmbeddingsRequestEnum(Enum): + # audio + transcribe_audio = "transcribe_audio" + # custom classification + reload_classification_model = "reload_classification_model" + # face clear_face_classifier = "clear_face_classifier" - embed_description = "embed_description" - embed_thumbnail = "embed_thumbnail" - generate_search = "generate_search" recognize_face = "recognize_face" register_face = "register_face" reprocess_face = "reprocess_face" - reprocess_plate = "reprocess_plate" + # semantic search + embed_description = "embed_description" + embed_thumbnail = "embed_thumbnail" + generate_search = "generate_search" reindex = "reindex" + # LPR + reprocess_plate = "reprocess_plate" + # Review Descriptions + summarize_review = "summarize_review" class EmbeddingsResponder: @@ -34,9 +47,16 @@ class EmbeddingsResponder: break try: - (topic, value) = self.socket.recv_json(flags=zmq.NOBLOCK) + raw = self.socket.recv_json(flags=zmq.NOBLOCK) - response = process(topic, value) + if isinstance(raw, list): + (topic, value) = raw + response = process(topic, value) + else: + logging.warning( + f"Received unexpected data type in ZMQ recv_json: {type(raw)}" + ) + response = None if response is not None: self.socket.send_json(response) @@ -58,7 +78,7 @@ class EmbeddingsRequestor: self.socket = self.context.socket(zmq.REQ) self.socket.connect(SOCKET_REP_REQ) - def send_data(self, topic: str, data: Any) -> str: + def send_data(self, topic: str, data: Any) -> Any: """Sends data and then waits for reply.""" try: self.socket.send_json((topic, data)) diff --git a/frigate/comms/event_metadata_updater.py b/frigate/comms/event_metadata_updater.py index 6305de5a1a..8977788326 100644 --- a/frigate/comms/event_metadata_updater.py +++ b/frigate/comms/event_metadata_updater.py @@ -15,7 +15,7 @@ class EventMetadataTypeEnum(str, Enum): manual_event_end = "manual_event_end" regenerate_description = "regenerate_description" sub_label = "sub_label" - recognized_license_plate = "recognized_license_plate" + attribute = "attribute" lpr_event_create = "lpr_event_create" save_lpr_snapshot = "save_lpr_snapshot" @@ -28,8 +28,8 @@ class EventMetadataPublisher(Publisher): def __init__(self) -> None: super().__init__() - def publish(self, topic: EventMetadataTypeEnum, payload: Any) -> None: - super().publish(payload, topic.value) + def publish(self, payload: Any, sub_topic: str = "") -> None: + super().publish(payload, sub_topic) class EventMetadataSubscriber(Subscriber): @@ -40,9 +40,10 @@ class EventMetadataSubscriber(Subscriber): def __init__(self, topic: EventMetadataTypeEnum) -> None: super().__init__(topic.value) - def _return_object(self, topic: str, payload: tuple) -> tuple: + def _return_object( + self, topic: str, payload: tuple | None + ) -> tuple[str, Any] | tuple[None, None]: if payload is None: return (None, None) - topic = EventMetadataTypeEnum[topic[len(self.topic_base) :]] return (topic, payload) diff --git a/frigate/comms/events_updater.py b/frigate/comms/events_updater.py index b1d7a63280..cfd958d2c7 100644 --- a/frigate/comms/events_updater.py +++ b/frigate/comms/events_updater.py @@ -7,7 +7,9 @@ from frigate.events.types import EventStateEnum, EventTypeEnum from .zmq_proxy import Publisher, Subscriber -class EventUpdatePublisher(Publisher): +class EventUpdatePublisher( + Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]] +): """Publishes events (objects, audio, manual).""" topic_base = "event/" @@ -16,9 +18,11 @@ class EventUpdatePublisher(Publisher): super().__init__("update") def publish( - self, payload: tuple[EventTypeEnum, EventStateEnum, str, str, dict[str, Any]] + self, + payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]], + sub_topic: str = "", ) -> None: - super().publish(payload) + super().publish(payload, sub_topic) class EventUpdateSubscriber(Subscriber): @@ -30,7 +34,9 @@ class EventUpdateSubscriber(Subscriber): super().__init__("update") -class EventEndPublisher(Publisher): +class EventEndPublisher( + Publisher[tuple[EventTypeEnum, EventStateEnum, str, dict[str, Any]]] +): """Publishes events that have ended.""" topic_base = "event/" @@ -39,9 +45,11 @@ class EventEndPublisher(Publisher): super().__init__("finalized") def publish( - self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, Any]] + self, + payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, Any]], + sub_topic: str = "", ) -> None: - super().publish(payload) + super().publish(payload, sub_topic) class EventEndSubscriber(Subscriber): diff --git a/frigate/comms/inter_process.py b/frigate/comms/inter_process.py index ee1a78efcd..e4aad9107d 100644 --- a/frigate/comms/inter_process.py +++ b/frigate/comms/inter_process.py @@ -1,5 +1,6 @@ """Facilitates communication between processes.""" +import logging import multiprocessing as mp import threading from multiprocessing.synchronize import Event as MpEvent @@ -9,6 +10,8 @@ import zmq from frigate.comms.base_communicator import Communicator +logger = logging.getLogger(__name__) + SOCKET_REP_REQ = "ipc:///tmp/cache/comms" @@ -19,7 +22,7 @@ class InterProcessCommunicator(Communicator): self.socket.bind(SOCKET_REP_REQ) self.stop_event: MpEvent = mp.Event() - def publish(self, topic: str, payload: str, retain: bool) -> None: + def publish(self, topic: str, payload: Any, retain: bool = False) -> None: """There is no communication back to the processes.""" pass @@ -37,9 +40,16 @@ class InterProcessCommunicator(Communicator): break try: - (topic, value) = self.socket.recv_json(flags=zmq.NOBLOCK) + raw = self.socket.recv_json(flags=zmq.NOBLOCK) - response = self._dispatcher(topic, value) + if isinstance(raw, list): + (topic, value) = raw + response = self._dispatcher(topic, value) + else: + logging.warning( + f"Received unexpected data type in ZMQ recv_json: {type(raw)}" + ) + response = None if response is not None: self.socket.send_json(response) diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index e487b30ee8..0af56e2592 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -11,7 +11,7 @@ from frigate.config import FrigateConfig logger = logging.getLogger(__name__) -class MqttClient(Communicator): # type: ignore[misc] +class MqttClient(Communicator): """Frigate wrapper for mqtt client.""" def __init__(self, config: FrigateConfig) -> None: @@ -75,7 +75,7 @@ class MqttClient(Communicator): # type: ignore[misc] ) self.publish( f"{camera_name}/improve_contrast/state", - "ON" if camera.motion.improve_contrast else "OFF", # type: ignore[union-attr] + "ON" if camera.motion.improve_contrast else "OFF", retain=True, ) self.publish( @@ -85,12 +85,12 @@ class MqttClient(Communicator): # type: ignore[misc] ) self.publish( f"{camera_name}/motion_threshold/state", - camera.motion.threshold, # type: ignore[union-attr] + camera.motion.threshold, retain=True, ) self.publish( f"{camera_name}/motion_contour_area/state", - camera.motion.contour_area, # type: ignore[union-attr] + camera.motion.contour_area, retain=True, ) self.publish( @@ -122,6 +122,16 @@ class MqttClient(Communicator): # type: ignore[misc] "ON" if camera.review.detections.enabled_in_config else "OFF", retain=True, ) + self.publish( + f"{camera_name}/object_descriptions/state", + "ON" if camera.objects.genai.enabled_in_config else "OFF", + retain=True, + ) + self.publish( + f"{camera_name}/review_descriptions/state", + "ON" if camera.review.genai.enabled_in_config else "OFF", + retain=True, + ) if self.config.notifications.enabled_in_config: self.publish( @@ -145,7 +155,7 @@ class MqttClient(Communicator): # type: ignore[misc] client: mqtt.Client, userdata: Any, flags: Any, - reason_code: mqtt.ReasonCode, + reason_code: mqtt.ReasonCode, # type: ignore[name-defined] properties: Any, ) -> None: """Mqtt connection callback.""" @@ -177,7 +187,7 @@ class MqttClient(Communicator): # type: ignore[misc] client: mqtt.Client, userdata: Any, flags: Any, - reason_code: mqtt.ReasonCode, + reason_code: mqtt.ReasonCode, # type: ignore[name-defined] properties: Any, ) -> None: """Mqtt disconnection callback.""" @@ -215,6 +225,7 @@ class MqttClient(Communicator): # type: ignore[misc] "birdseye_mode", "review_alerts", "review_detections", + "genai", ] for name in self.config.cameras.keys(): diff --git a/frigate/comms/object_detector_signaler.py b/frigate/comms/object_detector_signaler.py new file mode 100644 index 0000000000..e8871db1a2 --- /dev/null +++ b/frigate/comms/object_detector_signaler.py @@ -0,0 +1,92 @@ +"""Facilitates communication between processes for object detection signals.""" + +import threading + +import zmq + +SOCKET_PUB = "ipc:///tmp/cache/detector_pub" +SOCKET_SUB = "ipc:///tmp/cache/detector_sub" + + +class ZmqProxyRunner(threading.Thread): + def __init__(self, context: zmq.Context[zmq.Socket]) -> None: + super().__init__(name="detector_proxy") + self.context = context + + def run(self) -> None: + """Run the proxy.""" + incoming = self.context.socket(zmq.XSUB) + incoming.bind(SOCKET_PUB) + outgoing = self.context.socket(zmq.XPUB) + outgoing.bind(SOCKET_SUB) + + # Blocking: This will unblock (via exception) when we destroy the context + # The incoming and outgoing sockets will be closed automatically + # when the context is destroyed as well. + try: + zmq.proxy(incoming, outgoing) + except zmq.ZMQError: + pass + + +class DetectorProxy: + """Proxies object detection signals.""" + + def __init__(self) -> None: + self.context = zmq.Context() + self.runner = ZmqProxyRunner(self.context) + self.runner.start() + + def stop(self) -> None: + # destroying the context will tell the proxy to stop + self.context.destroy() + self.runner.join() + + +class ObjectDetectorPublisher: + """Publishes signal for object detection to different processes.""" + + topic_base = "object_detector/" + + def __init__(self, topic: str = "") -> None: + self.topic = f"{self.topic_base}{topic}" + self.context = zmq.Context() + self.socket = self.context.socket(zmq.PUB) + self.socket.connect(SOCKET_PUB) + + def publish(self, sub_topic: str = "") -> None: + """Publish message.""" + self.socket.send_string(f"{self.topic}{sub_topic}/") + + def stop(self) -> None: + self.socket.close() + self.context.destroy() + + +class ObjectDetectorSubscriber: + """Simplifies receiving a signal for object detection.""" + + topic_base = "object_detector/" + + def __init__(self, topic: str = "") -> None: + self.topic = f"{self.topic_base}{topic}/" + self.context = zmq.Context() + self.socket = self.context.socket(zmq.SUB) + self.socket.setsockopt_string(zmq.SUBSCRIBE, self.topic) + self.socket.connect(SOCKET_SUB) + + def check_for_update(self, timeout: float = 5) -> str | None: + """Returns message or None if no update.""" + try: + has_update, _, _ = zmq.select([self.socket], [], [], timeout) + + if has_update: + return self.socket.recv_string(flags=zmq.NOBLOCK) + except zmq.ZMQError: + pass + + return None + + def stop(self) -> None: + self.socket.close() + self.context.destroy() diff --git a/frigate/comms/recordings_updater.py b/frigate/comms/recordings_updater.py index 862ec10413..0db4ad2891 100644 --- a/frigate/comms/recordings_updater.py +++ b/frigate/comms/recordings_updater.py @@ -13,17 +13,16 @@ class RecordingsDataTypeEnum(str, Enum): recordings_available_through = "recordings_available_through" -class RecordingsDataPublisher(Publisher): +class RecordingsDataPublisher(Publisher[tuple[str, float]]): """Publishes latest recording data.""" topic_base = "recordings/" def __init__(self, topic: RecordingsDataTypeEnum) -> None: - topic = topic.value - super().__init__(topic) + super().__init__(topic.value) - def publish(self, payload: tuple[str, float]) -> None: - super().publish(payload) + def publish(self, payload: tuple[str, float], sub_topic: str = "") -> None: + super().publish(payload, sub_topic) class RecordingsDataSubscriber(Subscriber): @@ -32,5 +31,4 @@ class RecordingsDataSubscriber(Subscriber): topic_base = "recordings/" def __init__(self, topic: RecordingsDataTypeEnum) -> None: - topic = topic.value - super().__init__(topic) + super().__init__(topic.value) diff --git a/frigate/comms/review_updater.py b/frigate/comms/review_updater.py new file mode 100644 index 0000000000..2b3a5b3aa5 --- /dev/null +++ b/frigate/comms/review_updater.py @@ -0,0 +1,30 @@ +"""Facilitates communication between processes.""" + +import logging + +from .zmq_proxy import Publisher, Subscriber + +logger = logging.getLogger(__name__) + + +class ReviewDataPublisher( + Publisher +): # update when typing improvement is added Publisher[tuple[str, float]] + """Publishes review item data.""" + + topic_base = "review/" + + def __init__(self, topic: str) -> None: + super().__init__(topic) + + def publish(self, payload: tuple[str, float], sub_topic: str = "") -> None: + super().publish(payload, sub_topic) + + +class ReviewDataSubscriber(Subscriber): + """Receives review item data.""" + + topic_base = "review/" + + def __init__(self, topic: str) -> None: + super().__init__(topic) diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index c5986d45c9..f518a2d94c 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -17,6 +17,10 @@ from titlecase import titlecase from frigate.comms.base_communicator import Communicator from frigate.comms.config_updater import ConfigSubscriber from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import CONFIG_DIR from frigate.models import User @@ -35,7 +39,7 @@ class PushNotification: ttl: int = 0 -class WebPushClient(Communicator): # type: ignore[misc] +class WebPushClient(Communicator): """Frigate wrapper for webpush client.""" def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None: @@ -46,10 +50,12 @@ class WebPushClient(Communicator): # type: ignore[misc] self.web_pushers: dict[str, list[WebPusher]] = {} self.expired_subs: dict[str, list[str]] = {} self.suspended_cameras: dict[str, int] = { - c.name: 0 for c in self.config.cameras.values() + c.name: 0 # type: ignore[misc] + for c in self.config.cameras.values() } self.last_camera_notification_time: dict[str, float] = { - c.name: 0 for c in self.config.cameras.values() + c.name: 0 # type: ignore[misc] + for c in self.config.cameras.values() } self.last_notification_time: float = 0 self.notification_queue: queue.Queue[PushNotification] = queue.Queue() @@ -64,7 +70,7 @@ class WebPushClient(Communicator): # type: ignore[misc] # Pull keys from PEM or generate if they do not exist self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem")) - users: list[User] = ( + users: list[dict[str, Any]] = ( User.select(User.username, User.notification_tokens).dicts().iterator() ) for user in users: @@ -73,7 +79,12 @@ class WebPushClient(Communicator): # type: ignore[misc] self.web_pushers[user["username"]].append(WebPusher(sub)) # notification config updater - self.config_subscriber = ConfigSubscriber("config/notifications") + self.global_config_subscriber = ConfigSubscriber( + "config/notifications", exact=True + ) + self.config_subscriber = CameraConfigUpdateSubscriber( + self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications] + ) def subscribe(self, receiver: Callable) -> None: """Wrapper for allowing dispatcher to subscribe.""" @@ -154,15 +165,19 @@ class WebPushClient(Communicator): # type: ignore[misc] def publish(self, topic: str, payload: Any, retain: bool = False) -> None: """Wrapper for publishing when client is in valid state.""" # check for updated notification config - _, updated_notification_config = self.config_subscriber.check_for_update() + _, updated_notification_config = ( + self.global_config_subscriber.check_for_update() + ) if updated_notification_config: - for key, value in updated_notification_config.items(): - if key == "_global_notifications": - self.config.notifications = value + self.config.notifications = updated_notification_config - elif key in self.config.cameras: - self.config.cameras[key].notifications = value + updates = self.config_subscriber.check_for_updates() + + if "add" in updates: + for camera in updates["add"]: + self.suspended_cameras[camera] = 0 + self.last_camera_notification_time[camera] = 0 if topic == "reviews": decoded = json.loads(payload) @@ -173,6 +188,28 @@ class WebPushClient(Communicator): # type: ignore[misc] logger.debug(f"Notifications for {camera} are currently suspended.") return self.send_alert(decoded) + if topic == "triggers": + decoded = json.loads(payload) + + camera = decoded["camera"] + name = decoded["name"] + + # ensure notifications are enabled and the specific trigger has + # notification action enabled + if ( + not self.config.cameras[camera].notifications.enabled + or name not in self.config.cameras[camera].semantic_search.triggers + or "notification" + not in self.config.cameras[camera] + .semantic_search.triggers[name] + .actions + ): + return + + if self.is_camera_suspended(camera): + logger.debug(f"Notifications for {camera} are currently suspended.") + return + self.send_trigger(decoded) elif topic == "notification_test": if not self.config.notifications.enabled and not any( cam.notifications.enabled for cam in self.config.cameras.values() @@ -254,6 +291,23 @@ class WebPushClient(Communicator): # type: ignore[misc] except Exception as e: logger.error(f"Error processing notification: {str(e)}") + def _within_cooldown(self, camera: str) -> bool: + now = datetime.datetime.now().timestamp() + if now - self.last_notification_time < self.config.notifications.cooldown: + logger.debug( + f"Skipping notification for {camera} - in global cooldown period" + ) + return True + if ( + now - self.last_camera_notification_time[camera] + < self.config.cameras[camera].notifications.cooldown + ): + logger.debug( + f"Skipping notification for {camera} - in camera-specific cooldown period" + ) + return True + return False + def send_notification_test(self) -> None: if not self.config.notifications.email: return @@ -280,26 +334,12 @@ class WebPushClient(Communicator): # type: ignore[misc] return camera: str = payload["after"]["camera"] + camera_name: str = getattr( + self.config.cameras[camera], "friendly_name", None + ) or titlecase(camera.replace("_", " ")) current_time = datetime.datetime.now().timestamp() - # Check global cooldown period - if ( - current_time - self.last_notification_time - < self.config.notifications.cooldown - ): - logger.debug( - f"Skipping notification for {camera} - in global cooldown period" - ) - return - - # Check camera-specific cooldown period - if ( - current_time - self.last_camera_notification_time[camera] - < self.config.cameras[camera].notifications.cooldown - ): - logger.debug( - f"Skipping notification for {camera} - in camera-specific cooldown period" - ) + if self._within_cooldown(camera): return self.check_registrations() @@ -332,12 +372,22 @@ class WebPushClient(Communicator): # type: ignore[misc] sorted_objects.update(payload["after"]["data"]["sub_labels"]) title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}" - message = f"Detected on {titlecase(camera.replace('_', ' '))}" image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}" + ended = state == "end" or state == "genai" + + if state == "genai" and payload["after"]["data"]["metadata"]: + message = payload["after"]["data"]["metadata"]["scene"] + else: + message = f"Detected on {camera_name}" + + if ended: + logger.debug( + f"Sending a notification with state {state} and message {message}" + ) # if event is ongoing open to live view otherwise open to recordings view - direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}" - ttl = 3600 if state == "end" else 0 + direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}" + ttl = 3600 if ended else 0 logger.debug(f"Sending push notification for {camera}, review ID {reviewId}") @@ -354,6 +404,53 @@ class WebPushClient(Communicator): # type: ignore[misc] self.cleanup_registrations() + def send_trigger(self, payload: dict[str, Any]) -> None: + if not self.config.notifications.email: + return + + camera: str = payload["camera"] + camera_name: str = getattr( + self.config.cameras[camera], "friendly_name", None + ) or titlecase(camera.replace("_", " ")) + current_time = datetime.datetime.now().timestamp() + + if self._within_cooldown(camera): + return + + self.check_registrations() + + self.last_camera_notification_time[camera] = current_time + self.last_notification_time = current_time + + trigger_type = payload["type"] + event_id = payload["event_id"] + name = payload["name"] + score = payload["score"] + + title = f"{name.replace('_', ' ')} triggered on {camera_name}" + message = f"{titlecase(trigger_type)} trigger fired for {camera_name} with score {score:.2f}" + image = f"clips/triggers/{camera}/{event_id}.webp" + + direct_url = f"/explore?event_id={event_id}" + ttl = 0 + + logger.debug( + f"Sending push notification for {camera_name}, trigger name {name}" + ) + + for user in self.web_pushers: + self.send_push_notification( + user=user, + payload=payload, + title=title, + message=message, + direct_url=direct_url, + image=image, + ttl=ttl, + ) + + self.cleanup_registrations() + def stop(self) -> None: logger.info("Closing notification queue") self.notification_thread.join() diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index 1eed290f77..6cfe4ecc0b 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -4,7 +4,7 @@ import errno import json import logging import threading -from typing import Callable +from typing import Any, Callable from wsgiref.simple_server import make_server from ws4py.server.wsgirefserver import ( @@ -21,8 +21,8 @@ from frigate.config import FrigateConfig logger = logging.getLogger(__name__) -class WebSocket(WebSocket_): - def unhandled_error(self, error): +class WebSocket(WebSocket_): # type: ignore[misc] + def unhandled_error(self, error: Any) -> None: """ Handles the unfriendly socket closures on the server side without showing a confusing error message @@ -33,12 +33,12 @@ class WebSocket(WebSocket_): logging.getLogger("ws4py").exception("Failed to receive data") -class WebSocketClient(Communicator): # type: ignore[misc] +class WebSocketClient(Communicator): """Frigate wrapper for ws client.""" def __init__(self, config: FrigateConfig) -> None: self.config = config - self.websocket_server = None + self.websocket_server: WSGIServer | None = None def subscribe(self, receiver: Callable) -> None: self._dispatcher = receiver @@ -47,10 +47,10 @@ class WebSocketClient(Communicator): # type: ignore[misc] def start(self) -> None: """Start the websocket client.""" - class _WebSocketHandler(WebSocket): # type: ignore[misc] + class _WebSocketHandler(WebSocket): receiver = self._dispatcher - def received_message(self, message: WebSocket.received_message) -> None: + def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined] try: json_message = json.loads(message.data.decode("utf-8")) json_message = { @@ -86,7 +86,7 @@ class WebSocketClient(Communicator): # type: ignore[misc] ) self.websocket_thread.start() - def publish(self, topic: str, payload: str, _: bool) -> None: + def publish(self, topic: str, payload: Any, _: bool = False) -> None: try: ws_message = json.dumps( { @@ -109,9 +109,11 @@ class WebSocketClient(Communicator): # type: ignore[misc] pass def stop(self) -> None: - self.websocket_server.manager.close_all() - self.websocket_server.manager.stop() - self.websocket_server.manager.join() - self.websocket_server.shutdown() + if self.websocket_server is not None: + self.websocket_server.manager.close_all() + self.websocket_server.manager.stop() + self.websocket_server.manager.join() + self.websocket_server.shutdown() + self.websocket_thread.join() logger.info("Exiting websocket client...") diff --git a/frigate/comms/zmq_proxy.py b/frigate/comms/zmq_proxy.py index d26da3312e..29329ec595 100644 --- a/frigate/comms/zmq_proxy.py +++ b/frigate/comms/zmq_proxy.py @@ -2,7 +2,7 @@ import json import threading -from typing import Any, Optional +from typing import Generic, TypeVar import zmq @@ -47,7 +47,10 @@ class ZmqProxy: self.runner.join() -class Publisher: +T = TypeVar("T") + + +class Publisher(Generic[T]): """Publishes messages.""" topic_base: str = "" @@ -58,7 +61,7 @@ class Publisher: self.socket = self.context.socket(zmq.PUB) self.socket.connect(SOCKET_PUB) - def publish(self, payload: Any, sub_topic: str = "") -> None: + def publish(self, payload: T, sub_topic: str = "") -> None: """Publish message.""" self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}") @@ -67,7 +70,7 @@ class Publisher: self.context.destroy() -class Subscriber: +class Subscriber(Generic[T]): """Receives messages.""" topic_base: str = "" @@ -79,9 +82,7 @@ class Subscriber: self.socket.setsockopt_string(zmq.SUBSCRIBE, self.topic) self.socket.connect(SOCKET_SUB) - def check_for_update( - self, timeout: float = FAST_QUEUE_TIMEOUT - ) -> Optional[tuple[str, Any]]: + def check_for_update(self, timeout: float | None = FAST_QUEUE_TIMEOUT) -> T | None: """Returns message or None if no update.""" try: has_update, _, _ = zmq.select([self.socket], [], [], timeout) @@ -98,5 +99,5 @@ class Subscriber: self.socket.close() self.context.destroy() - def _return_object(self, topic: str, payload: Any) -> Any: + def _return_object(self, topic: str, payload: T | None) -> T | None: return payload diff --git a/frigate/config/auth.py b/frigate/config/auth.py index a202fb1af6..fd5d0e3945 100644 --- a/frigate/config/auth.py +++ b/frigate/config/auth.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import Dict, List, Optional -from pydantic import Field +from pydantic import Field, field_validator, model_validator from .base import FrigateBaseModel @@ -34,3 +34,41 @@ class AuthConfig(FrigateBaseModel): ) # As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256 hash_iterations: int = Field(default=600000, title="Password hash iterations") + roles: Dict[str, List[str]] = Field( + default_factory=dict, + title="Role to camera mappings. Empty list grants access to all cameras.", + ) + + @field_validator("roles") + @classmethod + def validate_roles(cls, v: Dict[str, List[str]]) -> Dict[str, List[str]]: + # Ensure role names are valid (alphanumeric with underscores) + for role in v.keys(): + if not role.replace("_", "").isalnum(): + raise ValueError( + f"Invalid role name '{role}'. Must be alphanumeric with underscores." + ) + + # Ensure 'admin' and 'viewer' are not used as custom role names + reserved_roles = {"admin", "viewer"} + if v.keys() & reserved_roles: + raise ValueError( + f"Reserved roles {reserved_roles} cannot be used as custom roles." + ) + + # Ensure no role has an empty camera list + for role, allowed_cameras in v.items(): + if not allowed_cameras: + raise ValueError( + f"Role '{role}' has no cameras assigned. Custom roles must have at least one camera." + ) + + return v + + @model_validator(mode="after") + def ensure_default_roles(self): + # Ensure admin and viewer are never overridden + self.roles["admin"] = [] + self.roles["viewer"] = [] + + return self diff --git a/frigate/config/base.py b/frigate/config/base.py index 068a68acd5..1e369e2938 100644 --- a/frigate/config/base.py +++ b/frigate/config/base.py @@ -1,5 +1,29 @@ +from typing import Any + from pydantic import BaseModel, ConfigDict class FrigateBaseModel(BaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) + + def get_nested_object(self, path: str) -> Any: + parts = path.split("/") + obj = self + for part in parts: + if part == "config": + continue + + if isinstance(obj, BaseModel): + try: + obj = getattr(obj, part) + except AttributeError: + return None + elif isinstance(obj, dict): + try: + obj = obj[part] + except KeyError: + return None + else: + return None + + return obj diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 3b24dabac6..68f874138b 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -2,7 +2,7 @@ import os from enum import Enum from typing import Optional -from pydantic import Field, PrivateAttr +from pydantic import Field, PrivateAttr, model_validator from frigate.const import CACHE_DIR, CACHE_SEGMENT_FORMAT, REGEX_CAMERA_NAME from frigate.ffmpeg_presets import ( @@ -19,14 +19,15 @@ from frigate.util.builtin import ( from ..base import FrigateBaseModel from ..classification import ( + AudioTranscriptionConfig, CameraFaceRecognitionConfig, CameraLicensePlateRecognitionConfig, + CameraSemanticSearchConfig, ) from .audio import AudioConfig from .birdseye import BirdseyeCameraConfig from .detect import DetectConfig from .ffmpeg import CameraFfmpegConfig, CameraInput -from .genai import GenAICameraConfig from .live import CameraLiveConfig from .motion import MotionConfig from .mqtt import CameraMqttConfig @@ -50,12 +51,27 @@ class CameraTypeEnum(str, Enum): class CameraConfig(FrigateBaseModel): name: Optional[str] = Field(None, title="Camera name.", pattern=REGEX_CAMERA_NAME) + + friendly_name: Optional[str] = Field( + None, title="Camera friendly name used in the Frigate UI." + ) + + @model_validator(mode="before") + @classmethod + def handle_friendly_name(cls, values): + if isinstance(values, dict) and "friendly_name" in values: + pass + return values + enabled: bool = Field(default=True, title="Enable camera.") # Options with global fallback audio: AudioConfig = Field( default_factory=AudioConfig, title="Audio events configuration." ) + audio_transcription: AudioTranscriptionConfig = Field( + default_factory=AudioTranscriptionConfig, title="Audio transcription config." + ) birdseye: BirdseyeCameraConfig = Field( default_factory=BirdseyeCameraConfig, title="Birdseye camera configuration." ) @@ -66,18 +82,13 @@ class CameraConfig(FrigateBaseModel): default_factory=CameraFaceRecognitionConfig, title="Face recognition config." ) ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.") - genai: GenAICameraConfig = Field( - default_factory=GenAICameraConfig, title="Generative AI configuration." - ) live: CameraLiveConfig = Field( default_factory=CameraLiveConfig, title="Live playback settings." ) lpr: CameraLicensePlateRecognitionConfig = Field( default_factory=CameraLicensePlateRecognitionConfig, title="LPR config." ) - motion: Optional[MotionConfig] = Field( - None, title="Motion detection configuration." - ) + motion: MotionConfig = Field(None, title="Motion detection configuration.") objects: ObjectConfig = Field( default_factory=ObjectConfig, title="Object configuration." ) @@ -87,6 +98,10 @@ class CameraConfig(FrigateBaseModel): review: ReviewConfig = Field( default_factory=ReviewConfig, title="Review configuration." ) + semantic_search: CameraSemanticSearchConfig = Field( + default_factory=CameraSemanticSearchConfig, + title="Semantic search configuration.", + ) snapshots: SnapshotsConfig = Field( default_factory=SnapshotsConfig, title="Snapshot configuration." ) diff --git a/frigate/config/camera/detect.py b/frigate/config/camera/detect.py index 99e02c2c87..1926f32542 100644 --- a/frigate/config/camera/detect.py +++ b/frigate/config/camera/detect.py @@ -29,6 +29,10 @@ class StationaryConfig(FrigateBaseModel): default_factory=StationaryMaxFramesConfig, title="Max frames for stationary objects.", ) + classifier: bool = Field( + default=True, + title="Enable visual classifier for determing if objects with jittery bounding boxes are stationary.", + ) class DetectConfig(FrigateBaseModel): diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index 6ef93682be..3c6baeb150 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -1,12 +1,12 @@ from enum import Enum -from typing import Optional, Union +from typing import Any, Optional -from pydantic import BaseModel, Field, field_validator +from pydantic import Field from ..base import FrigateBaseModel from ..env import EnvString -__all__ = ["GenAIConfig", "GenAICameraConfig", "GenAIProviderEnum"] +__all__ = ["GenAIConfig", "GenAIProviderEnum"] class GenAIProviderEnum(str, Enum): @@ -16,70 +16,13 @@ class GenAIProviderEnum(str, Enum): ollama = "ollama" -class GenAISendTriggersConfig(BaseModel): - tracked_object_end: bool = Field( - default=True, title="Send once the object is no longer tracked." - ) - after_significant_updates: Optional[int] = Field( - default=None, - title="Send an early request to generative AI when X frames accumulated.", - ge=1, - ) - - -# uses BaseModel because some global attributes are not available at the camera level -class GenAICameraConfig(BaseModel): - enabled: bool = Field(default=False, title="Enable GenAI for camera.") - use_snapshot: bool = Field( - default=False, title="Use snapshots for generating descriptions." - ) - prompt: str = Field( - default="Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", - title="Default caption prompt.", - ) - object_prompts: dict[str, str] = Field( - default_factory=dict, title="Object specific prompts." - ) - - objects: Union[str, list[str]] = Field( - default_factory=list, - title="List of objects to run generative AI for.", - ) - required_zones: Union[str, list[str]] = Field( - default_factory=list, - title="List of required zones to be entered in order to run generative AI.", - ) - debug_save_thumbnails: bool = Field( - default=False, - title="Save thumbnails sent to generative AI for debugging purposes.", - ) - send_triggers: GenAISendTriggersConfig = Field( - default_factory=GenAISendTriggersConfig, - title="What triggers to use to send frames to generative AI for a tracked object.", - ) - - @field_validator("required_zones", mode="before") - @classmethod - def validate_required_zones(cls, v): - if isinstance(v, str) and "," not in v: - return [v] - - return v - - class GenAIConfig(FrigateBaseModel): - enabled: bool = Field(default=False, title="Enable GenAI.") - prompt: str = Field( - default="Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", - title="Default caption prompt.", - ) - object_prompts: dict[str, str] = Field( - default_factory=dict, title="Object specific prompts." - ) + """Primary GenAI Config to define GenAI Provider.""" api_key: Optional[EnvString] = Field(default=None, title="Provider API key.") base_url: Optional[str] = Field(default=None, title="Provider base url.") model: str = Field(default="gpt-4o", title="GenAI model.") - provider: GenAIProviderEnum = Field( - default=GenAIProviderEnum.openai, title="GenAI provider." + provider: GenAIProviderEnum | None = Field(default=None, title="GenAI provider.") + provider_options: dict[str, Any] = Field( + default={}, title="GenAI Provider extra options." ) diff --git a/frigate/config/camera/notification.py b/frigate/config/camera/notification.py index b0d7cebf95..ce1ac8223c 100644 --- a/frigate/config/camera/notification.py +++ b/frigate/config/camera/notification.py @@ -10,7 +10,7 @@ __all__ = ["NotificationConfig"] class NotificationConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable notifications") email: Optional[str] = Field(default=None, title="Email required for push.") - cooldown: Optional[int] = Field( + cooldown: int = Field( default=0, ge=0, title="Cooldown period for notifications (time in seconds)." ) enabled_in_config: Optional[bool] = Field( diff --git a/frigate/config/camera/objects.py b/frigate/config/camera/objects.py index 0d559b6ce0..7b6317dd03 100644 --- a/frigate/config/camera/objects.py +++ b/frigate/config/camera/objects.py @@ -1,10 +1,10 @@ from typing import Any, Optional, Union -from pydantic import Field, PrivateAttr, field_serializer +from pydantic import Field, PrivateAttr, field_serializer, field_validator from ..base import FrigateBaseModel -__all__ = ["ObjectConfig", "FilterConfig"] +__all__ = ["ObjectConfig", "GenAIObjectConfig", "FilterConfig"] DEFAULT_TRACKED_OBJECTS = ["person"] @@ -49,12 +49,69 @@ class FilterConfig(FrigateBaseModel): return None +class GenAIObjectTriggerConfig(FrigateBaseModel): + tracked_object_end: bool = Field( + default=True, title="Send once the object is no longer tracked." + ) + after_significant_updates: Optional[int] = Field( + default=None, + title="Send an early request to generative AI when X frames accumulated.", + ge=1, + ) + + +class GenAIObjectConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable GenAI for camera.") + use_snapshot: bool = Field( + default=False, title="Use snapshots for generating descriptions." + ) + prompt: str = Field( + default="Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", + title="Default caption prompt.", + ) + object_prompts: dict[str, str] = Field( + default_factory=dict, title="Object specific prompts." + ) + + objects: Union[str, list[str]] = Field( + default_factory=list, + title="List of objects to run generative AI for.", + ) + required_zones: Union[str, list[str]] = Field( + default_factory=list, + title="List of required zones to be entered in order to run generative AI.", + ) + debug_save_thumbnails: bool = Field( + default=False, + title="Save thumbnails sent to generative AI for debugging purposes.", + ) + send_triggers: GenAIObjectTriggerConfig = Field( + default_factory=GenAIObjectTriggerConfig, + title="What triggers to use to send frames to generative AI for a tracked object.", + ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of generative AI." + ) + + @field_validator("required_zones", mode="before") + @classmethod + def validate_required_zones(cls, v): + if isinstance(v, str) and "," not in v: + return [v] + + return v + + class ObjectConfig(FrigateBaseModel): track: list[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.") filters: dict[str, FilterConfig] = Field( default_factory=dict, title="Object filters." ) mask: Union[str, list[str]] = Field(default="", title="Object mask.") + genai: GenAIObjectConfig = Field( + default_factory=GenAIObjectConfig, + title="Config for using genai to analyze objects.", + ) _all_objects: list[str] = PrivateAttr() @property diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index 52d11e2a58..09a7a84d5b 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -22,27 +22,31 @@ __all__ = [ DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" +class RecordRetainConfig(FrigateBaseModel): + days: float = Field(default=0, ge=0, title="Default retention period.") + + class RetainModeEnum(str, Enum): all = "all" motion = "motion" active_objects = "active_objects" -class RecordRetainConfig(FrigateBaseModel): - days: float = Field(default=0, title="Default retention period.") - mode: RetainModeEnum = Field(default=RetainModeEnum.all, title="Retain mode.") - - class ReviewRetainConfig(FrigateBaseModel): - days: float = Field(default=10, title="Default retention period.") + days: float = Field(default=10, ge=0, title="Default retention period.") mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.") class EventsConfig(FrigateBaseModel): pre_capture: int = Field( - default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE + default=5, + title="Seconds to retain before event starts.", + le=MAX_PRE_CAPTURE, + ge=0, + ) + post_capture: int = Field( + default=5, ge=0, title="Seconds to retain after event ends." ) - post_capture: int = Field(default=5, title="Seconds to retain after event ends.") retain: ReviewRetainConfig = Field( default_factory=ReviewRetainConfig, title="Event retention settings." ) @@ -77,8 +81,12 @@ class RecordConfig(FrigateBaseModel): default=60, title="Number of minutes to wait between cleanup runs.", ) - retain: RecordRetainConfig = Field( - default_factory=RecordRetainConfig, title="Record retention settings." + continuous: RecordRetainConfig = Field( + default_factory=RecordRetainConfig, + title="Continuous recording retention settings.", + ) + motion: RecordRetainConfig = Field( + default_factory=RecordRetainConfig, title="Motion recording retention settings." ) detections: EventsConfig = Field( default_factory=EventsConfig, title="Detection specific retention settings." diff --git a/frigate/config/camera/review.py b/frigate/config/camera/review.py index d8d26edb93..17f97efd18 100644 --- a/frigate/config/camera/review.py +++ b/frigate/config/camera/review.py @@ -26,6 +26,10 @@ class AlertsConfig(FrigateBaseModel): enabled_in_config: Optional[bool] = Field( default=None, title="Keep track of original state of alerts." ) + cutoff_time: int = Field( + default=40, + title="Time to cutoff alerts after no alert-causing activity has occurred.", + ) @field_validator("required_zones", mode="before") @classmethod @@ -48,6 +52,10 @@ class DetectionsConfig(FrigateBaseModel): default_factory=list, title="List of required zones to be entered in order to save the event as a detection.", ) + cutoff_time: int = Field( + default=30, + title="Time to cutoff detection after no detection-causing activity has occurred.", + ) enabled_in_config: Optional[bool] = Field( default=None, title="Keep track of original state of detections." @@ -62,6 +70,30 @@ class DetectionsConfig(FrigateBaseModel): return v +class GenAIReviewConfig(FrigateBaseModel): + enabled: bool = Field( + default=False, + title="Enable GenAI descriptions for review items.", + ) + alerts: bool = Field(default=True, title="Enable GenAI for alerts.") + detections: bool = Field(default=False, title="Enable GenAI for detections.") + additional_concerns: list[str] = Field( + default=[], + title="Additional concerns that GenAI should make note of on this camera.", + ) + debug_save_thumbnails: bool = Field( + default=False, + title="Save thumbnails sent to generative AI for debugging purposes.", + ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of generative AI." + ) + preferred_language: str | None = Field( + title="Preferred language for GenAI Response", + default=None, + ) + + class ReviewConfig(FrigateBaseModel): """Configure reviews""" @@ -71,3 +103,6 @@ class ReviewConfig(FrigateBaseModel): detections: DetectionsConfig = Field( default_factory=DetectionsConfig, title="Review detections config." ) + genai: GenAIReviewConfig = Field( + default_factory=GenAIReviewConfig, title="Review description genai config." + ) diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py new file mode 100644 index 0000000000..125094f107 --- /dev/null +++ b/frigate/config/camera/updater.py @@ -0,0 +1,147 @@ +"""Convenience classes for updating configurations dynamically.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from frigate.comms.config_updater import ConfigPublisher, ConfigSubscriber +from frigate.config import CameraConfig, FrigateConfig + + +class CameraConfigUpdateEnum(str, Enum): + """Supported camera config update types.""" + + add = "add" # for adding a camera + audio = "audio" + audio_transcription = "audio_transcription" + birdseye = "birdseye" + detect = "detect" + enabled = "enabled" + motion = "motion" # includes motion and motion masks + notifications = "notifications" + objects = "objects" + object_genai = "object_genai" + record = "record" + remove = "remove" # for removing a camera + review = "review" + review_genai = "review_genai" + semantic_search = "semantic_search" # for semantic search triggers + snapshots = "snapshots" + zones = "zones" + + +@dataclass +class CameraConfigUpdateTopic: + update_type: CameraConfigUpdateEnum + camera: str + + @property + def topic(self) -> str: + return f"config/cameras/{self.camera}/{self.update_type.name}" + + +class CameraConfigUpdatePublisher: + def __init__(self): + self.publisher = ConfigPublisher() + + def publish_update(self, topic: CameraConfigUpdateTopic, config: Any) -> None: + self.publisher.publish(topic.topic, config) + + def stop(self) -> None: + self.publisher.stop() + + +class CameraConfigUpdateSubscriber: + def __init__( + self, + config: FrigateConfig | None, + camera_configs: dict[str, CameraConfig], + topics: list[CameraConfigUpdateEnum], + ): + self.config = config + self.camera_configs = camera_configs + self.topics = topics + + base_topic = "config/cameras" + + if len(self.camera_configs) == 1: + base_topic += f"/{list(self.camera_configs.keys())[0]}" + + self.subscriber = ConfigSubscriber( + base_topic, + exact=False, + ) + + def __update_config( + self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any + ) -> None: + if update_type == CameraConfigUpdateEnum.add: + self.config.cameras[camera] = updated_config + self.camera_configs[camera] = updated_config + return + elif update_type == CameraConfigUpdateEnum.remove: + self.config.cameras.pop(camera) + self.camera_configs.pop(camera) + return + + config = self.camera_configs.get(camera) + + if not config: + return + + if update_type == CameraConfigUpdateEnum.audio: + config.audio = updated_config + elif update_type == CameraConfigUpdateEnum.audio_transcription: + config.audio_transcription = updated_config + elif update_type == CameraConfigUpdateEnum.birdseye: + config.birdseye = updated_config + elif update_type == CameraConfigUpdateEnum.detect: + config.detect = updated_config + elif update_type == CameraConfigUpdateEnum.enabled: + config.enabled = updated_config + elif update_type == CameraConfigUpdateEnum.object_genai: + config.objects.genai = updated_config + elif update_type == CameraConfigUpdateEnum.motion: + config.motion = updated_config + elif update_type == CameraConfigUpdateEnum.notifications: + config.notifications = updated_config + elif update_type == CameraConfigUpdateEnum.objects: + config.objects = updated_config + elif update_type == CameraConfigUpdateEnum.record: + config.record = updated_config + elif update_type == CameraConfigUpdateEnum.review: + config.review = updated_config + elif update_type == CameraConfigUpdateEnum.review_genai: + config.review.genai = updated_config + elif update_type == CameraConfigUpdateEnum.semantic_search: + config.semantic_search = updated_config + elif update_type == CameraConfigUpdateEnum.snapshots: + config.snapshots = updated_config + elif update_type == CameraConfigUpdateEnum.zones: + config.zones = updated_config + + def check_for_updates(self) -> dict[str, list[str]]: + updated_topics: dict[str, list[str]] = {} + + # get all updates available + while True: + update_topic, update_config = self.subscriber.check_for_update() + + if update_topic is None or update_config is None: + break + + _, _, camera, raw_type = update_topic.split("/") + update_type = CameraConfigUpdateEnum[raw_type] + + if update_type in self.topics: + if update_type.name in updated_topics: + updated_topics[update_type.name].append(camera) + else: + updated_topics[update_type.name] = [camera] + + self.__update_config(camera, update_type, update_config) + + return updated_topics + + def stop(self) -> None: + self.subscriber.stop() diff --git a/frigate/config/classification.py b/frigate/config/classification.py index 06e69a774a..98e0db0466 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -10,6 +10,7 @@ __all__ = [ "CameraLicensePlateRecognitionConfig", "FaceRecognitionConfig", "SemanticSearchConfig", + "CameraSemanticSearchConfig", "LicensePlateRecognitionConfig", ] @@ -19,11 +20,46 @@ class SemanticSearchModelEnum(str, Enum): jinav2 = "jinav2" -class LPRDeviceEnum(str, Enum): +class EnrichmentsDeviceEnum(str, Enum): GPU = "GPU" CPU = "CPU" +class TriggerType(str, Enum): + THUMBNAIL = "thumbnail" + DESCRIPTION = "description" + + +class TriggerAction(str, Enum): + NOTIFICATION = "notification" + + +class ObjectClassificationType(str, Enum): + sub_label = "sub_label" + attribute = "attribute" + + +class AudioTranscriptionConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable audio transcription.") + language: str = Field( + default="en", + title="Language abbreviation to use for audio event transcription/translation.", + ) + device: Optional[EnrichmentsDeviceEnum] = Field( + default=EnrichmentsDeviceEnum.CPU, + title="The device used for license plate recognition.", + ) + model_size: str = Field( + default="small", title="The size of the embeddings model used." + ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of camera." + ) + live_enabled: Optional[bool] = Field( + default=False, title="Enable live transcriptions." + ) + + class BirdClassificationConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable bird classification.") threshold: float = Field( @@ -34,10 +70,52 @@ class BirdClassificationConfig(FrigateBaseModel): ) +class CustomClassificationStateCameraConfig(FrigateBaseModel): + crop: list[int, int, int, int] = Field( + title="Crop of image frame on this camera to run classification on." + ) + + +class CustomClassificationStateConfig(FrigateBaseModel): + cameras: Dict[str, CustomClassificationStateCameraConfig] = Field( + title="Cameras to run classification on." + ) + motion: bool = Field( + default=False, + title="If classification should be run when motion is detected in the crop.", + ) + interval: int | None = Field( + default=None, + title="Interval to run classification on in seconds.", + gt=0, + ) + + +class CustomClassificationObjectConfig(FrigateBaseModel): + objects: list[str] = Field(title="Object types to classify.") + classification_type: ObjectClassificationType = Field( + default=ObjectClassificationType.sub_label, + title="Type of classification that is applied.", + ) + + +class CustomClassificationConfig(FrigateBaseModel): + enabled: bool = Field(default=True, title="Enable running the model.") + name: str | None = Field(default=None, title="Name of classification model.") + threshold: float = Field( + default=0.8, title="Classification score threshold to change the state." + ) + object_config: CustomClassificationObjectConfig | None = Field(default=None) + state_config: CustomClassificationStateConfig | None = Field(default=None) + + class ClassificationConfig(FrigateBaseModel): bird: BirdClassificationConfig = Field( default_factory=BirdClassificationConfig, title="Bird classification config." ) + custom: Dict[str, CustomClassificationConfig] = Field( + default={}, title="Custom Classification Model Configs." + ) class SemanticSearchConfig(FrigateBaseModel): @@ -52,6 +130,37 @@ class SemanticSearchConfig(FrigateBaseModel): model_size: str = Field( default="small", title="The size of the embeddings model used." ) + device: Optional[str] = Field( + default=None, + title="The device key to use for semantic search.", + description="This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", + ) + + +class TriggerConfig(FrigateBaseModel): + enabled: bool = Field(default=True, title="Enable this trigger") + type: TriggerType = Field(default=TriggerType.DESCRIPTION, title="Type of trigger") + data: str = Field(title="Trigger content (text phrase or image ID)") + threshold: float = Field( + title="Confidence score required to run the trigger", + default=0.8, + gt=0.0, + le=1.0, + ) + actions: List[TriggerAction] = Field( + default=[], title="Actions to perform when trigger is matched" + ) + + model_config = ConfigDict(extra="forbid", protected_namespaces=()) + + +class CameraSemanticSearchConfig(FrigateBaseModel): + triggers: Dict[str, TriggerConfig] = Field( + default={}, + title="Trigger actions on tracked objects that match existing thumbnails or descriptions", + ) + + model_config = ConfigDict(extra="forbid", protected_namespaces=()) class FaceRecognitionConfig(FrigateBaseModel): @@ -92,6 +201,11 @@ class FaceRecognitionConfig(FrigateBaseModel): blur_confidence_filter: bool = Field( default=True, title="Apply blur quality filter to face confidence." ) + device: Optional[str] = Field( + default=None, + title="The device key to use for face recognition.", + description="This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", + ) class CameraFaceRecognitionConfig(FrigateBaseModel): @@ -103,12 +217,15 @@ class CameraFaceRecognitionConfig(FrigateBaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) +class ReplaceRule(FrigateBaseModel): + pattern: str = Field(..., title="Regex pattern to match.") + replacement: str = Field( + ..., title="Replacement string (supports backrefs like '\\1')." + ) + + class LicensePlateRecognitionConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable license plate recognition.") - device: Optional[LPRDeviceEnum] = Field( - default=LPRDeviceEnum.CPU, - title="The device used for license plate recognition.", - ) model_size: str = Field( default="small", title="The size of the embeddings model used." ) @@ -154,6 +271,15 @@ class LicensePlateRecognitionConfig(FrigateBaseModel): default=False, title="Save plates captured for LPR for debugging purposes.", ) + device: Optional[str] = Field( + default=None, + title="The device key to use for LPR.", + description="This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", + ) + replace_rules: List[ReplaceRule] = Field( + default_factory=list, + title="List of regex replacement rules for normalizing detected plates. Each rule has 'pattern' and 'replacement'.", + ) class CameraLicensePlateRecognitionConfig(FrigateBaseModel): diff --git a/frigate/config/config.py b/frigate/config/config.py index 6ec048acd1..23f77aa9d8 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -48,12 +48,13 @@ from .camera.genai import GenAIConfig from .camera.motion import MotionConfig from .camera.notification import NotificationConfig from .camera.objects import FilterConfig, ObjectConfig -from .camera.record import RecordConfig, RetainModeEnum +from .camera.record import RecordConfig from .camera.review import ReviewConfig from .camera.snapshots import SnapshotsConfig from .camera.timestamp import TimestampStyleConfig from .camera_group import CameraGroupConfig from .classification import ( + AudioTranscriptionConfig, ClassificationConfig, FaceRecognitionConfig, LicensePlateRecognitionConfig, @@ -63,6 +64,7 @@ from .database import DatabaseConfig from .env import EnvVars from .logger import LoggerConfig from .mqtt import MqttConfig +from .network import NetworkingConfig from .proxy import ProxyConfig from .telemetry import TelemetryConfig from .tls import TlsConfig @@ -203,33 +205,6 @@ def verify_valid_live_stream_names( ) -def verify_recording_retention(camera_config: CameraConfig) -> None: - """Verify that recording retention modes are ranked correctly.""" - rank_map = { - RetainModeEnum.all: 0, - RetainModeEnum.motion: 1, - RetainModeEnum.active_objects: 2, - } - - if ( - camera_config.record.retain.days != 0 - and rank_map[camera_config.record.retain.mode] - > rank_map[camera_config.record.alerts.retain.mode] - ): - logger.warning( - f"{camera_config.name}: Recording retention is configured for {camera_config.record.retain.mode} and alert retention is configured for {camera_config.record.alerts.retain.mode}. The more restrictive retention policy will be applied." - ) - - if ( - camera_config.record.retain.days != 0 - and rank_map[camera_config.record.retain.mode] - > rank_map[camera_config.record.detections.retain.mode] - ): - logger.warning( - f"{camera_config.name}: Recording retention is configured for {camera_config.record.retain.mode} and detection retention is configured for {camera_config.record.detections.retain.mode}. The more restrictive retention policy will be applied." - ) - - def verify_recording_segments_setup_with_reasonable_time( camera_config: CameraConfig, ) -> None: @@ -334,6 +309,9 @@ def verify_lpr_and_face( class FrigateConfig(FrigateBaseModel): version: Optional[str] = Field(default=None, title="Current config version.") + safe_mode: bool = Field( + default=False, title="If Frigate should be started in safe mode." + ) # Fields that install global state should be defined first, so that their validators run first. environment_vars: EnvVars = Field( @@ -357,6 +335,9 @@ class FrigateConfig(FrigateBaseModel): notifications: NotificationConfig = Field( default_factory=NotificationConfig, title="Global notification configuration." ) + networking: NetworkingConfig = Field( + default_factory=NetworkingConfig, title="Networking configuration" + ) proxy: ProxyConfig = Field( default_factory=ProxyConfig, title="Proxy configuration." ) @@ -375,6 +356,11 @@ class FrigateConfig(FrigateBaseModel): default_factory=ModelConfig, title="Detection model configuration." ) + # GenAI config + genai: GenAIConfig = Field( + default_factory=GenAIConfig, title="Generative AI configuration." + ) + # Camera config cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.") audio: AudioConfig = Field( @@ -389,9 +375,6 @@ class FrigateConfig(FrigateBaseModel): ffmpeg: FfmpegConfig = Field( default_factory=FfmpegConfig, title="Global FFmpeg configuration." ) - genai: GenAIConfig = Field( - default_factory=GenAIConfig, title="Generative AI configuration." - ) live: CameraLiveConfig = Field( default_factory=CameraLiveConfig, title="Live playback settings." ) @@ -416,6 +399,9 @@ class FrigateConfig(FrigateBaseModel): ) # Classification Config + audio_transcription: AudioTranscriptionConfig = Field( + default_factory=AudioTranscriptionConfig, title="Audio transcription config." + ) classification: ClassificationConfig = Field( default_factory=ClassificationConfig, title="Object classification config." ) @@ -469,6 +455,7 @@ class FrigateConfig(FrigateBaseModel): global_config = self.model_dump( include={ "audio": ..., + "audio_transcription": ..., "birdseye": ..., "face_recognition": ..., "lpr": ..., @@ -477,7 +464,6 @@ class FrigateConfig(FrigateBaseModel): "live": ..., "objects": ..., "review": ..., - "genai": ..., "motion": ..., "notifications": ..., "detect": ..., @@ -506,7 +492,9 @@ class FrigateConfig(FrigateBaseModel): model_config["path"] = detector_config.model_path if "path" not in model_config: - if detector_config.type == "cpu": + if detector_config.type == "cpu" or detector_config.type.endswith( + "_tfl" + ): model_config["path"] = "/cpu_model.tflite" elif detector_config.type == "edgetpu": model_config["path"] = "/edgetpu_model.tflite" @@ -525,6 +513,7 @@ class FrigateConfig(FrigateBaseModel): allowed_fields_map = { "face_recognition": ["enabled", "min_area"], "lpr": ["enabled", "expire_time", "min_area", "enhancement"], + "audio_transcription": ["enabled", "live_enabled"], } for section in allowed_fields_map: @@ -606,6 +595,9 @@ class FrigateConfig(FrigateBaseModel): # set config pre-value camera_config.enabled_in_config = camera_config.enabled camera_config.audio.enabled_in_config = camera_config.audio.enabled + camera_config.audio_transcription.enabled_in_config = ( + camera_config.audio_transcription.enabled + ) camera_config.record.enabled_in_config = camera_config.record.enabled camera_config.notifications.enabled_in_config = ( camera_config.notifications.enabled @@ -619,6 +611,12 @@ class FrigateConfig(FrigateBaseModel): camera_config.review.detections.enabled_in_config = ( camera_config.review.detections.enabled ) + camera_config.objects.genai.enabled_in_config = ( + camera_config.objects.genai.enabled + ) + camera_config.review.genai.enabled_in_config = ( + camera_config.review.genai.enabled + ) # Add default filters object_keys = camera_config.objects.track @@ -685,7 +683,6 @@ class FrigateConfig(FrigateBaseModel): verify_config_roles(camera_config) verify_valid_live_stream_names(self, camera_config) - verify_recording_retention(camera_config) verify_recording_segments_setup_with_reasonable_time(camera_config) verify_zone_objects_are_tracked(camera_config) verify_required_zones_exist(camera_config) @@ -694,15 +691,46 @@ class FrigateConfig(FrigateBaseModel): verify_objects_track(camera_config, labelmap_objects) verify_lpr_and_face(self, camera_config) + # set names on classification configs + for name, config in self.classification.custom.items(): + config.name = name + self.objects.parse_all_objects(self.cameras) self.model.create_colormap(sorted(self.objects.all_objects)) self.model.check_and_load_plus_model(self.plus_api) + # Check audio transcription and audio detection requirements + if self.audio_transcription.enabled: + # If audio transcription is enabled globally, at least one camera must have audio detection enabled + if not any(camera.audio.enabled for camera in self.cameras.values()): + raise ValueError( + "Audio transcription is enabled globally, but no cameras have audio detection enabled. At least one camera must have audio detection enabled." + ) + else: + # If audio transcription is disabled globally, check each camera with audio_transcription enabled + for camera in self.cameras.values(): + if camera.audio_transcription.enabled and not camera.audio.enabled: + raise ValueError( + f"Camera {camera.name} has audio transcription enabled, but audio detection is not enabled for this camera. Audio detection must be enabled for cameras with audio transcription when it is disabled globally." + ) + if self.plus_api and not self.snapshots.clean_copy: logger.warning( "Frigate+ is configured but clean snapshots are not enabled, submissions to Frigate+ will not be possible./" ) + # Validate auth roles against cameras + camera_names = set(self.cameras.keys()) + + for role, allowed_cameras in self.auth.roles.items(): + invalid_cameras = [ + cam for cam in allowed_cameras if cam not in camera_names + ] + if invalid_cameras: + logger.warning( + f"Role '{role}' references non-existent cameras: {invalid_cameras}. " + ) + return self @field_validator("cameras") @@ -716,6 +744,7 @@ class FrigateConfig(FrigateBaseModel): @classmethod def load(cls, **kwargs): + """Loads the Frigate config file, runs migrations, and creates the config object.""" config_path = find_config_file() # No configuration file found, create one. @@ -743,7 +772,7 @@ class FrigateConfig(FrigateBaseModel): return FrigateConfig.parse(f, **kwargs) @classmethod - def parse(cls, config, *, is_json=None, **context): + def parse(cls, config, *, is_json=None, safe_load=False, **context): # If config is a file, read its contents. if hasattr(config, "read"): fname = getattr(config, "name", None) @@ -767,6 +796,15 @@ class FrigateConfig(FrigateBaseModel): else: config = yaml.load(config) + # load minimal Frigate config after the full config did not validate + if safe_load: + safe_config = {"safe_mode": True, "cameras": {}, "mqtt": {"enabled": False}} + + # copy over auth and proxy config in case auth needs to be enforced + safe_config["auth"] = config.get("auth", {}) + safe_config["proxy"] = config.get("proxy", {}) + return cls.parse_object(safe_config, **context) + # Validate and return the config dict. return cls.parse_object(config, **context) diff --git a/frigate/config/env.py b/frigate/config/env.py index 0a9b92e8f7..6534ff4114 100644 --- a/frigate/config/env.py +++ b/frigate/config/env.py @@ -5,12 +5,13 @@ from typing import Annotated from pydantic import AfterValidator, ValidationInfo FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")} -# read docker secret files as env vars too -if os.path.isdir("/run/secrets") and os.access("/run/secrets", os.R_OK): - for secret_file in os.listdir("/run/secrets"): +secrets_dir = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets") +# read secret files as env vars too +if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK): + for secret_file in os.listdir(secrets_dir): if secret_file.startswith("FRIGATE_"): FRIGATE_ENV_VARS[secret_file] = ( - Path(os.path.join("/run/secrets", secret_file)).read_text().strip() + Path(os.path.join(secrets_dir, secret_file)).read_text().strip() ) diff --git a/frigate/config/logger.py b/frigate/config/logger.py index e6e1c06d30..0ba3e6972d 100644 --- a/frigate/config/logger.py +++ b/frigate/config/logger.py @@ -1,20 +1,11 @@ -import logging -from enum import Enum - from pydantic import Field, ValidationInfo, model_validator from typing_extensions import Self +from frigate.log import LogLevel, apply_log_levels + from .base import FrigateBaseModel -__all__ = ["LoggerConfig", "LogLevel"] - - -class LogLevel(str, Enum): - debug = "debug" - info = "info" - warning = "warning" - error = "error" - critical = "critical" +__all__ = ["LoggerConfig"] class LoggerConfig(FrigateBaseModel): @@ -26,16 +17,6 @@ class LoggerConfig(FrigateBaseModel): @model_validator(mode="after") def post_validation(self, info: ValidationInfo) -> Self: if isinstance(info.context, dict) and info.context.get("install", False): - logging.getLogger().setLevel(self.default.value.upper()) - - log_levels = { - "httpx": LogLevel.error, - "werkzeug": LogLevel.error, - "ws4py": LogLevel.error, - **self.logs, - } - - for log, level in log_levels.items(): - logging.getLogger(log).setLevel(level.value.upper()) + apply_log_levels(self.default.value.upper(), self.logs) return self diff --git a/frigate/config/mqtt.py b/frigate/config/mqtt.py index cedd53734a..a760d0a1f5 100644 --- a/frigate/config/mqtt.py +++ b/frigate/config/mqtt.py @@ -30,7 +30,7 @@ class MqttConfig(FrigateBaseModel): ) tls_client_key: Optional[str] = Field(default=None, title="MQTT TLS Client Key") tls_insecure: Optional[bool] = Field(default=None, title="MQTT TLS Insecure") - qos: Optional[int] = Field(default=0, title="MQTT QoS") + qos: int = Field(default=0, title="MQTT QoS") @model_validator(mode="after") def user_requires_pass(self, info: ValidationInfo) -> Self: diff --git a/frigate/config/network.py b/frigate/config/network.py new file mode 100644 index 0000000000..c8b3cfd1c1 --- /dev/null +++ b/frigate/config/network.py @@ -0,0 +1,13 @@ +from pydantic import Field + +from .base import FrigateBaseModel + +__all__ = ["IPv6Config", "NetworkingConfig"] + + +class IPv6Config(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable IPv6 for port 5000 and/or 8971") + + +class NetworkingConfig(FrigateBaseModel): + ipv6: IPv6Config = Field(default_factory=IPv6Config, title="Network configuration") diff --git a/frigate/config/proxy.py b/frigate/config/proxy.py index 68bd400e72..a46b7b8973 100644 --- a/frigate/config/proxy.py +++ b/frigate/config/proxy.py @@ -16,6 +16,10 @@ class HeaderMappingConfig(FrigateBaseModel): default=None, title="Header name from upstream proxy to identify user role.", ) + role_map: Optional[dict[str, list[str]]] = Field( + default_factory=dict, + title=("Mapping of Frigate roles to upstream group values. "), + ) class ProxyConfig(FrigateBaseModel): diff --git a/frigate/const.py b/frigate/const.py index 699a194acd..5710966bf9 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -11,6 +11,7 @@ EXPORT_DIR = f"{BASE_DIR}/exports" FACE_DIR = f"{CLIPS_DIR}/faces" THUMB_DIR = f"{CLIPS_DIR}/thumbs" RECORD_DIR = f"{BASE_DIR}/recordings" +TRIGGER_DIR = f"{CLIPS_DIR}/triggers" BIRDSEYE_PIPE = "/tmp/cache/birdseye" CACHE_DIR = "/tmp/cache" FRIGATE_LOCALHOST = "http://127.0.0.1:5000" @@ -73,6 +74,7 @@ FFMPEG_HWACCEL_NVIDIA = "preset-nvidia" FFMPEG_HWACCEL_VAAPI = "preset-vaapi" FFMPEG_HWACCEL_VULKAN = "preset-vulkan" FFMPEG_HWACCEL_RKMPP = "preset-rkmpp" +FFMPEG_HWACCEL_AMF = "preset-amd-amf" FFMPEG_HVC1_ARGS = ["-tag:v", "hvc1"] # Regex constants @@ -109,11 +111,21 @@ REQUEST_REGION_GRID = "request_region_grid" UPSERT_REVIEW_SEGMENT = "upsert_review_segment" CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments" UPDATE_CAMERA_ACTIVITY = "update_camera_activity" +UPDATE_AUDIO_ACTIVITY = "update_audio_activity" +EXPIRE_AUDIO_ACTIVITY = "expire_audio_activity" UPDATE_EVENT_DESCRIPTION = "update_event_description" +UPDATE_REVIEW_DESCRIPTION = "update_review_description" UPDATE_MODEL_STATE = "update_model_state" UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress" +UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout" NOTIFICATION_TEST = "notification_test" +# IO Nice Values + +PROCESS_PRIORITY_HIGH = 0 +PROCESS_PRIORITY_MED = 10 +PROCESS_PRIORITY_LOW = 19 + # Stats Values FREQUENCY_STATS_POINTS = 15 diff --git a/frigate/data_processing/common/audio_transcription/model.py b/frigate/data_processing/common/audio_transcription/model.py new file mode 100644 index 0000000000..0fe5ddb5c0 --- /dev/null +++ b/frigate/data_processing/common/audio_transcription/model.py @@ -0,0 +1,81 @@ +"""Set up audio transcription models based on model size.""" + +import logging +import os + +import sherpa_onnx +from faster_whisper.utils import download_model + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.const import MODEL_CACHE_DIR +from frigate.data_processing.types import AudioTranscriptionModel +from frigate.util.downloader import ModelDownloader + +logger = logging.getLogger(__name__) + + +class AudioTranscriptionModelRunner: + def __init__( + self, + device: str = "CPU", + model_size: str = "small", + ): + self.model: AudioTranscriptionModel = None + self.requestor = InterProcessRequestor() + + if model_size == "large": + # use the Whisper download function instead of our own + logger.debug("Downloading Whisper audio transcription model") + download_model( + size_or_id="small" if device == "cuda" else "tiny", + local_files_only=False, + cache_dir=os.path.join(MODEL_CACHE_DIR, "whisper"), + ) + logger.debug("Whisper audio transcription model downloaded") + + else: + # small model as default + download_path = os.path.join(MODEL_CACHE_DIR, "sherpa-onnx") + HF_ENDPOINT = os.environ.get("HF_ENDPOINT", "https://huggingface.co") + self.model_files = { + "encoder.onnx": f"{HF_ENDPOINT}/csukuangfj/sherpa-onnx-streaming-zipformer-en-2023-06-26/resolve/main/encoder-epoch-99-avg-1-chunk-16-left-128.onnx", + "decoder.onnx": f"{HF_ENDPOINT}/csukuangfj/sherpa-onnx-streaming-zipformer-en-2023-06-26/resolve/main/decoder-epoch-99-avg-1-chunk-16-left-128.onnx", + "joiner.onnx": f"{HF_ENDPOINT}/csukuangfj/sherpa-onnx-streaming-zipformer-en-2023-06-26/resolve/main/joiner-epoch-99-avg-1-chunk-16-left-128.onnx", + "tokens.txt": f"{HF_ENDPOINT}/csukuangfj/sherpa-onnx-streaming-zipformer-en-2023-06-26/resolve/main/tokens.txt", + } + + if not all( + os.path.exists(os.path.join(download_path, n)) + for n in self.model_files.keys() + ): + self.downloader = ModelDownloader( + model_name="sherpa-onnx", + download_path=download_path, + file_names=self.model_files.keys(), + download_func=self.__download_models, + ) + self.downloader.ensure_model_files() + self.downloader.wait_for_download() + + self.model = sherpa_onnx.OnlineRecognizer.from_transducer( + tokens=os.path.join(MODEL_CACHE_DIR, "sherpa-onnx/tokens.txt"), + encoder=os.path.join(MODEL_CACHE_DIR, "sherpa-onnx/encoder.onnx"), + decoder=os.path.join(MODEL_CACHE_DIR, "sherpa-onnx/decoder.onnx"), + joiner=os.path.join(MODEL_CACHE_DIR, "sherpa-onnx/joiner.onnx"), + num_threads=2, + sample_rate=16000, + feature_dim=80, + enable_endpoint_detection=True, + rule1_min_trailing_silence=2.4, + rule2_min_trailing_silence=1.2, + rule3_min_utterance_length=300, + decoding_method="greedy_search", + provider="cpu", + ) + + def __download_models(self, path: str) -> None: + try: + file_name = os.path.basename(path) + ModelDownloader.download_from_url(self.model_files[file_name], path) + except Exception as e: + logger.error(f"Failed to download {path}: {e}") diff --git a/frigate/data_processing/common/face/model.py b/frigate/data_processing/common/face/model.py index aea6751a0c..68a5681c35 100644 --- a/frigate/data_processing/common/face/model.py +++ b/frigate/data_processing/common/face/model.py @@ -9,8 +9,9 @@ import numpy as np from scipy import stats from frigate.config import FrigateConfig -from frigate.const import MODEL_CACHE_DIR +from frigate.const import FACE_DIR, MODEL_CACHE_DIR from frigate.embeddings.onnx.face_embedding import ArcfaceEmbedding, FaceNetEmbedding +from frigate.log import redirect_output_to_logger logger = logging.getLogger(__name__) @@ -37,6 +38,7 @@ class FaceRecognizer(ABC): def classify(self, face_image: np.ndarray) -> tuple[str, float] | None: pass + @redirect_output_to_logger(logger, logging.DEBUG) def init_landmark_detector(self) -> None: landmark_model = os.path.join(MODEL_CACHE_DIR, "facedet/landmarkdet.yaml") @@ -170,7 +172,7 @@ class FaceNetRecognizer(FaceRecognizer): face_embeddings_map: dict[str, list[np.ndarray]] = {} idx = 0 - dir = "/media/frigate/clips/faces" + dir = FACE_DIR for name in os.listdir(dir): if name == "train": continue @@ -267,7 +269,7 @@ class ArcFaceRecognizer(FaceRecognizer): def __init__(self, config: FrigateConfig): super().__init__(config) self.mean_embs: dict[int, np.ndarray] = {} - self.face_embedder: ArcfaceEmbedding = ArcfaceEmbedding() + self.face_embedder: ArcfaceEmbedding = ArcfaceEmbedding(config.face_recognition) self.model_builder_queue: queue.Queue | None = None def clear(self) -> None: @@ -280,7 +282,7 @@ class ArcFaceRecognizer(FaceRecognizer): face_embeddings_map: dict[str, list[np.ndarray]] = {} idx = 0 - dir = "/media/frigate/clips/faces" + dir = FACE_DIR for name in os.listdir(dir): if name == "train": continue diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index 2c68ce374a..d2083afec3 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -22,7 +22,7 @@ from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, EventMetadataTypeEnum, ) -from frigate.const import CLIPS_DIR +from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR from frigate.embeddings.onnx.lpr_embedding import LPR_EMBEDDING_SIZE from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import EventsPerSecond, InferenceSpeed @@ -43,9 +43,20 @@ class LicensePlateProcessingMixin: self.plates_det_second = EventsPerSecond() self.plates_det_second.start() self.event_metadata_publisher = EventMetadataPublisher() - self.ctc_decoder = CTCDecoder() + self.ctc_decoder = CTCDecoder( + character_dict_path=os.path.join( + MODEL_CACHE_DIR, "paddleocr-onnx", "ppocr_keys_v1.txt" + ) + ) self.batch_size = 6 + # Object config + self.lp_objects: list[str] = [] + + for obj, attributes in self.config.model.attributes_map.items(): + if "license_plate" in attributes: + self.lp_objects.append(obj) + # Detection specific parameters self.min_size = 8 self.max_size = 960 @@ -54,6 +65,7 @@ class LicensePlateProcessingMixin: # matching self.similarity_threshold = 0.8 + self.cluster_threshold = 0.85 def _detect(self, image: np.ndarray) -> List[np.ndarray]: """ @@ -198,7 +210,7 @@ class LicensePlateProcessingMixin: boxes = self._detect(image) if len(boxes) == 0: - logger.debug("No boxes found by OCR detector model") + logger.debug(f"{camera}: No boxes found by OCR detector model") return [], [], [] if len(boxes) > 0: @@ -348,6 +360,27 @@ class LicensePlateProcessingMixin: conf for conf_list in qualifying_confidences for conf in conf_list ] + # Apply replace rules to combined_plate if configured + original_combined = combined_plate + if self.lpr_config.replace_rules: + for rule in self.lpr_config.replace_rules: + try: + pattern = getattr(rule, "pattern", "") + replacement = getattr(rule, "replacement", "") + if pattern: + combined_plate = re.sub( + pattern, replacement, combined_plate + ) + except re.error as e: + logger.warning( + f"{camera}: Invalid regex in replace_rules '{pattern}': {e}" + ) + + if combined_plate != original_combined: + logger.debug( + f"{camera}: Rules applied: '{original_combined}' -> '{combined_plate}'" + ) + # Compute the combined area for qualifying boxes qualifying_boxes = [boxes[i] for i in qualifying_indices] qualifying_plate_images = [ @@ -370,15 +403,22 @@ class LicensePlateProcessingMixin: ): if len(plate) < self.lpr_config.min_plate_length: logger.debug( - f"Filtered out '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})" + f"{camera}: Filtered out '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})" ) continue - if self.lpr_config.format and not re.fullmatch( - self.lpr_config.format, plate - ): - logger.debug(f"Filtered out '{plate}' due to format mismatch") - continue + if self.lpr_config.format: + try: + if not re.fullmatch(self.lpr_config.format, plate): + logger.debug( + f"{camera}: Filtered out '{plate}' due to format mismatch" + ) + continue + except re.error: + # Skip format filtering if regex is invalid + logger.error( + f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}" + ) filtered_data.append((plate, conf_list, area)) @@ -978,7 +1018,9 @@ class LicensePlateProcessingMixin: image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) return image - def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]: + def _detect_license_plate( + self, camera: string, input: np.ndarray + ) -> tuple[int, int, int, int]: """ Use a lightweight YOLOv9 model to detect license plates for users without Frigate+ @@ -1048,118 +1090,87 @@ class LicensePlateProcessingMixin: ).clip(0, [input.shape[1], input.shape[0]] * 2) logger.debug( - f"Found license plate. Bounding box: {expanded_box.astype(int)}" + f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}" ) return tuple(expanded_box.astype(int)) else: return None # No detection above the threshold - def _should_keep_previous_plate( - self, id, top_plate, top_char_confidences, top_area, avg_confidence - ): - """Determine if the previous plate should be kept over the current one.""" - if id not in self.detected_license_plates: - return False + def _get_cluster_rep( + self, plates: List[dict] + ) -> Tuple[str, float, List[float], int]: + """ + Cluster plate variants and select the representative from the best cluster. + """ + if len(plates) == 0: + return "", 0.0, [], 0 - prev_data = self.detected_license_plates[id] - prev_plate = prev_data["plate"] - prev_char_confidences = prev_data["char_confidences"] - prev_area = prev_data["area"] - prev_avg_confidence = ( - sum(prev_char_confidences) / len(prev_char_confidences) - if prev_char_confidences - else 0 + if len(plates) == 1: + p = plates[0] + return p["plate"], p["conf"], p["char_confidences"], p["area"] + + # Log initial variants + logger.debug(f"Clustering {len(plates)} plate variants:") + for i, p in enumerate(plates): + logger.debug( + f" Variant {i + 1}: '{p['plate']}' (conf: {p['conf']:.3f}, area: {p['area']})" + ) + + clusters = [] + for i, plate in enumerate(plates): + merged = False + for j, cluster in enumerate(clusters): + sims = [jaro_winkler(plate["plate"], v["plate"]) for v in cluster] + if len(sims) > 0: + avg_sim = sum(sims) / len(sims) + if avg_sim >= self.cluster_threshold: + cluster.append(plate) + logger.debug( + f" Merged variant {i + 1} '{plate['plate']}' (conf: {plate['conf']:.3f}) into cluster {j + 1} (avg_sim: {avg_sim:.3f})" + ) + merged = True + break + if not merged: + clusters.append([plate]) + logger.debug( + f" Started new cluster {len(clusters)} with variant {i + 1} '{plate['plate']}' (conf: {plate['conf']:.3f})" + ) + + if not clusters: + return "", 0.0, [], 0 + + # Log cluster summaries + for j, cluster in enumerate(clusters): + cluster_size = len(cluster) + max_conf = max(v["conf"] for v in cluster) + sample_variants = [v["plate"] for v in cluster[:3]] # First 3 for brevity + logger.debug( + f" Cluster {j + 1}: size {cluster_size}, max conf {max_conf:.3f}, variants: {sample_variants}{'...' if cluster_size > 3 else ''}" + ) + + # Best cluster: largest size, tiebroken by max conf + def cluster_score(c): + return (len(c), max(v["conf"] for v in c)) + + best_cluster_idx = max( + range(len(clusters)), key=lambda j: cluster_score(clusters[j]) ) - - # 1. Normalize metrics - # Length score: Equal lengths = 0.5, penalize extra characters if low confidence - length_diff = len(top_plate) - len(prev_plate) - max_length_diff = 3 - curr_length_score = 0.5 + (length_diff / (2 * max_length_diff)) - curr_length_score = max(0, min(1, curr_length_score)) - prev_length_score = 0.5 - (length_diff / (2 * max_length_diff)) - prev_length_score = max(0, min(1, prev_length_score)) - - # Adjust length score based on confidence of extra characters - conf_threshold = 0.75 # Minimum confidence for a character to be "trusted" - top_plate_char_count = len(top_plate.replace(" ", "")) - prev_plate_char_count = len(prev_plate.replace(" ", "")) - - if top_plate_char_count > prev_plate_char_count: - extra_confidences = top_char_confidences[prev_plate_char_count:] - if extra_confidences: # Ensure the slice is not empty - extra_conf = min(extra_confidences) # Lowest extra char confidence - if extra_conf < conf_threshold: - curr_length_score *= extra_conf / conf_threshold # Penalize if weak - elif prev_plate_char_count > top_plate_char_count: - extra_confidences = prev_char_confidences[top_plate_char_count:] - if extra_confidences: # Ensure the slice is not empty - extra_conf = min(extra_confidences) - if extra_conf < conf_threshold: - prev_length_score *= extra_conf / conf_threshold - - # Area score: Normalize by max area - max_area = max(top_area, prev_area) - curr_area_score = top_area / max_area if max_area > 0 else 0 - prev_area_score = prev_area / max_area if max_area > 0 else 0 - - # Confidence scores - curr_conf_score = avg_confidence - prev_conf_score = prev_avg_confidence - - # Character confidence comparison (average over shared length) - min_length = min(len(top_plate), len(prev_plate)) - if min_length > 0: - curr_char_conf = sum(top_char_confidences[:min_length]) / min_length - prev_char_conf = sum(prev_char_confidences[:min_length]) / min_length - else: - curr_char_conf = prev_char_conf = 0 - - # Penalize any character below threshold - curr_min_conf = min(top_char_confidences) if top_char_confidences else 0 - prev_min_conf = min(prev_char_confidences) if prev_char_confidences else 0 - curr_conf_penalty = ( - 1.0 if curr_min_conf >= conf_threshold else (curr_min_conf / conf_threshold) - ) - prev_conf_penalty = ( - 1.0 if prev_min_conf >= conf_threshold else (prev_min_conf / conf_threshold) - ) - - # 2. Define weights (boost confidence importance) - weights = { - "length": 0.2, - "area": 0.2, - "avg_confidence": 0.35, - "char_confidence": 0.25, - } - - # 3. Calculate weighted scores with penalty - curr_score = ( - curr_length_score * weights["length"] - + curr_area_score * weights["area"] - + curr_conf_score * weights["avg_confidence"] - + curr_char_conf * weights["char_confidence"] - ) * curr_conf_penalty - - prev_score = ( - prev_length_score * weights["length"] - + prev_area_score * weights["area"] - + prev_conf_score * weights["avg_confidence"] - + prev_char_conf * weights["char_confidence"] - ) * prev_conf_penalty - - # 4. Log the comparison + best_cluster = clusters[best_cluster_idx] + best_size, best_max_conf = cluster_score(best_cluster) logger.debug( - f"Plate comparison - Current: {top_plate} (score: {curr_score:.3f}, min_conf: {curr_min_conf:.2f}) vs " - f"Previous: {prev_plate} (score: {prev_score:.3f}, min_conf: {prev_min_conf:.2f}) " - f"Metrics - Length: {len(top_plate)} vs {len(prev_plate)} (scores: {curr_length_score:.2f} vs {prev_length_score:.2f}), " - f"Area: {top_area} vs {prev_area}, " - f"Avg Conf: {avg_confidence:.2f} vs {prev_avg_confidence:.2f}, " - f"Char Conf: {curr_char_conf:.2f} vs {prev_char_conf:.2f}" + f" Selected best cluster {best_cluster_idx + 1}: size {best_size}, max conf {best_max_conf:.3f}" ) - # 5. Return True if previous plate scores higher - return prev_score > curr_score + # Rep: highest conf in best cluster + rep = max(best_cluster, key=lambda v: v["conf"]) + logger.debug( + f" Selected rep from best cluster: '{rep['plate']}' (conf: {rep['conf']:.3f})" + ) + logger.debug( + f" Final clustered plate: '{rep['plate']}' (conf: {rep['conf']:.3f})" + ) + + return rep["plate"], rep["conf"], rep["char_confidences"], rep["area"] def _generate_plate_event(self, camera: str, plate: str, plate_score: float) -> str: """Generate a unique ID for a plate event based on camera and text.""" @@ -1168,7 +1179,6 @@ class LicensePlateProcessingMixin: event_id = f"{now}-{rand_id}" self.event_metadata_publisher.publish( - EventMetadataTypeEnum.lpr_event_create, ( now, camera, @@ -1179,6 +1189,7 @@ class LicensePlateProcessingMixin: None, plate, ), + EventMetadataTypeEnum.lpr_event_create.value, ) return event_id @@ -1210,7 +1221,7 @@ class LicensePlateProcessingMixin: ) yolov9_start = datetime.datetime.now().timestamp() - license_plate = self._detect_license_plate(rgb) + license_plate = self._detect_license_plate(camera, rgb) logger.debug( f"{camera}: YOLOv9 LPD inference time: {(datetime.datetime.now().timestamp() - yolov9_start) * 1000:.2f} ms" @@ -1250,7 +1261,7 @@ class LicensePlateProcessingMixin: # don't run for non car/motorcycle or non license plate (dedicated lpr with frigate+) objects if ( - obj_data.get("label") not in ["car", "motorcycle"] + obj_data.get("label") not in self.lp_objects and obj_data.get("label") != "license_plate" ): logger.debug( @@ -1302,7 +1313,7 @@ class LicensePlateProcessingMixin: ) yolov9_start = datetime.datetime.now().timestamp() - license_plate = self._detect_license_plate(car) + license_plate = self._detect_license_plate(camera, car) logger.debug( f"{camera}: YOLOv9 LPD inference time: {(datetime.datetime.now().timestamp() - yolov9_start) * 1000:.2f} ms" ) @@ -1342,7 +1353,7 @@ class LicensePlateProcessingMixin: logger.debug(f"{camera}: No attributes to parse.") return - if obj_data.get("label") in ["car", "motorcycle"]: + if obj_data.get("label") in self.lp_objects: attributes: list[dict[str, Any]] = obj_data.get( "current_attributes", [] ) @@ -1433,7 +1444,7 @@ class LicensePlateProcessingMixin: f"{camera}: Detected text: {plate} (average confidence: {avg_confidence:.2f}, area: {text_area} pixels)" ) else: - logger.debug("No text detected") + logger.debug(f"{camera}: No text detected") return top_plate, top_char_confidences, top_area = ( @@ -1476,9 +1487,7 @@ class LicensePlateProcessingMixin: ) break if plate_id is None: - plate_id = self._generate_plate_event( - obj_data, top_plate, avg_confidence - ) + plate_id = self._generate_plate_event(camera, top_plate, avg_confidence) logger.debug( f"{camera}: New plate event for dedicated LPR camera {plate_id}: {top_plate}" ) @@ -1490,25 +1499,68 @@ class LicensePlateProcessingMixin: id = plate_id - # Check if we have a previously detected plate for this ID - if id in self.detected_license_plates: - if self._should_keep_previous_plate( - id, top_plate, top_char_confidences, top_area, avg_confidence - ): - logger.debug(f"{camera}: Keeping previous plate") - return + is_new = id not in self.detected_license_plates + + # Collect variant + variant = { + "plate": top_plate, + "conf": avg_confidence, + "char_confidences": top_char_confidences, + "area": top_area, + "timestamp": current_time, + } + + # Initialize or append to plates + self.detected_license_plates.setdefault(id, {"plates": [], "camera": camera}) + self.detected_license_plates[id]["plates"].append(variant) + + # Prune old variants - this is probably higher than it needs to be + # since we don't detect a plate every frame + num_variants = self.config.cameras[camera].detect.fps * 5 + if len(self.detected_license_plates[id]["plates"]) > num_variants: + self.detected_license_plates[id]["plates"] = self.detected_license_plates[ + id + ]["plates"][-num_variants:] + + # Cluster and select rep + plates = self.detected_license_plates[id]["plates"] + rep_plate, rep_conf, rep_char_confs, rep_area = self._get_cluster_rep(plates) + + if rep_plate != top_plate: + logger.debug( + f"{camera}: Clustering changed top plate '{top_plate}' (conf: {avg_confidence:.3f}) to rep '{rep_plate}' (conf: {rep_conf:.3f})" + ) + + # Update stored rep + self.detected_license_plates[id].update( + { + "plate": rep_plate, + "char_confidences": rep_char_confs, + "area": rep_area, + "last_seen": current_time if dedicated_lpr else None, + } + ) + + if not dedicated_lpr: + self.detected_license_plates[id]["obj_data"] = obj_data + + if is_new: + if camera not in self.camera_current_cars: + self.camera_current_cars[camera] = [] + self.camera_current_cars[camera].append(id) # Determine subLabel based on known plates, use regex matching # Default to the detected plate, use label name if there's a match + sub_label = None try: sub_label = next( ( label - for label, plates in self.lpr_config.known_plates.items() + for label, plates_list in self.lpr_config.known_plates.items() if any( - re.match(f"^{plate}$", top_plate) - or distance(plate, top_plate) <= self.lpr_config.match_distance - for plate in plates + re.match(f"^{plate}$", rep_plate) + or distance(plate, rep_plate) <= self.lpr_config.match_distance + for plate in plates_list ) ), None, @@ -1517,12 +1569,11 @@ class LicensePlateProcessingMixin: logger.error( f"{camera}: Invalid regex in known plates configuration: {self.lpr_config.known_plates}" ) - sub_label = None # If it's a known plate, publish to sub_label if sub_label is not None: self.sub_label_publisher.publish( - EventMetadataTypeEnum.sub_label, (id, sub_label, avg_confidence) + (id, sub_label, rep_conf), EventMetadataTypeEnum.sub_label.value ) # always publish to recognized_license_plate field @@ -1532,8 +1583,8 @@ class LicensePlateProcessingMixin: { "type": TrackedObjectUpdateTypesEnum.lpr, "name": sub_label, - "plate": top_plate, - "score": avg_confidence, + "plate": rep_plate, + "score": rep_conf, "id": id, "camera": camera, "timestamp": start, @@ -1541,8 +1592,8 @@ class LicensePlateProcessingMixin: ), ) self.sub_label_publisher.publish( - EventMetadataTypeEnum.recognized_license_plate, - (id, top_plate, avg_confidence), + (id, "recognized_license_plate", rep_plate, rep_conf), + EventMetadataTypeEnum.attribute.value, ) # save the best snapshot for dedicated lpr cams not using frigate+ @@ -1551,30 +1602,15 @@ class LicensePlateProcessingMixin: and "license_plate" not in self.config.cameras[camera].objects.track ): logger.debug( - f"{camera}: Writing snapshot for {id}, {top_plate}, {current_time}" + f"{camera}: Writing snapshot for {id}, {rep_plate}, {current_time}" ) frame_bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) _, encoded_img = cv2.imencode(".jpg", frame_bgr) self.sub_label_publisher.publish( - EventMetadataTypeEnum.save_lpr_snapshot, (base64.b64encode(encoded_img).decode("ASCII"), id, camera), + EventMetadataTypeEnum.save_lpr_snapshot.value, ) - if id not in self.detected_license_plates: - if camera not in self.camera_current_cars: - self.camera_current_cars[camera] = [] - - self.camera_current_cars[camera].append(id) - - self.detected_license_plates[id] = { - "plate": top_plate, - "char_confidences": top_char_confidences, - "area": top_area, - "obj_data": obj_data, - "camera": camera, - "last_seen": current_time if dedicated_lpr else None, - } - def handle_request(self, topic, request_data) -> dict[str, Any] | None: return @@ -1595,113 +1631,121 @@ class CTCDecoder: for each decoded character sequence. """ - def __init__(self): + def __init__(self, character_dict_path=None): """ - Initialize the CTCDecoder with a list of characters and a character map. + Initializes the CTCDecoder. + :param character_dict_path: Path to the character dictionary file. + If None, a default (English-focused) list is used. + For Chinese models, this should point to the correct + character dictionary file provided with the model. + """ + self.characters = [] + if character_dict_path and os.path.exists(character_dict_path): + with open(character_dict_path, "r", encoding="utf-8") as f: + self.characters = ( + ["blank"] + [line.strip() for line in f if line.strip()] + [" "] + ) + else: + self.characters = [ + "blank", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "[", + "\\", + "]", + "^", + "_", + "`", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "{", + "|", + "}", + "~", + "!", + '"', + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + " ", + " ", + ] - The character set includes digits, letters, special characters, and a "blank" token - (used by the CTC model for decoding purposes). A character map is created to map - indices to characters. - """ - self.characters = [ - "blank", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - ":", - ";", - "<", - "=", - ">", - "?", - "@", - "A", - "B", - "C", - "D", - "E", - "F", - "G", - "H", - "I", - "J", - "K", - "L", - "M", - "N", - "O", - "P", - "Q", - "R", - "S", - "T", - "U", - "V", - "W", - "X", - "Y", - "Z", - "[", - "\\", - "]", - "^", - "_", - "`", - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", - "o", - "p", - "q", - "r", - "s", - "t", - "u", - "v", - "w", - "x", - "y", - "z", - "{", - "|", - "}", - "~", - "!", - '"', - "#", - "$", - "%", - "&", - "'", - "(", - ")", - "*", - "+", - ",", - "-", - ".", - "/", - " ", - " ", - ] self.char_map = {i: char for i, char in enumerate(self.characters)} def __call__( @@ -1735,7 +1779,7 @@ class CTCDecoder: merged_path.append(char_index) merged_probs.append(seq_log_probs[t, char_index]) - result = "".join(self.char_map[idx] for idx in merged_path) + result = "".join(self.char_map.get(idx, "") for idx in merged_path) results.append(result) confidence = np.exp(merged_probs).tolist() diff --git a/frigate/data_processing/post/api.py b/frigate/data_processing/post/api.py index cd6dda1284..c341bd8ef9 100644 --- a/frigate/data_processing/post/api.py +++ b/frigate/data_processing/post/api.py @@ -39,7 +39,9 @@ class PostProcessorApi(ABC): pass @abstractmethod - def handle_request(self, request_data: dict[str, Any]) -> dict[str, Any] | None: + def handle_request( + self, topic: str, request_data: dict[str, Any] + ) -> dict[str, Any] | None: """Handle metadata requests. Args: request_data (dict): containing data about requested change to process. diff --git a/frigate/data_processing/post/audio_transcription.py b/frigate/data_processing/post/audio_transcription.py new file mode 100644 index 0000000000..146b4e0f1e --- /dev/null +++ b/frigate/data_processing/post/audio_transcription.py @@ -0,0 +1,212 @@ +"""Handle post-processing for audio transcription.""" + +import logging +import os +import threading +import time +from typing import Optional + +from faster_whisper import WhisperModel +from peewee import DoesNotExist + +from frigate.comms.embeddings_updater import EmbeddingsRequestEnum +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.const import ( + CACHE_DIR, + MODEL_CACHE_DIR, + UPDATE_EVENT_DESCRIPTION, +) +from frigate.data_processing.types import PostProcessDataEnum +from frigate.types import TrackedObjectUpdateTypesEnum +from frigate.util.audio import get_audio_from_recording + +from ..types import DataProcessorMetrics +from .api import PostProcessorApi + +logger = logging.getLogger(__name__) + + +class AudioTranscriptionPostProcessor(PostProcessorApi): + def __init__( + self, + config: FrigateConfig, + requestor: InterProcessRequestor, + metrics: DataProcessorMetrics, + ): + super().__init__(config, metrics, None) + self.config = config + self.requestor = requestor + self.recognizer = None + self.transcription_lock = threading.Lock() + self.transcription_thread = None + self.transcription_running = False + + # faster-whisper handles model downloading automatically + self.model_path = os.path.join(MODEL_CACHE_DIR, "whisper") + os.makedirs(self.model_path, exist_ok=True) + + self.__build_recognizer() + + def __build_recognizer(self) -> None: + try: + self.recognizer = WhisperModel( + model_size_or_path="small", + device="cuda" + if self.config.audio_transcription.device == "GPU" + else "cpu", + download_root=self.model_path, + local_files_only=False, # Allow downloading if not cached + compute_type="int8", + ) + logger.debug("Audio transcription (recordings) initialized") + except Exception as e: + logger.error(f"Failed to initialize recordings audio transcription: {e}") + self.recognizer = None + + def process_data( + self, data: dict[str, any], data_type: PostProcessDataEnum + ) -> None: + """Transcribe audio from a recording. + + Args: + data (dict): Contains data about the input (event_id, camera, etc.). + data_type (enum): Describes the data being processed (recording or tracked_object). + + Returns: + None + """ + event_id = data["event_id"] + camera_name = data["camera"] + + if data_type == PostProcessDataEnum.recording: + start_ts = data["frame_time"] + recordings_available_through = data["recordings_available"] + end_ts = min(recordings_available_through, start_ts + 60) # Default 60s + + elif data_type == PostProcessDataEnum.tracked_object: + obj_data = data["event"]["data"] + obj_data["id"] = data["event"]["id"] + obj_data["camera"] = data["event"]["camera"] + start_ts = data["event"]["start_time"] + end_ts = data["event"].get( + "end_time", start_ts + 60 + ) # Use end_time if available + + else: + logger.error("No data type passed to audio transcription post-processing") + return + + try: + audio_data = get_audio_from_recording( + self.config.cameras[camera_name].ffmpeg, + camera_name, + start_ts, + end_ts, + sample_rate=16000, + ) + + if not audio_data: + logger.debug(f"No audio data extracted for {event_id}") + return + + transcription = self.__transcribe_audio(audio_data) + if not transcription: + logger.debug("No transcription generated from audio") + return + + logger.debug(f"Transcribed audio for {event_id}: '{transcription}'") + + self.requestor.send_data( + UPDATE_EVENT_DESCRIPTION, + { + "type": TrackedObjectUpdateTypesEnum.description, + "id": event_id, + "description": transcription, + "camera": camera_name, + }, + ) + + # Embed the description + self.requestor.send_data( + EmbeddingsRequestEnum.embed_description.value, + {"id": event_id, "description": transcription}, + ) + + except DoesNotExist: + logger.debug("No recording found for audio transcription post-processing") + return + except Exception as e: + logger.error(f"Error in audio transcription post-processing: {e}") + + def __transcribe_audio(self, audio_data: bytes) -> Optional[tuple[str, float]]: + """Transcribe WAV audio data using faster-whisper.""" + if not self.recognizer: + logger.debug("Recognizer not initialized") + return None + + try: + # Save audio data to a temporary wav (faster-whisper expects a file) + temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav") + with open(temp_wav, "wb") as f: + f.write(audio_data) + + segments, info = self.recognizer.transcribe( + temp_wav, + language=self.config.audio_transcription.language, + beam_size=5, + ) + + os.remove(temp_wav) + + # Combine all segment texts + text = " ".join(segment.text.strip() for segment in segments) + if not text: + return None + + logger.debug( + "Detected language '%s' with probability %f" + % (info.language, info.language_probability) + ) + + return text + except Exception as e: + logger.error(f"Error transcribing audio: {e}") + return None + + def _transcription_wrapper(self, event: dict[str, any]) -> None: + """Wrapper to run transcription and reset running flag when done.""" + try: + self.process_data( + { + "event_id": event["id"], + "camera": event["camera"], + "event": event, + }, + PostProcessDataEnum.tracked_object, + ) + finally: + with self.transcription_lock: + self.transcription_running = False + self.transcription_thread = None + + def handle_request(self, topic: str, request_data: dict[str, any]) -> str | None: + if topic == "transcribe_audio": + event = request_data["event"] + + with self.transcription_lock: + if self.transcription_running: + logger.warning( + "Audio transcription for a speech event is already running." + ) + return "in_progress" + + # Mark as running and start the thread + self.transcription_running = True + self.transcription_thread = threading.Thread( + target=self._transcription_wrapper, args=(event,), daemon=True + ) + self.transcription_thread.start() + return "started" + + return None diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py new file mode 100644 index 0000000000..4c405221ea --- /dev/null +++ b/frigate/data_processing/post/review_descriptions.py @@ -0,0 +1,277 @@ +"""Post processor for review items to get descriptions.""" + +import copy +import datetime +import logging +import os +import shutil +import threading +from pathlib import Path +from typing import Any + +import cv2 + +from frigate.comms.embeddings_updater import EmbeddingsRequestEnum +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.config.camera.review import GenAIReviewConfig +from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION +from frigate.data_processing.types import PostProcessDataEnum +from frigate.genai import GenAIClient +from frigate.models import ReviewSegment +from frigate.util.builtin import EventsPerSecond, InferenceSpeed + +from ..post.api import PostProcessorApi +from ..types import DataProcessorMetrics + +logger = logging.getLogger(__name__) + + +class ReviewDescriptionProcessor(PostProcessorApi): + def __init__( + self, + config: FrigateConfig, + requestor: InterProcessRequestor, + metrics: DataProcessorMetrics, + client: GenAIClient, + ): + super().__init__(config, metrics, None) + self.requestor = requestor + self.metrics = metrics + self.genai_client = client + self.review_desc_speed = InferenceSpeed(self.metrics.review_desc_speed) + self.review_descs_dps = EventsPerSecond() + self.review_descs_dps.start() + + def process_data(self, data, data_type): + self.metrics.review_desc_dps.value = self.review_descs_dps.eps() + + if data_type != PostProcessDataEnum.review: + return + + camera = data["after"]["camera"] + camera_config = self.config.cameras[camera] + + if not camera_config.review.genai.enabled: + return + + id = data["after"]["id"] + + if data["type"] == "new" or data["type"] == "update": + return + else: + final_data = data["after"] + + if ( + final_data["severity"] == "alert" + and not camera_config.review.genai.alerts + ): + return + elif ( + final_data["severity"] == "detection" + and not camera_config.review.genai.detections + ): + return + + frames = self.get_cache_frames( + camera, final_data["start_time"], final_data["end_time"] + ) + + if not frames: + frames = [final_data["thumb_path"]] + + thumbs = [] + + for idx, thumb_path in enumerate(frames): + thumb_data = cv2.imread(thumb_path) + ret, jpg = cv2.imencode( + ".jpg", thumb_data, [int(cv2.IMWRITE_JPEG_QUALITY), 100] + ) + + if ret: + thumbs.append(jpg.tobytes()) + + if camera_config.review.genai.debug_save_thumbnails: + id = data["after"]["id"] + Path(os.path.join(CLIPS_DIR, "genai-requests", f"{id}")).mkdir( + parents=True, exist_ok=True + ) + shutil.copy( + thumb_path, + os.path.join( + CLIPS_DIR, + f"genai-requests/{id}/{idx}.webp", + ), + ) + + # kickoff analysis + self.review_descs_dps.update() + threading.Thread( + target=run_analysis, + args=( + self.requestor, + self.genai_client, + self.review_desc_speed, + camera, + final_data, + thumbs, + camera_config.review.genai, + list(self.config.model.merged_labelmap.values()), + ), + ).start() + + def handle_request(self, topic, request_data): + if topic == EmbeddingsRequestEnum.summarize_review.value: + start_ts = request_data["start_ts"] + end_ts = request_data["end_ts"] + logger.debug( + f"Found GenAI Review Summary request for {start_ts} to {end_ts}" + ) + items: list[dict[str, Any]] = [ + r["data"]["metadata"] + for r in ( + ReviewSegment.select(ReviewSegment.data) + .where( + (ReviewSegment.data["metadata"].is_null(False)) + & (ReviewSegment.start_time < end_ts) + & (ReviewSegment.end_time > start_ts) + ) + .order_by(ReviewSegment.start_time.asc()) + .dicts() + .iterator() + ) + ] + + if len(items) == 0: + logger.debug("No review items with metadata found during time period") + return "No activity was found during this time." + + important_items = list( + filter( + lambda item: item.get("potential_threat_level", 0) > 0 + or item.get("other_concerns"), + items, + ) + ) + + if not important_items: + return "No concerns were found during this time period." + + if self.config.review.genai.debug_save_thumbnails: + Path( + os.path.join(CLIPS_DIR, "genai-requests", f"{start_ts}-{end_ts}") + ).mkdir(parents=True, exist_ok=True) + + return self.genai_client.generate_review_summary( + start_ts, + end_ts, + important_items, + self.config.review.genai.debug_save_thumbnails, + ) + else: + return None + + def get_cache_frames( + self, + camera: str, + start_time: float, + end_time: float, + desired_frame_count: int = 12, + ) -> list[str]: + preview_dir = os.path.join(CACHE_DIR, "preview_frames") + file_start = f"preview_{camera}" + start_file = f"{file_start}-{start_time}.webp" + end_file = f"{file_start}-{end_time}.webp" + all_frames = [] + + for file in sorted(os.listdir(preview_dir)): + if not file.startswith(file_start): + continue + + if file < start_file: + if len(all_frames): + all_frames[0] = os.path.join(preview_dir, file) + else: + all_frames.append(os.path.join(preview_dir, file)) + + continue + + if file > end_file: + all_frames.append(os.path.join(preview_dir, file)) + break + + all_frames.append(os.path.join(preview_dir, file)) + + frame_count = len(all_frames) + if frame_count <= desired_frame_count: + return all_frames + + selected_frames = [] + step_size = (frame_count - 1) / (desired_frame_count - 1) + + for i in range(desired_frame_count): + index = round(i * step_size) + selected_frames.append(all_frames[index]) + + return selected_frames + + +@staticmethod +def run_analysis( + requestor: InterProcessRequestor, + genai_client: GenAIClient, + review_inference_speed: InferenceSpeed, + camera: str, + final_data: dict[str, str], + thumbs: list[bytes], + genai_config: GenAIReviewConfig, + labelmap_objects: list[str], +) -> None: + start = datetime.datetime.now().timestamp() + analytics_data = { + "id": final_data["id"], + "camera": camera, + "zones": final_data["data"]["zones"], + "start": datetime.datetime.fromtimestamp(final_data["start_time"]).strftime( + "%A, %I:%M %p" + ), + "duration": final_data["end_time"] - final_data["start_time"], + } + + objects = [] + verified_objects = [] + + for label in set(final_data["data"]["objects"] + final_data["data"]["sub_labels"]): + if "-verified" in label: + continue + + if label in labelmap_objects: + objects.append(label.replace("_", " ").title()) + else: + verified_objects.append(label.replace("_", " ").title()) + + analytics_data["objects"] = objects + analytics_data["recognized_objects"] = verified_objects + + metadata = genai_client.generate_review_description( + analytics_data, + thumbs, + genai_config.additional_concerns, + genai_config.preferred_language, + genai_config.debug_save_thumbnails, + ) + review_inference_speed.update(datetime.datetime.now().timestamp() - start) + + if not metadata: + return None + + prev_data = copy.deepcopy(final_data) + final_data["data"]["metadata"] = metadata.model_dump() + requestor.send_data( + UPDATE_REVIEW_DESCRIPTION, + { + "type": "genai", + "before": {k: v for k, v in prev_data.items()}, + "after": {k: v for k, v in final_data.items()}, + }, + ) diff --git a/frigate/data_processing/post/semantic_trigger.py b/frigate/data_processing/post/semantic_trigger.py new file mode 100644 index 0000000000..baa47ba1c7 --- /dev/null +++ b/frigate/data_processing/post/semantic_trigger.py @@ -0,0 +1,233 @@ +"""Post time processor to trigger actions based on similar embeddings.""" + +import datetime +import json +import logging +import os +from typing import Any + +import cv2 +import numpy as np +from peewee import DoesNotExist + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.const import CONFIG_DIR +from frigate.data_processing.types import PostProcessDataEnum +from frigate.db.sqlitevecq import SqliteVecQueueDatabase +from frigate.embeddings.util import ZScoreNormalization +from frigate.models import Event, Trigger +from frigate.util.builtin import cosine_distance +from frigate.util.path import get_event_thumbnail_bytes + +from ..post.api import PostProcessorApi +from ..types import DataProcessorMetrics + +logger = logging.getLogger(__name__) + +WRITE_DEBUG_IMAGES = False + + +class SemanticTriggerProcessor(PostProcessorApi): + def __init__( + self, + db: SqliteVecQueueDatabase, + config: FrigateConfig, + requestor: InterProcessRequestor, + metrics: DataProcessorMetrics, + embeddings, + ): + super().__init__(config, metrics, None) + self.db = db + self.embeddings = embeddings + self.requestor = requestor + self.trigger_embeddings: list[np.ndarray] = [] + + self.thumb_stats = ZScoreNormalization() + self.desc_stats = ZScoreNormalization() + + # load stats from disk + try: + with open(os.path.join(CONFIG_DIR, ".search_stats.json"), "r") as f: + data = json.loads(f.read()) + self.thumb_stats.from_dict(data["thumb_stats"]) + self.desc_stats.from_dict(data["desc_stats"]) + except FileNotFoundError: + pass + + def process_data( + self, data: dict[str, Any], data_type: PostProcessDataEnum + ) -> None: + event_id = data["event_id"] + camera = data["camera"] + process_type = data["type"] + + if self.config.cameras[camera].semantic_search.triggers is None: + return + + triggers = ( + Trigger.select( + Trigger.camera, + Trigger.name, + Trigger.data, + Trigger.type, + Trigger.embedding, + Trigger.threshold, + ) + .where(Trigger.camera == camera) + .dicts() + .iterator() + ) + + for trigger in triggers: + if ( + trigger["name"] + not in self.config.cameras[camera].semantic_search.triggers + or not self.config.cameras[camera] + .semantic_search.triggers[trigger["name"]] + .enabled + ): + logger.debug( + f"Trigger {trigger['name']} is disabled for camera {camera}" + ) + continue + + logger.debug( + f"Processing {trigger['type']} trigger for {event_id} on {trigger['camera']}: {trigger['name']}" + ) + + trigger_embedding = np.frombuffer(trigger["embedding"], dtype=np.float32) + + # Get embeddings based on type + thumbnail_embedding = None + description_embedding = None + + if process_type == "image": + cursor = self.db.execute_sql( + """ + SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ? + """, + [event_id], + ) + row = cursor.fetchone() if cursor else None + if row: + thumbnail_embedding = np.frombuffer(row[0], dtype=np.float32) + + if process_type == "text": + cursor = self.db.execute_sql( + """ + SELECT description_embedding FROM vec_descriptions WHERE id = ? + """, + [event_id], + ) + row = cursor.fetchone() if cursor else None + if row: + description_embedding = np.frombuffer(row[0], dtype=np.float32) + + # Skip processing if we don't have any embeddings + if thumbnail_embedding is None and description_embedding is None: + logger.debug(f"No embeddings found for {event_id}") + return + + # Determine which embedding to compare based on trigger type + if ( + trigger["type"] in ["text", "thumbnail"] + and thumbnail_embedding is not None + ): + data_embedding = thumbnail_embedding + normalized_distance = self.thumb_stats.normalize( + [cosine_distance(data_embedding, trigger_embedding)], + save_stats=False, + )[0] + elif trigger["type"] == "description" and description_embedding is not None: + data_embedding = description_embedding + normalized_distance = self.desc_stats.normalize( + [cosine_distance(data_embedding, trigger_embedding)], + save_stats=False, + )[0] + + else: + continue + + similarity = 1 - normalized_distance + + logger.debug( + f"Trigger {trigger['name']} ({trigger['data'] if trigger['type'] == 'text' or trigger['type'] == 'description' else 'image'}): " + f"normalized distance: {normalized_distance:.4f}, " + f"similarity: {similarity:.4f}, threshold: {trigger['threshold']}" + ) + + # Check if similarity meets threshold + if similarity >= trigger["threshold"]: + logger.info( + f"Trigger {trigger['name']} activated with similarity {similarity:.4f}" + ) + + # Update the trigger's last_triggered and triggering_event_id + Trigger.update( + last_triggered=datetime.datetime.now(), triggering_event_id=event_id + ).where( + Trigger.camera == camera, Trigger.name == trigger["name"] + ).execute() + + # Always publish MQTT message + self.requestor.send_data( + "triggers", + json.dumps( + { + "name": trigger["name"], + "camera": camera, + "event_id": event_id, + "type": trigger["type"], + "score": similarity, + } + ), + ) + + if ( + self.config.cameras[camera] + .semantic_search.triggers[trigger["name"]] + .actions + ): + # TODO: handle actions for the trigger + # notifications already handled by webpush + pass + + if WRITE_DEBUG_IMAGES: + try: + event: Event = Event.get(Event.id == event_id) + except DoesNotExist: + return + + # Skip the event if not an object + if event.data.get("type") != "object": + return + + thumbnail_bytes = get_event_thumbnail_bytes(event) + + nparr = np.frombuffer(thumbnail_bytes, np.uint8) + thumbnail = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + font_scale = 0.5 + font = cv2.FONT_HERSHEY_SIMPLEX + cv2.putText( + thumbnail, + f"{similarity:.4f}", + (10, 30), + font, + fontScale=font_scale, + color=(0, 255, 0), + thickness=2, + ) + + current_time = int(datetime.datetime.now().timestamp()) + cv2.imwrite( + f"debug/frames/trigger-{event_id}_{current_time}.jpg", + thumbnail, + ) + + def handle_request(self, topic, request_data): + return None + + def expire_object(self, object_id, camera): + pass diff --git a/frigate/data_processing/post/types.py b/frigate/data_processing/post/types.py new file mode 100644 index 0000000000..1da7c5eb88 --- /dev/null +++ b/frigate/data_processing/post/types.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class ReviewMetadata(BaseModel): + model_config = ConfigDict(extra="ignore", protected_namespaces=()) + + scene: str = Field( + description="A comprehensive description of the setting and entities, including relevant context and plausible inferences if supported by visual evidence." + ) + confidence: float = Field( + description="A float between 0 and 1 representing your overall confidence in this analysis." + ) + potential_threat_level: int = Field( + ge=0, + le=3, + description="An integer representing the potential threat level (1-3). 1: Minor anomaly. 2: Moderate concern. 3: High threat. Only include this field if a clear security concern is observable; otherwise, omit it.", + ) + other_concerns: list[str] | None = Field( + default=None, + description="Other concerns highlighted by the user that are observed.", + ) + time: str | None = Field(default=None, description="Time of activity.") diff --git a/frigate/data_processing/real_time/audio_transcription.py b/frigate/data_processing/real_time/audio_transcription.py new file mode 100644 index 0000000000..2e6d599ebf --- /dev/null +++ b/frigate/data_processing/real_time/audio_transcription.py @@ -0,0 +1,281 @@ +"""Handle processing audio for speech transcription using sherpa-onnx with FFmpeg pipe.""" + +import logging +import os +import queue +import threading +from typing import Optional + +import numpy as np + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import CameraConfig, FrigateConfig +from frigate.const import MODEL_CACHE_DIR +from frigate.data_processing.common.audio_transcription.model import ( + AudioTranscriptionModelRunner, +) +from frigate.data_processing.real_time.whisper_online import ( + FasterWhisperASR, + OnlineASRProcessor, +) + +from ..types import DataProcessorMetrics +from .api import RealTimeProcessorApi + +logger = logging.getLogger(__name__) + + +class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi): + def __init__( + self, + config: FrigateConfig, + camera_config: CameraConfig, + requestor: InterProcessRequestor, + model_runner: AudioTranscriptionModelRunner, + metrics: DataProcessorMetrics, + stop_event: threading.Event, + ): + super().__init__(config, metrics) + self.config = config + self.camera_config = camera_config + self.requestor = requestor + self.stream = None + self.whisper_model = None + self.model_runner = model_runner + self.transcription_segments = [] + self.audio_queue = queue.Queue() + self.stop_event = stop_event + + def __build_recognizer(self) -> None: + try: + if self.config.audio_transcription.model_size == "large": + # Whisper models need to be per-process and can only run one stream at a time + # TODO: try parallel: https://github.com/SYSTRAN/faster-whisper/issues/100 + logger.debug(f"Loading Whisper model for {self.camera_config.name}") + self.whisper_model = FasterWhisperASR( + modelsize="tiny", + device="cuda" + if self.config.audio_transcription.device == "GPU" + else "cpu", + lan=self.config.audio_transcription.language, + model_dir=os.path.join(MODEL_CACHE_DIR, "whisper"), + ) + self.whisper_model.use_vad() + self.stream = OnlineASRProcessor( + asr=self.whisper_model, + ) + else: + logger.debug(f"Loading sherpa stream for {self.camera_config.name}") + self.stream = self.model_runner.model.create_stream() + logger.debug( + f"Audio transcription (live) initialized for {self.camera_config.name}" + ) + except Exception as e: + logger.error( + f"Failed to initialize live streaming audio transcription: {e}" + ) + + def __process_audio_stream( + self, audio_data: np.ndarray + ) -> Optional[tuple[str, bool]]: + if ( + self.model_runner.model is None + and self.config.audio_transcription.model_size == "small" + ): + logger.debug("Audio transcription (live) model not initialized") + return None + + if not self.stream: + self.__build_recognizer() + + try: + if audio_data.dtype != np.float32: + audio_data = audio_data.astype(np.float32) + + if audio_data.max() > 1.0 or audio_data.min() < -1.0: + audio_data = audio_data / 32768.0 # Normalize from int16 + + rms = float(np.sqrt(np.mean(np.absolute(np.square(audio_data))))) + logger.debug(f"Audio chunk size: {audio_data.size}, RMS: {rms:.4f}") + + if self.config.audio_transcription.model_size == "large": + # large model + self.stream.insert_audio_chunk(audio_data) + output = self.stream.process_iter() + text = output[2].strip() + is_endpoint = ( + text.endswith((".", "!", "?")) + and sum(len(str(lines)) for lines in self.transcription_segments) + > 300 + ) + + if text: + self.transcription_segments.append(text) + concatenated_text = " ".join(self.transcription_segments) + logger.debug(f"Concatenated transcription: '{concatenated_text}'") + text = concatenated_text + + else: + # small model + self.stream.accept_waveform(16000, audio_data) + + while self.model_runner.model.is_ready(self.stream): + self.model_runner.model.decode_stream(self.stream) + + text = self.model_runner.model.get_result(self.stream).strip() + is_endpoint = self.model_runner.model.is_endpoint(self.stream) + + logger.debug(f"Transcription result: '{text}'") + + if not text: + logger.debug("No transcription, returning") + return None + + logger.debug(f"Endpoint detected: {is_endpoint}") + + if is_endpoint and self.config.audio_transcription.model_size == "small": + # reset sherpa if we've reached an endpoint + self.model_runner.model.reset(self.stream) + + return text, is_endpoint + except Exception as e: + logger.error(f"Error processing audio stream: {e}") + return None + + def process_frame(self, obj_data: dict[str, any], frame: np.ndarray) -> None: + pass + + def process_audio(self, obj_data: dict[str, any], audio: np.ndarray) -> bool | None: + if audio is None or audio.size == 0: + logger.debug("No audio data provided for transcription") + return None + + # enqueue audio data for processing in the thread + self.audio_queue.put((obj_data, audio)) + return None + + def run(self) -> None: + """Run method for the transcription thread to process queued audio data.""" + logger.debug( + f"Starting audio transcription thread for {self.camera_config.name}" + ) + + # start with an empty transcription + self.requestor.send_data( + f"{self.camera_config.name}/audio/transcription", + "", + ) + + while not self.stop_event.is_set(): + try: + # Get audio data from queue with a timeout to check stop_event + _, audio = self.audio_queue.get(timeout=0.1) + result = self.__process_audio_stream(audio) + + if not result: + continue + + text, is_endpoint = result + logger.debug(f"Transcribed audio: '{text}', Endpoint: {is_endpoint}") + + self.requestor.send_data( + f"{self.camera_config.name}/audio/transcription", text + ) + + self.audio_queue.task_done() + + if is_endpoint: + self.reset() + + except queue.Empty: + continue + except Exception as e: + logger.error(f"Error processing audio in thread: {e}") + self.audio_queue.task_done() + + logger.debug( + f"Stopping audio transcription thread for {self.camera_config.name}" + ) + + def clear_audio_queue(self) -> None: + # Clear the audio queue + while not self.audio_queue.empty(): + try: + self.audio_queue.get_nowait() + self.audio_queue.task_done() + except queue.Empty: + break + + def reset(self) -> None: + if self.config.audio_transcription.model_size == "large": + # get final output from whisper + output = self.stream.finish() + self.transcription_segments = [] + + self.requestor.send_data( + f"{self.camera_config.name}/audio/transcription", + (output[2].strip() + " "), + ) + + # reset whisper + self.stream.init() + self.transcription_segments = [] + else: + # reset sherpa + self.model_runner.model.reset(self.stream) + + logger.debug("Stream reset") + + def check_unload_model(self) -> None: + # regularly called in the loop in audio maintainer + if ( + self.config.audio_transcription.model_size == "large" + and self.whisper_model is not None + ): + logger.debug(f"Unloading Whisper model for {self.camera_config.name}") + self.clear_audio_queue() + self.transcription_segments = [] + self.stream = None + self.whisper_model = None + + self.requestor.send_data( + f"{self.camera_config.name}/audio/transcription", + "", + ) + if ( + self.config.audio_transcription.model_size == "small" + and self.stream is not None + ): + logger.debug(f"Clearing sherpa stream for {self.camera_config.name}") + self.stream = None + + self.requestor.send_data( + f"{self.camera_config.name}/audio/transcription", + "", + ) + + def stop(self) -> None: + """Stop the transcription thread and clean up.""" + self.stop_event.set() + # Clear the queue to prevent processing stale data + while not self.audio_queue.empty(): + try: + self.audio_queue.get_nowait() + self.audio_queue.task_done() + except queue.Empty: + break + logger.debug( + f"Transcription thread stop signaled for {self.camera_config.name}" + ) + + def handle_request( + self, topic: str, request_data: dict[str, any] + ) -> dict[str, any] | None: + if topic == "clear_audio_recognizer": + self.stream = None + self.__build_recognizer() + return {"message": "Audio recognizer cleared and rebuilt", "success": True} + return None + + def expire_object(self, object_id: str) -> None: + pass diff --git a/frigate/data_processing/real_time/bird.py b/frigate/data_processing/real_time/bird.py index d547f2ddd3..e599ab0fb1 100644 --- a/frigate/data_processing/real_time/bird.py +++ b/frigate/data_processing/real_time/bird.py @@ -13,6 +13,7 @@ from frigate.comms.event_metadata_updater import ( ) from frigate.config import FrigateConfig from frigate.const import MODEL_CACHE_DIR +from frigate.log import redirect_output_to_logger from frigate.util.object import calculate_region from ..types import DataProcessorMetrics @@ -79,6 +80,7 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): except Exception as e: logger.error(f"Failed to download {path}: {e}") + @redirect_output_to_logger(logger, logging.DEBUG) def __build_detector(self) -> None: self.interpreter = Interpreter( model_path=os.path.join(MODEL_CACHE_DIR, "bird/bird.tflite"), @@ -129,7 +131,11 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): ] if input.shape != (224, 224): - input = cv2.resize(input, (224, 224)) + try: + input = cv2.resize(input, (224, 224)) + except Exception: + logger.warning("Failed to resize image for bird classification") + return input = np.expand_dims(input, axis=0) self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input) @@ -157,8 +163,8 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): return self.sub_label_publisher.publish( - EventMetadataTypeEnum.sub_label, (obj_data["id"], self.labelmap[best_id], score), + EventMetadataTypeEnum.sub_label.value, ) self.detected_birds[obj_data["id"]] = score diff --git a/frigate/data_processing/real_time/custom_classification.py b/frigate/data_processing/real_time/custom_classification.py new file mode 100644 index 0000000000..841267a608 --- /dev/null +++ b/frigate/data_processing/real_time/custom_classification.py @@ -0,0 +1,360 @@ +"""Real time processor that works with classification tflite models.""" + +import datetime +import logging +import os +from typing import Any + +import cv2 +import numpy as np + +from frigate.comms.embeddings_updater import EmbeddingsRequestEnum +from frigate.comms.event_metadata_updater import ( + EventMetadataPublisher, + EventMetadataTypeEnum, +) +from frigate.comms.inter_process import InterProcessRequestor +from frigate.config import FrigateConfig +from frigate.config.classification import ( + CustomClassificationConfig, + ObjectClassificationType, +) +from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR +from frigate.log import redirect_output_to_logger +from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels +from frigate.util.object import box_overlaps, calculate_region + +from ..types import DataProcessorMetrics +from .api import RealTimeProcessorApi + +try: + from tflite_runtime.interpreter import Interpreter +except ModuleNotFoundError: + from tensorflow.lite.python.interpreter import Interpreter + +logger = logging.getLogger(__name__) + + +class CustomStateClassificationProcessor(RealTimeProcessorApi): + def __init__( + self, + config: FrigateConfig, + model_config: CustomClassificationConfig, + requestor: InterProcessRequestor, + metrics: DataProcessorMetrics, + ): + super().__init__(config, metrics) + self.model_config = model_config + self.requestor = requestor + self.model_dir = os.path.join(MODEL_CACHE_DIR, self.model_config.name) + self.train_dir = os.path.join(CLIPS_DIR, self.model_config.name, "train") + self.interpreter: Interpreter = None + self.tensor_input_details: dict[str, Any] = None + self.tensor_output_details: dict[str, Any] = None + self.labelmap: dict[int, str] = {} + self.classifications_per_second = EventsPerSecond() + self.inference_speed = InferenceSpeed( + self.metrics.classification_speeds[self.model_config.name] + ) + self.last_run = datetime.datetime.now().timestamp() + self.__build_detector() + + @redirect_output_to_logger(logger, logging.DEBUG) + def __build_detector(self) -> None: + self.interpreter = Interpreter( + model_path=os.path.join(self.model_dir, "model.tflite"), + num_threads=2, + ) + self.interpreter.allocate_tensors() + self.tensor_input_details = self.interpreter.get_input_details() + self.tensor_output_details = self.interpreter.get_output_details() + self.labelmap = load_labels( + os.path.join(self.model_dir, "labelmap.txt"), + prefill=0, + ) + self.classifications_per_second.start() + + def __update_metrics(self, duration: float) -> None: + self.classifications_per_second.update() + self.inference_speed.update(duration) + + def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray): + self.metrics.classification_cps[ + self.model_config.name + ].value = self.classifications_per_second.eps() + camera = frame_data.get("camera") + + if camera not in self.model_config.state_config.cameras: + return + + camera_config = self.model_config.state_config.cameras[camera] + crop = [ + camera_config.crop[0], + camera_config.crop[1], + camera_config.crop[2], + camera_config.crop[3], + ] + should_run = False + + now = datetime.datetime.now().timestamp() + if ( + self.model_config.state_config.interval + and now > self.last_run + self.model_config.state_config.interval + ): + self.last_run = now + should_run = True + + if ( + not should_run + and self.model_config.state_config.motion + and any([box_overlaps(crop, mb) for mb in frame_data.get("motion", [])]) + ): + # classification should run at most once per second + if now > self.last_run + 1: + self.last_run = now + should_run = True + + if not should_run: + return + + x, y, x2, y2 = calculate_region( + frame.shape, + crop[0], + crop[1], + crop[2], + crop[3], + 224, + 1.0, + ) + + rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) + frame = rgb[ + y:y2, + x:x2, + ] + + if frame.shape != (224, 224): + try: + frame = cv2.resize(frame, (224, 224)) + except Exception: + logger.warning("Failed to resize image for state classification") + return + + input = np.expand_dims(frame, axis=0) + self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input) + self.interpreter.invoke() + res: np.ndarray = self.interpreter.get_tensor( + self.tensor_output_details[0]["index"] + )[0] + probs = res / res.sum(axis=0) + best_id = np.argmax(probs) + score = round(probs[best_id], 2) + self.__update_metrics(datetime.datetime.now().timestamp() - now) + + write_classification_attempt( + self.train_dir, + cv2.cvtColor(frame, cv2.COLOR_RGB2BGR), + now, + self.labelmap[best_id], + score, + ) + + if score >= self.model_config.threshold: + self.requestor.send_data( + f"{camera}/classification/{self.model_config.name}", + self.labelmap[best_id], + ) + + def handle_request(self, topic, request_data): + if topic == EmbeddingsRequestEnum.reload_classification_model.value: + if request_data.get("model_name") == self.model_config.name: + self.__build_detector() + logger.info( + f"Successfully loaded updated model for {self.model_config.name}" + ) + return { + "success": True, + "message": f"Loaded {self.model_config.name} model.", + } + else: + return None + else: + return None + + def expire_object(self, object_id, camera): + pass + + +class CustomObjectClassificationProcessor(RealTimeProcessorApi): + def __init__( + self, + config: FrigateConfig, + model_config: CustomClassificationConfig, + sub_label_publisher: EventMetadataPublisher, + metrics: DataProcessorMetrics, + ): + super().__init__(config, metrics) + self.model_config = model_config + self.model_dir = os.path.join(MODEL_CACHE_DIR, self.model_config.name) + self.train_dir = os.path.join(CLIPS_DIR, self.model_config.name, "train") + self.interpreter: Interpreter = None + self.sub_label_publisher = sub_label_publisher + self.tensor_input_details: dict[str, Any] = None + self.tensor_output_details: dict[str, Any] = None + self.detected_objects: dict[str, float] = {} + self.labelmap: dict[int, str] = {} + self.classifications_per_second = EventsPerSecond() + self.inference_speed = InferenceSpeed( + self.metrics.classification_speeds[self.model_config.name] + ) + self.__build_detector() + + @redirect_output_to_logger(logger, logging.DEBUG) + def __build_detector(self) -> None: + self.interpreter = Interpreter( + model_path=os.path.join(self.model_dir, "model.tflite"), + num_threads=2, + ) + self.interpreter.allocate_tensors() + self.tensor_input_details = self.interpreter.get_input_details() + self.tensor_output_details = self.interpreter.get_output_details() + self.labelmap = load_labels( + os.path.join(self.model_dir, "labelmap.txt"), + prefill=0, + ) + + def __update_metrics(self, duration: float) -> None: + self.classifications_per_second.update() + self.inference_speed.update(duration) + + def process_frame(self, obj_data, frame): + self.metrics.classification_cps[ + self.model_config.name + ].value = self.classifications_per_second.eps() + + if obj_data["false_positive"]: + return + + if obj_data["label"] not in self.model_config.object_config.objects: + return + + now = datetime.datetime.now().timestamp() + x, y, x2, y2 = calculate_region( + frame.shape, + obj_data["box"][0], + obj_data["box"][1], + obj_data["box"][2], + obj_data["box"][3], + max( + obj_data["box"][1] - obj_data["box"][0], + obj_data["box"][3] - obj_data["box"][2], + ), + 1.0, + ) + + rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) + crop = rgb[ + y:y2, + x:x2, + ] + + if crop.shape != (224, 224): + try: + crop = cv2.resize(crop, (224, 224)) + except Exception: + logger.warning("Failed to resize image for state classification") + return + + input = np.expand_dims(crop, axis=0) + self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input) + self.interpreter.invoke() + res: np.ndarray = self.interpreter.get_tensor( + self.tensor_output_details[0]["index"] + )[0] + probs = res / res.sum(axis=0) + best_id = np.argmax(probs) + score = round(probs[best_id], 2) + previous_score = self.detected_objects.get(obj_data["id"], 0.0) + self.__update_metrics(datetime.datetime.now().timestamp() - now) + + write_classification_attempt( + self.train_dir, + cv2.cvtColor(crop, cv2.COLOR_RGB2BGR), + now, + self.labelmap[best_id], + score, + ) + + if score < self.model_config.threshold: + logger.debug(f"Score {score} is less than threshold.") + return + + if score <= previous_score: + logger.debug(f"Score {score} is worse than previous score {previous_score}") + return + + sub_label = self.labelmap[best_id] + self.detected_objects[obj_data["id"]] = score + + if ( + self.model_config.object_config.classification_type + == ObjectClassificationType.sub_label + ): + if sub_label != "none": + self.sub_label_publisher.publish( + (obj_data["id"], sub_label, score), + EventMetadataTypeEnum.sub_label, + ) + elif ( + self.model_config.object_config.classification_type + == ObjectClassificationType.attribute + ): + self.sub_label_publisher.publish( + (obj_data["id"], self.model_config.name, sub_label, score), + EventMetadataTypeEnum.attribute.value, + ) + + def handle_request(self, topic, request_data): + if topic == EmbeddingsRequestEnum.reload_classification_model.value: + if request_data.get("model_name") == self.model_config.name: + logger.info( + f"Successfully loaded updated model for {self.model_config.name}" + ) + return { + "success": True, + "message": f"Loaded {self.model_config.name} model.", + } + else: + return None + else: + return None + + def expire_object(self, object_id, camera): + if object_id in self.detected_objects: + self.detected_objects.pop(object_id) + + +@staticmethod +def write_classification_attempt( + folder: str, + frame: np.ndarray, + timestamp: float, + label: str, + score: float, +) -> None: + if "-" in label: + label = label.replace("-", "_") + + file = os.path.join(folder, f"{timestamp}-{label}-{score}.webp") + os.makedirs(folder, exist_ok=True) + cv2.imwrite(file, frame) + + files = sorted( + filter(lambda f: (f.endswith(".webp")), os.listdir(folder)), + key=lambda f: os.path.getctime(os.path.join(folder, f)), + reverse=True, + ) + + # delete oldest face image if maximum is reached + if len(files) > 100: + os.unlink(os.path.join(folder, files[-1])) diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index 5a6525362c..d3ebc5397e 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -173,7 +173,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): # don't run for non person objects if obj_data.get("label") != "person": - logger.debug("Not a processing face for non person object.") + logger.debug("Not processing face for a non person object.") return # don't overwrite sub label for objects that have a sub label @@ -321,8 +321,8 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): if weighted_score >= self.face_config.recognition_threshold: self.sub_label_publisher.publish( - EventMetadataTypeEnum.sub_label, (id, weighted_sub_label, weighted_score), + EventMetadataTypeEnum.sub_label.value, ) self.__update_metrics(datetime.datetime.now().timestamp() - start) diff --git a/frigate/data_processing/real_time/whisper_online.py b/frigate/data_processing/real_time/whisper_online.py new file mode 100644 index 0000000000..9b81d7fbe4 --- /dev/null +++ b/frigate/data_processing/real_time/whisper_online.py @@ -0,0 +1,1158 @@ +# imported to Frigate from https://github.com/ufal/whisper_streaming +# with only minor modifications +import io +import logging +import math +import sys +import time +from functools import lru_cache + +import librosa +import numpy as np +import soundfile as sf + +logger = logging.getLogger(__name__) + + +@lru_cache(10**6) +def load_audio(fname): + a, _ = librosa.load(fname, sr=16000, dtype=np.float32) + return a + + +def load_audio_chunk(fname, beg, end): + audio = load_audio(fname) + beg_s = int(beg * 16000) + end_s = int(end * 16000) + return audio[beg_s:end_s] + + +# Whisper backend + + +class ASRBase: + sep = "" # join transcribe words with this character (" " for whisper_timestamped, + # "" for faster-whisper because it emits the spaces when neeeded) + + def __init__( + self, + lan, + modelsize=None, + cache_dir=None, + model_dir=None, + logfile=sys.stderr, + device="cpu", + ): + self.logfile = logfile + + self.transcribe_kargs = {} + if lan == "auto": + self.original_language = None + else: + self.original_language = lan + + self.model = self.load_model(modelsize, cache_dir, model_dir, device) + + def load_model(self, modelsize, cache_dir): + raise NotImplementedError("must be implemented in the child class") + + def transcribe(self, audio, init_prompt=""): + raise NotImplementedError("must be implemented in the child class") + + def use_vad(self): + raise NotImplementedError("must be implemented in the child class") + + +class WhisperTimestampedASR(ASRBase): + """Uses whisper_timestamped library as the backend. Initially, we tested the code on this backend. It worked, but slower than faster-whisper. + On the other hand, the installation for GPU could be easier. + """ + + sep = " " + + def load_model(self, modelsize=None, cache_dir=None, model_dir=None): + import whisper + from whisper_timestamped import transcribe_timestamped + + self.transcribe_timestamped = transcribe_timestamped + if model_dir is not None: + logger.debug("ignoring model_dir, not implemented") + return whisper.load_model(modelsize, download_root=cache_dir) + + def transcribe(self, audio, init_prompt=""): + result = self.transcribe_timestamped( + self.model, + audio, + language=self.original_language, + initial_prompt=init_prompt, + verbose=None, + condition_on_previous_text=True, + **self.transcribe_kargs, + ) + return result + + def ts_words(self, r): + # return: transcribe result object to [(beg,end,"word1"), ...] + o = [] + for s in r["segments"]: + for w in s["words"]: + t = (w["start"], w["end"], w["text"]) + o.append(t) + return o + + def segments_end_ts(self, res): + return [s["end"] for s in res["segments"]] + + def use_vad(self): + self.transcribe_kargs["vad"] = True + + def set_translate_task(self): + self.transcribe_kargs["task"] = "translate" + + +class FasterWhisperASR(ASRBase): + """Uses faster-whisper library as the backend. Works much faster, appx 4-times (in offline mode). For GPU, it requires installation with a specific CUDNN version.""" + + sep = "" + + def load_model(self, modelsize=None, cache_dir=None, model_dir=None, device="cpu"): + from faster_whisper import WhisperModel + + logging.getLogger("faster_whisper").setLevel(logging.WARNING) + + # this worked fast and reliably on NVIDIA L40 + model = WhisperModel( + model_size_or_path="small" if device == "cuda" else "tiny", + device=device, + compute_type="float16" if device == "cuda" else "int8", + local_files_only=False, + download_root=model_dir, + ) + + # or run on GPU with INT8 + # tested: the transcripts were different, probably worse than with FP16, and it was slightly (appx 20%) slower + # model = WhisperModel(model_size, device="cuda", compute_type="int8_float16") + + # or run on CPU with INT8 + # tested: works, but slow, appx 10-times than cuda FP16 + # model = WhisperModel(modelsize, device="cpu", compute_type="int8") #, download_root="faster-disk-cache-dir/") + return model + + def transcribe(self, audio, init_prompt=""): + from faster_whisper import BatchedInferencePipeline + + # tested: beam_size=5 is faster and better than 1 (on one 200 second document from En ESIC, min chunk 0.01) + batched_model = BatchedInferencePipeline(model=self.model) + segments, info = batched_model.transcribe( + audio, + language=self.original_language, + initial_prompt=init_prompt, + beam_size=5, + word_timestamps=True, + condition_on_previous_text=True, + **self.transcribe_kargs, + ) + # print(info) # info contains language detection result + + return list(segments) + + def ts_words(self, segments): + o = [] + for segment in segments: + for word in segment.words: + if segment.no_speech_prob > 0.9: + continue + # not stripping the spaces -- should not be merged with them! + w = word.word + t = (word.start, word.end, w) + o.append(t) + return o + + def segments_end_ts(self, res): + return [s.end for s in res] + + def use_vad(self): + self.transcribe_kargs["vad_filter"] = True + + def set_translate_task(self): + self.transcribe_kargs["task"] = "translate" + + +class MLXWhisper(ASRBase): + """ + Uses MLX Whisper library as the backend, optimized for Apple Silicon. + Models available: https://huggingface.co/collections/mlx-community/whisper-663256f9964fbb1177db93dc + Significantly faster than faster-whisper (without CUDA) on Apple M1. + """ + + sep = " " + + def load_model(self, modelsize=None, cache_dir=None, model_dir=None): + """ + Loads the MLX-compatible Whisper model. + + Args: + modelsize (str, optional): The size or name of the Whisper model to load. + If provided, it will be translated to an MLX-compatible model path using the `translate_model_name` method. + Example: "large-v3-turbo" -> "mlx-community/whisper-large-v3-turbo". + cache_dir (str, optional): Path to the directory for caching models. + **Note**: This is not supported by MLX Whisper and will be ignored. + model_dir (str, optional): Direct path to a custom model directory. + If specified, it overrides the `modelsize` parameter. + """ + import mlx.core as mx # Is installed with mlx-whisper + from mlx_whisper.transcribe import ModelHolder, transcribe + + if model_dir is not None: + logger.debug( + f"Loading whisper model from model_dir {model_dir}. modelsize parameter is not used." + ) + model_size_or_path = model_dir + elif modelsize is not None: + model_size_or_path = self.translate_model_name(modelsize) + logger.debug( + f"Loading whisper model {modelsize}. You use mlx whisper, so {model_size_or_path} will be used." + ) + + self.model_size_or_path = model_size_or_path + + # Note: ModelHolder.get_model loads the model into a static class variable, + # making it a global resource. This means: + # - Only one model can be loaded at a time; switching models requires reloading. + # - This approach may not be suitable for scenarios requiring multiple models simultaneously, + # such as using whisper-streaming as a module with varying model sizes. + dtype = mx.float16 # Default to mx.float16. In mlx_whisper.transcribe: dtype = mx.float16 if decode_options.get("fp16", True) else mx.float32 + ModelHolder.get_model( + model_size_or_path, dtype + ) # Model is preloaded to avoid reloading during transcription + + return transcribe + + def translate_model_name(self, model_name): + """ + Translates a given model name to its corresponding MLX-compatible model path. + + Args: + model_name (str): The name of the model to translate. + + Returns: + str: The MLX-compatible model path. + """ + # Dictionary mapping model names to MLX-compatible paths + model_mapping = { + "tiny.en": "mlx-community/whisper-tiny.en-mlx", + "tiny": "mlx-community/whisper-tiny-mlx", + "base.en": "mlx-community/whisper-base.en-mlx", + "base": "mlx-community/whisper-base-mlx", + "small.en": "mlx-community/whisper-small.en-mlx", + "small": "mlx-community/whisper-small-mlx", + "medium.en": "mlx-community/whisper-medium.en-mlx", + "medium": "mlx-community/whisper-medium-mlx", + "large-v1": "mlx-community/whisper-large-v1-mlx", + "large-v2": "mlx-community/whisper-large-v2-mlx", + "large-v3": "mlx-community/whisper-large-v3-mlx", + "large-v3-turbo": "mlx-community/whisper-large-v3-turbo", + "large": "mlx-community/whisper-large-mlx", + } + + # Retrieve the corresponding MLX model path + mlx_model_path = model_mapping.get(model_name) + + if mlx_model_path: + return mlx_model_path + else: + raise ValueError( + f"Model name '{model_name}' is not recognized or not supported." + ) + + def transcribe(self, audio, init_prompt=""): + segments = self.model( + audio, + language=self.original_language, + initial_prompt=init_prompt, + word_timestamps=True, + condition_on_previous_text=True, + path_or_hf_repo=self.model_size_or_path, + **self.transcribe_kargs, + ) + return segments.get("segments", []) + + def ts_words(self, segments): + """ + Extract timestamped words from transcription segments and skips words with high no-speech probability. + """ + return [ + (word["start"], word["end"], word["word"]) + for segment in segments + for word in segment.get("words", []) + if segment.get("no_speech_prob", 0) <= 0.9 + ] + + def segments_end_ts(self, res): + return [s["end"] for s in res] + + def use_vad(self): + self.transcribe_kargs["vad_filter"] = True + + def set_translate_task(self): + self.transcribe_kargs["task"] = "translate" + + +class OpenaiApiASR(ASRBase): + """Uses OpenAI's Whisper API for audio transcription.""" + + def __init__(self, lan=None, temperature=0, logfile=sys.stderr): + self.logfile = logfile + + self.modelname = "whisper-1" + self.original_language = ( + None if lan == "auto" else lan + ) # ISO-639-1 language code + self.response_format = "verbose_json" + self.temperature = temperature + + self.load_model() + + self.use_vad_opt = False + + # reset the task in set_translate_task + self.task = "transcribe" + + def load_model(self, *args, **kwargs): + from openai import OpenAI + + self.client = OpenAI() + + self.transcribed_seconds = ( + 0 # for logging how many seconds were processed by API, to know the cost + ) + + def ts_words(self, segments): + no_speech_segments = [] + if self.use_vad_opt: + for segment in segments.segments: + # TODO: threshold can be set from outside + if segment["no_speech_prob"] > 0.8: + no_speech_segments.append( + (segment.get("start"), segment.get("end")) + ) + + o = [] + for word in segments.words: + start = word.start + end = word.end + if any(s[0] <= start <= s[1] for s in no_speech_segments): + # print("Skipping word", word.get("word"), "because it's in a no-speech segment") + continue + o.append((start, end, word.word)) + return o + + def segments_end_ts(self, res): + return [s.end for s in res.words] + + def transcribe(self, audio_data, prompt=None, *args, **kwargs): + # Write the audio data to a buffer + buffer = io.BytesIO() + buffer.name = "temp.wav" + sf.write(buffer, audio_data, samplerate=16000, format="WAV", subtype="PCM_16") + buffer.seek(0) # Reset buffer's position to the beginning + + self.transcribed_seconds += math.ceil( + len(audio_data) / 16000 + ) # it rounds up to the whole seconds + + params = { + "model": self.modelname, + "file": buffer, + "response_format": self.response_format, + "temperature": self.temperature, + "timestamp_granularities": ["word", "segment"], + } + if self.task != "translate" and self.original_language: + params["language"] = self.original_language + if prompt: + params["prompt"] = prompt + + if self.task == "translate": + proc = self.client.audio.translations + else: + proc = self.client.audio.transcriptions + + # Process transcription/translation + transcript = proc.create(**params) + logger.debug( + f"OpenAI API processed accumulated {self.transcribed_seconds} seconds" + ) + + return transcript + + def use_vad(self): + self.use_vad_opt = True + + def set_translate_task(self): + self.task = "translate" + + +class HypothesisBuffer: + def __init__(self, logfile=sys.stderr): + self.commited_in_buffer = [] + self.buffer = [] + self.new = [] + + self.last_commited_time = 0 + self.last_commited_word = None + + self.logfile = logfile + + def insert(self, new, offset): + # compare self.commited_in_buffer and new. It inserts only the words in new that extend the commited_in_buffer, it means they are roughly behind last_commited_time and new in content + # the new tail is added to self.new + + new = [(a + offset, b + offset, t) for a, b, t in new] + self.new = [(a, b, t) for a, b, t in new if a > self.last_commited_time - 0.1] + + if len(self.new) >= 1: + a, b, t = self.new[0] + if abs(a - self.last_commited_time) < 1: + if self.commited_in_buffer: + # it's going to search for 1, 2, ..., 5 consecutive words (n-grams) that are identical in commited and new. If they are, they're dropped. + cn = len(self.commited_in_buffer) + nn = len(self.new) + for i in range(1, min(min(cn, nn), 5) + 1): # 5 is the maximum + c = " ".join( + [self.commited_in_buffer[-j][2] for j in range(1, i + 1)][ + ::-1 + ] + ) + tail = " ".join(self.new[j - 1][2] for j in range(1, i + 1)) + if c == tail: + words = [] + for j in range(i): + words.append(repr(self.new.pop(0))) + words_msg = " ".join(words) + logger.debug(f"removing last {i} words: {words_msg}") + break + + def flush(self): + # returns commited chunk = the longest common prefix of 2 last inserts. + + commit = [] + while self.new: + na, nb, nt = self.new[0] + + if len(self.buffer) == 0: + break + + if nt == self.buffer[0][2]: + commit.append((na, nb, nt)) + self.last_commited_word = nt + self.last_commited_time = nb + self.buffer.pop(0) + self.new.pop(0) + else: + break + self.buffer = self.new + self.new = [] + self.commited_in_buffer.extend(commit) + return commit + + def pop_commited(self, time): + while self.commited_in_buffer and self.commited_in_buffer[0][1] <= time: + self.commited_in_buffer.pop(0) + + def complete(self): + return self.buffer + + +class OnlineASRProcessor: + SAMPLING_RATE = 16000 + + def __init__( + self, asr, tokenizer=None, buffer_trimming=("segment", 15), logfile=sys.stderr + ): + """asr: WhisperASR object + tokenizer: sentence tokenizer object for the target language. Must have a method *split* that behaves like the one of MosesTokenizer. It can be None, if "segment" buffer trimming option is used, then tokenizer is not used at all. + ("segment", 15) + buffer_trimming: a pair of (option, seconds), where option is either "sentence" or "segment", and seconds is a number. Buffer is trimmed if it is longer than "seconds" threshold. Default is the most recommended option. + logfile: where to store the log. + """ + self.asr = asr + self.tokenizer = tokenizer + self.logfile = logfile + + self.init() + + self.buffer_trimming_way, self.buffer_trimming_sec = buffer_trimming + + def init(self, offset=None): + """run this when starting or restarting processing""" + self.audio_buffer = np.array([], dtype=np.float32) + self.transcript_buffer = HypothesisBuffer(logfile=self.logfile) + self.buffer_time_offset = 0 + if offset is not None: + self.buffer_time_offset = offset + self.transcript_buffer.last_commited_time = self.buffer_time_offset + self.commited = [] + + def insert_audio_chunk(self, audio): + self.audio_buffer = np.append(self.audio_buffer, audio) + + def prompt(self): + """Returns a tuple: (prompt, context), where "prompt" is a 200-character suffix of commited text that is inside of the scrolled away part of audio buffer. + "context" is the commited text that is inside the audio buffer. It is transcribed again and skipped. It is returned only for debugging and logging reasons. + """ + k = max(0, len(self.commited) - 1) + while k > 0 and self.commited[k - 1][1] > self.buffer_time_offset: + k -= 1 + + p = self.commited[:k] + p = [t for _, _, t in p] + prompt = [] + y = 0 + while p and y < 200: # 200 characters prompt size + x = p.pop(-1) + y += len(x) + 1 + prompt.append(x) + non_prompt = self.commited[k:] + return self.asr.sep.join(prompt[::-1]), self.asr.sep.join( + t for _, _, t in non_prompt + ) + + def process_iter(self): + """Runs on the current audio buffer. + Returns: a tuple (beg_timestamp, end_timestamp, "text"), or (None, None, ""). + The non-emty text is confirmed (committed) partial transcript. + """ + + prompt, non_prompt = self.prompt() + logger.debug(f"PROMPT: {prompt}") + logger.debug(f"CONTEXT: {non_prompt}") + logger.debug( + f"transcribing {len(self.audio_buffer) / self.SAMPLING_RATE:2.2f} seconds from {self.buffer_time_offset:2.2f}" + ) + res = self.asr.transcribe(self.audio_buffer, init_prompt=prompt) + + # transform to [(beg,end,"word1"), ...] + tsw = self.asr.ts_words(res) + + self.transcript_buffer.insert(tsw, self.buffer_time_offset) + o = self.transcript_buffer.flush() + self.commited.extend(o) + completed = self.to_flush(o) + logger.debug(f">>>>COMPLETE NOW: {completed}") + the_rest = self.to_flush(self.transcript_buffer.complete()) + logger.debug(f"INCOMPLETE: {the_rest}") + + # there is a newly confirmed text + + if o and self.buffer_trimming_way == "sentence": # trim the completed sentences + if ( + len(self.audio_buffer) / self.SAMPLING_RATE > self.buffer_trimming_sec + ): # longer than this + self.chunk_completed_sentence() + + if self.buffer_trimming_way == "segment": + s = self.buffer_trimming_sec # trim the completed segments longer than s, + else: + s = 30 # if the audio buffer is longer than 30s, trim it + + if len(self.audio_buffer) / self.SAMPLING_RATE > s: + self.chunk_completed_segment(res) + + # alternative: on any word + # l = self.buffer_time_offset + len(self.audio_buffer)/self.SAMPLING_RATE - 10 + # let's find commited word that is less + # k = len(self.commited)-1 + # while k>0 and self.commited[k][1] > l: + # k -= 1 + # t = self.commited[k][1] + logger.debug("chunking segment") + # self.chunk_at(t) + + logger.debug( + f"len of buffer now: {len(self.audio_buffer) / self.SAMPLING_RATE:2.2f}" + ) + return self.to_flush(o) + + def chunk_completed_sentence(self): + if self.commited == []: + return + logger.debug(self.commited) + sents = self.words_to_sentences(self.commited) + for s in sents: + logger.debug(f"\t\tSENT: {s}") + if len(sents) < 2: + return + while len(sents) > 2: + sents.pop(0) + # we will continue with audio processing at this timestamp + chunk_at = sents[-2][1] + + logger.debug(f"--- sentence chunked at {chunk_at:2.2f}") + self.chunk_at(chunk_at) + + def chunk_completed_segment(self, res): + if self.commited == []: + return + + ends = self.asr.segments_end_ts(res) + + t = self.commited[-1][1] + + if len(ends) > 1: + e = ends[-2] + self.buffer_time_offset + while len(ends) > 2 and e > t: + ends.pop(-1) + e = ends[-2] + self.buffer_time_offset + if e <= t: + logger.debug(f"--- segment chunked at {e:2.2f}") + self.chunk_at(e) + else: + logger.debug("--- last segment not within commited area") + else: + logger.debug("--- not enough segments to chunk") + + def chunk_at(self, time): + """trims the hypothesis and audio buffer at "time" """ + self.transcript_buffer.pop_commited(time) + cut_seconds = time - self.buffer_time_offset + self.audio_buffer = self.audio_buffer[int(cut_seconds * self.SAMPLING_RATE) :] + self.buffer_time_offset = time + + def words_to_sentences(self, words): + """Uses self.tokenizer for sentence segmentation of words. + Returns: [(beg,end,"sentence 1"),...] + """ + + cwords = [w for w in words] + t = " ".join(o[2] for o in cwords) + s = self.tokenizer.split(t) + out = [] + while s: + beg = None + end = None + sent = s.pop(0).strip() + fsent = sent + while cwords: + b, e, w = cwords.pop(0) + w = w.strip() + if beg is None and sent.startswith(w): + beg = b + elif end is None and sent == w: + end = e + out.append((beg, end, fsent)) + break + sent = sent[len(w) :].strip() + return out + + def finish(self): + """Flush the incomplete text when the whole processing ends. + Returns: the same format as self.process_iter() + """ + o = self.transcript_buffer.complete() + f = self.to_flush(o) + logger.debug(f"last, noncommited: {f}") + self.buffer_time_offset += len(self.audio_buffer) / 16000 + return f + + def to_flush( + self, + sents, + sep=None, + offset=0, + ): + # concatenates the timestamped words or sentences into one sequence that is flushed in one line + # sents: [(beg1, end1, "sentence1"), ...] or [] if empty + # return: (beg1,end-of-last-sentence,"concatenation of sentences") or (None, None, "") if empty + if sep is None: + sep = self.asr.sep + t = sep.join(s[2] for s in sents) + if len(sents) == 0: + b = None + e = None + else: + b = offset + sents[0][0] + e = offset + sents[-1][1] + return (b, e, t) + + +class VACOnlineASRProcessor(OnlineASRProcessor): + """Wraps OnlineASRProcessor with VAC (Voice Activity Controller). + + It works the same way as OnlineASRProcessor: it receives chunks of audio (e.g. 0.04 seconds), + it runs VAD and continuously detects whether there is speech or not. + When it detects end of speech (non-voice for 500ms), it makes OnlineASRProcessor to end the utterance immediately. + """ + + def __init__(self, online_chunk_size, *a, **kw): + self.online_chunk_size = online_chunk_size + + self.online = OnlineASRProcessor(*a, **kw) + + # VAC: + import torch + + model, _ = torch.hub.load(repo_or_dir="snakers4/silero-vad", model="silero_vad") + from silero_vad_iterator import FixedVADIterator + + self.vac = FixedVADIterator( + model + ) # we use the default options there: 500ms silence, 100ms padding, etc. + + self.logfile = self.online.logfile + self.init() + + def init(self): + self.online.init() + self.vac.reset_states() + self.current_online_chunk_buffer_size = 0 + + self.is_currently_final = False + + self.status = None # or "voice" or "nonvoice" + self.audio_buffer = np.array([], dtype=np.float32) + self.buffer_offset = 0 # in frames + + def clear_buffer(self): + self.buffer_offset += len(self.audio_buffer) + self.audio_buffer = np.array([], dtype=np.float32) + + def insert_audio_chunk(self, audio): + res = self.vac(audio) + self.audio_buffer = np.append(self.audio_buffer, audio) + + if res is not None: + frame = list(res.values())[0] - self.buffer_offset + if "start" in res and "end" not in res: + self.status = "voice" + send_audio = self.audio_buffer[frame:] + self.online.init( + offset=(frame + self.buffer_offset) / self.SAMPLING_RATE + ) + self.online.insert_audio_chunk(send_audio) + self.current_online_chunk_buffer_size += len(send_audio) + self.clear_buffer() + elif "end" in res and "start" not in res: + self.status = "nonvoice" + send_audio = self.audio_buffer[:frame] + self.online.insert_audio_chunk(send_audio) + self.current_online_chunk_buffer_size += len(send_audio) + self.is_currently_final = True + self.clear_buffer() + else: + beg = res["start"] - self.buffer_offset + end = res["end"] - self.buffer_offset + self.status = "nonvoice" + send_audio = self.audio_buffer[beg:end] + self.online.init(offset=(beg + self.buffer_offset) / self.SAMPLING_RATE) + self.online.insert_audio_chunk(send_audio) + self.current_online_chunk_buffer_size += len(send_audio) + self.is_currently_final = True + self.clear_buffer() + else: + if self.status == "voice": + self.online.insert_audio_chunk(self.audio_buffer) + self.current_online_chunk_buffer_size += len(self.audio_buffer) + self.clear_buffer() + else: + # We keep 1 second because VAD may later find start of voice in it. + # But we trim it to prevent OOM. + self.buffer_offset += max( + 0, len(self.audio_buffer) - self.SAMPLING_RATE + ) + self.audio_buffer = self.audio_buffer[-self.SAMPLING_RATE :] + + def process_iter(self): + if self.is_currently_final: + return self.finish() + elif ( + self.current_online_chunk_buffer_size + > self.SAMPLING_RATE * self.online_chunk_size + ): + self.current_online_chunk_buffer_size = 0 + ret = self.online.process_iter() + return ret + else: + print("no online update, only VAD", self.status, file=self.logfile) + return (None, None, "") + + def finish(self): + ret = self.online.finish() + self.current_online_chunk_buffer_size = 0 + self.is_currently_final = False + return ret + + +WHISPER_LANG_CODES = "af,am,ar,as,az,ba,be,bg,bn,bo,br,bs,ca,cs,cy,da,de,el,en,es,et,eu,fa,fi,fo,fr,gl,gu,ha,haw,he,hi,hr,ht,hu,hy,id,is,it,ja,jw,ka,kk,km,kn,ko,la,lb,ln,lo,lt,lv,mg,mi,mk,ml,mn,mr,ms,mt,my,ne,nl,nn,no,oc,pa,pl,ps,pt,ro,ru,sa,sd,si,sk,sl,sn,so,sq,sr,su,sv,sw,ta,te,tg,th,tk,tl,tr,tt,uk,ur,uz,vi,yi,yo,zh".split( + "," +) + + +def create_tokenizer(lan): + """returns an object that has split function that works like the one of MosesTokenizer""" + + assert lan in WHISPER_LANG_CODES, ( + "language must be Whisper's supported lang code: " + + " ".join(WHISPER_LANG_CODES) + ) + + if lan == "uk": + import tokenize_uk + + class UkrainianTokenizer: + def split(self, text): + return tokenize_uk.tokenize_sents(text) + + return UkrainianTokenizer() + + # supported by fast-mosestokenizer + if ( + lan + in "as bn ca cs de el en es et fi fr ga gu hi hu is it kn lt lv ml mni mr nl or pa pl pt ro ru sk sl sv ta te yue zh".split() + ): + from mosestokenizer import MosesTokenizer + + return MosesTokenizer(lan) + + # the following languages are in Whisper, but not in wtpsplit: + if ( + lan + in "as ba bo br bs fo haw hr ht jw lb ln lo mi nn oc sa sd sn so su sw tk tl tt".split() + ): + logger.debug( + f"{lan} code is not supported by wtpsplit. Going to use None lang_code option." + ) + lan = None + + from wtpsplit import WtP + + # downloads the model from huggingface on the first use + wtp = WtP("wtp-canine-s-12l-no-adapters") + + class WtPtok: + def split(self, sent): + return wtp.split(sent, lang_code=lan) + + return WtPtok() + + +def add_shared_args(parser): + """shared args for simulation (this entry point) and server + parser: argparse.ArgumentParser object + """ + parser.add_argument( + "--min-chunk-size", + type=float, + default=1.0, + help="Minimum audio chunk size in seconds. It waits up to this time to do processing. If the processing takes shorter time, it waits, otherwise it processes the whole segment that was received by this time.", + ) + parser.add_argument( + "--model", + type=str, + default="large-v2", + choices="tiny.en,tiny,base.en,base,small.en,small,medium.en,medium,large-v1,large-v2,large-v3,large,large-v3-turbo".split( + "," + ), + help="Name size of the Whisper model to use (default: large-v2). The model is automatically downloaded from the model hub if not present in model cache dir.", + ) + parser.add_argument( + "--model_cache_dir", + type=str, + default=None, + help="Overriding the default model cache dir where models downloaded from the hub are saved", + ) + parser.add_argument( + "--model_dir", + type=str, + default=None, + help="Dir where Whisper model.bin and other files are saved. This option overrides --model and --model_cache_dir parameter.", + ) + parser.add_argument( + "--lan", + "--language", + type=str, + default="auto", + help="Source language code, e.g. en,de,cs, or 'auto' for language detection.", + ) + parser.add_argument( + "--task", + type=str, + default="transcribe", + choices=["transcribe", "translate"], + help="Transcribe or translate.", + ) + parser.add_argument( + "--backend", + type=str, + default="faster-whisper", + choices=["faster-whisper", "whisper_timestamped", "mlx-whisper", "openai-api"], + help="Load only this backend for Whisper processing.", + ) + parser.add_argument( + "--vac", + action="store_true", + default=False, + help="Use VAC = voice activity controller. Recommended. Requires torch.", + ) + parser.add_argument( + "--vac-chunk-size", type=float, default=0.04, help="VAC sample size in seconds." + ) + parser.add_argument( + "--vad", + action="store_true", + default=False, + help="Use VAD = voice activity detection, with the default parameters.", + ) + parser.add_argument( + "--buffer_trimming", + type=str, + default="segment", + choices=["sentence", "segment"], + help='Buffer trimming strategy -- trim completed sentences marked with punctuation mark and detected by sentence segmenter, or the completed segments returned by Whisper. Sentence segmenter must be installed for "sentence" option.', + ) + parser.add_argument( + "--buffer_trimming_sec", + type=float, + default=15, + help="Buffer trimming length threshold in seconds. If buffer length is longer, trimming sentence/segment is triggered.", + ) + parser.add_argument( + "-l", + "--log-level", + dest="log_level", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Set the log level", + default="DEBUG", + ) + + +def asr_factory(args, logfile=sys.stderr): + """ + Creates and configures an ASR and ASR Online instance based on the specified backend and arguments. + """ + backend = args.backend + if backend == "openai-api": + logger.debug("Using OpenAI API.") + asr = OpenaiApiASR(lan=args.lan) + else: + if backend == "faster-whisper": + asr_cls = FasterWhisperASR + elif backend == "mlx-whisper": + asr_cls = MLXWhisper + else: + asr_cls = WhisperTimestampedASR + + # Only for FasterWhisperASR and WhisperTimestampedASR + size = args.model + t = time.time() + logger.info(f"Loading Whisper {size} model for {args.lan}...") + asr = asr_cls( + modelsize=size, + lan=args.lan, + cache_dir=args.model_cache_dir, + model_dir=args.model_dir, + ) + e = time.time() + logger.info(f"done. It took {round(e - t, 2)} seconds.") + + # Apply common configurations + if getattr(args, "vad", False): # Checks if VAD argument is present and True + logger.info("Setting VAD filter") + asr.use_vad() + + language = args.lan + if args.task == "translate": + asr.set_translate_task() + tgt_language = "en" # Whisper translates into English + else: + tgt_language = language # Whisper transcribes in this language + + # Create the tokenizer + if args.buffer_trimming == "sentence": + tokenizer = create_tokenizer(tgt_language) + else: + tokenizer = None + + # Create the OnlineASRProcessor + if args.vac: + online = VACOnlineASRProcessor( + args.min_chunk_size, + asr, + tokenizer, + logfile=logfile, + buffer_trimming=(args.buffer_trimming, args.buffer_trimming_sec), + ) + else: + online = OnlineASRProcessor( + asr, + tokenizer, + logfile=logfile, + buffer_trimming=(args.buffer_trimming, args.buffer_trimming_sec), + ) + + return asr, online + + +def set_logging(args, logger, other="_server"): + logging.basicConfig( # format='%(name)s + format="%(levelname)s\t%(message)s" + ) + logger.setLevel(args.log_level) + logging.getLogger("whisper_online" + other).setLevel(args.log_level) + + +# logging.getLogger("whisper_online_server").setLevel(args.log_level) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument( + "audio_path", + type=str, + help="Filename of 16kHz mono channel wav, on which live streaming is simulated.", + ) + add_shared_args(parser) + parser.add_argument( + "--start_at", + type=float, + default=0.0, + help="Start processing audio at this time.", + ) + parser.add_argument( + "--offline", action="store_true", default=False, help="Offline mode." + ) + parser.add_argument( + "--comp_unaware", + action="store_true", + default=False, + help="Computationally unaware simulation.", + ) + + args = parser.parse_args() + + # reset to store stderr to different file stream, e.g. open(os.devnull,"w") + logfile = sys.stderr + + if args.offline and args.comp_unaware: + logger.error( + "No or one option from --offline and --comp_unaware are available, not both. Exiting." + ) + sys.exit(1) + + # if args.log_level: + # logging.basicConfig(format='whisper-%(levelname)s:%(name)s: %(message)s', + # level=getattr(logging, args.log_level)) + + set_logging(args, logger) + + audio_path = args.audio_path + + SAMPLING_RATE = 16000 + duration = len(load_audio(audio_path)) / SAMPLING_RATE + logger.info("Audio duration is: %2.2f seconds" % duration) + + asr, online = asr_factory(args, logfile=logfile) + if args.vac: + min_chunk = args.vac_chunk_size + else: + min_chunk = args.min_chunk_size + + # load the audio into the LRU cache before we start the timer + a = load_audio_chunk(audio_path, 0, 1) + + # warm up the ASR because the very first transcribe takes much more time than the other + asr.transcribe(a) + + beg = args.start_at + start = time.time() - beg + + def output_transcript(o, now=None): + # output format in stdout is like: + # 4186.3606 0 1720 Takhle to je + # - the first three words are: + # - emission time from beginning of processing, in milliseconds + # - beg and end timestamp of the text segment, as estimated by Whisper model. The timestamps are not accurate, but they're useful anyway + # - the next words: segment transcript + if now is None: + now = time.time() - start + if o[0] is not None: + print( + "%1.4f %1.0f %1.0f %s" % (now * 1000, o[0] * 1000, o[1] * 1000, o[2]), + file=logfile, + flush=True, + ) + print( + "%1.4f %1.0f %1.0f %s" % (now * 1000, o[0] * 1000, o[1] * 1000, o[2]), + flush=True, + ) + else: + # No text, so no output + pass + + if args.offline: ## offline mode processing (for testing/debugging) + a = load_audio(audio_path) + online.insert_audio_chunk(a) + try: + o = online.process_iter() + except AssertionError as e: + logger.error(f"assertion error: {repr(e)}") + else: + output_transcript(o) + now = None + elif args.comp_unaware: # computational unaware mode + end = beg + min_chunk + while True: + a = load_audio_chunk(audio_path, beg, end) + online.insert_audio_chunk(a) + try: + o = online.process_iter() + except AssertionError as e: + logger.error(f"assertion error: {repr(e)}") + pass + else: + output_transcript(o, now=end) + + logger.debug(f"## last processed {end:.2f}s") + + if end >= duration: + break + + beg = end + + if end + min_chunk > duration: + end = duration + else: + end += min_chunk + now = duration + + else: # online = simultaneous mode + end = 0 + while True: + now = time.time() - start + if now < end + min_chunk: + time.sleep(min_chunk + end - now) + end = time.time() - start + a = load_audio_chunk(audio_path, beg, end) + beg = end + online.insert_audio_chunk(a) + + try: + o = online.process_iter() + except AssertionError as e: + logger.error(f"assertion error: {e}") + pass + else: + output_transcript(o) + now = time.time() - start + logger.debug( + f"## last processed {end:.2f} s, now is {now:.2f}, the latency is {now - end:.2f}" + ) + + if end >= duration: + break + now = None + + o = online.finish() + output_transcript(o, now=now) diff --git a/frigate/data_processing/types.py b/frigate/data_processing/types.py index a19a856bf8..c778805357 100644 --- a/frigate/data_processing/types.py +++ b/frigate/data_processing/types.py @@ -1,9 +1,13 @@ """Embeddings types.""" -import multiprocessing as mp from enum import Enum +from multiprocessing.managers import SyncManager from multiprocessing.sharedctypes import Synchronized +import sherpa_onnx + +from frigate.data_processing.real_time.whisper_online import FasterWhisperASR + class DataProcessorMetrics: image_embeddings_speed: Synchronized @@ -16,18 +20,31 @@ class DataProcessorMetrics: alpr_pps: Synchronized yolov9_lpr_speed: Synchronized yolov9_lpr_pps: Synchronized + review_desc_speed: Synchronized + review_desc_dps: Synchronized + classification_speeds: dict[str, Synchronized] + classification_cps: dict[str, Synchronized] - def __init__(self): - self.image_embeddings_speed = mp.Value("d", 0.0) - self.image_embeddings_eps = mp.Value("d", 0.0) - self.text_embeddings_speed = mp.Value("d", 0.0) - self.text_embeddings_eps = mp.Value("d", 0.0) - self.face_rec_speed = mp.Value("d", 0.0) - self.face_rec_fps = mp.Value("d", 0.0) - self.alpr_speed = mp.Value("d", 0.0) - self.alpr_pps = mp.Value("d", 0.0) - self.yolov9_lpr_speed = mp.Value("d", 0.0) - self.yolov9_lpr_pps = mp.Value("d", 0.0) + def __init__(self, manager: SyncManager, custom_classification_models: list[str]): + self.image_embeddings_speed = manager.Value("d", 0.0) + self.image_embeddings_eps = manager.Value("d", 0.0) + self.text_embeddings_speed = manager.Value("d", 0.0) + self.text_embeddings_eps = manager.Value("d", 0.0) + self.face_rec_speed = manager.Value("d", 0.0) + self.face_rec_fps = manager.Value("d", 0.0) + self.alpr_speed = manager.Value("d", 0.0) + self.alpr_pps = manager.Value("d", 0.0) + self.yolov9_lpr_speed = manager.Value("d", 0.0) + self.yolov9_lpr_pps = manager.Value("d", 0.0) + self.review_desc_speed = manager.Value("d", 0.0) + self.review_desc_dps = manager.Value("d", 0.0) + self.classification_speeds = manager.dict() + self.classification_cps = manager.dict() + + if custom_classification_models: + for key in custom_classification_models: + self.classification_speeds[key] = manager.Value("d", 0.0) + self.classification_cps[key] = manager.Value("d", 0.0) class DataProcessorModelRunner: @@ -41,3 +58,6 @@ class PostProcessDataEnum(str, Enum): recording = "recording" review = "review" tracked_object = "tracked_object" + + +AudioTranscriptionModel = FasterWhisperASR | sherpa_onnx.OnlineRecognizer | None diff --git a/frigate/db/sqlitevecq.py b/frigate/db/sqlitevecq.py index ccb75ae54d..aa4928e849 100644 --- a/frigate/db/sqlitevecq.py +++ b/frigate/db/sqlitevecq.py @@ -1,3 +1,4 @@ +import re import sqlite3 from playhouse.sqliteq import SqliteQueueDatabase @@ -14,6 +15,10 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): conn: sqlite3.Connection = super()._connect(*args, **kwargs) if self.load_vec_extension: self._load_vec_extension(conn) + + # register REGEXP support + self._register_regexp(conn) + return conn def _load_vec_extension(self, conn: sqlite3.Connection) -> None: @@ -21,6 +26,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): conn.load_extension(self.sqlite_vec_path) conn.enable_load_extension(False) + def _register_regexp(self, conn: sqlite3.Connection) -> None: + def regexp(expr: str, item: str) -> bool: + if item is None: + return False + try: + return re.search(expr, item) is not None + except re.error: + return False + + conn.create_function("REGEXP", 2, regexp) + def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: ids = ",".join(["?" for _ in event_ids]) self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids) diff --git a/frigate/detectors/detection_runners.py b/frigate/detectors/detection_runners.py new file mode 100644 index 0000000000..19c91e4875 --- /dev/null +++ b/frigate/detectors/detection_runners.py @@ -0,0 +1,450 @@ +"""Base runner implementation for ONNX models.""" + +import logging +import os +from abc import ABC, abstractmethod +from typing import Any + +import numpy as np +import onnxruntime as ort + +from frigate.util.model import get_ort_providers +from frigate.util.rknn_converter import auto_convert_model, is_rknn_compatible + +logger = logging.getLogger(__name__) + +# Import OpenVINO only when needed to avoid circular dependencies +try: + import openvino as ov +except ImportError: + ov = None + + +def get_openvino_available_devices() -> list[str]: + """Get available OpenVINO devices without using ONNX Runtime. + + Returns: + List of available OpenVINO device names (e.g., ['CPU', 'GPU', 'MYRIAD']) + """ + if ov is None: + logger.debug("OpenVINO is not available") + return [] + + try: + core = ov.Core() + available_devices = core.available_devices + logger.debug(f"OpenVINO available devices: {available_devices}") + return available_devices + except Exception as e: + logger.warning(f"Failed to get OpenVINO available devices: {e}") + return [] + + +def is_openvino_gpu_npu_available() -> bool: + """Check if OpenVINO GPU or NPU devices are available. + + Returns: + True if GPU or NPU devices are available, False otherwise + """ + available_devices = get_openvino_available_devices() + # Check for GPU, NPU, or other acceleration devices (excluding CPU) + acceleration_devices = ["GPU", "MYRIAD", "NPU", "GNA", "HDDL"] + return any(device in available_devices for device in acceleration_devices) + + +class BaseModelRunner(ABC): + """Abstract base class for model runners.""" + + def __init__(self, model_path: str, device: str, **kwargs): + self.model_path = model_path + self.device = device + + @abstractmethod + def get_input_names(self) -> list[str]: + """Get input names for the model.""" + pass + + @abstractmethod + def get_input_width(self) -> int: + """Get the input width of the model.""" + pass + + @abstractmethod + def run(self, input: dict[str, Any]) -> Any | None: + """Run inference with the model.""" + pass + + +class ONNXModelRunner(BaseModelRunner): + """Run ONNX models using ONNX Runtime.""" + + def __init__(self, ort: ort.InferenceSession): + self.ort = ort + + def get_input_names(self) -> list[str]: + return [input.name for input in self.ort.get_inputs()] + + def get_input_width(self) -> int: + """Get the input width of the model.""" + return self.ort.get_inputs()[0].shape[3] + + def run(self, input: dict[str, Any]) -> Any | None: + return self.ort.run(None, input) + + +class CudaGraphRunner(BaseModelRunner): + """Encapsulates CUDA Graph capture and replay using ONNX Runtime IOBinding. + + This runner assumes a single tensor input and binds all model outputs. + + NOTE: CUDA Graphs limit supported model operations, so they are not usable + for more complex models like CLIP or PaddleOCR. + """ + + @staticmethod + def is_complex_model(model_type: str) -> bool: + # Import here to avoid circular imports + from frigate.detectors.detector_config import ModelTypeEnum + from frigate.embeddings.types import EnrichmentModelTypeEnum + + return model_type in [ + ModelTypeEnum.yolonas.value, + EnrichmentModelTypeEnum.paddleocr.value, + EnrichmentModelTypeEnum.jina_v1.value, + EnrichmentModelTypeEnum.jina_v2.value, + EnrichmentModelTypeEnum.yolov9_license_plate.value, + ] + + def __init__(self, session: ort.InferenceSession, cuda_device_id: int): + self._session = session + self._cuda_device_id = cuda_device_id + self._captured = False + self._io_binding: ort.IOBinding | None = None + self._input_name: str | None = None + self._output_names: list[str] | None = None + self._input_ortvalue: ort.OrtValue | None = None + self._output_ortvalues: ort.OrtValue | None = None + + def get_input_names(self) -> list[str]: + """Get input names for the model.""" + return [input.name for input in self._session.get_inputs()] + + def get_input_width(self) -> int: + """Get the input width of the model.""" + return self._session.get_inputs()[0].shape[3] + + def run(self, input: dict[str, Any]): + # Extract the single tensor input (assuming one input) + input_name = list(input.keys())[0] + tensor_input = input[input_name] + tensor_input = np.ascontiguousarray(tensor_input) + + if not self._captured: + # Prepare IOBinding with CUDA buffers and let ORT allocate outputs on device + self._io_binding = self._session.io_binding() + self._input_name = input_name + self._output_names = [o.name for o in self._session.get_outputs()] + + self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy( + tensor_input, "cuda", self._cuda_device_id + ) + self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue) + + for name in self._output_names: + # Bind outputs to CUDA and allow ORT to allocate appropriately + self._io_binding.bind_output(name, "cuda", self._cuda_device_id) + + # First IOBinding run to allocate, execute, and capture CUDA Graph + ro = ort.RunOptions() + self._session.run_with_iobinding(self._io_binding, ro) + self._captured = True + return self._io_binding.copy_outputs_to_cpu() + + # Replay using updated input, copy results to CPU + self._input_ortvalue.update_inplace(tensor_input) + ro = ort.RunOptions() + self._session.run_with_iobinding(self._io_binding, ro) + return self._io_binding.copy_outputs_to_cpu() + + +class OpenVINOModelRunner(BaseModelRunner): + """OpenVINO model runner that handles inference efficiently.""" + + @staticmethod + def is_complex_model(model_type: str) -> bool: + # Import here to avoid circular imports + from frigate.embeddings.types import EnrichmentModelTypeEnum + + return model_type in [EnrichmentModelTypeEnum.paddleocr.value] + + def __init__(self, model_path: str, device: str, model_type: str, **kwargs): + self.model_path = model_path + self.device = device + self.complex_model = OpenVINOModelRunner.is_complex_model(model_type) + + if not os.path.isfile(model_path): + raise FileNotFoundError(f"OpenVINO model file {model_path} not found.") + + if ov is None: + raise ImportError( + "OpenVINO is not available. Please install openvino package." + ) + + self.ov_core = ov.Core() + + # Apply performance optimization + self.ov_core.set_property(device, {"PERF_COUNT": "NO"}) + + if device in ["GPU", "AUTO"]: + self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"}) + + # Compile model + self.compiled_model = self.ov_core.compile_model( + model=model_path, device_name=device + ) + + # Create reusable inference request + self.infer_request = self.compiled_model.create_infer_request() + self.input_tensor: ov.Tensor | None = None + + if not self.complex_model: + try: + input_shape = self.compiled_model.inputs[0].get_shape() + input_element_type = self.compiled_model.inputs[0].get_element_type() + self.input_tensor = ov.Tensor(input_element_type, input_shape) + except RuntimeError: + # model is complex and has dynamic shape + pass + + def get_input_names(self) -> list[str]: + """Get input names for the model.""" + return [input.get_any_name() for input in self.compiled_model.inputs] + + def get_input_width(self) -> int: + """Get the input width of the model.""" + input_info = self.compiled_model.inputs + first_input = input_info[0] + + try: + partial_shape = first_input.get_partial_shape() + # width dimension + if len(partial_shape) >= 4 and partial_shape[3].is_static: + return partial_shape[3].get_length() + + # If width is dynamic or we can't determine it + return -1 + except Exception: + try: + # gemini says some ov versions might still allow this + input_shape = first_input.shape + return input_shape[3] if len(input_shape) >= 4 else -1 + except Exception: + return -1 + + def run(self, inputs: dict[str, Any]) -> list[np.ndarray]: + """Run inference with the model. + + Args: + inputs: Dictionary mapping input names to input data + + Returns: + List of output tensors + """ + # Handle single input case for backward compatibility + if ( + len(inputs) == 1 + and len(self.compiled_model.inputs) == 1 + and self.input_tensor is not None + ): + # Single input case - use the pre-allocated tensor for efficiency + input_data = list(inputs.values())[0] + np.copyto(self.input_tensor.data, input_data) + self.infer_request.infer(self.input_tensor) + else: + if self.complex_model: + try: + # This ensures the model starts with a clean state for each sequence + # Important for RNN models like PaddleOCR recognition + self.infer_request.reset_state() + except Exception: + # this will raise an exception for models with AUTO set as the device + pass + + # Multiple inputs case - set each input by name + for input_name, input_data in inputs.items(): + # Find the input by name + input_port = None + for port in self.compiled_model.inputs: + if port.get_any_name() == input_name: + input_port = port + break + + if input_port is None: + raise ValueError(f"Input '{input_name}' not found in model") + + # Create tensor with the correct element type + input_element_type = input_port.get_element_type() + input_tensor = ov.Tensor(input_element_type, input_data.shape) + np.copyto(input_tensor.data, input_data) + + # Set the input tensor + self.infer_request.set_input_tensor(input_tensor) + + # Run inference + self.infer_request.infer() + + # Get all output tensors + outputs = [] + for i in range(len(self.compiled_model.outputs)): + outputs.append(self.infer_request.get_output_tensor(i).data) + + return outputs + + +class RKNNModelRunner(BaseModelRunner): + """Run RKNN models for embeddings.""" + + def __init__(self, model_path: str, model_type: str = None, core_mask: int = 0): + self.model_path = model_path + self.model_type = model_type + self.core_mask = core_mask + self.rknn = None + self._load_model() + + def _load_model(self): + """Load the RKNN model.""" + try: + from rknnlite.api import RKNNLite + + self.rknn = RKNNLite(verbose=False) + + if self.rknn.load_rknn(self.model_path) != 0: + logger.error(f"Failed to load RKNN model: {self.model_path}") + raise RuntimeError("Failed to load RKNN model") + + if self.rknn.init_runtime(core_mask=self.core_mask) != 0: + logger.error("Failed to initialize RKNN runtime") + raise RuntimeError("Failed to initialize RKNN runtime") + + logger.info(f"Successfully loaded RKNN model: {self.model_path}") + + except ImportError: + logger.error("RKNN Lite not available") + raise ImportError("RKNN Lite not available") + except Exception as e: + logger.error(f"Error loading RKNN model: {e}") + raise + + def get_input_names(self) -> list[str]: + """Get input names for the model.""" + # For detection models, we typically use "input" as the default input name + # For CLIP models, we need to determine the model type from the path + model_name = os.path.basename(self.model_path).lower() + + if "vision" in model_name: + return ["pixel_values"] + elif "arcface" in model_name: + return ["data"] + else: + # Default fallback - try to infer from model type + if self.model_type and "jina-clip" in self.model_type: + if "vision" in self.model_type: + return ["pixel_values"] + + # Generic fallback + return ["input"] + + def get_input_width(self) -> int: + """Get the input width of the model.""" + # For CLIP vision models, this is typically 224 + model_name = os.path.basename(self.model_path).lower() + if "vision" in model_name: + return 224 # CLIP V1 uses 224x224 + elif "arcface" in model_name: + return 112 + # For detection models, we can't easily determine this from the RKNN model + # The calling code should provide this information + return -1 + + def run(self, inputs: dict[str, Any]) -> Any: + """Run inference with the RKNN model.""" + if not self.rknn: + raise RuntimeError("RKNN model not loaded") + + try: + input_names = self.get_input_names() + rknn_inputs = [] + + for name in input_names: + if name in inputs: + if name == "pixel_values": + # RKNN expects NHWC format, but ONNX typically provides NCHW + # Transpose from [batch, channels, height, width] to [batch, height, width, channels] + pixel_data = inputs[name] + if len(pixel_data.shape) == 4 and pixel_data.shape[1] == 3: + # Transpose from NCHW to NHWC + pixel_data = np.transpose(pixel_data, (0, 2, 3, 1)) + rknn_inputs.append(pixel_data) + else: + rknn_inputs.append(inputs[name]) + + outputs = self.rknn.inference(inputs=rknn_inputs) + return outputs + + except Exception as e: + logger.error(f"Error during RKNN inference: {e}") + raise + + def __del__(self): + """Cleanup when the runner is destroyed.""" + if self.rknn: + try: + self.rknn.release() + except Exception: + pass + + +def get_optimized_runner( + model_path: str, device: str | None, model_type: str, **kwargs +) -> BaseModelRunner: + """Get an optimized runner for the hardware.""" + device = device or "AUTO" + if is_rknn_compatible(model_path): + rknn_path = auto_convert_model(model_path) + + if rknn_path: + return RKNNModelRunner(rknn_path) + + providers, options = get_ort_providers(device == "CPU", device, **kwargs) + + if providers[0] == "CPUExecutionProvider": + # In the default image, ONNXRuntime is used so we will only get CPUExecutionProvider + # In other images we will get CUDA / ROCm which are preferred over OpenVINO + # There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images + if device != "CPU" and is_openvino_gpu_npu_available(): + return OpenVINOModelRunner(model_path, device, model_type, **kwargs) + + if ( + not CudaGraphRunner.is_complex_model(model_type) + and providers[0] == "CUDAExecutionProvider" + ): + options[0] = { + **options[0], + "enable_cuda_graph": True, + } + return CudaGraphRunner( + ort.InferenceSession( + model_path, + providers=providers, + provider_options=options, + ), + options[0]["device_id"], + ) + + return ONNXModelRunner( + ort.InferenceSession( + model_path, + providers=providers, + provider_options=options, + ) + ) diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index 7ee04bde5e..aa92f28f41 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -154,12 +154,12 @@ class ModelConfig(BaseModel): self.width = model_info["width"] self.height = model_info["height"] - self.input_tensor = model_info["inputShape"] - self.input_pixel_format = model_info["pixelFormat"] - self.model_type = model_info["type"] + self.input_tensor = InputTensorEnum(model_info["inputShape"]) + self.input_pixel_format = PixelFormatEnum(model_info["pixelFormat"]) + self.model_type = ModelTypeEnum(model_info["type"]) if model_info.get("inputDataType"): - self.input_dtype = model_info["inputDataType"] + self.input_dtype = InputDTypeEnum(model_info["inputDataType"]) # RKNN always uses NHWC if detector == "rknn": diff --git a/frigate/detectors/detector_utils.py b/frigate/detectors/detector_utils.py new file mode 100644 index 0000000000..d732de8717 --- /dev/null +++ b/frigate/detectors/detector_utils.py @@ -0,0 +1,74 @@ +import logging +import os + +import numpy as np + +try: + from tflite_runtime.interpreter import Interpreter, load_delegate +except ModuleNotFoundError: + from tensorflow.lite.python.interpreter import Interpreter, load_delegate + + +logger = logging.getLogger(__name__) + + +def tflite_init(self, interpreter): + self.interpreter = interpreter + + self.interpreter.allocate_tensors() + + self.tensor_input_details = self.interpreter.get_input_details() + self.tensor_output_details = self.interpreter.get_output_details() + + +def tflite_detect_raw(self, tensor_input): + self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input) + self.interpreter.invoke() + + boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0] + class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0] + scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0] + count = int(self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0]) + + detections = np.zeros((20, 6), np.float32) + + for i in range(count): + if scores[i] < 0.4 or i == 20: + break + detections[i] = [ + class_ids[i], + float(scores[i]), + boxes[i][0], + boxes[i][1], + boxes[i][2], + boxes[i][3], + ] + + return detections + + +def tflite_load_delegate_interpreter( + delegate_library: str, detector_config, device_config +): + try: + logger.info("Attempting to load NPU") + tf_delegate = load_delegate(delegate_library, device_config) + logger.info("NPU found") + interpreter = Interpreter( + model_path=detector_config.model.path, + experimental_delegates=[tf_delegate], + ) + return interpreter + except ValueError: + _, ext = os.path.splitext(detector_config.model.path) + + if ext and ext != ".tflite": + logger.error( + "Incorrect model used with NPU. Only .tflite models can be used with a TFLite delegate." + ) + else: + logger.error( + "No NPU was detected. If you do not have a TFLite device yet, you must configure CPU detectors." + ) + + raise diff --git a/frigate/detectors/plugins/cpu_tfl.py b/frigate/detectors/plugins/cpu_tfl.py index 8a54363e1a..37cc10777c 100644 --- a/frigate/detectors/plugins/cpu_tfl.py +++ b/frigate/detectors/plugins/cpu_tfl.py @@ -1,11 +1,13 @@ import logging -import numpy as np from pydantic import Field from typing_extensions import Literal from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig +from frigate.log import redirect_output_to_logger + +from ..detector_utils import tflite_detect_raw, tflite_init try: from tflite_runtime.interpreter import Interpreter @@ -26,40 +28,14 @@ class CpuDetectorConfig(BaseDetectorConfig): class CpuTfl(DetectionApi): type_key = DETECTOR_KEY + @redirect_output_to_logger(logger, logging.DEBUG) def __init__(self, detector_config: CpuDetectorConfig): - self.interpreter = Interpreter( + interpreter = Interpreter( model_path=detector_config.model.path, num_threads=detector_config.num_threads or 3, ) - self.interpreter.allocate_tensors() - - self.tensor_input_details = self.interpreter.get_input_details() - self.tensor_output_details = self.interpreter.get_output_details() + tflite_init(self, interpreter) def detect_raw(self, tensor_input): - self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input) - self.interpreter.invoke() - - boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0] - class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0] - scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0] - count = int( - self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0] - ) - - detections = np.zeros((20, 6), np.float32) - - for i in range(count): - if scores[i] < 0.4 or i == 20: - break - detections[i] = [ - class_ids[i], - float(scores[i]), - boxes[i][0], - boxes[i][1], - boxes[i][2], - boxes[i][3], - ] - - return detections + return tflite_detect_raw(self, tensor_input) diff --git a/frigate/detectors/plugins/degirum.py b/frigate/detectors/plugins/degirum.py new file mode 100644 index 0000000000..28a13389f9 --- /dev/null +++ b/frigate/detectors/plugins/degirum.py @@ -0,0 +1,139 @@ +import logging +import queue + +import numpy as np +from pydantic import Field +from typing_extensions import Literal + +from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detector_config import BaseDetectorConfig + +logger = logging.getLogger(__name__) +DETECTOR_KEY = "degirum" + + +### DETECTOR CONFIG ### +class DGDetectorConfig(BaseDetectorConfig): + type: Literal[DETECTOR_KEY] + location: str = Field(default=None, title="Inference Location") + zoo: str = Field(default=None, title="Model Zoo") + token: str = Field(default=None, title="DeGirum Cloud Token") + + +### ACTUAL DETECTOR ### +class DGDetector(DetectionApi): + type_key = DETECTOR_KEY + + def __init__(self, detector_config: DGDetectorConfig): + try: + import degirum as dg + except ModuleNotFoundError: + raise ImportError("Unable to import DeGirum detector.") + + self._queue = queue.Queue() + self._zoo = dg.connect( + detector_config.location, detector_config.zoo, detector_config.token + ) + + logger.debug(f"Models in zoo: {self._zoo.list_models()}") + + self.dg_model = self._zoo.load_model( + detector_config.model.path, + ) + + # Setting input image format to raw reduces preprocessing time + self.dg_model.input_image_format = "RAW" + + # Prioritize the most powerful hardware available + self.select_best_device_type() + # Frigate handles pre processing as long as these are all set + input_shape = self.dg_model.input_shape[0] + self.model_height = input_shape[1] + self.model_width = input_shape[2] + + # Passing in dummy frame so initial connection latency happens in + # init function and not during actual prediction + frame = np.zeros( + (detector_config.model.width, detector_config.model.height, 3), + dtype=np.uint8, + ) + # Pass in frame to overcome first frame latency + self.dg_model(frame) + self.prediction = self.prediction_generator() + + def select_best_device_type(self): + """ + Helper function that selects fastest hardware available per model runtime + """ + types = self.dg_model.supported_device_types + + device_map = { + "OPENVINO": ["GPU", "NPU", "CPU"], + "HAILORT": ["HAILO8L", "HAILO8"], + "N2X": ["ORCA1", "CPU"], + "ONNX": ["VITIS_NPU", "CPU"], + "RKNN": ["RK3566", "RK3568", "RK3588"], + "TENSORRT": ["DLA", "GPU", "DLA_ONLY"], + "TFLITE": ["ARMNN", "EDGETPU", "CPU"], + } + + runtime = types[0].split("/")[0] + # Just create an array of format {runtime}/{hardware} for every hardware + # in the value for appropriate key in device_map + self.dg_model.device_type = [ + f"{runtime}/{hardware}" for hardware in device_map[runtime] + ] + + def prediction_generator(self): + """ + Generator for all incoming frames. By using this generator, we don't have to keep + reconnecting our websocket on every "predict" call. + """ + logger.debug("Prediction generator was called") + with self.dg_model as model: + while 1: + logger.info(f"q size before calling get: {self._queue.qsize()}") + data = self._queue.get(block=True) + logger.info(f"q size after calling get: {self._queue.qsize()}") + logger.debug( + f"Data we're passing into model predict: {data}, shape of data: {data.shape}" + ) + result = model.predict(data) + logger.debug(f"Prediction result: {result}") + yield result + + def detect_raw(self, tensor_input): + # Reshaping tensor to work with pysdk + truncated_input = tensor_input.reshape(tensor_input.shape[1:]) + logger.debug(f"Detect raw was called for tensor input: {tensor_input}") + + # add tensor_input to input queue + self._queue.put(truncated_input) + logger.debug(f"Queue size after adding truncated input: {self._queue.qsize()}") + + # define empty detection result + detections = np.zeros((20, 6), np.float32) + # grab prediction + res = next(self.prediction) + + # If we have an empty prediction, return immediately + if len(res.results) == 0 or len(res.results[0]) == 0: + return detections + + i = 0 + for result in res.results: + if i >= 20: + break + + detections[i] = [ + result["category_id"], + float(result["score"]), + result["bbox"][1] / self.model_height, + result["bbox"][0] / self.model_width, + result["bbox"][3] / self.model_height, + result["bbox"][2] / self.model_width, + ] + i += 1 + + logger.debug(f"Detections output: {detections}") + return detections diff --git a/frigate/detectors/plugins/hailo8l.py b/frigate/detectors/plugins/hailo8l.py index aa856dd80e..cafc809c97 100755 --- a/frigate/detectors/plugins/hailo8l.py +++ b/frigate/detectors/plugins/hailo8l.py @@ -33,10 +33,6 @@ def preprocess_tensor(image: np.ndarray, model_w: int, model_h: int) -> np.ndarr image = image[0] h, w = image.shape[:2] - - if (w, h) == (320, 320) and (model_w, model_h) == (640, 640): - return cv2.resize(image, (model_w, model_h), interpolation=cv2.INTER_LINEAR) - scale = min(model_w / w, model_h / h) new_w, new_h = int(w * scale), int(h * scale) resized_image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_CUBIC) diff --git a/frigate/detectors/plugins/memryx.py b/frigate/detectors/plugins/memryx.py new file mode 100644 index 0000000000..3b424bcc09 --- /dev/null +++ b/frigate/detectors/plugins/memryx.py @@ -0,0 +1,767 @@ +import glob +import logging +import os +import shutil +import time +import urllib.request +import zipfile +from queue import Queue + +import cv2 +import numpy as np +from pydantic import BaseModel, Field +from typing_extensions import Literal + +from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detector_config import ( + BaseDetectorConfig, + ModelTypeEnum, +) +from frigate.util.model import post_process_yolo + +logger = logging.getLogger(__name__) + +DETECTOR_KEY = "memryx" + + +# Configuration class for model settings +class ModelConfig(BaseModel): + path: str = Field(default=None, title="Model Path") # Path to the DFP file + labelmap_path: str = Field(default=None, title="Path to Label Map") + + +class MemryXDetectorConfig(BaseDetectorConfig): + type: Literal[DETECTOR_KEY] + device: str = Field(default="PCIe", title="Device Path") + + +class MemryXDetector(DetectionApi): + type_key = DETECTOR_KEY # Set the type key + supported_models = [ + ModelTypeEnum.ssd, + ModelTypeEnum.yolonas, + ModelTypeEnum.yologeneric, # Treated as yolov9 in MemryX implementation + ModelTypeEnum.yolox, + ] + + def __init__(self, detector_config): + """Initialize MemryX detector with the provided configuration.""" + try: + # Import MemryX SDK + from memryx import AsyncAccl + except ModuleNotFoundError: + raise ImportError( + "MemryX SDK is not installed. Install it and set up MIX environment." + ) + return + + model_cfg = getattr(detector_config, "model", None) + + # Check if model_type was explicitly set by the user + if "model_type" in getattr(model_cfg, "__fields_set__", set()): + detector_config.model.model_type = model_cfg.model_type + else: + logger.info( + "model_type not set in config — defaulting to yolonas for MemryX." + ) + detector_config.model.model_type = ModelTypeEnum.yolonas + + self.capture_queue = Queue(maxsize=10) + self.output_queue = Queue(maxsize=10) + self.capture_id_queue = Queue(maxsize=10) + self.logger = logger + + self.memx_model_path = detector_config.model.path # Path to .dfp file + self.memx_post_model = None # Path to .post file + self.expected_post_model = None + + self.memx_device_path = detector_config.device # Device path + # Parse the device string to split PCIe: + device_str = self.memx_device_path + self.device_id = [] + self.device_id.append(int(device_str.split(":")[1])) + + self.memx_model_height = detector_config.model.height + self.memx_model_width = detector_config.model.width + self.memx_model_type = detector_config.model.model_type + + self.cache_dir = "/memryx_models" + + if self.memx_model_type == ModelTypeEnum.yologeneric: + model_mapping = { + (640, 640): ( + "https://developer.memryx.com/example_files/2p0_frigate/yolov9_640.zip", + "yolov9_640", + ), + (320, 320): ( + "https://developer.memryx.com/example_files/2p0_frigate/yolov9_320.zip", + "yolov9_320", + ), + } + self.model_url, self.model_folder = model_mapping.get( + (self.memx_model_height, self.memx_model_width), + ( + "https://developer.memryx.com/example_files/2p0_frigate/yolov9_320.zip", + "yolov9_320", + ), + ) + self.expected_dfp_model = "YOLO_v9_small_onnx.dfp" + + elif self.memx_model_type == ModelTypeEnum.yolonas: + model_mapping = { + (640, 640): ( + "https://developer.memryx.com/example_files/2p0_frigate/yolonas_640.zip", + "yolonas_640", + ), + (320, 320): ( + "https://developer.memryx.com/example_files/2p0_frigate/yolonas_320.zip", + "yolonas_320", + ), + } + self.model_url, self.model_folder = model_mapping.get( + (self.memx_model_height, self.memx_model_width), + ( + "https://developer.memryx.com/example_files/2p0_frigate/yolonas_320.zip", + "yolonas_320", + ), + ) + self.expected_dfp_model = "yolo_nas_s.dfp" + self.expected_post_model = "yolo_nas_s_post.onnx" + + elif self.memx_model_type == ModelTypeEnum.yolox: + self.model_folder = "yolox" + self.model_url = ( + "https://developer.memryx.com/example_files/2p0_frigate/yolox.zip" + ) + self.expected_dfp_model = "YOLOX_640_640_3_onnx.dfp" + self.set_strides_grids() + + elif self.memx_model_type == ModelTypeEnum.ssd: + self.model_folder = "ssd" + self.model_url = ( + "https://developer.memryx.com/example_files/2p0_frigate/ssd.zip" + ) + self.expected_dfp_model = "SSDlite_MobileNet_v2_320_320_3_onnx.dfp" + self.expected_post_model = "SSDlite_MobileNet_v2_320_320_3_onnx_post.onnx" + + self.check_and_prepare_model() + logger.info( + f"Initializing MemryX with model: {self.memx_model_path} on device {self.memx_device_path}" + ) + + try: + # Load MemryX Model + logger.info(f"dfp path: {self.memx_model_path}") + + # Initialization code + # Load MemryX Model with a device target + self.accl = AsyncAccl( + self.memx_model_path, + device_ids=self.device_id, # AsyncAccl device ids + local_mode=True, + ) + + # Models that use cropped post-processing sections (YOLO-NAS and SSD) + # --> These will be moved to pure numpy in the future to improve performance on low-end CPUs + if self.memx_post_model: + self.accl.set_postprocessing_model(self.memx_post_model, model_idx=0) + + self.accl.connect_input(self.process_input) + self.accl.connect_output(self.process_output) + + logger.info( + f"Loaded MemryX model from {self.memx_model_path} and {self.memx_post_model}" + ) + + except Exception as e: + logger.error(f"Failed to initialize MemryX model: {e}") + raise + + def _acquire_file_lock(self, lock_path: str, timeout: int = 60, poll: float = 0.2): + """ + Create an exclusive lock file. Blocks (with polling) until it can acquire, + or raises TimeoutError. Uses only stdlib (os.O_EXCL). + """ + start = time.time() + while True: + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR) + os.close(fd) + return + except FileExistsError: + if time.time() - start > timeout: + raise TimeoutError(f"Timeout waiting for lock: {lock_path}") + time.sleep(poll) + + def _release_file_lock(self, lock_path: str): + """Best-effort removal of the lock file.""" + try: + os.remove(lock_path) + except FileNotFoundError: + pass + + def load_yolo_constants(self): + base = f"{self.cache_dir}/{self.model_folder}" + # constants for yolov9 post-processing + self.const_A = np.load(f"{base}/_model_22_Constant_9_output_0.npy") + self.const_B = np.load(f"{base}/_model_22_Constant_10_output_0.npy") + self.const_C = np.load(f"{base}/_model_22_Constant_12_output_0.npy") + + def check_and_prepare_model(self): + if not os.path.exists(self.cache_dir): + os.makedirs(self.cache_dir, exist_ok=True) + + lock_path = os.path.join(self.cache_dir, f".{self.model_folder}.lock") + self._acquire_file_lock(lock_path) + + try: + # ---------- CASE 1: user provided a custom model path ---------- + if self.memx_model_path: + if not self.memx_model_path.endswith(".zip"): + raise ValueError( + f"Invalid model path: {self.memx_model_path}. " + "Only .zip files are supported. Please provide a .zip model archive." + ) + if not os.path.exists(self.memx_model_path): + raise FileNotFoundError( + f"Custom model zip not found: {self.memx_model_path}" + ) + + logger.info(f"User provided zip model: {self.memx_model_path}") + + # Extract custom zip into a separate area so it never clashes with MemryX cache + custom_dir = os.path.join( + self.cache_dir, "custom_models", self.model_folder + ) + if os.path.isdir(custom_dir): + shutil.rmtree(custom_dir) + os.makedirs(custom_dir, exist_ok=True) + + with zipfile.ZipFile(self.memx_model_path, "r") as zip_ref: + zip_ref.extractall(custom_dir) + logger.info(f"Custom model extracted to {custom_dir}.") + + # Find .dfp and optional *_post.onnx recursively + dfp_candidates = glob.glob( + os.path.join(custom_dir, "**", "*.dfp"), recursive=True + ) + post_candidates = glob.glob( + os.path.join(custom_dir, "**", "*_post.onnx"), recursive=True + ) + + if not dfp_candidates: + raise FileNotFoundError( + "No .dfp file found in custom model zip after extraction." + ) + + self.memx_model_path = dfp_candidates[0] + + # Handle post model requirements by model type + if self.memx_model_type in [ + ModelTypeEnum.yologeneric, + ModelTypeEnum.yolonas, + ModelTypeEnum.ssd, + ]: + if not post_candidates: + raise FileNotFoundError( + f"No *_post.onnx file found in custom model zip for {self.memx_model_type.name}." + ) + self.memx_post_model = post_candidates[0] + elif self.memx_model_type == ModelTypeEnum.yolox: + # Explicitly ignore any post model even if present + self.memx_post_model = None + else: + # Future model types can optionally use post if present + self.memx_post_model = ( + post_candidates[0] if post_candidates else None + ) + + logger.info(f"Using custom model: {self.memx_model_path}") + return + + # ---------- CASE 2: no custom model path -> use MemryX cached models ---------- + model_subdir = os.path.join(self.cache_dir, self.model_folder) + dfp_path = os.path.join(model_subdir, self.expected_dfp_model) + post_path = ( + os.path.join(model_subdir, self.expected_post_model) + if self.expected_post_model + else None + ) + + dfp_exists = os.path.exists(dfp_path) + post_exists = os.path.exists(post_path) if post_path else True + + if dfp_exists and post_exists: + logger.info("Using cached models.") + self.memx_model_path = dfp_path + self.memx_post_model = post_path + if self.memx_model_type == ModelTypeEnum.yologeneric: + self.load_yolo_constants() + return + + # ---------- CASE 3: download MemryX model (no cache) ---------- + logger.info( + f"Model files not found locally. Downloading from {self.model_url}..." + ) + zip_path = os.path.join(self.cache_dir, f"{self.model_folder}.zip") + + try: + if not os.path.exists(zip_path): + urllib.request.urlretrieve(self.model_url, zip_path) + logger.info(f"Model ZIP downloaded to {zip_path}. Extracting...") + + if not os.path.exists(model_subdir): + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(self.cache_dir) + logger.info(f"Model extracted to {self.cache_dir}.") + + # Re-assign model paths after extraction + self.memx_model_path = os.path.join( + model_subdir, self.expected_dfp_model + ) + self.memx_post_model = ( + os.path.join(model_subdir, self.expected_post_model) + if self.expected_post_model + else None + ) + + if self.memx_model_type == ModelTypeEnum.yologeneric: + self.load_yolo_constants() + + finally: + if os.path.exists(zip_path): + try: + os.remove(zip_path) + logger.info("Cleaned up ZIP file after extraction.") + except Exception as e: + logger.warning( + f"Failed to remove downloaded zip {zip_path}: {e}" + ) + + finally: + self._release_file_lock(lock_path) + + def send_input(self, connection_id, tensor_input: np.ndarray): + """Pre-process (if needed) and send frame to MemryX input queue""" + if tensor_input is None: + raise ValueError("[send_input] No image data provided for inference") + + if self.memx_model_type == ModelTypeEnum.yolonas: + if tensor_input.ndim == 4 and tensor_input.shape[1:] == (320, 320, 3): + logger.debug("Transposing tensor from NHWC to NCHW for YOLO-NAS") + tensor_input = np.transpose( + tensor_input, (0, 3, 1, 2) + ) # (1, H, W, C) → (1, C, H, W) + tensor_input = tensor_input.astype(np.float32) + tensor_input /= 255 + + if self.memx_model_type == ModelTypeEnum.yolox: + # Remove batch dim → (3, 640, 640) + tensor_input = tensor_input.squeeze(0) + + # Convert CHW to HWC for OpenCV + tensor_input = np.transpose(tensor_input, (1, 2, 0)) # (640, 640, 3) + + padded_img = np.ones((640, 640, 3), dtype=np.uint8) * 114 + + scale = min( + 640 / float(tensor_input.shape[0]), 640 / float(tensor_input.shape[1]) + ) + sx, sy = ( + int(tensor_input.shape[1] * scale), + int(tensor_input.shape[0] * scale), + ) + + resized_img = cv2.resize( + tensor_input, (sx, sy), interpolation=cv2.INTER_LINEAR + ) + padded_img[:sy, :sx] = resized_img.astype(np.uint8) + + # Step 4: Slice the padded image into 4 quadrants and concatenate them into 12 channels + x0 = padded_img[0::2, 0::2, :] # Top-left + x1 = padded_img[1::2, 0::2, :] # Bottom-left + x2 = padded_img[0::2, 1::2, :] # Top-right + x3 = padded_img[1::2, 1::2, :] # Bottom-right + + # Step 5: Concatenate along the channel dimension (axis 2) + concatenated_img = np.concatenate([x0, x1, x2, x3], axis=2) + tensor_input = concatenated_img.astype(np.float32) + # Convert to CHW format (12, 320, 320) + tensor_input = np.transpose(tensor_input, (2, 0, 1)) + + # Add batch dimension → (1, 12, 320, 320) + tensor_input = np.expand_dims(tensor_input, axis=0) + + # Send frame to MemryX for processing + self.capture_queue.put(tensor_input) + self.capture_id_queue.put(connection_id) + + def process_input(self): + """Input callback function: wait for frames in the input queue, preprocess, and send to MX3 (return)""" + while True: + try: + # Wait for a frame from the queue (blocking call) + frame = self.capture_queue.get( + block=True + ) # Blocks until data is available + + return frame + + except Exception as e: + logger.info(f"[process_input] Error processing input: {e}") + time.sleep(0.1) # Prevent busy waiting in case of error + + def receive_output(self): + """Retrieve processed results from MemryX output queue + a copy of the original frame""" + connection_id = ( + self.capture_id_queue.get() + ) # Get the corresponding connection ID + detections = self.output_queue.get() # Get detections from MemryX + + return connection_id, detections + + def post_process_yolonas(self, output): + predictions = output[0] + + 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 + + if class_id < 0: + break + + detections[i] = [ + class_id, + confidence, + y_min / self.memx_model_height, + x_min / self.memx_model_width, + y_max / self.memx_model_height, + x_max / self.memx_model_width, + ] + + # Return the list of final detections + self.output_queue.put(detections) + + def process_yolo(self, class_id, conf, pos): + """ + Takes in class ID, confidence score, and array of [x, y, w, h] that describes detection position, + returns an array that's easily passable back to Frigate. + """ + return [ + class_id, # class ID + conf, # confidence score + (pos[1] - (pos[3] / 2)) / self.memx_model_height, # y_min + (pos[0] - (pos[2] / 2)) / self.memx_model_width, # x_min + (pos[1] + (pos[3] / 2)) / self.memx_model_height, # y_max + (pos[0] + (pos[2] / 2)) / self.memx_model_width, # x_max + ] + + def set_strides_grids(self): + grids = [] + expanded_strides = [] + + strides = [8, 16, 32] + + hsize_list = [self.memx_model_height // stride for stride in strides] + wsize_list = [self.memx_model_width // stride for stride in strides] + + for hsize, wsize, stride in zip(hsize_list, wsize_list, strides): + xv, yv = np.meshgrid(np.arange(wsize), np.arange(hsize)) + grid = np.stack((xv, yv), 2).reshape(1, -1, 2) + grids.append(grid) + shape = grid.shape[:2] + expanded_strides.append(np.full((*shape, 1), stride)) + self.grids = np.concatenate(grids, 1) + self.expanded_strides = np.concatenate(expanded_strides, 1) + + def sigmoid(self, x: np.ndarray) -> np.ndarray: + return 1 / (1 + np.exp(-x)) + + def onnx_concat(self, inputs: list, axis: int) -> np.ndarray: + # Ensure all inputs are numpy arrays + if not all(isinstance(x, np.ndarray) for x in inputs): + raise TypeError("All inputs must be numpy arrays.") + + # Ensure shapes match on non-concat axes + ref_shape = list(inputs[0].shape) + for i, tensor in enumerate(inputs[1:], start=1): + for ax in range(len(ref_shape)): + if ax == axis: + continue + if tensor.shape[ax] != ref_shape[ax]: + raise ValueError( + f"Shape mismatch at axis {ax} between input[0] and input[{i}]" + ) + + return np.concatenate(inputs, axis=axis) + + def onnx_reshape(self, data: np.ndarray, shape: np.ndarray) -> np.ndarray: + # Ensure shape is a 1D array of integers + target_shape = shape.astype(int).tolist() + + # Use NumPy reshape with dynamic handling of -1 + reshaped = np.reshape(data, target_shape) + + return reshaped + + def post_process_yolox(self, output): + output_785 = output[0] # 785 + output_794 = output[1] # 794 + output_795 = output[2] # 795 + output_811 = output[3] # 811 + output_820 = output[4] # 820 + output_821 = output[5] # 821 + output_837 = output[6] # 837 + output_846 = output[7] # 846 + output_847 = output[8] # 847 + + output_795 = self.sigmoid(output_795) + output_785 = self.sigmoid(output_785) + output_821 = self.sigmoid(output_821) + output_811 = self.sigmoid(output_811) + output_847 = self.sigmoid(output_847) + output_837 = self.sigmoid(output_837) + + concat_1 = self.onnx_concat([output_794, output_795, output_785], axis=1) + concat_2 = self.onnx_concat([output_820, output_821, output_811], axis=1) + concat_3 = self.onnx_concat([output_846, output_847, output_837], axis=1) + + shape = np.array([1, 85, -1], dtype=np.int64) + + reshape_1 = self.onnx_reshape(concat_1, shape) + reshape_2 = self.onnx_reshape(concat_2, shape) + reshape_3 = self.onnx_reshape(concat_3, shape) + + concat_out = self.onnx_concat([reshape_1, reshape_2, reshape_3], axis=2) + + output = concat_out.transpose(0, 2, 1) # 1, 840, 85 + + self.num_classes = output.shape[2] - 5 + + # [x, y, h, w, box_score, class_no_1, ..., class_no_80], + results = output + + results[..., :2] = (results[..., :2] + self.grids) * self.expanded_strides + results[..., 2:4] = np.exp(results[..., 2:4]) * self.expanded_strides + image_pred = results[0, ...] + + class_conf = np.max( + image_pred[:, 5 : 5 + self.num_classes], axis=1, keepdims=True + ) + class_pred = np.argmax(image_pred[:, 5 : 5 + self.num_classes], axis=1) + class_pred = np.expand_dims(class_pred, axis=1) + + conf_mask = (image_pred[:, 4] * class_conf.squeeze() >= 0.3).squeeze() + # Detections ordered as (x1, y1, x2, y2, obj_conf, class_conf, class_pred) + detections = np.concatenate((image_pred[:, :5], class_conf, class_pred), axis=1) + detections = detections[conf_mask] + + # Sort by class confidence (index 5) and keep top 20 detections + ordered = detections[detections[:, 5].argsort()[::-1]][:20] + + # Prepare a final detections array of shape (20, 6) + final_detections = np.zeros((20, 6), np.float32) + for i, object_detected in enumerate(ordered): + final_detections[i] = self.process_yolo( + object_detected[6], object_detected[5], object_detected[:4] + ) + + self.output_queue.put(final_detections) + + def post_process_ssdlite(self, outputs): + dets = outputs[0].squeeze(0) # Shape: (1, num_dets, 5) + labels = outputs[1].squeeze(0) + + detections = [] + + for i in range(dets.shape[0]): + x_min, y_min, x_max, y_max, confidence = dets[i] + class_id = int(labels[i]) # Convert label to integer + + if confidence < 0.45: + continue # Skip detections below threshold + + # Convert coordinates to integers + x_min, y_min, x_max, y_max = map(int, [x_min, y_min, x_max, y_max]) + + # Append valid detections [class_id, confidence, x, y, width, height] + detections.append([class_id, confidence, x_min, y_min, x_max, y_max]) + + final_detections = np.zeros((20, 6), np.float32) + + if len(detections) == 0: + # logger.info("No detections found.") + self.output_queue.put(final_detections) + return + + # Convert to NumPy array + detections = np.array(detections, dtype=np.float32) + + # Apply Non-Maximum Suppression (NMS) + bboxes = detections[:, 2:6].tolist() # (x_min, y_min, width, height) + scores = detections[:, 1].tolist() # Confidence scores + + indices = cv2.dnn.NMSBoxes(bboxes, scores, 0.45, 0.5) + + if len(indices) > 0: + indices = indices.flatten()[:20] # Keep only the top 20 detections + selected_detections = detections[indices] + + # Normalize coordinates AFTER NMS + for i, det in enumerate(selected_detections): + class_id, confidence, x_min, y_min, x_max, y_max = det + + # Normalize coordinates + x_min /= self.memx_model_width + y_min /= self.memx_model_height + x_max /= self.memx_model_width + y_max /= self.memx_model_height + + final_detections[i] = [class_id, confidence, y_min, x_min, y_max, x_max] + + self.output_queue.put(final_detections) + + def onnx_reshape_with_allowzero( + self, data: np.ndarray, shape: np.ndarray, allowzero: int = 0 + ) -> np.ndarray: + shape = shape.astype(int) + input_shape = data.shape + output_shape = [] + + for i, dim in enumerate(shape): + if dim == 0 and allowzero == 0: + output_shape.append(input_shape[i]) # Copy dimension from input + else: + output_shape.append(dim) + + # Now let NumPy infer any -1 if needed + reshaped = np.reshape(data, output_shape) + + return reshaped + + def process_output(self, *outputs): + """Output callback function -- receives frames from the MX3 and triggers post-processing""" + if self.memx_model_type == ModelTypeEnum.yologeneric: + if not self.memx_post_model: + conv_out1 = outputs[0] + conv_out2 = outputs[1] + conv_out3 = outputs[2] + conv_out4 = outputs[3] + conv_out5 = outputs[4] + conv_out6 = outputs[5] + + concat_1 = self.onnx_concat([conv_out1, conv_out2], axis=1) + concat_2 = self.onnx_concat([conv_out3, conv_out4], axis=1) + concat_3 = self.onnx_concat([conv_out5, conv_out6], axis=1) + + shape = np.array([1, 144, -1], dtype=np.int64) + + reshaped_1 = self.onnx_reshape_with_allowzero( + concat_1, shape, allowzero=0 + ) + reshaped_2 = self.onnx_reshape_with_allowzero( + concat_2, shape, allowzero=0 + ) + reshaped_3 = self.onnx_reshape_with_allowzero( + concat_3, shape, allowzero=0 + ) + + concat_4 = self.onnx_concat([reshaped_1, reshaped_2, reshaped_3], 2) + + axis = 1 + split_sizes = [64, 80] + + # Calculate indices at which to split + indices = np.cumsum(split_sizes)[ + :-1 + ] # [64] — split before the second chunk + + # Perform split along axis 1 + split_0, split_1 = np.split(concat_4, indices, axis=axis) + + num_boxes = 2100 if self.memx_model_height == 320 else 8400 + shape1 = np.array([1, 4, 16, num_boxes]) + reshape_4 = self.onnx_reshape_with_allowzero( + split_0, shape1, allowzero=0 + ) + + transpose_1 = reshape_4.transpose(0, 2, 1, 3) + + axis = 1 # As per ONNX softmax node + + # Subtract max for numerical stability + x_max = np.max(transpose_1, axis=axis, keepdims=True) + x_exp = np.exp(transpose_1 - x_max) + x_sum = np.sum(x_exp, axis=axis, keepdims=True) + softmax_output = x_exp / x_sum + + # Weight W from the ONNX initializer (1, 16, 1, 1) with values 0 to 15 + W = np.arange(16, dtype=np.float32).reshape( + 1, 16, 1, 1 + ) # (1, 16, 1, 1) + + # Apply 1x1 convolution: this is a weighted sum over channels + conv_output = np.sum( + softmax_output * W, axis=1, keepdims=True + ) # shape: (1, 1, 4, 8400) + + shape2 = np.array([1, 4, num_boxes]) + reshape_5 = self.onnx_reshape_with_allowzero( + conv_output, shape2, allowzero=0 + ) + + # ONNX Slice — get first 2 channels: [0:2] along axis 1 + slice_output1 = reshape_5[:, 0:2, :] # Result: (1, 2, 8400) + + # Slice channels 2 to 4 → axis = 1 + slice_output2 = reshape_5[:, 2:4, :] + + # Perform Subtraction + sub_output = self.const_A - slice_output1 # Equivalent to ONNX Sub + + # Perform the ONNX-style Add + add_output = self.const_B + slice_output2 + + sub1 = add_output - sub_output + + add1 = sub_output + add_output + + div_output = add1 / 2.0 + + concat_5 = self.onnx_concat([div_output, sub1], axis=1) + + # Expand B to (1, 1, 8400) so it can broadcast across axis=1 (4 channels) + const_C_expanded = self.const_C[:, np.newaxis, :] # Shape: (1, 1, 8400) + + # Perform ONNX-style element-wise multiplication + mul_output = concat_5 * const_C_expanded # Result: (1, 4, 8400) + + sigmoid_output = self.sigmoid(split_1) + outputs = self.onnx_concat([mul_output, sigmoid_output], axis=1) + + final_detections = post_process_yolo( + outputs, self.memx_model_width, self.memx_model_height + ) + self.output_queue.put(final_detections) + + elif self.memx_model_type == ModelTypeEnum.yolonas: + return self.post_process_yolonas(outputs) + + elif self.memx_model_type == ModelTypeEnum.yolox: + return self.post_process_yolox(outputs) + + elif self.memx_model_type == ModelTypeEnum.ssd: + return self.post_process_ssdlite(outputs) + + else: + raise Exception( + f"{self.memx_model_type} is currently not supported for memryx. See the docs for more info on supported models." + ) + + def detect_raw(self, tensor_input: np.ndarray): + """Removed synchronous detect_raw() function so that we only use async""" + return 0 diff --git a/frigate/detectors/plugins/onnx.py b/frigate/detectors/plugins/onnx.py index 45e37d6cd3..6c9e510cef 100644 --- a/frigate/detectors/plugins/onnx.py +++ b/frigate/detectors/plugins/onnx.py @@ -5,12 +5,12 @@ from pydantic import Field from typing_extensions import Literal from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detection_runners import get_optimized_runner from frigate.detectors.detector_config import ( BaseDetectorConfig, ModelTypeEnum, ) from frigate.util.model import ( - get_ort_providers, post_process_dfine, post_process_rfdetr, post_process_yolo, @@ -33,31 +33,18 @@ class ONNXDetector(DetectionApi): def __init__(self, detector_config: ONNXDetectorConfig): super().__init__(detector_config) - try: - import onnxruntime as ort - - logger.info("ONNX: loaded onnxruntime module") - except ModuleNotFoundError: - logger.error( - "ONNX: module loading failed, need 'pip install onnxruntime'?!?" - ) - raise - path = detector_config.model.path logger.info(f"ONNX: loading {detector_config.model.path}") - providers, options = get_ort_providers( - detector_config.device == "CPU", detector_config.device - ) - - self.model = ort.InferenceSession( - path, providers=providers, provider_options=options + self.runner = get_optimized_runner( + path, + detector_config.device, + model_type=detector_config.model.model_type, ) self.onnx_model_type = detector_config.model.model_type self.onnx_model_px = detector_config.model.input_pixel_format self.onnx_model_shape = detector_config.model.input_tensor - path = detector_config.model.path if self.onnx_model_type == ModelTypeEnum.yolox: self.calculate_grids_strides() @@ -66,19 +53,18 @@ class ONNXDetector(DetectionApi): def detect_raw(self, tensor_input: np.ndarray): if self.onnx_model_type == ModelTypeEnum.dfine: - tensor_output = self.model.run( - None, + tensor_output = self.runner.run( { "images": tensor_input, "orig_target_sizes": np.array( [[self.height, self.width]], dtype=np.int64 ), - }, + } ) return post_process_dfine(tensor_output, self.width, self.height) - model_input_name = self.model.get_inputs()[0].name - tensor_output = self.model.run(None, {model_input_name: tensor_input}) + model_input_name = self.runner.get_input_names()[0] + tensor_output = self.runner.run({model_input_name: tensor_input}) if self.onnx_model_type == ModelTypeEnum.rfdetr: return post_process_rfdetr(tensor_output) diff --git a/frigate/detectors/plugins/openvino.py b/frigate/detectors/plugins/openvino.py index 066b6d311b..bda5c8871c 100644 --- a/frigate/detectors/plugins/openvino.py +++ b/frigate/detectors/plugins/openvino.py @@ -1,5 +1,4 @@ import logging -import os import numpy as np import openvino as ov @@ -7,6 +6,7 @@ from pydantic import Field from typing_extensions import Literal from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detection_runners import OpenVINOModelRunner from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum from frigate.util.model import ( post_process_dfine, @@ -37,20 +37,23 @@ class OvDetector(DetectionApi): def __init__(self, detector_config: OvDetectorConfig): super().__init__(detector_config) - self.ov_core = ov.Core() self.ov_model_type = detector_config.model.model_type self.h = detector_config.model.height self.w = detector_config.model.width - if not os.path.isfile(detector_config.model.path): - logger.error(f"OpenVino model file {detector_config.model.path} not found.") - raise FileNotFoundError - - self.interpreter = self.ov_core.compile_model( - model=detector_config.model.path, device_name=detector_config.device + self.runner = OpenVINOModelRunner( + model_path=detector_config.model.path, + device=detector_config.device, + model_type=detector_config.model.model_type, ) + # For dfine models, also pre-allocate target sizes tensor + if self.ov_model_type == ModelTypeEnum.dfine: + self.target_sizes_tensor = ov.Tensor( + np.array([[self.h, self.w]], dtype=np.int64) + ) + self.model_invalid = False if self.ov_model_type not in self.supported_models: @@ -60,8 +63,8 @@ class OvDetector(DetectionApi): self.model_invalid = True if self.ov_model_type == ModelTypeEnum.ssd: - model_inputs = self.interpreter.inputs - model_outputs = self.interpreter.outputs + model_inputs = self.runner.compiled_model.inputs + model_outputs = self.runner.compiled_model.outputs if len(model_inputs) != 1: logger.error( @@ -80,8 +83,8 @@ class OvDetector(DetectionApi): self.model_invalid = True if self.ov_model_type == ModelTypeEnum.yolonas: - model_inputs = self.interpreter.inputs - model_outputs = self.interpreter.outputs + model_inputs = self.runner.compiled_model.inputs + model_outputs = self.runner.compiled_model.outputs if len(model_inputs) != 1: logger.error( @@ -104,7 +107,9 @@ class OvDetector(DetectionApi): self.output_indexes = 0 while True: try: - tensor_shape = self.interpreter.output(self.output_indexes).shape + tensor_shape = self.runner.compiled_model.output( + self.output_indexes + ).shape logger.info( f"Model Output-{self.output_indexes} Shape: {tensor_shape}" ) @@ -129,39 +134,33 @@ class OvDetector(DetectionApi): ] def detect_raw(self, tensor_input): - infer_request = self.interpreter.create_infer_request() - # TODO: see if we can use shared_memory=True - input_tensor = ov.Tensor(array=tensor_input) + if self.model_invalid: + return np.zeros((20, 6), np.float32) if self.ov_model_type == ModelTypeEnum.dfine: - infer_request.set_tensor("images", input_tensor) - target_sizes_tensor = ov.Tensor( - np.array([[self.h, self.w]], dtype=np.int64) - ) - infer_request.set_tensor("orig_target_sizes", target_sizes_tensor) - infer_request.infer() + # Use named inputs for dfine models + inputs = { + "images": tensor_input, + "orig_target_sizes": np.array([[self.h, self.w]], dtype=np.int64), + } + outputs = self.runner.run(inputs) tensor_output = ( - infer_request.get_output_tensor(0).data, - infer_request.get_output_tensor(1).data, - infer_request.get_output_tensor(2).data, + outputs[0], + outputs[1], + outputs[2], ) return post_process_dfine(tensor_output, self.w, self.h) - infer_request.infer(input_tensor) + # Run inference using the runner + input_name = self.runner.get_input_names()[0] + outputs = self.runner.run({input_name: tensor_input}) detections = np.zeros((20, 6), np.float32) - if self.model_invalid: - return detections - elif self.ov_model_type == ModelTypeEnum.rfdetr: - return post_process_rfdetr( - [ - infer_request.get_output_tensor(0).data, - infer_request.get_output_tensor(1).data, - ] - ) + if self.ov_model_type == ModelTypeEnum.rfdetr: + return post_process_rfdetr(outputs) elif self.ov_model_type == ModelTypeEnum.ssd: - results = infer_request.get_output_tensor(0).data[0][0] + results = outputs[0][0][0] for i, (_, class_id, score, xmin, ymin, xmax, ymax) in enumerate(results): if i == 20: @@ -176,7 +175,7 @@ class OvDetector(DetectionApi): ] return detections elif self.ov_model_type == ModelTypeEnum.yolonas: - predictions = infer_request.get_output_tensor(0).data + predictions = outputs[0] for i, prediction in enumerate(predictions): if i == 20: @@ -195,16 +194,10 @@ class OvDetector(DetectionApi): ] return detections elif self.ov_model_type == ModelTypeEnum.yologeneric: - out_tensor = [] - - for item in infer_request.output_tensors: - out_tensor.append(item.data) - - return post_process_yolo(out_tensor, self.w, self.h) + return post_process_yolo(outputs, self.w, self.h) elif self.ov_model_type == ModelTypeEnum.yolox: - out_tensor = infer_request.get_output_tensor() # [x, y, h, w, box_score, class_no_1, ..., class_no_80], - results = out_tensor.data + results = outputs[0] results[..., :2] = (results[..., :2] + self.grids) * self.expanded_strides results[..., 2:4] = np.exp(results[..., 2:4]) * self.expanded_strides image_pred = results[0, ...] diff --git a/frigate/detectors/plugins/rknn.py b/frigate/detectors/plugins/rknn.py index 46fae3e62f..bcfe6b7f13 100644 --- a/frigate/detectors/plugins/rknn.py +++ b/frigate/detectors/plugins/rknn.py @@ -10,8 +10,10 @@ from pydantic import Field from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detection_runners import RKNNModelRunner from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum from frigate.util.model import post_process_yolo +from frigate.util.rknn_converter import auto_convert_model logger = logging.getLogger(__name__) @@ -60,18 +62,18 @@ class Rknn(DetectionApi): "For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html" ) - from rknnlite.api import RKNNLite - - self.rknn = RKNNLite(verbose=False) - if self.rknn.load_rknn(model_props["path"]) != 0: - logger.error("Error initializing rknn model.") - if self.rknn.init_runtime(core_mask=core_mask) != 0: - logger.error( - "Error initializing rknn runtime. Do you run docker in privileged mode?" - ) + self.runner = RKNNModelRunner( + model_path=model_props["path"], + model_type=config.model.model_type.value + if config.model.model_type + else None, + core_mask=core_mask, + ) def __del__(self): - self.rknn.release() + if hasattr(self, "runner") and self.runner: + # The runner's __del__ method will handle cleanup + pass def get_soc(self): try: @@ -94,7 +96,31 @@ class Rknn(DetectionApi): # user provided models should be a path and contain a "/" if "/" in model_path: model_props["preset"] = False - model_props["path"] = model_path + + # Check if this is an ONNX model or model without extension that needs conversion + if model_path.endswith(".onnx") or not os.path.splitext(model_path)[1]: + # Try to auto-convert to RKNN format + logger.info( + f"Attempting to auto-convert {model_path} to RKNN format..." + ) + + # Determine model type from config + model_type = self.detector_config.model.model_type + + # Auto-convert the model + converted_path = auto_convert_model(model_path, model_type.value) + + if converted_path: + model_props["path"] = converted_path + logger.info(f"Successfully converted model to: {converted_path}") + else: + # Fall back to original path if conversion fails + logger.warning( + f"Failed to convert {model_path} to RKNN format, using original path" + ) + model_props["path"] = model_path + else: + model_props["path"] = model_path else: model_props["preset"] = True @@ -281,9 +307,7 @@ class Rknn(DetectionApi): ) def detect_raw(self, tensor_input): - output = self.rknn.inference( - [ - tensor_input, - ] - ) + # Prepare input for the runner + inputs = {"input": tensor_input} + output = self.runner.run(inputs) return self.post_process(output) diff --git a/frigate/detectors/plugins/synaptics.py b/frigate/detectors/plugins/synaptics.py new file mode 100644 index 0000000000..0faed399a1 --- /dev/null +++ b/frigate/detectors/plugins/synaptics.py @@ -0,0 +1,91 @@ +import logging +import os + +import numpy as np +from synap import Network +from synap.postprocessor import Detector +from synap.preprocessor import Preprocessor +from synap.types import Layout, Shape +from typing_extensions import Literal + +from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detector_config import ( + BaseDetectorConfig, + InputTensorEnum, + ModelTypeEnum, +) + +logger = logging.getLogger(__name__) + +DETECTOR_KEY = "synaptics" + + +class SynapDetectorConfig(BaseDetectorConfig): + type: Literal[DETECTOR_KEY] + + +class SynapDetector(DetectionApi): + type_key = DETECTOR_KEY + + def __init__(self, detector_config: SynapDetectorConfig): + try: + _, ext = os.path.splitext(detector_config.model.path) + if ext and ext != ".synap": + raise ValueError("Model path config for Synap1680 is wrong.") + + synap_network = Network(detector_config.model.path) + logger.info(f"Synap NPU loaded model: {detector_config.model.path}") + except ValueError as ve: + logger.error(f"Config to Synap1680 was Failed: {ve}") + raise + except Exception as e: + logger.error(f"Failed to init Synap NPU: {e}") + raise + + self.width = detector_config.model.width + self.height = detector_config.model.height + self.model_type = detector_config.model.model_type + self.network = synap_network + self.network_input_details = self.network.inputs[0] + self.input_tensor_layout = detector_config.model.input_tensor + + # Create Inference Engine + self.preprocessor = Preprocessor() + self.detector = Detector(score_threshold=0.4, iou_threshold=0.4) + + def detect_raw(self, tensor_input: np.ndarray): + # It has only been testing for pre-converted mobilenet80 .tflite -> .synap model currently + layout = Layout.nhwc # default layout + detections = np.zeros((20, 6), np.float32) + + if self.input_tensor_layout == InputTensorEnum.nhwc: + layout = Layout.nhwc + + postprocess_data = self.preprocessor.assign( + self.network.inputs, tensor_input, Shape(tensor_input.shape), layout + ) + output_tensor_obj = self.network.predict() + output = self.detector.process(output_tensor_obj, postprocess_data) + + if self.model_type == ModelTypeEnum.ssd: + for i, item in enumerate(output.items): + if i == 20: + break + + bb = item.bounding_box + # Convert corner coordinates to normalized [0,1] range + x1 = bb.origin.x / self.width # Top-left X + y1 = bb.origin.y / self.height # Top-left Y + x2 = (bb.origin.x + bb.size.x) / self.width # Bottom-right X + y2 = (bb.origin.y + bb.size.y) / self.height # Bottom-right Y + detections[i] = [ + item.class_index, + float(item.confidence), + y1, + x1, + y2, + x2, + ] + else: + logger.error(f"Unsupported model type: {self.model_type}") + return detections diff --git a/frigate/detectors/plugins/teflon_tfl.py b/frigate/detectors/plugins/teflon_tfl.py new file mode 100644 index 0000000000..7e29d66306 --- /dev/null +++ b/frigate/detectors/plugins/teflon_tfl.py @@ -0,0 +1,38 @@ +import logging + +from typing_extensions import Literal + +from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detector_config import BaseDetectorConfig + +from ..detector_utils import ( + tflite_detect_raw, + tflite_init, + tflite_load_delegate_interpreter, +) + +logger = logging.getLogger(__name__) + +# Use _tfl suffix to default tflite model +DETECTOR_KEY = "teflon_tfl" + + +class TeflonDetectorConfig(BaseDetectorConfig): + type: Literal[DETECTOR_KEY] + + +class TeflonTfl(DetectionApi): + type_key = DETECTOR_KEY + + def __init__(self, detector_config: TeflonDetectorConfig): + # Location in Debian's mesa-teflon-delegate + delegate_library = "/usr/lib/teflon/libteflon.so" + device_config = {} + + interpreter = tflite_load_delegate_interpreter( + delegate_library, detector_config, device_config + ) + tflite_init(self, interpreter) + + def detect_raw(self, tensor_input): + return tflite_detect_raw(self, tensor_input) diff --git a/frigate/detectors/plugins/zmq_ipc.py b/frigate/detectors/plugins/zmq_ipc.py new file mode 100644 index 0000000000..cd397aefa9 --- /dev/null +++ b/frigate/detectors/plugins/zmq_ipc.py @@ -0,0 +1,331 @@ +import json +import logging +import os +from typing import Any, List + +import numpy as np +import zmq +from pydantic import Field +from typing_extensions import Literal + +from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detector_config import BaseDetectorConfig + +logger = logging.getLogger(__name__) + +DETECTOR_KEY = "zmq" + + +class ZmqDetectorConfig(BaseDetectorConfig): + type: Literal[DETECTOR_KEY] + endpoint: str = Field( + default="ipc:///tmp/cache/zmq_detector", title="ZMQ IPC endpoint" + ) + request_timeout_ms: int = Field( + default=200, title="ZMQ request timeout in milliseconds" + ) + linger_ms: int = Field(default=0, title="ZMQ socket linger in milliseconds") + + +class ZmqIpcDetector(DetectionApi): + """ + ZMQ-based detector plugin using a REQ/REP socket over an IPC endpoint. + + Protocol: + - Request is sent as a multipart message: + [ header_json_bytes, tensor_bytes ] + where header is a JSON object containing: + { + "shape": List[int], + "dtype": str, # numpy dtype string, e.g. "uint8", "float32" + } + tensor_bytes are the raw bytes of the numpy array in C-order. + + - Response is expected to be either: + a) Multipart [ header_json_bytes, tensor_bytes ] with header specifying + shape [20,6] and dtype "float32"; or + b) Single frame tensor_bytes of length 20*6*4 bytes (float32). + + On any error or timeout, this detector returns a zero array of shape (20, 6). + + Model Management: + - On initialization, sends model request to check if model is available + - If model not available, sends model data via ZMQ + - Only starts inference after model is ready + """ + + type_key = DETECTOR_KEY + + def __init__(self, detector_config: ZmqDetectorConfig): + super().__init__(detector_config) + + self._context = zmq.Context() + self._endpoint = detector_config.endpoint + self._request_timeout_ms = detector_config.request_timeout_ms + self._linger_ms = detector_config.linger_ms + self._socket = None + self._create_socket() + + # Model management + self._model_ready = False + self._model_name = self._get_model_name() + + # Initialize model if needed + self._initialize_model() + + # Preallocate zero result for error paths + self._zero_result = np.zeros((20, 6), np.float32) + + def _create_socket(self) -> None: + if self._socket is not None: + try: + self._socket.close(linger=self._linger_ms) + except Exception: + pass + self._socket = self._context.socket(zmq.REQ) + # Apply timeouts and linger so calls don't block indefinitely + self._socket.setsockopt(zmq.RCVTIMEO, self._request_timeout_ms) + self._socket.setsockopt(zmq.SNDTIMEO, self._request_timeout_ms) + self._socket.setsockopt(zmq.LINGER, self._linger_ms) + + logger.debug(f"ZMQ detector connecting to {self._endpoint}") + self._socket.connect(self._endpoint) + + def _get_model_name(self) -> str: + """Get the model filename from the detector config.""" + model_path = self.detector_config.model.path + return os.path.basename(model_path) + + def _initialize_model(self) -> None: + """Initialize the model by checking availability and transferring if needed.""" + try: + logger.info(f"Initializing model: {self._model_name}") + + # Check if model is available and transfer if needed + if self._check_and_transfer_model(): + logger.info(f"Model {self._model_name} is ready") + self._model_ready = True + else: + logger.error(f"Failed to initialize model {self._model_name}") + + except Exception as e: + logger.error(f"Failed to initialize model: {e}") + + def _check_and_transfer_model(self) -> bool: + """Check if model is available and transfer if needed in one atomic operation.""" + try: + # Send model availability request + header = {"model_request": True, "model_name": self._model_name} + header_bytes = json.dumps(header).encode("utf-8") + + self._socket.send_multipart([header_bytes]) + + # Temporarily increase timeout for model operations + original_timeout = self._socket.getsockopt(zmq.RCVTIMEO) + self._socket.setsockopt(zmq.RCVTIMEO, 30000) + + try: + response_frames = self._socket.recv_multipart() + finally: + self._socket.setsockopt(zmq.RCVTIMEO, original_timeout) + + if len(response_frames) == 1: + try: + response = json.loads(response_frames[0].decode("utf-8")) + model_available = response.get("model_available", False) + model_loaded = response.get("model_loaded", False) + + if model_available and model_loaded: + return True + elif model_available and not model_loaded: + logger.error("Model exists but failed to load") + return False + else: + return self._send_model_data() + + except json.JSONDecodeError: + logger.warning( + "Received non-JSON response for model availability check" + ) + return False + else: + logger.warning( + "Received unexpected response format for model availability check" + ) + return False + + except Exception as e: + logger.error(f"Failed to check and transfer model: {e}") + return False + + def _check_model_availability(self) -> bool: + """Check if the model is available on the detector.""" + try: + # Send model availability request + header = {"model_request": True, "model_name": self._model_name} + header_bytes = json.dumps(header).encode("utf-8") + + self._socket.send_multipart([header_bytes]) + + # Receive response + response_frames = self._socket.recv_multipart() + + # Check if this is a JSON response (model management) + if len(response_frames) == 1: + try: + response = json.loads(response_frames[0].decode("utf-8")) + model_available = response.get("model_available", False) + model_loaded = response.get("model_loaded", False) + logger.debug( + f"Model availability check: available={model_available}, loaded={model_loaded}" + ) + return model_available and model_loaded + except json.JSONDecodeError: + logger.warning( + "Received non-JSON response for model availability check" + ) + return False + else: + logger.warning( + "Received unexpected response format for model availability check" + ) + return False + + except Exception as e: + logger.error(f"Failed to check model availability: {e}") + return False + + def _send_model_data(self) -> bool: + """Send model data to the detector.""" + try: + model_path = self.detector_config.model.path + + if not os.path.exists(model_path): + logger.error(f"Model file not found: {model_path}") + return False + + logger.info(f"Transferring model to detector: {self._model_name}") + with open(model_path, "rb") as f: + model_data = f.read() + + header = {"model_data": True, "model_name": self._model_name} + header_bytes = json.dumps(header).encode("utf-8") + + self._socket.send_multipart([header_bytes, model_data]) + + # Temporarily increase timeout for model loading (can take several seconds) + original_timeout = self._socket.getsockopt(zmq.RCVTIMEO) + self._socket.setsockopt(zmq.RCVTIMEO, 30000) + + try: + # Receive response + response_frames = self._socket.recv_multipart() + finally: + # Restore original timeout + self._socket.setsockopt(zmq.RCVTIMEO, original_timeout) + + # Check if this is a JSON response (model management) + if len(response_frames) == 1: + try: + response = json.loads(response_frames[0].decode("utf-8")) + model_saved = response.get("model_saved", False) + model_loaded = response.get("model_loaded", False) + if model_saved and model_loaded: + logger.info( + f"Model {self._model_name} transferred and loaded successfully" + ) + else: + logger.error( + f"Model transfer failed: saved={model_saved}, loaded={model_loaded}" + ) + return model_saved and model_loaded + except json.JSONDecodeError: + logger.warning("Received non-JSON response for model data transfer") + return False + else: + logger.warning( + "Received unexpected response format for model data transfer" + ) + return False + + except Exception as e: + logger.error(f"Failed to send model data: {e}") + return False + + def _build_header(self, tensor_input: np.ndarray) -> bytes: + header: dict[str, Any] = { + "shape": list(tensor_input.shape), + "dtype": str(tensor_input.dtype.name), + "model_type": str(self.detector_config.model.model_type.name), + } + return json.dumps(header).encode("utf-8") + + def _decode_response(self, frames: List[bytes]) -> np.ndarray: + try: + if len(frames) == 1: + # Single-frame raw float32 (20x6) + buf = frames[0] + if len(buf) != 20 * 6 * 4: + logger.warning( + f"ZMQ detector received unexpected payload size: {len(buf)}" + ) + return self._zero_result + return np.frombuffer(buf, dtype=np.float32).reshape((20, 6)) + + if len(frames) >= 2: + header = json.loads(frames[0].decode("utf-8")) + shape = tuple(header.get("shape", [])) + dtype = np.dtype(header.get("dtype", "float32")) + return np.frombuffer(frames[1], dtype=dtype).reshape(shape) + + logger.warning("ZMQ detector received empty reply") + return self._zero_result + except Exception as exc: # noqa: BLE001 + logger.error(f"ZMQ detector failed to decode response: {exc}") + return self._zero_result + + def detect_raw(self, tensor_input: np.ndarray) -> np.ndarray: + if not self._model_ready: + logger.warning("Model not ready, returning zero detections") + return self._zero_result + + try: + header_bytes = self._build_header(tensor_input) + payload_bytes = memoryview(tensor_input.tobytes(order="C")) + + # Send request + self._socket.send_multipart([header_bytes, payload_bytes]) + + # Receive reply + reply_frames = self._socket.recv_multipart() + detections = self._decode_response(reply_frames) + + # Ensure output shape and dtype are exactly as expected + return detections + except zmq.Again: + # Timeout + logger.debug("ZMQ detector request timed out; resetting socket") + try: + self._create_socket() + self._initialize_model() + except Exception: + pass + return self._zero_result + except zmq.ZMQError as exc: + logger.error(f"ZMQ detector ZMQError: {exc}; resetting socket") + try: + self._create_socket() + self._initialize_model() + except Exception: + pass + return self._zero_result + except Exception as exc: # noqa: BLE001 + logger.error(f"ZMQ detector unexpected error: {exc}") + return self._zero_result + + def __del__(self) -> None: # pragma: no cover - best-effort cleanup + try: + if self._socket is not None: + self._socket.close(linger=self.detector_config.linger_ms) + except Exception: + pass diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index fbdc8d940e..0a854fcfad 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -3,26 +3,24 @@ import base64 import json import logging -import multiprocessing as mp import os -import signal import threading from json.decoder import JSONDecodeError -from types import FrameType -from typing import Any, Optional, Union +from multiprocessing.synchronize import Event as MpEvent +from typing import Any, Union import regex from pathvalidate import ValidationError, sanitize_filename -from setproctitle import setproctitle from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsRequestor from frigate.config import FrigateConfig -from frigate.const import CONFIG_DIR, FACE_DIR +from frigate.const import CONFIG_DIR, FACE_DIR, PROCESS_PRIORITY_HIGH from frigate.data_processing.types import DataProcessorMetrics from frigate.db.sqlitevecq import SqliteVecQueueDatabase -from frigate.models import Event, Recordings +from frigate.models import Event from frigate.util.builtin import serialize -from frigate.util.services import listen +from frigate.util.classification import kickoff_model_training +from frigate.util.process import FrigateProcess from .maintainer import EmbeddingMaintainer from .util import ZScoreNormalization @@ -30,40 +28,30 @@ from .util import ZScoreNormalization logger = logging.getLogger(__name__) -def manage_embeddings(config: FrigateConfig, metrics: DataProcessorMetrics) -> None: - stop_event = mp.Event() +class EmbeddingProcess(FrigateProcess): + def __init__( + self, + config: FrigateConfig, + metrics: DataProcessorMetrics | None, + stop_event: MpEvent, + ) -> None: + super().__init__( + stop_event, + PROCESS_PRIORITY_HIGH, + name="frigate.embeddings_manager", + daemon=True, + ) + self.config = config + self.metrics = metrics - def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: - stop_event.set() - - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) - - threading.current_thread().name = "process:embeddings_manager" - setproctitle("frigate.embeddings_manager") - listen() - - # Configure Frigate DB - db = SqliteVecQueueDatabase( - config.database.path, - pragmas={ - "auto_vacuum": "FULL", # Does not defragment database - "cache_size": -512 * 1000, # 512MB of cache - "synchronous": "NORMAL", # Safe when using WAL https://www.sqlite.org/pragma.html#pragma_synchronous - }, - timeout=max(60, 10 * len([c for c in config.cameras.values() if c.enabled])), - load_vec_extension=True, - ) - models = [Event, Recordings] - db.bind(models) - - maintainer = EmbeddingMaintainer( - db, - config, - metrics, - stop_event, - ) - maintainer.start() + def run(self) -> None: + self.pre_run_setup(self.config.logger) + maintainer = EmbeddingMaintainer( + self.config, + self.metrics, + self.stop_event, + ) + maintainer.start() class EmbeddingsContext: @@ -300,3 +288,34 @@ class EmbeddingsContext: def reindex_embeddings(self) -> dict[str, Any]: return self.requestor.send_data(EmbeddingsRequestEnum.reindex.value, {}) + + def start_classification_training(self, model_name: str) -> dict[str, Any]: + threading.Thread( + target=kickoff_model_training, + args=(self.requestor, model_name), + daemon=True, + ).start() + return {"success": True, "message": f"Began training {model_name} model."} + + def transcribe_audio(self, event: dict[str, any]) -> dict[str, any]: + return self.requestor.send_data( + EmbeddingsRequestEnum.transcribe_audio.value, {"event": event} + ) + + def generate_description_embedding(self, text: str) -> None: + return self.requestor.send_data( + EmbeddingsRequestEnum.embed_description.value, + {"id": None, "description": text, "upsert": False}, + ) + + def generate_image_embedding(self, event_id: str, thumbnail: bytes) -> None: + return self.requestor.send_data( + EmbeddingsRequestEnum.embed_thumbnail.value, + {"id": str(event_id), "thumbnail": str(thumbnail), "upsert": False}, + ) + + def generate_review_summary(self, start_ts: float, end_ts: float) -> str | None: + return self.requestor.send_data( + EmbeddingsRequestEnum.summarize_review.value, + {"start_ts": start_ts, "end_ts": end_ts}, + ) diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index 833ab9ab2e..788e3e6db1 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -7,21 +7,26 @@ import os import threading import time -from numpy import ndarray +import numpy as np +from peewee import DoesNotExist, IntegrityError from PIL import Image from playhouse.shortcuts import model_to_dict +from frigate.comms.embeddings_updater import ( + EmbeddingsRequestEnum, +) from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig from frigate.config.classification import SemanticSearchModelEnum from frigate.const import ( CONFIG_DIR, + TRIGGER_DIR, UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_MODEL_STATE, ) from frigate.data_processing.types import DataProcessorMetrics from frigate.db.sqlitevecq import SqliteVecQueueDatabase -from frigate.models import Event +from frigate.models import Event, Trigger from frigate.types import ModelStatusTypesEnum from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize from frigate.util.path import get_event_thumbnail_bytes @@ -107,9 +112,8 @@ class Embeddings: self.embedding = JinaV2Embedding( model_size=self.config.semantic_search.model_size, requestor=self.requestor, - device="GPU" - if self.config.semantic_search.model_size == "large" - else "CPU", + device=config.semantic_search.device + or ("GPU" if config.semantic_search.model_size == "large" else "CPU"), ) self.text_embedding = lambda input_data: self.embedding( input_data, embedding_type="text" @@ -126,7 +130,8 @@ class Embeddings: self.vision_embedding = JinaV1ImageEmbedding( model_size=config.semantic_search.model_size, requestor=self.requestor, - device="GPU" if config.semantic_search.model_size == "large" else "CPU", + device=config.semantic_search.device + or ("GPU" if config.semantic_search.model_size == "large" else "CPU"), ) def update_stats(self) -> None: @@ -167,7 +172,7 @@ class Embeddings: def embed_thumbnail( self, event_id: str, thumbnail: bytes, upsert: bool = True - ) -> ndarray: + ) -> np.ndarray: """Embed thumbnail and optionally insert into DB. @param: event_id in Events DB @@ -194,7 +199,7 @@ class Embeddings: def batch_embed_thumbnail( self, event_thumbs: dict[str, bytes], upsert: bool = True - ) -> list[ndarray]: + ) -> list[np.ndarray]: """Embed thumbnails and optionally insert into DB. @param: event_thumbs Map of Event IDs in DB to thumbnail bytes in jpg format @@ -244,7 +249,7 @@ class Embeddings: def embed_description( self, event_id: str, description: str, upsert: bool = True - ) -> ndarray: + ) -> np.ndarray: start = datetime.datetime.now().timestamp() embedding = self.text_embedding([description])[0] @@ -264,7 +269,7 @@ class Embeddings: def batch_embed_description( self, event_descriptions: dict[str, str], upsert: bool = True - ) -> ndarray: + ) -> np.ndarray: start = datetime.datetime.now().timestamp() # upsert embeddings one by one to avoid token limit embeddings = [] @@ -417,3 +422,224 @@ class Embeddings: with self.reindex_lock: self.reindex_running = False self.reindex_thread = None + + def sync_triggers(self) -> None: + for camera in self.config.cameras.values(): + # Get all existing triggers for this camera + existing_triggers = { + trigger.name: trigger + for trigger in Trigger.select().where(Trigger.camera == camera.name) + } + + # Get all configured trigger names + configured_trigger_names = set(camera.semantic_search.triggers or {}) + + # Create or update triggers from config + for trigger_name, trigger in ( + camera.semantic_search.triggers or {} + ).items(): + if trigger_name in existing_triggers: + existing_trigger = existing_triggers[trigger_name] + needs_embedding_update = False + thumbnail_missing = False + + # Check if data has changed or thumbnail is missing for thumbnail type + if trigger.type == "thumbnail": + thumbnail_path = os.path.join( + TRIGGER_DIR, camera.name, f"{trigger.data}.webp" + ) + try: + event = Event.get(Event.id == trigger.data) + if event.data.get("type") != "object": + logger.warning( + f"Event {trigger.data} is not a tracked object for {trigger.type} trigger" + ) + continue # Skip if not an object + + # Check if thumbnail needs to be updated (data changed or missing) + if ( + existing_trigger.data != trigger.data + or not os.path.exists(thumbnail_path) + ): + thumbnail = get_event_thumbnail_bytes(event) + if not thumbnail: + logger.warning( + f"Unable to retrieve thumbnail for event ID {trigger.data} for {trigger_name}." + ) + continue + self.write_trigger_thumbnail( + camera.name, trigger.data, thumbnail + ) + thumbnail_missing = True + except DoesNotExist: + logger.warning( + f"Event ID {trigger.data} for trigger {trigger_name} does not exist." + ) + continue + + # Update existing trigger if data has changed + if ( + existing_trigger.type != trigger.type + or existing_trigger.data != trigger.data + or existing_trigger.threshold != trigger.threshold + ): + existing_trigger.type = trigger.type + existing_trigger.data = trigger.data + existing_trigger.threshold = trigger.threshold + needs_embedding_update = True + + # Check if embedding is missing or needs update + if ( + not existing_trigger.embedding + or needs_embedding_update + or thumbnail_missing + ): + existing_trigger.embedding = self._calculate_trigger_embedding( + trigger + ) + needs_embedding_update = True + + if needs_embedding_update: + existing_trigger.save() + else: + # Create new trigger + try: + try: + event: Event = Event.get(Event.id == trigger.data) + except DoesNotExist: + logger.warning( + f"Event ID {trigger.data} for trigger {trigger_name} does not exist." + ) + continue + + # Skip the event if not an object + if event.data.get("type") != "object": + logger.warning( + f"Event ID {trigger.data} for trigger {trigger_name} is not a tracked object." + ) + continue + + thumbnail = get_event_thumbnail_bytes(event) + + if not thumbnail: + logger.warning( + f"Unable to retrieve thumbnail for event ID {trigger.data} for {trigger_name}." + ) + continue + + self.write_trigger_thumbnail( + camera.name, trigger.data, thumbnail + ) + + # Calculate embedding for new trigger + embedding = self._calculate_trigger_embedding(trigger) + + Trigger.create( + camera=camera.name, + name=trigger_name, + type=trigger.type, + data=trigger.data, + threshold=trigger.threshold, + model=self.config.semantic_search.model, + embedding=embedding, + triggering_event_id="", + last_triggered=None, + ) + + except IntegrityError: + pass # Handle duplicate creation attempts + + # Remove triggers that are no longer in config + triggers_to_remove = ( + set(existing_triggers.keys()) - configured_trigger_names + ) + if triggers_to_remove: + Trigger.delete().where( + Trigger.camera == camera.name, Trigger.name.in_(triggers_to_remove) + ).execute() + for trigger_name in triggers_to_remove: + self.remove_trigger_thumbnail(camera.name, trigger_name) + + def write_trigger_thumbnail( + self, camera: str, event_id: str, thumbnail: bytes + ) -> None: + """Write the thumbnail to the trigger directory.""" + try: + os.makedirs(os.path.join(TRIGGER_DIR, camera), exist_ok=True) + with open(os.path.join(TRIGGER_DIR, camera, f"{event_id}.webp"), "wb") as f: + f.write(thumbnail) + logger.debug( + f"Writing thumbnail for trigger with data {event_id} in {camera}." + ) + except Exception as e: + logger.error( + f"Failed to write thumbnail for trigger with data {event_id} in {camera}: {e}" + ) + + def remove_trigger_thumbnail(self, camera: str, event_id: str) -> None: + """Write the thumbnail to the trigger directory.""" + try: + os.remove(os.path.join(TRIGGER_DIR, camera, f"{event_id}.webp")) + logger.debug( + f"Deleted thumbnail for trigger with data {event_id} in {camera}." + ) + except Exception as e: + logger.error( + f"Failed to delete thumbnail for trigger with data {event_id} in {camera}: {e}" + ) + + def _calculate_trigger_embedding(self, trigger) -> bytes: + """Calculate embedding for a trigger based on its type and data.""" + if trigger.type == "description": + logger.debug(f"Generating embedding for trigger description {trigger.name}") + embedding = self.requestor.send_data( + EmbeddingsRequestEnum.embed_description.value, + {"id": None, "description": trigger.data, "upsert": False}, + ) + return embedding.astype(np.float32).tobytes() + + elif trigger.type == "thumbnail": + # For image triggers, trigger.data should be an image ID + # Try to get embedding from vec_thumbnails table first + cursor = self.db.execute_sql( + "SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?", + [trigger.data], + ) + row = cursor.fetchone() if cursor else None + if row: + return row[0] # Already in bytes format + else: + logger.debug( + f"No thumbnail embedding found for image ID: {trigger.data}, generating from saved trigger thumbnail" + ) + + try: + with open( + os.path.join( + TRIGGER_DIR, trigger.camera, f"{trigger.data}.webp" + ), + "rb", + ) as f: + thumbnail = f.read() + except Exception as e: + logger.error( + f"Failed to read thumbnail for trigger {trigger.name} with ID {trigger.data}: {e}" + ) + return b"" + + logger.debug( + f"Generating embedding for trigger thumbnail {trigger.name} with ID {trigger.data}" + ) + embedding = self.requestor.send_data( + EmbeddingsRequestEnum.embed_thumbnail.value, + { + "id": str(trigger.data), + "thumbnail": str(thumbnail), + "upsert": False, + }, + ) + return embedding.astype(np.float32).tobytes() + + else: + logger.warning(f"Unknown trigger type: {trigger.type}") + return b"" diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 86bc75737f..dfb0eef613 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -12,10 +12,12 @@ from typing import Any, Optional import cv2 import numpy as np from peewee import DoesNotExist -from playhouse.sqliteq import SqliteQueueDatabase from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum -from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsResponder +from frigate.comms.embeddings_updater import ( + EmbeddingsRequestEnum, + EmbeddingsResponder, +) from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, EventMetadataSubscriber, @@ -27,8 +29,13 @@ from frigate.comms.recordings_updater import ( RecordingsDataSubscriber, RecordingsDataTypeEnum, ) -from frigate.config import FrigateConfig +from frigate.comms.review_updater import ReviewDataSubscriber +from frigate.config import CameraConfig, FrigateConfig from frigate.config.camera.camera import CameraTypeEnum +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import ( CLIPS_DIR, UPDATE_EVENT_DESCRIPTION, @@ -37,19 +44,29 @@ from frigate.data_processing.common.license_plate.model import ( LicensePlateModelRunner, ) from frigate.data_processing.post.api import PostProcessorApi +from frigate.data_processing.post.audio_transcription import ( + AudioTranscriptionPostProcessor, +) from frigate.data_processing.post.license_plate import ( LicensePlatePostProcessor, ) +from frigate.data_processing.post.review_descriptions import ReviewDescriptionProcessor +from frigate.data_processing.post.semantic_trigger import SemanticTriggerProcessor from frigate.data_processing.real_time.api import RealTimeProcessorApi from frigate.data_processing.real_time.bird import BirdRealTimeProcessor +from frigate.data_processing.real_time.custom_classification import ( + CustomObjectClassificationProcessor, + CustomStateClassificationProcessor, +) from frigate.data_processing.real_time.face import FaceRealTimeProcessor from frigate.data_processing.real_time.license_plate import ( LicensePlateRealTimeProcessor, ) from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum +from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum from frigate.genai import get_genai_client -from frigate.models import Event +from frigate.models import Event, Recordings, ReviewSegment, Trigger from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import serialize from frigate.util.image import ( @@ -71,15 +88,41 @@ class EmbeddingMaintainer(threading.Thread): def __init__( self, - db: SqliteQueueDatabase, config: FrigateConfig, - metrics: DataProcessorMetrics, + metrics: DataProcessorMetrics | None, stop_event: MpEvent, ) -> None: super().__init__(name="embeddings_maintainer") self.config = config self.metrics = metrics self.embeddings = None + self.config_updater = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.remove, + CameraConfigUpdateEnum.object_genai, + CameraConfigUpdateEnum.review_genai, + CameraConfigUpdateEnum.semantic_search, + ], + ) + + # Configure Frigate DB + db = SqliteVecQueueDatabase( + config.database.path, + pragmas={ + "auto_vacuum": "FULL", # Does not defragment database + "cache_size": -512 * 1000, # 512MB of cache + "synchronous": "NORMAL", # Safe when using WAL https://www.sqlite.org/pragma.html#pragma_synchronous + }, + timeout=max( + 60, 10 * len([c for c in config.cameras.values() if c.enabled]) + ), + load_vec_extension=True, + ) + models = [Event, Recordings, ReviewSegment, Trigger] + db.bind(models) if config.semantic_search.enabled: self.embeddings = Embeddings(config, db, metrics) @@ -88,6 +131,9 @@ class EmbeddingMaintainer(threading.Thread): if config.semantic_search.reindex: self.embeddings.reindex() + # Sync semantic search triggers in db with config + self.embeddings.sync_triggers() + # create communication for updating event descriptions self.requestor = InterProcessRequestor() @@ -100,11 +146,13 @@ class EmbeddingMaintainer(threading.Thread): self.recordings_subscriber = RecordingsDataSubscriber( RecordingsDataTypeEnum.recordings_available_through ) - self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video) + self.review_subscriber = ReviewDataSubscriber("") + self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value) self.embeddings_responder = EmbeddingsResponder() self.frame_manager = SharedMemoryFrameManager() self.detected_license_plates: dict[str, dict[str, Any]] = {} + self.genai_client = get_genai_client(config) # model runners to share between realtime and post processors if self.config.lpr.enabled: @@ -143,9 +191,30 @@ class EmbeddingMaintainer(threading.Thread): ) ) + for model_config in self.config.classification.custom.values(): + self.realtime_processors.append( + CustomStateClassificationProcessor( + self.config, model_config, self.requestor, self.metrics + ) + if model_config.state_config != None + else CustomObjectClassificationProcessor( + self.config, + model_config, + self.event_metadata_publisher, + self.metrics, + ) + ) + # post processors self.post_processors: list[PostProcessorApi] = [] + if any(c.review.genai.enabled_in_config for c in self.config.cameras.values()): + self.post_processors.append( + ReviewDescriptionProcessor( + self.config, self.requestor, self.metrics, self.genai_client + ) + ) + if self.config.lpr.enabled: self.post_processors.append( LicensePlatePostProcessor( @@ -158,10 +227,28 @@ class EmbeddingMaintainer(threading.Thread): ) ) + if any( + c.enabled_in_config and c.audio_transcription.enabled + for c in self.config.cameras.values() + ): + self.post_processors.append( + AudioTranscriptionPostProcessor(self.config, self.requestor, metrics) + ) + + if self.config.semantic_search.enabled: + self.post_processors.append( + SemanticTriggerProcessor( + db, + self.config, + self.requestor, + metrics, + self.embeddings, + ) + ) + self.stop_event = stop_event self.tracked_events: dict[str, list[Any]] = {} self.early_request_sent: dict[str, bool] = {} - self.genai_client = get_genai_client(config) # recordings data self.recordings_available_through: dict[str, float] = {} @@ -169,14 +256,17 @@ class EmbeddingMaintainer(threading.Thread): def run(self) -> None: """Maintain a SQLite-vec database for semantic search.""" while not self.stop_event.is_set(): + self.config_updater.check_for_updates() self._process_requests() self._process_updates() self._process_recordings_updates() - self._process_dedicated_lpr() + self._process_review_updates() + self._process_frame_updates() self._expire_dedicated_lpr() self._process_finalized() self._process_event_metadata() + self.config_updater.stop() self.event_subscriber.stop() self.event_end_subscriber.stop() self.recordings_subscriber.stop() @@ -223,6 +313,7 @@ class EmbeddingMaintainer(threading.Thread): if resp is not None: return resp + logger.error(f"No processor handled the topic {topic}") return None except Exception as e: logger.error(f"Unable to handle embeddings request {e}", exc_info=True) @@ -247,7 +338,10 @@ class EmbeddingMaintainer(threading.Thread): camera_config = self.config.cameras[camera] # no need to process updated objects if face recognition, lpr, genai are disabled - if not camera_config.genai.enabled and len(self.realtime_processors) == 0: + if ( + not camera_config.objects.genai.enabled + and len(self.realtime_processors) == 0 + ): return # Create our own thumbnail based on the bounding box and the frame time @@ -285,23 +379,23 @@ class EmbeddingMaintainer(threading.Thread): # check if we're configured to send an early request after a minimum number of updates received if ( self.genai_client is not None - and camera_config.genai.send_triggers.after_significant_updates + and camera_config.objects.genai.send_triggers.after_significant_updates ): if ( len(self.tracked_events.get(data["id"], [])) - >= camera_config.genai.send_triggers.after_significant_updates + >= camera_config.objects.genai.send_triggers.after_significant_updates and data["id"] not in self.early_request_sent ): if data["has_clip"] and data["has_snapshot"]: event: Event = Event.get(Event.id == data["id"]) if ( - not camera_config.genai.objects - or event.label in camera_config.genai.objects + not camera_config.objects.genai.objects + or event.label in camera_config.objects.genai.objects ) and ( - not camera_config.genai.required_zones + not camera_config.objects.genai.required_zones or set(data["entered_zones"]) - & set(camera_config.genai.required_zones) + & set(camera_config.objects.genai.required_zones) ): logger.debug(f"{camera} sending early request to GenAI") @@ -332,31 +426,6 @@ class EmbeddingMaintainer(threading.Thread): event_id, camera, updated_db = ended camera_config = self.config.cameras[camera] - # call any defined post processors - for processor in self.post_processors: - if isinstance(processor, LicensePlatePostProcessor): - recordings_available = self.recordings_available_through.get(camera) - if ( - recordings_available is not None - and event_id in self.detected_license_plates - and self.config.cameras[camera].type != "lpr" - ): - processor.process_data( - { - "event_id": event_id, - "camera": camera, - "recordings_available": self.recordings_available_through[ - camera - ], - "obj_data": self.detected_license_plates[event_id][ - "obj_data" - ], - }, - PostProcessDataEnum.recording, - ) - else: - processor.process_data(event_id, PostProcessDataEnum.event_id) - # expire in realtime processors for processor in self.realtime_processors: processor.expire_object(event_id, camera) @@ -379,20 +448,56 @@ class EmbeddingMaintainer(threading.Thread): # Run GenAI if ( - camera_config.genai.enabled - and camera_config.genai.send_triggers.tracked_object_end + camera_config.objects.genai.enabled + and camera_config.objects.genai.send_triggers.tracked_object_end and self.genai_client is not None and ( - not camera_config.genai.objects - or event.label in camera_config.genai.objects + not camera_config.objects.genai.objects + or event.label in camera_config.objects.genai.objects ) and ( - not camera_config.genai.required_zones - or set(event.zones) & set(camera_config.genai.required_zones) + not camera_config.objects.genai.required_zones + or set(event.zones) + & set(camera_config.objects.genai.required_zones) ) ): self._process_genai_description(event, camera_config, thumbnail) + # call any defined post processors + for processor in self.post_processors: + if isinstance(processor, LicensePlatePostProcessor): + recordings_available = self.recordings_available_through.get(camera) + if ( + recordings_available is not None + and event_id in self.detected_license_plates + and self.config.cameras[camera].type != "lpr" + ): + processor.process_data( + { + "event_id": event_id, + "camera": camera, + "recordings_available": self.recordings_available_through[ + camera + ], + "obj_data": self.detected_license_plates[event_id][ + "obj_data" + ], + }, + PostProcessDataEnum.recording, + ) + elif isinstance(processor, AudioTranscriptionPostProcessor): + continue + elif isinstance(processor, SemanticTriggerProcessor): + processor.process_data( + {"event_id": event_id, "camera": camera, "type": "image"}, + PostProcessDataEnum.tracked_object, + ) + else: + processor.process_data( + {"event_id": event_id, "camera": camera}, + PostProcessDataEnum.tracked_object, + ) + # Delete tracked events based on the event_id if event_id in self.tracked_events: del self.tracked_events[event_id] @@ -412,8 +517,8 @@ class EmbeddingMaintainer(threading.Thread): to_remove.append(id) for id in to_remove: self.event_metadata_publisher.publish( - EventMetadataTypeEnum.manual_event_end, (id, now), + EventMetadataTypeEnum.manual_event_end.value, ) self.detected_license_plates.pop(id) @@ -435,6 +540,18 @@ class EmbeddingMaintainer(threading.Thread): f"{camera} now has recordings available through {recordings_available_through_timestamp}" ) + def _process_review_updates(self) -> None: + """Process review updates.""" + while True: + review_updates = self.review_subscriber.check_for_update() + + if review_updates == None: + break + + for processor in self.post_processors: + if isinstance(processor, ReviewDescriptionProcessor): + processor.process_data(review_updates, PostProcessDataEnum.review) + def _process_event_metadata(self): # Check for regenerate description requests (topic, payload) = self.event_metadata_subscriber.check_for_update() @@ -442,14 +559,14 @@ class EmbeddingMaintainer(threading.Thread): if topic is None: return - event_id, source = payload + event_id, source, force = payload if event_id: self.handle_regenerate_description( - event_id, RegenerateDescriptionEnum(source) + event_id, RegenerateDescriptionEnum(source), force ) - def _process_dedicated_lpr(self) -> None: + def _process_frame_updates(self) -> None: """Process event updates""" (topic, data) = self.detection_subscriber.check_for_update() @@ -458,16 +575,17 @@ class EmbeddingMaintainer(threading.Thread): camera, frame_name, _, _, motion_boxes, _ = data - if not camera or not self.config.lpr.enabled or len(motion_boxes) == 0: + if not camera or len(motion_boxes) == 0: return camera_config = self.config.cameras[camera] + dedicated_lpr_enabled = ( + camera_config.type == CameraTypeEnum.lpr + and "license_plate" not in camera_config.objects.track + ) - if ( - camera_config.type != CameraTypeEnum.lpr - or "license_plate" in camera_config.objects.track - ): - # we're not a dedicated lpr camera or we are one but we're using frigate+ + if not dedicated_lpr_enabled and len(self.config.classification.custom) == 0: + # no active features that use this data return try: @@ -484,9 +602,16 @@ class EmbeddingMaintainer(threading.Thread): return for processor in self.realtime_processors: - if isinstance(processor, LicensePlateRealTimeProcessor): + if dedicated_lpr_enabled and isinstance( + processor, LicensePlateRealTimeProcessor + ): processor.process_frame(camera, yuv_frame, True) + if isinstance(processor, CustomStateClassificationProcessor): + processor.process_frame( + {"camera": camera, "motion": motion_boxes}, yuv_frame + ) + self.frame_manager.close(frame_name) def _create_thumbnail(self, yuv_frame, box, height=500) -> Optional[bytes]: @@ -512,8 +637,10 @@ class EmbeddingMaintainer(threading.Thread): self.embeddings.embed_thumbnail(event_id, thumbnail) - def _process_genai_description(self, event, camera_config, thumbnail) -> None: - if event.has_snapshot and camera_config.genai.use_snapshot: + def _process_genai_description( + self, event: Event, camera_config: CameraConfig, thumbnail + ) -> None: + if event.has_snapshot and camera_config.objects.genai.use_snapshot: snapshot_image = self._read_and_crop_snapshot(event, camera_config) if not snapshot_image: return @@ -525,7 +652,7 @@ class EmbeddingMaintainer(threading.Thread): embed_image = ( [snapshot_image] - if event.has_snapshot and camera_config.genai.use_snapshot + if event.has_snapshot and camera_config.objects.genai.use_snapshot else ( [data["thumbnail"] for data in self.tracked_events[event.id]] if num_thumbnails > 0 @@ -533,7 +660,7 @@ class EmbeddingMaintainer(threading.Thread): ) ) - if camera_config.genai.debug_save_thumbnails and num_thumbnails > 0: + if camera_config.objects.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( @@ -570,7 +697,7 @@ class EmbeddingMaintainer(threading.Thread): """Embed the description for an event.""" camera_config = self.config.cameras[event.camera] - description = self.genai_client.generate_description( + description = self.genai_client.generate_object_description( camera_config, thumbnails, event ) @@ -593,6 +720,16 @@ class EmbeddingMaintainer(threading.Thread): if self.config.semantic_search.enabled: self.embeddings.embed_description(event.id, description) + # Check semantic trigger for this description + for processor in self.post_processors: + if isinstance(processor, SemanticTriggerProcessor): + processor.process_data( + {"event_id": event.id, "camera": event.camera, "type": "text"}, + PostProcessDataEnum.tracked_object, + ) + else: + continue + logger.debug( "Generated description for %s (%d images): %s", event.id, @@ -639,15 +776,21 @@ class EmbeddingMaintainer(threading.Thread): except Exception: return None - def handle_regenerate_description(self, event_id: str, source: str) -> None: + def handle_regenerate_description( + self, event_id: str, source: str, force: bool + ) -> None: try: event: Event = Event.get(Event.id == event_id) except DoesNotExist: logger.error(f"Event {event_id} not found for description regeneration") return + if self.genai_client is None: + logger.error("GenAI not enabled") + return + camera_config = self.config.cameras[event.camera] - if not camera_config.genai.enabled or self.genai_client is None: + if not camera_config.objects.genai.enabled and not force: logger.error(f"GenAI not enabled for camera {event.camera}") return diff --git a/frigate/embeddings/onnx/base_embedding.py b/frigate/embeddings/onnx/base_embedding.py index fcadd2852a..c0bd58475b 100644 --- a/frigate/embeddings/onnx/base_embedding.py +++ b/frigate/embeddings/onnx/base_embedding.py @@ -3,7 +3,6 @@ import logging import os from abc import ABC, abstractmethod -from enum import Enum from io import BytesIO from typing import Any @@ -18,11 +17,6 @@ from frigate.util.downloader import ModelDownloader logger = logging.getLogger(__name__) -class EmbeddingTypeEnum(str, Enum): - thumbnail = "thumbnail" - description = "description" - - class BaseEmbedding(ABC): """Base embedding class.""" diff --git a/frigate/embeddings/onnx/face_embedding.py b/frigate/embeddings/onnx/face_embedding.py index eb04b43b27..e661f8d376 100644 --- a/frigate/embeddings/onnx/face_embedding.py +++ b/frigate/embeddings/onnx/face_embedding.py @@ -6,10 +6,13 @@ import os import numpy as np from frigate.const import MODEL_CACHE_DIR +from frigate.detectors.detection_runners import get_optimized_runner +from frigate.embeddings.types import EnrichmentModelTypeEnum +from frigate.log import redirect_output_to_logger from frigate.util.downloader import ModelDownloader +from ...config import FaceRecognitionConfig from .base_embedding import BaseEmbedding -from .runner import ONNXModelRunner try: from tflite_runtime.interpreter import Interpreter @@ -54,6 +57,7 @@ class FaceNetEmbedding(BaseEmbedding): self._load_model_and_utils() logger.debug(f"models are already downloaded for {self.model_name}") + @redirect_output_to_logger(logger, logging.DEBUG) def _load_model_and_utils(self): if self.runner is None: if self.downloader: @@ -110,7 +114,7 @@ class FaceNetEmbedding(BaseEmbedding): class ArcfaceEmbedding(BaseEmbedding): - def __init__(self): + def __init__(self, config: FaceRecognitionConfig): GITHUB_ENDPOINT = os.environ.get("GITHUB_ENDPOINT", "https://github.com") super().__init__( model_name="facedet", @@ -119,6 +123,7 @@ class ArcfaceEmbedding(BaseEmbedding): "arcface.onnx": f"{GITHUB_ENDPOINT}/NickM-27/facenet-onnx/releases/download/v1.0/arcface.onnx", }, ) + self.config = config self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) self.tokenizer = None self.feature_extractor = None @@ -146,9 +151,10 @@ class ArcfaceEmbedding(BaseEmbedding): if self.downloader: self.downloader.wait_for_download() - self.runner = ONNXModelRunner( + self.runner = get_optimized_runner( os.path.join(self.download_path, self.model_file), - "GPU", + device=self.config.device or "GPU", + model_type=EnrichmentModelTypeEnum.arcface.value, ) def _preprocess_inputs(self, raw_inputs): diff --git a/frigate/embeddings/onnx/jina_v1_embedding.py b/frigate/embeddings/onnx/jina_v1_embedding.py index b448ec8165..e64d8da39c 100644 --- a/frigate/embeddings/onnx/jina_v1_embedding.py +++ b/frigate/embeddings/onnx/jina_v1_embedding.py @@ -4,19 +4,21 @@ import logging import os import warnings -# importing this without pytorch or others causes a warning -# https://github.com/huggingface/transformers/issues/27214 -# suppressed by setting env TRANSFORMERS_NO_ADVISORY_WARNINGS=1 from transformers import AutoFeatureExtractor, AutoTokenizer from transformers.utils.logging import disable_progress_bar from frigate.comms.inter_process import InterProcessRequestor from frigate.const import MODEL_CACHE_DIR, UPDATE_MODEL_STATE +from frigate.detectors.detection_runners import BaseModelRunner, get_optimized_runner + +# importing this without pytorch or others causes a warning +# https://github.com/huggingface/transformers/issues/27214 +# suppressed by setting env TRANSFORMERS_NO_ADVISORY_WARNINGS=1 +from frigate.embeddings.types import EnrichmentModelTypeEnum from frigate.types import ModelStatusTypesEnum from frigate.util.downloader import ModelDownloader from .base_embedding import BaseEmbedding -from .runner import ONNXModelRunner warnings.filterwarnings( "ignore", @@ -125,10 +127,10 @@ class JinaV1TextEmbedding(BaseEmbedding): clean_up_tokenization_spaces=True, ) - self.runner = ONNXModelRunner( + self.runner = get_optimized_runner( os.path.join(self.download_path, self.model_file), self.device, - self.model_size, + model_type=EnrichmentModelTypeEnum.jina_v1.value, ) def _preprocess_inputs(self, raw_inputs): @@ -171,7 +173,7 @@ class JinaV1ImageEmbedding(BaseEmbedding): self.device = device self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) self.feature_extractor = None - self.runner: ONNXModelRunner | None = None + self.runner: BaseModelRunner | None = None files_names = list(self.download_urls.keys()) if not all( os.path.exists(os.path.join(self.download_path, n)) for n in files_names @@ -204,10 +206,10 @@ class JinaV1ImageEmbedding(BaseEmbedding): f"{MODEL_CACHE_DIR}/{self.model_name}", ) - self.runner = ONNXModelRunner( + self.runner = get_optimized_runner( os.path.join(self.download_path, self.model_file), self.device, - self.model_size, + model_type=EnrichmentModelTypeEnum.jina_v1.value, ) def _preprocess_inputs(self, raw_inputs): diff --git a/frigate/embeddings/onnx/jina_v2_embedding.py b/frigate/embeddings/onnx/jina_v2_embedding.py index e9def9a074..44cc6c12bb 100644 --- a/frigate/embeddings/onnx/jina_v2_embedding.py +++ b/frigate/embeddings/onnx/jina_v2_embedding.py @@ -11,11 +11,12 @@ from transformers.utils.logging import disable_progress_bar, set_verbosity_error from frigate.comms.inter_process import InterProcessRequestor from frigate.const import MODEL_CACHE_DIR, UPDATE_MODEL_STATE +from frigate.detectors.detection_runners import get_optimized_runner +from frigate.embeddings.types import EnrichmentModelTypeEnum from frigate.types import ModelStatusTypesEnum from frigate.util.downloader import ModelDownloader from .base_embedding import BaseEmbedding -from .runner import ONNXModelRunner # disables the progress bar and download logging for downloading tokenizers and image processors disable_progress_bar() @@ -125,10 +126,10 @@ class JinaV2Embedding(BaseEmbedding): clean_up_tokenization_spaces=True, ) - self.runner = ONNXModelRunner( + self.runner = get_optimized_runner( os.path.join(self.download_path, self.model_file), self.device, - self.model_size, + model_type=EnrichmentModelTypeEnum.jina_v2.value, ) def _preprocess_image(self, image_data: bytes | Image.Image) -> np.ndarray: diff --git a/frigate/embeddings/onnx/lpr_embedding.py b/frigate/embeddings/onnx/lpr_embedding.py index 35ff5ceeed..ad20999572 100644 --- a/frigate/embeddings/onnx/lpr_embedding.py +++ b/frigate/embeddings/onnx/lpr_embedding.py @@ -7,11 +7,12 @@ import numpy as np from frigate.comms.inter_process import InterProcessRequestor from frigate.const import MODEL_CACHE_DIR +from frigate.detectors.detection_runners import BaseModelRunner, get_optimized_runner +from frigate.embeddings.types import EnrichmentModelTypeEnum from frigate.types import ModelStatusTypesEnum from frigate.util.downloader import ModelDownloader from .base_embedding import BaseEmbedding -from .runner import ONNXModelRunner warnings.filterwarnings( "ignore", @@ -32,21 +33,23 @@ class PaddleOCRDetection(BaseEmbedding): device: str = "AUTO", ): model_file = ( - "detection-large.onnx" if model_size == "large" else "detection-small.onnx" + "detection_v3-large.onnx" + if model_size == "large" + else "detection_v5-small.onnx" ) GITHUB_ENDPOINT = os.environ.get("GITHUB_ENDPOINT", "https://github.com") super().__init__( model_name="paddleocr-onnx", model_file=model_file, download_urls={ - model_file: f"{GITHUB_ENDPOINT}/hawkeye217/paddleocr-onnx/raw/refs/heads/master/models/{model_file}" + model_file: f"{GITHUB_ENDPOINT}/hawkeye217/paddleocr-onnx/raw/refs/heads/master/models/{'v3' if model_size == 'large' else 'v5'}/{model_file}" }, ) self.requestor = requestor self.model_size = model_size self.device = device self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) - self.runner: ONNXModelRunner | None = None + self.runner: BaseModelRunner | None = None files_names = list(self.download_urls.keys()) if not all( os.path.exists(os.path.join(self.download_path, n)) for n in files_names @@ -75,10 +78,10 @@ class PaddleOCRDetection(BaseEmbedding): if self.downloader: self.downloader.wait_for_download() - self.runner = ONNXModelRunner( + self.runner = get_optimized_runner( os.path.join(self.download_path, self.model_file), self.device, - self.model_size, + model_type=EnrichmentModelTypeEnum.paddleocr.value, ) def _preprocess_inputs(self, raw_inputs): @@ -107,7 +110,7 @@ class PaddleOCRClassification(BaseEmbedding): self.model_size = model_size self.device = device self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) - self.runner: ONNXModelRunner | None = None + self.runner: BaseModelRunner | None = None files_names = list(self.download_urls.keys()) if not all( os.path.exists(os.path.join(self.download_path, n)) for n in files_names @@ -136,10 +139,10 @@ class PaddleOCRClassification(BaseEmbedding): if self.downloader: self.downloader.wait_for_download() - self.runner = ONNXModelRunner( + self.runner = get_optimized_runner( os.path.join(self.download_path, self.model_file), self.device, - self.model_size, + model_type=EnrichmentModelTypeEnum.paddleocr.value, ) def _preprocess_inputs(self, raw_inputs): @@ -159,16 +162,17 @@ class PaddleOCRRecognition(BaseEmbedding): GITHUB_ENDPOINT = os.environ.get("GITHUB_ENDPOINT", "https://github.com") super().__init__( model_name="paddleocr-onnx", - model_file="recognition.onnx", + model_file="recognition_v4.onnx", download_urls={ - "recognition.onnx": f"{GITHUB_ENDPOINT}/hawkeye217/paddleocr-onnx/raw/refs/heads/master/models/recognition.onnx" + "recognition_v4.onnx": f"{GITHUB_ENDPOINT}/hawkeye217/paddleocr-onnx/raw/refs/heads/master/models/v4/recognition_v4.onnx", + "ppocr_keys_v1.txt": f"{GITHUB_ENDPOINT}/hawkeye217/paddleocr-onnx/raw/refs/heads/master/models/v4/ppocr_keys_v1.txt", }, ) self.requestor = requestor self.model_size = model_size self.device = device self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) - self.runner: ONNXModelRunner | None = None + self.runner: BaseModelRunner | None = None files_names = list(self.download_urls.keys()) if not all( os.path.exists(os.path.join(self.download_path, n)) for n in files_names @@ -197,10 +201,10 @@ class PaddleOCRRecognition(BaseEmbedding): if self.downloader: self.downloader.wait_for_download() - self.runner = ONNXModelRunner( + self.runner = get_optimized_runner( os.path.join(self.download_path, self.model_file), self.device, - self.model_size, + model_type=EnrichmentModelTypeEnum.paddleocr.value, ) def _preprocess_inputs(self, raw_inputs): @@ -230,7 +234,7 @@ class LicensePlateDetector(BaseEmbedding): self.model_size = model_size self.device = device self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) - self.runner: ONNXModelRunner | None = None + self.runner: BaseModelRunner | None = None files_names = list(self.download_urls.keys()) if not all( os.path.exists(os.path.join(self.download_path, n)) for n in files_names @@ -259,10 +263,10 @@ class LicensePlateDetector(BaseEmbedding): if self.downloader: self.downloader.wait_for_download() - self.runner = ONNXModelRunner( + self.runner = get_optimized_runner( os.path.join(self.download_path, self.model_file), self.device, - self.model_size, + model_type=EnrichmentModelTypeEnum.yolov9_license_plate.value, ) def _preprocess_inputs(self, raw_inputs): diff --git a/frigate/embeddings/onnx/runner.py b/frigate/embeddings/onnx/runner.py deleted file mode 100644 index c34c97a8d1..0000000000 --- a/frigate/embeddings/onnx/runner.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Convenience runner for onnx models.""" - -import logging -import os.path -from typing import Any - -import onnxruntime as ort - -from frigate.const import MODEL_CACHE_DIR -from frigate.util.model import get_ort_providers - -try: - import openvino as ov -except ImportError: - # openvino is not included - pass - -logger = logging.getLogger(__name__) - - -class ONNXModelRunner: - """Run onnx models optimally based on available hardware.""" - - def __init__(self, model_path: str, device: str, requires_fp16: bool = False): - self.model_path = model_path - self.ort: ort.InferenceSession = None - self.ov: ov.Core = None - providers, options = get_ort_providers(device == "CPU", device, requires_fp16) - self.interpreter = None - - if "OpenVINOExecutionProvider" in providers: - try: - # use OpenVINO directly - self.type = "ov" - self.ov = ov.Core() - self.ov.set_property( - {ov.properties.cache_dir: os.path.join(MODEL_CACHE_DIR, "openvino")} - ) - self.interpreter = self.ov.compile_model( - model=model_path, device_name=device - ) - except Exception as e: - logger.warning( - f"OpenVINO failed to build model, using CPU instead: {e}" - ) - self.interpreter = None - - # Use ONNXRuntime - if self.interpreter is None: - self.type = "ort" - self.ort = ort.InferenceSession( - model_path, - providers=providers, - provider_options=options, - ) - - def get_input_names(self) -> list[str]: - if self.type == "ov": - input_names = [] - - for input in self.interpreter.inputs: - input_names.extend(input.names) - - return input_names - elif self.type == "ort": - return [input.name for input in self.ort.get_inputs()] - - def get_input_width(self): - """Get the input width of the model regardless of backend.""" - if self.type == "ort": - return self.ort.get_inputs()[0].shape[3] - elif self.type == "ov": - input_info = self.interpreter.inputs - first_input = input_info[0] - - try: - partial_shape = first_input.get_partial_shape() - # width dimension - if len(partial_shape) >= 4 and partial_shape[3].is_static: - return partial_shape[3].get_length() - - # If width is dynamic or we can't determine it - return -1 - except Exception: - try: - # gemini says some ov versions might still allow this - input_shape = first_input.shape - return input_shape[3] if len(input_shape) >= 4 else -1 - except Exception: - return -1 - return -1 - - def run(self, input: dict[str, Any]) -> Any: - if self.type == "ov": - infer_request = self.interpreter.create_infer_request() - - try: - # This ensures the model starts with a clean state for each sequence - # Important for RNN models like PaddleOCR recognition - infer_request.reset_state() - except Exception: - # this will raise an exception for models with AUTO set as the device - pass - - outputs = infer_request.infer(input) - - return outputs - elif self.type == "ort": - return self.ort.run(None, input) diff --git a/frigate/embeddings/types.py b/frigate/embeddings/types.py new file mode 100644 index 0000000000..32cbe5dd08 --- /dev/null +++ b/frigate/embeddings/types.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class EmbeddingTypeEnum(str, Enum): + thumbnail = "thumbnail" + description = "description" + + +class EnrichmentModelTypeEnum(str, Enum): + arcface = "arcface" + facenet = "facenet" + jina_v1 = "jina_v1" + jina_v2 = "jina_v2" + paddleocr = "paddleocr" + yolov9_license_plate = "yolov9_license_plate" diff --git a/frigate/events/audio.py b/frigate/events/audio.py index f2a217fd39..31b9a7f3c5 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -2,35 +2,42 @@ import datetime import logging -import random -import string import threading import time -from typing import Any, Tuple +from multiprocessing.managers import DictProxy +from multiprocessing.synchronize import Event as MpEvent +from typing import Tuple import numpy as np -import frigate.util as util -from frigate.camera import CameraMetrics -from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum -from frigate.comms.event_metadata_updater import ( - EventMetadataPublisher, - EventMetadataTypeEnum, -) from frigate.comms.inter_process import InterProcessRequestor -from frigate.config import CameraConfig, CameraInput, FfmpegConfig +from frigate.config import CameraConfig, CameraInput, FfmpegConfig, FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import ( AUDIO_DURATION, AUDIO_FORMAT, AUDIO_MAX_BIT_RANGE, AUDIO_MIN_CONFIDENCE, AUDIO_SAMPLE_RATE, + EXPIRE_AUDIO_ACTIVITY, + PROCESS_PRIORITY_HIGH, + UPDATE_AUDIO_ACTIVITY, +) +from frigate.data_processing.common.audio_transcription.model import ( + AudioTranscriptionModelRunner, +) +from frigate.data_processing.real_time.audio_transcription import ( + AudioTranscriptionRealTimeProcessor, ) from frigate.ffmpeg_presets import parse_preset_input -from frigate.log import LogPipe +from frigate.log import LogPipe, redirect_output_to_logger from frigate.object_detection.base import load_labels from frigate.util.builtin import get_ffmpeg_arg_list +from frigate.util.process import FrigateProcess from frigate.video import start_or_restart_ffmpeg, stop_ffmpeg try: @@ -39,6 +46,9 @@ except ModuleNotFoundError: from tensorflow.lite.python.interpreter import Interpreter +logger = logging.getLogger(__name__) + + def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]: ffmpeg_input: CameraInput = [i for i in ffmpeg.inputs if "audio" in i.roles][0] input_args = get_ffmpeg_arg_list(ffmpeg.global_args) + ( @@ -67,20 +77,34 @@ def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]: ) -class AudioProcessor(util.Process): +class AudioProcessor(FrigateProcess): name = "frigate.audio_manager" def __init__( self, + config: FrigateConfig, cameras: list[CameraConfig], - camera_metrics: dict[str, CameraMetrics], + camera_metrics: DictProxy, + stop_event: MpEvent, ): - super().__init__(name="frigate.audio_manager", daemon=True) + super().__init__( + stop_event, PROCESS_PRIORITY_HIGH, name="frigate.audio_manager", daemon=True + ) self.camera_metrics = camera_metrics self.cameras = cameras + self.config = config + + if self.config.audio_transcription.enabled: + self.transcription_model_runner = AudioTranscriptionModelRunner( + self.config.audio_transcription.device, + self.config.audio_transcription.model_size, + ) + else: + self.transcription_model_runner = None def run(self) -> None: + self.pre_run_setup(self.config.logger) audio_threads: list[AudioEventMaintainer] = [] threading.current_thread().name = "process:audio_manager" @@ -91,7 +115,9 @@ class AudioProcessor(util.Process): for camera in self.cameras: audio_thread = AudioEventMaintainer( camera, + self.config, self.camera_metrics, + self.transcription_model_runner, self.stop_event, ) audio_threads.append(audio_thread) @@ -119,76 +145,121 @@ class AudioEventMaintainer(threading.Thread): def __init__( self, camera: CameraConfig, - camera_metrics: dict[str, CameraMetrics], + config: FrigateConfig, + camera_metrics: DictProxy, + audio_transcription_model_runner: AudioTranscriptionModelRunner | None, stop_event: threading.Event, ) -> None: super().__init__(name=f"{camera.name}_audio_event_processor") - self.config = camera + self.config = config + self.camera_config = camera self.camera_metrics = camera_metrics - self.detections: dict[dict[str, Any]] = {} self.stop_event = stop_event - self.detector = AudioTfl(stop_event, self.config.audio.num_threads) + self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads) self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),) self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2)) - self.logger = logging.getLogger(f"audio.{self.config.name}") - self.ffmpeg_cmd = get_ffmpeg_command(self.config.ffmpeg) - self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.audio") + self.logger = logging.getLogger(f"audio.{self.camera_config.name}") + self.ffmpeg_cmd = get_ffmpeg_command(self.camera_config.ffmpeg) + self.logpipe = LogPipe(f"ffmpeg.{self.camera_config.name}.audio") self.audio_listener = None + self.audio_transcription_model_runner = audio_transcription_model_runner + self.transcription_processor = None + self.transcription_thread = None # create communication for audio detections self.requestor = InterProcessRequestor() - self.config_subscriber = ConfigSubscriber(f"config/audio/{camera.name}") - self.enabled_subscriber = ConfigSubscriber( - f"config/enabled/{camera.name}", True + self.config_subscriber = CameraConfigUpdateSubscriber( + None, + {self.camera_config.name: self.camera_config}, + [ + CameraConfigUpdateEnum.audio, + CameraConfigUpdateEnum.enabled, + CameraConfigUpdateEnum.audio_transcription, + ], ) - self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio) - self.event_metadata_publisher = EventMetadataPublisher() + self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value) + + if self.camera_config.audio_transcription.enabled_in_config: + # init the transcription processor for this camera + self.transcription_processor = AudioTranscriptionRealTimeProcessor( + config=self.config, + camera_config=self.camera_config, + requestor=self.requestor, + model_runner=self.audio_transcription_model_runner, + metrics=self.camera_metrics[self.camera_config.name], + stop_event=self.stop_event, + ) + + self.transcription_thread = threading.Thread( + target=self.transcription_processor.run, + name=f"{self.camera_config.name}_transcription_processor", + daemon=True, + ) + self.transcription_thread.start() self.was_enabled = camera.enabled def detect_audio(self, audio) -> None: - if not self.config.audio.enabled or self.stop_event.is_set(): + if not self.camera_config.audio.enabled or self.stop_event.is_set(): return audio_as_float = audio.astype(np.float32) rms, dBFS = self.calculate_audio_levels(audio_as_float) - self.camera_metrics[self.config.name].audio_rms.value = rms - self.camera_metrics[self.config.name].audio_dBFS.value = dBFS + self.camera_metrics[self.camera_config.name].audio_rms.value = rms + self.camera_metrics[self.camera_config.name].audio_dBFS.value = dBFS + + audio_detections: list[Tuple[str, float]] = [] # only run audio detection when volume is above min_volume - if rms >= self.config.audio.min_volume: + if rms >= self.camera_config.audio.min_volume: # create waveform relative to max range and look for detections waveform = (audio / AUDIO_MAX_BIT_RANGE).astype(np.float32) model_detections = self.detector.detect(waveform) - audio_detections = [] for label, score, _ in model_detections: self.logger.debug( - f"{self.config.name} heard {label} with a score of {score}" + f"{self.camera_config.name} heard {label} with a score of {score}" ) - if label not in self.config.audio.listen: + if label not in self.camera_config.audio.listen: continue - if score > dict((self.config.audio.filters or {}).get(label, {})).get( - "threshold", 0.8 - ): - self.handle_detection(label, score) - audio_detections.append(label) + if score > dict( + (self.camera_config.audio.filters or {}).get(label, {}) + ).get("threshold", 0.8): + audio_detections.append((label, score)) # send audio detection data self.detection_publisher.publish( ( - self.config.name, + self.camera_config.name, datetime.datetime.now().timestamp(), dBFS, - audio_detections, + [label for label, _ in audio_detections], ) ) - self.expire_detections() + # send audio activity update + self.requestor.send_data( + UPDATE_AUDIO_ACTIVITY, + {self.camera_config.name: {"detections": audio_detections}}, + ) + + # run audio transcription + if self.transcription_processor is not None: + if self.camera_config.audio_transcription.live_enabled: + # process audio until we've reached the endpoint + self.transcription_processor.process_audio( + { + "id": f"{self.camera_config.name}_audio", + "camera": self.camera_config.name, + }, + audio, + ) + else: + self.transcription_processor.check_unload_model() def calculate_audio_levels(self, audio_as_float: np.float32) -> Tuple[float, float]: # Calculate RMS (Root-Mean-Square) which represents the average signal amplitude @@ -201,78 +272,11 @@ class AudioEventMaintainer(threading.Thread): else: dBFS = 0 - self.requestor.send_data(f"{self.config.name}/audio/dBFS", float(dBFS)) - self.requestor.send_data(f"{self.config.name}/audio/rms", float(rms)) + self.requestor.send_data(f"{self.camera_config.name}/audio/dBFS", float(dBFS)) + self.requestor.send_data(f"{self.camera_config.name}/audio/rms", float(rms)) return float(rms), float(dBFS) - def handle_detection(self, label: str, score: float) -> None: - if self.detections.get(label): - self.detections[label]["last_detection"] = ( - datetime.datetime.now().timestamp() - ) - else: - now = datetime.datetime.now().timestamp() - rand_id = "".join( - random.choices(string.ascii_lowercase + string.digits, k=6) - ) - event_id = f"{now}-{rand_id}" - self.requestor.send_data(f"{self.config.name}/audio/{label}", "ON") - - self.event_metadata_publisher.publish( - EventMetadataTypeEnum.manual_event_create, - ( - now, - self.config.name, - label, - event_id, - True, - score, - None, - None, - "audio", - {}, - ), - ) - self.detections[label] = { - "id": event_id, - "label": label, - "last_detection": now, - } - - def expire_detections(self) -> None: - now = datetime.datetime.now().timestamp() - - for detection in self.detections.values(): - if not detection: - continue - - if ( - now - detection.get("last_detection", now) - > self.config.audio.max_not_heard - ): - self.requestor.send_data( - f"{self.config.name}/audio/{detection['label']}", "OFF" - ) - - self.event_metadata_publisher.publish( - EventMetadataTypeEnum.manual_event_end, - (detection["id"], detection["last_detection"]), - ) - self.detections[detection["label"]] = None - - def expire_all_detections(self) -> None: - """Immediately end all current detections""" - now = datetime.datetime.now().timestamp() - for label, detection in list(self.detections.items()): - if detection: - self.requestor.send_data(f"{self.config.name}/audio/{label}", "OFF") - self.event_metadata_publisher.publish( - EventMetadataTypeEnum.manual_event_end, - (detection["id"], now), - ) - self.detections[label] = None - def start_or_restart_ffmpeg(self) -> None: self.audio_listener = start_or_restart_ffmpeg( self.ffmpeg_cmd, @@ -281,13 +285,14 @@ class AudioEventMaintainer(threading.Thread): self.chunk_size, self.audio_listener, ) + self.requestor.send_data(f"{self.camera_config.name}/status/audio", "online") def read_audio(self) -> None: def log_and_restart() -> None: if self.stop_event.is_set(): return - time.sleep(self.config.ffmpeg.retry_interval) + time.sleep(self.camera_config.ffmpeg.retry_interval) self.logpipe.dump() self.start_or_restart_ffmpeg() @@ -296,6 +301,9 @@ class AudioEventMaintainer(threading.Thread): if not chunk: if self.audio_listener.poll() is not None: + self.requestor.send_data( + f"{self.camera_config.name}/status/audio", "offline" + ) self.logger.error("ffmpeg process is not running, restarting...") log_and_restart() return @@ -308,32 +316,28 @@ class AudioEventMaintainer(threading.Thread): self.logger.error(f"Error reading audio data from ffmpeg process: {e}") log_and_restart() - def _update_enabled_state(self) -> bool: - """Fetch the latest config and update enabled state.""" - _, config_data = self.enabled_subscriber.check_for_update() - if config_data: - self.config.enabled = config_data.enabled - return config_data.enabled - - return self.config.enabled - def run(self) -> None: - if self._update_enabled_state(): + if self.camera_config.enabled: self.start_or_restart_ffmpeg() while not self.stop_event.is_set(): - enabled = self._update_enabled_state() + enabled = self.camera_config.enabled if enabled != self.was_enabled: if enabled: self.logger.debug( - f"Enabling audio detections for {self.config.name}" + f"Enabling audio detections for {self.camera_config.name}" ) self.start_or_restart_ffmpeg() else: - self.logger.debug( - f"Disabling audio detections for {self.config.name}, ending events" + self.requestor.send_data( + f"{self.camera_config.name}/status/audio", "disabled" + ) + self.logger.debug( + f"Disabling audio detections for {self.camera_config.name}, ending events" + ) + self.requestor.send_data( + EXPIRE_AUDIO_ACTIVITY, self.camera_config.name ) - self.expire_all_detections() stop_ffmpeg(self.audio_listener, self.logger) self.audio_listener = None self.was_enabled = enabled @@ -344,26 +348,26 @@ class AudioEventMaintainer(threading.Thread): continue # check if there is an updated config - ( - updated_topic, - updated_audio_config, - ) = self.config_subscriber.check_for_update() - - if updated_topic: - self.config.audio = updated_audio_config + self.config_subscriber.check_for_updates() self.read_audio() if self.audio_listener: stop_ffmpeg(self.audio_listener, self.logger) + if self.transcription_thread: + self.transcription_thread.join(timeout=2) + if self.transcription_thread.is_alive(): + self.logger.warning( + f"Audio transcription thread {self.transcription_thread.name} is still alive" + ) self.logpipe.close() self.requestor.stop() self.config_subscriber.stop() - self.enabled_subscriber.stop() self.detection_publisher.stop() class AudioTfl: + @redirect_output_to_logger(logger, logging.DEBUG) def __init__(self, stop_event: threading.Event, num_threads=2): self.stop_event = stop_event self.num_threads = num_threads diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index a26efae3e0..d23c8b3729 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -7,6 +7,7 @@ from typing import Any from frigate.const import ( FFMPEG_HVC1_ARGS, + FFMPEG_HWACCEL_AMF, FFMPEG_HWACCEL_NVIDIA, FFMPEG_HWACCEL_RKMPP, FFMPEG_HWACCEL_VAAPI, @@ -74,6 +75,7 @@ PRESETS_HW_ACCEL_DECODE = { f"{FFMPEG_HWACCEL_RKMPP}-no-dump_extra": "-hwaccel rkmpp -hwaccel_output_format drm_prime", # experimental presets FFMPEG_HWACCEL_VULKAN: "-hwaccel vulkan -init_hw_device vulkan=gpu:0 -filter_hw_device gpu -hwaccel_output_format vulkan", + FFMPEG_HWACCEL_AMF: "-hwaccel amf -init_hw_device amf=gpu:0 -filter_hw_device gpu -hwaccel_output_format amf", } PRESETS_HW_ACCEL_DECODE["preset-nvidia-h264"] = PRESETS_HW_ACCEL_DECODE[ FFMPEG_HWACCEL_NVIDIA @@ -108,6 +110,7 @@ PRESETS_HW_ACCEL_SCALE = { "default": "-r {0} -vf fps={0},scale={1}:{2}", # experimental presets FFMPEG_HWACCEL_VULKAN: "-r {0} -vf fps={0},hwupload,scale_vulkan=w={1}:h={2},hwdownload", + FFMPEG_HWACCEL_AMF: "-r {0} -vf fps={0},hwupload,scale_amf=w={1}:h={2},hwdownload", } PRESETS_HW_ACCEL_SCALE["preset-nvidia-h264"] = PRESETS_HW_ACCEL_SCALE[ FFMPEG_HWACCEL_NVIDIA @@ -133,6 +136,7 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = { "preset-jetson-h265": "{0} -hide_banner {1} -c:v h264_nvmpi -profile main {2}", FFMPEG_HWACCEL_RKMPP: "{0} -hide_banner {1} -c:v h264_rkmpp -profile:v high {2}", "preset-rk-h265": "{0} -hide_banner {1} -c:v hevc_rkmpp -profile:v main {2}", + FFMPEG_HWACCEL_AMF: "{0} -hide_banner {1} -c:v h264_amf -g 50 -profile:v high {2}", "default": "{0} -hide_banner {1} -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency {2}", } PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["preset-nvidia-h264"] = ( @@ -161,6 +165,7 @@ PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = { "preset-jetson-h265": "{0} -hide_banner {1} -c:v hevc_nvmpi -profile main {2}", FFMPEG_HWACCEL_RKMPP: "{0} -hide_banner {1} -c:v h264_rkmpp -profile:v high {2}", "preset-rk-h265": "{0} -hide_banner {1} -c:v hevc_rkmpp -profile:v main {2}", + FFMPEG_HWACCEL_AMF: "{0} -hide_banner {1} -c:v h264_amf -profile:v high {2}", "default": "{0} -hide_banner {1} -c:v libx264 -preset:v ultrafast -tune:v zerolatency {2}", } PRESETS_HW_ACCEL_ENCODE_TIMELAPSE["preset-nvidia-h264"] = ( diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index a3fc7a09ce..e94360da36 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -1,13 +1,17 @@ """Generative AI module for Frigate.""" +import datetime import importlib import logging import os -from typing import Optional +import re +from typing import Any, Optional from playhouse.shortcuts import model_to_dict from frigate.config import CameraConfig, FrigateConfig, GenAIConfig, GenAIProviderEnum +from frigate.const import CLIPS_DIR +from frigate.data_processing.post.types import ReviewMetadata from frigate.models import Event logger = logging.getLogger(__name__) @@ -33,7 +37,187 @@ class GenAIClient: self.timeout = timeout self.provider = self._init_provider() - def generate_description( + def generate_review_description( + self, + review_data: dict[str, Any], + thumbnails: list[bytes], + concerns: list[str], + preferred_language: str | None, + debug_save: bool, + ) -> ReviewMetadata | None: + """Generate a description for the review item activity.""" + + def get_concern_prompt() -> str: + if concerns: + concern_list = "\n - ".join(concerns) + return f""" +- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring: + - {concern_list}""" + else: + return "" + + def get_language_prompt() -> str: + if preferred_language: + return f"Provide your answer in {preferred_language}" + else: + return "" + + context_prompt = f""" +Please analyze the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"].replace("_", " ")} security camera. + +Your task is to provide a clear, security-focused description of the scene that: +1. States exactly what is happening based on observable actions and movements. +2. Identifies and emphasizes behaviors that match patterns of suspicious activity. +3. Assigns a potential_threat_level based on the definitions below, applying them consistently. + +Facts come first, but identifying security risks is the primary goal. + +When forming your description: +- Describe the people and objects exactly as seen. Include any observable environmental changes (e.g., lighting changes triggered by activity). +- Time of day should **increase suspicion only when paired with unusual or security-relevant behaviors**. Do not raise the threat level for common residential activities (e.g., residents walking pets, retrieving mail, gardening, playing with pets, supervising children) even at unusual hours, unless other suspicious indicators are present. +- Focus on behaviors that are uncharacteristic of innocent activity: loitering without clear purpose, avoiding cameras, inspecting vehicles/doors, changing behavior when lights activate, scanning surroundings without an apparent benign reason. +- **Benign context override**: If scanning or looking around is clearly part of an innocent activity (such as playing with a dog, gardening, supervising children, or watching for a pet), do not treat it as suspicious. + +Your response MUST be a flat JSON object with: +- `scene` (string): A full description including setting, entities, actions, and any plausible supported inferences. +- `confidence` (float): 0-1 confidence in the analysis. +- `potential_threat_level` (integer): 0, 1, or 2 as defined below. +{get_concern_prompt()} + +Threat-level definitions: +- 0 — Typical or expected activity for this location/time (includes residents, guests, or known animals engaged in normal activities, even if they glance around or scan surroundings). +- 1 — Unusual or suspicious activity: At least one security-relevant behavior is present **and not explainable by a normal residential activity**. +- 2 — Active or immediate threat: Breaking in, vandalism, aggression, weapon display. + +Sequence details: +- Frame 1 = earliest, Frame {len(thumbnails)} = latest +- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds +- Detected objects: {", ".join(review_data["objects"])} +- Verified recognized objects: {", ".join(review_data["recognized_objects"]) or "None"} +- Zones involved: {", ".join(z.replace("_", " ").title() for z in review_data["zones"]) or "None"} + +**IMPORTANT:** +- Values must be plain strings, floats, or integers — no nested objects, no extra commentary. +{get_language_prompt()} +""" + logger.debug( + f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}" + ) + + if debug_save: + with open( + os.path.join( + CLIPS_DIR, "genai-requests", review_data["id"], "prompt.txt" + ), + "w", + ) as f: + f.write(context_prompt) + + response = self._send(context_prompt, thumbnails) + + if debug_save and response: + with open( + os.path.join( + CLIPS_DIR, "genai-requests", review_data["id"], "response.txt" + ), + "w", + ) as f: + f.write(response) + + if response: + clean_json = re.sub( + r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response) + ) + + try: + metadata = ReviewMetadata.model_validate_json(clean_json) + + if review_data["recognized_objects"]: + metadata.potential_threat_level = 0 + + metadata.time = review_data["start"] + return metadata + except Exception as e: + # rarely LLMs can fail to follow directions on output format + logger.warning( + f"Failed to parse review description as the response did not match expected format. {e}" + ) + return None + else: + return None + + def generate_review_summary( + self, + start_ts: float, + end_ts: float, + segments: list[dict[str, Any]], + debug_save: bool, + ) -> str | None: + """Generate a summary of review item descriptions over a period of time.""" + time_range = f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')} to {datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}" + timeline_summary_prompt = f""" +You are a security officer. +Time range: {time_range}. +Input: JSON list with "scene", "confidence", "potential_threat_level" (1-2), "other_concerns". + +Task: Write a concise, human-presentable security report in markdown format. + +Rules for the report: + +- Title & overview + - Start with: + # Security Summary - {time_range} + - Write a 1-2 sentence situational overview capturing the general pattern of the period. + +- Event details + - Present events in chronological order as a bullet list. + - **If multiple events occur within the same minute or overlapping time range, COMBINE them into a single bullet.** + - Summarize the distinct activities as sub-points under the shared timestamp. + - If no timestamp is given, preserve order but label as “Time not specified.” + - Use bold timestamps for clarity. + - Group bullets under subheadings when multiple events fall into the same category (e.g., Vehicle Activity, Porch Activity, Unusual Behavior). + +- Threat levels + - Always show (threat level: X) for each event. + - If multiple events at the same time share the same threat level, only state it once. + +- Final assessment + - End with a Final Assessment section. + - If all events are threat level 1 with no escalation: + Final assessment: Only normal residential activity observed during this period. + - If threat level 2+ events are present, clearly summarize them as Potential concerns requiring review. + +- Conciseness + - Do not repeat benign clothing/appearance details unless they distinguish individuals. + - Summarize similar routine events instead of restating full scene descriptions. +""" + + for item in segments: + timeline_summary_prompt += f"\n{item}" + + if debug_save: + with open( + os.path.join( + CLIPS_DIR, "genai-requests", f"{start_ts}-{end_ts}", "prompt.txt" + ), + "w", + ) as f: + f.write(timeline_summary_prompt) + + response = self._send(timeline_summary_prompt, []) + + if debug_save and response: + with open( + os.path.join( + CLIPS_DIR, "genai-requests", f"{start_ts}-{end_ts}", "response.txt" + ), + "w", + ) as f: + f.write(response) + + return response + + def generate_object_description( self, camera_config: CameraConfig, thumbnails: list[bytes], @@ -63,16 +247,13 @@ class GenAIClient: def get_genai_client(config: FrigateConfig) -> Optional[GenAIClient]: """Get the GenAI client.""" - genai_config = config.genai - genai_cameras = [ - c for c in config.cameras.values() if c.enabled and c.genai.enabled - ] + if not config.genai.provider: + return None - if genai_cameras: - load_providers() - provider = PROVIDERS.get(genai_config.provider) - if provider: - return provider(genai_config) + load_providers() + provider = PROVIDERS.get(config.genai.provider) + if provider: + return provider(config.genai) return None diff --git a/frigate/genai/gemini.py b/frigate/genai/gemini.py index 750454e251..8c355b37ad 100644 --- a/frigate/genai/gemini.py +++ b/frigate/genai/gemini.py @@ -21,7 +21,9 @@ class GeminiClient(GenAIClient): def _init_provider(self): """Initialize the client.""" genai.configure(api_key=self.genai_config.api_key) - return genai.GenerativeModel(self.genai_config.model) + return genai.GenerativeModel( + self.genai_config.model, **self.genai_config.provider_options + ) def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: """Submit a request to Gemini.""" diff --git a/frigate/genai/ollama.py b/frigate/genai/ollama.py index e67d532f02..0fb44d7852 100644 --- a/frigate/genai/ollama.py +++ b/frigate/genai/ollama.py @@ -47,7 +47,8 @@ class OllamaClient(GenAIClient): result = self.provider.generate( self.genai_config.model, prompt, - images=images, + images=images if images else None, + **self.genai_config.provider_options, ) return result["response"].strip() except (TimeoutException, ResponseError) as e: diff --git a/frigate/genai/openai.py b/frigate/genai/openai.py index 76ba8cb442..eb3016fadc 100644 --- a/frigate/genai/openai.py +++ b/frigate/genai/openai.py @@ -21,7 +21,9 @@ class OpenAIClient(GenAIClient): def _init_provider(self): """Initialize the client.""" - return OpenAI(api_key=self.genai_config.api_key) + return OpenAI( + api_key=self.genai_config.api_key, **self.genai_config.provider_options + ) def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: """Submit a request to OpenAI.""" diff --git a/frigate/log.py b/frigate/log.py index 096b522159..f2171ffe00 100644 --- a/frigate/log.py +++ b/frigate/log.py @@ -1,14 +1,18 @@ # In log.py import atexit +import io import logging -import multiprocessing as mp import os import sys import threading from collections import deque +from contextlib import contextmanager +from enum import Enum +from functools import wraps from logging.handlers import QueueHandler, QueueListener -from queue import Queue -from typing import Deque, Optional +from multiprocessing.managers import SyncManager +from queue import Empty, Queue +from typing import Any, Callable, Deque, Generator, Optional from frigate.util.builtin import clean_camera_user_pass @@ -33,14 +37,21 @@ LOG_HANDLER.addFilter( not in record.getMessage() ) + +class LogLevel(str, Enum): + debug = "debug" + info = "info" + warning = "warning" + error = "error" + critical = "critical" + + log_listener: Optional[QueueListener] = None log_queue: Optional[Queue] = None -manager = None -def setup_logging() -> None: - global log_listener, log_queue, manager - manager = mp.Manager() +def setup_logging(manager: SyncManager) -> None: + global log_listener, log_queue log_queue = manager.Queue() log_listener = QueueListener(log_queue, LOG_HANDLER, respect_handler_level=True) @@ -57,13 +68,27 @@ def setup_logging() -> None: def _stop_logging() -> None: - global log_listener, manager + global log_listener if log_listener is not None: log_listener.stop() log_listener = None - if manager is not None: - manager.shutdown() - manager = None + + +def apply_log_levels(default: str, log_levels: dict[str, LogLevel]) -> None: + logging.getLogger().setLevel(default) + + log_levels = { + "absl": LogLevel.error, + "httpx": LogLevel.error, + "matplotlib": LogLevel.error, + "tensorflow": LogLevel.error, + "werkzeug": LogLevel.error, + "ws4py": LogLevel.error, + **log_levels, + } + + for log, level in log_levels.items(): + logging.getLogger(log).setLevel(level.value.upper()) # When a multiprocessing.Process exits, python tries to flush stdout and stderr. However, if the @@ -81,11 +106,11 @@ os.register_at_fork(after_in_child=reopen_std_streams) # based on https://codereview.stackexchange.com/a/17959 class LogPipe(threading.Thread): - def __init__(self, log_name: str): + def __init__(self, log_name: str, level: int = logging.ERROR): """Setup the object with a logger and start the thread""" super().__init__(daemon=False) self.logger = logging.getLogger(log_name) - self.level = logging.ERROR + self.level = level self.deque: Deque[str] = deque(maxlen=100) self.fdRead, self.fdWrite = os.pipe() self.pipeReader = os.fdopen(self.fdRead) @@ -114,3 +139,182 @@ class LogPipe(threading.Thread): def close(self) -> None: """Close the write end of the pipe.""" os.close(self.fdWrite) + + +class LogRedirect(io.StringIO): + """ + A custom file-like object to capture stdout and process it. + It extends io.StringIO to capture output and then processes it + line by line. + """ + + def __init__(self, logger_instance: logging.Logger, level: int): + super().__init__() + self.logger = logger_instance + self.log_level = level + self._line_buffer: list[str] = [] + + def write(self, s: Any) -> int: + if not isinstance(s, str): + s = str(s) + + self._line_buffer.append(s) + + # Process output line by line if a newline is present + if "\n" in s: + full_output = "".join(self._line_buffer) + lines = full_output.splitlines(keepends=True) + self._line_buffer = [] + + for line in lines: + if line.endswith("\n"): + self._process_line(line.rstrip("\n")) + else: + self._line_buffer.append(line) + + return len(s) + + def _process_line(self, line: str) -> None: + self.logger.log(self.log_level, line) + + def flush(self) -> None: + if self._line_buffer: + full_output = "".join(self._line_buffer) + self._line_buffer = [] + if full_output: # Only process if there's content + self._process_line(full_output) + + def __enter__(self) -> "LogRedirect": + """Context manager entry point.""" + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Context manager exit point. Ensures buffered content is flushed.""" + self.flush() + + +@contextmanager +def __redirect_fd_to_queue(queue: Queue[str]) -> Generator[None, None, None]: + """Redirect file descriptor 1 (stdout) to a pipe and capture output in a queue.""" + stdout_fd = os.dup(1) + read_fd, write_fd = os.pipe() + os.dup2(write_fd, 1) + os.close(write_fd) + + stop_event = threading.Event() + + def reader() -> None: + """Read from pipe and put lines in queue until stop_event is set.""" + try: + with os.fdopen(read_fd, "r") as pipe: + while not stop_event.is_set(): + line = pipe.readline() + if not line: # EOF + break + queue.put(line.strip()) + except OSError as e: + queue.put(f"Reader error: {e}") + finally: + if not stop_event.is_set(): + stop_event.set() + + reader_thread = threading.Thread(target=reader, daemon=False) + reader_thread.start() + + try: + yield + finally: + os.dup2(stdout_fd, 1) + os.close(stdout_fd) + stop_event.set() + reader_thread.join(timeout=1.0) + try: + os.close(read_fd) + except OSError: + pass + + +def redirect_output_to_logger(logger: logging.Logger, level: int) -> Any: + """Decorator to redirect both Python sys.stdout/stderr and C-level stdout to logger.""" + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + queue: Queue[str] = Queue() + + log_redirect = LogRedirect(logger, level) + old_stdout = sys.stdout + old_stderr = sys.stderr + sys.stdout = log_redirect + sys.stderr = log_redirect + + try: + # Redirect C-level stdout + with __redirect_fd_to_queue(queue): + result = func(*args, **kwargs) + finally: + # Restore Python stdout/stderr + sys.stdout = old_stdout + sys.stderr = old_stderr + log_redirect.flush() + + # Log C-level output from queue + while True: + try: + logger.log(level, queue.get_nowait()) + except Empty: + break + + return result + + return wrapper + + return decorator + + +def suppress_os_output(func: Callable) -> Callable: + """ + A decorator that suppresses all output (stdout and stderr) + at the operating system file descriptor level for the decorated function. + This is useful for silencing noisy C/C++ libraries. + Note: This is a Unix-specific solution using os.dup2 and os.pipe. + It temporarily redirects file descriptors 1 (stdout) and 2 (stderr) + to a non-read pipe, effectively discarding their output. + """ + + @wraps(func) + def wrapper(*args: tuple, **kwargs: dict[str, Any]) -> Any: + # Save the original file descriptors for stdout (1) and stderr (2) + original_stdout_fd = os.dup(1) + original_stderr_fd = os.dup(2) + + # Create dummy pipes. We only need the write ends to redirect to. + # The data written to these pipes will be discarded as nothing + # will read from the read ends. + devnull_read_fd, devnull_write_fd = os.pipe() + + try: + # Redirect stdout (FD 1) and stderr (FD 2) to the write end of our dummy pipe + os.dup2(devnull_write_fd, 1) # Redirect stdout to devnull pipe + os.dup2(devnull_write_fd, 2) # Redirect stderr to devnull pipe + + # Execute the original function + result = func(*args, **kwargs) + + finally: + # Restore original stdout and stderr file descriptors (1 and 2) + # This is crucial to ensure normal printing resumes after the decorated function. + os.dup2(original_stdout_fd, 1) + os.dup2(original_stderr_fd, 2) + + # Close all duplicated and pipe file descriptors to prevent resource leaks. + # It's important to close the read end of the dummy pipe too, + # as nothing is explicitly reading from it. + os.close(original_stdout_fd) + os.close(original_stderr_fd) + os.close(devnull_read_fd) + os.close(devnull_write_fd) + + return result + + return wrapper diff --git a/frigate/models.py b/frigate/models.py index 5aa0dc5b2e..59188128bb 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -1,6 +1,8 @@ from peewee import ( + BlobField, BooleanField, CharField, + CompositeKey, DateTimeField, FloatField, ForeignKeyField, @@ -11,7 +13,7 @@ from peewee import ( from playhouse.sqlite_ext import JSONField -class Event(Model): # type: ignore[misc] +class Event(Model): id = CharField(null=False, primary_key=True, max_length=30) label = CharField(index=True, max_length=20) sub_label = CharField(max_length=100, null=True) @@ -49,7 +51,7 @@ class Event(Model): # type: ignore[misc] data = JSONField() # ex: tracked object box, region, etc. -class Timeline(Model): # type: ignore[misc] +class Timeline(Model): timestamp = DateTimeField() camera = CharField(index=True, max_length=20) source = CharField(index=True, max_length=20) # ex: tracked object, audio, external @@ -58,13 +60,13 @@ class Timeline(Model): # type: ignore[misc] data = JSONField() # ex: tracked object id, region, box, etc. -class Regions(Model): # type: ignore[misc] +class Regions(Model): camera = CharField(null=False, primary_key=True, max_length=20) grid = JSONField() # json blob of grid last_update = DateTimeField() -class Recordings(Model): # type: ignore[misc] +class Recordings(Model): id = CharField(null=False, primary_key=True, max_length=30) camera = CharField(index=True, max_length=20) path = CharField(unique=True) @@ -78,7 +80,7 @@ class Recordings(Model): # type: ignore[misc] regions = IntegerField(null=True) -class Export(Model): # type: ignore[misc] +class Export(Model): id = CharField(null=False, primary_key=True, max_length=30) camera = CharField(index=True, max_length=20) name = CharField(index=True, max_length=100) @@ -88,7 +90,7 @@ class Export(Model): # type: ignore[misc] in_progress = BooleanField() -class ReviewSegment(Model): # type: ignore[misc] +class ReviewSegment(Model): id = CharField(null=False, primary_key=True, max_length=30) camera = CharField(index=True, max_length=20) start_time = DateTimeField() @@ -98,7 +100,7 @@ class ReviewSegment(Model): # type: ignore[misc] data = JSONField() # additional data about detection like list of labels, zone, areas of significant motion -class UserReviewStatus(Model): # type: ignore[misc] +class UserReviewStatus(Model): user_id = CharField(max_length=30) review_segment = ForeignKeyField(ReviewSegment, backref="user_reviews") has_been_reviewed = BooleanField(default=False) @@ -107,7 +109,7 @@ class UserReviewStatus(Model): # type: ignore[misc] indexes = ((("user_id", "review_segment"), True),) -class Previews(Model): # type: ignore[misc] +class Previews(Model): id = CharField(null=False, primary_key=True, max_length=30) camera = CharField(index=True, max_length=20) path = CharField(unique=True) @@ -117,14 +119,14 @@ class Previews(Model): # type: ignore[misc] # Used for temporary table in record/cleanup.py -class RecordingsToDelete(Model): # type: ignore[misc] +class RecordingsToDelete(Model): id = CharField(null=False, primary_key=False, max_length=30) class Meta: temporary = True -class User(Model): # type: ignore[misc] +class User(Model): username = CharField(null=False, primary_key=True, max_length=30) role = CharField( max_length=20, @@ -132,3 +134,30 @@ class User(Model): # type: ignore[misc] ) password_hash = CharField(null=False, max_length=120) notification_tokens = JSONField() + + @classmethod + def get_allowed_cameras( + cls, role: str, roles_dict: dict[str, list[str]], all_camera_names: set[str] + ) -> list[str]: + if role not in roles_dict: + return [] # Invalid role grants no access + allowed = roles_dict[role] + if not allowed: # Empty list means all cameras + return list(all_camera_names) + + return [cam for cam in allowed if cam in all_camera_names] + + +class Trigger(Model): + camera = CharField(max_length=20) + name = CharField() + type = CharField(max_length=10) + data = TextField() + threshold = FloatField() + model = CharField(max_length=30) + embedding = BlobField() + triggering_event_id = CharField(max_length=30) + last_triggered = DateTimeField() + + class Meta: + primary_key = CompositeKey("camera", "name") diff --git a/frigate/motion/__init__.py b/frigate/motion/__init__.py index db5f258791..1f6785d5da 100644 --- a/frigate/motion/__init__.py +++ b/frigate/motion/__init__.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod from typing import Tuple +from numpy import ndarray + from frigate.config import MotionConfig @@ -18,13 +20,21 @@ class MotionDetector(ABC): pass @abstractmethod - def detect(self, frame): + def detect(self, frame: ndarray) -> list: + """Detect motion and return motion boxes.""" pass @abstractmethod def is_calibrating(self): + """Return if motion is recalibrating.""" + pass + + @abstractmethod + def update_mask(self) -> None: + """Update the motion mask after a config change.""" pass @abstractmethod def stop(self): + """Stop any ongoing work and processes.""" pass diff --git a/frigate/motion/improved_motion.py b/frigate/motion/improved_motion.py index 69de6d0155..77eae26a95 100644 --- a/frigate/motion/improved_motion.py +++ b/frigate/motion/improved_motion.py @@ -5,7 +5,6 @@ import numpy as np from scipy.ndimage import gaussian_filter from frigate.camera import PTZMetrics -from frigate.comms.config_updater import ConfigSubscriber from frigate.config import MotionConfig from frigate.motion import MotionDetector from frigate.util.image import grab_cv2_contours @@ -36,12 +35,7 @@ class ImprovedMotionDetector(MotionDetector): self.avg_frame = np.zeros(self.motion_frame_size, np.float32) self.motion_frame_count = 0 self.frame_counter = 0 - resized_mask = cv2.resize( - config.mask, - dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), - interpolation=cv2.INTER_AREA, - ) - self.mask = np.where(resized_mask == [0]) + self.update_mask() self.save_images = False self.calibrating = True self.blur_radius = blur_radius @@ -49,7 +43,6 @@ class ImprovedMotionDetector(MotionDetector): self.contrast_values = np.zeros((contrast_frame_history, 2), np.uint8) self.contrast_values[:, 1:2] = 255 self.contrast_values_index = 0 - self.config_subscriber = ConfigSubscriber(f"config/motion/{name}", True) self.ptz_metrics = ptz_metrics self.last_stop_time = None @@ -59,12 +52,6 @@ class ImprovedMotionDetector(MotionDetector): def detect(self, frame): motion_boxes = [] - # check for updated motion config - _, updated_motion_config = self.config_subscriber.check_for_update() - - if updated_motion_config: - self.config = updated_motion_config - if not self.config.enabled: return motion_boxes @@ -244,6 +231,14 @@ class ImprovedMotionDetector(MotionDetector): return motion_boxes + def update_mask(self) -> None: + resized_mask = cv2.resize( + self.config.mask, + dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), + interpolation=cv2.INTER_AREA, + ) + self.mask = np.where(resized_mask == [0]) + def stop(self) -> None: """stop the motion detector.""" - self.config_subscriber.stop() + pass diff --git a/frigate/mypy.ini b/frigate/mypy.ini index c687a254d6..5bad10f497 100644 --- a/frigate/mypy.ini +++ b/frigate/mypy.ini @@ -35,6 +35,9 @@ disallow_untyped_calls = false [mypy-frigate.const] ignore_errors = false +[mypy-frigate.comms.*] +ignore_errors = false + [mypy-frigate.events] ignore_errors = false @@ -50,6 +53,9 @@ ignore_errors = false [mypy-frigate.stats] ignore_errors = false +[mypy-frigate.track.*] +ignore_errors = false + [mypy-frigate.types] ignore_errors = false diff --git a/frigate/object_detection/base.py b/frigate/object_detection/base.py index c77a720a04..9f49651116 100644 --- a/frigate/object_detection/base.py +++ b/frigate/object_detection/base.py @@ -1,18 +1,21 @@ import datetime import logging -import multiprocessing as mp -import os import queue -import signal import threading +import time from abc import ABC, abstractmethod +from collections import deque from multiprocessing import Queue, Value from multiprocessing.synchronize import Event as MpEvent import numpy as np -from setproctitle import setproctitle -import frigate.util as util +from frigate.comms.object_detector_signaler import ( + ObjectDetectorPublisher, + ObjectDetectorSubscriber, +) +from frigate.config import FrigateConfig +from frigate.const import PROCESS_PRIORITY_HIGH from frigate.detectors import create_detector from frigate.detectors.detector_config import ( BaseDetectorConfig, @@ -21,7 +24,7 @@ from frigate.detectors.detector_config import ( ) from frigate.util.builtin import EventsPerSecond, load_labels from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory -from frigate.util.services import listen +from frigate.util.process import FrigateProcess from .util import tensor_transform @@ -34,7 +37,7 @@ class ObjectDetector(ABC): pass -class LocalObjectDetector(ObjectDetector): +class BaseLocalDetector(ObjectDetector): def __init__( self, detector_config: BaseDetectorConfig = None, @@ -56,6 +59,18 @@ class LocalObjectDetector(ObjectDetector): self.detect_api = create_detector(detector_config) + def _transform_input(self, tensor_input: np.ndarray) -> np.ndarray: + if self.input_transform: + tensor_input = np.transpose(tensor_input, self.input_transform) + + if self.dtype == InputDTypeEnum.float: + tensor_input = tensor_input.astype(np.float32) + tensor_input /= 255 + elif self.dtype == InputDTypeEnum.float_denorm: + tensor_input = tensor_input.astype(np.float32) + + return tensor_input + def detect(self, tensor_input: np.ndarray, threshold=0.4): detections = [] @@ -73,76 +88,198 @@ class LocalObjectDetector(ObjectDetector): self.fps.update() return detections + +class LocalObjectDetector(BaseLocalDetector): def detect_raw(self, tensor_input: np.ndarray): - if self.input_transform: - tensor_input = np.transpose(tensor_input, self.input_transform) - - if self.dtype == InputDTypeEnum.float: - tensor_input = tensor_input.astype(np.float32) - tensor_input /= 255 - elif self.dtype == InputDTypeEnum.float_denorm: - tensor_input = tensor_input.astype(np.float32) - + tensor_input = self._transform_input(tensor_input) return self.detect_api.detect_raw(tensor_input=tensor_input) -def run_detector( - name: str, - detection_queue: Queue, - out_events: dict[str, MpEvent], - avg_speed: Value, - start: Value, - detector_config: BaseDetectorConfig, -): - threading.current_thread().name = f"detector:{name}" - logger = logging.getLogger(f"detector.{name}") - logger.info(f"Starting detection process: {os.getpid()}") - setproctitle(f"frigate.detector.{name}") - listen() +class AsyncLocalObjectDetector(BaseLocalDetector): + def async_send_input(self, tensor_input: np.ndarray, connection_id: str): + tensor_input = self._transform_input(tensor_input) + return self.detect_api.send_input(connection_id, tensor_input) - stop_event: MpEvent = mp.Event() + def async_receive_output(self): + return self.detect_api.receive_output() - def receiveSignal(signalNumber, frame): - stop_event.set() - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) +class DetectorRunner(FrigateProcess): + def __init__( + self, + name, + detection_queue: Queue, + cameras: list[str], + avg_speed: Value, + start_time: Value, + config: FrigateConfig, + detector_config: BaseDetectorConfig, + stop_event: MpEvent, + ) -> None: + super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True) + self.detection_queue = detection_queue + self.cameras = cameras + self.avg_speed = avg_speed + self.start_time = start_time + self.config = config + self.detector_config = detector_config + self.outputs: dict = {} - frame_manager = SharedMemoryFrameManager() - object_detector = LocalObjectDetector(detector_config=detector_config) - - outputs = {} - for name in out_events.keys(): + def create_output_shm(self, name: str): out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False) out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf) - outputs[name] = {"shm": out_shm, "np": out_np} + self.outputs[name] = {"shm": out_shm, "np": out_np} - while not stop_event.is_set(): - try: - connection_id = detection_queue.get(timeout=1) - except queue.Empty: - continue - input_frame = frame_manager.get( - connection_id, - (1, detector_config.model.height, detector_config.model.width, 3), - ) + def run(self) -> None: + self.pre_run_setup(self.config.logger) - if input_frame is None: - logger.warning(f"Failed to get frame {connection_id} from SHM") - continue + frame_manager = SharedMemoryFrameManager() + object_detector = LocalObjectDetector(detector_config=self.detector_config) + detector_publisher = ObjectDetectorPublisher() - # detect and send the output - start.value = datetime.datetime.now().timestamp() - detections = object_detector.detect_raw(input_frame) - duration = datetime.datetime.now().timestamp() - start.value - frame_manager.close(connection_id) - outputs[connection_id]["np"][:] = detections[:] - out_events[connection_id].set() - start.value = 0.0 + for name in self.cameras: + self.create_output_shm(name) - avg_speed.value = (avg_speed.value * 9 + duration) / 10 + while not self.stop_event.is_set(): + try: + connection_id = self.detection_queue.get(timeout=1) + except queue.Empty: + continue + input_frame = frame_manager.get( + connection_id, + ( + 1, + self.detector_config.model.height, + self.detector_config.model.width, + 3, + ), + ) - logger.info("Exited detection process...") + if input_frame is None: + logger.warning(f"Failed to get frame {connection_id} from SHM") + continue + + # detect and send the output + self.start_time.value = datetime.datetime.now().timestamp() + detections = object_detector.detect_raw(input_frame) + duration = datetime.datetime.now().timestamp() - self.start_time.value + frame_manager.close(connection_id) + + if connection_id not in self.outputs: + self.create_output_shm(connection_id) + + self.outputs[connection_id]["np"][:] = detections[:] + detector_publisher.publish(connection_id) + self.start_time.value = 0.0 + + self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10 + + detector_publisher.stop() + logger.info("Exited detection process...") + + +class AsyncDetectorRunner(FrigateProcess): + def __init__( + self, + name, + detection_queue: Queue, + cameras: list[str], + avg_speed: Value, + start_time: Value, + config: FrigateConfig, + detector_config: BaseDetectorConfig, + stop_event: MpEvent, + ) -> None: + super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True) + self.detection_queue = detection_queue + self.cameras = cameras + self.avg_speed = avg_speed + self.start_time = start_time + self.config = config + self.detector_config = detector_config + self.outputs: dict = {} + self._frame_manager: SharedMemoryFrameManager | None = None + self._publisher: ObjectDetectorPublisher | None = None + self._detector: AsyncLocalObjectDetector | None = None + self.send_times = deque() + + def create_output_shm(self, name: str): + out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False) + out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf) + self.outputs[name] = {"shm": out_shm, "np": out_np} + + def _detect_worker(self) -> None: + logger.info("Starting Detect Worker Thread") + while not self.stop_event.is_set(): + try: + connection_id = self.detection_queue.get(timeout=1) + except queue.Empty: + continue + + input_frame = self._frame_manager.get( + connection_id, + ( + 1, + self.detector_config.model.height, + self.detector_config.model.width, + 3, + ), + ) + + if input_frame is None: + logger.warning(f"Failed to get frame {connection_id} from SHM") + continue + + # mark start time and send to accelerator + self.send_times.append(time.perf_counter()) + self._detector.async_send_input(input_frame, connection_id) + + def _result_worker(self) -> None: + logger.info("Starting Result Worker Thread") + while not self.stop_event.is_set(): + connection_id, detections = self._detector.async_receive_output() + + if not self.send_times: + # guard; shouldn't happen if send/recv are balanced + continue + ts = self.send_times.popleft() + duration = time.perf_counter() - ts + + # release input buffer + self._frame_manager.close(connection_id) + + if connection_id not in self.outputs: + self.create_output_shm(connection_id) + + # write results and publish + if detections is not None: + self.outputs[connection_id]["np"][:] = detections[:] + self._publisher.publish(connection_id) + + # update timers + self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10 + self.start_time.value = 0.0 + + def run(self) -> None: + self.pre_run_setup(self.config.logger) + + self._frame_manager = SharedMemoryFrameManager() + self._publisher = ObjectDetectorPublisher() + self._detector = AsyncLocalObjectDetector(detector_config=self.detector_config) + + for name in self.cameras: + self.create_output_shm(name) + + t_detect = threading.Thread(target=self._detect_worker, daemon=True) + t_result = threading.Thread(target=self._result_worker, daemon=True) + t_detect.start() + t_result.start() + + while not self.stop_event.is_set(): + time.sleep(0.5) + + self._publisher.stop() + logger.info("Exited async detection process...") class ObjectDetectProcess: @@ -150,16 +287,20 @@ class ObjectDetectProcess: self, name: str, detection_queue: Queue, - out_events: dict[str, MpEvent], + cameras: list[str], + config: FrigateConfig, detector_config: BaseDetectorConfig, + stop_event: MpEvent, ): self.name = name - self.out_events = out_events + self.cameras = cameras self.detection_queue = detection_queue self.avg_inference_speed = Value("d", 0.01) self.detection_start = Value("d", 0.0) - self.detect_process: util.Process | None = None + self.detect_process: FrigateProcess | None = None + self.config = config self.detector_config = detector_config + self.stop_event = stop_event self.start_or_restart() def stop(self): @@ -179,19 +320,30 @@ class ObjectDetectProcess: self.detection_start.value = 0.0 if (self.detect_process is not None) and self.detect_process.is_alive(): self.stop() - self.detect_process = util.Process( - target=run_detector, - name=f"detector:{self.name}", - args=( - self.name, + + # Async path for MemryX + if self.detector_config.type == "memryx": + self.detect_process = AsyncDetectorRunner( + f"frigate.detector:{self.name}", self.detection_queue, - self.out_events, + self.cameras, self.avg_inference_speed, self.detection_start, + self.config, self.detector_config, - ), - ) - self.detect_process.daemon = True + self.stop_event, + ) + else: + self.detect_process = DetectorRunner( + f"frigate.detector:{self.name}", + self.detection_queue, + self.cameras, + self.avg_inference_speed, + self.detection_start, + self.config, + self.detector_config, + self.stop_event, + ) self.detect_process.start() @@ -201,7 +353,6 @@ class RemoteObjectDetector: name: str, labels: dict[int, str], detection_queue: Queue, - event: MpEvent, model_config: ModelConfig, stop_event: MpEvent, ): @@ -209,7 +360,6 @@ class RemoteObjectDetector: self.name = name self.fps = EventsPerSecond() self.detection_queue = detection_queue - self.event = event self.stop_event = stop_event self.shm = UntrackedSharedMemory(name=self.name, create=False) self.np_shm = np.ndarray( @@ -219,6 +369,7 @@ class RemoteObjectDetector: ) self.out_shm = UntrackedSharedMemory(name=f"out-{self.name}", create=False) self.out_np_shm = np.ndarray((20, 6), dtype=np.float32, buffer=self.out_shm.buf) + self.detector_subscriber = ObjectDetectorSubscriber(name) def detect(self, tensor_input, threshold=0.4): detections = [] @@ -228,9 +379,8 @@ class RemoteObjectDetector: # copy input to shared memory self.np_shm[:] = tensor_input[:] - self.event.clear() self.detection_queue.put(self.name) - result = self.event.wait(timeout=5.0) + result = self.detector_subscriber.check_for_update() # if it timed out if result is None: @@ -246,5 +396,6 @@ class RemoteObjectDetector: return detections def cleanup(self): + self.detector_subscriber.stop() self.shm.unlink() self.out_shm.unlink() diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index b295af82e0..0939b5ce4b 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -15,9 +15,9 @@ from typing import Any, Optional import cv2 import numpy as np -from frigate.comms.config_updater import ConfigSubscriber +from frigate.comms.inter_process import InterProcessRequestor from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig -from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR +from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT from frigate.util.image import ( SharedMemoryFrameManager, copy_yuv_to_position, @@ -319,35 +319,48 @@ class BirdsEyeFrameManager: self.frame[:] = self.blank_frame self.cameras = {} - for camera, settings in self.config.cameras.items(): - # precalculate the coordinates for all the channels - y, u1, u2, v1, v2 = get_yuv_crop( - settings.frame_shape_yuv, - ( - 0, - 0, - settings.frame_shape[1], - settings.frame_shape[0], - ), - ) - self.cameras[camera] = { - "dimensions": [settings.detect.width, settings.detect.height], - "last_active_frame": 0.0, - "current_frame": 0.0, - "layout_frame": 0.0, - "channel_dims": { - "y": y, - "u1": u1, - "u2": u2, - "v1": v1, - "v2": v2, - }, - } + for camera in self.config.cameras.keys(): + self.add_camera(camera) self.camera_layout = [] self.active_cameras = set() self.last_output_time = 0.0 + def add_camera(self, cam: str): + """Add a camera to self.cameras with the correct structure.""" + settings = self.config.cameras[cam] + # precalculate the coordinates for all the channels + y, u1, u2, v1, v2 = get_yuv_crop( + settings.frame_shape_yuv, + ( + 0, + 0, + settings.frame_shape[1], + settings.frame_shape[0], + ), + ) + self.cameras[cam] = { + "dimensions": [ + settings.detect.width, + settings.detect.height, + ], + "last_active_frame": 0.0, + "current_frame": 0.0, + "layout_frame": 0.0, + "channel_dims": { + "y": y, + "u1": u1, + "u2": u2, + "v1": v1, + "v2": v2, + }, + } + + def remove_camera(self, cam: str): + """Remove a camera from self.cameras.""" + if cam in self.cameras: + del self.cameras[cam] + def clear_frame(self): logger.debug("Clearing the birdseye frame") self.frame[:] = self.blank_frame @@ -381,10 +394,24 @@ class BirdsEyeFrameManager: if mode == BirdseyeModeEnum.objects and object_box_count > 0: return True - def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: + def get_camera_coordinates(self) -> dict[str, dict[str, int]]: + """Return the coordinates of each camera in the current layout.""" + coordinates = {} + for row in self.camera_layout: + for position in row: + camera_name, (x, y, width, height) = position + coordinates[camera_name] = { + "x": x, + "y": y, + "width": width, + "height": height, + } + return coordinates + + def update_frame(self, frame: Optional[np.ndarray] = None) -> tuple[bool, bool]: """ Update birdseye, optionally with a new frame. - When no frame is passed, check the layout and update for any disabled cameras. + Returns (frame_changed, layout_changed) to indicate if the frame or layout changed. """ # determine how many cameras are tracking objects within the last inactivity_threshold seconds @@ -422,19 +449,21 @@ class BirdsEyeFrameManager: max_camera_refresh = True self.last_refresh_time = now - # Track if the frame changes + # Track if the frame or layout changes frame_changed = False + layout_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 + return False, False # if the layout needs to be cleared self.camera_layout = [] self.active_cameras = set() self.clear_frame() frame_changed = True + layout_changed = True else: # Determine if layout needs resetting if len(self.active_cameras) - len(active_cameras) == 0: @@ -454,7 +483,7 @@ class BirdsEyeFrameManager: logger.debug("Resetting Birdseye layout...") self.clear_frame() self.active_cameras = active_cameras - + layout_changed = True # Layout is changing due to reset # this also converts added_cameras from a set to a list since we need # to pop elements in order active_cameras_to_add = sorted( @@ -504,7 +533,7 @@ class BirdsEyeFrameManager: # decrease scaling coefficient until height of all cameras can fit into the birdseye canvas while calculating: if self.stop_event.is_set(): - return + return frame_changed, layout_changed layout_candidate = self.calculate_layout( active_cameras_to_add, coefficient @@ -518,7 +547,7 @@ class BirdsEyeFrameManager: logger.error( "Error finding appropriate birdseye layout" ) - return + return frame_changed, layout_changed calculating = False self.canvas.set_coefficient(len(active_cameras), coefficient) @@ -536,7 +565,7 @@ class BirdsEyeFrameManager: if frame is not None: # Frame presence indicates a potential change frame_changed = True - return frame_changed + return frame_changed, layout_changed def calculate_layout( self, @@ -688,7 +717,11 @@ class BirdsEyeFrameManager: motion_count: int, frame_time: float, frame: np.ndarray, - ) -> bool: + ) -> tuple[bool, bool]: + """ + Update birdseye for a specific camera with new frame data. + Returns (frame_changed, layout_changed) to indicate if the frame or layout changed. + """ # don't process if birdseye is disabled for this camera camera_config = self.config.cameras[camera] force_update = False @@ -701,7 +734,7 @@ class BirdsEyeFrameManager: self.cameras[camera]["last_active_frame"] = 0 force_update = True else: - return False + return False, False # update the last active frame for the camera self.cameras[camera]["current_frame"] = frame.copy() @@ -713,21 +746,22 @@ class BirdsEyeFrameManager: # limit output to 10 fps if not force_update and (now - self.last_output_time) < 1 / 10: - return False + return False, False try: - updated_frame = self.update_frame(frame) + frame_changed, layout_changed = self.update_frame(frame) except Exception: - updated_frame = False + frame_changed, layout_changed = False, False self.active_cameras = [] self.camera_layout = [] print(traceback.format_exc()) # if the frame was updated or the fps is too low, send frame - if force_update or updated_frame or (now - self.last_output_time) > 1: + if force_update or frame_changed or (now - self.last_output_time) > 1: self.last_output_time = now - return True - return False + return True, layout_changed + + return False, layout_changed class Birdseye: @@ -753,10 +787,10 @@ class Birdseye: self.broadcaster = BroadcastThread( "birdseye", self.converter, websocket_server, stop_event ) - self.birdseye_manager = BirdsEyeFrameManager(config, stop_event) - self.birdseye_subscriber = ConfigSubscriber("config/birdseye/") + self.birdseye_manager = BirdsEyeFrameManager(self.config, stop_event) self.frame_manager = SharedMemoryFrameManager() self.stop_event = stop_event + self.requestor = InterProcessRequestor() if config.birdseye.restream: self.birdseye_buffer = self.frame_manager.create( @@ -783,6 +817,16 @@ class Birdseye: self.birdseye_manager.clear_frame() self.__send_new_frame() + def add_camera(self, camera: str) -> None: + """Add a camera to the birdseye manager.""" + self.birdseye_manager.add_camera(camera) + logger.debug(f"Added camera {camera} to birdseye") + + def remove_camera(self, camera: str) -> None: + """Remove a camera from the birdseye manager.""" + self.birdseye_manager.remove_camera(camera) + logger.debug(f"Removed camera {camera} from birdseye") + def write_data( self, camera: str, @@ -791,30 +835,20 @@ class Birdseye: frame_time: float, frame: np.ndarray, ) -> None: - # check if there is an updated config - while True: - ( - updated_birdseye_topic, - updated_birdseye_config, - ) = self.birdseye_subscriber.check_for_update() - - if not updated_birdseye_topic: - break - - if updated_birdseye_config: - camera_name = updated_birdseye_topic.rpartition("/")[-1] - self.config.cameras[camera_name].birdseye = updated_birdseye_config - - if self.birdseye_manager.update( + frame_changed, frame_layout_changed = self.birdseye_manager.update( camera, len([o for o in current_tracked_objects if not o["stationary"]]), len(motion_boxes), frame_time, frame, - ): + ) + if frame_changed: self.__send_new_frame() + if frame_layout_changed: + coordinates = self.birdseye_manager.get_camera_coordinates() + self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates) + def stop(self) -> None: - self.birdseye_subscriber.stop() self.converter.join() self.broadcaster.join() diff --git a/frigate/output/output.py b/frigate/output/output.py index 1723ac73cc..674c02b783 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -2,14 +2,12 @@ import datetime import logging -import multiprocessing as mp import os import shutil -import signal import threading +from multiprocessing.synchronize import Event as MpEvent from wsgiref.simple_server import make_server -from setproctitle import setproctitle from ws4py.server.wsgirefserver import ( WebSocketWSGIHandler, WebSocketWSGIRequestHandler, @@ -17,15 +15,19 @@ 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 -from frigate.const import CACHE_DIR, CLIPS_DIR +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) +from frigate.const import CACHE_DIR, CLIPS_DIR, PROCESS_PRIORITY_MED from frigate.output.birdseye import Birdseye from frigate.output.camera import JsmpegCamera from frigate.output.preview import PreviewRecorder from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame +from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) @@ -70,183 +72,201 @@ def check_disabled_camera_update( birdseye.all_cameras_disabled() -def output_frames( - config: FrigateConfig, -): - threading.current_thread().name = "output" - setproctitle("frigate.output") +class OutputProcess(FrigateProcess): + def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None: + super().__init__( + stop_event, PROCESS_PRIORITY_MED, name="frigate.output", daemon=True + ) + self.config = config - stop_event = mp.Event() + def run(self) -> None: + self.pre_run_setup(self.config.logger) - def receiveSignal(signalNumber, frame): - stop_event.set() + frame_manager = SharedMemoryFrameManager() - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) + # start a websocket server on 8082 + WebSocketWSGIHandler.http_version = "1.1" + websocket_server = make_server( + "127.0.0.1", + 8082, + server_class=WSGIServer, + handler_class=WebSocketWSGIRequestHandler, + app=WebSocketWSGIApplication(handler_cls=WebSocket), + ) + websocket_server.initialize_websockets_manager() + websocket_thread = threading.Thread(target=websocket_server.serve_forever) - frame_manager = SharedMemoryFrameManager() + detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value) + config_subscriber = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.birdseye, + CameraConfigUpdateEnum.enabled, + CameraConfigUpdateEnum.record, + ], + ) - # start a websocket server on 8082 - WebSocketWSGIHandler.http_version = "1.1" - websocket_server = make_server( - "127.0.0.1", - 8082, - server_class=WSGIServer, - handler_class=WebSocketWSGIRequestHandler, - app=WebSocketWSGIApplication(handler_cls=WebSocket), - ) - websocket_server.initialize_websockets_manager() - websocket_thread = threading.Thread(target=websocket_server.serve_forever) + jsmpeg_cameras: dict[str, JsmpegCamera] = {} + 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() - detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video) - config_enabled_subscriber = ConfigSubscriber("config/enabled/") + move_preview_frames("cache") - jsmpeg_cameras: dict[str, JsmpegCamera] = {} - 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() + for camera, cam_config in self.config.cameras.items(): + if not cam_config.enabled_in_config: + continue - move_preview_frames("cache") - - for camera, cam_config in config.cameras.items(): - if not cam_config.enabled_in_config: - continue - - jsmpeg_cameras[camera] = JsmpegCamera(cam_config, stop_event, websocket_server) - preview_recorders[camera] = PreviewRecorder(cam_config) - preview_write_times[camera] = 0 - - if config.birdseye.enabled: - birdseye = Birdseye(config, stop_event, websocket_server) - - websocket_thread.start() - - while not stop_event.is_set(): - # check if there is an updated config - while True: - ( - updated_enabled_topic, - updated_enabled_config, - ) = config_enabled_subscriber.check_for_update() - - if not updated_enabled_topic: - break - - if updated_enabled_config: - camera_name = updated_enabled_topic.rpartition("/")[-1] - config.cameras[camera_name].enabled = updated_enabled_config.enabled - - (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 + jsmpeg_cameras[camera] = JsmpegCamera( + cam_config, self.stop_event, websocket_server ) + preview_recorders[camera] = PreviewRecorder(cam_config) + preview_write_times[camera] = 0 - if not topic: - continue + if self.config.birdseye.enabled: + birdseye = Birdseye(self.config, self.stop_event, websocket_server) - ( - camera, - frame_name, - frame_time, - current_tracked_objects, - motion_boxes, - _, - ) = data + websocket_thread.start() - if not config.cameras[camera].enabled: - continue + while not self.stop_event.is_set(): + # check if there is an updated config + updates = config_subscriber.check_for_updates() - frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv) + if CameraConfigUpdateEnum.add in updates: + for camera in updates["add"]: + jsmpeg_cameras[camera] = JsmpegCamera( + cam_config, self.stop_event, websocket_server + ) + preview_recorders[camera] = PreviewRecorder(cam_config) + preview_write_times[camera] = 0 - if frame is None: - logger.debug(f"Failed to get frame {frame_name} from SHM") - failed_frame_requests[camera] = failed_frame_requests.get(camera, 0) + 1 + if ( + self.config.birdseye.enabled + and self.config.cameras[camera].birdseye.enabled + ): + birdseye.add_camera(camera) - if failed_frame_requests[camera] > config.cameras[camera].detect.fps: - logger.warning( - f"Failed to retrieve many frames for {camera} from SHM, consider increasing SHM size if this continues." + (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( + self.config, birdseye, preview_recorders, preview_write_times ) - continue - else: - failed_frame_requests[camera] = 0 + if not topic: + continue - # send frames for low fps recording - preview_recorders[camera].write_data( - current_tracked_objects, motion_boxes, frame_time, frame - ) - preview_write_times[camera] = frame_time - - # send camera frame to ffmpeg process if websockets are connected - if any( - ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager - ): - # write to the converter for the camera if clients are listening to the specific camera - jsmpeg_cameras[camera].write_frame(frame.tobytes()) - - # send output data to birdseye if websocket is connected or restreaming - if config.birdseye.enabled and ( - config.birdseye.restream - or any( - ws.environ["PATH_INFO"].endswith("birdseye") - for ws in websocket_server.manager - ) - ): - birdseye.write_data( + ( camera, + frame_name, + frame_time, current_tracked_objects, motion_boxes, - frame_time, - frame, + _, + ) = data + + if not self.config.cameras[camera].enabled: + continue + + frame = frame_manager.get( + frame_name, self.config.cameras[camera].frame_shape_yuv ) - frame_manager.close(frame_name) + if frame is None: + logger.debug(f"Failed to get frame {frame_name} from SHM") + failed_frame_requests[camera] = failed_frame_requests.get(camera, 0) + 1 - move_preview_frames("clips") + if ( + failed_frame_requests[camera] + > self.config.cameras[camera].detect.fps + ): + logger.warning( + f"Failed to retrieve many frames for {camera} from SHM, consider increasing SHM size if this continues." + ) - while True: - (topic, data) = detection_subscriber.check_for_update(timeout=0) + continue + else: + failed_frame_requests[camera] = 0 - if not topic: - break + # send frames for low fps recording + preview_recorders[camera].write_data( + current_tracked_objects, motion_boxes, frame_time, frame + ) + preview_write_times[camera] = frame_time - ( - camera, - frame_name, - frame_time, - current_tracked_objects, - motion_boxes, - regions, - ) = data + # send camera frame to ffmpeg process if websockets are connected + if any( + ws.environ["PATH_INFO"].endswith(camera) + for ws in websocket_server.manager + ): + # write to the converter for the camera if clients are listening to the specific camera + jsmpeg_cameras[camera].write_frame(frame.tobytes()) - frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv) - frame_manager.close(frame_name) + # send output data to birdseye if websocket is connected or restreaming + if self.config.birdseye.enabled and ( + self.config.birdseye.restream + or any( + ws.environ["PATH_INFO"].endswith("birdseye") + for ws in websocket_server.manager + ) + ): + birdseye.write_data( + camera, + current_tracked_objects, + motion_boxes, + frame_time, + frame, + ) - detection_subscriber.stop() + frame_manager.close(frame_name) - for jsmpeg in jsmpeg_cameras.values(): - jsmpeg.stop() + move_preview_frames("clips") - for preview in preview_recorders.values(): - preview.stop() + while True: + (topic, data) = detection_subscriber.check_for_update(timeout=0) - if birdseye is not None: - birdseye.stop() + if not topic: + break - config_enabled_subscriber.stop() - websocket_server.manager.close_all() - websocket_server.manager.stop() - websocket_server.manager.join() - websocket_server.shutdown() - websocket_thread.join() - logger.info("exiting output process...") + ( + camera, + frame_name, + frame_time, + current_tracked_objects, + motion_boxes, + regions, + ) = data + + frame = frame_manager.get( + frame_name, self.config.cameras[camera].frame_shape_yuv + ) + frame_manager.close(frame_name) + + detection_subscriber.stop() + + for jsmpeg in jsmpeg_cameras.values(): + jsmpeg.stop() + + for preview in preview_recorders.values(): + preview.stop() + + if birdseye is not None: + birdseye.stop() + + config_subscriber.stop() + websocket_server.manager.close_all() + websocket_server.manager.stop() + websocket_server.manager.join() + websocket_server.shutdown() + websocket_thread.join() + logger.info("exiting output process...") def move_preview_frames(loc: str): diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 08caa6738a..6dfd909047 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -13,7 +13,6 @@ from typing import Any import cv2 import numpy as np -from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.inter_process import InterProcessRequestor from frigate.config import CameraConfig, RecordQualityEnum from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE @@ -174,9 +173,6 @@ class PreviewRecorder: # create communication for finished previews self.requestor = InterProcessRequestor() - self.config_subscriber = ConfigSubscriber( - f"config/record/{self.config.name}", True - ) y, u1, u2, v1, v2 = get_yuv_crop( self.config.frame_shape_yuv, @@ -323,12 +319,6 @@ class PreviewRecorder: ) -> None: self.offline = False - # check for updated record config - _, updated_record_config = self.config_subscriber.check_for_update() - - if updated_record_config: - self.config.record = updated_record_config - # always write the first frame if self.start_time == 0: self.start_time = frame_time diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index c6d43bbba7..6e86ecbf2c 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -31,7 +31,7 @@ from frigate.const import ( ) from frigate.ptz.onvif import OnvifController from frigate.track.tracked_object import TrackedObject -from frigate.util.builtin import update_yaml_file +from frigate.util.builtin import update_yaml_file_bulk from frigate.util.config import find_config_file from frigate.util.image import SharedMemoryFrameManager, intersection_over_union @@ -60,10 +60,10 @@ class PtzMotionEstimator: def motion_estimator( self, - detections: list[dict[str, Any]], + detections: list[tuple[Any, Any, Any, Any, Any, Any]], frame_name: str, frame_time: float, - camera: str, + camera: str | None, ): # If we've just started up or returned to our preset, reset motion estimator for new tracking session if self.ptz_metrics.reset.is_set(): @@ -348,10 +348,13 @@ class PtzAutoTracker: f"{camera}: Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}" ) - update_yaml_file( + update_yaml_file_bulk( config_file, - ["cameras", camera, "onvif", "autotracking", "movement_weights"], - self.config.cameras[camera].onvif.autotracking.movement_weights, + { + f"cameras.{camera}.onvif.autotracking.movement_weights": self.config.cameras[ + camera + ].onvif.autotracking.movement_weights + }, ) async def _calibrate_camera(self, camera): diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 424c4c0dd4..13faffc978 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -33,6 +33,8 @@ class OnvifCommandEnum(str, Enum): stop = "stop" zoom_in = "zoom_in" zoom_out = "zoom_out" + focus_in = "focus_in" + focus_out = "focus_out" class OnvifController: @@ -188,6 +190,16 @@ class OnvifController: ptz: ONVIFService = await onvif.create_ptz_service() self.cams[camera_name]["ptz"] = ptz + imaging: ONVIFService = await onvif.create_imaging_service() + self.cams[camera_name]["imaging"] = imaging + try: + video_sources = await media.GetVideoSources() + if video_sources and len(video_sources) > 0: + self.cams[camera_name]["video_source_token"] = video_sources[0].token + except (Fault, ONVIFError, TransportError, Exception) as e: + logger.debug(f"Unable to get video sources for {camera_name}: {e}") + self.cams[camera_name]["video_source_token"] = None + # setup continuous moving request move_request = ptz.create_type("ContinuousMove") move_request.ProfileToken = profile.token @@ -369,7 +381,19 @@ class OnvifController: f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}" ) - # set relative pan/tilt space for autotracker + if self.cams[camera_name]["video_source_token"] is not None: + try: + imaging_capabilities = await imaging.GetImagingSettings( + {"VideoSourceToken": self.cams[camera_name]["video_source_token"]} + ) + if ( + hasattr(imaging_capabilities, "Focus") + and imaging_capabilities.Focus + ): + supported_features.append("focus") + except (Fault, ONVIFError, TransportError, Exception) as e: + logger.debug(f"Focus not supported for {camera_name}: {e}") + if ( self.config.cameras[camera_name].onvif.autotracking.enabled_in_config and self.config.cameras[camera_name].onvif.autotracking.enabled @@ -394,6 +418,18 @@ class OnvifController: "Zoom": True, } ) + if ( + "focus" in self.cams[camera_name]["features"] + and self.cams[camera_name]["video_source_token"] + ): + try: + stop_request = self.cams[camera_name]["imaging"].create_type("Stop") + stop_request.VideoSourceToken = self.cams[camera_name][ + "video_source_token" + ] + await self.cams[camera_name]["imaging"].Stop(stop_request) + except (Fault, ONVIFError, TransportError, Exception) as e: + logger.warning(f"Failed to stop focus for {camera_name}: {e}") self.cams[camera_name]["active"] = False async def _move(self, camera_name: str, command: OnvifCommandEnum) -> None: @@ -602,6 +638,35 @@ class OnvifController: self.cams[camera_name]["active"] = False + async def _focus(self, camera_name: str, command: OnvifCommandEnum) -> None: + if self.cams[camera_name]["active"]: + logger.warning( + f"{camera_name} is already performing an action, not moving..." + ) + await self._stop(camera_name) + + if ( + "focus" not in self.cams[camera_name]["features"] + or not self.cams[camera_name]["video_source_token"] + ): + logger.error(f"{camera_name} does not support ONVIF continuous focus.") + return + + self.cams[camera_name]["active"] = True + move_request = self.cams[camera_name]["imaging"].create_type("Move") + move_request.VideoSourceToken = self.cams[camera_name]["video_source_token"] + move_request.Focus = { + "Continuous": { + "Speed": 0.5 if command == OnvifCommandEnum.focus_in else -0.5 + } + } + + try: + await self.cams[camera_name]["imaging"].Move(move_request) + except (Fault, ONVIFError, TransportError, Exception) as e: + logger.warning(f"Onvif sending focus request to {camera_name} failed: {e}") + self.cams[camera_name]["active"] = False + async def handle_command_async( self, camera_name: str, command: OnvifCommandEnum, param: str = "" ) -> None: @@ -625,11 +690,10 @@ class OnvifController: elif command == OnvifCommandEnum.move_relative: _, pan, tilt = param.split("_") await self._move_relative(camera_name, float(pan), float(tilt), 0, 1) - elif ( - command == OnvifCommandEnum.zoom_in - or command == OnvifCommandEnum.zoom_out - ): + elif command in (OnvifCommandEnum.zoom_in, OnvifCommandEnum.zoom_out): await self._zoom(camera_name, command) + elif command in (OnvifCommandEnum.focus_in, OnvifCommandEnum.focus_out): + await self._focus(camera_name, command) else: await self._move(camera_name, command) except (Fault, ONVIFError, TransportError, Exception) as e: @@ -640,7 +704,6 @@ class OnvifController: ) -> None: """ Handle ONVIF commands by scheduling them in the event loop. - This is the synchronous interface that schedules async work. """ future = asyncio.run_coroutine_threadsafe( self.handle_command_async(camera_name, command, param), self.loop diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 1de08a8996..b0359e71d7 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -100,7 +100,11 @@ class RecordingCleanup(threading.Thread): ).execute() def expire_existing_camera_recordings( - self, expire_date: float, config: CameraConfig, reviews: ReviewSegment + self, + continuous_expire_date: float, + motion_expire_date: float, + config: CameraConfig, + reviews: ReviewSegment, ) -> None: """Delete recordings for existing camera based on retention config.""" # Get the timestamp for cutoff of retained days @@ -116,8 +120,14 @@ class RecordingCleanup(threading.Thread): Recordings.motion, ) .where( - Recordings.camera == config.name, - Recordings.end_time < expire_date, + (Recordings.camera == config.name) + & ( + ( + (Recordings.end_time < continuous_expire_date) + & (Recordings.motion == 0) + ) + | (Recordings.end_time < motion_expire_date) + ) ) .order_by(Recordings.start_time) .namedtuples() @@ -188,7 +198,7 @@ class RecordingCleanup(threading.Thread): Recordings.id << deleted_recordings_list[i : i + max_deletes] ).execute() - previews: Previews = ( + previews: list[Previews] = ( Previews.select( Previews.id, Previews.start_time, @@ -196,8 +206,9 @@ class RecordingCleanup(threading.Thread): Previews.path, ) .where( - Previews.camera == config.name, - Previews.end_time < expire_date, + (Previews.camera == config.name) + & (Previews.end_time < continuous_expire_date) + & (Previews.end_time < motion_expire_date) ) .order_by(Previews.start_time) .namedtuples() @@ -253,7 +264,9 @@ class RecordingCleanup(threading.Thread): logger.debug("Start deleted cameras.") # Handle deleted cameras - expire_days = self.config.record.retain.days + expire_days = max( + self.config.record.continuous.days, self.config.record.motion.days + ) expire_before = ( datetime.datetime.now() - datetime.timedelta(days=expire_days) ).timestamp() @@ -291,9 +304,17 @@ class RecordingCleanup(threading.Thread): now = datetime.datetime.now() self.expire_review_segments(config, now) - - expire_days = config.record.retain.days - expire_date = (now - datetime.timedelta(days=expire_days)).timestamp() + continuous_expire_date = ( + now - datetime.timedelta(days=config.record.continuous.days) + ).timestamp() + motion_expire_date = ( + now + - datetime.timedelta( + days=max( + config.record.motion.days, config.record.continuous.days + ) # can't keep motion for less than continuous + ) + ).timestamp() # Get all the reviews to check against reviews: ReviewSegment = ( @@ -306,13 +327,15 @@ class RecordingCleanup(threading.Thread): ReviewSegment.camera == camera, # need to ensure segments for all reviews starting # before the expire date are included - ReviewSegment.start_time < expire_date, + ReviewSegment.start_time < motion_expire_date, ) .order_by(ReviewSegment.start_time) .namedtuples() ) - self.expire_existing_camera_recordings(expire_date, config, reviews) + self.expire_existing_camera_recordings( + continuous_expire_date, motion_expire_date, config, reviews + ) logger.debug(f"End camera: {camera}.") logger.debug("End all cameras.") diff --git a/frigate/record/export.py b/frigate/record/export.py index 0d3f96da09..1d56baf157 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -21,6 +21,7 @@ from frigate.const import ( EXPORT_DIR, MAX_PLAYLIST_SECONDS, PREVIEW_FRAME_TYPE, + PROCESS_PRIORITY_LOW, ) from frigate.ffmpeg_presets import ( EncodeTypeEnum, @@ -36,7 +37,7 @@ TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey" def lower_priority(): - os.nice(10) + os.nice(PROCESS_PRIORITY_LOW) class PlaybackFactorEnum(str, Enum): diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index f1b9a600e3..20f1eb2899 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -16,7 +16,6 @@ from typing import Any, Optional, Tuple import numpy as np import psutil -from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.recordings_updater import ( @@ -24,6 +23,10 @@ from frigate.comms.recordings_updater import ( RecordingsDataTypeEnum, ) from frigate.config import FrigateConfig, RetainModeEnum +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import ( CACHE_DIR, CACHE_SEGMENT_FORMAT, @@ -71,8 +74,12 @@ class RecordingMaintainer(threading.Thread): # create communication for retained recordings self.requestor = InterProcessRequestor() - self.config_subscriber = ConfigSubscriber("config/record/") - self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all) + self.config_subscriber = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.record], + ) + self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all.value) self.recordings_publisher = RecordingsDataPublisher( RecordingsDataTypeEnum.recordings_available_through ) @@ -280,12 +287,16 @@ class RecordingMaintainer(threading.Thread): Path(cache_path).unlink(missing_ok=True) return - # if cached file's start_time is earlier than the retain days for the camera - # meaning continuous recording is not enabled - if start_time <= ( - datetime.datetime.now().astimezone(datetime.timezone.utc) - - datetime.timedelta(days=self.config.cameras[camera].record.retain.days) - ): + record_config = self.config.cameras[camera].record + highest = None + + if record_config.continuous.days > 0: + highest = "continuous" + elif record_config.motion.days > 0: + highest = "motion" + + # continuous / motion recording is not enabled + if highest is None: # if the cached segment overlaps with the review items: overlaps = False for review in reviews: @@ -339,8 +350,7 @@ class RecordingMaintainer(threading.Thread): ).astimezone(datetime.timezone.utc) if end_time < retain_cutoff: self.drop_segment(cache_path) - # else retain days includes this segment - # meaning continuous recording is enabled + # continuous / motion is enabled else: # assume that empty means the relevant recording info has not been received yet camera_info = self.object_recordings_info[camera] @@ -355,7 +365,11 @@ class RecordingMaintainer(threading.Thread): ).astimezone(datetime.timezone.utc) >= end_time ): - record_mode = self.config.cameras[camera].record.retain.mode + record_mode = ( + RetainModeEnum.all + if highest == "continuous" + else RetainModeEnum.motion + ) return await self.move_segment( camera, start_time, end_time, duration, cache_path, record_mode ) @@ -518,17 +532,7 @@ class RecordingMaintainer(threading.Thread): run_start = datetime.datetime.now().timestamp() # check if there is an updated config - while True: - ( - updated_topic, - updated_record_config, - ) = self.config_subscriber.check_for_update() - - if not updated_topic: - break - - camera_name = updated_topic.rpartition("/")[-1] - self.config.cameras[camera_name].record = updated_record_config + self.config_subscriber.check_for_updates() stale_frame_count = 0 stale_frame_count_threshold = 10 @@ -541,7 +545,7 @@ class RecordingMaintainer(threading.Thread): if not topic: break - if topic == DetectionTypeEnum.video: + if topic == DetectionTypeEnum.video.value: ( camera, _, @@ -560,7 +564,7 @@ class RecordingMaintainer(threading.Thread): regions, ) ) - elif topic == DetectionTypeEnum.audio: + elif topic == DetectionTypeEnum.audio.value: ( camera, frame_time, @@ -576,7 +580,9 @@ class RecordingMaintainer(threading.Thread): audio_detections, ) ) - elif topic == DetectionTypeEnum.api or DetectionTypeEnum.lpr: + elif ( + topic == DetectionTypeEnum.api.value or DetectionTypeEnum.lpr.value + ): continue if frame_time < run_start - stale_frame_count_threshold: diff --git a/frigate/record/record.py b/frigate/record/record.py index 252b80545f..624ed6e9a2 100644 --- a/frigate/record/record.py +++ b/frigate/record/record.py @@ -1,50 +1,47 @@ """Run recording maintainer and cleanup.""" import logging -import multiprocessing as mp -import signal -import threading -from types import FrameType -from typing import Optional +from multiprocessing.synchronize import Event as MpEvent from playhouse.sqliteq import SqliteQueueDatabase -from setproctitle import setproctitle from frigate.config import FrigateConfig +from frigate.const import PROCESS_PRIORITY_HIGH from frigate.models import Recordings, ReviewSegment from frigate.record.maintainer import RecordingMaintainer -from frigate.util.services import listen +from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) -def manage_recordings(config: FrigateConfig) -> None: - stop_event = mp.Event() +class RecordProcess(FrigateProcess): + def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None: + super().__init__( + stop_event, + PROCESS_PRIORITY_HIGH, + name="frigate.recording_manager", + daemon=True, + ) + self.config = config - def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: - stop_event.set() + def run(self) -> None: + self.pre_run_setup(self.config.logger) + db = SqliteQueueDatabase( + self.config.database.path, + pragmas={ + "auto_vacuum": "FULL", # Does not defragment database + "cache_size": -512 * 1000, # 512MB of cache + "synchronous": "NORMAL", # Safe when using WAL https://www.sqlite.org/pragma.html#pragma_synchronous + }, + timeout=max( + 60, 10 * len([c for c in self.config.cameras.values() if c.enabled]) + ), + ) + models = [ReviewSegment, Recordings] + db.bind(models) - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) - - threading.current_thread().name = "process:recording_manager" - setproctitle("frigate.recording_manager") - listen() - - db = SqliteQueueDatabase( - config.database.path, - pragmas={ - "auto_vacuum": "FULL", # Does not defragment database - "cache_size": -512 * 1000, # 512MB of cache - "synchronous": "NORMAL", # Safe when using WAL https://www.sqlite.org/pragma.html#pragma_synchronous - }, - timeout=max(60, 10 * len([c for c in config.cameras.values() if c.enabled])), - ) - models = [ReviewSegment, Recordings] - db.bind(models) - - maintainer = RecordingMaintainer( - config, - stop_event, - ) - maintainer.start() + maintainer = RecordingMaintainer( + self.config, + self.stop_event, + ) + maintainer.start() diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index b144b6e529..202b9b7012 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -1,6 +1,7 @@ """Maintain review segments in db.""" import copy +import datetime import json import logging import os @@ -15,10 +16,14 @@ from typing import Any, Optional import cv2 import numpy as np -from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.inter_process import InterProcessRequestor +from frigate.comms.review_updater import ReviewDataPublisher from frigate.config import CameraConfig, FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import ( CLEAR_ONGOING_REVIEW_SEGMENTS, CLIPS_DIR, @@ -35,9 +40,6 @@ logger = logging.getLogger(__name__) THUMB_HEIGHT = 180 THUMB_WIDTH = 320 -THRESHOLD_ALERT_ACTIVITY = 120 -THRESHOLD_DETECTION_ACTIVITY = 30 - class PendingReviewSegment: def __init__( @@ -59,7 +61,12 @@ class PendingReviewSegment: self.sub_labels = sub_labels self.zones = zones self.audio = audio - self.last_update = frame_time + self.thumb_time: float | None = None + self.last_alert_time: float | None = None + self.last_detection_time: float = frame_time + + if severity == SeverityEnum.alert: + self.last_alert_time = frame_time # thumbnail self._frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8) @@ -101,6 +108,7 @@ class PendingReviewSegment: ) if self._frame is not None: + self.thumb_time = datetime.datetime.now().timestamp() self.has_frame = True cv2.imwrite( self.frame_path, self._frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60] @@ -120,12 +128,20 @@ class PendingReviewSegment: ) def get_data(self, ended: bool) -> dict: + end_time = None + + if ended: + if self.severity == SeverityEnum.alert: + end_time = self.last_alert_time + else: + end_time = self.last_detection_time + return copy.deepcopy( { ReviewSegment.id.name: self.id, ReviewSegment.camera.name: self.camera, ReviewSegment.start_time.name: self.start_time, - ReviewSegment.end_time.name: self.last_update if ended else None, + ReviewSegment.end_time.name: end_time, ReviewSegment.severity.name: self.severity.value, ReviewSegment.thumb_path.name: self.frame_path, ReviewSegment.data.name: { @@ -134,11 +150,109 @@ class PendingReviewSegment: "sub_labels": list(self.sub_labels.values()), "zones": self.zones, "audio": list(self.audio), + "thumb_time": self.thumb_time, + "metadata": None, }, } ) +class ActiveObjects: + def __init__( + self, + frame_time: float, + camera_config: CameraConfig, + all_objects: list[TrackedObject], + ): + self.camera_config = camera_config + + # get current categorization of objects to know if + # these objects are currently being categorized + self.categorized_objects = { + "alerts": [], + "detections": [], + } + + for o in all_objects: + if ( + o["motionless_count"] >= camera_config.detect.stationary.threshold + and not o["pending_loitering"] + ): + # no stationary objects unless loitering + continue + + if o["position_changes"] == 0: + # object must have moved at least once + continue + + if o["frame_time"] != frame_time: + # object must be detected in this frame + continue + + if o["false_positive"]: + # object must not be a false positive + continue + + if ( + o["label"] in camera_config.review.alerts.labels + and ( + not camera_config.review.alerts.required_zones + or ( + len(o["current_zones"]) > 0 + and set(o["current_zones"]) + & set(camera_config.review.alerts.required_zones) + ) + ) + and camera_config.review.alerts.enabled + ): + self.categorized_objects["alerts"].append(o) + continue + + if ( + ( + camera_config.review.detections.labels is None + or o["label"] in camera_config.review.detections.labels + ) + and ( + not camera_config.review.detections.required_zones + or ( + len(o["current_zones"]) > 0 + and set(o["current_zones"]) + & set(camera_config.review.detections.required_zones) + ) + ) + and camera_config.review.detections.enabled + ): + self.categorized_objects["detections"].append(o) + continue + + def has_active_objects(self) -> bool: + return ( + len(self.categorized_objects["alerts"]) > 0 + or len(self.categorized_objects["detections"]) > 0 + ) + + def has_activity_category(self, severity: SeverityEnum) -> bool: + if ( + severity == SeverityEnum.alert + and len(self.categorized_objects["alerts"]) > 0 + ): + return True + + if ( + severity == SeverityEnum.detection + and len(self.categorized_objects["detections"]) > 0 + ): + return True + + return False + + def get_all_objects(self) -> list[TrackedObject]: + return ( + self.categorized_objects["alerts"] + self.categorized_objects["detections"] + ) + + class ReviewSegmentMaintainer(threading.Thread): """Maintain review segments.""" @@ -150,10 +264,19 @@ class ReviewSegmentMaintainer(threading.Thread): # create communication for review segments self.requestor = InterProcessRequestor() - self.record_config_subscriber = ConfigSubscriber("config/record/") - self.review_config_subscriber = ConfigSubscriber("config/review/") - self.enabled_config_subscriber = ConfigSubscriber("config/enabled/") - self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all) + self.config_subscriber = CameraConfigUpdateSubscriber( + config, + config.cameras, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.enabled, + CameraConfigUpdateEnum.record, + CameraConfigUpdateEnum.remove, + CameraConfigUpdateEnum.review, + ], + ) + self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all.value) + self.review_publisher = ReviewDataPublisher("") # manual events self.indefinite_events: dict[str, dict[str, Any]] = {} @@ -174,16 +297,16 @@ class ReviewSegmentMaintainer(threading.Thread): new_data = segment.get_data(ended=False) self.requestor.send_data(UPSERT_REVIEW_SEGMENT, new_data) start_data = {k: v for k, v in new_data.items()} + review_update = { + "type": "new", + "before": start_data, + "after": start_data, + } self.requestor.send_data( "reviews", - json.dumps( - { - "type": "new", - "before": start_data, - "after": start_data, - } - ), + json.dumps(review_update), ) + self.review_publisher.publish(review_update, segment.camera) self.requestor.send_data( f"{segment.camera}/review_status", segment.severity.value.upper() ) @@ -202,16 +325,16 @@ class ReviewSegmentMaintainer(threading.Thread): new_data = segment.get_data(ended=False) self.requestor.send_data(UPSERT_REVIEW_SEGMENT, new_data) + review_update = { + "type": "update", + "before": {k: v for k, v in prev_data.items()}, + "after": {k: v for k, v in new_data.items()}, + } self.requestor.send_data( "reviews", - json.dumps( - { - "type": "update", - "before": {k: v for k, v in prev_data.items()}, - "after": {k: v for k, v in new_data.items()}, - } - ), + json.dumps(review_update), ) + self.review_publisher.publish(review_update, segment.camera) self.requestor.send_data( f"{segment.camera}/review_status", segment.severity.value.upper() ) @@ -220,29 +343,31 @@ class ReviewSegmentMaintainer(threading.Thread): self, segment: PendingReviewSegment, prev_data: dict[str, Any], - ) -> None: + ) -> float: """End segment.""" final_data = segment.get_data(ended=True) + end_time = final_data[ReviewSegment.end_time.name] self.requestor.send_data(UPSERT_REVIEW_SEGMENT, final_data) + review_update = { + "type": "end", + "before": {k: v for k, v in prev_data.items()}, + "after": {k: v for k, v in final_data.items()}, + } self.requestor.send_data( "reviews", - json.dumps( - { - "type": "end", - "before": {k: v for k, v in prev_data.items()}, - "after": {k: v for k, v in final_data.items()}, - } - ), + json.dumps(review_update), ) + self.review_publisher.publish(review_update, segment.camera) self.requestor.send_data(f"{segment.camera}/review_status", "NONE") self.active_review_segments[segment.camera] = None + return end_time - def end_segment(self, camera: str) -> None: - """End the pending segment for a camera.""" + def forcibly_end_segment(self, camera: str) -> float: + """Forcibly end the pending segment for a camera.""" segment = self.active_review_segments.get(camera) if segment: prev_data = segment.get_data(False) - self._publish_segment_end(segment, prev_data) + return self._publish_segment_end(segment, prev_data) def update_existing_segment( self, @@ -255,21 +380,30 @@ class ReviewSegmentMaintainer(threading.Thread): camera_config = self.config.cameras[segment.camera] # get active objects + objects loitering in loitering zones - active_objects = get_active_objects( - frame_time, camera_config, objects - ) + get_loitering_objects(frame_time, camera_config, objects) + activity = ActiveObjects(frame_time, camera_config, objects) prev_data = segment.get_data(False) has_activity = False - if len(active_objects) > 0: + if activity.has_active_objects(): has_activity = True should_update_image = False should_update_state = False - if frame_time > segment.last_update: - segment.last_update = frame_time + if activity.has_activity_category(SeverityEnum.alert): + # update current time for last alert activity + segment.last_alert_time = frame_time - for object in active_objects: + if segment.severity != SeverityEnum.alert: + # if segment is not alert category but current activity is + # update this segment to be an alert + segment.severity = SeverityEnum.alert + should_update_state = True + should_update_image = True + + if activity.has_activity_category(SeverityEnum.detection): + segment.last_detection_time = frame_time + + for object in activity.get_all_objects(): if not object["sub_label"]: segment.detections[object["id"]] = object["label"] elif object["sub_label"][0] in self.config.model.all_attributes: @@ -278,33 +412,13 @@ class ReviewSegmentMaintainer(threading.Thread): segment.detections[object["id"]] = f"{object['label']}-verified" segment.sub_labels[object["id"]] = object["sub_label"][0] - # if object is alert label - # and has entered required zones or required zones is not set - # mark this review as alert - if ( - segment.severity != SeverityEnum.alert - and object["label"] in camera_config.review.alerts.labels - and ( - not camera_config.review.alerts.required_zones - or ( - len(object["current_zones"]) > 0 - and set(object["current_zones"]) - & set(camera_config.review.alerts.required_zones) - ) - ) - and camera_config.review.alerts.enabled - ): - segment.severity = SeverityEnum.alert - should_update_state = True - should_update_image = True - # keep zones up to date if len(object["current_zones"]) > 0: for zone in object["current_zones"]: if zone not in segment.zones: segment.zones.append(zone) - if len(active_objects) > segment.frame_active_count: + if len(activity.get_all_objects()) > segment.frame_active_count: should_update_state = True should_update_image = True @@ -325,7 +439,11 @@ class ReviewSegmentMaintainer(threading.Thread): yuv_frame = None self._publish_segment_update( - segment, camera_config, yuv_frame, active_objects, prev_data + segment, + camera_config, + yuv_frame, + activity.get_all_objects(), + prev_data, ) self.frame_manager.close(frame_name) except FileNotFoundError: @@ -351,10 +469,50 @@ class ReviewSegmentMaintainer(threading.Thread): return if segment.severity == SeverityEnum.alert and frame_time > ( - segment.last_update + THRESHOLD_ALERT_ACTIVITY + segment.last_alert_time + camera_config.review.alerts.cutoff_time + ): + needs_new_detection = ( + segment.last_detection_time > segment.last_alert_time + and ( + segment.last_detection_time + + camera_config.review.detections.cutoff_time + ) + > frame_time + ) + last_detection_time = segment.last_detection_time + + end_time = self._publish_segment_end(segment, prev_data) + + if needs_new_detection: + new_detections: dict[str, str] = {} + new_zones = set() + + for o in activity.categorized_objects["detections"]: + new_detections[o["id"]] = o["label"] + new_zones.update(o["current_zones"]) + + if new_detections: + self.active_review_segments[activity.camera_config.name] = ( + PendingReviewSegment( + activity.camera_config.name, + end_time, + SeverityEnum.detection, + new_detections, + sub_labels={}, + audio=set(), + zones=list(new_zones), + ) + ) + self._publish_segment_start( + self.active_review_segments[activity.camera_config.name] + ) + self.active_review_segments[ + activity.camera_config.name + ].last_detection_time = last_detection_time + elif segment.severity == SeverityEnum.detection and frame_time > ( + segment.last_detection_time + + camera_config.review.detections.cutoff_time ): - self._publish_segment_end(segment, prev_data) - elif frame_time > (segment.last_update + THRESHOLD_DETECTION_ACTIVITY): self._publish_segment_end(segment, prev_data) def check_if_new_segment( @@ -366,15 +524,26 @@ class ReviewSegmentMaintainer(threading.Thread): ) -> None: """Check if a new review segment should be created.""" camera_config = self.config.cameras[camera] - active_objects = get_active_objects(frame_time, camera_config, objects) + activity = ActiveObjects(frame_time, camera_config, objects) - if len(active_objects) > 0: + if activity.has_active_objects(): detections: dict[str, str] = {} sub_labels: dict[str, str] = {} zones: list[str] = [] - severity = None + severity: SeverityEnum | None = None - for object in active_objects: + # if activity is alert category mark this review as alert + if severity != SeverityEnum.alert and activity.has_activity_category( + SeverityEnum.alert + ): + severity = SeverityEnum.alert + + # if object is detection label and not already higher severity + # mark this review as detection + if not severity and activity.has_activity_category(SeverityEnum.detection): + severity = SeverityEnum.detection + + for object in activity.get_all_objects(): if not object["sub_label"]: detections[object["id"]] = object["label"] elif object["sub_label"][0] in self.config.model.all_attributes: @@ -383,46 +552,6 @@ class ReviewSegmentMaintainer(threading.Thread): detections[object["id"]] = f"{object['label']}-verified" sub_labels[object["id"]] = object["sub_label"][0] - # if object is alert label - # and has entered required zones or required zones is not set - # mark this review as alert - if ( - severity != SeverityEnum.alert - and object["label"] in camera_config.review.alerts.labels - and ( - not camera_config.review.alerts.required_zones - or ( - len(object["current_zones"]) > 0 - and set(object["current_zones"]) - & set(camera_config.review.alerts.required_zones) - ) - ) - and camera_config.review.alerts.enabled - ): - severity = SeverityEnum.alert - - # if object is detection label - # and review is not already a detection or alert - # and has entered required zones or required zones is not set - # mark this review as detection - if ( - not severity - and ( - camera_config.review.detections.labels is None - or object["label"] in (camera_config.review.detections.labels) - ) - and ( - not camera_config.review.detections.required_zones - or ( - len(object["current_zones"]) > 0 - and set(object["current_zones"]) - & set(camera_config.review.detections.required_zones) - ) - ) - and camera_config.review.detections.enabled - ): - severity = SeverityEnum.detection - for zone in object["current_zones"]: if zone not in zones: zones.append(zone) @@ -448,7 +577,7 @@ class ReviewSegmentMaintainer(threading.Thread): return self.active_review_segments[camera].update_frame( - camera_config, yuv_frame, active_objects + camera_config, yuv_frame, activity.get_all_objects() ) self.frame_manager.close(frame_name) self._publish_segment_start(self.active_review_segments[camera]) @@ -458,57 +587,22 @@ class ReviewSegmentMaintainer(threading.Thread): def run(self) -> None: while not self.stop_event.is_set(): # check if there is an updated config - while True: - ( - updated_record_topic, - updated_record_config, - ) = self.record_config_subscriber.check_for_update() + updated_topics = self.config_subscriber.check_for_updates() - ( - updated_review_topic, - updated_review_config, - ) = self.review_config_subscriber.check_for_update() + if "record" in updated_topics: + for camera in updated_topics["record"]: + self.forcibly_end_segment(camera) - ( - updated_enabled_topic, - updated_enabled_config, - ) = self.enabled_config_subscriber.check_for_update() - - if ( - not updated_record_topic - and not updated_review_topic - and not updated_enabled_topic - ): - break - - if updated_record_topic: - camera_name = updated_record_topic.rpartition("/")[-1] - self.config.cameras[camera_name].record = updated_record_config - - # immediately end segment - if not updated_record_config.enabled: - self.end_segment(camera_name) - - if updated_review_topic: - camera_name = updated_review_topic.rpartition("/")[-1] - self.config.cameras[camera_name].review = updated_review_config - - if updated_enabled_config: - camera_name = updated_enabled_topic.rpartition("/")[-1] - self.config.cameras[ - camera_name - ].enabled = updated_enabled_config.enabled - - # immediately end segment as we may not get another update - if not updated_enabled_config.enabled: - self.end_segment(camera_name) + if "enabled" in updated_topics: + for camera in updated_topics["enabled"]: + self.forcibly_end_segment(camera) (topic, data) = self.detection_subscriber.check_for_update(timeout=1) if not topic: continue - if topic == DetectionTypeEnum.video: + if topic == DetectionTypeEnum.video.value: ( camera, frame_name, @@ -517,14 +611,14 @@ class ReviewSegmentMaintainer(threading.Thread): _, _, ) = data - elif topic == DetectionTypeEnum.audio: + elif topic == DetectionTypeEnum.audio.value: ( camera, frame_time, _, audio_detections, ) = data - elif topic == DetectionTypeEnum.api or DetectionTypeEnum.lpr: + elif topic == DetectionTypeEnum.api.value or DetectionTypeEnum.lpr.value: ( camera, frame_time, @@ -551,7 +645,7 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.severity == SeverityEnum.detection and not self.config.cameras[camera].review.detections.enabled ): - self.end_segment(camera) + self.forcibly_end_segment(camera) continue # If we reach here, the segment can be processed (if it exists) @@ -566,9 +660,6 @@ class ReviewSegmentMaintainer(threading.Thread): elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: camera_config = self.config.cameras[camera] - if frame_time > current_segment.last_update: - current_segment.last_update = frame_time - for audio in audio_detections: if ( audio in camera_config.review.alerts.labels @@ -576,11 +667,13 @@ class ReviewSegmentMaintainer(threading.Thread): ): current_segment.audio.add(audio) current_segment.severity = SeverityEnum.alert + current_segment.last_alert_time = frame_time elif ( camera_config.review.detections.labels is None or audio in camera_config.review.detections.labels ) and camera_config.review.detections.enabled: current_segment.audio.add(audio) + current_segment.last_detection_time = frame_time elif topic == DetectionTypeEnum.api or topic == DetectionTypeEnum.lpr: if manual_info["state"] == ManualEventState.complete: current_segment.detections[manual_info["event_id"]] = ( @@ -596,7 +689,7 @@ class ReviewSegmentMaintainer(threading.Thread): and self.config.cameras[camera].review.detections.enabled ): current_segment.severity = SeverityEnum.detection - current_segment.last_update = manual_info["end_time"] + current_segment.last_alert_time = manual_info["end_time"] elif manual_info["state"] == ManualEventState.start: self.indefinite_events[camera][manual_info["event_id"]] = ( manual_info["label"] @@ -616,7 +709,8 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.severity = SeverityEnum.detection # temporarily make it so this event can not end - current_segment.last_update = sys.maxsize + current_segment.last_alert_time = sys.maxsize + current_segment.last_detection_time = sys.maxsize elif manual_info["state"] == ManualEventState.end: event_id = manual_info["event_id"] @@ -624,7 +718,12 @@ class ReviewSegmentMaintainer(threading.Thread): self.indefinite_events[camera].pop(event_id) if len(self.indefinite_events[camera]) == 0: - current_segment.last_update = manual_info["end_time"] + current_segment.last_alert_time = manual_info[ + "end_time" + ] + current_segment.last_detection_time = manual_info[ + "end_time" + ] else: logger.error( f"Event with ID {event_id} has a set duration and can not be ended manually." @@ -692,11 +791,17 @@ class ReviewSegmentMaintainer(threading.Thread): # temporarily make it so this event can not end self.active_review_segments[ camera - ].last_update = sys.maxsize + ].last_alert_time = sys.maxsize + self.active_review_segments[ + camera + ].last_detection_time = sys.maxsize elif manual_info["state"] == ManualEventState.complete: self.active_review_segments[ camera - ].last_update = manual_info["end_time"] + ].last_alert_time = manual_info["end_time"] + self.active_review_segments[ + camera + ].last_detection_time = manual_info["end_time"] else: logger.warning( f"Manual event API has been called for {camera}, but alerts are disabled. This manual event will not appear as an alert." @@ -720,61 +825,23 @@ class ReviewSegmentMaintainer(threading.Thread): # temporarily make it so this event can not end self.active_review_segments[ camera - ].last_update = sys.maxsize + ].last_alert_time = sys.maxsize + self.active_review_segments[ + camera + ].last_detection_time = sys.maxsize elif manual_info["state"] == ManualEventState.complete: self.active_review_segments[ camera - ].last_update = manual_info["end_time"] + ].last_alert_time = manual_info["end_time"] + self.active_review_segments[ + camera + ].last_detection_time = manual_info["end_time"] else: logger.warning( f"Dedicated LPR camera API has been called for {camera}, but detections are disabled. LPR events will not appear as a detection." ) - self.record_config_subscriber.stop() - self.review_config_subscriber.stop() + self.config_subscriber.stop() self.requestor.stop() self.detection_subscriber.stop() logger.info("Exiting review maintainer...") - - -def get_active_objects( - frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject] -) -> list[TrackedObject]: - """get active objects for detection.""" - return [ - o - for o in all_objects - if o["motionless_count"] - < camera_config.detect.stationary.threshold # no stationary objects - and o["position_changes"] > 0 # object must have moved at least once - and o["frame_time"] == frame_time # object must be detected in this frame - and not o["false_positive"] # object must not be a false positive - and ( - o["label"] in camera_config.review.alerts.labels - or ( - camera_config.review.detections.labels is None - or o["label"] in camera_config.review.detections.labels - ) - ) # object must be in the alerts or detections label list - ] - - -def get_loitering_objects( - frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject] -) -> list[TrackedObject]: - """get loitering objects for detection.""" - return [ - o - for o in all_objects - if o["pending_loitering"] # object must be pending loitering - and o["position_changes"] > 0 # object must have moved at least once - and o["frame_time"] == frame_time # object must be detected in this frame - and not o["false_positive"] # object must not be a false positive - and ( - o["label"] in camera_config.review.alerts.labels - or ( - camera_config.review.detections.labels is None - or o["label"] in camera_config.review.detections.labels - ) - ) # object must be in the alerts or detections label list - ] diff --git a/frigate/review/review.py b/frigate/review/review.py index dafa6c802e..c00c302a20 100644 --- a/frigate/review/review.py +++ b/frigate/review/review.py @@ -1,36 +1,30 @@ """Run recording maintainer and cleanup.""" import logging -import multiprocessing as mp -import signal -import threading -from types import FrameType -from typing import Optional - -from setproctitle import setproctitle +from multiprocessing.synchronize import Event as MpEvent from frigate.config import FrigateConfig +from frigate.const import PROCESS_PRIORITY_MED from frigate.review.maintainer import ReviewSegmentMaintainer -from frigate.util.services import listen +from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) -def manage_review_segments(config: FrigateConfig) -> None: - stop_event = mp.Event() +class ReviewProcess(FrigateProcess): + def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None: + super().__init__( + stop_event, + PROCESS_PRIORITY_MED, + name="frigate.review_segment_manager", + daemon=True, + ) + self.config = config - def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: - stop_event.set() - - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) - - threading.current_thread().name = "process:review_segment_manager" - setproctitle("frigate.review_segment_manager") - listen() - - maintainer = ReviewSegmentMaintainer( - config, - stop_event, - ) - maintainer.start() + def run(self) -> None: + self.pre_run_setup(self.config.logger) + maintainer = ReviewSegmentMaintainer( + self.config, + self.stop_event, + ) + maintainer.start() diff --git a/frigate/stats/prometheus.py b/frigate/stats/prometheus.py index bc545f21db..67d8d03d83 100644 --- a/frigate/stats/prometheus.py +++ b/frigate/stats/prometheus.py @@ -1,5 +1,6 @@ import logging import re +from typing import Any, Dict, List from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from prometheus_client.core import ( @@ -450,51 +451,17 @@ class CustomCollector(object): yield storage_total yield storage_used - # count events - events = [] - - if len(events) > 0: - # events[0] is newest event, last element is oldest, don't need to sort - - if not self.previous_event_id: - # ignore all previous events on startup, prometheus might have already counted them - self.previous_event_id = events[0]["id"] - self.previous_event_start_time = int(events[0]["start_time"]) - - for event in events: - # break if event already counted - if event["id"] == self.previous_event_id: - break - - # break if event starts before previous event - if event["start_time"] < self.previous_event_start_time: - break - - # store counted events in a dict - try: - cam = self.all_events[event["camera"]] - try: - cam[event["label"]] += 1 - except KeyError: - # create label dict if not exists - cam.update({event["label"]: 1}) - except KeyError: - # create camera and label dict if not exists - self.all_events.update({event["camera"]: {event["label"]: 1}}) - - # don't recount events next time - self.previous_event_id = events[0]["id"] - self.previous_event_start_time = int(events[0]["start_time"]) - camera_events = CounterMetricFamily( "frigate_camera_events", "Count of camera events since exporter started", labels=["camera", "label"], ) - for camera, cam_dict in self.all_events.items(): - for label, label_value in cam_dict.items(): - camera_events.add_metric([camera, label], label_value) + if len(self.all_events) > 0: + for event_count in self.all_events: + camera_events.add_metric( + [event_count["camera"], event_count["label"]], event_count["Count"] + ) yield camera_events @@ -503,7 +470,7 @@ collector = CustomCollector(None) REGISTRY.register(collector) -def update_metrics(stats): +def update_metrics(stats: Dict[str, Any], event_counts: List[Dict[str, Any]]): """Updates the Prometheus metrics with the given stats data.""" try: # Store the complete stats for later use by collect() @@ -512,6 +479,8 @@ def update_metrics(stats): # For backwards compatibility collector.process_stats = stats.copy() + collector.all_events = event_counts + # No need to call collect() here - it will be called by get_metrics() except Exception as e: logging.error(f"Error updating metrics: {e}") diff --git a/frigate/stats/util.py b/frigate/stats/util.py index e098bc541a..6d20f8f9ae 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -5,22 +5,23 @@ import os import shutil import time from json import JSONDecodeError +from multiprocessing.managers import DictProxy from typing import Any, Optional -import psutil import requests from requests.exceptions import RequestException -from frigate.camera import CameraMetrics from frigate.config import FrigateConfig from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR from frigate.data_processing.types import DataProcessorMetrics from frigate.object_detection.base import ObjectDetectProcess from frigate.types import StatsTrackingTypes from frigate.util.services import ( + calculate_shm_requirements, get_amd_gpu_stats, get_bandwidth_stats, get_cpu_stats, + get_fs_type, get_intel_gpu_stats, get_jetson_stats, get_nvidia_gpu_stats, @@ -53,7 +54,7 @@ def get_latest_version(config: FrigateConfig) -> str: def stats_init( config: FrigateConfig, - camera_metrics: dict[str, CameraMetrics], + camera_metrics: DictProxy, embeddings_metrics: DataProcessorMetrics | None, detectors: dict[str, ObjectDetectProcess], processes: dict[str, int], @@ -70,16 +71,6 @@ def stats_init( return stats_tracking -def get_fs_type(path: str) -> str: - bestMatch = "" - fsType = "" - for part in psutil.disk_partitions(all=True): - if path.startswith(part.mountpoint) and len(bestMatch) < len(part.mountpoint): - fsType = part.fstype - bestMatch = part.mountpoint - return fsType - - def read_temperature(path: str) -> Optional[float]: if os.path.isfile(path): with open(path) as f: @@ -268,15 +259,20 @@ def stats_snapshot( camera_metrics = stats_tracking["camera_metrics"] stats: dict[str, Any] = {} - total_detection_fps = 0 + total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0 stats["cameras"] = {} for name, camera_stats in camera_metrics.items(): + total_camera_fps += camera_stats.camera_fps.value + total_process_fps += camera_stats.process_fps.value + total_skipped_fps += camera_stats.skipped_fps.value total_detection_fps += camera_stats.detection_fps.value - pid = camera_stats.process.pid if camera_stats.process else None + pid = camera_stats.process_pid.value if camera_stats.process_pid.value else None ffmpeg_pid = camera_stats.ffmpeg_pid.value if camera_stats.ffmpeg_pid else None capture_pid = ( - camera_stats.capture_process.pid if camera_stats.capture_process else None + camera_stats.capture_process_pid.value + if camera_stats.capture_process_pid.value + else None ) stats["cameras"][name] = { "camera_fps": round(camera_stats.camera_fps.value, 2), @@ -303,6 +299,9 @@ def stats_snapshot( # from mypy 0.981 onwards "pid": pid, } + stats["camera_fps"] = round(total_camera_fps, 2) + stats["process_fps"] = round(total_process_fps, 2) + stats["skipped_fps"] = round(total_skipped_fps, 2) stats["detection_fps"] = round(total_detection_fps, 2) stats["embeddings"] = {} @@ -354,6 +353,22 @@ def stats_snapshot( embeddings_metrics.yolov9_lpr_pps.value, 2 ) + if embeddings_metrics.review_desc_speed.value > 0.0: + stats["embeddings"]["review_description_speed"] = round( + embeddings_metrics.review_desc_speed.value * 1000, 2 + ) + stats["embeddings"]["review_descriptions"] = round( + embeddings_metrics.review_desc_dps.value, 2 + ) + + for key in embeddings_metrics.classification_speeds.keys(): + stats["embeddings"][f"{key}_classification_speed"] = round( + embeddings_metrics.classification_speeds[key].value * 1000, 2 + ) + stats["embeddings"][f"{key}_classification"] = round( + embeddings_metrics.classification_cps[key].value, 2 + ) + get_processing_stats(config, stats, hwaccel_errors) stats["service"] = { @@ -365,7 +380,7 @@ def stats_snapshot( "last_updated": int(time.time()), } - for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]: + for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR]: try: storage_stats = shutil.disk_usage(path) except (FileNotFoundError, OSError): @@ -379,6 +394,8 @@ def stats_snapshot( "mount_type": get_fs_type(path), } + stats["service"]["storage"]["/dev/shm"] = calculate_shm_requirements(config) + stats["processes"] = {} for name, pid in stats_tracking["processes"].items(): stats["processes"][name] = { diff --git a/frigate/storage.py b/frigate/storage.py index 1c4650271c..611412e1ed 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -77,7 +77,10 @@ class StorageMaintainer(threading.Thread): .scalar() ) - usages[camera] = { + camera_key = ( + getattr(self.config.cameras[camera], "friendly_name", None) or camera + ) + usages[camera_key] = { "usage": camera_storage, "bandwidth": self.camera_storage_stats.get(camera, {}).get( "bandwidth", 0 diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 3c4a7ccdcf..99c44d1c0f 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -45,6 +45,9 @@ class BaseTestHttp(unittest.TestCase): }, } self.test_stats = { + "camera_fps": 5.0, + "process_fps": 5.0, + "skipped_fps": 0.0, "detection_fps": 13.7, "detectors": { "cpu1": { @@ -109,7 +112,7 @@ class BaseTestHttp(unittest.TestCase): except OSError: pass - def create_app(self, stats=None): + def create_app(self, stats=None, event_metadata_publisher=None): return create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, @@ -118,6 +121,7 @@ class BaseTestHttp(unittest.TestCase): None, None, stats, + event_metadata_publisher, None, ) @@ -130,12 +134,13 @@ class BaseTestHttp(unittest.TestCase): top_score: int = 100, score: int = 0, data: Json = {}, + camera: str = "front_door", ) -> Event: """Inserts a basic event model with a given id.""" return Event.insert( id=id, label="Mock", - camera="front_door", + camera=camera, start_time=start_time, end_time=end_time, top_score=top_score, @@ -154,15 +159,23 @@ class BaseTestHttp(unittest.TestCase): def insert_mock_review_segment( self, id: str, - start_time: float = datetime.datetime.now().timestamp(), - end_time: float = datetime.datetime.now().timestamp() + 20, + start_time: float | None = None, + end_time: float | None = None, severity: SeverityEnum = SeverityEnum.alert, - data: Json = {}, + data: dict | None = None, + camera: str = "front_door", ) -> ReviewSegment: """Inserts a review segment model with a given id.""" + if start_time is None: + start_time = datetime.datetime.now().timestamp() + if end_time is None: + end_time = start_time + 20 + if data is None: + data = {} + return ReviewSegment.insert( id=id, - camera="front_door", + camera=camera, start_time=start_time, end_time=end_time, severity=severity, diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py new file mode 100644 index 0000000000..db5446bff9 --- /dev/null +++ b/frigate/test/http_api/test_http_camera_access.py @@ -0,0 +1,169 @@ +from unittest.mock import patch + +from fastapi import HTTPException, Request +from fastapi.testclient import TestClient + +from frigate.api.auth import ( + get_allowed_cameras_for_filter, + get_current_user, +) +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import BaseTestHttp + + +class TestCameraAccessEventReview(BaseTestHttp): + def setUp(self): + super().setUp([Event, ReviewSegment, Recordings]) + self.app = super().create_app() + + # Mock get_current_user to return valid user for all tests + async def mock_get_current_user(): + return {"username": "test_user", "role": "user"} + + self.app.dependency_overrides[get_current_user] = mock_get_current_user + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + + def test_event_camera_access(self): + super().insert_mock_event("event1", camera="front_door") + super().insert_mock_event("event2", camera="back_door") + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + with TestClient(self.app) as client: + resp = client.get("/events") + assert resp.status_code == 200 + ids = [e["id"] for e in resp.json()] + assert "event1" in ids + assert "event2" not in ids + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door", + "back_door", + ] + with TestClient(self.app) as client: + resp = client.get("/events") + assert resp.status_code == 200 + ids = [e["id"] for e in resp.json()] + assert "event1" in ids and "event2" in ids + + def test_review_camera_access(self): + super().insert_mock_review_segment("rev1", camera="front_door") + super().insert_mock_review_segment("rev2", camera="back_door") + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + with TestClient(self.app) as client: + resp = client.get("/review") + assert resp.status_code == 200 + ids = [r["id"] for r in resp.json()] + assert "rev1" in ids + assert "rev2" not in ids + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door", + "back_door", + ] + with TestClient(self.app) as client: + resp = client.get("/review") + assert resp.status_code == 200 + ids = [r["id"] for r in resp.json()] + assert "rev1" in ids and "rev2" in ids + + def test_event_single_access(self): + super().insert_mock_event("event1", camera="front_door") + + # Allowed + async def mock_require_allowed(camera: str, request: Request = None): + if camera == "front_door": + return + raise HTTPException(status_code=403, detail="Access denied") + + with patch("frigate.api.event.require_camera_access", mock_require_allowed): + with TestClient(self.app) as client: + resp = client.get("/events/event1") + assert resp.status_code == 200 + assert resp.json()["id"] == "event1" + + # Disallowed + async def mock_require_disallowed(camera: str, request: Request = None): + raise HTTPException(status_code=403, detail="Access denied") + + with patch("frigate.api.event.require_camera_access", mock_require_disallowed): + with TestClient(self.app) as client: + resp = client.get("/events/event1") + assert resp.status_code == 403 + + def test_review_single_access(self): + super().insert_mock_review_segment("rev1", camera="front_door") + + # Allowed + async def mock_require_allowed(camera: str, request: Request = None): + if camera == "front_door": + return + raise HTTPException(status_code=403, detail="Access denied") + + with patch("frigate.api.review.require_camera_access", mock_require_allowed): + with TestClient(self.app) as client: + resp = client.get("/review/rev1") + assert resp.status_code == 200 + assert resp.json()["id"] == "rev1" + + # Disallowed + async def mock_require_disallowed(camera: str, request: Request = None): + raise HTTPException(status_code=403, detail="Access denied") + + with patch("frigate.api.review.require_camera_access", mock_require_disallowed): + with TestClient(self.app) as client: + resp = client.get("/review/rev1") + assert resp.status_code == 403 + + def test_event_search_access(self): + super().insert_mock_event("event1", camera="front_door") + super().insert_mock_event("event2", camera="back_door") + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + with TestClient(self.app) as client: + resp = client.get("/events", params={"cameras": "all"}) + assert resp.status_code == 200 + ids = [e["id"] for e in resp.json()] + assert "event1" in ids + assert "event2" not in ids + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door", + "back_door", + ] + with TestClient(self.app) as client: + resp = client.get("/events", params={"cameras": "all"}) + assert resp.status_code == 200 + ids = [e["id"] for e in resp.json()] + assert "event1" in ids and "event2" in ids + + def test_event_summary_access(self): + super().insert_mock_event("event1", camera="front_door") + super().insert_mock_event("event2", camera="back_door") + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + with TestClient(self.app) as client: + resp = client.get("/events/summary") + assert resp.status_code == 200 + summary_list = resp.json() + assert len(summary_list) == 1 + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door", + "back_door", + ] + with TestClient(self.app) as client: + resp = client.get("/events/summary") + summary_list = resp.json() + assert len(summary_list) == 2 diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index e3f41fdc3c..2ef00aa055 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -1,16 +1,36 @@ from datetime import datetime +from typing import Any +from unittest.mock import Mock from fastapi.testclient import TestClient +from playhouse.shortcuts import model_to_dict -from frigate.models import Event, Recordings, ReviewSegment +from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user +from frigate.comms.event_metadata_updater import EventMetadataPublisher +from frigate.models import Event, Recordings, ReviewSegment, Timeline +from frigate.stats.emitter import StatsEmitter from frigate.test.http_api.base_http_test import BaseTestHttp +from frigate.test.test_storage import _insert_mock_event class TestHttpApp(BaseTestHttp): def setUp(self): - super().setUp([Event, Recordings, ReviewSegment]) + super().setUp([Event, Recordings, ReviewSegment, Timeline]) self.app = super().create_app() + # Mock auth to bypass camera access for tests + async def mock_get_current_user(request: Any): + return {"username": "test_user", "role": "admin"} + + self.app.dependency_overrides[get_current_user] = mock_get_current_user + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + #################################################################################################################### ################################### GET /events Endpoint ######################################################### #################################################################################################################### @@ -135,3 +155,248 @@ class TestHttpApp(BaseTestHttp): assert len(events) == 2 assert events[0]["id"] == id assert events[1]["id"] == id2 + + def test_get_good_event(self): + id = "123456.random" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + event = client.get(f"/events/{id}").json() + + assert event + assert event["id"] == id + assert event["id"] == model_to_dict(Event.get(Event.id == id))["id"] + + def test_get_bad_event(self): + id = "123456.random" + bad_id = "654321.other" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + event_response = client.get(f"/events/{bad_id}") + assert event_response.status_code == 404 + assert event_response.json() == "Event not found" + + def test_delete_event(self): + id = "123456.random" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + response = client.delete(f"/events/{id}", headers={"remote-role": "admin"}) + assert response.status_code == 200 + event_after_delete = client.get(f"/events/{id}") + assert event_after_delete.status_code == 404 + + def test_event_retention(self): + id = "123456.random" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + client.post(f"/events/{id}/retain", headers={"remote-role": "admin"}) + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + assert event["retain_indefinitely"] is True + client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"}) + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + assert event["retain_indefinitely"] is False + + def test_event_time_filtering(self): + morning_id = "123456.random" + evening_id = "654321.random" + morning = 1656590400 # 06/30/2022 6 am (GMT) + evening = 1656633600 # 06/30/2022 6 pm (GMT) + + with TestClient(self.app) as client: + super().insert_mock_event(morning_id, morning) + super().insert_mock_event(evening_id, evening) + # both events come back + events = client.get("/events").json() + print("events!!!", events) + assert events + assert len(events) == 2 + # morning event is excluded + events = client.get( + "/events", + params={"time_range": "07:00,24:00"}, + ).json() + assert events + assert len(events) == 1 + # evening event is excluded + events = client.get( + "/events", + params={"time_range": "00:00,18:00"}, + ).json() + assert events + assert len(events) == 1 + + def test_set_delete_sub_label(self): + mock_event_updater = Mock(spec=EventMetadataPublisher) + app = super().create_app(event_metadata_publisher=mock_event_updater) + id = "123456.random" + sub_label = "sub" + + def update_event(payload: Any, topic: str): + event = Event.get(id=id) + event.sub_label = payload[1] + event.save() + + mock_event_updater.publish.side_effect = update_event + + with TestClient(app) as client: + super().insert_mock_event(id) + new_sub_label_response = client.post( + f"/events/{id}/sub_label", + json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, + ) + assert new_sub_label_response.status_code == 200 + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + assert event["sub_label"] == sub_label + empty_sub_label_response = client.post( + f"/events/{id}/sub_label", + json={"subLabel": ""}, + headers={"remote-role": "admin"}, + ) + assert empty_sub_label_response.status_code == 200 + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + assert event["sub_label"] == None + + def test_sub_label_list(self): + mock_event_updater = Mock(spec=EventMetadataPublisher) + app = super().create_app(event_metadata_publisher=mock_event_updater) + app.event_metadata_publisher = mock_event_updater + id = "123456.random" + sub_label = "sub" + + def update_event(payload: Any, _: str): + event = Event.get(id=id) + event.sub_label = payload[1] + event.save() + + mock_event_updater.publish.side_effect = update_event + + with TestClient(app) as client: + super().insert_mock_event(id) + client.post( + f"/events/{id}/sub_label", + json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, + ) + sub_labels = client.get("/sub_labels").json() + assert sub_labels + assert sub_labels == [sub_label] + + #################################################################################################################### + ################################### GET /metrics Endpoint ######################################################### + #################################################################################################################### + def test_get_metrics(self): + """ensure correct prometheus metrics api response""" + with TestClient(self.app) as client: + ts_start = datetime.now().timestamp() + ts_end = ts_start + 30 + _insert_mock_event( + id="abcde.random", start=ts_start, end=ts_end, retain=True + ) + _insert_mock_event( + id="01234.random", start=ts_start, end=ts_end, retain=True + ) + _insert_mock_event( + id="56789.random", start=ts_start, end=ts_end, retain=True + ) + _insert_mock_event( + id="101112.random", + label="outside", + start=ts_start, + end=ts_end, + retain=True, + ) + _insert_mock_event( + id="131415.random", + label="outside", + start=ts_start, + end=ts_end, + retain=True, + ) + _insert_mock_event( + id="161718.random", + camera="porch", + start=ts_start, + end=ts_end, + retain=True, + ) + _insert_mock_event( + id="192021.random", + camera="porch", + start=ts_start, + end=ts_end, + retain=True, + ) + _insert_mock_event( + id="222324.random", + camera="porch", + label="inside", + start=ts_start, + end=ts_end, + retain=True, + ) + _insert_mock_event( + id="252627.random", + camera="porch", + label="inside", + start=ts_start, + end=ts_end, + retain=True, + ) + _insert_mock_event( + id="282930.random", + label="inside", + start=ts_start, + end=ts_end, + retain=True, + ) + _insert_mock_event( + id="313233.random", + label="inside", + start=ts_start, + end=ts_end, + retain=True, + ) + + stats_emitter = Mock(spec=StatsEmitter) + stats_emitter.get_latest_stats.return_value = self.test_stats + self.app.stats_emitter = stats_emitter + event = client.get("/metrics") + + assert "# TYPE frigate_detection_total_fps gauge" in event.text + assert "frigate_detection_total_fps 13.7" in event.text + assert ( + "# HELP frigate_camera_events_total Count of camera events since exporter started" + in event.text + ) + assert "# TYPE frigate_camera_events_total counter" in event.text + assert ( + 'frigate_camera_events_total{camera="front_door",label="Mock"} 3.0' + in event.text + ) + assert ( + 'frigate_camera_events_total{camera="front_door",label="inside"} 2.0' + in event.text + ) + assert ( + 'frigate_camera_events_total{camera="front_door",label="outside"} 2.0' + in event.text + ) + assert ( + 'frigate_camera_events_total{camera="porch",label="Mock"} 2.0' in event.text + ) + assert 'frigate_camera_events_total{camera="porch",label="inside"} 2.0' diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index 469e012b20..731438e042 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from fastapi.testclient import TestClient from peewee import DoesNotExist -from frigate.api.auth import get_current_user +from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus from frigate.review.types import SeverityEnum from frigate.test.http_api.base_http_test import BaseTestHttp @@ -21,6 +21,10 @@ class TestHttpReview(BaseTestHttp): self.app.dependency_overrides[get_current_user] = mock_get_current_user + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + def tearDown(self): self.app.dependency_overrides.clear() super().tearDown() diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py deleted file mode 100644 index 4d949c5436..0000000000 --- a/frigate/test/test_http.py +++ /dev/null @@ -1,393 +0,0 @@ -import datetime -import logging -import os -import unittest -from unittest.mock import Mock - -from fastapi.testclient import TestClient -from peewee_migrate import Router -from playhouse.shortcuts import model_to_dict -from playhouse.sqlite_ext import SqliteExtDatabase -from playhouse.sqliteq import SqliteQueueDatabase - -from frigate.api.fastapi_app import create_fastapi_app -from frigate.comms.event_metadata_updater import EventMetadataPublisher -from frigate.config import FrigateConfig -from frigate.const import BASE_DIR, CACHE_DIR -from frigate.models import Event, Recordings, Timeline -from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS - - -class TestHttp(unittest.TestCase): - def setUp(self): - # setup clean database for each test run - migrate_db = SqliteExtDatabase("test.db") - del logging.getLogger("peewee_migrate").handlers[:] - router = Router(migrate_db) - router.run() - migrate_db.close() - self.db = SqliteQueueDatabase(TEST_DB) - models = [Event, Recordings, Timeline] - self.db.bind(models) - - self.minimal_config = { - "mqtt": {"host": "mqtt"}, - "cameras": { - "front_door": { - "ffmpeg": { - "inputs": [ - {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} - ] - }, - "detect": { - "height": 1080, - "width": 1920, - "fps": 5, - }, - } - }, - } - self.test_stats = { - "detection_fps": 13.7, - "detectors": { - "cpu1": { - "detection_start": 0.0, - "inference_speed": 91.43, - "pid": 42, - }, - "cpu2": { - "detection_start": 0.0, - "inference_speed": 84.99, - "pid": 44, - }, - }, - "front_door": { - "camera_fps": 0.0, - "capture_pid": 53, - "detection_fps": 0.0, - "pid": 52, - "process_fps": 0.0, - "skipped_fps": 0.0, - }, - "service": { - "storage": { - "/dev/shm": { - "free": 50.5, - "mount_type": "tmpfs", - "total": 67.1, - "used": 16.6, - }, - os.path.join(BASE_DIR, "clips"): { - "free": 42429.9, - "mount_type": "ext4", - "total": 244529.7, - "used": 189607.0, - }, - os.path.join(BASE_DIR, "recordings"): { - "free": 0.2, - "mount_type": "ext4", - "total": 8.0, - "used": 7.8, - }, - CACHE_DIR: { - "free": 976.8, - "mount_type": "tmpfs", - "total": 1000.0, - "used": 23.2, - }, - }, - "uptime": 101113, - "version": "0.10.1", - "latest_version": "0.11", - }, - } - - def tearDown(self): - if not self.db.is_closed(): - self.db.close() - - try: - for file in TEST_DB_CLEANUPS: - os.remove(file) - except OSError: - pass - - def test_get_good_event(self): - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - None, - ) - id = "123456.random" - - with TestClient(app) as client: - _insert_mock_event(id) - event = client.get(f"/events/{id}").json() - - assert event - assert event["id"] == id - assert event["id"] == model_to_dict(Event.get(Event.id == id))["id"] - - def test_get_bad_event(self): - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - None, - ) - id = "123456.random" - bad_id = "654321.other" - - with TestClient(app) as client: - _insert_mock_event(id) - event_response = client.get(f"/events/{bad_id}") - assert event_response.status_code == 404 - assert event_response.json() == "Event not found" - - def test_delete_event(self): - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - None, - ) - id = "123456.random" - - with TestClient(app) as client: - _insert_mock_event(id) - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - client.delete(f"/events/{id}", headers={"remote-role": "admin"}) - event = client.get(f"/events/{id}").json() - assert event == "Event not found" - - def test_event_retention(self): - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - None, - ) - id = "123456.random" - - with TestClient(app) as client: - _insert_mock_event(id) - client.post(f"/events/{id}/retain", headers={"remote-role": "admin"}) - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - assert event["retain_indefinitely"] is True - client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"}) - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - assert event["retain_indefinitely"] is False - - def test_event_time_filtering(self): - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - None, - ) - morning_id = "123456.random" - evening_id = "654321.random" - morning = 1656590400 # 06/30/2022 6 am (GMT) - evening = 1656633600 # 06/30/2022 6 pm (GMT) - - with TestClient(app) as client: - _insert_mock_event(morning_id, morning) - _insert_mock_event(evening_id, evening) - # both events come back - events = client.get("/events").json() - assert events - assert len(events) == 2 - # morning event is excluded - events = client.get( - "/events", - params={"time_range": "07:00,24:00"}, - ).json() - assert events - # assert len(events) == 1 - # evening event is excluded - events = client.get( - "/events", - params={"time_range": "00:00,18:00"}, - ).json() - assert events - assert len(events) == 1 - - def test_set_delete_sub_label(self): - mock_event_updater = Mock(spec=EventMetadataPublisher) - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - mock_event_updater, - ) - id = "123456.random" - sub_label = "sub" - - def update_event(topic, payload): - event = Event.get(id=id) - event.sub_label = payload[1] - event.save() - - mock_event_updater.publish.side_effect = update_event - - with TestClient(app) as client: - _insert_mock_event(id) - new_sub_label_response = client.post( - f"/events/{id}/sub_label", - json={"subLabel": sub_label}, - headers={"remote-role": "admin"}, - ) - assert new_sub_label_response.status_code == 200 - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - assert event["sub_label"] == sub_label - empty_sub_label_response = client.post( - f"/events/{id}/sub_label", - json={"subLabel": ""}, - headers={"remote-role": "admin"}, - ) - assert empty_sub_label_response.status_code == 200 - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - assert event["sub_label"] == None - - def test_sub_label_list(self): - mock_event_updater = Mock(spec=EventMetadataPublisher) - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - mock_event_updater, - ) - id = "123456.random" - sub_label = "sub" - - def update_event(topic, payload): - event = Event.get(id=id) - event.sub_label = payload[1] - event.save() - - mock_event_updater.publish.side_effect = update_event - - with TestClient(app) as client: - _insert_mock_event(id) - client.post( - f"/events/{id}/sub_label", - json={"subLabel": sub_label}, - headers={"remote-role": "admin"}, - ) - sub_labels = client.get("/sub_labels").json() - assert sub_labels - assert sub_labels == [sub_label] - - def test_config(self): - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - None, - ) - - with TestClient(app) as client: - config = client.get("/config").json() - assert config - assert config["cameras"]["front_door"] - - def test_recordings(self): - app = create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - None, - ) - id = "123456.random" - - with TestClient(app) as client: - _insert_mock_recording(id) - response = client.get("/front_door/recordings") - assert response.status_code == 200 - recording = response.json() - assert recording - assert recording[0]["id"] == id - - -def _insert_mock_event( - id: str, - start_time: datetime.datetime = datetime.datetime.now().timestamp(), -) -> Event: - """Inserts a basic event model with a given id.""" - return Event.insert( - id=id, - label="Mock", - camera="front_door", - start_time=start_time, - end_time=start_time + 20, - top_score=100, - false_positive=False, - zones=list(), - thumbnail="", - region=[], - box=[], - area=0, - has_clip=True, - has_snapshot=True, - ).execute() - - -def _insert_mock_recording(id: str) -> Event: - """Inserts a basic recording model with a given id.""" - return Recordings.insert( - id=id, - camera="front_door", - path=f"/recordings/{id}", - start_time=datetime.datetime.now().timestamp() - 60, - end_time=datetime.datetime.now().timestamp() - 50, - duration=10, - motion=True, - objects=True, - ).execute() diff --git a/frigate/test/test_proxy_auth.py b/frigate/test/test_proxy_auth.py new file mode 100644 index 0000000000..61955486a2 --- /dev/null +++ b/frigate/test/test_proxy_auth.py @@ -0,0 +1,78 @@ +import unittest + +from frigate.api.auth import resolve_role +from frigate.config import HeaderMappingConfig, ProxyConfig + + +class TestProxyRoleResolution(unittest.TestCase): + def setUp(self): + self.proxy_config = ProxyConfig( + auth_secret=None, + default_role="viewer", + separator="|", + header_map=HeaderMappingConfig( + user="x-remote-user", + role="x-remote-role", + role_map={ + "admin": ["group_admin"], + "viewer": ["group_viewer"], + }, + ), + ) + self.config_roles = list(["admin", "viewer"]) + + def test_role_map_single_group_match(self): + headers = {"x-remote-role": "group_admin"} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, "admin") + + def test_role_map_multiple_groups(self): + headers = {"x-remote-role": "group_admin|group_viewer"} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, "admin") + + def test_direct_role_header_with_separator(self): + config = self.proxy_config + config.header_map.role_map = None # disable role_map + headers = {"x-remote-role": "admin|viewer"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "admin") + + def test_invalid_role_header(self): + config = self.proxy_config + config.header_map.role_map = None + headers = {"x-remote-role": "notarole"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, config.default_role) + + def test_missing_role_header(self): + headers = {} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, self.proxy_config.default_role) + + def test_empty_role_header(self): + headers = {"x-remote-role": ""} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, self.proxy_config.default_role) + + def test_whitespace_groups(self): + headers = {"x-remote-role": " | group_admin | "} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, "admin") + + def test_mixed_valid_and_invalid_groups(self): + headers = {"x-remote-role": "bogus|group_viewer"} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, "viewer") + + def test_case_insensitive_role_direct(self): + config = self.proxy_config + config.header_map.role_map = None + headers = {"x-remote-role": "AdMiN"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "admin") + + def test_role_map_no_match_falls_back(self): + headers = {"x-remote-role": "group_unknown"} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, self.proxy_config.default_role) diff --git a/frigate/test/test_storage.py b/frigate/test/test_storage.py index d36960f477..4ae5715cab 100644 --- a/frigate/test/test_storage.py +++ b/frigate/test/test_storage.py @@ -261,12 +261,19 @@ class TestHttp(unittest.TestCase): assert Recordings.get(Recordings.id == rec_k3_id) -def _insert_mock_event(id: str, start: int, end: int, retain: bool) -> Event: +def _insert_mock_event( + id: str, + start: int, + end: int, + retain: bool, + camera: str = "front_door", + label: str = "Mock", +) -> Event: """Inserts a basic event model with a given id.""" return Event.insert( id=id, - label="Mock", - camera="front_door", + label=label, + camera=camera, start_time=start, end_time=end, top_score=100, diff --git a/frigate/track/__init__.py b/frigate/track/__init__.py index dc72be4f0a..b5453aaeba 100644 --- a/frigate/track/__init__.py +++ b/frigate/track/__init__.py @@ -11,6 +11,9 @@ class ObjectTracker(ABC): @abstractmethod def match_and_update( - self, frame_name: str, frame_time: float, detections: list[dict[str, Any]] + self, + frame_name: str, + frame_time: float, + detections: list[tuple[Any, Any, Any, Any, Any, Any]], ) -> None: pass diff --git a/frigate/track/centroid_tracker.py b/frigate/track/centroid_tracker.py index 25d4cb860a..56f20629cd 100644 --- a/frigate/track/centroid_tracker.py +++ b/frigate/track/centroid_tracker.py @@ -1,25 +1,26 @@ import random import string from collections import defaultdict +from typing import Any import numpy as np from scipy.spatial import distance as dist from frigate.config import DetectConfig from frigate.track import ObjectTracker -from frigate.util import intersection_over_union +from frigate.util.image import intersection_over_union class CentroidTracker(ObjectTracker): def __init__(self, config: DetectConfig): - self.tracked_objects = {} - self.untracked_object_boxes = [] - self.disappeared = {} - self.positions = {} + self.tracked_objects: dict[str, dict[str, Any]] = {} + self.untracked_object_boxes: list[tuple[int, int, int, int]] = [] + self.disappeared: dict[str, Any] = {} + self.positions: dict[str, Any] = {} self.max_disappeared = config.max_disappeared self.detect_config = config - def register(self, index, obj): + def register(self, obj: dict[str, Any]) -> None: rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) id = f"{obj['frame_time']}-{rand_id}" obj["id"] = id @@ -39,13 +40,13 @@ class CentroidTracker(ObjectTracker): "ymax": self.detect_config.height, } - def deregister(self, id): + def deregister(self, id: str) -> None: del self.tracked_objects[id] del self.disappeared[id] # tracks the current position of the object based on the last N bounding boxes # returns False if the object has moved outside its previous position - def update_position(self, id, box): + def update_position(self, id: str, box: tuple[int, int, int, int]) -> bool: position = self.positions[id] position_box = ( position["xmin"], @@ -88,7 +89,7 @@ class CentroidTracker(ObjectTracker): return True - def is_expired(self, id): + def is_expired(self, id: str) -> bool: obj = self.tracked_objects[id] # get the max frames for this label type or the default max_frames = self.detect_config.stationary.max_frames.objects.get( @@ -108,7 +109,7 @@ class CentroidTracker(ObjectTracker): return False - def update(self, id, new_obj): + def update(self, id: str, new_obj: dict[str, Any]) -> None: self.disappeared[id] = 0 # update the motionless count if the object has not moved to a new position if self.update_position(id, new_obj["box"]): @@ -129,25 +130,30 @@ class CentroidTracker(ObjectTracker): self.tracked_objects[id].update(new_obj) - def update_frame_times(self, frame_name, frame_time): + def update_frame_times(self, frame_name: str, frame_time: float) -> None: for id in list(self.tracked_objects.keys()): self.tracked_objects[id]["frame_time"] = frame_time self.tracked_objects[id]["motionless_count"] += 1 if self.is_expired(id): self.deregister(id) - def match_and_update(self, frame_time, detections): + def match_and_update( + self, + frame_name: str, + frame_time: float, + detections: list[tuple[Any, Any, Any, Any, Any, Any]], + ) -> None: # group by name detection_groups = defaultdict(lambda: []) - for obj in detections: - detection_groups[obj[0]].append( + for det in detections: + detection_groups[det[0]].append( { - "label": obj[0], - "score": obj[1], - "box": obj[2], - "area": obj[3], - "ratio": obj[4], - "region": obj[5], + "label": det[0], + "score": det[1], + "box": det[2], + "area": det[3], + "ratio": det[4], + "region": det[5], "frame_time": frame_time, } ) @@ -180,7 +186,7 @@ class CentroidTracker(ObjectTracker): if len(current_objects) == 0: for index, obj in enumerate(group): - self.register(index, obj) + self.register(obj) continue new_centroids = np.array([o["centroid"] for o in group]) @@ -238,4 +244,4 @@ class CentroidTracker(ObjectTracker): # register each new input centroid as a trackable object else: for col in unusedCols: - self.register(col, group[col]) + self.register(group[col]) diff --git a/frigate/track/norfair_tracker.py b/frigate/track/norfair_tracker.py index 900971e0d4..49b45f4b59 100644 --- a/frigate/track/norfair_tracker.py +++ b/frigate/track/norfair_tracker.py @@ -1,18 +1,14 @@ import logging import random import string -from typing import Any, Sequence +from typing import Any, Sequence, cast import cv2 import numpy as np -from norfair import ( - Detection, - Drawable, - OptimizedKalmanFilterFactory, - Tracker, - draw_boxes, -) -from norfair.drawing.drawer import Drawer +from norfair.drawing.draw_boxes import draw_boxes +from norfair.drawing.drawer import Drawable, Drawer +from norfair.filter import OptimizedKalmanFilterFactory +from norfair.tracker import Detection, TrackedObject, Tracker from rich import print from rich.console import Console from rich.table import Table @@ -21,6 +17,7 @@ from frigate.camera import PTZMetrics from frigate.config import CameraConfig from frigate.ptz.autotrack import PtzMotionEstimator from frigate.track import ObjectTracker +from frigate.track.stationary_classifier import StationaryMotionClassifier from frigate.util.image import ( SharedMemoryFrameManager, get_histogram, @@ -43,7 +40,7 @@ MAX_STATIONARY_HISTORY = 10 # - could be variable based on time since last_detection # - include estimated velocity in the distance (car driving by of a parked car) # - include some visual similarity factor in the distance for occlusions -def distance(detection: np.array, estimate: np.array) -> float: +def distance(detection: np.ndarray, estimate: np.ndarray) -> float: # ultimately, this should try and estimate distance in 3-dimensional space # consider change in location, width, and height @@ -73,14 +70,16 @@ def distance(detection: np.array, estimate: np.array) -> float: change = np.append(distance, np.array([width_ratio, height_ratio])) # calculate euclidean distance of the change vector - return np.linalg.norm(change) + return float(np.linalg.norm(change)) -def frigate_distance(detection: Detection, tracked_object) -> float: +def frigate_distance(detection: Detection, tracked_object: TrackedObject) -> float: return distance(detection.points, tracked_object.estimate) -def histogram_distance(matched_not_init_trackers, unmatched_trackers): +def histogram_distance( + matched_not_init_trackers: TrackedObject, unmatched_trackers: TrackedObject +) -> float: snd_embedding = unmatched_trackers.last_detection.embedding if snd_embedding is None: @@ -110,17 +109,18 @@ class NorfairTracker(ObjectTracker): ptz_metrics: PTZMetrics, ): self.frame_manager = SharedMemoryFrameManager() - self.tracked_objects = {} + self.tracked_objects: dict[str, dict[str, Any]] = {} self.untracked_object_boxes: list[list[int]] = [] - self.disappeared = {} - self.positions = {} - self.stationary_box_history: dict[str, list[list[int, int, int, int]]] = {} + self.disappeared: dict[str, int] = {} + self.positions: dict[str, dict[str, Any]] = {} + self.stationary_box_history: dict[str, list[list[int]]] = {} self.camera_config = config self.detect_config = config.detect self.ptz_metrics = ptz_metrics - self.ptz_motion_estimator = {} + self.ptz_motion_estimator: PtzMotionEstimator | None = None self.camera_name = config.name - self.track_id_map = {} + self.track_id_map: dict[str, str] = {} + self.stationary_classifier = StationaryMotionClassifier() # Define tracker configurations for static camera self.object_type_configs = { @@ -169,7 +169,7 @@ class NorfairTracker(ObjectTracker): "distance_threshold": 3, } - self.trackers = {} + self.trackers: dict[str, dict[str, Tracker]] = {} # Handle static trackers for obj_type, tracker_config in self.object_type_configs.items(): if obj_type in self.camera_config.objects.track: @@ -195,19 +195,21 @@ class NorfairTracker(ObjectTracker): self.default_tracker = { "static": Tracker( distance_function=frigate_distance, - distance_threshold=self.default_tracker_config["distance_threshold"], + distance_threshold=self.default_tracker_config[ # type: ignore[arg-type] + "distance_threshold" + ], initialization_delay=self.detect_config.min_initialized, - hit_counter_max=self.detect_config.max_disappeared, - filter_factory=self.default_tracker_config["filter_factory"], + hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type] + filter_factory=self.default_tracker_config["filter_factory"], # type: ignore[arg-type] ), "ptz": Tracker( distance_function=frigate_distance, distance_threshold=self.default_ptz_tracker_config[ "distance_threshold" - ], + ], # type: ignore[arg-type] initialization_delay=self.detect_config.min_initialized, - hit_counter_max=self.detect_config.max_disappeared, - filter_factory=self.default_ptz_tracker_config["filter_factory"], + hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type] + filter_factory=self.default_ptz_tracker_config["filter_factory"], # type: ignore[arg-type] ), } @@ -216,7 +218,7 @@ class NorfairTracker(ObjectTracker): self.camera_config, self.ptz_metrics ) - def _create_tracker(self, obj_type, tracker_config): + def _create_tracker(self, obj_type: str, tracker_config: dict[str, Any]) -> Tracker: """Helper function to create a tracker with given configuration.""" tracker_params = { "distance_function": tracker_config["distance_function"], @@ -258,7 +260,7 @@ class NorfairTracker(ObjectTracker): return self.trackers[object_type][mode] return self.default_tracker[mode] - def register(self, track_id, obj): + def register(self, track_id: str, obj: dict[str, Any]) -> None: rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) id = f"{obj['frame_time']}-{rand_id}" self.track_id_map[track_id] = id @@ -270,7 +272,7 @@ class NorfairTracker(ObjectTracker): # Get the correct tracker for this object's label tracker = self.get_tracker(obj["label"]) obj_match = next( - (o for o in tracker.tracked_objects if o.global_id == track_id), None + (o for o in tracker.tracked_objects if str(o.global_id) == track_id), None ) # if we don't have a match, we have a new object obj["score_history"] = ( @@ -297,7 +299,7 @@ class NorfairTracker(ObjectTracker): } self.stationary_box_history[id] = boxes - def deregister(self, id, track_id): + def deregister(self, id: str, track_id: str) -> None: obj = self.tracked_objects[id] del self.tracked_objects[id] @@ -314,30 +316,21 @@ class NorfairTracker(ObjectTracker): tracker.tracked_objects = [ o for o in tracker.tracked_objects - if o.global_id != track_id and o.hit_counter < 0 + if str(o.global_id) != track_id and o.hit_counter < 0 ] del self.track_id_map[track_id] # tracks the current position of the object based on the last N bounding boxes # returns False if the object has moved outside its previous position - def update_position(self, id: str, box: list[int, int, int, int], stationary: bool): - xmin, ymin, xmax, ymax = box - position = self.positions[id] - self.stationary_box_history[id].append(box) - - if len(self.stationary_box_history[id]) > MAX_STATIONARY_HISTORY: - self.stationary_box_history[id] = self.stationary_box_history[id][ - -MAX_STATIONARY_HISTORY: - ] - - avg_iou = intersection_over_union( - box, average_boxes(self.stationary_box_history[id]) - ) - - # object has minimal or zero iou - # assume object is active - if avg_iou < THRESHOLD_KNOWN_ACTIVE_IOU: + def update_position( + self, + id: str, + box: list[int], + stationary: bool, + yuv_frame: np.ndarray | None, + ) -> bool: + def reset_position(xmin: int, ymin: int, xmax: int, ymax: int) -> None: self.positions[id] = { "xmins": [xmin], "ymins": [ymin], @@ -348,13 +341,48 @@ class NorfairTracker(ObjectTracker): "xmax": xmax, "ymax": ymax, } - return False + + xmin, ymin, xmax, ymax = box + position = self.positions[id] + self.stationary_box_history[id].append(box) + + if len(self.stationary_box_history[id]) > MAX_STATIONARY_HISTORY: + self.stationary_box_history[id] = self.stationary_box_history[id][ + -MAX_STATIONARY_HISTORY: + ] + + avg_box = average_boxes(self.stationary_box_history[id]) + avg_iou = intersection_over_union(box, avg_box) + median_box = median_of_boxes(self.stationary_box_history[id]) + + # Establish anchor early when stationary and stable + if stationary and yuv_frame is not None: + history = self.stationary_box_history[id] + if id not in self.stationary_classifier.anchor_crops and len(history) >= 5: + stability_iou = intersection_over_union(avg_box, median_box) + if stability_iou >= 0.7: + self.stationary_classifier.ensure_anchor( + id, yuv_frame, cast(tuple[int, int, int, int], median_box) + ) + + # object has minimal or zero iou + # assume object is active + if avg_iou < THRESHOLD_KNOWN_ACTIVE_IOU: + if stationary and yuv_frame is not None: + if not self.stationary_classifier.evaluate( + id, yuv_frame, cast(tuple[int, int, int, int], tuple(box)) + ): + reset_position(xmin, ymin, xmax, ymax) + return False + else: + reset_position(xmin, ymin, xmax, ymax) + return False threshold = ( THRESHOLD_STATIONARY_CHECK_IOU if stationary else THRESHOLD_ACTIVE_CHECK_IOU ) - # object has iou below threshold, check median to reduce outliers + # object has iou below threshold, check median and optionally crop similarity if avg_iou < threshold: median_iou = intersection_over_union( ( @@ -363,27 +391,26 @@ class NorfairTracker(ObjectTracker): position["xmax"], position["ymax"], ), - median_of_boxes(self.stationary_box_history[id]), + median_box, ) # if the median iou drops below the threshold # assume object is no longer stationary if median_iou < threshold: - self.positions[id] = { - "xmins": [xmin], - "ymins": [ymin], - "xmaxs": [xmax], - "ymaxs": [ymax], - "xmin": xmin, - "ymin": ymin, - "xmax": xmax, - "ymax": ymax, - } - return False + # If we have a yuv_frame to check before flipping to active, check with classifier if we have YUV frame + if stationary and yuv_frame is not None: + if not self.stationary_classifier.evaluate( + id, yuv_frame, cast(tuple[int, int, int, int], tuple(box)) + ): + reset_position(xmin, ymin, xmax, ymax) + return False + else: + reset_position(xmin, ymin, xmax, ymax) + return False # if there are more than 5 and less than 10 entries for the position, add the bounding box # and recompute the position box - if 5 <= len(position["xmins"]) < 10: + if len(position["xmins"]) < 10: position["xmins"].append(xmin) position["ymins"].append(ymin) position["xmaxs"].append(xmax) @@ -396,7 +423,7 @@ class NorfairTracker(ObjectTracker): return True - def is_expired(self, id): + def is_expired(self, id: str) -> bool: obj = self.tracked_objects[id] # get the max frames for this label type or the default max_frames = self.detect_config.stationary.max_frames.objects.get( @@ -416,7 +443,12 @@ class NorfairTracker(ObjectTracker): return False - def update(self, track_id, obj): + def update( + self, + track_id: str, + obj: dict[str, Any], + yuv_frame: np.ndarray | None, + ) -> None: id = self.track_id_map[track_id] self.disappeared[id] = 0 stationary = ( @@ -424,7 +456,7 @@ class NorfairTracker(ObjectTracker): >= self.detect_config.stationary.threshold ) # update the motionless count if the object has not moved to a new position - if self.update_position(id, obj["box"], stationary): + if self.update_position(id, obj["box"], stationary, yuv_frame): self.tracked_objects[id]["motionless_count"] += 1 if self.is_expired(id): self.deregister(id, track_id) @@ -440,10 +472,11 @@ class NorfairTracker(ObjectTracker): self.tracked_objects[id]["position_changes"] += 1 self.tracked_objects[id]["motionless_count"] = 0 self.stationary_box_history[id] = [] + self.stationary_classifier.on_active(id) self.tracked_objects[id].update(obj) - def update_frame_times(self, frame_name: str, frame_time: float): + def update_frame_times(self, frame_name: str, frame_time: float) -> None: # if the object was there in the last frame, assume it's still there detections = [ ( @@ -460,10 +493,22 @@ class NorfairTracker(ObjectTracker): self.match_and_update(frame_name, frame_time, detections=detections) def match_and_update( - self, frame_name: str, frame_time: float, detections: list[dict[str, Any]] - ): + self, + frame_name: str, + frame_time: float, + detections: list[tuple[Any, Any, Any, Any, Any, Any]], + ) -> None: # Group detections by object type - detections_by_type = {} + detections_by_type: dict[str, list[Detection]] = {} + yuv_frame: np.ndarray | None = None + + if self.ptz_metrics.autotracker_enabled.value or ( + self.detect_config.stationary.classifier + and any(obj[0] == "car" for obj in detections) + ): + yuv_frame = self.frame_manager.get( + frame_name, self.camera_config.frame_shape_yuv + ) for obj in detections: label = obj[0] if label not in detections_by_type: @@ -478,9 +523,6 @@ class NorfairTracker(ObjectTracker): embedding = None if self.ptz_metrics.autotracker_enabled.value: - yuv_frame = self.frame_manager.get( - frame_name, self.camera_config.frame_shape_yuv - ) embedding = get_histogram( yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3] ) @@ -551,28 +593,32 @@ class NorfairTracker(ObjectTracker): estimate = ( max(0, estimate[0]), max(0, estimate[1]), - min(self.detect_config.width - 1, estimate[2]), - min(self.detect_config.height - 1, estimate[3]), + min(self.detect_config.width - 1, estimate[2]), # type: ignore[operator] + min(self.detect_config.height - 1, estimate[3]), # type: ignore[operator] ) - obj = { + new_obj = { **t.last_detection.data, "estimate": estimate, "estimate_velocity": t.estimate_velocity, } - active_ids.append(t.global_id) - if t.global_id not in self.track_id_map: - self.register(t.global_id, obj) + active_ids.append(str(t.global_id)) + if str(t.global_id) not in self.track_id_map: + self.register(str(t.global_id), new_obj) # if there wasn't a detection in this frame, increment disappeared elif t.last_detection.data["frame_time"] != frame_time: - id = self.track_id_map[t.global_id] + id = self.track_id_map[str(t.global_id)] self.disappeared[id] += 1 # sometimes the estimate gets way off # only update if the upper left corner is actually upper left if estimate[0] < estimate[2] and estimate[1] < estimate[3]: - self.tracked_objects[id]["estimate"] = obj["estimate"] + self.tracked_objects[id]["estimate"] = new_obj["estimate"] # else update it else: - self.update(t.global_id, obj) + self.update( + str(t.global_id), + new_obj, + yuv_frame if new_obj["label"] == "car" else None, + ) # clear expired tracks expired_ids = [k for k in self.track_id_map.keys() if k not in active_ids] @@ -585,7 +631,7 @@ class NorfairTracker(ObjectTracker): o[2] for o in detections if o[2] not in tracked_object_boxes ] - def print_objects_as_table(self, tracked_objects: Sequence): + def print_objects_as_table(self, tracked_objects: Sequence) -> None: """Used for helping in debugging""" print() console = Console() @@ -605,13 +651,13 @@ class NorfairTracker(ObjectTracker): ) console.print(table) - def debug_draw(self, frame, frame_time): + def debug_draw(self, frame: np.ndarray, frame_time: float) -> None: # Collect all tracked objects from each tracker - all_tracked_objects = [] + all_tracked_objects: list[TrackedObject] = [] # print a table to the console with norfair tracked object info if False: - if len(self.trackers["license_plate"]["static"].tracked_objects) > 0: + if len(self.trackers["license_plate"]["static"].tracked_objects) > 0: # type: ignore[unreachable] self.print_objects_as_table( self.trackers["license_plate"]["static"].tracked_objects ) @@ -638,9 +684,9 @@ class NorfairTracker(ObjectTracker): # draw the estimated bounding box draw_boxes(frame, all_tracked_objects, color="green", draw_ids=True) # draw the detections that were detected in the current frame - draw_boxes(frame, active_detections, color="blue", draw_ids=True) + draw_boxes(frame, active_detections, color="blue", draw_ids=True) # type: ignore[arg-type] # draw the detections that are missing in the current frame - draw_boxes(frame, missing_detections, color="red", draw_ids=True) + draw_boxes(frame, missing_detections, color="red", draw_ids=True) # type: ignore[arg-type] # draw the distance calculation for the last detection # estimate vs detection @@ -648,8 +694,8 @@ class NorfairTracker(ObjectTracker): ld = obj.last_detection # bottom right text_anchor = ( - ld.points[1, 0], - ld.points[1, 1], + ld.points[1, 0], # type: ignore[index] + ld.points[1, 1], # type: ignore[index] ) frame = Drawer.text( frame, @@ -662,7 +708,7 @@ class NorfairTracker(ObjectTracker): if False: # draw the current formatted time on the frame - from datetime import datetime + from datetime import datetime # type: ignore[unreachable] formatted_time = datetime.fromtimestamp(frame_time).strftime( "%m/%d/%Y %I:%M:%S %p" diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 773c6da306..25128d6df9 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -6,6 +6,7 @@ import queue import threading from collections import defaultdict from enum import Enum +from multiprocessing import Queue as MpQueue from multiprocessing.synchronize import Event as MpEvent from typing import Any @@ -14,7 +15,6 @@ import numpy as np from peewee import SQL, DoesNotExist from frigate.camera.state import CameraState -from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.dispatcher import Dispatcher from frigate.comms.event_metadata_updater import ( @@ -29,6 +29,10 @@ from frigate.config import ( RecordConfig, SnapshotsConfig, ) +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import ( FAST_QUEUE_TIMEOUT, UPDATE_CAMERA_ACTIVITY, @@ -36,6 +40,7 @@ from frigate.const import ( ) from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Event, ReviewSegment, Timeline +from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.track.tracked_object import TrackedObject from frigate.util.image import SharedMemoryFrameManager @@ -53,10 +58,10 @@ class TrackedObjectProcessor(threading.Thread): self, config: FrigateConfig, dispatcher: Dispatcher, - tracked_objects_queue, - ptz_autotracker_thread, - stop_event, - ): + tracked_objects_queue: MpQueue, + ptz_autotracker_thread: PtzAutoTrackerThread, + stop_event: MpEvent, + ) -> None: super().__init__(name="detected_frames_processor") self.config = config self.dispatcher = dispatcher @@ -67,10 +72,19 @@ class TrackedObjectProcessor(threading.Thread): self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread - self.config_enabled_subscriber = ConfigSubscriber("config/enabled/") + self.camera_config_subscriber = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.enabled, + CameraConfigUpdateEnum.remove, + CameraConfigUpdateEnum.zones, + ], + ) self.requestor = InterProcessRequestor() - self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all) + self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all.value) self.event_sender = EventUpdatePublisher() self.event_end_subscriber = EventEndSubscriber() self.sub_label_subscriber = EventMetadataSubscriber(EventMetadataTypeEnum.all) @@ -86,10 +100,20 @@ class TrackedObjectProcessor(threading.Thread): # } # } # } - self.zone_data = defaultdict(lambda: defaultdict(dict)) - self.active_zone_data = defaultdict(lambda: defaultdict(dict)) + self.zone_data: dict[str, dict[str, Any]] = defaultdict( + lambda: defaultdict(dict) + ) + self.active_zone_data: dict[str, dict[str, Any]] = defaultdict( + lambda: defaultdict(dict) + ) - def start(camera: str, obj: TrackedObject, frame_name: str): + for camera in self.config.cameras.keys(): + self.create_camera_state(camera) + + def create_camera_state(self, camera: str) -> None: + """Creates a new camera state.""" + + def start(camera: str, obj: TrackedObject, frame_name: str) -> None: self.event_sender.publish( ( EventTypeEnum.tracked_object, @@ -100,7 +124,7 @@ class TrackedObjectProcessor(threading.Thread): ) ) - def update(camera: str, obj: TrackedObject, frame_name: str): + def update(camera: str, obj: TrackedObject, frame_name: str) -> None: obj.has_snapshot = self.should_save_snapshot(camera, obj) obj.has_clip = self.should_retain_recording(camera, obj) after = obj.to_dict() @@ -121,10 +145,10 @@ class TrackedObjectProcessor(threading.Thread): ) ) - def autotrack(camera: str, obj: TrackedObject, frame_name: str): + def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None: self.ptz_autotracker_thread.ptz_autotracker.autotrack_object(camera, obj) - def end(camera: str, obj: TrackedObject, frame_name: str): + def end(camera: str, obj: TrackedObject, frame_name: str) -> None: # populate has_snapshot obj.has_snapshot = self.should_save_snapshot(camera, obj) obj.has_clip = self.should_retain_recording(camera, obj) @@ -193,26 +217,25 @@ class TrackedObjectProcessor(threading.Thread): return False - def camera_activity(camera, activity): + def camera_activity(camera: str, activity: dict[str, Any]) -> None: last_activity = self.camera_activity.get(camera) if not last_activity or activity != last_activity: self.camera_activity[camera] = activity self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity) - for camera in self.config.cameras.keys(): - camera_state = CameraState( - camera, self.config, self.frame_manager, self.ptz_autotracker_thread - ) - camera_state.on("start", start) - camera_state.on("autotrack", autotrack) - camera_state.on("update", update) - camera_state.on("end", end) - camera_state.on("snapshot", snapshot) - camera_state.on("camera_activity", camera_activity) - self.camera_states[camera] = camera_state + camera_state = CameraState( + camera, self.config, self.frame_manager, self.ptz_autotracker_thread + ) + camera_state.on("start", start) + camera_state.on("autotrack", autotrack) + camera_state.on("update", update) + camera_state.on("end", end) + camera_state.on("snapshot", snapshot) + camera_state.on("camera_activity", camera_activity) + self.camera_states[camera] = camera_state - def should_save_snapshot(self, camera, obj: TrackedObject): + def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool: if obj.false_positive: return False @@ -235,7 +258,7 @@ class TrackedObjectProcessor(threading.Thread): return True - def should_retain_recording(self, camera: str, obj: TrackedObject): + def should_retain_recording(self, camera: str, obj: TrackedObject) -> bool: if obj.false_positive: return False @@ -255,7 +278,7 @@ class TrackedObjectProcessor(threading.Thread): return True - def should_mqtt_snapshot(self, camera, obj: TrackedObject): + def should_mqtt_snapshot(self, camera: str, obj: TrackedObject) -> bool: # object never changed position if obj.is_stationary(): return False @@ -270,7 +293,9 @@ class TrackedObjectProcessor(threading.Thread): return True - def update_mqtt_motion(self, camera, frame_time, motion_boxes): + def update_mqtt_motion( + self, camera: str, frame_time: float, motion_boxes: list + ) -> None: # publish if motion is currently being detected if motion_boxes: # only send ON if motion isn't already active @@ -296,11 +321,15 @@ class TrackedObjectProcessor(threading.Thread): # reset the last_motion so redundant `off` commands aren't sent self.last_motion_detected[camera] = 0 - def get_best(self, camera, label): + def get_best(self, camera: str, label: str) -> dict[str, Any]: # TODO: need a lock here camera_state = self.camera_states[camera] if label in camera_state.best_objects: best_obj = camera_state.best_objects[label] + + if not best_obj.thumbnail_data: + return {} + best = best_obj.thumbnail_data.copy() best["frame"] = camera_state.frame_cache.get( best_obj.thumbnail_data["frame_time"] @@ -323,7 +352,7 @@ class TrackedObjectProcessor(threading.Thread): return self.camera_states[camera].get_current_frame(draw_options) - def get_current_frame_time(self, camera) -> int: + def get_current_frame_time(self, camera: str) -> float: """Returns the latest frame time for a given camera.""" return self.camera_states[camera].current_frame_time @@ -331,7 +360,7 @@ class TrackedObjectProcessor(threading.Thread): self, event_id: str, sub_label: str | None, score: float | None ) -> None: """Update sub label for given event id.""" - tracked_obj: TrackedObject = None + tracked_obj: TrackedObject | None = None for state in self.camera_states.values(): tracked_obj = state.tracked_objects.get(event_id) @@ -340,7 +369,7 @@ class TrackedObjectProcessor(threading.Thread): break try: - event: Event = Event.get(Event.id == event_id) + event: Event | None = Event.get(Event.id == event_id) except DoesNotExist: event = None @@ -351,12 +380,12 @@ class TrackedObjectProcessor(threading.Thread): tracked_obj.obj_data["sub_label"] = (sub_label, score) if event: - event.sub_label = sub_label + event.sub_label = sub_label # type: ignore[assignment] data = event.data if sub_label is None: - data["sub_label_score"] = None + data["sub_label_score"] = None # type: ignore[index] elif score is not None: - data["sub_label_score"] = score + data["sub_label_score"] = score # type: ignore[index] event.data = data event.save() @@ -385,7 +414,7 @@ class TrackedObjectProcessor(threading.Thread): objects_list = [] sub_labels = set() events = Event.select(Event.id, Event.label, Event.sub_label).where( - Event.id.in_(detection_ids) + Event.id.in_(detection_ids) # type: ignore[call-arg, misc] ) for det_event in events: if det_event.sub_label: @@ -414,18 +443,20 @@ class TrackedObjectProcessor(threading.Thread): f"Updated sub_label for event {event_id} in review segment {review_segment.id}" ) - except ReviewSegment.DoesNotExist: + except DoesNotExist: logger.debug( f"No review segment found with event ID {event_id} when updating sub_label" ) - return True - - def set_recognized_license_plate( - self, event_id: str, recognized_license_plate: str | None, score: float | None + def set_object_attribute( + self, + event_id: str, + field_name: str, + field_value: str | None, + score: float | None, ) -> None: - """Update recognized license plate for given event id.""" - tracked_obj: TrackedObject = None + """Update attribute for given event id.""" + tracked_obj: TrackedObject | None = None for state in self.camera_states.values(): tracked_obj = state.tracked_objects.get(event_id) @@ -434,7 +465,7 @@ class TrackedObjectProcessor(threading.Thread): break try: - event: Event = Event.get(Event.id == event_id) + event: Event | None = Event.get(Event.id == event_id) except DoesNotExist: event = None @@ -442,23 +473,21 @@ class TrackedObjectProcessor(threading.Thread): return if tracked_obj: - tracked_obj.obj_data["recognized_license_plate"] = ( - recognized_license_plate, + tracked_obj.obj_data[field_name] = ( + field_value, score, ) if event: data = event.data - data["recognized_license_plate"] = recognized_license_plate - if recognized_license_plate is None: - data["recognized_license_plate_score"] = None + data[field_name] = field_value # type: ignore[index] + if field_value is None: + data[f"{field_name}_score"] = None # type: ignore[index] elif score is not None: - data["recognized_license_plate_score"] = score + data[f"{field_name}_score"] = score # type: ignore[index] event.data = data event.save() - return True - def save_lpr_snapshot(self, payload: tuple) -> None: # save the snapshot image (frame, event_id, camera) = payload @@ -617,7 +646,7 @@ class TrackedObjectProcessor(threading.Thread): ) self.ongoing_manual_events.pop(event_id) - def force_end_all_events(self, camera: str, camera_state: CameraState): + def force_end_all_events(self, camera: str, camera_state: CameraState) -> None: """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()): @@ -635,27 +664,28 @@ class TrackedObjectProcessor(threading.Thread): {"enabled": False, "motion": 0, "objects": []}, ) - def run(self): + def run(self) -> None: while not self.stop_event.is_set(): # check for config updates - while True: - ( - updated_enabled_topic, - updated_enabled_config, - ) = self.config_enabled_subscriber.check_for_update() + updated_topics = self.camera_config_subscriber.check_for_updates() - if not updated_enabled_topic: - break - - camera_name = updated_enabled_topic.rpartition("/")[-1] - self.config.cameras[ - camera_name - ].enabled = updated_enabled_config.enabled - - if self.camera_states[camera_name].prev_enabled is None: - self.camera_states[ - camera_name - ].prev_enabled = updated_enabled_config.enabled + if "enabled" in updated_topics: + for camera in updated_topics["enabled"]: + if self.camera_states[camera].prev_enabled is None: + self.camera_states[camera].prev_enabled = self.config.cameras[ + camera + ].enabled + elif "add" in updated_topics: + for camera in updated_topics["add"]: + self.config.cameras[camera] = ( + self.camera_config_subscriber.camera_configs[camera] + ) + self.create_camera_state(camera) + elif "remove" in updated_topics: + for camera in updated_topics["remove"]: + camera_state = self.camera_states[camera] + camera_state.shutdown() + self.camera_states.pop(camera) # manage camera disabled state for camera, config in self.config.cameras.items(): @@ -676,11 +706,14 @@ class TrackedObjectProcessor(threading.Thread): # check for sub label updates while True: - (raw_topic, payload) = self.sub_label_subscriber.check_for_update( - timeout=0 - ) + update = self.sub_label_subscriber.check_for_update(timeout=0) - if not raw_topic: + if not update: + break + + (raw_topic, payload) = update + + if not raw_topic or not payload: break topic = str(raw_topic) @@ -688,11 +721,9 @@ class TrackedObjectProcessor(threading.Thread): if topic.endswith(EventMetadataTypeEnum.sub_label.value): (event_id, sub_label, score) = payload self.set_sub_label(event_id, sub_label, score) - if topic.endswith(EventMetadataTypeEnum.recognized_license_plate.value): - (event_id, recognized_license_plate, score) = payload - self.set_recognized_license_plate( - event_id, recognized_license_plate, score - ) + if topic.endswith(EventMetadataTypeEnum.attribute.value): + (event_id, field_name, field_value, score) = payload + self.set_object_attribute(event_id, field_name, field_value, score) elif topic.endswith(EventMetadataTypeEnum.lpr_event_create.value): self.create_lpr_event(payload) elif topic.endswith(EventMetadataTypeEnum.save_lpr_snapshot.value): @@ -764,6 +795,6 @@ class TrackedObjectProcessor(threading.Thread): self.event_sender.stop() self.event_end_subscriber.stop() self.sub_label_subscriber.stop() - self.config_enabled_subscriber.stop() + self.camera_config_subscriber.stop() logger.info("Exiting object processor...") diff --git a/frigate/track/stationary_classifier.py b/frigate/track/stationary_classifier.py new file mode 100644 index 0000000000..45e51b839f --- /dev/null +++ b/frigate/track/stationary_classifier.py @@ -0,0 +1,202 @@ +"""Tools for determining if an object is stationary.""" + +import logging +from typing import Any, cast + +import cv2 +import numpy as np +from scipy.ndimage import gaussian_filter + +logger = logging.getLogger(__name__) + + +THRESHOLD_KNOWN_ACTIVE_IOU = 0.2 +THRESHOLD_STATIONARY_CHECK_IOU = 0.6 +THRESHOLD_ACTIVE_CHECK_IOU = 0.9 +MAX_STATIONARY_HISTORY = 10 + + +class StationaryMotionClassifier: + """Fallback classifier to prevent false flips from stationary to active. + + Uses appearance consistency on a fixed spatial region (historical median box) + to detect actual movement, ignoring bounding box detection variations. + """ + + CROP_SIZE = 96 + NCC_KEEP_THRESHOLD = 0.90 # High correlation = keep stationary + NCC_ACTIVE_THRESHOLD = 0.85 # Low correlation = consider active + SHIFT_KEEP_THRESHOLD = 0.02 # Small shift = keep stationary + SHIFT_ACTIVE_THRESHOLD = 0.04 # Large shift = consider active + DRIFT_ACTIVE_THRESHOLD = 0.12 # Cumulative drift over 5 frames + CHANGED_FRAMES_TO_FLIP = 2 + + def __init__(self) -> None: + self.anchor_crops: dict[str, np.ndarray] = {} + self.anchor_boxes: dict[str, tuple[int, int, int, int]] = {} + self.changed_counts: dict[str, int] = {} + self.shift_histories: dict[str, list[float]] = {} + + # Pre-compute Hanning window for phase correlation + hann = np.hanning(self.CROP_SIZE).astype(np.float64) + self._hann2d = np.outer(hann, hann) + + def reset(self, id: str) -> None: + logger.debug("StationaryMotionClassifier.reset: id=%s", id) + if id in self.anchor_crops: + del self.anchor_crops[id] + if id in self.anchor_boxes: + del self.anchor_boxes[id] + self.changed_counts[id] = 0 + self.shift_histories[id] = [] + + def _extract_y_crop( + self, yuv_frame: np.ndarray, box: tuple[int, int, int, int] + ) -> np.ndarray: + """Extract and normalize Y-plane crop from bounding box.""" + y_height = yuv_frame.shape[0] // 3 * 2 + width = yuv_frame.shape[1] + x1 = max(0, min(width - 1, box[0])) + y1 = max(0, min(y_height - 1, box[1])) + x2 = max(0, min(width - 1, box[2])) + y2 = max(0, min(y_height - 1, box[3])) + + if x2 <= x1: + x2 = min(width - 1, x1 + 1) + if y2 <= y1: + y2 = min(y_height - 1, y1 + 1) + + # Extract Y-plane crop, resize, and blur + y_plane = yuv_frame[0:y_height, 0:width] + crop = y_plane[y1:y2, x1:x2] + crop_resized = cv2.resize( + crop, (self.CROP_SIZE, self.CROP_SIZE), interpolation=cv2.INTER_AREA + ) + result = cast(np.ndarray[Any, Any], gaussian_filter(crop_resized, sigma=0.5)) + logger.debug( + "_extract_y_crop: box=%s clamped=(%d,%d,%d,%d) crop_shape=%s", + box, + x1, + y1, + x2, + y2, + crop.shape if "crop" in locals() else None, + ) + return result + + def ensure_anchor( + self, id: str, yuv_frame: np.ndarray, median_box: tuple[int, int, int, int] + ) -> None: + """Initialize anchor crop from stable median box when object becomes stationary.""" + if id not in self.anchor_crops: + self.anchor_boxes[id] = median_box + self.anchor_crops[id] = self._extract_y_crop(yuv_frame, median_box) + self.changed_counts[id] = 0 + self.shift_histories[id] = [] + logger.debug( + "ensure_anchor: initialized id=%s median_box=%s crop_shape=%s", + id, + median_box, + self.anchor_crops[id].shape, + ) + + def on_active(self, id: str) -> None: + """Reset state when object becomes active to allow re-anchoring.""" + logger.debug("on_active: id=%s became active; resetting state", id) + self.reset(id) + + def evaluate( + self, id: str, yuv_frame: np.ndarray, current_box: tuple[int, int, int, int] + ) -> bool: + """Return True to keep stationary, False to flip to active. + + Compares the same spatial region (historical median box) across frames + to detect actual movement, ignoring bounding box variations. + """ + + if id not in self.anchor_crops or id not in self.anchor_boxes: + logger.debug("evaluate: id=%s has no anchor; default keep stationary", id) + return True + + # Compare same spatial region across frames + anchor_box = self.anchor_boxes[id] + anchor_crop = self.anchor_crops[id] + curr_crop = self._extract_y_crop(yuv_frame, anchor_box) + + # Compute appearance and motion metrics + ncc = cv2.matchTemplate(curr_crop, anchor_crop, cv2.TM_CCOEFF_NORMED)[0, 0] + a64 = anchor_crop.astype(np.float64) * self._hann2d + c64 = curr_crop.astype(np.float64) * self._hann2d + (shift_x, shift_y), _ = cv2.phaseCorrelate(a64, c64) + shift_norm = float(np.hypot(shift_x, shift_y)) / float(self.CROP_SIZE) + + logger.debug( + "evaluate: id=%s metrics ncc=%.4f shift_norm=%.4f (shift_x=%.3f, shift_y=%.3f)", + id, + float(ncc), + shift_norm, + float(shift_x), + float(shift_y), + ) + + # Update rolling shift history + history = self.shift_histories.get(id, []) + history.append(shift_norm) + if len(history) > 5: + history = history[-5:] + self.shift_histories[id] = history + drift_sum = float(sum(history)) + + logger.debug( + "evaluate: id=%s history_len=%d last_shift=%.4f drift_sum=%.4f", + id, + len(history), + history[-1] if history else -1.0, + drift_sum, + ) + + # Early exit for clear stationary case + if ncc >= self.NCC_KEEP_THRESHOLD and shift_norm < self.SHIFT_KEEP_THRESHOLD: + self.changed_counts[id] = 0 + logger.debug( + "evaluate: id=%s early-stationary keep=True (ncc>=%.2f and shift<%.2f)", + id, + self.NCC_KEEP_THRESHOLD, + self.SHIFT_KEEP_THRESHOLD, + ) + return True + + # Check for movement indicators + movement_detected = ( + ncc < self.NCC_ACTIVE_THRESHOLD + or shift_norm >= self.SHIFT_ACTIVE_THRESHOLD + or drift_sum >= self.DRIFT_ACTIVE_THRESHOLD + ) + + if movement_detected: + cnt = self.changed_counts.get(id, 0) + 1 + self.changed_counts[id] = cnt + if ( + cnt >= self.CHANGED_FRAMES_TO_FLIP + or drift_sum >= self.DRIFT_ACTIVE_THRESHOLD + ): + logger.debug( + "evaluate: id=%s flip_to_active=True cnt=%d drift_sum=%.4f thresholds(changed>=%d drift>=%.2f)", + id, + cnt, + drift_sum, + self.CHANGED_FRAMES_TO_FLIP, + self.DRIFT_ACTIVE_THRESHOLD, + ) + return False + logger.debug( + "evaluate: id=%s movement_detected cnt=%d keep_until_cnt>=%d", + id, + cnt, + self.CHANGED_FRAMES_TO_FLIP, + ) + else: + self.changed_counts[id] = 0 + logger.debug("evaluate: id=%s no_movement keep=True", id) + + return True diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 2cb028a9af..111dd2c40e 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -5,18 +5,19 @@ import math import os from collections import defaultdict from statistics import median -from typing import Any, Optional +from typing import Any, Optional, cast import cv2 import numpy as np from frigate.config import ( CameraConfig, - ModelConfig, + FilterConfig, SnapshotsConfig, UIConfig, ) from frigate.const import CLIPS_DIR, THUMB_DIR +from frigate.detectors.detector_config import ModelConfig from frigate.review.types import SeverityEnum from frigate.util.builtin import sanitize_float from frigate.util.image import ( @@ -32,17 +33,25 @@ from frigate.util.velocity import calculate_real_world_speed logger = logging.getLogger(__name__) +# In most cases objects that loiter in a loitering zone should alert, +# but can still be expected to stay stationary for extended periods of time +# (ex: car loitering on the street vs when a known person parks on the street) +# person is the main object that should keep alerts going as long as they loiter +# even if they are stationary. +EXTENDED_LOITERING_OBJECTS = ["person"] + + class TrackedObject: def __init__( self, model_config: ModelConfig, camera_config: CameraConfig, ui_config: UIConfig, - frame_cache, + frame_cache: dict[float, dict[str, Any]], obj_data: dict[str, Any], - ): + ) -> None: # set the score history then remove as it is not part of object state - self.score_history = obj_data["score_history"] + self.score_history: list[float] = obj_data["score_history"] del obj_data["score_history"] self.obj_data = obj_data @@ -53,24 +62,24 @@ class TrackedObject: self.frame_cache = frame_cache self.zone_presence: dict[str, int] = {} self.zone_loitering: dict[str, int] = {} - self.current_zones = [] - self.entered_zones = [] - self.attributes = defaultdict(float) + self.current_zones: list[str] = [] + self.entered_zones: list[str] = [] + self.attributes: dict[str, float] = defaultdict(float) self.false_positive = True self.has_clip = False self.has_snapshot = False self.top_score = self.computed_score = 0.0 - self.thumbnail_data = None + self.thumbnail_data: dict[str, Any] | None = None self.last_updated = 0 self.last_published = 0 self.frame = None self.active = True self.pending_loitering = False - self.speed_history = [] - self.current_estimated_speed = 0 - self.average_estimated_speed = 0 + self.speed_history: list[float] = [] + self.current_estimated_speed: float = 0 + self.average_estimated_speed: float = 0 self.velocity_angle = 0 - self.path_data = [] + self.path_data: list[tuple[Any, float]] = [] self.previous = self.to_dict() @property @@ -103,7 +112,7 @@ class TrackedObject: return None - def _is_false_positive(self): + def _is_false_positive(self) -> bool: # once a true positive, always a true positive if not self.false_positive: return False @@ -111,11 +120,13 @@ class TrackedObject: threshold = self.camera_config.objects.filters[self.obj_data["label"]].threshold return self.computed_score < threshold - def compute_score(self): + def compute_score(self) -> float: """get median of scores for object.""" return median(self.score_history) - def update(self, current_frame_time: float, obj_data, has_valid_frame: bool): + def update( + self, current_frame_time: float, obj_data: dict[str, Any], has_valid_frame: bool + ) -> tuple[bool, bool, bool, bool]: thumb_update = False significant_change = False path_update = False @@ -247,8 +258,12 @@ class TrackedObject: if zone.distances and not in_speed_zone: continue # Skip zone entry for speed zones until speed threshold met - # if the zone has loitering time, update loitering status - if zone.loitering_time > 0: + # if the zone has loitering time, and the object is an extended loiter object + # always mark it as loitering actively + if ( + self.obj_data["label"] in EXTENDED_LOITERING_OBJECTS + and zone.loitering_time > 0 + ): in_loitering_zone = True loitering_score = self.zone_loitering.get(name, 0) + 1 @@ -264,6 +279,10 @@ class TrackedObject: self.entered_zones.append(name) else: self.zone_loitering[name] = loitering_score + + # this object is pending loitering but has not entered the zone yet + if zone.loitering_time > 0: + in_loitering_zone = True else: self.zone_presence[name] = zone_score else: @@ -289,7 +308,7 @@ class TrackedObject: k: self.attributes[k] for k in self.logos if k in self.attributes } if len(recognized_logos) > 0: - max_logo = max(recognized_logos, key=recognized_logos.get) + max_logo = max(recognized_logos, key=recognized_logos.get) # type: ignore[arg-type] # don't overwrite sub label if it is already set if ( @@ -326,28 +345,30 @@ class TrackedObject: # update path width = self.camera_config.detect.width height = self.camera_config.detect.height - bottom_center = ( - round(obj_data["centroid"][0] / width, 4), - round(obj_data["box"][3] / height, 4), - ) - # calculate a reasonable movement threshold (e.g., 5% of the frame diagonal) - threshold = 0.05 * math.sqrt(width**2 + height**2) / max(width, height) - - if not self.path_data: - self.path_data.append((bottom_center, obj_data["frame_time"])) - path_update = True - elif ( - math.dist(self.path_data[-1][0], bottom_center) >= threshold - or len(self.path_data) == 1 - ): - # check Euclidean distance before appending - self.path_data.append((bottom_center, obj_data["frame_time"])) - path_update = True - logger.debug( - f"Point tracking: {obj_data['id']}, {bottom_center}, {obj_data['frame_time']}" + if width is not None and height is not None: + bottom_center = ( + round(obj_data["centroid"][0] / width, 4), + round(obj_data["box"][3] / height, 4), ) + # calculate a reasonable movement threshold (e.g., 5% of the frame diagonal) + threshold = 0.05 * math.sqrt(width**2 + height**2) / max(width, height) + + if not self.path_data: + self.path_data.append((bottom_center, obj_data["frame_time"])) + path_update = True + elif ( + math.dist(self.path_data[-1][0], bottom_center) >= threshold + or len(self.path_data) == 1 + ): + # check Euclidean distance before appending + self.path_data.append((bottom_center, obj_data["frame_time"])) + path_update = True + logger.debug( + f"Point tracking: {obj_data['id']}, {bottom_center}, {obj_data['frame_time']}" + ) + self.obj_data.update(obj_data) self.current_zones = current_zones logger.debug( @@ -355,7 +376,7 @@ class TrackedObject: ) return (thumb_update, significant_change, path_update, autotracker_update) - def to_dict(self): + def to_dict(self) -> dict[str, Any]: event = { "id": self.obj_data["id"], "camera": self.camera_config.name, @@ -397,10 +418,8 @@ class TrackedObject: return not self.is_stationary() def is_stationary(self) -> bool: - return ( - self.obj_data["motionless_count"] - > self.camera_config.detect.stationary.threshold - ) + count = cast(int | float, self.obj_data["motionless_count"]) + return count > (self.camera_config.detect.stationary.threshold or 50) def get_thumbnail(self, ext: str) -> bytes | None: img_bytes = self.get_img_bytes( @@ -437,9 +456,9 @@ class TrackedObject: def get_img_bytes( self, ext: str, - timestamp=False, - bounding_box=False, - crop=False, + timestamp: bool = False, + bounding_box: bool = False, + crop: bool = False, height: int | None = None, quality: int | None = None, ) -> bytes | None: @@ -516,18 +535,18 @@ class TrackedObject: best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA ) if timestamp: - color = self.camera_config.timestamp_style.color + colors = self.camera_config.timestamp_style.color draw_timestamp( best_frame, self.thumbnail_data["frame_time"], self.camera_config.timestamp_style.format, font_effect=self.camera_config.timestamp_style.effect, font_thickness=self.camera_config.timestamp_style.thickness, - font_color=(color.blue, color.green, color.red), + font_color=(colors.blue, colors.green, colors.red), position=self.camera_config.timestamp_style.position, ) - quality_params = None + quality_params = [] if ext == "jpg": quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality or 70] @@ -580,6 +599,9 @@ class TrackedObject: p.write(png_bytes) def write_thumbnail_to_disk(self) -> None: + if not self.camera_config.name: + return + directory = os.path.join(THUMB_DIR, self.camera_config.name) if not os.path.exists(directory): @@ -587,11 +609,14 @@ class TrackedObject: thumb_bytes = self.get_thumbnail("webp") - with open(os.path.join(directory, f"{self.obj_data['id']}.webp"), "wb") as f: - f.write(thumb_bytes) + if thumb_bytes: + with open( + os.path.join(directory, f"{self.obj_data['id']}.webp"), "wb" + ) as f: + f.write(thumb_bytes) -def zone_filtered(obj: TrackedObject, object_config): +def zone_filtered(obj: TrackedObject, object_config: dict[str, FilterConfig]) -> bool: object_name = obj.obj_data["label"] if object_name in object_config: @@ -641,9 +666,9 @@ class TrackedObjectAttribute: def find_best_object(self, objects: list[dict[str, Any]]) -> Optional[str]: """Find the best attribute for each object and return its ID.""" - best_object_area = None - best_object_id = None - best_object_label = None + best_object_area: float | None = None + best_object_id: str | None = None + best_object_label: str | None = None for obj in objects: if not box_inside(obj["box"], self.box): diff --git a/frigate/types.py b/frigate/types.py index ee48cc02b8..a9e27ba90e 100644 --- a/frigate/types.py +++ b/frigate/types.py @@ -21,6 +21,8 @@ class ModelStatusTypesEnum(str, Enum): downloading = "downloading" downloaded = "downloaded" error = "error" + training = "training" + complete = "complete" class TrackedObjectUpdateTypesEnum(str, Enum): diff --git a/frigate/util/__init__.py b/frigate/util/__init__.py index 307bf4f8b6..e69de29bb2 100644 --- a/frigate/util/__init__.py +++ b/frigate/util/__init__.py @@ -1,3 +0,0 @@ -from .process import Process - -__all__ = ["Process"] diff --git a/frigate/util/audio.py b/frigate/util/audio.py new file mode 100644 index 0000000000..eede9c0ea0 --- /dev/null +++ b/frigate/util/audio.py @@ -0,0 +1,116 @@ +"""Utilities for creating and manipulating audio.""" + +import logging +import os +import subprocess as sp +from typing import Optional + +from pathvalidate import sanitize_filename + +from frigate.const import CACHE_DIR +from frigate.models import Recordings + +logger = logging.getLogger(__name__) + + +def get_audio_from_recording( + ffmpeg, + camera_name: str, + start_ts: float, + end_ts: float, + sample_rate: int = 16000, +) -> Optional[bytes]: + """Extract audio from recording files between start_ts and end_ts in WAV format suitable for sherpa-onnx. + + Args: + ffmpeg: FFmpeg configuration object + camera_name: Name of the camera + start_ts: Start timestamp in seconds + end_ts: End timestamp in seconds + sample_rate: Sample rate for output audio (default 16kHz for sherpa-onnx) + + Returns: + Bytes of WAV audio data or None if extraction failed + """ + # Fetch all relevant recording segments + recordings = ( + Recordings.select( + Recordings.path, + Recordings.start_time, + Recordings.end_time, + ) + .where( + (Recordings.start_time.between(start_ts, end_ts)) + | (Recordings.end_time.between(start_ts, end_ts)) + | ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time)) + ) + .where(Recordings.camera == camera_name) + .order_by(Recordings.start_time.asc()) + ) + + if not recordings: + logger.debug( + f"No recordings found for {camera_name} between {start_ts} and {end_ts}" + ) + return None + + # Generate concat playlist file + file_name = sanitize_filename( + f"audio_playlist_{camera_name}_{start_ts}-{end_ts}.txt" + ) + file_path = os.path.join(CACHE_DIR, file_name) + try: + with open(file_path, "w") as file: + for clip in recordings: + file.write(f"file '{clip.path}'\n") + if clip.start_time < start_ts: + file.write(f"inpoint {int(start_ts - clip.start_time)}\n") + if clip.end_time > end_ts: + file.write(f"outpoint {int(end_ts - clip.start_time)}\n") + + ffmpeg_cmd = [ + ffmpeg.ffmpeg_path, + "-hide_banner", + "-loglevel", + "warning", + "-protocol_whitelist", + "pipe,file", + "-f", + "concat", + "-safe", + "0", + "-i", + file_path, + "-vn", # No video + "-acodec", + "pcm_s16le", # 16-bit PCM encoding + "-ar", + str(sample_rate), + "-ac", + "1", # Mono audio + "-f", + "wav", + "-", + ] + + process = sp.run( + ffmpeg_cmd, + capture_output=True, + ) + + if process.returncode == 0: + logger.debug( + f"Successfully extracted audio for {camera_name} from {start_ts} to {end_ts}" + ) + return process.stdout + else: + logger.error(f"Failed to extract audio: {process.stderr.decode()}") + return None + except Exception as e: + logger.error(f"Error extracting audio from recordings: {e}") + return None + finally: + try: + os.unlink(file_path) + except OSError: + pass diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 52280ecd8f..5ab29a6ea6 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -5,7 +5,7 @@ import copy import datetime import logging import math -import multiprocessing as mp +import multiprocessing.queues import queue import re import shlex @@ -14,7 +14,7 @@ import urllib.parse from collections.abc import Mapping from multiprocessing.sharedctypes import Synchronized from pathlib import Path -from typing import Any, Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union from zoneinfo import ZoneInfoNotFoundError import numpy as np @@ -184,25 +184,12 @@ def create_mask(frame_shape, mask): mask_img[:] = 255 -def update_yaml_from_url(file_path, url): - parsed_url = urllib.parse.urlparse(url) - query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True) - - # Filter out empty keys but keep blank values for non-empty keys - query_string = {k: v for k, v in query_string.items() if k} - +def process_config_query_string(query_string: Dict[str, list]) -> Dict[str, Any]: + updates = {} for key_path_str, new_value_list in query_string.items(): - key_path = key_path_str.split(".") - for i in range(len(key_path)): - try: - index = int(key_path[i]) - key_path[i] = (key_path[i - 1], index) - key_path.pop(i - 1) - except ValueError: - pass - + # use the string key as-is for updates dictionary if len(new_value_list) > 1: - update_yaml_file(file_path, key_path, new_value_list) + updates[key_path_str] = new_value_list else: value = new_value_list[0] try: @@ -210,10 +197,24 @@ def update_yaml_from_url(file_path, url): value = ast.literal_eval(value) if "," not in value else value except (ValueError, SyntaxError): pass - update_yaml_file(file_path, key_path, value) + updates[key_path_str] = value + return updates -def update_yaml_file(file_path, key_path, new_value): +def flatten_config_data( + config_data: Dict[str, Any], parent_key: str = "" +) -> Dict[str, Any]: + items = [] + for key, value in config_data.items(): + new_key = f"{parent_key}.{key}" if parent_key else key + if isinstance(value, dict): + items.extend(flatten_config_data(value, new_key).items()) + else: + items.append((new_key, value)) + return dict(items) + + +def update_yaml_file_bulk(file_path: str, updates: Dict[str, Any]): yaml = YAML() yaml.indent(mapping=2, sequence=4, offset=2) @@ -226,7 +227,17 @@ def update_yaml_file(file_path, key_path, new_value): ) return - data = update_yaml(data, key_path, new_value) + # Apply all updates + for key_path_str, new_value in updates.items(): + key_path = key_path_str.split(".") + for i in range(len(key_path)): + try: + index = int(key_path[i]) + key_path[i] = (key_path[i - 1], index) + key_path.pop(i - 1) + except ValueError: + pass + data = update_yaml(data, key_path, new_value) try: with open(file_path, "w") as f: @@ -327,14 +338,24 @@ def clear_and_unlink(file: Path, missing_ok: bool = True) -> None: file.unlink(missing_ok=missing_ok) -def empty_and_close_queue(q: mp.Queue): +def empty_and_close_queue(q): while True: try: q.get(block=True, timeout=0.5) - except queue.Empty: + except (queue.Empty, EOFError): + break + except Exception as e: + logger.debug(f"Error while emptying queue: {e}") + break + + # close the queue if it is a multiprocessing queue + # manager proxy queues do not have close or join_thread method + if isinstance(q, multiprocessing.queues.Queue): + try: q.close() q.join_thread() - return + except Exception: + pass def generate_color_palette(n): @@ -407,3 +428,19 @@ def sanitize_float(value): if isinstance(value, (int, float)) and not math.isfinite(value): return 0.0 return value + + +def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: + return 1 - cosine_distance(a, b) + + +def cosine_distance(a: np.ndarray, b: np.ndarray) -> float: + """Returns cosine distance to match sqlite-vec's calculation.""" + dot = np.dot(a, b) + a_mag = np.dot(a, a) # ||a||^2 + b_mag = np.dot(b, b) # ||b||^2 + + if a_mag == 0 or b_mag == 0: + return 1.0 + + return 1.0 - (dot / (np.sqrt(a_mag) * np.sqrt(b_mag))) diff --git a/frigate/util/classification.py b/frigate/util/classification.py new file mode 100644 index 0000000000..e4133ded45 --- /dev/null +++ b/frigate/util/classification.py @@ -0,0 +1,174 @@ +"""Util for classification models.""" + +import logging +import os + +import cv2 +import numpy as np + +from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsRequestor +from frigate.comms.inter_process import InterProcessRequestor +from frigate.const import ( + CLIPS_DIR, + MODEL_CACHE_DIR, + PROCESS_PRIORITY_LOW, + UPDATE_MODEL_STATE, +) +from frigate.log import redirect_output_to_logger +from frigate.types import ModelStatusTypesEnum +from frigate.util.process import FrigateProcess + +BATCH_SIZE = 16 +EPOCHS = 50 +LEARNING_RATE = 0.001 + +logger = logging.getLogger(__name__) + + +class ClassificationTrainingProcess(FrigateProcess): + def __init__(self, model_name: str) -> None: + super().__init__( + stop_event=None, + priority=PROCESS_PRIORITY_LOW, + name=f"model_training:{model_name}", + ) + self.model_name = model_name + + def run(self) -> None: + self.pre_run_setup() + self.__train_classification_model() + + def __generate_representative_dataset_factory(self, dataset_dir: str): + def generate_representative_dataset(): + image_paths = [] + for root, dirs, files in os.walk(dataset_dir): + for file in files: + if file.lower().endswith((".jpg", ".jpeg", ".png")): + image_paths.append(os.path.join(root, file)) + + for path in image_paths[:300]: + img = cv2.imread(path) + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + img = cv2.resize(img, (224, 224)) + img_array = np.array(img, dtype=np.float32) / 255.0 + img_array = img_array[None, ...] + yield [img_array] + + return generate_representative_dataset + + @redirect_output_to_logger(logger, logging.DEBUG) + def __train_classification_model(self) -> bool: + """Train a classification model.""" + + # import in the function so that tensorflow is not initialized multiple times + import tensorflow as tf + from tensorflow.keras import layers, models, optimizers + from tensorflow.keras.applications import MobileNetV2 + from tensorflow.keras.preprocessing.image import ImageDataGenerator + + logger.info(f"Kicking off classification training for {self.model_name}.") + dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset") + model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name) + num_classes = len( + [ + d + for d in os.listdir(dataset_dir) + if os.path.isdir(os.path.join(dataset_dir, d)) + ] + ) + + # Start with imagenet base model with 35% of channels in each layer + base_model = MobileNetV2( + input_shape=(224, 224, 3), + include_top=False, + weights="imagenet", + alpha=0.35, + ) + base_model.trainable = False # Freeze pre-trained layers + + model = models.Sequential( + [ + base_model, + layers.GlobalAveragePooling2D(), + layers.Dense(128, activation="relu"), + layers.Dropout(0.3), + layers.Dense(num_classes, activation="softmax"), + ] + ) + + model.compile( + optimizer=optimizers.Adam(learning_rate=LEARNING_RATE), + loss="categorical_crossentropy", + metrics=["accuracy"], + ) + + # create training set + datagen = ImageDataGenerator(rescale=1.0 / 255, validation_split=0.2) + train_gen = datagen.flow_from_directory( + dataset_dir, + target_size=(224, 224), + batch_size=BATCH_SIZE, + class_mode="categorical", + subset="training", + ) + + # write labelmap + class_indices = train_gen.class_indices + index_to_class = {v: k for k, v in class_indices.items()} + sorted_classes = [index_to_class[i] for i in range(len(index_to_class))] + with open(os.path.join(model_dir, "labelmap.txt"), "w") as f: + for class_name in sorted_classes: + f.write(f"{class_name}\n") + + # train the model + model.fit(train_gen, epochs=EPOCHS, verbose=0) + + # convert model to tflite + converter = tf.lite.TFLiteConverter.from_keras_model(model) + converter.optimizations = [tf.lite.Optimize.DEFAULT] + converter.representative_dataset = ( + self.__generate_representative_dataset_factory(dataset_dir) + ) + converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] + converter.inference_input_type = tf.uint8 + converter.inference_output_type = tf.uint8 + tflite_model = converter.convert() + + # write model + with open(os.path.join(model_dir, "model.tflite"), "wb") as f: + f.write(tflite_model) + + +@staticmethod +def kickoff_model_training( + embeddingRequestor: EmbeddingsRequestor, model_name: str +) -> None: + requestor = InterProcessRequestor() + requestor.send_data( + UPDATE_MODEL_STATE, + { + "model": model_name, + "state": ModelStatusTypesEnum.training, + }, + ) + + # run training in sub process so that + # tensorflow will free CPU / GPU memory + # upon training completion + training_process = ClassificationTrainingProcess(model_name) + training_process.start() + training_process.join() + + # reload model and mark training as complete + embeddingRequestor.send_data( + EmbeddingsRequestEnum.reload_classification_model.value, + {"model_name": model_name}, + ) + requestor.send_data( + UPDATE_MODEL_STATE, + { + "model": model_name, + "state": ModelStatusTypesEnum.complete, + }, + ) + requestor.stop() diff --git a/frigate/util/config.py b/frigate/util/config.py index 70492adbc8..56f5662fc2 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -13,7 +13,7 @@ from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) -CURRENT_CONFIG_VERSION = "0.16-0" +CURRENT_CONFIG_VERSION = "0.17-0" DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml") @@ -91,6 +91,13 @@ def migrate_frigate_config(config_file: str): yaml.dump(new_config, f) previous_version = "0.16-0" + if previous_version < "0.17-0": + logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...") + new_config = migrate_017_0(config) + with open(config_file, "w") as f: + yaml.dump(new_config, f) + previous_version = "0.17-0" + logger.info("Finished frigate config migration...") @@ -340,6 +347,84 @@ def migrate_016_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] return new_config +def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Handle migrating frigate config to 0.16-0""" + new_config = config.copy() + + # migrate global to new recording configuration + global_record_retain = config.get("record", {}).get("retain") + + if global_record_retain: + continuous = {"days": 0} + motion = {"days": 0} + days = global_record_retain.get("days") + mode = global_record_retain.get("mode", "all") + + if days: + if mode == "all": + continuous["days"] = days + + # if a user was keeping all for number of days + # we need to keep motion and all for that number of days + motion["days"] = days + else: + motion["days"] = days + + new_config["record"]["continuous"] = continuous + new_config["record"]["motion"] = motion + + del new_config["record"]["retain"] + + # migrate global genai to new objects config + global_genai = config.get("genai", {}) + + if global_genai: + new_genai_config = {} + new_object_config = config.get("objects", {}) + new_object_config["genai"] = {} + + for key in global_genai.keys(): + if key not in ["enabled", "model", "provider", "base_url", "api_key"]: + new_object_config["genai"][key] = global_genai[key] + else: + new_genai_config[key] = global_genai[key] + + config["genai"] = new_genai_config + + for name, camera in config.get("cameras", {}).items(): + camera_config: dict[str, dict[str, Any]] = camera.copy() + camera_record_retain = camera_config.get("record", {}).get("retain") + + if camera_record_retain: + continuous = {"days": 0} + motion = {"days": 0} + days = camera_record_retain.get("days") + mode = camera_record_retain.get("mode", "all") + + if days: + if mode == "all": + continuous["days"] = days + else: + motion["days"] = days + + camera_config["record"]["continuous"] = continuous + camera_config["record"]["motion"] = motion + + del camera_config["record"]["retain"] + + camera_genai = camera_config.get("genai", {}) + + if camera_genai: + new_object_config = config.get("objects", {}) + new_object_config["genai"] = camera_genai + del camera_config["genai"] + + new_config["cameras"][name] = camera_config + + new_config["version"] = "0.17-0" + return new_config + + def get_relative_coordinates( mask: Optional[Union[str, list]], frame_shape: tuple[int, int] ) -> Union[str, list]: diff --git a/frigate/util/image.py b/frigate/util/image.py index 58afe8b365..3406c092f5 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -66,7 +66,12 @@ def has_better_attr(current_thumb, new_obj, attr_label) -> bool: return max_new_attr > max_current_attr -def is_better_thumbnail(label, current_thumb, new_obj, frame_shape) -> bool: +def is_better_thumbnail( + label: str, + current_thumb: dict[str, Any], + new_obj: dict[str, Any], + frame_shape: tuple[int, int], +) -> bool: # larger is better # cutoff images are less ideal, but they should also be smaller? # better scores are obviously better too diff --git a/frigate/util/model.py b/frigate/util/model.py index 65f9b60323..45a3a6c8bd 100644 --- a/frigate/util/model.py +++ b/frigate/util/model.py @@ -284,7 +284,7 @@ def post_process_yolox( def get_ort_providers( - force_cpu: bool = False, device: str = "AUTO", requires_fp16: bool = False + force_cpu: bool = False, device: str | None = "AUTO", requires_fp16: bool = False ) -> tuple[list[str], list[dict[str, Any]]]: if force_cpu: return ( @@ -301,11 +301,12 @@ def get_ort_providers( for provider in ort.get_available_providers(): if provider == "CUDAExecutionProvider": - device_id = 0 if not device.isdigit() else int(device) + device_id = 0 if (not device or not device.isdigit()) else int(device) providers.append(provider) options.append( { "arena_extend_strategy": "kSameAsRequested", + "use_ep_level_unified_stream": True, "device_id": device_id, } ) @@ -337,14 +338,18 @@ def get_ort_providers( else: continue elif provider == "OpenVINOExecutionProvider": - os.makedirs(os.path.join(MODEL_CACHE_DIR, "openvino/ort"), exist_ok=True) - providers.append(provider) - options.append( - { - "cache_dir": os.path.join(MODEL_CACHE_DIR, "openvino/ort"), - "device_type": device, - } - ) + # OpenVINO is used directly + if device == "OpenVINO": + os.makedirs( + os.path.join(MODEL_CACHE_DIR, "openvino/ort"), exist_ok=True + ) + providers.append(provider) + options.append( + { + "cache_dir": os.path.join(MODEL_CACHE_DIR, "openvino/ort"), + "device_type": device, + } + ) elif provider == "MIGraphXExecutionProvider": # MIGraphX uses more CPU than ROCM, while also being the same speed if device == "MIGraphX": diff --git a/frigate/util/object.py b/frigate/util/object.py index d9a8c2f71e..905745da62 100644 --- a/frigate/util/object.py +++ b/frigate/util/object.py @@ -269,7 +269,20 @@ def is_object_filtered(obj, objects_to_track, object_filters): def get_min_region_size(model_config: ModelConfig) -> int: """Get the min region size.""" - return max(model_config.height, model_config.width) + largest_dimension = max(model_config.height, model_config.width) + + if largest_dimension > 320: + # We originally tested allowing any model to have a region down to half of the model size + # but this led to many false positives. In this case we specifically target larger models + # which can benefit from a smaller region in some cases to detect smaller objects. + half = int(largest_dimension / 2) + + if half % 4 == 0: + return half + + return int((half + 3) / 4) * 4 + + return largest_dimension def create_tensor_input(frame, model_config: ModelConfig, region): diff --git a/frigate/util/process.py b/frigate/util/process.py index ac15539fe7..b9fede44ef 100644 --- a/frigate/util/process.py +++ b/frigate/util/process.py @@ -1,19 +1,23 @@ import faulthandler import logging import multiprocessing as mp -import signal -import sys +import os import threading -from functools import wraps from logging.handlers import QueueHandler -from typing import Any, Callable, Optional +from multiprocessing.synchronize import Event as MpEvent +from typing import Callable, Optional + +from setproctitle import setproctitle import frigate.log +from frigate.config.logger import LoggerConfig class BaseProcess(mp.Process): def __init__( self, + stop_event: MpEvent, + priority: int, *, name: Optional[str] = None, target: Optional[Callable] = None, @@ -21,6 +25,8 @@ class BaseProcess(mp.Process): kwargs: dict = {}, daemon: Optional[bool] = None, ): + self.priority = priority + self.stop_event = stop_event super().__init__( name=name, target=target, args=args, kwargs=kwargs, daemon=daemon ) @@ -30,66 +36,31 @@ class BaseProcess(mp.Process): super().start(*args, **kwargs) self.after_start() - def __getattribute__(self, name: str) -> Any: - if name == "run": - run = super().__getattribute__("run") - - @wraps(run) - def run_wrapper(*args, **kwargs): - try: - self.before_run() - return run(*args, **kwargs) - finally: - self.after_run() - - return run_wrapper - - return super().__getattribute__(name) - def before_start(self) -> None: pass def after_start(self) -> None: pass - def before_run(self) -> None: - pass - def after_run(self) -> None: - pass - - -class Process(BaseProcess): +class FrigateProcess(BaseProcess): logger: logging.Logger - @property - def stop_event(self) -> threading.Event: - # Lazily create the stop_event. This allows the signal handler to tell if anyone is - # monitoring the stop event, and to raise a SystemExit if not. - if "stop_event" not in self.__dict__: - self.__dict__["stop_event"] = threading.Event() - return self.__dict__["stop_event"] - def before_start(self) -> None: self.__log_queue = frigate.log.log_listener.queue - def before_run(self) -> None: + def pre_run_setup(self, logConfig: LoggerConfig | None = None) -> None: + os.nice(self.priority) + setproctitle(self.name) + threading.current_thread().name = f"process:{self.name}" faulthandler.enable() - def receiveSignal(signalNumber, frame): - # Get the stop_event through the dict to bypass lazy initialization. - stop_event = self.__dict__.get("stop_event") - if stop_event is not None: - # Someone is monitoring stop_event. We should set it. - stop_event.set() - else: - # Nobody is monitoring stop_event. We should raise SystemExit. - sys.exit() - - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) - + # setup logging self.logger = logging.getLogger(self.name) - logging.basicConfig(handlers=[], force=True) logging.getLogger().addHandler(QueueHandler(self.__log_queue)) + + if logConfig: + frigate.log.apply_log_levels( + logConfig.default.value.upper(), logConfig.logs + ) diff --git a/frigate/util/rknn_converter.py b/frigate/util/rknn_converter.py new file mode 100644 index 0000000000..fb292b84cb --- /dev/null +++ b/frigate/util/rknn_converter.py @@ -0,0 +1,470 @@ +"""RKNN model conversion utility for Frigate.""" + +import fcntl +import logging +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +MODEL_TYPE_CONFIGS = { + "yolo-generic": { + "mean_values": [[0, 0, 0]], + "std_values": [[255, 255, 255]], + "target_platform": None, # Will be set dynamically + }, + "yolonas": { + "mean_values": [[0, 0, 0]], + "std_values": [[255, 255, 255]], + "target_platform": None, # Will be set dynamically + }, + "yolox": { + "mean_values": [[0, 0, 0]], + "std_values": [[255, 255, 255]], + "target_platform": None, # Will be set dynamically + }, + "jina-clip-v1-vision": { + "mean_values": [[0.48145466 * 255, 0.4578275 * 255, 0.40821073 * 255]], + "std_values": [[0.26862954 * 255, 0.26130258 * 255, 0.27577711 * 255]], + "target_platform": None, # Will be set dynamically + }, + "arcface-r100": { + "mean_values": [[127.5, 127.5, 127.5]], + "std_values": [[127.5, 127.5, 127.5]], + "target_platform": None, # Will be set dynamically + }, +} + + +def get_rknn_model_type(model_path: str) -> str | None: + if all(keyword in str(model_path) for keyword in ["jina-clip-v1", "vision"]): + return "jina-clip-v1-vision" + + model_name = os.path.basename(str(model_path)).lower() + + if "arcface" in model_name: + return "arcface-r100" + + if any(keyword in model_name for keyword in ["yolo", "yolox", "yolonas"]): + return model_name + + return None + + +def is_rknn_compatible(model_path: str, model_type: str | None = None) -> bool: + """ + Check if a model is compatible with RKNN conversion. + + Args: + model_path: Path to the model file + model_type: Type of the model (if known) + + Returns: + True if the model is RKNN-compatible, False otherwise + """ + soc = get_soc_type() + if soc is None: + return False + + if not model_type: + model_type = get_rknn_model_type(model_path) + + if model_type and model_type in MODEL_TYPE_CONFIGS: + return True + + return False + + +def ensure_torch_dependencies() -> bool: + """Dynamically install torch dependencies if not available.""" + try: + import torch # type: ignore + + logger.debug("PyTorch is already available") + return True + except ImportError: + logger.info("PyTorch not found, attempting to install...") + + try: + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "--break-system-packages", + "torch", + "torchvision", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + import torch # type: ignore # noqa: F401 + + logger.info("PyTorch installed successfully") + return True + except (subprocess.CalledProcessError, ImportError) as e: + logger.error(f"Failed to install PyTorch: {e}") + return False + + +def ensure_rknn_toolkit() -> bool: + """Ensure RKNN toolkit is available.""" + try: + from rknn.api import RKNN # type: ignore # noqa: F401 + + logger.debug("RKNN toolkit is already available") + return True + except ImportError as e: + logger.error(f"RKNN toolkit not found. Please ensure it's installed. {e}") + return False + + +def get_soc_type() -> Optional[str]: + """Get the SoC type from device tree.""" + try: + with open("/proc/device-tree/compatible") as file: + soc = file.read().split(",")[-1].strip("\x00") + return soc + except FileNotFoundError: + logger.debug("Could not determine SoC type from device tree") + return None + + +def convert_onnx_to_rknn( + onnx_path: str, + output_path: str, + model_type: str, + quantization: bool = False, + soc: Optional[str] = None, +) -> bool: + """ + Convert ONNX model to RKNN format. + + Args: + onnx_path: Path to input ONNX model + output_path: Path for output RKNN model + model_type: Type of model (yolo-generic, yolonas, yolox, ssd) + quantization: Whether to use 8-bit quantization (i8) or 16-bit float (fp16) + soc: Target SoC platform (auto-detected if None) + + Returns: + True if conversion successful, False otherwise + """ + if not ensure_torch_dependencies(): + logger.debug("PyTorch dependencies not available") + return False + + if not ensure_rknn_toolkit(): + logger.debug("RKNN toolkit not available") + return False + + # Get SoC type if not provided + if soc is None: + soc = get_soc_type() + if soc is None: + logger.debug("Could not determine SoC type") + return False + + # Get model config for the specified type + if model_type not in MODEL_TYPE_CONFIGS: + logger.debug(f"Unsupported model type: {model_type}") + return False + + config = MODEL_TYPE_CONFIGS[model_type].copy() + config["target_platform"] = soc + + try: + from rknn.api import RKNN # type: ignore + + logger.info(f"Converting {onnx_path} to RKNN format for {soc}") + rknn = RKNN(verbose=True) + rknn.config(**config) + + if model_type == "jina-clip-v1-vision": + load_output = rknn.load_onnx( + model=onnx_path, + inputs=["pixel_values"], + input_size_list=[[1, 3, 224, 224]], + ) + elif model_type == "arcface-r100": + load_output = rknn.load_onnx( + model=onnx_path, + inputs=["data"], + input_size_list=[[1, 3, 112, 112]], + ) + else: + load_output = rknn.load_onnx(model=onnx_path) + + if load_output != 0: + logger.error("Failed to load ONNX model") + return False + + if rknn.build(do_quantization=quantization) != 0: + logger.error("Failed to build RKNN model") + return False + + if rknn.export_rknn(output_path) != 0: + logger.error("Failed to export RKNN model") + return False + + logger.info(f"Successfully converted model to {output_path}") + return True + + except Exception as e: + logger.error(f"Error during RKNN conversion: {e}") + return False + + +def cleanup_stale_lock(lock_file_path: Path) -> bool: + """ + Clean up a stale lock file if it exists and is old. + + Args: + lock_file_path: Path to the lock file + + Returns: + True if lock was cleaned up, False otherwise + """ + try: + if lock_file_path.exists(): + # Check if lock file is older than 10 minutes (stale) + lock_age = time.time() - lock_file_path.stat().st_mtime + if lock_age > 600: # 10 minutes + logger.warning( + f"Removing stale lock file: {lock_file_path} (age: {lock_age:.1f}s)" + ) + lock_file_path.unlink() + return True + except Exception as e: + logger.error(f"Error cleaning up stale lock: {e}") + + return False + + +def acquire_conversion_lock(lock_file_path: Path, timeout: int = 300) -> bool: + """ + Acquire a file-based lock for model conversion. + + Args: + lock_file_path: Path to the lock file + timeout: Maximum time to wait for lock in seconds + + Returns: + True if lock acquired, False if timeout or error + """ + try: + lock_file_path.parent.mkdir(parents=True, exist_ok=True) + cleanup_stale_lock(lock_file_path) + lock_fd = os.open(lock_file_path, os.O_CREAT | os.O_RDWR) + + # Try to acquire exclusive lock + start_time = time.time() + while time.time() - start_time < timeout: + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + # Lock acquired successfully + logger.debug(f"Acquired conversion lock: {lock_file_path}") + return True + except (OSError, IOError): + # Lock is held by another process, wait and retry + if time.time() - start_time >= timeout: + logger.warning( + f"Timeout waiting for conversion lock: {lock_file_path}" + ) + os.close(lock_fd) + return False + + logger.debug("Waiting for conversion lock to be released...") + time.sleep(1) + + os.close(lock_fd) + return False + + except Exception as e: + logger.error(f"Error acquiring conversion lock: {e}") + return False + + +def release_conversion_lock(lock_file_path: Path) -> None: + """ + Release the conversion lock. + + Args: + lock_file_path: Path to the lock file + """ + try: + if lock_file_path.exists(): + lock_file_path.unlink() + logger.debug(f"Released conversion lock: {lock_file_path}") + except Exception as e: + logger.error(f"Error releasing conversion lock: {e}") + + +def is_lock_stale(lock_file_path: Path, max_age: int = 600) -> bool: + """ + Check if a lock file is stale (older than max_age seconds). + + Args: + lock_file_path: Path to the lock file + max_age: Maximum age in seconds before considering lock stale + + Returns: + True if lock is stale, False otherwise + """ + try: + if lock_file_path.exists(): + lock_age = time.time() - lock_file_path.stat().st_mtime + return lock_age > max_age + except Exception: + pass + + return False + + +def wait_for_conversion_completion( + model_type: str, rknn_path: Path, lock_file_path: Path, timeout: int = 300 +) -> bool: + """ + Wait for another process to complete the conversion. + + Args: + rknn_path: Path to the expected RKNN model + lock_file_path: Path to the lock file to monitor + timeout: Maximum time to wait in seconds + + Returns: + True if RKNN model appears, False if timeout + """ + start_time = time.time() + while time.time() - start_time < timeout: + # Check if RKNN model appeared + if rknn_path.exists(): + logger.info(f"RKNN model appeared: {rknn_path}") + return True + + # Check if lock file is gone (conversion completed or failed) + if not lock_file_path.exists(): + logger.info("Lock file removed, checking for RKNN model...") + if rknn_path.exists(): + logger.info(f"RKNN model found after lock removal: {rknn_path}") + return True + else: + logger.warning( + "Lock file removed but RKNN model not found, conversion may have failed" + ) + return False + + # Check if lock is stale + if is_lock_stale(lock_file_path): + logger.warning("Lock file is stale, attempting to clean up and retry...") + cleanup_stale_lock(lock_file_path) + # Try to acquire lock again + if acquire_conversion_lock(lock_file_path, timeout=60): + try: + # Check if RKNN file appeared while waiting + if rknn_path.exists(): + logger.info(f"RKNN model appeared while waiting: {rknn_path}") + return True + + # Convert ONNX to RKNN + logger.info( + f"Retrying conversion of {rknn_path} after stale lock cleanup..." + ) + + # Get the original model path from rknn_path + base_path = rknn_path.parent / rknn_path.stem + onnx_path = base_path.with_suffix(".onnx") + + if onnx_path.exists(): + if convert_onnx_to_rknn( + str(onnx_path), str(rknn_path), model_type, False + ): + return True + + logger.error("Failed to convert model after stale lock cleanup") + return False + + finally: + release_conversion_lock(lock_file_path) + + logger.debug("Waiting for RKNN model to appear...") + time.sleep(1) + + logger.warning(f"Timeout waiting for RKNN model: {rknn_path}") + return False + + +def auto_convert_model( + model_path: str, model_type: str | None = None, quantization: bool = False +) -> Optional[str]: + """ + Automatically convert a model to RKNN format if needed. + + Args: + model_path: Path to the model file + model_type: Type of the model + quantization: Whether to use quantization + + Returns: + Path to the RKNN model if successful, None otherwise + """ + if model_path.endswith(".rknn"): + return model_path + + # Check if equivalent .rknn file exists + base_path = Path(model_path) + if base_path.suffix.lower() in [".onnx", ""]: + base_name = base_path.stem if base_path.suffix else base_path.name + rknn_path = base_path.parent / f"{base_name}.rknn" + + if rknn_path.exists(): + logger.info(f"Found existing RKNN model: {rknn_path}") + return str(rknn_path) + + lock_file_path = base_path.parent / f"{base_name}.conversion.lock" + + if acquire_conversion_lock(lock_file_path): + try: + if rknn_path.exists(): + logger.info( + f"RKNN model appeared while waiting for lock: {rknn_path}" + ) + return str(rknn_path) + + logger.info(f"Converting {model_path} to RKNN format...") + rknn_path.parent.mkdir(parents=True, exist_ok=True) + + if not model_type: + model_type = get_rknn_model_type(base_path) + + if convert_onnx_to_rknn( + str(base_path), str(rknn_path), model_type, quantization + ): + return str(rknn_path) + else: + logger.error(f"Failed to convert {model_path} to RKNN format") + return None + + finally: + release_conversion_lock(lock_file_path) + else: + logger.info( + f"Another process is converting {model_path}, waiting for completion..." + ) + + if not model_type: + model_type = get_rknn_model_type(base_path) + + if wait_for_conversion_completion(model_type, rknn_path, lock_file_path): + return str(rknn_path) + else: + logger.error(f"Timeout waiting for conversion of {model_path}") + return None + + return None diff --git a/frigate/util/services.py b/frigate/util/services.py index b31a7eea33..c5eacf6200 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -6,6 +6,7 @@ import logging import os import re import resource +import shutil import signal import subprocess as sp import traceback @@ -22,6 +23,7 @@ from frigate.const import ( DRIVER_ENV_VAR, FFMPEG_HWACCEL_NVIDIA, FFMPEG_HWACCEL_VAAPI, + SHM_FRAMES_VAR, ) from frigate.util.builtin import clean_camera_user_pass, escape_special_characters @@ -768,3 +770,65 @@ def set_file_limit() -> None: logger.debug( f"File limit set. New soft limit: {new_soft}, Hard limit remains: {current_hard}" ) + + +def get_fs_type(path: str) -> str: + bestMatch = "" + fsType = "" + for part in psutil.disk_partitions(all=True): + if path.startswith(part.mountpoint) and len(bestMatch) < len(part.mountpoint): + fsType = part.fstype + bestMatch = part.mountpoint + return fsType + + +def calculate_shm_requirements(config) -> dict: + try: + storage_stats = shutil.disk_usage("/dev/shm") + except (FileNotFoundError, OSError): + return {} + + total_mb = round(storage_stats.total / pow(2, 20), 1) + used_mb = round(storage_stats.used / pow(2, 20), 1) + free_mb = round(storage_stats.free / pow(2, 20), 1) + + # required for log files + nginx cache + min_req_shm = 40 + 10 + + if config.birdseye.restream: + min_req_shm += 8 + + available_shm = total_mb - min_req_shm + cam_total_frame_size = 0.0 + + for camera in config.cameras.values(): + if camera.enabled_in_config and camera.detect.width and camera.detect.height: + cam_total_frame_size += round( + (camera.detect.width * camera.detect.height * 1.5 + 270480) / 1048576, + 1, + ) + + # leave room for 2 cameras that are added dynamically, if a user wants to add more cameras they may need to increase the SHM size and restart after adding them. + cam_total_frame_size += 2 * round( + (1280 * 720 * 1.5 + 270480) / 1048576, + 1, + ) + + shm_frame_count = min( + int(os.environ.get(SHM_FRAMES_VAR, "50")), + int(available_shm / cam_total_frame_size), + ) + + # minimum required shm recommendation + min_shm = round(min_req_shm + cam_total_frame_size * 20) + + return { + "total": total_mb, + "used": used_mb, + "free": free_mb, + "mount_type": get_fs_type("/dev/shm"), + "available": round(available_shm, 1), + "camera_frame_size": cam_total_frame_size, + "shm_frame_count": shm_frame_count, + "min_shm": min_shm, + } diff --git a/frigate/video.py b/frigate/video.py index f2197ed665..57b620e3ae 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -1,9 +1,7 @@ import datetime import logging -import multiprocessing as mp import os import queue -import signal import subprocess as sp import threading import time @@ -12,16 +10,19 @@ from multiprocessing.synchronize import Event as MpEvent from typing import Any import cv2 -from setproctitle import setproctitle from frigate.camera import CameraMetrics, PTZMetrics -from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.inter_process import InterProcessRequestor from frigate.config import CameraConfig, DetectConfig, ModelConfig from frigate.config.camera.camera import CameraTypeEnum +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import ( CACHE_DIR, CACHE_SEGMENT_FORMAT, + PROCESS_PRIORITY_HIGH, REQUEST_REGION_GRID, ) from frigate.log import LogPipe @@ -50,12 +51,12 @@ from frigate.util.object import ( is_object_filtered, reduce_detections, ) -from frigate.util.services import listen +from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) -def stop_ffmpeg(ffmpeg_process, logger): +def stop_ffmpeg(ffmpeg_process: sp.Popen[Any], logger: logging.Logger): logger.info("Terminating the existing ffmpeg process...") ffmpeg_process.terminate() try: @@ -70,7 +71,7 @@ def stop_ffmpeg(ffmpeg_process, logger): def start_or_restart_ffmpeg( ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None -): +) -> sp.Popen[Any]: if ffmpeg_process is not None: stop_ffmpeg(ffmpeg_process, logger) @@ -95,7 +96,7 @@ def start_or_restart_ffmpeg( def capture_frames( - ffmpeg_process, + ffmpeg_process: sp.Popen[Any], config: CameraConfig, shm_frame_count: int, frame_index: int, @@ -106,21 +107,19 @@ def capture_frames( skipped_fps: Value, current_frame: Value, stop_event: MpEvent, -): +) -> None: frame_size = frame_shape[0] * frame_shape[1] frame_rate = EventsPerSecond() frame_rate.start() skipped_eps = EventsPerSecond() skipped_eps.start() - config_subscriber = ConfigSubscriber(f"config/enabled/{config.name}", True) + config_subscriber = CameraConfigUpdateSubscriber( + None, {config.name: config}, [CameraConfigUpdateEnum.enabled] + ) def get_enabled_state(): """Fetch the latest enabled state from ZMQ.""" - _, config_data = config_subscriber.check_for_update() - - if config_data: - config.enabled = config_data.enabled - + config_subscriber.check_for_updates() return config.enabled while not stop_event.is_set(): @@ -167,7 +166,6 @@ def capture_frames( class CameraWatchdog(threading.Thread): def __init__( self, - camera_name, config: CameraConfig, shm_frame_count: int, frame_queue: Queue, @@ -177,13 +175,12 @@ class CameraWatchdog(threading.Thread): stop_event, ): threading.Thread.__init__(self) - self.logger = logging.getLogger(f"watchdog.{camera_name}") - self.camera_name = camera_name + self.logger = logging.getLogger(f"watchdog.{config.name}") self.config = config self.shm_frame_count = shm_frame_count self.capture_thread = None self.ffmpeg_detect_process = None - self.logpipe = LogPipe(f"ffmpeg.{self.camera_name}.detect") + self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.detect") self.ffmpeg_other_processes: list[dict[str, Any]] = [] self.camera_fps = camera_fps self.skipped_fps = skipped_fps @@ -196,16 +193,15 @@ class CameraWatchdog(threading.Thread): self.stop_event = stop_event self.sleeptime = self.config.ffmpeg.retry_interval - self.config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True) + self.config_subscriber = CameraConfigUpdateSubscriber( + None, {config.name: config}, [CameraConfigUpdateEnum.enabled] + ) + self.requestor = InterProcessRequestor() self.was_enabled = self.config.enabled 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: - self.config.enabled = config_data.enabled - return config_data.enabled - + self.config_subscriber.check_for_updates() return self.config.enabled def reset_capture_thread( @@ -245,11 +241,19 @@ class CameraWatchdog(threading.Thread): enabled = self._update_enabled_state() if enabled != self.was_enabled: if enabled: - self.logger.debug(f"Enabling camera {self.camera_name}") + self.logger.debug(f"Enabling camera {self.config.name}") self.start_all_ffmpeg() else: - self.logger.debug(f"Disabling camera {self.camera_name}") + self.logger.debug(f"Disabling camera {self.config.name}") self.stop_all_ffmpeg() + + # update camera status + self.requestor.send_data( + f"{self.config.name}/status/detect", "disabled" + ) + self.requestor.send_data( + f"{self.config.name}/status/record", "disabled" + ) self.was_enabled = enabled continue @@ -259,29 +263,35 @@ class CameraWatchdog(threading.Thread): now = datetime.datetime.now().timestamp() if not self.capture_thread.is_alive(): + self.requestor.send_data(f"{self.config.name}/status/detect", "offline") self.camera_fps.value = 0 self.logger.error( - f"Ffmpeg process crashed unexpectedly for {self.camera_name}." + f"Ffmpeg process crashed unexpectedly for {self.config.name}." ) self.reset_capture_thread(terminate=False) elif self.camera_fps.value >= (self.config.detect.fps + 10): self.fps_overflow_count += 1 if self.fps_overflow_count == 3: + self.requestor.send_data( + f"{self.config.name}/status/detect", "offline" + ) self.fps_overflow_count = 0 self.camera_fps.value = 0 self.logger.info( - f"{self.camera_name} exceeded fps limit. Exiting ffmpeg..." + f"{self.config.name} exceeded fps limit. Exiting ffmpeg..." ) self.reset_capture_thread(drain_output=False) elif now - self.capture_thread.current_frame.value > 20: + self.requestor.send_data(f"{self.config.name}/status/detect", "offline") self.camera_fps.value = 0 self.logger.info( - f"No frames received from {self.camera_name} in 20 seconds. Exiting ffmpeg..." + f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..." ) self.reset_capture_thread() else: # process is running normally + self.requestor.send_data(f"{self.config.name}/status/detect", "online") self.fps_overflow_count = 0 for p in self.ffmpeg_other_processes: @@ -299,7 +309,7 @@ class CameraWatchdog(threading.Thread): latest_segment_time + datetime.timedelta(seconds=120) ): self.logger.error( - f"No new recording segments were created for {self.camera_name} in the last 120s. restarting the ffmpeg record process..." + f"No new recording segments were created for {self.config.name} in the last 120s. restarting the ffmpeg record process..." ) p["process"] = start_or_restart_ffmpeg( p["cmd"], @@ -307,13 +317,27 @@ class CameraWatchdog(threading.Thread): p["logpipe"], ffmpeg_process=p["process"], ) + + for role in p["roles"]: + self.requestor.send_data( + f"{self.config.name}/status/{role}", "offline" + ) + continue else: + self.requestor.send_data( + f"{self.config.name}/status/record", "online" + ) p["latest_segment_time"] = latest_segment_time if poll is None: continue + for role in p["roles"]: + self.requestor.send_data( + f"{self.config.name}/status/{role}", "offline" + ) + p["logpipe"].dump() p["process"] = start_or_restart_ffmpeg( p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] @@ -331,7 +355,7 @@ class CameraWatchdog(threading.Thread): ffmpeg_cmd, self.logger, self.logpipe, self.frame_size ) self.ffmpeg_pid.value = self.ffmpeg_detect_process.pid - self.capture_thread = CameraCapture( + self.capture_thread = CameraCaptureRunner( self.config, self.shm_frame_count, self.frame_index, @@ -346,13 +370,13 @@ class CameraWatchdog(threading.Thread): def start_all_ffmpeg(self): """Start all ffmpeg processes (detection and others).""" - logger.debug(f"Starting all ffmpeg processes for {self.camera_name}") + logger.debug(f"Starting all ffmpeg processes for {self.config.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']))}" + f"ffmpeg.{self.config.name}.{'_'.join(sorted(c['roles']))}" ) self.ffmpeg_other_processes.append( { @@ -365,12 +389,12 @@ class CameraWatchdog(threading.Thread): def stop_all_ffmpeg(self): """Stop all ffmpeg processes (detection and others).""" - logger.debug(f"Stopping all ffmpeg processes for {self.camera_name}") + logger.debug(f"Stopping all ffmpeg processes for {self.config.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." + f"Capture thread for {self.config.name} did not stop gracefully." ) if self.ffmpeg_detect_process is not None: stop_ffmpeg(self.ffmpeg_detect_process, self.logger) @@ -397,7 +421,7 @@ class CameraWatchdog(threading.Thread): newest_segment_time = latest_segment for file in cache_files: - if self.camera_name in file: + if self.config.name in file: basename = os.path.splitext(file)[0] _, date = basename.rsplit("@", maxsplit=1) segment_time = datetime.datetime.strptime( @@ -409,7 +433,7 @@ class CameraWatchdog(threading.Thread): return newest_segment_time -class CameraCapture(threading.Thread): +class CameraCaptureRunner(threading.Thread): def __init__( self, config: CameraConfig, @@ -453,110 +477,118 @@ class CameraCapture(threading.Thread): ) -def capture_camera( - name, config: CameraConfig, shm_frame_count: int, camera_metrics: CameraMetrics -): - stop_event = mp.Event() +class CameraCapture(FrigateProcess): + def __init__( + self, + config: CameraConfig, + shm_frame_count: int, + camera_metrics: CameraMetrics, + stop_event: MpEvent, + ) -> None: + super().__init__( + stop_event, + PROCESS_PRIORITY_HIGH, + name=f"frigate.capture:{config.name}", + daemon=True, + ) + self.config = config + self.shm_frame_count = shm_frame_count + self.camera_metrics = camera_metrics - def receiveSignal(signalNumber, frame): - stop_event.set() - - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) - - threading.current_thread().name = f"capture:{name}" - setproctitle(f"frigate.capture:{name}") - - camera_watchdog = CameraWatchdog( - name, - config, - shm_frame_count, - camera_metrics.frame_queue, - camera_metrics.camera_fps, - camera_metrics.skipped_fps, - camera_metrics.ffmpeg_pid, - stop_event, - ) - camera_watchdog.start() - camera_watchdog.join() + def run(self) -> None: + self.pre_run_setup() + camera_watchdog = CameraWatchdog( + self.config, + self.shm_frame_count, + self.camera_metrics.frame_queue, + self.camera_metrics.camera_fps, + self.camera_metrics.skipped_fps, + self.camera_metrics.ffmpeg_pid, + self.stop_event, + ) + camera_watchdog.start() + camera_watchdog.join() -def track_camera( - name, - config: CameraConfig, - model_config: ModelConfig, - labelmap: dict[int, str], - detection_queue: Queue, - result_connection: MpEvent, - detected_objects_queue, - camera_metrics: CameraMetrics, - ptz_metrics: PTZMetrics, - region_grid: list[list[dict[str, Any]]], -): - stop_event = mp.Event() - - def receiveSignal(signalNumber, frame): - stop_event.set() - - signal.signal(signal.SIGTERM, receiveSignal) - signal.signal(signal.SIGINT, receiveSignal) - - threading.current_thread().name = f"process:{name}" - setproctitle(f"frigate.process:{name}") - listen() - - frame_queue = camera_metrics.frame_queue - - frame_shape = config.frame_shape - objects_to_track = config.objects.track - object_filters = config.objects.filters - - motion_detector = ImprovedMotionDetector( - frame_shape, - config.motion, - config.detect.fps, - name=config.name, - ptz_metrics=ptz_metrics, - ) - object_detector = RemoteObjectDetector( - name, labelmap, detection_queue, result_connection, model_config, stop_event - ) - - object_tracker = NorfairTracker(config, ptz_metrics) - - frame_manager = SharedMemoryFrameManager() - - # create communication for region grid updates - requestor = InterProcessRequestor() - - process_frames( - name, - requestor, - frame_queue, - frame_shape, - model_config, - config, - config.detect, - frame_manager, - motion_detector, - object_detector, - object_tracker, +class CameraTracker(FrigateProcess): + def __init__( + self, + config: CameraConfig, + model_config: ModelConfig, + labelmap: dict[int, str], + detection_queue: Queue, detected_objects_queue, - camera_metrics, - objects_to_track, - object_filters, - stop_event, - ptz_metrics, - region_grid, - ) + camera_metrics: CameraMetrics, + ptz_metrics: PTZMetrics, + region_grid: list[list[dict[str, Any]]], + stop_event: MpEvent, + ) -> None: + super().__init__( + stop_event, + PROCESS_PRIORITY_HIGH, + name=f"frigate.process:{config.name}", + daemon=True, + ) + self.config = config + self.model_config = model_config + self.labelmap = labelmap + self.detection_queue = detection_queue + self.detected_objects_queue = detected_objects_queue + self.camera_metrics = camera_metrics + self.ptz_metrics = ptz_metrics + self.region_grid = region_grid - # empty the frame queue - logger.info(f"{name}: emptying frame queue") - while not frame_queue.empty(): - (frame_name, _) = frame_queue.get(False) - frame_manager.delete(frame_name) + def run(self) -> None: + self.pre_run_setup() + frame_queue = self.camera_metrics.frame_queue + frame_shape = self.config.frame_shape - logger.info(f"{name}: exiting subprocess") + motion_detector = ImprovedMotionDetector( + frame_shape, + self.config.motion, + self.config.detect.fps, + name=self.config.name, + ptz_metrics=self.ptz_metrics, + ) + object_detector = RemoteObjectDetector( + self.config.name, + self.labelmap, + self.detection_queue, + self.model_config, + self.stop_event, + ) + + object_tracker = NorfairTracker(self.config, self.ptz_metrics) + + frame_manager = SharedMemoryFrameManager() + + # create communication for region grid updates + requestor = InterProcessRequestor() + + process_frames( + requestor, + frame_queue, + frame_shape, + self.model_config, + self.config, + frame_manager, + motion_detector, + object_detector, + object_tracker, + self.detected_objects_queue, + self.camera_metrics, + self.stop_event, + self.ptz_metrics, + self.region_grid, + ) + + # empty the frame queue + logger.info(f"{self.config.name}: emptying frame queue") + while not frame_queue.empty(): + (frame_name, _) = frame_queue.get(False) + frame_manager.delete(frame_name) + + logger.info(f"{self.config.name}: exiting subprocess") def detect( @@ -597,29 +629,33 @@ def detect( def process_frames( - camera_name: str, requestor: InterProcessRequestor, frame_queue: Queue, frame_shape: tuple[int, int], model_config: ModelConfig, camera_config: CameraConfig, - detect_config: DetectConfig, frame_manager: FrameManager, motion_detector: MotionDetector, object_detector: RemoteObjectDetector, object_tracker: ObjectTracker, detected_objects_queue: Queue, camera_metrics: CameraMetrics, - objects_to_track: list[str], - object_filters, stop_event: MpEvent, ptz_metrics: PTZMetrics, region_grid: list[list[dict[str, Any]]], exit_on_empty: bool = False, ): next_region_update = get_tomorrow_at_time(2) - detect_config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True) - enabled_config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True) + config_subscriber = CameraConfigUpdateSubscriber( + None, + {camera_config.name: camera_config}, + [ + CameraConfigUpdateEnum.detect, + CameraConfigUpdateEnum.enabled, + CameraConfigUpdateEnum.motion, + CameraConfigUpdateEnum.objects, + ], + ) fps_tracker = EventsPerSecond() fps_tracker.start() @@ -654,18 +690,23 @@ def process_frames( ] while not stop_event.is_set(): - _, updated_enabled_config = enabled_config_subscriber.check_for_update() + updated_configs = config_subscriber.check_for_updates() - if updated_enabled_config: + if "enabled" in updated_configs: prev_enabled = camera_enabled - camera_enabled = updated_enabled_config.enabled + camera_enabled = camera_config.enabled + + if "motion" in updated_configs: + motion_detector.update_mask() if ( not camera_enabled and prev_enabled != camera_enabled and camera_metrics.frame_queue.empty() ): - logger.debug(f"Camera {camera_name} disabled, clearing tracked objects") + logger.debug( + f"Camera {camera_config.name} disabled, clearing tracked objects" + ) prev_enabled = camera_enabled # Clear norfair's dictionaries @@ -686,17 +727,11 @@ def process_frames( time.sleep(0.1) continue - # check for updated detect config - _, updated_detect_config = detect_config_subscriber.check_for_update() - - if updated_detect_config: - detect_config = updated_detect_config - if ( datetime.datetime.now().astimezone(datetime.timezone.utc) > next_region_update ): - region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_name) + region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name) next_region_update = get_tomorrow_at_time(2) try: @@ -716,7 +751,9 @@ def process_frames( frame = frame_manager.get(frame_name, (frame_shape[0] * 3 // 2, frame_shape[1])) if frame is None: - logger.debug(f"{camera_name}: frame {frame_time} is not in memory store.") + logger.debug( + f"{camera_config.name}: frame {frame_time} is not in memory store." + ) continue # look for motion if enabled @@ -726,14 +763,14 @@ def process_frames( consolidated_detections = [] # if detection is disabled - if not detect_config.enabled: + if not camera_config.detect.enabled: object_tracker.match_and_update(frame_name, frame_time, []) else: # get stationary object ids # check every Nth frame for stationary objects # disappeared objects are not stationary # also check for overlapping motion boxes - if stationary_frame_counter == detect_config.stationary.interval: + if stationary_frame_counter == camera_config.detect.stationary.interval: stationary_frame_counter = 0 stationary_object_ids = [] else: @@ -742,7 +779,8 @@ def process_frames( obj["id"] for obj in object_tracker.tracked_objects.values() # if it has exceeded the stationary threshold - if obj["motionless_count"] >= detect_config.stationary.threshold + if obj["motionless_count"] + >= camera_config.detect.stationary.threshold # and it hasn't disappeared and object_tracker.disappeared[obj["id"]] == 0 # and it doesn't overlap with any current motion boxes when not calibrating @@ -757,7 +795,8 @@ def process_frames( ( # use existing object box for stationary objects obj["estimate"] - if obj["motionless_count"] < detect_config.stationary.threshold + if obj["motionless_count"] + < camera_config.detect.stationary.threshold else obj["box"] ) for obj in object_tracker.tracked_objects.values() @@ -831,13 +870,13 @@ def process_frames( for region in regions: detections.extend( detect( - detect_config, + camera_config.detect, object_detector, frame, model_config, region, - objects_to_track, - object_filters, + camera_config.objects.track, + camera_config.objects.filters, ) ) @@ -953,7 +992,7 @@ def process_frames( ) cv2.imwrite( - f"debug/frames/{camera_name}-{'{:.6f}'.format(frame_time)}.jpg", + f"debug/frames/{camera_config.name}-{'{:.6f}'.format(frame_time)}.jpg", bgr_frame, ) # add to the queue if not full @@ -965,7 +1004,7 @@ def process_frames( camera_metrics.process_fps.value = fps_tracker.eps() detected_objects_queue.put( ( - camera_name, + camera_config.name, frame_name, frame_time, detections, @@ -978,5 +1017,4 @@ def process_frames( motion_detector.stop() requestor.stop() - detect_config_subscriber.stop() - enabled_config_subscriber.stop() + config_subscriber.stop() diff --git a/migrations/031_create_trigger_table.py b/migrations/031_create_trigger_table.py new file mode 100644 index 0000000000..7c8c289cc2 --- /dev/null +++ b/migrations/031_create_trigger_table.py @@ -0,0 +1,50 @@ +"""Peewee migrations -- 031_create_trigger_table.py. + +This migration creates the Trigger table to track semantic search triggers for cameras. + +Some examples (model - class or model_name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.sql( + """ + CREATE TABLE IF NOT EXISTS trigger ( + camera VARCHAR(20) NOT NULL, + name VARCHAR NOT NULL, + type VARCHAR(10) NOT NULL, + model VARCHAR(30) NOT NULL, + data TEXT NOT NULL, + threshold REAL, + embedding BLOB, + triggering_event_id VARCHAR(30), + last_triggered DATETIME, + PRIMARY KEY (camera, name) + ) + """ + ) + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.sql("DROP TABLE IF EXISTS trigger") diff --git a/notebooks/YOLO_NAS_Pretrained_Export.ipynb b/notebooks/YOLO_NAS_Pretrained_Export.ipynb index 4e0439e9e5..e9ee223149 100644 --- a/notebooks/YOLO_NAS_Pretrained_Export.ipynb +++ b/notebooks/YOLO_NAS_Pretrained_Export.ipynb @@ -19,8 +19,8 @@ }, "outputs": [], "source": [ - "! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.11/dist-packages/super_gradients/training/pretrained_models.py\n", - "! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.11/dist-packages/super_gradients/training/utils/checkpoint_utils.py" + "! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.12/dist-packages/super_gradients/training/pretrained_models.py\n", + "! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.12/dist-packages/super_gradients/training/utils/checkpoint_utils.py" ] }, { diff --git a/web/public/locales/ar/audio.json b/web/public/locales/ar/audio.json index 5c6d142638..b72a52c904 100644 --- a/web/public/locales/ar/audio.json +++ b/web/public/locales/ar/audio.json @@ -70,5 +70,9 @@ "clip_clop": "حَوَافِر الخَيْل", "car": "سيارة", "motorcycle": "دراجة نارية", - "bicycle": "دراجة هوائية" + "bicycle": "دراجة هوائية", + "bus": "حافلة", + "train": "قطار", + "boat": "زورق", + "bird": "طائر" } diff --git a/web/public/locales/ar/common.json b/web/public/locales/ar/common.json index 691643630e..92390a7ffe 100644 --- a/web/public/locales/ar/common.json +++ b/web/public/locales/ar/common.json @@ -3,6 +3,18 @@ "untilForTime": "حتى {{time}}", "untilForRestart": "حتى يعاد تشغيل فرايجيت.", "untilRestart": "حتى إعادة التشغيل", - "ago": "منذ {{timeAgo}}" + "ago": "منذ {{timeAgo}}", + "justNow": "في التو", + "today": "اليوم", + "last14": "آخر 14 يومًا", + "last30": "آخر 30 يومًا", + "thisWeek": "هذا الأسبوع", + "lastWeek": "الأسبوع الماضي", + "thisMonth": "هذا الشهر", + "yesterday": "بالأمس", + "last7": "آخر 7 أيام", + "lastMonth": "الشهر المنصرم", + "5minutes": "5 دقائق", + "10minutes": "10 دقائق" } } diff --git a/web/public/locales/ar/components/auth.json b/web/public/locales/ar/components/auth.json index 7ee15b6e25..1c8eabf5fc 100644 --- a/web/public/locales/ar/components/auth.json +++ b/web/public/locales/ar/components/auth.json @@ -4,7 +4,12 @@ "user": "أسم المستخدم", "login": "تسجيل الدخول", "errors": { - "usernameRequired": "اسم المستخدم مطلوب" + "usernameRequired": "اسم المستخدم مطلوب", + "passwordRequired": "كلمة المرور مطلوبة", + "rateLimit": "تجاوز الحد الأقصى للمعدل. حاول مرة أخرى في وقت لاحق.", + "webUnknownError": "خطأ غير معروف. تحقق من سجلات وحدة التحكم.", + "loginFailed": "فشل تسجيل الدخول", + "unknownError": "خطأ غير معروف. تحقق من السجلات." } } } diff --git a/web/public/locales/ar/components/camera.json b/web/public/locales/ar/components/camera.json index daaddbfacd..9bc19f109a 100644 --- a/web/public/locales/ar/components/camera.json +++ b/web/public/locales/ar/components/camera.json @@ -4,7 +4,48 @@ "add": "إضافة مجموعة الكاميرات", "edit": "تعديل مجموعة الكاميرات", "delete": { - "label": "حذف مجموعة الكاميرات" + "label": "حذف مجموعة الكاميرات", + "confirm": { + "title": "تأكيد الحذف", + "desc": "هل أنت متأكد أنك تريد حذف مجموعة الكاميرات {{name}}؟" + } + }, + "name": { + "errorMessage": { + "mustLeastCharacters": "يجب أن يتكون اسم مجموعة الكاميرا من حرفين على الأقل.", + "exists": "اسم مجموعة الكاميرا موجود بالفعل.", + "nameMustNotPeriod": "يجب ألا يحتوي اسم مجموعة الكاميرا على نقطة.", + "invalid": "اسم مجموعة الكاميرا غير صالح." + }, + "label": "الاسم", + "placeholder": "أدخل اسمًا…" + }, + "cameras": { + "label": "الكاميرات", + "desc": "اختر الكاميرات لهذه المجموعة." + }, + "icon": "أيقونة", + "camera": { + "setting": { + "streamMethod": { + "placeholder": "إختيار طريقة البث", + "method": { + "noStreaming": { + "label": "لايوجد بث", + "desc": "صور الكاميرا سيتم تحديثها مرة واحدة فقط كل دقيقة من دون بث حي." + }, + "smartStreaming": { + "label": "البث الذكي (ينصح به)" + }, + "continuousStreaming": { + "label": "بث متواصل" + } + } + } + } } + }, + "debug": { + "timestamp": "الختم الزمني" } } diff --git a/web/public/locales/ar/components/dialog.json b/web/public/locales/ar/components/dialog.json index 2d3372caaa..42918739f9 100644 --- a/web/public/locales/ar/components/dialog.json +++ b/web/public/locales/ar/components/dialog.json @@ -3,7 +3,36 @@ "title": "هل أنت متأكد أنك تريد إعادة تشغيل فرايجيت؟", "button": "إعادة التشغيل", "restarting": { - "title": "يتم إعادة تشغيل فرايجيت" + "title": "يتم إعادة تشغيل فرايجيت", + "content": "العد التنازلي", + "button": "فرض إعادة التحميل الآن" + } + }, + "explore": { + "plus": { + "submitToPlus": { + "label": "التقديم إلى Frigate+", + "desc": "الكائنات الموجودة في الأماكن التي تريد تجنبها ليست ضمن النتائج الإيجابية الخاطئة. إرسالها كنتائج إيجابية خاطئة سيؤدي إلى إرباك النموذج." + }, + "review": { + "state": { + "submitted": "تم تقديمه" + }, + "question": { + "label": "تأكد من صحة هذه التسمية لـ Frigate Plus", + "ask_a": "هل هذا الكائن هو {{label}}؟", + "ask_an": "هل هذا الكائن هو {{label}}؟", + "ask_full": "هل هذا الكائن هو {{untranslatedLabel}} ({{translatedLabel}})?" + } + } + }, + "video": { + "viewInHistory": "عرض في التاريخ" + } + }, + "export": { + "time": { + "fromTimeline": "اختر من التسلسل الزمني" } } } diff --git a/web/public/locales/ar/components/filter.json b/web/public/locales/ar/components/filter.json index e55bedd66b..954d69fac1 100644 --- a/web/public/locales/ar/components/filter.json +++ b/web/public/locales/ar/components/filter.json @@ -3,7 +3,29 @@ "labels": { "label": "التسميات", "all": { - "title": "كل التسميات" + "title": "كل التسميات", + "short": "المسمّيات" + } + }, + "classes": { + "label": "فئات", + "all": { + "title": "جميع الفئات" + }, + "count_one": "{{عدد}} الفئة", + "count_other": "{{count}} الفئات" + }, + "zones": { + "label": "المناطق", + "all": { + "title": "جميع المناطق", + "short": "المناطق" + } + }, + "dates": { + "selectPreset": "اختر إعدادًا مسبقًا…", + "all": { + "title": "جميع التواريخ" } } } diff --git a/web/public/locales/ar/components/player.json b/web/public/locales/ar/components/player.json index da1bd4859c..5a3e87d294 100644 --- a/web/public/locales/ar/components/player.json +++ b/web/public/locales/ar/components/player.json @@ -3,6 +3,27 @@ "noPreviewFound": "لا يوجد معاينة", "noPreviewFoundFor": "لا يوجد معاينة لـ{{cameraName}}", "submitFrigatePlus": { - "title": "هل ترغب بإرسال هذه الصوره الى Frigate+؟" + "title": "هل ترغب بإرسال هذه الصوره الى Frigate+؟", + "submit": "تقديم" + }, + "livePlayerRequiredIOSVersion": "مطلوب نظام iOS 17.1 أو أكبر لهذا النوع من البث المباشر.", + "cameraDisabled": "الكاميرا معطلة", + "stats": { + "streamType": { + "title": "نوع الدفق:", + "short": "النوع" + }, + "bandwidth": { + "title": "العرض الترددي:", + "short": "العرض الترددي" + }, + "latency": { + "title": "التأخير:", + "value": "{{seconds}} ثانية" + } + }, + "streamOffline": { + "title": "البث دون اتصال بالإنترنت", + "desc": "لم يتم استلام أي إطارات على دفق {{cameraName}} detect، تحقق من سجلات الأخطاء" } } diff --git a/web/public/locales/ar/objects.json b/web/public/locales/ar/objects.json index bf0ac87373..4aff9d76e1 100644 --- a/web/public/locales/ar/objects.json +++ b/web/public/locales/ar/objects.json @@ -7,5 +7,16 @@ "person": "شخص", "bicycle": "دراجة هوائية", "car": "سيارة", - "motorcycle": "دراجة نارية" + "motorcycle": "دراجة نارية", + "airplane": "طائرة", + "bus": "حافلة", + "traffic_light": "إشارة المرور", + "fire_hydrant": "حنفية إطفاء الحريق", + "street_sign": "لافتة شارع", + "stop_sign": "إشارة توقف", + "parking_meter": "عداد موقف سيارات", + "train": "قطار", + "boat": "زورق", + "bench": "مقعدة", + "bird": "طائر" } diff --git a/web/public/locales/ar/views/configEditor.json b/web/public/locales/ar/views/configEditor.json index 10e9cd7397..6387006cef 100644 --- a/web/public/locales/ar/views/configEditor.json +++ b/web/public/locales/ar/views/configEditor.json @@ -2,5 +2,17 @@ "documentTitle": "محرر الإعدادات - فرايجيت", "configEditor": "محرر الإعدادات", "copyConfig": "نسخ الإعدادات", - "saveAndRestart": "حفظ وإعادة تشغيل" + "saveAndRestart": "حفظ وإعادة تشغيل", + "safeConfigEditor": "محرر التكوين في ( الوضع الامن )", + "safeModeDescription": "أصبح Frigate في الوضع الآمن بسبب خطأ في التحقق من صحة التكوين.", + "toast": { + "success": { + "copyToClipboard": "تم نسخ التكوين إلى الحافظة." + }, + "error": { + "savingError": "خطأ في حفظ التكوين" + } + }, + "saveOnly": "احفظ فقط", + "confirm": "أتود الخروج دون حفظ؟" } diff --git a/web/public/locales/ar/views/events.json b/web/public/locales/ar/views/events.json index 74ec7d7f57..41312c9148 100644 --- a/web/public/locales/ar/views/events.json +++ b/web/public/locales/ar/views/events.json @@ -4,5 +4,22 @@ "motion": { "label": "الحركة", "only": "حركة فقط" + }, + "allCameras": "كافة الكاميرات", + "empty": { + "alert": "لا توجد تنبيهات لمراجعتها", + "detection": "لا توجد عمليات كشف لمراجعتها", + "motion": "لم يتم العثور على بيانات الحركة" + }, + "timeline": "التسلسل الزمني", + "timeline.aria": "اختر التسلسل الزمني", + "events": { + "label": "اﻷحداث", + "aria": "اختر الأحداث", + "noFoundForTimePeriod": "لم يتم العثور على أي أحداث لهذه الفترة الزمنية." + }, + "documentTitle": "مراجعة - Frigate", + "recordings": { + "documentTitle": "التسجيلات - Frigate" } } diff --git a/web/public/locales/ar/views/explore.json b/web/public/locales/ar/views/explore.json index e430d47d26..4b54ed1130 100644 --- a/web/public/locales/ar/views/explore.json +++ b/web/public/locales/ar/views/explore.json @@ -3,6 +3,28 @@ "documentTitle": "اكتشف - فرايجيت", "generativeAI": "ذكاء اصطناعي مولد", "exploreIsUnavailable": { - "title": "المتصفح غير متاح" + "title": "المتصفح غير متاح", + "embeddingsReindexing": { + "context": "يمكن استخدام الاستكشاف بعد انتهاء تضمين الكائنات المتعقبة من إعادة الفهرسة.", + "startingUp": "إبتدا التشغيل…", + "step": { + "thumbnailsEmbedded": "الصور المصغرة المضمنة: ", + "descriptionsEmbedded": "الأوصاف المضمنة: ", + "trackedObjectsProcessed": "الأشياء المتعقبة التي تمت معالجتها: " + }, + "estimatedTime": "الزمن المتبقي المقدر:", + "finishingShortly": "سينتهي قريبًا" + }, + "downloadingModels": { + "context": "تقوم Frigate بتنزيل نماذج التضمين اللازمة لدعم ميزة البحث الدلالي. قد يستغرق ذلك عدة دقائق حسب سرعة اتصالك بالإنترنت.", + "setup": { + "visionModel": "نموذج الرؤية", + "visionModelFeatureExtractor": "مستخرج ميزات نموذج الرؤية", + "textModel": "نموذج النص" + } + } + }, + "details": { + "timestamp": "الطابع الزمني" } } diff --git a/web/public/locales/ar/views/exports.json b/web/public/locales/ar/views/exports.json index 6d0c418d61..318ec2fd86 100644 --- a/web/public/locales/ar/views/exports.json +++ b/web/public/locales/ar/views/exports.json @@ -1,5 +1,17 @@ { "search": "بحث", "noExports": "لا يوجد تصديرات", - "documentTitle": "التصدير - فرايجيت" + "documentTitle": "التصدير - فرايجيت", + "deleteExport": "حذف التصدير", + "deleteExport.desc": "هل أنت متأكد من رغبتك في حذف{{exportName}}؟", + "editExport": { + "title": "إعادة تسمية التصدير", + "desc": "قم بإدخال اسم جديد لهذا التصدير.", + "saveExport": "حفظ التصدير" + }, + "toast": { + "error": { + "renameExportFailed": "فشل إعادة تسمية التصدير: {{errorMessage}}" + } + } } diff --git a/web/public/locales/ar/views/faceLibrary.json b/web/public/locales/ar/views/faceLibrary.json index cb515dde3e..c6c2c394e1 100644 --- a/web/public/locales/ar/views/faceLibrary.json +++ b/web/public/locales/ar/views/faceLibrary.json @@ -5,6 +5,22 @@ "placeholder": "أدخل أسم لهذه المجموعة" }, "details": { - "person": "شخص" + "person": "شخص", + "subLabelScore": "نتيجة العلامة الفرعية", + "timestamp": "الطابع الزمني", + "unknown": "غير معروف", + "scoreInfo": "النتيجة الفرعية هي النتيجة المرجحة لجميع درجات الثقة المعترف بها للوجه، لذلك قد تختلف عن النتيجة الموضحة في اللقطة.", + "face": "تفاصيل الوجه", + "faceDesc": "تفاصيل الكائن المتتبع الذي أنشأ هذا الوجه" + }, + "documentTitle": "مكتبة الوجوه - Frigate", + "uploadFaceImage": { + "title": "رفع صورة الوجه", + "desc": "قم بتحميل صورة لمسح الوجوه وإدراجها في {{pageToggle}}" + }, + "collections": "المجموعات", + "createFaceLibrary": { + "title": "إنشاء المجاميع", + "desc": "إنشاء مجموعة جديدة" } } diff --git a/web/public/locales/ar/views/live.json b/web/public/locales/ar/views/live.json index 242365f65b..6e4f32d808 100644 --- a/web/public/locales/ar/views/live.json +++ b/web/public/locales/ar/views/live.json @@ -3,6 +3,37 @@ "documentTitle.withCamera": "{{camera}} - بث حي - فرايجيت", "lowBandwidthMode": "وضع موفر للبيانات", "twoWayTalk": { - "enable": "تفعيل المكالمات ثنائية الاتجاه" + "enable": "تفعيل المكالمات ثنائية الاتجاه", + "disable": "تعطيل المحادثة ثنائية الاتجاه" + }, + "cameraAudio": { + "enable": "تمكين صوت الكاميرا", + "disable": "تعطيل صوت الكاميرا" + }, + "ptz": { + "move": { + "clickMove": { + "enable": "تمكين النقر للتحريك", + "disable": "تعطيل النقر للتحريك", + "label": "سينتهي قريبًا" + }, + "left": { + "label": "حرك الكاميرا PTZ إلى اليسار" + }, + "up": { + "label": "حرك كاميرا PTZ لأعلى" + }, + "down": { + "label": "حرك كاميرا PTZ لأسفل" + }, + "right": { + "label": "حرك الكاميرا PTZ إلى اليمين" + } + }, + "zoom": { + "in": { + "label": "تقريب كاميرا PTZ" + } + } } } diff --git a/web/public/locales/ar/views/recording.json b/web/public/locales/ar/views/recording.json index d79f0ed87a..c12dfda011 100644 --- a/web/public/locales/ar/views/recording.json +++ b/web/public/locales/ar/views/recording.json @@ -1,5 +1,12 @@ { "filter": "ترشيح", "export": "إرسال", - "calendar": "التقويم" + "calendar": "التقويم", + "filters": "المنقيات", + "toast": { + "error": { + "noValidTimeSelected": "لم يتم تحديد نطاق زمني صحيح", + "endTimeMustAfterStartTime": "يجب أن يكون وقت الانتهاء بعد وقت بدء التشغيل" + } + } } diff --git a/web/public/locales/ar/views/search.json b/web/public/locales/ar/views/search.json index 3ed3dc2b78..7964a0f0e5 100644 --- a/web/public/locales/ar/views/search.json +++ b/web/public/locales/ar/views/search.json @@ -1,5 +1,23 @@ { "search": "بحث", "savedSearches": "عمليات البحث المحفوظة", - "searchFor": "البحث عن {{inputValue}}" + "searchFor": "البحث عن {{inputValue}}", + "button": { + "clear": "محو البحث", + "save": "احفظ البحث", + "delete": "حذف البحث المحفوظ", + "filterInformation": "تصفية المعلومات", + "filterActive": "الفلتر النشط" + }, + "trackedObjectId": "مُعرف الكائن المتعقّب", + "filter": { + "label": { + "cameras": "الكاميرات", + "labels": "الملصقات", + "zones": "مناطق", + "search_type": "نوع البحث", + "sub_labels": "العلامات الفرعية", + "time_range": "النطاق الزمني" + } + } } diff --git a/web/public/locales/ar/views/settings.json b/web/public/locales/ar/views/settings.json index fb6f817601..6a40658190 100644 --- a/web/public/locales/ar/views/settings.json +++ b/web/public/locales/ar/views/settings.json @@ -2,6 +2,34 @@ "documentTitle": { "camera": "إعدادات الكاميرا - فرايجيت", "default": "الإعدادات - فرايجيت", - "authentication": "إعدادات المصادقة - فرايجيت" + "authentication": "إعدادات المصادقة - فرايجيت", + "enrichments": "إحصاء الاعدادات", + "masksAndZones": "القناع ومحرر المنطقة - Frigate", + "motionTuner": "مضبط الحركة - Firgate", + "object": "تصحيح الأخطاء - Frigate", + "general": "الإعدادات العامة - Frigate", + "notifications": "إعدادات الإشعارات - Frigate" + }, + "menu": { + "ui": "واجهة المستخدم", + "enrichments": "التحسينات", + "cameras": "إعدادات الكاميرا", + "masksAndZones": "أقنعة / مناطق", + "motionTuner": "مضبط الحركة", + "debug": "تصحيح", + "users": "المستخدمون", + "notifications": "إشعارات" + }, + "dialog": { + "unsavedChanges": { + "title": "لديك تغييرات غير محفوظة.", + "desc": "هل تريد حفظ تغييراتك قبل المتابعة؟" + } + }, + "cameraSetting": { + "camera": "كاميرا" + }, + "general": { + "title": "الإعدادات العامة" } } diff --git a/web/public/locales/ar/views/system.json b/web/public/locales/ar/views/system.json index 581494cb16..e68d544e4d 100644 --- a/web/public/locales/ar/views/system.json +++ b/web/public/locales/ar/views/system.json @@ -2,6 +2,79 @@ "documentTitle": { "cameras": "إحصاءات الكاميرات - فرايجيت", "storage": "إحصاءات التخزين - فرايجيت", - "general": "إحصاءات عامة - فرايجيت" + "general": "إحصاءات عامة - فرايجيت", + "enrichments": "إحصاء العمليات", + "logs": { + "frigate": "سجلات Frigate - Frigate", + "go2rtc": "Go2RTC سجلات - Frigate", + "nginx": "سجلات إنجنإكس - Frigate" + } + }, + "metrics": "مقاييس النظام", + "logs": { + "download": { + "label": "تنزيل السجلات" + }, + "copy": { + "label": "نسخ إلى الحافظة", + "success": "نسخ السجلات إلى الحافظة", + "error": "تعذر نسخ السجلات إلى الحافظة" + }, + "type": { + "label": "النوع", + "timestamp": "الختم الزمني" + }, + "tips": "يتم بث السجلات من الخادم" + }, + "title": "النظام", + "general": { + "hardwareInfo": { + "gpuEncoder": "مشفر ترميز GPU", + "gpuDecoder": "مفكك ترميز GPU", + "gpuInfo": { + "vainfoOutput": { + "title": "مخرجات Vainfo", + "processOutput": "ناتج العملية:", + "processError": "خطأ في العملية:" + }, + "nvidiaSMIOutput": { + "title": "مخرجات Nvidia SMI", + "name": "الاسم: {{name}}", + "driver": "برنامج التشغيل: {{driver}}", + "cudaComputerCapability": "قدرة الحوسبة CUDA: {{cuda_compute}}" + } + }, + "title": "معلومات الاجهزة المادية", + "gpuUsage": "مقدار استخدام GPU", + "gpuMemory": "ذاكرة GPU" + }, + "title": "لمحة عامة", + "detector": { + "title": "أجهزة الكشف", + "inferenceSpeed": "سرعة استنتاج الكاشف", + "temperature": "درجة حرارة الكاشف", + "cpuUsage": "كشف استخدام CPU", + "memoryUsage": "كشف استخدام الذاكرة" + }, + "otherProcesses": { + "title": "عمليات أخرى", + "processCpuUsage": "استخدام وحدة المعالجة المركزية (CPU)", + "processMemoryUsage": "استخدام ذاكرة العملية" + } + }, + "storage": { + "title": "التخزين", + "overview": "نظرة عامة", + "recordings": { + "title": "التسجيلات", + "tips": "تمثل هذه القيمة إجمالي مساحة التخزين المستخدمة للتسجيلات في قاعدة بيانات Frigate. لا يتتبع Frigate استخدام مساحة التخزين لجميع الملفات الموجودة على القرص.", + "earliestRecording": "أقدم تسجيل متاح:" + } + }, + "cameras": { + "overview": "نظرة عامة", + "info": { + "unknown": "غير معروف" + } } } diff --git a/web/public/locales/bg/common.json b/web/public/locales/bg/common.json index 8c55198850..e69813950c 100644 --- a/web/public/locales/bg/common.json +++ b/web/public/locales/bg/common.json @@ -106,5 +106,7 @@ }, "label": { "back": "Върни се" - } + }, + "selectItem": "Избери {{item}}", + "readTheDocumentation": "Прочетете документацията" } diff --git a/web/public/locales/ca/common.json b/web/public/locales/ca/common.json index c981fd7166..ba8a11bb52 100644 --- a/web/public/locales/ca/common.json +++ b/web/public/locales/ca/common.json @@ -40,7 +40,15 @@ "sk": "Slovenčina (Eslovac)", "ru": "Русский (Rus)", "th": "ไทย (Tailandès)", - "ca": "Català (Catalan)" + "ca": "Català (Catalan)", + "ptBR": "Português brasileiro (Portuguès Brasiler)", + "sr": "Српски (Serbi)", + "sl": "Slovenščina (Sloveni)", + "lt": "Lietuvių (Lituà)", + "bg": "Български (Búlgar)", + "gl": "Galego (Gallec)", + "id": "Bahasa Indonesia (Indonesi)", + "ur": "اردو (Urdú)" }, "system": "Sistema", "systemMetrics": "Mètriques del sistema", @@ -261,5 +269,6 @@ "title": "404", "desc": "Pàgina no trobada" }, - "selectItem": "Selecciona {{item}}" + "selectItem": "Selecciona {{item}}", + "readTheDocumentation": "Llegir la documentació" } diff --git a/web/public/locales/ca/components/auth.json b/web/public/locales/ca/components/auth.json index 5d4b413a6d..6cbe2877c1 100644 --- a/web/public/locales/ca/components/auth.json +++ b/web/public/locales/ca/components/auth.json @@ -1,6 +1,6 @@ { "form": { - "user": "Nom d'usuari", + "user": "Usuari", "password": "Contrasenya", "login": "Iniciar sessió", "errors": { diff --git a/web/public/locales/ca/components/camera.json b/web/public/locales/ca/components/camera.json index b93a84a5f9..bfa8ea1613 100644 --- a/web/public/locales/ca/components/camera.json +++ b/web/public/locales/ca/components/camera.json @@ -63,11 +63,12 @@ "desc": "Cambia les opcions de transmissió en viu del panell de control d'aquest grup de càmeres. Aquest paràmetres son específics del dispositiu/navegador.", "stream": "Transmissió", "placeholder": "Seleccionar una transmissió" - } + }, + "birdseye": "Ull d'ocell" }, "success": "El grup de càmeres ({{name}}) ha estat guardat.", "icon": "Icona", - "label": "Grups de càmeres" + "label": "Grups de Càmeres" }, "debug": { "options": { diff --git a/web/public/locales/ca/components/dialog.json b/web/public/locales/ca/components/dialog.json index b2759e896b..20988372a7 100644 --- a/web/public/locales/ca/components/dialog.json +++ b/web/public/locales/ca/components/dialog.json @@ -110,5 +110,12 @@ "error": "No s'ha pogut suprimir: {{error}}" } } + }, + "imagePicker": { + "selectImage": "Selecciona la miniatura d'un objecte rastrejat", + "search": { + "placeholder": "Cerca per etiqueta o subetiqueta..." + }, + "noImages": "No s'han trobat miniatures per a aquesta càmera" } } diff --git a/web/public/locales/ca/components/filter.json b/web/public/locales/ca/components/filter.json index aa02310f70..e178972947 100644 --- a/web/public/locales/ca/components/filter.json +++ b/web/public/locales/ca/components/filter.json @@ -108,7 +108,9 @@ "loading": "Carregant les matrícules reconegudes…", "placeholder": "Escriu per a buscar matrícules…", "noLicensePlatesFound": "No s'han trobat matrícules.", - "selectPlatesFromList": "Seleccioni una o més matrícules de la llista." + "selectPlatesFromList": "Seleccioni una o més matrícules de la llista.", + "selectAll": "Seleccionar tots", + "clearAll": "Netejar tot" }, "cameras": { "label": "Filtre de càmeres", @@ -122,5 +124,13 @@ }, "motion": { "showMotionOnly": "Mostar només el moviment" + }, + "classes": { + "label": "Classes", + "all": { + "title": "Totes les classes" + }, + "count_one": "{{count}} Classe", + "count_other": "{{count}} Classes" } } diff --git a/web/public/locales/ca/views/configEditor.json b/web/public/locales/ca/views/configEditor.json index 8d47ea04cd..bd3149a3f4 100644 --- a/web/public/locales/ca/views/configEditor.json +++ b/web/public/locales/ca/views/configEditor.json @@ -12,5 +12,7 @@ "savingError": "Error al desar la configuració" } }, - "confirm": "Sortir sense desar?" + "confirm": "Sortir sense desar?", + "safeConfigEditor": "Editor de Configuració (Mode Segur)", + "safeModeDescription": "Frigate està en mode segur a causa d'un error de validació de la configuració." } diff --git a/web/public/locales/ca/views/events.json b/web/public/locales/ca/views/events.json index 1a219b9c11..99d56c1d93 100644 --- a/web/public/locales/ca/views/events.json +++ b/web/public/locales/ca/views/events.json @@ -34,5 +34,7 @@ }, "camera": "Càmera", "selected_one": "{{count}} seleccionats", - "selected_other": "{{count}} seleccionats" + "selected_other": "{{count}} seleccionats", + "suspiciousActivity": "Activitat sospitosa", + "threateningActivity": "Activitat amenaçadora" } diff --git a/web/public/locales/ca/views/explore.json b/web/public/locales/ca/views/explore.json index 07f787ed37..3b79a696f9 100644 --- a/web/public/locales/ca/views/explore.json +++ b/web/public/locales/ca/views/explore.json @@ -97,12 +97,14 @@ "success": { "updatedSublabel": "Subetiqueta actualitzada amb èxit.", "updatedLPR": "Matrícula actualitzada amb èxit.", - "regenerate": "El {{provider}} ha sol·licitat una nova descripció. En funció de la velocitat del vostre proveïdor, la nova descripció pot trigar un temps a regenerar-se." + "regenerate": "El {{provider}} ha sol·licitat una nova descripció. En funció de la velocitat del vostre proveïdor, la nova descripció pot trigar un temps a regenerar-se.", + "audioTranscription": "Transcripció d'àudio sol·licitada amb èxit." }, "error": { "regenerate": "No s'ha pogut contactar amb {{provider}} per obtenir una nova descripció: {{errorMessage}}", "updatedSublabelFailed": "No s'ha pogut actualitzar la subetiqueta: {{errorMessage}}", - "updatedLPRFailed": "No s'ha pogut actualitzar la matrícula: {{errorMessage}}" + "updatedLPRFailed": "No s'ha pogut actualitzar la matrícula: {{errorMessage}}", + "audioTranscription": "Error en demanar la transcripció d'audio {{errorMessage}}" } }, "title": "Revisar detalls de l'element", @@ -155,6 +157,9 @@ "title": "Editar matrícula", "descNoLabel": "Introdueix un nou valor de matrícula per a aquest objecte rastrejat", "desc": "Introdueix un nou valor per a la matrícula per aquesta {{label}}" + }, + "score": { + "label": "Puntuació" } }, "searchResult": { @@ -193,6 +198,14 @@ }, "deleteTrackedObject": { "label": "Suprimeix aquest objecte rastrejat" + }, + "addTrigger": { + "label": "Afegir disparador", + "aria": "Afegir disparador per aquest objecte" + }, + "audioTranscription": { + "label": "Transcriu", + "aria": "Demanar una transcripció d'audio" } }, "noTrackedObjects": "No s'han trobat objectes rastrejats", @@ -205,5 +218,11 @@ "fetchingTrackedObjectsFailed": "Error al obtenir objectes rastrejats: {{errorMessage}}", "trackedObjectsCount_one": "{{count}} objecte rastrejat ", "trackedObjectsCount_many": "{{count}} objectes rastrejats ", - "trackedObjectsCount_other": "{{count}} objectes rastrejats " + "trackedObjectsCount_other": "{{count}} objectes rastrejats ", + "aiAnalysis": { + "title": "Anàlisi d'IA" + }, + "concerns": { + "label": "Preocupacions" + } } diff --git a/web/public/locales/ca/views/live.json b/web/public/locales/ca/views/live.json index dd091b7de9..f4d20fc705 100644 --- a/web/public/locales/ca/views/live.json +++ b/web/public/locales/ca/views/live.json @@ -32,7 +32,15 @@ "label": "Fer clic a la imatge per centrar la càmera PTZ" } }, - "presets": "Predefinits de la càmera PTZ" + "presets": "Predefinits de la càmera PTZ", + "focus": { + "in": { + "label": "Enfoca la càmera PTZ aprop" + }, + "out": { + "label": "Enfoca la càmera PTZ lluny" + } + } }, "documentTitle": "Directe - Frigate", "documentTitle.withCamera": "{{camera}} - Directe - Frigate", @@ -135,7 +143,8 @@ "snapshots": "Instantànies", "autotracking": "Seguiment automàtic", "objectDetection": "Detecció d'objectes", - "audioDetection": "Detecció d'àudio" + "audioDetection": "Detecció d'àudio", + "transcription": "Transcripció d'audio" }, "history": { "label": "Mostrar gravacions històriques" @@ -154,5 +163,9 @@ "label": "Editar grup de càmeres" }, "exitEdit": "Sortir de l'edició" + }, + "transcription": { + "enable": "Habilita la transcripció d'àudio en temps real", + "disable": "Deshabilita la transcripció d'àudio en temps real" } } diff --git a/web/public/locales/ca/views/settings.json b/web/public/locales/ca/views/settings.json index a94e86bd1f..900c6b3c06 100644 --- a/web/public/locales/ca/views/settings.json +++ b/web/public/locales/ca/views/settings.json @@ -20,7 +20,8 @@ "notifications": "Notificacions", "debug": "Depuració", "frigateplus": "Frigate+", - "enrichments": "Enriquiments" + "enrichments": "Enriquiments", + "triggers": "Disparadors" }, "dialog": { "unsavedChanges": { @@ -344,6 +345,43 @@ "detections": "Deteccions ", "title": "Revisar", "desc": "Habilita o deshabilita temporalment les alertes i deteccions per a aquesta càmera fins que es reiniciï Frigate. Quan estigui desactivat, no es generaran nous elements de revisió. " + }, + "object_descriptions": { + "title": "Descripció d'objectes per IA generativa", + "desc": "Activar/desactivar temporalment la IA generativa de descripcions per aquesta càmera. Quan està desactivat, les descripcions d'IA generativa no seran requerides per als objectes seguits per aquesta càmera." + }, + "review_descriptions": { + "title": "Revisar las descripcions d'IA generativa", + "desc": "Activar/desactivals temporalment les descripcions d'IA generativa per aquesta càmera. Quan estan desactivades, les descripcions d'IA generativa no serán requerides per revisar els items en aquesta càmera." + }, + "addCamera": "Afegir Nova Càmera", + "editCamera": "Editar Càmera:", + "selectCamera": "Seleccionar Càmera", + "backToSettings": "Tornar a la Configuració de Càmera", + "cameraConfig": { + "add": "Afegir Càmera", + "edit": "Editar Càmera", + "description": "Configurar la càmera incloent les entrades y rols.", + "name": "Nom de Càmera", + "nameRequired": "El nom de càmera es necesari", + "nameLength": "El nom de la càmera ha de ser com a mínim de 24 caràcters.", + "namePlaceholder": "e.x., porta_entrada", + "enabled": "Activat", + "ffmpeg": { + "inputs": "Entrades", + "path": "Direcció d'entrada", + "pathRequired": "Direcció d'entrada necesaria", + "pathPlaceholder": "rtsp://...", + "roles": "Rols", + "rolesRequired": "Com a mínin un rol es necesari", + "rolesUnique": "Cada rol (audio, detecció, gravació) pot ser assiganda a una entrada", + "addInput": "Afegir una entrada", + "removeInput": "Esborrar una entrada", + "inputsRequired": "Com a mínim una entrada es necesaria" + }, + "toast": { + "success": "La càmera {{cameraName}} s'ha guardat correctament" + } } }, "motionDetectionTuner": { @@ -414,11 +452,24 @@ "tips": "

Caixes de moviment


Es sobreposaran requadres vermells a les àrees del fotograma on actualment s’estigui detectant moviment.

" }, "detectorDesc": "Frigate fa servir els teus detectors ({{detectors}}) per a detectar objectes a les imatges de la teva càmera.", - "desc": "La vista de depuració mostra en temps real els objectes rastrejats i les seves estadístiques. La llista d’objectes mostra un resum amb retard temporal dels objectes detectats." + "desc": "La vista de depuració mostra en temps real els objectes rastrejats i les seves estadístiques. La llista d’objectes mostra un resum amb retard temporal dels objectes detectats.", + "openCameraWebUI": "Obrir la interficie d'usuari de {{camera}}", + "audio": { + "title": "Audio", + "noAudioDetections": "No hi ha deteccions d'audio", + "score": "puntuació", + "currentRMS": "RMS Actual", + "currentdbFS": "dbFS Actual" + }, + "paths": { + "title": "Rutes", + "desc": "Mostrar els punts significatius de la ruta dels objectes seguits", + "tips": "

Rutes


Les línies i cercles indicarán els punts significatius dels objectes seguits durant el seu cicle de vida.

" + } }, "users": { "table": { - "username": "Nom d'usuari", + "username": "Usuari", "password": "Contrasenya", "deleteUser": "Suprimir usuari", "noUsers": "No s'han trobat usuaris.", @@ -492,7 +543,8 @@ "admin": "Administrador", "adminDesc": "Accés complet a totes les funcionalitats.", "intro": "Selecciona el rol adequat per a aquest usuari:", - "viewerDesc": "Limitat només a panells en directe, revisió, exporació i exportació." + "viewerDesc": "Limitat només a panells en directe, revisió, exporació i exportació.", + "customDesc": "Rol personalitzat per accés específic a una cámera." }, "title": "Canviar la funció d’usuari", "desc": "Actualitzar permisos per a {{username}}", @@ -618,5 +670,160 @@ "success": "Els paràmetres complementaris s'han desat. Reinicia Frigate per aplicar els canvis." }, "restart_required": "És necessari reiniciar (Han cambiat paràmetres complementaris)" + }, + "triggers": { + "table": { + "actions": "Accions", + "noTriggers": "No hi ha disparadors configurats en aquesta càmera.", + "edit": "Editar", + "deleteTrigger": "Esborrar Disparador", + "lastTriggered": "Últim Disparo", + "name": "Nom", + "type": "Tipus", + "content": "Contingut", + "threshold": "Llindar" + }, + "type": { + "thumbnail": "Miniatura", + "description": "Descripció" + }, + "actions": { + "alert": "Marcar com Alerta", + "notification": "Enviar Notificació" + }, + "dialog": { + "createTrigger": { + "title": "Crear Disparador", + "desc": "Crear disparador per una càmera {{camera}}" + }, + "editTrigger": { + "title": "Editar Disparador", + "desc": "Editar la configuració per al disparador de càmera {{camera}}" + }, + "deleteTrigger": { + "title": "Esborrar Disparador", + "desc": "Estas segur que vols esborrar el disparador {{triggerName}}? Aquesta acció no es pot desfer." + }, + "form": { + "name": { + "title": "Nom", + "placeholder": "Entrar el nom del disparador", + "error": { + "minLength": "El nom ha de tenir almenys 2 caràcters de llargada.", + "invalidCharacters": "El nom només pot contenir lletres, números, guions i guinons baixos.", + "alreadyExists": "El disparador amb aquest nom ja existeix per aquesta càmera." + } + }, + "enabled": { + "description": "Activar o desactivar aquest disparador" + }, + "type": { + "title": "Tipus", + "placeholder": "Selecciona un tipus de disparador" + }, + "content": { + "title": "Contingut", + "imagePlaceholder": "Selecciona una imatge", + "textPlaceholder": "Entra el contingut de text", + "imageDesc": "Selecciona una imatge per disparar aquesta acció quan una imatge similar sigui detectada.", + "textDesc": "Entra el text per disparar aquesta acció quan es detecti una descripció d'objecte a rastrejar similar.", + "error": { + "required": "Contigunt requerit." + } + }, + "threshold": { + "title": "Llindar", + "error": { + "min": "El llindar ha de ser mínim 0", + "max": "El llindar ha de ser máxim 1" + } + }, + "actions": { + "title": "Accions", + "desc": "Per defecte, Frigate dispara un missatge MQTT per tots els disparadors. Tria una acció adicional per realitzar quan aquest disparador dispari.", + "error": { + "min": "S'ha de seleccionar una acció com a mínim." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "El disparador {{name}} s'ha creat existosament.", + "updateTrigger": "El disparador {{name}} s'ha actualitzat correctament.", + "deleteTrigger": "El disparador {{name}} s'ha borrat correctament." + }, + "error": { + "createTriggerFailed": "Error al crear el disparador: {{errorMessage}}", + "updateTriggerFailed": "Error a l'actualitzar el disparador: {{errorMessage}}", + "deleteTriggerFailed": "Error a l'esborrar el disparador: {{errorMessage}}" + } + }, + "documentTitle": "Disparadors", + "management": { + "title": "Gestió de disparadors", + "desc": "Gestionar els disparadors de {{camera}}. Usa les tipus de miniatures per disparar miniatures similars a l'objecte a seguir seleccionat, i el tipus de descripció per disparar en cas de descripcions similars a l'especificada." + }, + "addTrigger": "Afegir disaprador" + }, + "roles": { + "dialog": { + "form": { + "cameras": { + "required": "Almenys has de seleccionar una càmera.", + "title": "Càmeres", + "desc": "Selecciona les càmeres que tingui accés aquest rol. Com a mínim s'ha de seleccionar una càmera." + }, + "role": { + "title": "Nom del Rol", + "placeholder": "Entra el nom del rol", + "desc": "Només lletres, números, els punts i subrallats están permesos.", + "roleIsRequired": "Nom del Rol requerit", + "roleOnlyInclude": "El nom de Rol només pot incloure lletres, nombres, . o _", + "roleExists": "Ja existeis un rol amb aquest nom." + } + }, + "createRole": { + "title": "Crear nou Rol", + "desc": "Afegir nou rol y especificar permisos d'accés." + }, + "editCameras": { + "title": "Editar Càmeres Rol", + "desc": "Actualitza l'acces a les càmeres per al rol {{role}}." + }, + "deleteRole": { + "title": "Eliminar Rol", + "desc": "Aquesta acció no pot ser restablerta. S'esborrarà permenentment el rol y els usuaris asignats amb aquest rol de 'visor', que els dona accés a totes les càmeres.", + "warn": "Estas segur que vols eliminar {{role}}?", + "deleting": "Eliminant..." + } + }, + "management": { + "title": "Gestió del Rols de Visors", + "desc": "Gestiona els rols visors personalitzats y els seus permisos d'accés per aquesta instancia de Frigate." + }, + "addRole": "Afegir Rol", + "table": { + "role": "Rol", + "cameras": "Càmeres", + "actions": "Accions", + "noRoles": "No s'han trobat rols personalitzats.", + "editCameras": "Editar Càmeres", + "deleteRole": "Eliminar Rol" + }, + "toast": { + "success": { + "createRole": "Rol {{role}} creat exitosament", + "updateCameras": "Càmeres actualitzades per al rol {{role}}", + "deleteRole": "Rol {{role}} eliminat exitosament", + "userRolesUpdated": "{{count}} usuari(s) asignats a aquest rol s'han actualitzat a 'visor', i tenen accés a totes les càmeres." + }, + "error": { + "createRoleFailed": "Error al crear el rol: {{errorMessage}}", + "updateCamerasFailed": "Error a l'actualitzar les càmeres: {{errorMessage}}", + "deleteRoleFailed": "Error a l'eliminar el rol: {{errorMessage}}", + "userUpdateFailed": "Error a l'actualitzar els ros d'usuari: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/ca/views/system.json b/web/public/locales/ca/views/system.json index d4d63a31df..30f5257d1d 100644 --- a/web/public/locales/ca/views/system.json +++ b/web/public/locales/ca/views/system.json @@ -41,7 +41,8 @@ "title": "Detectors", "inferenceSpeed": "Velocitat d'inferència del detector", "cpuUsage": "Ús de CPU del detector", - "temperature": "Temperatura del detector" + "temperature": "Temperatura del detector", + "cpuUsageInformation": "CPU usada en la preparació d'entrades i sortides desde/cap als models de detecció. Aquest valor no mesura l'utilització d'inferència, encara que usis una GPU o accelerador." }, "title": "General", "hardwareInfo": { @@ -102,7 +103,11 @@ }, "percentageOfTotalUsed": "Percentatge del total" }, - "overview": "Visió general" + "overview": "Visió general", + "shm": { + "title": "Ubicació de SHM (memória compartida)", + "warning": "El tamany de la SHM oh {{total}}MB es massa petita. Augmenta almenys fins a {{min_shm}}MB." + } }, "cameras": { "framesAndDetections": "Fotogrames / Deteccions", @@ -158,7 +163,8 @@ "ffmpegHighCpuUsage": "{{camera}} te un ús elevat de CPU per FFmpeg ({{ffmpegAvg}}%)", "detectHighCpuUsage": "{{camera}} te un ús elevat de CPU per la detecció ({{detectAvg}}%)", "detectIsVerySlow": "{{detect}} és molt lent ({{speed}} ms)", - "detectIsSlow": "{{detect}} és lent ({{speed}} ms)" + "detectIsSlow": "{{detect}} és lent ({{speed}} ms)", + "shmTooLow": "/dev/shm directori ({{total}} MB) hauria de ser incrementat com a mínim {{min}} MB." }, "enrichments": { "title": "Enriquiments", diff --git a/web/public/locales/cs/audio.json b/web/public/locales/cs/audio.json index 4308f7487e..8876626acc 100644 --- a/web/public/locales/cs/audio.json +++ b/web/public/locales/cs/audio.json @@ -53,7 +53,7 @@ "moo": "Bučení", "cowbell": "Kravský zvonec", "pig": "Prase", - "oink": "Chrochtání", + "oink": "Chrochtanie", "fowl": "Drůbež", "chicken": "Slepice", "cluck": "Kvokání", diff --git a/web/public/locales/cs/common.json b/web/public/locales/cs/common.json index 08cff4992c..856c88a636 100644 --- a/web/public/locales/cs/common.json +++ b/web/public/locales/cs/common.json @@ -130,7 +130,7 @@ "meters": "metry" } }, - "selectItem": "Vyberte {{item}}", + "selectItem": "Vybrat {{item}}", "menu": { "documentation": { "label": "Dokumentace Frigate", @@ -185,7 +185,15 @@ "hu": "Magyar (Maďarština)", "pl": "Polski (Polština)", "th": "ไทย (Thaiština)", - "ca": "Català (Katalánština)" + "ca": "Català (Katalánština)", + "sl": "Slovinština (Slovinsko)", + "ptBR": "Português brasileiro (Brazilian Portuguese)", + "sr": "Српски (Serbian)", + "lt": "Lietuvių (Lithuanian)", + "bg": "Български (Bulgarian)", + "gl": "Galego (Galician)", + "id": "Bahasa Indonesia (Indonesian)", + "ur": "اردو (Urdu)" }, "theme": { "highcontrast": "Vysoký kontrast", @@ -261,5 +269,6 @@ "admin": "Správce", "viewer": "Divák", "desc": "Správci mají plný přístup ke všem funkcím v uživatelském rozhraní Frigate. Diváci jsou omezeni na sledování kamer, položek přehledu a historických záznamů v UI." - } + }, + "readTheDocumentation": "Přečtěte si dokumentaci" } diff --git a/web/public/locales/cs/components/camera.json b/web/public/locales/cs/components/camera.json index 2c3f0d6c72..ef56aa7295 100644 --- a/web/public/locales/cs/components/camera.json +++ b/web/public/locales/cs/components/camera.json @@ -41,7 +41,8 @@ "desc": "Změní možnosti živého vysílání pro dashboard této skupiny kamer. Tato nastavení jsou specifická pro zařízení/prohlížeč.", "stream": "Proud", "placeholder": "Vyberte proud" - } + }, + "birdseye": "Ptačí oko" }, "delete": { "confirm": { diff --git a/web/public/locales/cs/components/dialog.json b/web/public/locales/cs/components/dialog.json index 53318710a1..8b982edcde 100644 --- a/web/public/locales/cs/components/dialog.json +++ b/web/public/locales/cs/components/dialog.json @@ -110,5 +110,12 @@ "label": "Uložit vyhledávání", "overwrite": "{{searchName}} už existuje. Uložení přepíše existující hodnotu." } + }, + "imagePicker": { + "selectImage": "Vyber náhled sledovaného objektu", + "search": { + "placeholder": "Hledej pomocí štítku nebo podštítku..." + }, + "noImages": "Nebyly nalezeny žádné náhledy pro tuto kameru" } } diff --git a/web/public/locales/cs/components/filter.json b/web/public/locales/cs/components/filter.json index d057b6e7d2..55ff667c14 100644 --- a/web/public/locales/cs/components/filter.json +++ b/web/public/locales/cs/components/filter.json @@ -92,7 +92,9 @@ "loading": "Načítám rozeznané SPZ…", "placeholder": "Zadejte text pro hledání SPZ…", "selectPlatesFromList": "Vyberte jednu, nebo více SPZ ze seznamu.", - "noLicensePlatesFound": "Žádné SPZ nebyly nalezeny." + "noLicensePlatesFound": "Žádné SPZ nebyly nalezeny.", + "selectAll": "Označit vše", + "clearAll": "Vymazat vše" }, "zones": { "all": { @@ -122,5 +124,13 @@ }, "review": { "showReviewed": "Zobrazit zkontrolované" + }, + "classes": { + "label": "Třídy", + "all": { + "title": "Všechny třídy" + }, + "count_one": "Třída {{count}}", + "count_other": "Třídy {{count}}" } } diff --git a/web/public/locales/cs/objects.json b/web/public/locales/cs/objects.json index f257102358..ca2092069c 100644 --- a/web/public/locales/cs/objects.json +++ b/web/public/locales/cs/objects.json @@ -111,7 +111,7 @@ "fedex": "FedEx", "dhl": "DHL", "an_post": "An Post", - "purolator": "Purolator", + "purolator": "Čistič", "postnl": "PostNL", "nzpost": "NZPost", "postnord": "PostNord", diff --git a/web/public/locales/cs/views/configEditor.json b/web/public/locales/cs/views/configEditor.json index 55fbafb2c8..19982f20c1 100644 --- a/web/public/locales/cs/views/configEditor.json +++ b/web/public/locales/cs/views/configEditor.json @@ -12,5 +12,7 @@ "savingError": "Chyba ukládání konfigurace" } }, - "confirm": "Opustit bez uložení?" + "confirm": "Opustit bez uložení?", + "safeConfigEditor": "Editor konfigurace (Nouzový režim)", + "safeModeDescription": "Frigate je v nouzovém režimu kvůli chybě při ověřování konfigurace." } diff --git a/web/public/locales/cs/views/events.json b/web/public/locales/cs/views/events.json index 17cade7e0f..34a00ce578 100644 --- a/web/public/locales/cs/views/events.json +++ b/web/public/locales/cs/views/events.json @@ -34,5 +34,7 @@ }, "detected": "Detekováno", "selected_one": "{{count}} vybráno", - "selected_other": "{{count}} vybráno" + "selected_other": "{{count}} vybráno", + "suspiciousActivity": "Podezřelá aktivita", + "threateningActivity": "Ohrožující činnost" } diff --git a/web/public/locales/cs/views/explore.json b/web/public/locales/cs/views/explore.json index 1dba5c6058..8acdd23860 100644 --- a/web/public/locales/cs/views/explore.json +++ b/web/public/locales/cs/views/explore.json @@ -23,12 +23,14 @@ "success": { "regenerate": "Od {{provider}} byl vyžádán nový popis. V závislosti na rychlosti vašeho poskytovatele může obnovení nového popisu nějakou dobu trvat.", "updatedSublabel": "Úspěšně aktualizovaný podružný štítek.", - "updatedLPR": "Úspěšně aktualizovaná SPZ." + "updatedLPR": "Úspěšně aktualizovaná SPZ.", + "audioTranscription": "Požádání o přepis zvuku bylo úspěšné." }, "error": { "regenerate": "Chyba volání {{provider}} pro nový popis: {{errorMessage}}", "updatedSublabelFailed": "Chyba obnovení podružného štítku: {{errorMessage}}", - "updatedLPRFailed": "Chyba obnovení SPZ: {{errorMessage}}" + "updatedLPRFailed": "Chyba obnovení SPZ: {{errorMessage}}", + "audioTranscription": "Požádání o přepis zvuku bylo neúspěšné: {{errorMessage}}" } } }, @@ -70,7 +72,10 @@ "label": "Nejvyšší skóre" }, "label": "Štítek", - "recognizedLicensePlate": "Rozpoznaná SPZ" + "recognizedLicensePlate": "Rozpoznaná SPZ", + "score": { + "label": "Skóre" + } }, "exploreIsUnavailable": { "title": "Prozkoumat je nedostupné", @@ -188,6 +193,14 @@ "viewObjectLifecycle": { "label": "Zobrazit životní cyklus objektu", "aria": "Ukázat životní cyklus objektu" + }, + "addTrigger": { + "label": "Přidat spouštěč", + "aria": "Přidat spouštěč pro tento sledovaný objekt" + }, + "audioTranscription": { + "label": "Přepsat", + "aria": "Požádat o přepis zvukového záznamu" } }, "dialog": { @@ -205,5 +218,11 @@ }, "noTrackedObjects": "Žádné sledované objekty nebyly nalezeny", "fetchingTrackedObjectsFailed": "Chyba při načítání sledovaných objektů: {{errorMessage}}", - "exploreMore": "Prozkoumat více {{label}} objektů" + "exploreMore": "Prozkoumat více {{label}} objektů", + "aiAnalysis": { + "title": "Analýza AI" + }, + "concerns": { + "label": "Obavy" + } } diff --git a/web/public/locales/cs/views/live.json b/web/public/locales/cs/views/live.json index 1e6004a05e..f8e77f659d 100644 --- a/web/public/locales/cs/views/live.json +++ b/web/public/locales/cs/views/live.json @@ -43,7 +43,15 @@ "label": "Klikněte do snímku pro vycentrování PTZ kamery" } }, - "presets": "Předvolby PTZ kamery" + "presets": "Předvolby PTZ kamery", + "focus": { + "in": { + "label": "Zaostření PTZ kamery" + }, + "out": { + "label": "Rozostření PTZ kamery" + } + } }, "camera": { "enable": "Povolit kameru", @@ -103,7 +111,7 @@ "forTime": "Pozastavení na: " }, "stream": { - "title": "Stream", + "title": "Proud", "audio": { "tips": { "title": "Zvuk musí být kamerou vysílán a nakonfigurován v go2rtc pro tento stream.", @@ -134,7 +142,8 @@ "snapshots": "Snímky", "audioDetection": "Detekce Zvuku", "autotracking": "Automatické sledování", - "recording": "Nahrávání" + "recording": "Nahrávání", + "transcription": "Zvukový přepis" }, "history": { "label": "Zobrazit historické záznamy" @@ -154,5 +163,9 @@ "label": "Upravit Skupinu Kamer" } }, - "notifications": "Notifikace" + "notifications": "Notifikace", + "transcription": { + "enable": "Povolit živý přepis zvuku", + "disable": "Zakázat živý přepis zvuku" + } } diff --git a/web/public/locales/cs/views/settings.json b/web/public/locales/cs/views/settings.json index 0657707621..a8a5b2d315 100644 --- a/web/public/locales/cs/views/settings.json +++ b/web/public/locales/cs/views/settings.json @@ -6,7 +6,7 @@ "classification": "Nastavení klasifikace - Frigate", "notifications": "Nastavení notifikací - Frigate", "masksAndZones": "Editor masky a zón - Frigate", - "motionTuner": "Ladič detekce pohybu - Frigate", + "motionTuner": "Ladění detekce pohybu - Frigate", "object": "Ladění - Frigate", "general": "Obecné nastavení - Frigate", "frigatePlus": "Frigate+ nastavení - Frigate", @@ -298,12 +298,13 @@ "classification": "Klasifikace", "cameras": "Nastavení kamery", "masksAndZones": "Masky / Zóny", - "motionTuner": "Ladič detekce pohybu", + "motionTuner": "Ladění detekce pohybu", "debug": "Ladění", "users": "Uživatelé", "notifications": "Notifikace", - "frigateplus": "Frigate +", - "enrichments": "Obohacení" + "frigateplus": "Frigate+", + "enrichments": "Obohacení", + "triggers": "Spouštěče" }, "dialog": { "unsavedChanges": { @@ -318,7 +319,7 @@ "general": { "title": "Hlavní nastavení", "liveDashboard": { - "title": "Živý Dashboard", + "title": "Živý dashboard", "automaticLiveView": { "desc": "Při detekci aktivity se automaticky přepne na živý náhled kamery. Vypnutí této možnosti způsobí, že se statické snímky z kamery na ovládacím panelu Live aktualizují pouze jednou za minutu.", "label": "Automatický živý náhled" @@ -339,9 +340,9 @@ "clearAll": "Vymazat všechna nastavení streamování" }, "recordingsViewer": { - "title": "Prohlížeč Nahrávek", + "title": "Prohlížeč nahrávek", "defaultPlaybackRate": { - "label": "Výchozí Rychlost Přehrávání", + "label": "Výchozí rychlost přehrávání", "desc": "Výchozí rychlost přehrávání pro nahrávky." } }, @@ -375,9 +376,9 @@ "desc": "Zobrazit rámeček oblasti zájmu odesílané detektoru objektů", "tips": "

Boxy oblastí zájmu


Jasně zelené boxy budou překryty na oblastech zájmu ve snímku, které jsou odesílány detektoru objektů.

" }, - "title": "Ladit", + "title": "Ladění", "detectorDesc": "Frigate používá vaše detektory {{detectors}} k detekci objektů ve streamu vašich kamer.", - "objectList": "Seznam Objektů", + "objectList": "Seznam objektů", "boundingBoxes": { "title": "Ohraničující rámečky", "desc": "Zobrazit ohraničující rámečky okolo sledovaných objektů", @@ -394,7 +395,7 @@ "title": "Masky detekce pohybu", "desc": "Zobrazit polygony masek detekce pohybu" }, - "debugging": "Ledění", + "debugging": "Ladění", "desc": "Ladicí zobrazení ukazuje sledované objekty a jejich statistiky v reálném čase. Seznam objektů zobrazuje časově zpožděný přehled detekovaných objektů.", "motion": { "title": "Rámečky detekce pohybu", @@ -403,13 +404,26 @@ }, "noObjects": "Žádné objekty", "objectShapeFilterDrawing": { - "title": "Kreslení Filtru Tvaru Objektu", + "title": "Vykreslení filtru tvaru objektu", "desc": "Nakreslete na obrázek obdélník pro zobrazení informací o ploše a poměru stran", "tips": "Povolte tuto možnost pro nakreslení obdélníku na obraz kamery, který zobrazí jeho plochu a poměr stran. Tyto hodnoty pak můžete použít pro nastavení parametrů tvarového filtru objektu ve vaší konfiguraci.", "document": "Přečtěte si dokumentaci ", "score": "Skóre", "ratio": "Poměr", "area": "Oblast" + }, + "openCameraWebUI": "Otevřít webové rozhraní {{camera}}", + "audio": { + "title": "Zvuk", + "noAudioDetections": "Žádné detekce zvuku", + "score": "skóre", + "currentRMS": "Aktuální RMS", + "currentdbFS": "Aktuální dbFS" + }, + "paths": { + "title": "Cesty", + "desc": "Zobrazit významné body trasy sledovaného objektu", + "tips": "

Cesty


Čáry a kruhy označují významné body, kterými se sledovaný objekt během svého životního cyklu pohyboval." } }, "camera": { @@ -444,7 +458,44 @@ }, "limitDetections": "Omezit detekce pro specifické zóny" }, - "title": "Nastavení Kamery" + "title": "Nastavení Kamery", + "object_descriptions": { + "title": "AI generované popisy objektů", + "desc": "Dočasně povolit/zakázat generativní popisy objektů AI pro tuto kameru. Pokud je tato funkce zakázána, nebudou pro sledované objekty na této kameře vyžadovány popisy generované AI." + }, + "review_descriptions": { + "title": "Popisy generativní AI", + "desc": "Dočasně povolit/zakázat generativní AI recenze popisů pro tuto kameru. Pokud je tato funkce zakázána, nebudou pro položky recenzí na této kameře vyžadovány popisy generované AI." + }, + "addCamera": "Přidat novou kameru", + "editCamera": "Upravit kameru:", + "selectCamera": "Vybrat kameru", + "backToSettings": "Zpět k nastavení kamery", + "cameraConfig": { + "add": "Přidat kameru", + "edit": "Upravit kameru", + "description": "Konfigurovat nastavení kamery, včetně vstupů streamu a rolí.", + "name": "Název kamery", + "nameRequired": "Název kamery je povinný", + "nameLength": "Název kamery musí mít méně než 24 znaků.", + "namePlaceholder": "např. přední dveře", + "enabled": "Povolit", + "ffmpeg": { + "inputs": "Vstupní streamy", + "path": "Cesta streamu", + "pathRequired": "Cesta ke streamu je povinná", + "pathPlaceholder": "rtsp://...", + "roles": "Role", + "rolesRequired": "Je vyžadována alespoň jedna role", + "rolesUnique": "Každá role (audio, detekce, záznam) může být přiřazena pouze k jednomu streamu", + "addInput": "Přidat vstupní stream", + "removeInput": "Odebrat vstupní stream", + "inputsRequired": "Je vyžadován alespoň jeden vstupní stream" + }, + "toast": { + "success": "Kamera {{cameraName}} byla úspěšně uložena" + } + } }, "notification": { "notificationSettings": { @@ -469,7 +520,7 @@ "desc": "Je vyžadována platná e-mailová adresa, která bude použita k upozornění v případě problémů se službou push notifikací." }, "registerDevice": "Registrovat Toto Zařízení", - "deviceSpecific": "Nastavení Specifická pro Zařízení", + "deviceSpecific": "Nastavení specifická pro zařízení", "unregisterDevice": "Odregistrovat Toto Zařízení", "sendTestNotification": "Poslat testovací notifikaci", "unsavedRegistrations": "Neuložené přihlášky k Notifikacím", @@ -593,13 +644,13 @@ }, "management": { "desc": "Spravujte uživatelské účty této instance Frigate.", - "title": "Správa Uživatelů" + "title": "Správa uživatelů" }, "addUser": "Přidat uživatele", "title": "Uživatelé" }, "motionDetectionTuner": { - "unsavedChanges": "Neuložené změny Ladiče Detekce Pohybu {{camera}}", + "unsavedChanges": "Neuložené změny ladění detekce pohybu {{camera}}", "improveContrast": { "title": "Zlepšit Kontrast", "desc": "Zlepšit kontrast pro tmavé scény Výchozí: ON" @@ -607,9 +658,9 @@ "toast": { "success": "Nastavení detekce pohybu bylo uloženo." }, - "title": "Ladič Detekce Pohybu", + "title": "Ladění detekce pohybu", "desc": { - "documentation": "Přečtěte si příručku Ladiče Detekce Pohybu", + "documentation": "Přečtěte si příručku Ladění detekce pohybu", "title": "Frigate používá detekci pohybu jako první kontrolu k ověření, zda se ve snímku děje něco, co stojí za další analýzu pomocí detekce objektů." }, "Threshold": { @@ -624,7 +675,7 @@ "enrichments": { "title": "Nastavení obohacení", "faceRecognition": { - "title": "Rozpoznání Obličeje", + "title": "Rozpoznání obličeje", "desc": "Rozpoznávání obličeje umožňuje přiřadit lidem jména a po rozpoznání jejich obličeje. Frigate přiřadí jméno osoby jako podštítek. Tyto informace jsou zahrnuty v uživatelském rozhraní, filtrech a také v oznámeních.", "readTheDocumentation": "Přečtěte si Dokumentaci", "modelSize": { @@ -651,11 +702,11 @@ "alreadyInProgress": "Přeindexování je již spuštěno.", "error": "Chyba spuštění přeindexování: {{errorMessage}}" }, - "title": "Sémantické Vyhledávání", + "title": "Sémantické vyhledávání", "desc": "Sémantické vyhledávání ve Frigate umožňuje najít sledované objekty v rámci vašich zkontrolovaných položek pomocí samotného obrázku, uživatelem definovaného textového popisu nebo automaticky generovaného popisu.", "readTheDocumentation": "Přečtěte si Dokumentaci", "modelSize": { - "label": "Velikost Modelu", + "label": "Velikost modelu", "desc": "Velikost modelu použitého pro vkládání sémantického vyhledávání.", "small": { "title": "malý", @@ -669,7 +720,7 @@ }, "birdClassification": { "desc": "Klasifikace ptáků identifikuje známé ptáky pomocí kvantovaného modelu Tensorflow. Po rozpoznání známého ptáka se jeho běžný název přidá jako sub_label. Tato informace je zahrnuta v uživatelském rozhraní, filtrech a také v oznámeních.", - "title": "Klasifikace Ptáků" + "title": "Klasifikace ptáků" }, "unsavedChanges": "Neuložené změny nastavení Obohacení", "licensePlateRecognition": { @@ -682,5 +733,70 @@ "success": "Nastavení Obohacení uloženo. Restartujte Frigate aby se změny aplikovaly.", "error": "Chyba ukládání změn konfigurace: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Spouštěče", + "management": { + "title": "Správa spouštěčů", + "desc": "Spravovat spouštěče pro {{camera}}. Použít typ miniatury ke spuštění u miniatur podobných vybranému sledovanému objektu a typ popisu ke spuštění u popisů podobných zadanému textu." + }, + "addTrigger": "Přidat spouštěč", + "table": { + "name": "Jméno", + "type": "Typ", + "content": "Obsah", + "threshold": "Prahová hodnota", + "actions": "Akce", + "noTriggers": "Pro tuto kameru nejsou nakonfigurovány žádné spouštěče.", + "edit": "Upravit", + "deleteTrigger": "Smazat spouštěč", + "lastTriggered": "Naposledy spuštěno" + }, + "type": { + "thumbnail": "Miniatura", + "description": "Popis" + }, + "actions": { + "alert": "Označit jako upozornění", + "notification": "Odeslat oznámení" + }, + "dialog": { + "createTrigger": { + "title": "Vytvořit spouštěč", + "desc": "Vytvořit spouštěč pro kameru {{camera}}" + }, + "editTrigger": { + "title": "Upravit spouštěč", + "desc": "Upravit nastavení spouštěče na kameře {{camera}}" + }, + "deleteTrigger": { + "title": "Odstranit spouštěč", + "desc": "Opravdu chcete odstranit spouštěč {{triggerName}}? Tuto akci nelze vrátit zpět." + }, + "form": { + "name": { + "title": "Název", + "placeholder": "Zadejte název spouštěče", + "error": { + "minLength": "Název musí mít alespoň 2 znaky.", + "invalidCharacters": "Jméno může obsahovat pouze písmena, číslice, podtržítka a pomlčky.", + "alreadyExists": "Spouštěč s tímto názvem již pro tuto kameru existuje." + } + }, + "enabled": { + "description": "Povolit nebo zakázat tento spouštěč" + }, + "type": { + "title": "Typ", + "placeholder": "Vybrat typ spouštěče" + }, + "content": { + "title": "Obsah", + "imagePlaceholder": "Vybrat obrázek", + "textPlaceholder": "Zadat textový obsah", + "imageDesc": "Vybrat obrázek, který spustí tuto akci, když bude detekován podobný obrázek." + } + } + } } } diff --git a/web/public/locales/cs/views/system.json b/web/public/locales/cs/views/system.json index fca20986f4..f920a21595 100644 --- a/web/public/locales/cs/views/system.json +++ b/web/public/locales/cs/views/system.json @@ -52,7 +52,8 @@ "detectIsSlow": "{{detect}} je pomalé ({{speed}} ms)", "detectIsVerySlow": "{{detect}} je velmi pomalé ({{speed}} ms)", "detectHighCpuUsage": "{{camera}} má vysoké využití CPU detekcemi ({{detectAvg}} %)", - "ffmpegHighCpuUsage": "{{camera}} má vyské využití CPU FFmpegem ({{ffmpegAvg}}%)" + "ffmpegHighCpuUsage": "{{camera}} má vyské využití CPU FFmpegem ({{ffmpegAvg}}%)", + "shmTooLow": "Alokace /dev/shm ({{total}} MB) by měla být zvýšena alespoň na {{min}} MB." }, "enrichments": { "embeddings": { @@ -77,7 +78,8 @@ "title": "Detektory", "inferenceSpeed": "Detekční rychlost", "memoryUsage": "Detektor využití paměti", - "cpuUsage": "Detektor využití CPU" + "cpuUsage": "Detektor využití CPU", + "cpuUsageInformation": "CPU používané při přípravě vstupních a výstupních dat do/z detekčních modelů. Tato hodnota neměří využití inferenčních operací, ani v případě použití GPU nebo akcelerátoru." }, "hardwareInfo": { "title": "Informace o hardware", @@ -138,7 +140,11 @@ "tips": "Tato hodnota uvádí celkové využití disku záznamy uloženými v databázi Frigate. Frigate nesleduje využití disku ostatními soubory na vašem disku." }, "title": "Úložiště", - "overview": "Přehled" + "overview": "Přehled", + "shm": { + "title": "přiřazení SHM (sdílené paměti)", + "warning": "Nynější velikost SHM činící {{total}}MB je příliš malá. Zvyšte ji alespoň na {{min_shm}}MB." + } }, "lastRefreshed": "Poslední aktualizace: ", "documentTitle": { diff --git a/web/public/locales/da/audio.json b/web/public/locales/da/audio.json index 0967ef424b..c43ee7e63b 100644 --- a/web/public/locales/da/audio.json +++ b/web/public/locales/da/audio.json @@ -1 +1,9 @@ -{} +{ + "clip_clop": "Klepanie kopyt", + "neigh": "Revanie", + "cattle": "Hovädzí dobytok", + "moo": "Bučanie", + "cowbell": "Kravský zvonec", + "pig": "Prasa", + "speech": "Tale" +} diff --git a/web/public/locales/da/common.json b/web/public/locales/da/common.json index b0bbd3d5f8..7625631c2c 100644 --- a/web/public/locales/da/common.json +++ b/web/public/locales/da/common.json @@ -254,5 +254,6 @@ "title": "404", "desc": "Side ikke fundet" }, - "selectItem": "Vælg {{item}}" + "selectItem": "Vælg {{item}}", + "readTheDocumentation": "Læs dokumentationen" } diff --git a/web/public/locales/da/components/auth.json b/web/public/locales/da/components/auth.json index 0967ef424b..6d33375832 100644 --- a/web/public/locales/da/components/auth.json +++ b/web/public/locales/da/components/auth.json @@ -1 +1,11 @@ -{} +{ + "form": { + "user": "Brugernavn", + "password": "Kodeord", + "login": "Log ind", + "errors": { + "usernameRequired": "Brugernavn kræves", + "passwordRequired": "Kodeord kræves" + } + } +} diff --git a/web/public/locales/da/components/camera.json b/web/public/locales/da/components/camera.json index 0967ef424b..45c08ced18 100644 --- a/web/public/locales/da/components/camera.json +++ b/web/public/locales/da/components/camera.json @@ -1 +1,6 @@ -{} +{ + "group": { + "label": "Kamera Grupper", + "add": "Tilføj Kameragruppe" + } +} diff --git a/web/public/locales/da/components/icons.json b/web/public/locales/da/components/icons.json index 0967ef424b..f34a5e7dfc 100644 --- a/web/public/locales/da/components/icons.json +++ b/web/public/locales/da/components/icons.json @@ -1 +1,5 @@ -{} +{ + "iconPicker": { + "selectIcon": "Vælg et ikon" + } +} diff --git a/web/public/locales/da/objects.json b/web/public/locales/da/objects.json index 0967ef424b..cae8fc8e2c 100644 --- a/web/public/locales/da/objects.json +++ b/web/public/locales/da/objects.json @@ -1 +1,3 @@ -{} +{ + "person": "Person" +} diff --git a/web/public/locales/da/views/explore.json b/web/public/locales/da/views/explore.json index 0967ef424b..2c8a63e58a 100644 --- a/web/public/locales/da/views/explore.json +++ b/web/public/locales/da/views/explore.json @@ -1 +1,13 @@ -{} +{ + "documentTitle": "Udforsk - Frigate", + "generativeAI": "Generativ AI", + "type": { + "details": "detaljer", + "video": "video" + }, + "objectLifecycle": { + "lifecycleItemDesc": { + "active": "{{label}} blev aktiv" + } + } +} diff --git a/web/public/locales/da/views/exports.json b/web/public/locales/da/views/exports.json index 0967ef424b..472dead1d0 100644 --- a/web/public/locales/da/views/exports.json +++ b/web/public/locales/da/views/exports.json @@ -1 +1,4 @@ -{} +{ + "documentTitle": "Eksporter - Frigate", + "search": "Søg" +} diff --git a/web/public/locales/da/views/search.json b/web/public/locales/da/views/search.json index 0967ef424b..dffdb9cb18 100644 --- a/web/public/locales/da/views/search.json +++ b/web/public/locales/da/views/search.json @@ -1 +1,3 @@ -{} +{ + "search": "Søg" +} diff --git a/web/public/locales/da/views/settings.json b/web/public/locales/da/views/settings.json index 0967ef424b..ab16dd9f6b 100644 --- a/web/public/locales/da/views/settings.json +++ b/web/public/locales/da/views/settings.json @@ -1 +1,5 @@ -{} +{ + "documentTitle": { + "default": "Indstillinger - Frigate" + } +} diff --git a/web/public/locales/de/common.json b/web/public/locales/de/common.json index 8a3eff88c3..2d20c7f0c9 100644 --- a/web/public/locales/de/common.json +++ b/web/public/locales/de/common.json @@ -15,10 +15,10 @@ "d": "{{time}} Tag", "day_one": "{{time}} Tag", "day_other": "{{time}} Tage", - "m": "{{time}} Minute", + "m": "{{time}} Min", "minute_one": "{{time}} Minute", "minute_other": "{{time}} Minuten", - "s": "{{time}} Sekunde", + "s": "{{time}}s", "second_one": "{{time}} Sekunde", "second_other": "{{time}} Sekunden", "formattedTimestamp2": { @@ -38,7 +38,7 @@ "1hour": "1 Stunde", "lastWeek": "Letzte Woche", "h": "{{time}} Stunde", - "ago": "{{timeAgo}} her", + "ago": "vor {{timeAgo}}", "untilRestart": "Bis zum Neustart", "justNow": "Gerade", "pm": "nachmittags", @@ -107,7 +107,7 @@ "off": "AUS", "reset": "Zurücksetzen", "copy": "Kopieren", - "twoWayTalk": "bidirecktionales Gespräch", + "twoWayTalk": "Zwei-Wege-Kommunikation", "exitFullscreen": "Vollbild verlassen", "unselect": "Selektion aufheben", "copyCoordinates": "Kopiere Koordinaten", @@ -160,7 +160,15 @@ "sk": "Slowakisch", "yue": "粵語 (Kantonesisch)", "th": "ไทย (Thailändisch)", - "ca": "Català (Katalanisch)" + "ca": "Català (Katalanisch)", + "ur": "اردو (Urdu)", + "ptBR": "Portugiesisch (Brasilianisch)", + "sr": "Српски (Serbisch)", + "sl": "Slovenščina (Slowenisch)", + "lt": "Lietuvių (Litauisch)", + "bg": "Български (bulgarisch)", + "gl": "Galego (Galicisch)", + "id": "Bahasa Indonesia (Indonesisch)" }, "appearance": "Erscheinung", "theme": { @@ -168,7 +176,7 @@ "blue": "Blau", "green": "Grün", "default": "Standard", - "nord": "Norden", + "nord": "Nord", "red": "Rot", "contrast": "Hoher Kontrast", "highcontrast": "Hoher Kontrast" @@ -227,7 +235,7 @@ } }, "toast": { - "copyUrlToClipboard": "URL in zwischenablage kopiert.", + "copyUrlToClipboard": "URL in Zwischenablage kopiert.", "save": { "error": { "title": "Speichern der Konfigurationsänderungen gescheitert: {{errorMessage}}", @@ -240,7 +248,7 @@ "title": "Rolle", "admin": "Administrator", "viewer": "Zuschauer", - "desc": "Administratoren haben vollen Zugang zu allen funktionen der Frigate Benutzeroberfläche. Zuschauer können nur Kameras betrachten, erkannte Objekte überprüfen und historische Aufnahmen durchsehen." + "desc": "Administratoren haben vollen Zugang zu allen Funktionen der Frigate Benutzeroberfläche. Zuschauer können nur Kameras betrachten, erkannte Objekte überprüfen und historische Aufnahmen durchsehen." }, "pagination": { "previous": { @@ -260,6 +268,7 @@ "documentTitle": "Nicht gefunden - Frigate" }, "selectItem": "Wähle {{item}}", + "readTheDocumentation": "Dokumentation lesen", "accessDenied": { "desc": "Du hast keine Berechtigung diese Seite anzuzeigen.", "documentTitle": "Zugang verweigert - Frigate", diff --git a/web/public/locales/de/components/camera.json b/web/public/locales/de/components/camera.json index fb6f89e743..32874bab61 100644 --- a/web/public/locales/de/components/camera.json +++ b/web/public/locales/de/components/camera.json @@ -58,7 +58,8 @@ "desc": "Ändere die Live Stream Optionen für das Dashboard dieser Kameragruppe. Diese Einstellungen sind geräte-/browserspezifisch.", "stream": "Stream", "placeholder": "Wähle einen Stream" - } + }, + "birdseye": "Vogelperspektive" }, "add": "Kameragruppe hinzufügen", "cameras": { diff --git a/web/public/locales/de/components/dialog.json b/web/public/locales/de/components/dialog.json index cedd1c114b..578f027738 100644 --- a/web/public/locales/de/components/dialog.json +++ b/web/public/locales/de/components/dialog.json @@ -119,5 +119,12 @@ "markAsReviewed": "Als geprüft markieren", "deleteNow": "Jetzt löschen" } + }, + "imagePicker": { + "selectImage": "Vorschaubild eines verfolgten Objekts selektieren", + "search": { + "placeholder": "Nach Label oder Unterlabel suchen..." + }, + "noImages": "Kein Vorschaubild für diese Kamera gefunden" } } diff --git a/web/public/locales/de/components/filter.json b/web/public/locales/de/components/filter.json index a2c7db779a..193877603a 100644 --- a/web/public/locales/de/components/filter.json +++ b/web/public/locales/de/components/filter.json @@ -101,7 +101,7 @@ "title": "Lade", "desc": "Wenn das Protokollfenster nach unten gescrollt wird, werden neue Protokolle automatisch geladen, sobald sie hinzugefügt werden." }, - "disableLogStreaming": "Log des Streams deaktvieren", + "disableLogStreaming": "Log des Streams deaktivieren", "allLogs": "Alle Logs" }, "trackedObjectDelete": { @@ -121,6 +121,16 @@ "loadFailed": "Bekannte Nummernschilder konnten nicht geladen werden.", "loading": "Lade bekannte Nummernschilder…", "placeholder": "Tippe, um Kennzeichen zu suchen…", - "selectPlatesFromList": "Wählen eine oder mehrere Kennzeichen aus der Liste aus." + "selectPlatesFromList": "Wählen eine oder mehrere Kennzeichen aus der Liste aus.", + "selectAll": "Alle wählen", + "clearAll": "Alle löschen" + }, + "classes": { + "label": "Klassen", + "all": { + "title": "Alle Klassen" + }, + "count_one": "{{count}} Klasse", + "count_other": "{{count}} Klassen" } } diff --git a/web/public/locales/de/views/configEditor.json b/web/public/locales/de/views/configEditor.json index 7f975e31b8..86959e1265 100644 --- a/web/public/locales/de/views/configEditor.json +++ b/web/public/locales/de/views/configEditor.json @@ -12,5 +12,7 @@ } }, "documentTitle": "Konfigurationseditor – Frigate", - "confirm": "Verlassen ohne zu Speichern?" + "confirm": "Verlassen ohne zu Speichern?", + "safeConfigEditor": "Konfiguration Editor (abgesicherter Modus)", + "safeModeDescription": "Frigate ist aufgrund eines Konfigurationsvalidierungsfehlers im abgesicherten Modus." } diff --git a/web/public/locales/de/views/events.json b/web/public/locales/de/views/events.json index 2a38ac0296..e9bdcf4ffd 100644 --- a/web/public/locales/de/views/events.json +++ b/web/public/locales/de/views/events.json @@ -34,5 +34,7 @@ "markAsReviewed": "Als geprüft kennzeichnen", "selected_one": "{{count}} ausgewählt", "selected_other": "{{count}} ausgewählt", - "detected": "erkannt" + "detected": "erkannt", + "suspiciousActivity": "Verdächtige Aktivität", + "threateningActivity": "Bedrohliche Aktivität" } diff --git a/web/public/locales/de/views/explore.json b/web/public/locales/de/views/explore.json index ee518fc11a..96d64e1675 100644 --- a/web/public/locales/de/views/explore.json +++ b/web/public/locales/de/views/explore.json @@ -17,12 +17,14 @@ "success": { "updatedSublabel": "Unterkategorie erfolgreich aktualisiert.", "updatedLPR": "Nummernschild erfolgreich aktualisiert.", - "regenerate": "Eine neue Beschreibung wurde von {{provider}} angefordert. Je nach Geschwindigkeit des Anbieters kann es einige Zeit dauern, bis die neue Beschreibung generiert ist." + "regenerate": "Eine neue Beschreibung wurde von {{provider}} angefordert. Je nach Geschwindigkeit des Anbieters kann es einige Zeit dauern, bis die neue Beschreibung generiert ist.", + "audioTranscription": "Audio Transkription erfolgreich angefordert." }, "error": { "regenerate": "Der Aufruf von {{provider}} für eine neue Beschreibung ist fehlgeschlagen: {{errorMessage}}", "updatedSublabelFailed": "Untekategorie konnte nicht aktualisiert werden: {{errorMessage}}", - "updatedLPRFailed": "Aktualisierung des Kennzeichens fehlgeschlagen: {{errorMessage}}" + "updatedLPRFailed": "Aktualisierung des Kennzeichens fehlgeschlagen: {{errorMessage}}", + "audioTranscription": "Die Anforderung der Audio Transkription ist fehlgeschlagen: {{errorMessage}}" } } }, @@ -55,7 +57,7 @@ }, "description": { "label": "Beschreibung", - "placeholder": "Beschreibund des verfolgten Objekts", + "placeholder": "Beschreibung des verfolgten Objekts", "aiTips": "Frigate wird erst dann eine Beschreibung vom generativen KI-Anbieter anfordern, wenn der Lebenszyklus des verfolgten Objekts beendet ist." }, "expandRegenerationMenu": "Erneuerungsmenü erweitern", @@ -67,6 +69,9 @@ }, "snapshotScore": { "label": "Schnappschuss Bewertung" + }, + "score": { + "label": "Ergebnis" } }, "documentTitle": "Erkunde - Frigate", @@ -182,6 +187,14 @@ }, "deleteTrackedObject": { "label": "Dieses verfolgte Objekt löschen" + }, + "audioTranscription": { + "aria": "Audio Transkription anfordern", + "label": "Transkribieren" + }, + "addTrigger": { + "aria": "Einen Trigger für dieses verfolgte Objekt hinzufügen", + "label": "Trigger hinzufügen" } }, "dialog": { @@ -203,5 +216,11 @@ "fetchingTrackedObjectsFailed": "Fehler beim Abrufen von verfolgten Objekten: {{errorMessage}}", "trackedObjectsCount_one": "{{count}} verfolgtes Objekt ", "trackedObjectsCount_other": "{{count}} verfolgte Objekte ", - "exploreMore": "Erkunde mehr {{label}} Objekte" + "exploreMore": "Erkunde mehr {{label}} Objekte", + "aiAnalysis": { + "title": "KI-Analyse" + }, + "concerns": { + "label": "Bedenken" + } } diff --git a/web/public/locales/de/views/faceLibrary.json b/web/public/locales/de/views/faceLibrary.json index 960c555db1..cda458b658 100644 --- a/web/public/locales/de/views/faceLibrary.json +++ b/web/public/locales/de/views/faceLibrary.json @@ -46,7 +46,7 @@ "train": { "title": "Trainiere", "aria": "Wähle Training", - "empty": "Es gibt keine aktuellen Versuche zurGesichtserkennung" + "empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung" }, "deleteFaceLibrary": { "title": "Lösche Name", diff --git a/web/public/locales/de/views/live.json b/web/public/locales/de/views/live.json index 318c2b720c..fea1cabd8d 100644 --- a/web/public/locales/de/views/live.json +++ b/web/public/locales/de/views/live.json @@ -41,6 +41,14 @@ "center": { "label": "Klicken Sie in den Rahmen, um die PTZ-Kamera zu zentrieren" } + }, + "focus": { + "in": { + "label": "PTZ Kamera hinein fokussieren" + }, + "out": { + "label": "PTZ Kamera hinaus fokussieren" + } } }, "documentTitle": "Live - Frigate", @@ -100,7 +108,7 @@ "tips": "Ihr Gerät muss die Funktion unterstützen und WebRTC muss für die bidirektionale Kommunikation konfiguriert sein.", "tips.documentation": "Dokumentation lesen ", "available": "Für diesen Stream ist eine Zwei-Wege-Sprechfunktion verfügbar", - "unavailable": "Für diesen Stream ist keine Zwei-Wege-Kommunikation möglich." + "unavailable": "Zwei-Wege-Kommunikation für diesen Stream nicht verfügbar" }, "lowBandwidth": { "tips": "Die Live-Ansicht befindet sich aufgrund von Puffer- oder Stream-Fehlern im Modus mit geringer Bandbreite.", @@ -146,7 +154,8 @@ "cameraEnabled": "Kamera aktiviert", "autotracking": "Autotracking", "audioDetection": "Audioerkennung", - "title": "{{camera}} Einstellungen" + "title": "{{camera}} Einstellungen", + "transcription": "Audio Transkription" }, "history": { "label": "Historisches Filmmaterial zeigen" @@ -154,5 +163,9 @@ "audio": "Audio", "suspend": { "forTime": "Aussetzen für: " + }, + "transcription": { + "enable": "Live Audio Transkription einschalten", + "disable": "Live Audio Transkription ausschalten" } } diff --git a/web/public/locales/de/views/search.json b/web/public/locales/de/views/search.json index c3800ab28b..5729716d8a 100644 --- a/web/public/locales/de/views/search.json +++ b/web/public/locales/de/views/search.json @@ -58,7 +58,7 @@ "title": "Wie man Textfilter verwendet" }, "searchType": { - "thumbnail": "Miniaturansicht", + "thumbnail": "Vorschaubild", "description": "Beschreibung" } }, diff --git a/web/public/locales/de/views/settings.json b/web/public/locales/de/views/settings.json index 29c5d6ece4..80902106fa 100644 --- a/web/public/locales/de/views/settings.json +++ b/web/public/locales/de/views/settings.json @@ -8,7 +8,7 @@ "general": "Allgemeine Einstellungen – Frigate", "frigatePlus": "Frigate+ Einstellungen – Frigate", "classification": "Klassifizierungseinstellungen – Frigate", - "motionTuner": "Bewegungstuner – Frigate", + "motionTuner": "Bewegungserkennungs-Optimierer – Frigate", "notifications": "Benachrichtigungs-Einstellungen", "enrichments": "Erweiterte Statistiken - Frigate" }, @@ -17,12 +17,13 @@ "cameras": "Kameraeinstellungen", "classification": "Klassifizierung", "masksAndZones": "Maskierungen / Zonen", - "motionTuner": "Bewegungstuner", + "motionTuner": "Bewegungserkennungs-Optimierer", "debug": "Debug", "frigateplus": "Frigate+", "users": "Benutzer", "notifications": "Benachrichtigungen", - "enrichments": "Verbesserungen" + "enrichments": "Erkennungsfunktionen", + "triggers": "Auslöser" }, "dialog": { "unsavedChanges": { @@ -178,7 +179,45 @@ "detections": "Erkennungen ", "desc": "Aktiviere/deaktiviere Benachrichtigungen und Erkennungen für diese Kamera vorübergehend, bis Frigate neu gestartet wird. Wenn deaktiviert, werden keine neuen Überprüfungseinträge erstellt. " }, - "title": "Kamera-Einstellungen" + "title": "Kameraeinstellungen", + "object_descriptions": { + "title": "Generative KI-Objektbeschreibungen", + "desc": "Generativen KI-Objektbeschreibungen für diese Kamera vorübergehend aktivieren/deaktivieren. Wenn diese Funktion deaktiviert ist, werden keine KI-generierten Beschreibungen für verfolgte Objekte auf dieser Kamera angefordert." + }, + "cameraConfig": { + "ffmpeg": { + "roles": "Rollen", + "pathRequired": "Stream-Pfad ist erforderlich", + "path": "Stream-Pfad", + "inputs": "Eingabe Streams", + "pathPlaceholder": "rtsp://...", + "rolesRequired": "Mindestens eine Rolle ist erforderlich", + "rolesUnique": "Jede Rolle (Audio, Erkennung, Aufzeichnung) kann nur einem Stream zugewiesen werden", + "addInput": "Eingabe-Stream hinzufügen", + "removeInput": "Eingabe-Stream entfernen", + "inputsRequired": "Mindestens ein Eingabe-Stream ist erforderlich" + }, + "enabled": "Aktiviert", + "namePlaceholder": "z. B., Vorder_Türe", + "nameInvalid": "Der Name der Kamera darf nur Buchstaben, Zahlen, Unterstriche oder Bindestriche enthalten", + "name": "Kamera Name", + "edit": "Kamera bearbeiten", + "add": "Kamera hinzufügen", + "description": "Kameraeinstellungen einschließlich Stream-Eingänge und Rollen konfigurieren.", + "nameRequired": "Kameraname ist erforderlich", + "toast": { + "success": "Kamera {{cameraName}} erfolgreich gespeichert" + }, + "nameLength": "Der Name der Kamera darf maximal 24 Zeichen lang sein." + }, + "backToSettings": "Zurück zu den Kamera Einstellungen", + "selectCamera": "Kamera wählen", + "editCamera": "Kamera bearbeiten:", + "addCamera": "Neue Kamera hinzufügen", + "review_descriptions": { + "desc": "Generativen KI-Objektbeschreibungen für diese Kamera vorübergehend aktivieren/deaktivieren. Wenn diese Funktion deaktiviert ist, werden keine KI-generierten Beschreibungen für Überprüfungselemente auf dieser Kamera angefordert.", + "title": "Beschreibungen zur generativen KI-Überprüfung" + } }, "masksAndZones": { "form": { @@ -397,7 +436,20 @@ "desc": "Einen Rahmen für den an den Objektdetektor übermittelten Interessensbereich anzeigen" }, "title": "Debug", - "desc": "Die Debug-Ansicht zeigt eine Echtzeitansicht der verfolgten Objekte und ihrer Statistiken. Die Objektliste zeigt eine zeitverzögerte Zusammenfassung der erkannten Objekte." + "desc": "Die Debug-Ansicht zeigt eine Echtzeitansicht der verfolgten Objekte und ihrer Statistiken. Die Objektliste zeigt eine zeitverzögerte Zusammenfassung der erkannten Objekte.", + "paths": { + "title": "Pfade", + "desc": "Wichtige Punkte des Pfads des verfolgten Objekts anzeigen", + "tips": "

Pfade


Linien und Kreise zeigen wichtige Punkte an, an denen sich das verfolgte Objekt während seines Lebenszyklus bewegt hat.

" + }, + "openCameraWebUI": "Web-Benutzeroberfläche von {{camera}} öffnen", + "audio": { + "title": "Audio", + "noAudioDetections": "Keine Audioerkennungen", + "score": "Punktzahl", + "currentRMS": "Aktueller Effektivwert", + "currentdbFS": "Aktuelle dbFS" + } }, "motionDetectionTuner": { "Threshold": { @@ -420,7 +472,7 @@ "desc": "Der Wert für die Konturfläche wird verwendet, um zu bestimmen, welche Gruppen von veränderten Pixeln als Bewegung gelten. Standard: 10" }, "title": "Bewegungserkennungs-Optimierer", - "unsavedChanges": "Nicht gespeicherte Änderungen am Bewegungstuner ({{camera}})" + "unsavedChanges": "Nicht gespeicherte Änderungen im Bewegungserkennungs-Optimierer ({{camera}})" }, "users": { "addUser": "Benutzer hinzufügen", @@ -494,7 +546,8 @@ "admin": "Admin", "adminDesc": "Voller Zugang zu allen Funktionen.", "viewer": "Betrachter", - "viewerDesc": "Nur auf Live-Dashboards, Überprüfung, Erkundung und Exporte beschränkt." + "viewerDesc": "Nur auf Live-Dashboards, Überprüfung, Erkundung und Exporte beschränkt.", + "customDesc": "Benutzerdefinierte Rolle mit spezifischem Kamerazugriff." }, "title": "Benutzerrolle ändern", "select": "Wähle eine Rolle" @@ -679,5 +732,160 @@ "success": "Die Einstellungen für die Verbesserungen wurden gespeichert. Starten Sie Frigate neu, um Ihre Änderungen zu übernehmen.", "error": "Konfigurationsänderungen konnten nicht gespeichert werden: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Auslöser", + "management": { + "title": "Auslöser Verwaltung", + "desc": "Auslöser für {{camera}} verwalten. Verwenden Sie den Vorschaubild Typ, um ähnliche Vorschaubilder wie das ausgewählte verfolgte Objekt auszulösen, und den Beschreibungstyp, um ähnliche Beschreibungen wie den von Ihnen angegebenen Text auszulösen." + }, + "addTrigger": "Auslöser hinzufügen", + "table": { + "name": "Name", + "type": "Typ", + "content": "Inhalt", + "threshold": "Schwellenwert", + "actions": "Aktionen", + "noTriggers": "Für diese Kamera sind keine Auslöser konfiguriert.", + "edit": "Bearbeiten", + "deleteTrigger": "Auslöser löschen", + "lastTriggered": "Zuletzt ausgelöst" + }, + "type": { + "thumbnail": "Vorschaubild", + "description": "Beschreibung" + }, + "actions": { + "alert": "Als Alarm markieren", + "notification": "Benachrichtigung senden" + }, + "dialog": { + "createTrigger": { + "title": "Auslöser erstellen", + "desc": "Auslöser für Kamera {{camera}} erstellen" + }, + "editTrigger": { + "title": "Auslöser bearbeiten", + "desc": "Einstellungen für Kamera {{camera}} bearbeiten" + }, + "deleteTrigger": { + "title": "Auslöser löschen", + "desc": "Sind Sie sicher, dass Sie den Auslöser {{triggerName}} löschen wollen? Dies kann nicht Rückgängig gemacht werden." + }, + "form": { + "name": { + "title": "Name", + "placeholder": "Auslöser Name eingeben", + "error": { + "minLength": "Der Name muss mindestens 2 Zeichen lang sein.", + "invalidCharacters": "Der Name darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche enthalten.", + "alreadyExists": "Ein Auslöser mit diesem Namen existiert bereits für diese Kamera." + } + }, + "enabled": { + "description": "Diesen Auslöser aktivieren oder deaktivieren" + }, + "type": { + "title": "Typ", + "placeholder": "Auslöser Typ wählen" + }, + "content": { + "title": "Inhalt", + "imagePlaceholder": "Ein Bild auswählen", + "textPlaceholder": "Inhaltstext eingeben", + "imageDesc": "Ein Bild auswählen, um diese Aktion auszulösen, wenn ein ähnliches Bild erkannt wird.", + "textDesc": "Einen Text eingeben, um diese Aktion auszulösen, wenn eine ähnliche Beschreibung eines verfolgten Objekts erkannt wird.", + "error": { + "required": "Inhalt ist erforderlich." + } + }, + "threshold": { + "title": "Schwellenwert", + "error": { + "min": "Schwellenwert muss mindestens 0 sein", + "max": "Schwellenwert darf höchstens 1 sein" + } + }, + "actions": { + "title": "Aktionen", + "desc": "Standardmäßig sendet Frigate eine MQTT-Nachricht für alle Trigger. Wähle eine zusätzliche Aktion aus, die ausgeführt werden soll, wenn dieser Trigger ausgelöst wird.", + "error": { + "min": "Mindesten eine Aktion muss ausgewählt sein." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Auslöser {{name}} erfolgreich erstellt.", + "updateTrigger": "Auslöser {{name}} erfolgreich aktualisiert.", + "deleteTrigger": "Auslöser {{name}} erfolgreich gelöscht." + }, + "error": { + "createTriggerFailed": "Auslöser konnte nicht erstellt werden: {{errorMessage}}", + "updateTriggerFailed": "Auslöser könnte nicht aktualisiert werden: {{errorMessage}}", + "deleteTriggerFailed": "Auslöser konnte nicht gelöscht werden: {{errorMessage}}" + } + } + }, + "roles": { + "dialog": { + "form": { + "cameras": { + "required": "Mindestens eine Kamera muss ausgewählt werden.", + "title": "Kameras", + "desc": "Wählen Sie die Kameras aus, auf die diese Rolle Zugriff hat. Mindestens eine Kamera ist erforderlich." + }, + "role": { + "title": "Rolle Name", + "placeholder": "Rollen Name eingeben", + "desc": "Es sind nur Buchstaben, Zahlen, Punkte und Unterstriche zulässig.", + "roleIsRequired": "Rollen Name ist erforderlich", + "roleOnlyInclude": "Der Rollenname darf nur Buchstaben, Zahlen, . oder _ enthalten", + "roleExists": "Eine Rolle mit diesem Namen existiert bereits." + } + }, + "createRole": { + "title": "Neue Rolle erstellen", + "desc": "Fügen Sie eine neue Rolle hinzu und legen Sie die Berechtigungen für den Kamerazugriff fest." + }, + "editCameras": { + "title": "Rollenkameras bearbeiten", + "desc": "Aktualisieren Sie den Kamerazugriff für die Rolle {{role}}." + }, + "deleteRole": { + "title": "Rolle löschen", + "desc": "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch wird die Rolle dauerhaft gelöscht und allen Benutzern mit dieser Rolle die Rolle „Betrachter“ zugewiesen, die dann Zugriff auf alle Kameras erhält.", + "warn": "Möchten Sie {{role}} wirklich löschen?", + "deleting": "Lösche..." + } + }, + "management": { + "title": "Zuschauer Rollenverwaltung", + "desc": "Verwalten Sie benutzerdefinierte Zuschauerrollen und ihre Kamerazugriffsberechtigungen für diese Frigate-Instanz." + }, + "addRole": "Rolle hinzufügen", + "table": { + "role": "Rolle", + "cameras": "Kameras", + "actions": "Aktionen", + "noRoles": "Keine benutzerdefinierten Rollen gefunden.", + "editCameras": "Kameras bearbeiten", + "deleteRole": "Rolle löschen" + }, + "toast": { + "success": { + "createRole": "Rolle {{role}} erfolgreich erstellt", + "updateCameras": "Kameras für Rolle {{role}} aktualisiert", + "deleteRole": "Rolle {{role}} erfolgreich gelöscht", + "userRolesUpdated": "{{count}} Benutzer, denen diese Rolle zugewiesen wurde, wurden auf „Zuschauer“ aktualisiert, der Zugriff auf alle Kameras hat." + }, + "error": { + "createRoleFailed": "Fehler beim Erstellen der Rolle: {{errorMessage}}", + "updateCamerasFailed": "Aktualisierung der Kameras fehlgeschlagen: {{errorMessage}}", + "deleteRoleFailed": "Rolle konnte nicht gelöscht werden: {{errorMessage}}", + "userUpdateFailed": "Aktualisierung der Benutzerrollen fehlgeschlagen: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/de/views/system.json b/web/public/locales/de/views/system.json index f869f1ba20..b4720ef5fe 100644 --- a/web/public/locales/de/views/system.json +++ b/web/public/locales/de/views/system.json @@ -16,7 +16,7 @@ "vbios": "VBios Info: {{vbios}}" }, "closeInfo": { - "label": "Schhließe GPU Info" + "label": "Schließe GPU Info" }, "copyInfo": { "label": "Kopiere GPU Info" @@ -39,7 +39,8 @@ "cpuUsage": "CPU-Auslastung des Detektors", "memoryUsage": "Arbeitsspeichernutzung des Detektors", "inferenceSpeed": "Detektoren Inferenzgeschwindigkeit", - "temperature": "Temperatur des Detektors" + "temperature": "Temperatur des Detektors", + "cpuUsageInformation": "CPU, die zur Vorbereitung von Eingabe- und Ausgabedaten für/aus Erkennungsmodellen verwendet wird. Dieser Wert misst nicht die Inferenzauslastung, selbst wenn eine GPU oder ein Beschleuniger verwendet wird." }, "otherProcesses": { "title": "Andere Prozesse", @@ -102,7 +103,11 @@ "bandwidth": "Bandbreite" }, "title": "Speicher", - "overview": "Übersicht" + "overview": "Übersicht", + "shm": { + "title": "SHM (Shared Memory) Zuweisung", + "warning": "Die aktuelle SHM-Größe von {{total}} MB ist zu klein. Erhöhe sie auf mindestens {{min_shm}} MB." + } }, "cameras": { "info": { @@ -115,7 +120,7 @@ "unknown": "Unbekannt", "audio": "Audio:", "error": "Fehler: {{error}}", - "cameraProbeInfo": "{{camera}} Kamera-Untersuchsungsinfo", + "cameraProbeInfo": "{{camera}} Kamera-Untersuchungsinfo", "streamDataFromFFPROBE": "Stream-Daten werden mit ffprobe erhalten.", "tips": { "title": "Kamera-Untersuchsungsinfo" @@ -174,7 +179,8 @@ "reindexingEmbeddings": "Neuindizierung von Einbettungen ({{processed}}% erledigt)", "detectIsSlow": "{{detect}} ist langsam ({{speed}} ms)", "detectIsVerySlow": "{{detect}} ist sehr langsam ({{speed}} ms)", - "cameraIsOffline": "{{camera}} ist offline" + "cameraIsOffline": "{{camera}} ist offline", + "shmTooLow": "Die Zuweisung für /dev/shm ({{total}} MB) sollte auf mindestens {{min}} MB erhöht werden." }, "lastRefreshed": "Zuletzt aktualisiert: " } diff --git a/web/public/locales/el/audio.json b/web/public/locales/el/audio.json index f8dfffbc8d..2bd01b8717 100644 --- a/web/public/locales/el/audio.json +++ b/web/public/locales/el/audio.json @@ -48,5 +48,18 @@ "acoustic_guitar": "Ακουστική Κιθάρα", "classical_music": "Κλασική Μουσική", "opera": "Όπερα", - "electronic_music": "Ηλεκτρονική Μουσική" + "electronic_music": "Ηλεκτρονική Μουσική", + "bus": "Λεωφορείο", + "train": "Εκπαίδευση", + "boat": "Βάρκα", + "sigh": "Αναστεναγμός", + "singing": "Τραγούδι", + "choir": "Χορωδία", + "whistling": "Σφύριγμα", + "camera": "Κάμερα", + "wheeze": "Ξεφύσημα", + "yodeling": "Λαρυγγισμός", + "chant": "Ύμνος", + "mantra": "Μάντρα", + "synthetic_singing": "Συνθετικό Τραγούδι" } diff --git a/web/public/locales/el/common.json b/web/public/locales/el/common.json index d521af9a06..04c1dc01b1 100644 --- a/web/public/locales/el/common.json +++ b/web/public/locales/el/common.json @@ -3,6 +3,42 @@ "untilForTime": "Ως{{time}}", "untilForRestart": "Μέχρι να γίνει επανεκίννηση του Frigate.", "untilRestart": "Μέχρι να γίνει επανεκκίνηση", - "justNow": "Μόλις τώρα" + "justNow": "Μόλις τώρα", + "ago": "{{timeAgo}} Πριν", + "today": "Σήμερα", + "yesterday": "Εχθές", + "last7": "Τελευταίες 7 ημέρες", + "year_one": "{{time}} χρόνος", + "year_other": "{{time}} χρόνια", + "month_one": "{{time}} μήνας", + "month_other": "{{time}} μήνες", + "day_one": "{{time}} ημέρα", + "day_other": "{{time}} ημέρες", + "hour_one": "{{time}} ώρα", + "hour_other": "{{time}} ώρες", + "minute_one": "{{time}} λεπτό", + "minute_other": "{{time}} λεπτά", + "second_one": "{{time}} δευτερόλεπτο", + "second_other": "{{time}} δευτερόλεπτα", + "last14": "Τελευταίες 14 ημέρες", + "last30": "Τελευταίες 30 ημέρες", + "thisWeek": "Αυτή την εβδομάδα", + "lastWeek": "Προηγούμενη Εβδομάδα", + "am": "π.μ.", + "yr": "{{time}}χρ", + "mo": "{{time}}μη", + "thisMonth": "Αυτό τον Μήνα", + "lastMonth": "Τελευταίος Μήνας", + "5minutes": "5 λεπτά", + "10minutes": "10 λεπτά", + "30minutes": "30 λεπτά" + }, + "menu": { + "live": { + "cameras": { + "count_one": "{{count}} Κάμερα", + "count_other": "{{count}} Κάμερες" + } + } } } diff --git a/web/public/locales/el/components/auth.json b/web/public/locales/el/components/auth.json index 722e8efbf6..95d30f919d 100644 --- a/web/public/locales/el/components/auth.json +++ b/web/public/locales/el/components/auth.json @@ -4,7 +4,12 @@ "password": "Κωδικός", "login": "Σύνδεση", "errors": { - "usernameRequired": "Απαιτείται όνομα χρήστη" + "usernameRequired": "Απαιτείται όνομα χρήστη", + "passwordRequired": "Απαιτείται κωδικός", + "rateLimit": "Το όριο μεταφοράς έχει ξεπεραστεί. Δοκιμάστε ξανά αργότερα.", + "loginFailed": "Αποτυχία σύνδεσης", + "unknownError": "Άγνωστο σφάλμα. Ελέγξτε το αρχείο καταγραφής.", + "webUnknownError": "Άγνωστο σφάλμα. Εξετάστε το αρχείο καταγραφής κονσόλας." } } } diff --git a/web/public/locales/el/components/camera.json b/web/public/locales/el/components/camera.json index 8d0571fbe3..3de7248eea 100644 --- a/web/public/locales/el/components/camera.json +++ b/web/public/locales/el/components/camera.json @@ -1,6 +1,42 @@ { "group": { "add": "Προσθήκη ομάδας καμερών", - "label": "Ομάδες καμερών" + "label": "Ομάδες καμερών", + "edit": "Επεξεργασία ομάδας καμερών", + "delete": { + "label": "Διαγραφή ομάδας κάμερας", + "confirm": { + "title": "Επιβεβαίωση Διαγραφής", + "desc": "Είστε σίγουροι για την διαγραφή της ομάδας κάμερας {{name}};" + } + }, + "name": { + "label": "Όνομα", + "placeholder": "Εισάγετε όνομα…", + "errorMessage": { + "mustLeastCharacters": "Το όνομα ομάδας κάμερας πρέπει να περιέχει τουλάχιστον 2 χαρακτήρες.", + "exists": "Το όνομα ομάδας κάμερας υπάρχει ήδη.", + "nameMustNotPeriod": "Το όνομα ομάδας κάμερας δεν μπορεί να περιλαμβάνει κενά.", + "invalid": "Άκυρο όνομα ομάδας κάμερας." + } + }, + "camera": { + "setting": { + "audioIsUnavailable": "Ο ήχος δεν είναι διαθέσιμος για αυτή την μετάδοση", + "audio": { + "tips": { + "title": "Η κάμερα πρέπει να εκπέμπει ήχο και να είναι ρυθμισμένο το go2rtc για αυτή την μετάδοση." + } + }, + "stream": "Μετάδοση", + "placeholder": "Επιλέξτε μια μετάδοση" + } + }, + "cameras": { + "label": "Κάμερες", + "desc": "Διαλέξτε κάμερες για αυτή την ομάδα." + }, + "icon": "Εικονίδιο", + "success": "Η ομάδα κάμερας {{name}} έχει αποθηκευθεί." } } diff --git a/web/public/locales/el/components/dialog.json b/web/public/locales/el/components/dialog.json index 5d83ef580a..c4826881aa 100644 --- a/web/public/locales/el/components/dialog.json +++ b/web/public/locales/el/components/dialog.json @@ -32,7 +32,19 @@ }, "export": { "time": { - "fromTimeline": "Επιλογή από Χρονολόγιο" + "fromTimeline": "Επιλογή από Χρονολόγιο", + "lastHour_one": "Τελευταία ώρα", + "lastHour_other": "Τελευταίες {{count}} Ώρες", + "custom": "Προσαρμοσμένο", + "start": { + "title": "Αρχή Χρόνου" + } + }, + "select": "Επιλογή", + "export": "Εξαγωγή", + "selectOrExport": "Επιλογή ή Εξαγωγή", + "toast": { + "success": "Επιτυχής έναρξη εξαγωγής. Δείτε το αρχείο στον φάκελο /exports." } } } diff --git a/web/public/locales/el/components/filter.json b/web/public/locales/el/components/filter.json index ecfa4905e3..da69d7f0ef 100644 --- a/web/public/locales/el/components/filter.json +++ b/web/public/locales/el/components/filter.json @@ -1,6 +1,41 @@ { "filter": "Φίλτρο", "labels": { - "label": "Ετικέτες" - } + "label": "Ετικέτες", + "all": { + "title": "Όλες οι ετικέτες", + "short": "Ετικέτες" + }, + "count_one": "{{count}} Ετικέτα", + "count_other": "{{count}} Ετικέτες" + }, + "classes": { + "all": { + "title": "Όλες οι κλάσεις" + }, + "count_one": "{{count}} Κλάση", + "count_other": "{{count}} Κλάσεις", + "label": "Κλάσεις" + }, + "zones": { + "label": "Ζώνες", + "all": { + "title": "Όλες οι ζώνες", + "short": "Ζώνες" + } + }, + "score": "Σκορ", + "estimatedSpeed": "Εκτιμώμενη Ταχύτητα {{unit}}", + "features": { + "label": "Χαρακτηριστικά", + "hasSnapshot": "Έχει ένα στιγμιότυπο" + }, + "dates": { + "selectPreset": "Διαλέξτε μια Προεπιλογή…", + "all": { + "title": "Όλες οι Ημερομηνίες", + "short": "Ημερομηνίες" + } + }, + "more": "Επιπλέον Φίλτρα" } diff --git a/web/public/locales/el/components/player.json b/web/public/locales/el/components/player.json index 14f444437e..de23a97831 100644 --- a/web/public/locales/el/components/player.json +++ b/web/public/locales/el/components/player.json @@ -37,6 +37,15 @@ "value": "{{droppedFrames}} καρέ" } }, - "decodedFrames": "Αποκωδικοποιημένα Καρέ:" + "decodedFrames": "Αποκωδικοποιημένα Καρέ:", + "droppedFrameRate": "Ρυθμός Απορριφθέντων Καρέ:" + }, + "toast": { + "success": { + "submittedFrigatePlus": "Επιτυχής αποστολή εικόνας στο Frigate+" + }, + "error": { + "submitFrigatePlusFailed": "Αποτυχία αποστολής εικόνας στο Frigate+" + } } } diff --git a/web/public/locales/el/objects.json b/web/public/locales/el/objects.json index 22d5874cac..5cc7e4fe84 100644 --- a/web/public/locales/el/objects.json +++ b/web/public/locales/el/objects.json @@ -4,5 +4,21 @@ "car": "Αυτοκίνητο", "motorcycle": "Μηχανή", "airplane": "Αεροπλάνο", - "bird": "Πουλί" + "bird": "Πουλί", + "bus": "Λεωφορείο", + "train": "Εκπαίδευση", + "boat": "Βάρκα", + "traffic_light": "Φανάρι Κυκλοφορίας", + "fire_hydrant": "Πυροσβεστικός Κρουνός", + "horse": "Άλογο", + "street_sign": "Πινακίδα Δρόμου", + "stop_sign": "Πινακίδα Στοπ", + "bear": "Αρκούδα", + "zebra": "Ζέμπρα", + "giraffe": "Καμηλοπάρδαλη", + "hat": "Καπέλο", + "parking_meter": "Παρκόμετρο", + "bench": "Παγκάκι", + "cat": "Γάτα", + "dog": "Σκύλος" } diff --git a/web/public/locales/el/views/configEditor.json b/web/public/locales/el/views/configEditor.json index d468103fad..79917bf966 100644 --- a/web/public/locales/el/views/configEditor.json +++ b/web/public/locales/el/views/configEditor.json @@ -1,5 +1,18 @@ { "documentTitle": "Επεξεργαστής ρυθμίσεων - Frigate", "configEditor": "Επεξεργαστής Ρυθμίσεων", - "saveAndRestart": "Αποθήκευση και επανεκκίνηση" + "saveAndRestart": "Αποθήκευση και επανεκκίνηση", + "safeConfigEditor": "Επεξεργαστής ρυθμίσεων (Ασφαλής Λειτουργία)", + "safeModeDescription": "Το Frigate είναι σε ασφαλή λειτουργία λόγω λάθους εγκυρότητας ρυθμίσεων.", + "copyConfig": "Αντιγραφή Ρυθμίσεων", + "saveOnly": "Μόνο Αποθήκευση", + "confirm": "Έξοδος χωρίς αποθήκευση;", + "toast": { + "success": { + "copyToClipboard": "Οι Ρυθμίσεις αντιγράφτηκαν στο πρόχειρο." + }, + "error": { + "savingError": "Σφάλμα αποθήκευσης ρυθμίσεων" + } + } } diff --git a/web/public/locales/el/views/events.json b/web/public/locales/el/views/events.json index 76dc0264a7..e2e21a05ca 100644 --- a/web/public/locales/el/views/events.json +++ b/web/public/locales/el/views/events.json @@ -4,5 +4,29 @@ "motion": { "label": "Κίνηση", "only": "Κίνηση μόνο" - } + }, + "allCameras": "Όλες οι κάμερες", + "empty": { + "alert": "Δεν υπάρχουν ειδοποιήσεις για εξέταση", + "detection": "Δεν υπάρχουν εντοπισμοί για εξέταση", + "motion": "Δεν βρέθηκαν στοιχεία κίνησης" + }, + "timeline": "Χρονολόγιο", + "timeline.aria": "Επιλογή χρονοσειράς", + "events": { + "label": "Γεγονότα", + "aria": "Επιλογή γεγονότων", + "noFoundForTimePeriod": "Δεν βρέθηκαν γεγονότα για αυτή την περίοδο." + }, + "selected_other": "{{count}} επελεγμένα", + "camera": "Κάμερα", + "detected": "ανιχνέυτηκε", + "documentTitle": "Προεσκόπιση - Frigate", + "recordings": { + "documentTitle": "Καταγραφές - Frigate" + }, + "calendarFilter": { + "last24Hours": "Τελευταίες 24 Ώρες" + }, + "markAsReviewed": "Επιβεβαίωση ως Ελεγμένα" } diff --git a/web/public/locales/el/views/explore.json b/web/public/locales/el/views/explore.json index a48e770eaf..12390dcb94 100644 --- a/web/public/locales/el/views/explore.json +++ b/web/public/locales/el/views/explore.json @@ -1,3 +1,46 @@ { - "documentTitle": "Εξερευνήστε - Frigate" + "documentTitle": "Εξερευνήστε - Frigate", + "generativeAI": "Παραγωγική τεχνητή νοημοσύνη", + "exploreMore": "Εξερευνήστε περισσότερα αντικείμενα {{label}}", + "exploreIsUnavailable": { + "title": "Η εξερεύνηση δεν είναι διαθέσιμη", + "embeddingsReindexing": { + "context": "Η εξερεύνηση μπορεί να πραγματοποιηθεί μετά το πέρας της καταλογράφησης εμπλουτισμών.", + "startingUp": "Εκκίνηση…", + "estimatedTime": "Εκτιμώμενο υπόλοιπο χρόνου:", + "finishingShortly": "Ολοκλήρωση συντόμως", + "step": { + "thumbnailsEmbedded": "Ενσωματωμένες εικόνες: ", + "descriptionsEmbedded": "Ενσωματωμένες περιγραφές: ", + "trackedObjectsProcessed": "Επεξεργασία παρακολουθούμενων αντικειμένων: " + } + }, + "downloadingModels": { + "context": "Το Frigate κατεβάζει τα απαιτούμενα μοντέλα ενσωμάτωσης για να υποστηρίξει την σημασιολογική αναζήτηση. Αυτό μπορεί να διαρκέσει αρκετά λεπτά αναλόγως και της ταχύτητας σύνδεσης με το διαδύκτιο.", + "setup": { + "visionModel": "Οπτικό Μοντέλο", + "visionModelFeatureExtractor": "Εξαγωγή χαρακτηριστικών οπτικού μοντέλου", + "textModel": "Μοντέλο γραφής" + } + } + }, + "details": { + "timestamp": "Χρονοσήμανση", + "item": { + "tips": { + "mismatch_one": "{{count}} μη διαθέσιμο αντικείμενο ανιχνεύτηκε και έχει συνιπολογιστεί στην προεσκόπιση. Αυτό το αντικείμενο είτε δεν πληροί τις προϋποθέσεις ως προειδοποίηση ή ανίχνευση ή έχει ήδη καθαριστεί/διαγραφεί.", + "mismatch_other": "{{count}} μη διαθέσιμα αντικείμενα ανιχνεύτηκαν και έχουν συνιπολογιστεί στην προεσκόπιση. Αυτά τα αντικείμενα είτε δεν πληρούν τις προϋποθέσεις ως προειδοποιήσεις ή ανιχνεύσεις ή έχουν ήδη καθαριστεί/διαγραφεί." + } + } + }, + "type": { + "video": "βίντεο", + "object_lifecycle": "κύκλος ζωής αντικειμένου" + }, + "objectLifecycle": { + "title": "Κύκλος Ζωής Αντικειμένου", + "noImageFound": "Δεν βρέθηκε εικόνα για αυτό το χρονικό σημείο." + }, + "trackedObjectsCount_one": "{{count}} παρακολουθούμενο αντικείμενο ", + "trackedObjectsCount_other": "{{count}} παρακολουθούμενα αντικείμενα " } diff --git a/web/public/locales/el/views/exports.json b/web/public/locales/el/views/exports.json index e8517ae5cc..8aff542388 100644 --- a/web/public/locales/el/views/exports.json +++ b/web/public/locales/el/views/exports.json @@ -1,5 +1,17 @@ { "documentTitle": "Εξαγωγή - Frigate", "search": "Αναζήτηση", - "deleteExport": "Διαγραφή εξαγωγής" + "deleteExport": "Διαγραφή εξαγωγής", + "noExports": "Δεν βρέθηκαν εξαγωγές", + "deleteExport.desc": "Είστε σίγουροι οτι θέλετε να διαγράψετε {{exportName}};", + "editExport": { + "title": "Μετονομασία Εξαγωγής", + "desc": "Εισάγετε ένα νέο όνομα για την εξαγωγή.", + "saveExport": "Αποθήκευση Εξαγωγής" + }, + "toast": { + "error": { + "renameExportFailed": "Αποτυχία μετονομασίας εξαγωγής:{{errorMessage}}" + } + } } diff --git a/web/public/locales/el/views/faceLibrary.json b/web/public/locales/el/views/faceLibrary.json index 8ee6c96900..f41e89c968 100644 --- a/web/public/locales/el/views/faceLibrary.json +++ b/web/public/locales/el/views/faceLibrary.json @@ -5,6 +5,45 @@ "invalidName": "Μη έγκυρο όνομα. Τα ονόματα μπορούν να περιλαμβάνουν γράμματα, αριθμούς, κενό διάστημα, απόστροφο, παύλα, κάτω παύλα." }, "details": { - "person": "Άτομο" + "person": "Άτομο", + "subLabelScore": "Σκορ υποετικέτας", + "scoreInfo": "Το σκορ υποετικέτας είναι το σταθμισμένο σκορ όλων των αναγνωρισμένων προσώπων, αυτό μπορεί να διαφέρει από το σκορ που φαίνεται στο στιγμιότυπο.", + "face": "Λεπτομέρειες προσώπου", + "faceDesc": "Λεπτομέρειες του παρακολουθούμενου αντικειμένου που παρήγε αυτό το πρόσωπο", + "timestamp": "Χρονοσήμανση", + "unknown": "Άγνωστο" + }, + "deleteFaceAttempts": { + "desc_one": "Είστε σίγουροι ότι θέλετε να διαγράψετε {{count}} πρόσωπο; Αυτή η πράξη δεν επαναφέρεται.", + "desc_other": "Είστε σίγουροι ότι θέλετε να διαγράψετε {{count}} πρόσωπα; Αυτή η πράξη δεν επαναφέρεται." + }, + "toast": { + "success": { + "deletedFace_one": "Επιτυχής διαγραφή {{count}} προσώπου.", + "deletedFace_other": "Επιτυχής διαγραφή {{count}} προσώπων.", + "deletedName_one": "{{count}} πρόσωπο διεγράφη επιτυχημένα.", + "deletedName_other": "{{count}} πρόσωπα διεγράφη επιτυχημένα." + } + }, + "documentTitle": "Βιβλιοθήκη προσώπων - Frigate", + "uploadFaceImage": { + "title": "Μεταφόρτωση Εικόνας Προσώπου" + }, + "steps": { + "nextSteps": "Επόμενα βήματα", + "description": { + "uploadFace": "Μεταφορτώστε μια εικόνα του/της {{name}} που δείχνει το πρόσωπο τους από μπροστινή λήψη. Η εικόνα δεν χρειάζεται να περιέχει μόνο το πρόσωπο τους." + } + }, + "train": { + "title": "Εκπαίδευση", + "aria": "Επιλογή εκπαίδευσης", + "empty": "Δεν υπάρχουν πρόσφατες προσπάθειες αναγνώρισης προσώπου" + }, + "collections": "Συλλογές", + "createFaceLibrary": { + "title": "Δημιουργία Συλλογής", + "desc": "Δημιουργία νέας συλλογής", + "new": "Δημιουργία Νέου Προσώπου" } } diff --git a/web/public/locales/el/views/live.json b/web/public/locales/el/views/live.json index daeb09636e..8542f02bec 100644 --- a/web/public/locales/el/views/live.json +++ b/web/public/locales/el/views/live.json @@ -1,6 +1,66 @@ { "documentTitle": "Ζωντανά - Frigate", "twoWayTalk": { - "enable": "Ενεργοποίηση αμφίδρομης επικοινωνίας" + "enable": "Ενεργοποίηση αμφίδρομης επικοινωνίας", + "disable": "Απενεργοποίηση αμφίδρομης επικοινωνίας" + }, + "documentTitle.withCamera": "{{camera}} - Ζωντανή μετάδοση - Frigate", + "lowBandwidthMode": "Λειτουργία χαμηλής ευρυζωνικότητας", + "cameraAudio": { + "enable": "Ενεργοποίηση ήχου Κάμερας", + "disable": "Απενεργοποίηση Ήχου Κάμερας" + }, + "ptz": { + "move": { + "clickMove": { + "label": "Πατήστε στο πλαίσιο για να κεντράρετε την κάμερα", + "enable": "Ενεργοποίηση κλικ για μεταφορά", + "disable": "Απενεργοποίηση κλικ για μεταφορά" + }, + "left": { + "label": "Κίνηση κάμερας προς τα αριστερά" + }, + "up": { + "label": "Κίνηση κάμερας προς τα πάνω" + }, + "down": { + "label": "Κίνηση κάμερας προς τα κάτω" + }, + "right": { + "label": "Κίνηση κάμερας προς τα δεξιά" + } + }, + "zoom": { + "in": { + "label": "Ζουμάρισμα κάμερας προς τα μέσα" + }, + "out": { + "label": "Ζουμάρισμα κάμερας προς τα έξω" + } + } + }, + "camera": { + "enable": "Ενεργοποίηση Κάμερας", + "disable": "Απενεργοποίηση Κάμερας" + }, + "muteCameras": { + "enable": "Σίγαση Όλων των Καμερών", + "disable": "Απενεργοποίηση Σίγασης Όλων των Καμερών" + }, + "detect": { + "enable": "Ενεργοποίηση Ανίχνευσης", + "disable": "Απενεργοποίηση Ανίχνευσης" + }, + "recording": { + "enable": "Ενεργοποίηση Καταγραφής", + "disable": "Απενεργοποίηση Καταγραφής" + }, + "snapshots": { + "enable": "Ενεργοποίηση Στιγμιοτίπων", + "disable": "Απενεργοποίηση Στιγμιοτίπων" + }, + "audioDetect": { + "enable": "Ενεργοποίηση Ανίχνευσης Ήχου", + "disable": "Απενεργοποίηση Ανίχνευσης Ήχου" } } diff --git a/web/public/locales/el/views/recording.json b/web/public/locales/el/views/recording.json index 063abbd2ba..9681d0e2aa 100644 --- a/web/public/locales/el/views/recording.json +++ b/web/public/locales/el/views/recording.json @@ -2,5 +2,11 @@ "filter": "Φίλτρο", "export": "Εξαγωγή", "calendar": "Ημερολόγιο", - "filters": "Φίλτρα" + "filters": "Φίλτρα", + "toast": { + "error": { + "noValidTimeSelected": "Μη επιλογή έγκυρης περιόδου", + "endTimeMustAfterStartTime": "Το επιλεγμένο τέλος περιόδου πρέπει να είναι μετά την επιλεγμένη αρχή περιόδου" + } + } } diff --git a/web/public/locales/el/views/search.json b/web/public/locales/el/views/search.json index 96ca56e0d8..1281446cf0 100644 --- a/web/public/locales/el/views/search.json +++ b/web/public/locales/el/views/search.json @@ -2,6 +2,28 @@ "search": "Αναζήτηση", "savedSearches": "Αποθηκευμένες Αναζητήσεις", "button": { - "clear": "Εκαθάρηση αναζήτησης" + "clear": "Εκαθάρηση αναζήτησης", + "save": "Αποθήκευση αναζήτησης", + "delete": "Διαγραφή αποθηκευμένης αναζήτησης", + "filterInformation": "Πληροφορίες φίλτρου", + "filterActive": "Φίλτρα ενεργά" + }, + "searchFor": "Αναζήτηση {{inputValue}}", + "trackedObjectId": "Σήμανση παρακολουθούμενου αντικειμένου", + "filter": { + "label": { + "cameras": "Κάμερες", + "labels": "Ετικέτες", + "zones": "Ζώνες", + "max_speed": "Ανώτατη Ταχύτητα", + "recognized_license_plate": "Αναγνωρισμένη Πινακίδα Κυκλοφορίας", + "has_clip": "Έχει Κλιπ", + "has_snapshot": "Έχει Στιγμιότυπο", + "sub_labels": "Υποετικέτες", + "search_type": "Τύπος Αναζήτησης", + "time_range": "Χρονική Περίοδος", + "before": "Πριν", + "after": "Μετά" + } } } diff --git a/web/public/locales/el/views/settings.json b/web/public/locales/el/views/settings.json index 0d184f3d2c..43a5a4a52e 100644 --- a/web/public/locales/el/views/settings.json +++ b/web/public/locales/el/views/settings.json @@ -2,6 +2,45 @@ "documentTitle": { "default": "Ρυθμίσεις - Frigate", "authentication": "Ρυθμίσεις ελέγχου ταυτοποίησης - Frigate", - "camera": "Ρυθμίσεις Κάμερας - Frigate" + "camera": "Ρυθμίσεις Κάμερας - Frigate", + "enrichments": "Ρυθμίσεις εμπλουτισμού - Frigate", + "masksAndZones": "Ρυθμίσεις Μασκών και Ζωνών - Frigate", + "motionTuner": "Ρύθμιση Κίνησης - Frigate", + "object": "Επίλυση σφαλμάτων - Frigate", + "general": "Γενικές ρυθμίσεις - Frigate", + "frigatePlus": "Ρυθμίσεις Frigate+ - Frigate", + "notifications": "Ρυθμίσεις Ειδοποιήσεων" + }, + "masksAndZones": { + "zones": { + "point_one": "{{count}} σημέιο", + "point_other": "{{count}} σημεία" + }, + "motionMasks": { + "point_one": "{{count}} σημείο", + "point_other": "{{count}} σημεία" + }, + "objectMasks": { + "point_one": "{{count}} σημέιο", + "point_other": "{{count}} σημεία" + } + }, + "menu": { + "ui": "Επιφάνεια Εργασίας", + "enrichments": "Εμπλουτισμοί", + "cameras": "Ρυθμίσεις Κάμερας", + "masksAndZones": "Μάσκες / Ζώνες", + "motionTuner": "Ρυθμιστής Κίνησης", + "debug": "Επίλυση Σφαλμάτων" + }, + "dialog": { + "unsavedChanges": { + "title": "Έχετε μη αποθηκευμένες αλλαγές.", + "desc": "Θέλετε να αποθηκεύσετε τις αλλαγές σας πριν την συνέχεια;" + } + }, + "cameraSetting": { + "camera": "Κάμερα", + "noCamera": "Δεν υπάρχει Κάμερα" } } diff --git a/web/public/locales/el/views/system.json b/web/public/locales/el/views/system.json index 3076645d68..0ec8ff5879 100644 --- a/web/public/locales/el/views/system.json +++ b/web/public/locales/el/views/system.json @@ -1,5 +1,39 @@ { "documentTitle": { - "cameras": "Στατιστικά Καμερών - Frigate" + "cameras": "Στατιστικά Καμερών - Frigate", + "storage": "Στατιστικά αποθήκευσης - Frigate", + "general": "Γενικά στατιστικά - Frigate", + "enrichments": "Στατιστικά Εμπλουτισμού - Frigate", + "logs": { + "frigate": "Frigate αρχέιο καταγραφών - Frigate", + "go2rtc": "Αρχείο καταγραφής Go2RTC - Frigate", + "nginx": "Αρχείο καταγραφών Nginx - Frigate" + } + }, + "title": "Σύστημα", + "metrics": "Μετρήσεις συστήματος", + "logs": { + "download": { + "label": "Λήψη Αρχείων Καταγραφής" + }, + "copy": { + "label": "Αντιγραφή στο πρόχειρο", + "success": "Αρχεία καταγραφής αντιγράφτηκαν στο πρόχειρο", + "error": "Αποτυχία αντιγραφής των αρχείων καταγραφής στο πρόχειρο" + }, + "type": { + "label": "Τύπος", + "timestamp": "Χρονοσήμανση", + "tag": "Λέξη Κλειδί", + "message": "Μήνυμα" + } + }, + "general": { + "title": "Γενικά", + "detector": { + "title": "Ανιχνευτές", + "inferenceSpeed": "Ταχύτητα Συμπεράσματος Ανιχνευτή", + "temperature": "Θερμοκρασία Ανιχνευτή" + } } } diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 86304fff30..c22a9227d1 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -262,5 +262,6 @@ "title": "404", "desc": "Page not found" }, - "selectItem": "Select {{item}}" + "selectItem": "Select {{item}}", + "readTheDocumentation": "Read the documentation" } diff --git a/web/public/locales/en/components/camera.json b/web/public/locales/en/components/camera.json index 10513a7291..864efa6c4b 100644 --- a/web/public/locales/en/components/camera.json +++ b/web/public/locales/en/components/camera.json @@ -27,6 +27,7 @@ "icon": "Icon", "success": "Camera group ({{name}}) has been saved.", "camera": { + "birdseye": "Birdseye", "setting": { "label": "Camera Streaming Settings", "title": "{{cameraName}} Streaming Settings", @@ -35,8 +36,7 @@ "audioIsUnavailable": "Audio is unavailable for this stream", "audio": { "tips": { - "title": "Audio must be output from your camera and configured in go2rtc for this stream.", - "document": "Read the documentation " + "title": "Audio must be output from your camera and configured in go2rtc for this stream." } }, "stream": "Stream", diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 8b2dc0b880..a64dbf5948 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -69,8 +69,7 @@ "restreaming": { "disabled": "Restreaming is not enabled for this camera.", "desc": { - "title": "Set up go2rtc for additional live view options and audio for this camera.", - "readTheDocumentation": "Read the documentation" + "title": "Set up go2rtc for additional live view options and audio for this camera." } }, "showStats": { @@ -109,5 +108,12 @@ "markAsReviewed": "Mark as reviewed", "deleteNow": "Delete Now" } + }, + "imagePicker": { + "selectImage": "Select a tracked object's thumbnail", + "search": { + "placeholder": "Search by label or sub label..." + }, + "noImages": "No thumbnails found for this camera" } } diff --git a/web/public/locales/en/components/filter.json b/web/public/locales/en/components/filter.json index 08a0ee2b2c..177234bed0 100644 --- a/web/public/locales/en/components/filter.json +++ b/web/public/locales/en/components/filter.json @@ -1,5 +1,11 @@ { "filter": "Filter", + "classes": { + "label": "Classes", + "all": { "title": "All Classes" }, + "count_one": "{{count}} Class", + "count_other": "{{count}} Classes" + }, "labels": { "label": "Labels", "all": { @@ -121,6 +127,8 @@ "loading": "Loading recognized license plates…", "placeholder": "Type to search license plates…", "noLicensePlatesFound": "No license plates found.", - "selectPlatesFromList": "Select one or more plates from the list." + "selectPlatesFromList": "Select one or more plates from the list.", + "selectAll": "Select all", + "clearAll": "Clear all" } } diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json new file mode 100644 index 0000000000..47b2b13bfa --- /dev/null +++ b/web/public/locales/en/views/classificationModel.json @@ -0,0 +1,53 @@ +{ + "button": { + "deleteClassificationAttempts": "Delete Classification Images", + "renameCategory": "Rename Class", + "deleteCategory": "Delete Class", + "deleteImages": "Delete Images", + "trainModel": "Train Model" + }, + "toast": { + "success": { + "deletedCategory": "Deleted Class", + "deletedImage": "Deleted Images", + "categorizedImage": "Successfully Classified Image", + "trainedModel": "Successfully trained model.", + "trainingModel": "Successfully started model training." + }, + "error": { + "deleteImageFailed": "Failed to delete: {{errorMessage}}", + "deleteCategoryFailed": "Failed to delete class: {{errorMessage}}", + "categorizeFailed": "Failed to categorize image: {{errorMessage}}", + "trainingFailed": "Failed to start model training: {{errorMessage}}" + } + }, + "deleteCategory": { + "title": "Delete Class", + "desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model." + }, + "deleteDatasetImages": { + "title": "Delete Dataset Images", + "desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model." + }, + "deleteTrainImages": { + "title": "Delete Train Images", + "desc": "Are you sure you want to delete {{count}} images? This action cannot be undone." + }, + "renameCategory": { + "title": "Rename Class", + "desc": "Enter a new name for {{name}}. You will be required to retrain the model for the name change to take affect." + }, + "description": { + "invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens." + }, + "train": { + "title": "Train", + "aria": "Select Train" + }, + "categories": "Classes", + "createCategory": { + "new": "Create New Class" + }, + "categorizeImageAs": "Classify Image As:", + "categorizeImage": "Classify Image" +} diff --git a/web/public/locales/en/views/configEditor.json b/web/public/locales/en/views/configEditor.json index ef3035f385..614143c16d 100644 --- a/web/public/locales/en/views/configEditor.json +++ b/web/public/locales/en/views/configEditor.json @@ -1,6 +1,8 @@ { "documentTitle": "Config Editor - Frigate", "configEditor": "Config Editor", + "safeConfigEditor": "Config Editor (Safe Mode)", + "safeModeDescription": "Frigate is in safe mode due to a config validation error.", "copyConfig": "Copy Config", "saveAndRestart": "Save & Restart", "saveOnly": "Save Only", diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index 98bc7c4228..77c626adff 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -34,5 +34,7 @@ "selected_one": "{{count}} selected", "selected_other": "{{count}} selected", "camera": "Camera", - "detected": "detected" + "detected": "detected", + "suspiciousActivity": "Suspicious Activity", + "threateningActivity": "Threatening Activity" } diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index 7e23814450..f35cfdc1d9 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -24,8 +24,7 @@ "textTokenizer": "Text tokenizer" }, "tips": { - "context": "You may want to reindex the embeddings of your tracked objects once the models are downloaded.", - "documentation": "Read the documentation" + "context": "You may want to reindex the embeddings of your tracked objects once the models are downloaded." }, "error": "An error has occurred. Check Frigate logs." } @@ -73,7 +72,6 @@ "offset": { "label": "Annotation Offset", "desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. However, the annotation_offset field can be used to adjust this.", - "documentation": "Read the documentation ", "millisecondsToOffset": "Milliseconds to offset detect annotations by. Default: 0", "tips": "TIP: Imagine there is an event clip with a person walking from left to right. If the event timeline bounding box is consistently to the left of the person then the value should be decreased. Similarly, if a person is walking from left to right and the bounding box is consistently ahead of the person then the value should be increased.", "toast": { @@ -103,12 +101,14 @@ "success": { "regenerate": "A new description has been requested from {{provider}}. Depending on the speed of your provider, the new description may take some time to regenerate.", "updatedSublabel": "Successfully updated sub label.", - "updatedLPR": "Successfully updated license plate." + "updatedLPR": "Successfully updated license plate.", + "audioTranscription": "Successfully requested audio transcription." }, "error": { "regenerate": "Failed to call {{provider}} for a new description: {{errorMessage}}", "updatedSublabelFailed": "Failed to update sub label: {{errorMessage}}", - "updatedLPRFailed": "Failed to update license plate: {{errorMessage}}" + "updatedLPRFailed": "Failed to update license plate: {{errorMessage}}", + "audioTranscription": "Failed to request audio transcription: {{errorMessage}}" } } }, @@ -130,6 +130,9 @@ "label": "Top Score", "info": "The top score is the highest median score for the tracked object, so this may differ from the score shown on the search result thumbnail." }, + "score": { + "label": "Score" + }, "recognizedLicensePlate": "Recognized License Plate", "estimatedSpeed": "Estimated Speed", "objects": "Objects", @@ -173,6 +176,14 @@ "label": "Find similar", "aria": "Find similar tracked objects" }, + "addTrigger": { + "label": "Add trigger", + "aria": "Add a trigger for this tracked object" + }, + "audioTranscription": { + "label": "Transcribe", + "aria": "Request audio transcription" + }, "submitToPlus": { "label": "Submit to Frigate+", "aria": "Submit to Frigate Plus" @@ -203,5 +214,11 @@ "error": "Failed to delete tracked object: {{errorMessage}}" } } + }, + "aiAnalysis": { + "title": "AI Analysis" + }, + "concerns": { + "label": "Concerns" } } diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index e734ca9740..7bd60f95af 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -71,7 +71,6 @@ }, "nofaces": "No faces available", "pixels": "{{area}}px", - "readTheDocs": "Read the documentation", "trainFaceAs": "Train Face as:", "trainFace": "Train Face", "toast": { diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index 1790467d2c..f20fc51189 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -38,6 +38,14 @@ "label": "Zoom PTZ camera out" } }, + "focus": { + "in": { + "label": "Focus PTZ camera in" + }, + "out": { + "label": "Focus PTZ camera out" + } + }, "frame": { "center": { "label": "Click in the frame to center the PTZ camera" @@ -69,6 +77,10 @@ "enable": "Enable Audio Detect", "disable": "Disable Audio Detect" }, + "transcription": { + "enable": "Enable Live Audio Transcription", + "disable": "Disable Live Audio Transcription" + }, "autotracking": { "enable": "Enable Autotracking", "disable": "Disable Autotracking" @@ -107,15 +119,13 @@ "title": "Stream", "audio": { "tips": { - "title": "Audio must be output from your camera and configured in go2rtc for this stream.", - "documentation": "Read the documentation " + "title": "Audio must be output from your camera and configured in go2rtc for this stream." }, "available": "Audio is available for this stream", "unavailable": "Audio is not available for this stream" }, "twoWayTalk": { "tips": "Your device must support the feature and WebRTC must be configured for two-way talk.", - "tips.documentation": "Read the documentation ", "available": "Two-way talk is available for this stream", "unavailable": "Two-way talk is unavailable for this stream" }, @@ -135,6 +145,7 @@ "recording": "Recording", "snapshots": "Snapshots", "audioDetection": "Audio Detection", + "transcription": "Audio Transcription", "autotracking": "Autotracking" }, "history": { diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 2b92e81cd2..7d31180a9b 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -17,6 +17,7 @@ "cameras": "Camera Settings", "masksAndZones": "Masks / Zones", "motionTuner": "Motion Tuner", + "triggers": "Triggers", "debug": "Debug", "users": "Users", "notifications": "Notifications", @@ -92,7 +93,6 @@ "semanticSearch": { "title": "Semantic Search", "desc": "Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one.", - "readTheDocumentation": "Read the Documentation", "reindexNow": { "label": "Reindex Now", "desc": "Reindexing will regenerate embeddings for all tracked object. This process runs in the background and may max out your CPU and take a fair amount of time depending on the number of tracked objects you have.", @@ -119,7 +119,6 @@ "faceRecognition": { "title": "Face Recognition", "desc": "Face recognition allows people to be assigned names and when their face is recognized Frigate will assign the person's name as a sub label. This information is included in the UI, filters, as well as in notifications.", - "readTheDocumentation": "Read the Documentation", "modelSize": { "label": "Model Size", "desc": "The size of the model used for face recognition.", @@ -135,8 +134,7 @@ }, "licensePlateRecognition": { "title": "License Plate Recognition", - "desc": "Frigate can recognize license plates on vehicles and automatically add the detected characters to the recognized_license_plate field or a known name as a sub_label to objects that are of type car. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.", - "readTheDocumentation": "Read the Documentation" + "desc": "Frigate can recognize license plates on vehicles and automatically add the detected characters to the recognized_license_plate field or a known name as a sub_label to objects that are of type car. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street." }, "restart_required": "Restart required (Enrichments settings changed)", "toast": { @@ -150,6 +148,14 @@ "title": "Streams", "desc": "Temporarily disable a camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams." }, + "object_descriptions": { + "title": "Generative AI Object Descriptions", + "desc": "Temporarily enable/disable Generative AI object descriptions for this camera. When disabled, AI generated descriptions will not be requested for tracked objects on this camera." + }, + "review_descriptions": { + "title": "Generative AI Review Descriptions", + "desc": "Temporarily enable/disable Generative AI review descriptions for this camera. When disabled, AI generated descriptions will not be requested for review items on this camera." + }, "review": { "title": "Review", "desc": "Temporarily enable/disable alerts and detections for this camera until Frigate restarts. When disabled, no new review items will be generated. ", @@ -159,7 +165,7 @@ "reviewClassification": { "title": "Review Classification", "desc": "Frigate categorizes review items as Alerts and Detections. By default, all person and car objects are considered Alerts. You can refine categorization of your review items by configuring required zones for them.", - "readTheDocumentation": "Read the Documentation", + "noDefinedZones": "No zones are defined for this camera.", "objectAlertsTips": "All {{alertsLabels}} objects on {{cameraName}} will be shown as Alerts.", "zoneObjectAlertsTips": "All {{alertsLabels}} objects detected in {{zone}} on {{cameraName}} will be shown as Alerts.", @@ -176,6 +182,35 @@ "toast": { "success": "Review Classification configuration has been saved. Restart Frigate to apply changes." } + }, + "addCamera": "Add New Camera", + "editCamera": "Edit Camera:", + "selectCamera": "Select a Camera", + "backToSettings": "Back to Camera Settings", + "cameraConfig": { + "add": "Add Camera", + "edit": "Edit Camera", + "description": "Configure camera settings including stream inputs and roles.", + "name": "Camera Name", + "nameRequired": "Camera name is required", + "nameLength": "Camera name must be less than 24 characters.", + "namePlaceholder": "e.g., front_door", + "enabled": "Enabled", + "ffmpeg": { + "inputs": "Input Streams", + "path": "Stream Path", + "pathRequired": "Stream path is required", + "pathPlaceholder": "rtsp://...", + "roles": "Roles", + "rolesRequired": "At least one role is required", + "rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream", + "addInput": "Add Input Stream", + "removeInput": "Remove Input Stream", + "inputsRequired": "At least one input stream is required" + }, + "toast": { + "success": "Camera {{cameraName}} saved successfully" + } } }, "masksAndZones": { @@ -276,7 +311,6 @@ "speedEstimation": { "title": "Speed Estimation", "desc": "Enable speed estimation for objects in this zone. The zone must have exactly 4 points.", - "docs": "Read the documentation", "lineADistance": "Line A distance ({{unit}})", "lineBDistance": "Line B distance ({{unit}})", "lineCDistance": "Line C distance ({{unit}})", @@ -306,16 +340,14 @@ "add": "New Motion Mask", "edit": "Edit Motion Mask", "context": { - "title": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used very sparingly, over-masking will make it more difficult for objects to be tracked.", - "documentation": "Read the documentation" + "title": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used very sparingly, over-masking will make it more difficult for objects to be tracked." }, "point_one": "{{count}} point", "point_other": "{{count}} points", "clickDrawPolygon": "Click to draw a polygon on the image.", "polygonAreaTooLarge": { "title": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.", - "tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead.", - "documentation": "Read the documentation" + "tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead." }, "toast": { "success": { @@ -377,9 +409,17 @@ "title": "Debug", "detectorDesc": "Frigate uses your detectors ({{detectors}}) to detect objects in your camera's video stream.", "desc": "Debugging view shows a real-time view of tracked objects and their statistics. The object list shows a time-delayed summary of detected objects.", + "openCameraWebUI": "Open {{camera}}'s Web UI", "debugging": "Debugging", "objectList": "Object List", "noObjects": "No objects", + "audio": { + "title": "Audio", + "noAudioDetections": "No audio detections", + "score": "score", + "currentRMS": "Current RMS", + "currentdbFS": "Current dbFS" + }, "boundingBoxes": { "title": "Bounding boxes", "desc": "Show bounding boxes around tracked objects", @@ -410,11 +450,15 @@ "desc": "Show a box of the region of interest sent to the object detector", "tips": "

Region Boxes


Bright green boxes will be overlaid on areas of interest in the frame that are being sent to the object detector.

" }, + "paths": { + "title": "Paths", + "desc": "Show significant points of the tracked object's path", + "tips": "

Paths


Lines and circles will indicate significant points the tracked object has moved during its lifecycle.

" + }, "objectShapeFilterDrawing": { "title": "Object Shape Filter Drawing", "desc": "Draw a rectangle on the image to view area and ratio details", "tips": "Enable this option to draw a rectangle on the camera image to show its area and ratio. These values can then be used to set object shape filter parameters in your config.", - "document": "Read the documentation ", "score": "Score", "ratio": "Ratio", "area": "Area" @@ -512,7 +556,68 @@ "admin": "Admin", "adminDesc": "Full access to all features.", "viewer": "Viewer", - "viewerDesc": "Limited to Live dashboards, Review, Explore, and Exports only." + "viewerDesc": "Limited to Live dashboards, Review, Explore, and Exports only.", + "customDesc": "Custom role with specific camera access." + } + } + } + }, + "roles": { + "management": { + "title": "Viewer Role Management", + "desc": "Manage custom viewer roles and their camera access permissions for this Frigate instance." + }, + "addRole": "Add Role", + "table": { + "role": "Role", + "cameras": "Cameras", + "actions": "Actions", + "noRoles": "No custom roles found.", + "editCameras": "Edit Cameras", + "deleteRole": "Delete Role" + }, + "toast": { + "success": { + "createRole": "Role {{role}} created successfully", + "updateCameras": "Cameras updated for role {{role}}", + "deleteRole": "Role {{role}} deleted successfully", + "userRolesUpdated": "{{count}} user(s) assigned to this role have been updated to 'viewer', which has access to all cameras." + }, + "error": { + "createRoleFailed": "Failed to create role: {{errorMessage}}", + "updateCamerasFailed": "Failed to update cameras: {{errorMessage}}", + "deleteRoleFailed": "Failed to delete role: {{errorMessage}}", + "userUpdateFailed": "Failed to update user roles: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Create New Role", + "desc": "Add a new role and specify camera access permissions." + }, + "editCameras": { + "title": "Edit Role Cameras", + "desc": "Update camera access for the role {{role}}." + }, + "deleteRole": { + "title": "Delete Role", + "desc": "This action cannot be undone. This will permanently delete the role and assign any users with this role to the 'viewer' role, which will give viewer access to all cameras.", + "warn": "Are you sure you want to delete {{role}}?", + "deleting": "Deleting..." + }, + "form": { + "role": { + "title": "Role Name", + "placeholder": "Enter role name", + "desc": "Only letters, numbers, periods and underscores allowed.", + "roleIsRequired": "Role name is required", + "roleOnlyInclude": "Role name may only include letters, numbers, . or _", + "roleExists": "A role with this name already exists." + }, + "cameras": { + "title": "Cameras", + "desc": "Select cameras this role has access to. At least one camera is required.", + "required": "At least one camera must be selected." } } } @@ -521,13 +626,11 @@ "title": "Notifications", "notificationSettings": { "title": "Notification Settings", - "desc": "Frigate can natively send push notifications to your device when it is running in the browser or installed as a PWA.", - "documentation": "Read the Documentation" + "desc": "Frigate can natively send push notifications to your device when it is running in the browser or installed as a PWA." }, "notificationUnavailable": { "title": "Notifications Unavailable", - "desc": "Web push notifications require a secure context (https://…). This is a browser limitation. Access Frigate securely to use notifications.", - "documentation": "Read the Documentation" + "desc": "Web push notifications require a secure context (https://…). This is a browser limitation. Access Frigate securely to use notifications." }, "globalSettings": { "title": "Global Settings", @@ -584,7 +687,6 @@ "snapshotConfig": { "title": "Snapshot Configuration", "desc": "Submitting to Frigate+ requires both snapshots and clean_copy snapshots to be enabled in your config.", - "documentation": "Read the documentation", "cleanCopyWarning": "Some cameras have snapshots enabled but have the clean copy disabled. You need to enable clean_copy in your snapshot config to be able to submit images from these cameras to Frigate+.", "table": { "camera": "Camera", @@ -615,5 +717,100 @@ "success": "Frigate+ settings have been saved. Restart Frigate to apply changes.", "error": "Failed to save config changes: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Triggers", + "management": { + "title": "Trigger Management", + "desc": "Manage triggers for {{camera}}. Use the thumbnail type to trigger on similar thumbnails to your selected tracked object, and the description type to trigger on similar descriptions to text you specify." + }, + "addTrigger": "Add Trigger", + "table": { + "name": "Name", + "type": "Type", + "content": "Content", + "threshold": "Threshold", + "actions": "Actions", + "noTriggers": "No triggers configured for this camera.", + "edit": "Edit", + "deleteTrigger": "Delete Trigger", + "lastTriggered": "Last triggered" + }, + "type": { + "thumbnail": "Thumbnail", + "description": "Description" + }, + "actions": { + "alert": "Mark as Alert", + "notification": "Send Notification" + }, + "dialog": { + "createTrigger": { + "title": "Create Trigger", + "desc": "Create a trigger for camera {{camera}}" + }, + "editTrigger": { + "title": "Edit Trigger", + "desc": "Edit the settings for trigger on camera {{camera}}" + }, + "deleteTrigger": { + "title": "Delete Trigger", + "desc": "Are you sure you want to delete the trigger {{triggerName}}? This action cannot be undone." + }, + "form": { + "name": { + "title": "Name", + "placeholder": "Enter trigger name", + "error": { + "minLength": "Name must be at least 2 characters long.", + "invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.", + "alreadyExists": "A trigger with this name already exists for this camera." + } + }, + "enabled": { + "description": "Enable or disable this trigger" + }, + "type": { + "title": "Type", + "placeholder": "Select trigger type" + }, + "content": { + "title": "Content", + "imagePlaceholder": "Select an image", + "textPlaceholder": "Enter text content", + "imageDesc": "Select an image to trigger this action when a similar image is detected.", + "textDesc": "Enter text to trigger this action when a similar tracked object description is detected.", + "error": { + "required": "Content is required." + } + }, + "threshold": { + "title": "Threshold", + "error": { + "min": "Threshold must be at least 0", + "max": "Threshold must be at most 1" + } + }, + "actions": { + "title": "Actions", + "desc": "By default, Frigate fires an MQTT message for all triggers. Choose an additional action to perform when this trigger fires.", + "error": { + "min": "At least one action must be selected." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Trigger {{name}} created successfully.", + "updateTrigger": "Trigger {{name}} updated successfully.", + "deleteTrigger": "Trigger {{name}} deleted successfully." + }, + "error": { + "createTriggerFailed": "Failed to create trigger: {{errorMessage}}", + "updateTriggerFailed": "Failed to update trigger: {{errorMessage}}", + "deleteTriggerFailed": "Failed to delete trigger: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 059f05f9f9..c4c6fd4f68 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -42,6 +42,7 @@ "inferenceSpeed": "Detector Inference Speed", "temperature": "Detector Temperature", "cpuUsage": "Detector CPU Usage", + "cpuUsageInformation": "CPU used in preparing input and output data to/from detection models. This value does not measure inference usage, even if using a GPU or accelerator.", "memoryUsage": "Detector Memory Usage" }, "hardwareInfo": { @@ -91,6 +92,10 @@ "tips": "This value represents the total storage used by the recordings in Frigate's database. Frigate does not track storage usage for all files on your disk.", "earliestRecording": "Earliest recording available:" }, + "shm": { + "title": "SHM (shared memory) allocation", + "warning": "The current SHM size of {{total}}MB is too small. Increase it to at least {{min_shm}}MB." + }, "cameraStorage": { "title": "Camera Storage", "camera": "Camera", @@ -158,7 +163,8 @@ "reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)", "cameraIsOffline": "{{camera}} is offline", "detectIsSlow": "{{detect}} is slow ({{speed}} ms)", - "detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)" + "detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)", + "shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB." }, "enrichments": { "title": "Enrichments", diff --git a/web/public/locales/es/common.json b/web/public/locales/es/common.json index bf6a735fa1..3c682b7069 100644 --- a/web/public/locales/es/common.json +++ b/web/public/locales/es/common.json @@ -141,7 +141,15 @@ "fr": "Français (Frances)", "yue": "粵語 (Cantonés)", "th": "ไทย (Tailandés)", - "ca": "Català (Catalan)" + "ca": "Català (Catalan)", + "ptBR": "Português brasileiro (Portugués brasileño)", + "sr": "Српски (Serbio)", + "sl": "Slovenščina (Esloveno)", + "lt": "Lietuvių (Lituano)", + "bg": "Български (Búlgaro)", + "gl": "Galego (Gallego)", + "id": "Bahasa Indonesia (Indonesio)", + "ur": "اردو (Urdu)" }, "appearance": "Apariencia", "darkMode": { @@ -271,5 +279,6 @@ "title": "404", "desc": "Página no encontrada" }, - "selectItem": "Seleccionar {{item}}" + "selectItem": "Seleccionar {{item}}", + "readTheDocumentation": "Leer la documentación" } diff --git a/web/public/locales/es/components/camera.json b/web/public/locales/es/components/camera.json index bf036e0ae5..69605875e8 100644 --- a/web/public/locales/es/components/camera.json +++ b/web/public/locales/es/components/camera.json @@ -66,7 +66,8 @@ "desc": "Cambia las opciones de transmisión en vivo para el panel de control de este grupo de cámaras. Estos ajustes son específicos del dispositivo/navegador.", "placeholder": "Elige una transmisión", "stream": "Transmitir" - } + }, + "birdseye": "Vista Aérea" } }, "debug": { diff --git a/web/public/locales/es/components/dialog.json b/web/public/locales/es/components/dialog.json index 376b385e60..689b4fcc17 100644 --- a/web/public/locales/es/components/dialog.json +++ b/web/public/locales/es/components/dialog.json @@ -122,5 +122,12 @@ "markAsReviewed": "Marcar como revisado", "deleteNow": "Eliminar ahora" } + }, + "imagePicker": { + "selectImage": "Seleccione la miniatura de un objeto rastreado", + "search": { + "placeholder": "Búsqueda por etiqueta o sub-etiqueta..." + }, + "noImages": "No se encontraron miniaturas para esta cámara" } } diff --git a/web/public/locales/es/components/filter.json b/web/public/locales/es/components/filter.json index 7c627ad5fc..3625030f92 100644 --- a/web/public/locales/es/components/filter.json +++ b/web/public/locales/es/components/filter.json @@ -119,9 +119,19 @@ "loading": "Cargando matrículas reconocidas…", "placeholder": "Escribe para buscar matrículas…", "noLicensePlatesFound": "No se encontraron matrículas.", - "selectPlatesFromList": "Selecciona una o más matrículas de la lista." + "selectPlatesFromList": "Selecciona una o más matrículas de la lista.", + "selectAll": "Seleccionar todas", + "clearAll": "Limpiar todas" }, "zoneMask": { "filterBy": "Filtrar por máscara de zona" + }, + "classes": { + "label": "Clases", + "all": { + "title": "Todas las Clases" + }, + "count_one": "{{count}} Clase", + "count_other": "{{count}} Clases" } } diff --git a/web/public/locales/es/views/configEditor.json b/web/public/locales/es/views/configEditor.json index 39514ec826..3b9f2779e8 100644 --- a/web/public/locales/es/views/configEditor.json +++ b/web/public/locales/es/views/configEditor.json @@ -12,5 +12,7 @@ } }, "documentTitle": "Editor de Configuración - Frigate", - "confirm": "¿Salir sin guardar?" + "confirm": "¿Salir sin guardar?", + "safeConfigEditor": "Editor de Configuración (Modo Seguro)", + "safeModeDescription": "Frigate esta en modo seguro debido a un error en la configuración." } diff --git a/web/public/locales/es/views/events.json b/web/public/locales/es/views/events.json index b06cd92e93..a428169a45 100644 --- a/web/public/locales/es/views/events.json +++ b/web/public/locales/es/views/events.json @@ -35,5 +35,7 @@ "selected": "{{count}} seleccionados", "selected_one": "{{count}} seleccionados", "selected_other": "{{count}} seleccionados", - "detected": "detectado" + "detected": "detectado", + "suspiciousActivity": "Actividad Sospechosa", + "threateningActivity": "Actividad Amenzadora" } diff --git a/web/public/locales/es/views/explore.json b/web/public/locales/es/views/explore.json index f5fb869e0e..6379bd5aad 100644 --- a/web/public/locales/es/views/explore.json +++ b/web/public/locales/es/views/explore.json @@ -41,12 +41,14 @@ "success": { "updatedSublabel": "Subetiqueta actualizada con éxito.", "regenerate": "Se ha solicitado una nueva descripción a {{provider}}. Dependiendo de la velocidad de tu proveedor, la nueva descripción puede tardar algún tiempo en regenerarse.", - "updatedLPR": "Matrícula actualizada con éxito." + "updatedLPR": "Matrícula actualizada con éxito.", + "audioTranscription": "Transcripción de audio solicitada con éxito." }, "error": { "regenerate": "No se pudo llamar a {{provider}} para una nueva descripción: {{errorMessage}}", "updatedSublabelFailed": "No se pudo actualizar la subetiqueta: {{errorMessage}}", - "updatedLPRFailed": "No se pudo actualizar la matrícula: {{errorMessage}}" + "updatedLPRFailed": "No se pudo actualizar la matrícula: {{errorMessage}}", + "audioTranscription": "Transcripción de audio solicitada falló: {{errorMessage}}" } }, "tips": { @@ -97,6 +99,9 @@ "recognizedLicensePlate": "Matrícula Reconocida", "snapshotScore": { "label": "Puntuación de Instantánea" + }, + "score": { + "label": "Puntuación" } }, "documentTitle": "Explorar - Frigate", @@ -183,6 +188,10 @@ }, "deleteTrackedObject": { "label": "Eliminar este objeto rastreado" + }, + "audioTranscription": { + "label": "Transcribir", + "aria": "Solicitar transcripción de audio" } }, "dialog": { @@ -205,5 +214,11 @@ "trackedObjectsCount_one": "{{count}} objeto rastreado ", "trackedObjectsCount_many": "{{count}} objetos rastreados ", "trackedObjectsCount_other": "{{count}} objetos rastreados ", - "exploreMore": "Explora más objetos {{label}}" + "exploreMore": "Explora más objetos {{label}}", + "aiAnalysis": { + "title": "Análisis AI" + }, + "concerns": { + "label": "Preocupaciones" + } } diff --git a/web/public/locales/es/views/live.json b/web/public/locales/es/views/live.json index 8191aebb82..70e2dcdd4b 100644 --- a/web/public/locales/es/views/live.json +++ b/web/public/locales/es/views/live.json @@ -42,7 +42,15 @@ "label": "Haz clic en el marco para centrar la cámara PTZ" } }, - "presets": "Preajustes de cámara PTZ" + "presets": "Preajustes de cámara PTZ", + "focus": { + "in": { + "label": "Enfocar camara PTZ" + }, + "out": { + "label": "Desenfocar camara PTZ" + } + } }, "camera": { "enable": "Habilitar cámara", @@ -135,7 +143,8 @@ "recording": "Grabación", "snapshots": "Capturas de pantalla", "autotracking": "Seguimiento automático", - "cameraEnabled": "Cámara habilitada" + "cameraEnabled": "Cámara habilitada", + "transcription": "Transcripción de audio" }, "history": { "label": "Mostrar grabaciones históricas" @@ -154,5 +163,9 @@ "label": "Editar grupo de cámaras" }, "exitEdit": "Salir de la edición" + }, + "transcription": { + "enable": "Habilitar transcripción de audio en tiempo real", + "disable": "Deshabilitar transcripción de audio en tiempo real" } } diff --git a/web/public/locales/es/views/settings.json b/web/public/locales/es/views/settings.json index 6950c79990..828a1adcbf 100644 --- a/web/public/locales/es/views/settings.json +++ b/web/public/locales/es/views/settings.json @@ -22,7 +22,8 @@ "frigateplus": "Frigate+", "users": "Usuarios", "notifications": "Notificaciones", - "enrichments": "Análisis avanzado" + "enrichments": "Análisis avanzado", + "triggers": "Disparadores" }, "dialog": { "unsavedChanges": { @@ -178,6 +179,44 @@ "streams": { "title": "Transmisiones", "desc": "Desactivar temporalmente una cámara hasta que Frigate se reinicie. Desactivar una cámara detiene por completo el procesamiento de las transmisiones de esta cámara por parte de Frigate. La detección, grabación y depuración no estarán disponibles.
Nota: Esto no desactiva las retransmisiones de go2rtc." + }, + "object_descriptions": { + "title": "Descripciones de objetos de IA generativa", + "desc": "Habilitar/deshabilitar temporalmente las descripciones de objetos de IA generativa para esta cámara. Cuando está deshabilitado, no se solicitarán descripciones generadas por IA para los objetos rastreados en esta cámara." + }, + "review_descriptions": { + "title": "Descripciones de revisión de IA generativa", + "desc": "Habilitar/deshabilitar temporalmente las descripciones de revisión de IA generativa para esta cámara. Cuando está deshabilitado, no se solicitarán descripciones generadas por IA para los elementos de revisión en esta cámara." + }, + "addCamera": "Añadir nueva cámara", + "editCamera": "Editar cámara:", + "selectCamera": "Seleccionar una cámara", + "backToSettings": "Volver a la configuración de la cámara", + "cameraConfig": { + "add": "Añadir cámara", + "edit": "Editar cámara", + "description": "Configurar los ajustes de la cámara, incluyendo las entradas de flujo y los roles.", + "name": "Nombre de la cámara", + "nameRequired": "El nombre de la cámara es obligatorio", + "nameInvalid": "El nombre de la cámara debe contener solo letras, números, guiones bajos o guiones", + "namePlaceholder": "p. ej., puerta_principal", + "enabled": "Habilitado", + "ffmpeg": { + "inputs": "Flujos de entrada", + "path": "Ruta del flujo", + "pathRequired": "La ruta del flujo es obligatoria", + "pathPlaceholder": "rtsp://...", + "roles": "Roles", + "rolesRequired": "Se requiere al menos un rol", + "rolesUnique": "Cada rol (audio, detección, grabación) solo puede asignarse a un flujo", + "addInput": "Añadir flujo de entrada", + "removeInput": "Eliminar flujo de entrada", + "inputsRequired": "Se requiere al menos un flujo de entrada" + }, + "toast": { + "success": "Cámara {{cameraName}} guardada con éxito" + }, + "nameLength": "Nombre de cámara debe ser de mínimo 24 caracteres." } }, "masksAndZones": { @@ -423,6 +462,19 @@ "score": "Puntuación", "ratio": "Proporción", "area": "Área" + }, + "paths": { + "title": "Rutas", + "desc": "Mostrar puntos significativos de la ruta del objeto rastreado", + "tips": "

Rutas


Líneas y círculos indicarán los puntos significativos por los que se ha movido el objeto rastreado durante su ciclo de vida.

" + }, + "openCameraWebUI": "Abrir Web UI de {{camera}}", + "audio": { + "title": "Audio", + "noAudioDetections": "No hay detecciones de audio", + "score": "puntuación", + "currentRMS": "RMS actual", + "currentdbFS": "dbFS actual" } }, "users": { @@ -683,5 +735,12 @@ "success": "Los ajustes de enriquecimientos se han guardado. Reinicia Frigate para aplicar los cambios.", "error": "No se pudieron guardar los cambios en la configuración: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Disparadores", + "management": { + "title": "Gestión de disparadores", + "desc": "Gestionar disparadores para {{camera}}. Usa el tipo de miniatura para activar en miniaturas similares al objeto rastreado seleccionado, y el tipo de descripción para activar en descripciones similares al texto que especifiques." + } } } diff --git a/web/public/locales/es/views/system.json b/web/public/locales/es/views/system.json index 0aaade6263..5b407bb918 100644 --- a/web/public/locales/es/views/system.json +++ b/web/public/locales/es/views/system.json @@ -42,7 +42,8 @@ "inferenceSpeed": "Velocidad de inferencia del detector", "cpuUsage": "Uso de CPU del Detector", "memoryUsage": "Uso de Memoria del Detector", - "temperature": "Detector de Temperatura" + "temperature": "Detector de Temperatura", + "cpuUsageInformation": "CPU utilizado para preparar los datos de entrada y salida desde/hacia la detección del modelo. Este valor no mide el uso de inferencia, incluso si se está utilizando una GPU o un acelerador." }, "hardwareInfo": { "title": "Información de Hardware", @@ -102,6 +103,10 @@ "title": "Almacenamiento de la Cámara", "storageUsed": "Almacenamiento", "unusedStorageInformation": "Información de Almacenamiento No Utilizado" + }, + "shm": { + "title": "Asignación SHM (memoria compartida)", + "warning": "El tamaño actual de SHM de {{total}}MB es muy pequeño. Aumente al menos a {{min_shm}}MB." } }, "cameras": { @@ -175,6 +180,7 @@ "reindexingEmbeddings": "Reindexando incrustaciones ({{processed}}% completado)", "detectIsSlow": "{{detect}} es lento ({{speed}} ms)", "cameraIsOffline": "{{camera}} está desconectada", - "detectIsVerySlow": "{{detect}} es muy lento ({{speed}} ms)" + "detectIsVerySlow": "{{detect}} es muy lento ({{speed}} ms)", + "shmTooLow": "Asignación de /dev/shm ({{total}} MB) debe aumentarse al menos a {{min}} MB." } } diff --git a/web/public/locales/fa/common.json b/web/public/locales/fa/common.json index 0967ef424b..e3b44ba550 100644 --- a/web/public/locales/fa/common.json +++ b/web/public/locales/fa/common.json @@ -1 +1,7 @@ -{} +{ + "time": { + "untilForTime": "تا {{time}}", + "untilForRestart": "تا زمانی که فریگیت دوباره شروع به کار کند.", + "untilRestart": "تا زمان ری‌استارت" + } +} diff --git a/web/public/locales/fa/components/auth.json b/web/public/locales/fa/components/auth.json index 0967ef424b..b74e4b03f6 100644 --- a/web/public/locales/fa/components/auth.json +++ b/web/public/locales/fa/components/auth.json @@ -1 +1,7 @@ -{} +{ + "form": { + "user": "نام کاربری", + "password": "رمز عبور", + "login": "ورود" + } +} diff --git a/web/public/locales/fa/components/camera.json b/web/public/locales/fa/components/camera.json index 0967ef424b..b5567927ba 100644 --- a/web/public/locales/fa/components/camera.json +++ b/web/public/locales/fa/components/camera.json @@ -1 +1,7 @@ -{} +{ + "group": { + "label": "گروه‌های دوربین", + "add": "افزودن گروه دوربین", + "edit": "ویرایش گروه دوربین" + } +} diff --git a/web/public/locales/fa/components/dialog.json b/web/public/locales/fa/components/dialog.json index 0967ef424b..05f66ce60d 100644 --- a/web/public/locales/fa/components/dialog.json +++ b/web/public/locales/fa/components/dialog.json @@ -1 +1,9 @@ -{} +{ + "restart": { + "title": "آیا از ری‌استارت فریگیت اطمینان دارید؟", + "button": "ری‌استارت", + "restarting": { + "title": "فریگیت در حال ری‌استارت شدن" + } + } +} diff --git a/web/public/locales/fa/components/filter.json b/web/public/locales/fa/components/filter.json index 0967ef424b..97410c81e6 100644 --- a/web/public/locales/fa/components/filter.json +++ b/web/public/locales/fa/components/filter.json @@ -1 +1,6 @@ -{} +{ + "filter": "فیلتر", + "classes": { + "label": "کلاس‌ها" + } +} diff --git a/web/public/locales/fa/components/icons.json b/web/public/locales/fa/components/icons.json index 0967ef424b..20111cbaa0 100644 --- a/web/public/locales/fa/components/icons.json +++ b/web/public/locales/fa/components/icons.json @@ -1 +1,8 @@ -{} +{ + "iconPicker": { + "selectIcon": "انتخاب آیکون", + "search": { + "placeholder": "جستجو برای آیکون" + } + } +} diff --git a/web/public/locales/fa/components/input.json b/web/public/locales/fa/components/input.json index 0967ef424b..20de892807 100644 --- a/web/public/locales/fa/components/input.json +++ b/web/public/locales/fa/components/input.json @@ -1 +1,10 @@ -{} +{ + "button": { + "downloadVideo": { + "label": "دریافت ویدیو", + "toast": { + "success": "ویدیوی مورد بررسی شما درحال دریافت می‌باشد." + } + } + } +} diff --git a/web/public/locales/fa/components/player.json b/web/public/locales/fa/components/player.json index 0967ef424b..dae6c8b373 100644 --- a/web/public/locales/fa/components/player.json +++ b/web/public/locales/fa/components/player.json @@ -1 +1,5 @@ -{} +{ + "noRecordingsFoundForThisTime": "ویدیویی برای این زمان وجود ندارد", + "noPreviewFound": "پیش‌نمایش پیدا نشد", + "noPreviewFoundFor": "هیچ پیش‌نمایشی برای {{cameraName}} پیدا نشد" +} diff --git a/web/public/locales/fa/views/configEditor.json b/web/public/locales/fa/views/configEditor.json index 0967ef424b..0a57836db0 100644 --- a/web/public/locales/fa/views/configEditor.json +++ b/web/public/locales/fa/views/configEditor.json @@ -1 +1,4 @@ -{} +{ + "documentTitle": "ویرایشگر کانفیگ - فریگیت", + "configEditor": "ویرایشگر کانفیگ" +} diff --git a/web/public/locales/fa/views/events.json b/web/public/locales/fa/views/events.json index 0967ef424b..4fc9338d64 100644 --- a/web/public/locales/fa/views/events.json +++ b/web/public/locales/fa/views/events.json @@ -1 +1,7 @@ -{} +{ + "alerts": "هشدار‌ها", + "detections": "تشخیص‌ها", + "motion": { + "label": "حرکت" + } +} diff --git a/web/public/locales/fa/views/explore.json b/web/public/locales/fa/views/explore.json index 0967ef424b..f98ef706f4 100644 --- a/web/public/locales/fa/views/explore.json +++ b/web/public/locales/fa/views/explore.json @@ -1 +1,3 @@ -{} +{ + "generativeAI": "هوش مصنوعی تولید کننده" +} diff --git a/web/public/locales/fa/views/exports.json b/web/public/locales/fa/views/exports.json index 0967ef424b..a025b07523 100644 --- a/web/public/locales/fa/views/exports.json +++ b/web/public/locales/fa/views/exports.json @@ -1 +1,5 @@ -{} +{ + "search": "جستجو", + "documentTitle": "گرفتن خروجی - فریگیت", + "noExports": "هیچ خروجی یافت نشد" +} diff --git a/web/public/locales/fa/views/faceLibrary.json b/web/public/locales/fa/views/faceLibrary.json index 0967ef424b..d14ad4fdc4 100644 --- a/web/public/locales/fa/views/faceLibrary.json +++ b/web/public/locales/fa/views/faceLibrary.json @@ -1 +1,6 @@ -{} +{ + "description": { + "addFace": "مراحل اضافه کردن یک مجموعه جدید به کتابخانه چهره را دنبال کنید.", + "placeholder": "نامی برای این مجموعه وارد کنید" + } +} diff --git a/web/public/locales/fa/views/live.json b/web/public/locales/fa/views/live.json index 0967ef424b..b459a7cff1 100644 --- a/web/public/locales/fa/views/live.json +++ b/web/public/locales/fa/views/live.json @@ -1 +1,4 @@ -{} +{ + "documentTitle": "زنده - فریگیت", + "documentTitle.withCamera": "{{camera}} - زنده - فریگیت" +} diff --git a/web/public/locales/fa/views/recording.json b/web/public/locales/fa/views/recording.json index 0967ef424b..5dbfe95bef 100644 --- a/web/public/locales/fa/views/recording.json +++ b/web/public/locales/fa/views/recording.json @@ -1 +1,5 @@ -{} +{ + "filter": "فیلتر", + "export": "گرفتن خروجی", + "calendar": "تفویم" +} diff --git a/web/public/locales/fa/views/search.json b/web/public/locales/fa/views/search.json index 0967ef424b..b4931ae700 100644 --- a/web/public/locales/fa/views/search.json +++ b/web/public/locales/fa/views/search.json @@ -1 +1,5 @@ -{} +{ + "search": "جستجو", + "savedSearches": "جستجوهای ذخیره شده", + "searchFor": "جستجو برای {{inputValue}}" +} diff --git a/web/public/locales/fa/views/settings.json b/web/public/locales/fa/views/settings.json index 0967ef424b..ea7753a5be 100644 --- a/web/public/locales/fa/views/settings.json +++ b/web/public/locales/fa/views/settings.json @@ -1 +1,7 @@ -{} +{ + "documentTitle": { + "default": "تنظیمات - فریگیت", + "authentication": "تنظیمات احراز هویت - فریگیت", + "camera": "تنظیمات دوربین - فریگیت" + } +} diff --git a/web/public/locales/fa/views/system.json b/web/public/locales/fa/views/system.json index 0967ef424b..4cc9550d7e 100644 --- a/web/public/locales/fa/views/system.json +++ b/web/public/locales/fa/views/system.json @@ -1 +1,7 @@ -{} +{ + "documentTitle": { + "cameras": "آمار دوربین‌ها - فریگیت", + "storage": "آمار حافظه - فریگیت", + "general": "آمار عمومی - فریگیت" + } +} diff --git a/web/public/locales/fi/audio.json b/web/public/locales/fi/audio.json index 1623e89bd7..f0665039fc 100644 --- a/web/public/locales/fi/audio.json +++ b/web/public/locales/fi/audio.json @@ -56,7 +56,110 @@ "cough": "Yskä", "sneeze": "Niistää", "throat_clearing": "Kurkun selvittäminen", - "sniff": "Poimi", - "run": "Käynnistä", - "shuffle": "Sekoitus" + "sniff": "Nuuhkia", + "run": "Juokse", + "shuffle": "Sekoitus", + "hiccup": "Hikka", + "radio": "Radio", + "television": "Televisio", + "environmental_noise": "Ympäristön melu", + "sound_effect": "Äänitehoste", + "silence": "Hiljaisuus", + "glass": "Lasi", + "wood": "Puu", + "eruption": "Purkaus", + "firecracker": "Sähikäinen", + "fireworks": "Ilotulitus", + "artillery_fire": "Tykistötuli", + "machine_gun": "Konekivääri", + "explosion": "Räjähdys", + "drill": "Pora", + "sanding": "Hionta", + "sawing": "Sahaus", + "hammer": "Vasara", + "tools": "Työkalut", + "printer": "Tulostin", + "cash_register": "Kassakone", + "air_conditioning": "Ilmastointi", + "mechanical_fan": "Mekaaninen tuuletin", + "sewing_machine": "Ompelukone", + "gears": "Hammasrattaat", + "ratchet": "Räikkä", + "pigeon": "Kyyhkynen", + "crow": "Varis", + "owl": "Pöllö", + "flapping_wings": "Siipien räpyttely", + "dogs": "Koirat", + "rats": "Rotat", + "insect": "Hyönteinen", + "cricket": "Sirkka", + "mosquito": "Hyttynen", + "fly": "Kärpänen", + "footsteps": "Askelia", + "chewing": "Pureskelu", + "biting": "Pureminen", + "gargling": "Kurlaus", + "stomach_rumble": "Vatsan kurina", + "burping": "Röyhtäily", + "fart": "Pieru", + "hands": "Kädet", + "finger_snapping": "Sormien napsauttaminen", + "clapping": "Taputtaminen", + "heartbeat": "Sydämenlyönti", + "cheering": "Hurraus", + "applause": "Aplodit", + "crowd": "Väkijoukko", + "children_playing": "Lapset leikkivät", + "pets": "Lemmikit", + "whimper_dog": "Koiran vinkuminen", + "meow": "Miau", + "livestock": "Karja", + "cattle": "Nautakarja", + "cowbell": "Lehmänkello", + "pig": "Sika", + "chicken": "Kana", + "duck": "Ankka", + "frog": "Sammakko", + "snake": "Käärme", + "music": "Musiikki", + "musical_instrument": "Musiikki-instrumentti", + "guitar": "Kitara", + "electric_guitar": "Sähkökitara", + "bass_guitar": "Bassokitara", + "acoustic_guitar": "Akustinen kitara", + "tapping": "Napauttaminen", + "piano": "Piano", + "electric_piano": "Sähköpiano", + "organ": "Urku", + "synthesizer": "Syntetisaattori", + "drum_kit": "Rumpusetti", + "drum": "Rumpu", + "wood_block": "Puupalikka", + "steelpan": "Teräspannu", + "trumpet": "Trumpetti", + "violin": "Viulu", + "cello": "Sello", + "flute": "Huilu", + "saxophone": "Saksofoni", + "clarinet": "Klarinetti", + "harp": "Harppu", + "bell": "Kello", + "church_bell": "Kirkonkello", + "bicycle_bell": "Polkupyörän kello", + "tuning_fork": "Virityshaarukka", + "pop_music": "Popmusiikki", + "hip_hop_music": "Hiphop-musiikki", + "rock_music": "Rock-musiikki", + "heavy_metal": "Heavy metal", + "punk_rock": "Punkrock", + "rock_and_roll": "Rock and Roll", + "scream": "Huutaa", + "accelerating": "Kiihdyttäminen", + "air_brake": "Ilmajarru", + "aircraft": "Ilma-alus", + "aircraft_engine": "Lentokoneen moottori", + "alarm": "Hälytys", + "ambient_music": "Tunnelmamusiikki", + "ambulance": "Ambulanssi", + "angry_music": "Vihainen musiikki" } diff --git a/web/public/locales/fi/common.json b/web/public/locales/fi/common.json index f76eb0e67f..5cebc89399 100644 --- a/web/public/locales/fi/common.json +++ b/web/public/locales/fi/common.json @@ -39,7 +39,10 @@ "minute_one": "{{time}}minuutti", "minute_other": "{{time}}minuuttia", "second_one": "{{time}}sekuntti", - "second_other": "{{time}}sekunttia" + "second_other": "{{time}}sekunttia", + "formattedTimestampHourMinute": { + "24hour": "HH:mm" + } }, "pagination": { "next": { @@ -168,5 +171,6 @@ "length": { "feet": "jalka" } - } + }, + "readTheDocumentation": "Lue dokumentaatio" } diff --git a/web/public/locales/fi/components/auth.json b/web/public/locales/fi/components/auth.json index 5ce3ffa021..f81993d86b 100644 --- a/web/public/locales/fi/components/auth.json +++ b/web/public/locales/fi/components/auth.json @@ -1,7 +1,7 @@ { "form": { "password": "Salasana", - "user": "Käyttäjä", + "user": "Käyttäjänimi", "login": "Kirjaudu", "errors": { "usernameRequired": "Käyttäjänimi vaaditaan", diff --git a/web/public/locales/fi/components/camera.json b/web/public/locales/fi/components/camera.json index 9dae4c5ed3..a641ca65ec 100644 --- a/web/public/locales/fi/components/camera.json +++ b/web/public/locales/fi/components/camera.json @@ -66,7 +66,8 @@ }, "stream": "Kuvavirta", "placeholder": "Valitse kuvavirta" - } + }, + "birdseye": "Linnun silmä" } }, "debug": { diff --git a/web/public/locales/fi/components/dialog.json b/web/public/locales/fi/components/dialog.json index 9a1ca575d4..819e4a55ea 100644 --- a/web/public/locales/fi/components/dialog.json +++ b/web/public/locales/fi/components/dialog.json @@ -73,5 +73,15 @@ "readTheDocumentation": "Lue dokumentaatio" } } + }, + "search": { + "saveSearch": { + "label": "Tallenna haku" + } + }, + "imagePicker": { + "search": { + "placeholder": "Hae nimikkeen tai alinimikkeen mukaan..." + } } } diff --git a/web/public/locales/fi/components/filter.json b/web/public/locales/fi/components/filter.json index 5a21e54240..c3058bd29f 100644 --- a/web/public/locales/fi/components/filter.json +++ b/web/public/locales/fi/components/filter.json @@ -56,7 +56,36 @@ "cameras": { "label": "Kameran suodattimet", "all": { - "title": "Kaikki kamerat" + "title": "Kaikki kamerat", + "short": "Kamerat" + } + }, + "classes": { + "label": "Luokat", + "all": { + "title": "Kaikki luokat" + }, + "count_one": "{{count}} Luokka", + "count_other": "{{count}} Luokkaa" + }, + "recognizedLicensePlates": { + "clearAll": "Tyhjennä kaikki", + "title": "Tunnistetut rekisterikilvet", + "loadFailed": "Tunnistettujen rekisterikilpien lataaminen epäonnistui.", + "loading": "Ladataan tunnistettuja rekisterikilpiä…", + "placeholder": "Kirjoita hakeaksesi rekisterikilpeä…", + "noLicensePlatesFound": "Rekisterikilpiä ei löytynyt.", + "selectPlatesFromList": "Valitse yksi tai useampi rekisterikilpi luettelosta.", + "selectAll": "Valitse kaikki" + }, + "logSettings": { + "allLogs": "Kaikki lokit", + "filterBySeverity": "Suodata lokit vakavuuden mukaan" + }, + "trackedObjectDelete": { + "title": "Vahvista poisto", + "toast": { + "error": "Seurattujen kohteiden poistaminen epäonnistui: {{errorMessage}}" } } } diff --git a/web/public/locales/fi/views/configEditor.json b/web/public/locales/fi/views/configEditor.json index 472c59e371..96990e1408 100644 --- a/web/public/locales/fi/views/configEditor.json +++ b/web/public/locales/fi/views/configEditor.json @@ -12,5 +12,7 @@ }, "configEditor": "Konfiguraatioeditori", "copyConfig": "Kopioi konfiguraatio", - "saveAndRestart": "Tallenna & uudelleenkäynnistä" + "saveAndRestart": "Tallenna & uudelleenkäynnistä", + "safeConfigEditor": "Konfiguraatioeditori (vikasietotila)", + "safeModeDescription": "Frigate on vikasietotilassa konfiguraation vahvistusvirheen vuoksi." } diff --git a/web/public/locales/fi/views/events.json b/web/public/locales/fi/views/events.json index 638a05f7a1..57eb44a808 100644 --- a/web/public/locales/fi/views/events.json +++ b/web/public/locales/fi/views/events.json @@ -34,5 +34,7 @@ "label": "Näytä uudet katselmoitavat kohteet", "button": "Uudet katselmoitavat kohteet" }, - "camera": "Kamera" + "camera": "Kamera", + "suspiciousActivity": "Epäilyttävä toiminta", + "threateningActivity": "Uhkaava toiminta" } diff --git a/web/public/locales/fi/views/explore.json b/web/public/locales/fi/views/explore.json index c6950c9417..25743e4706 100644 --- a/web/public/locales/fi/views/explore.json +++ b/web/public/locales/fi/views/explore.json @@ -7,7 +7,45 @@ "desc": "Tarkastele kohteen tietoja", "button": { "share": "Jaa tämä tarkasteltu kohde" + }, + "toast": { + "error": { + "updatedSublabelFailed": "Alatunnisteen päivitys epäonnistui", + "updatedLPRFailed": "Rekisterikilven päivitys epäonnistui" + } } + }, + "recognizedLicensePlate": "Tunnistettu rekisterikilpi", + "estimatedSpeed": "Arvioitu nopeus", + "objects": "Objektit", + "camera": "Kamera", + "zones": "Alueet", + "label": "Tunniste", + "editSubLabel": { + "title": "Editoi alitunnistetta", + "desc": "Syötä uusi alitunniste tähän", + "descNoLabel": "Lisää uusi alatunniste tähän seurattuun kohteeseen" + }, + "editLPR": { + "title": "Muokkaa rekisterikilpeä", + "desc": "Syötä uusi rekisterikilven arvo tähän", + "descNoLabel": "Syötä uusi rekisterikilven arvo tähän seurattuun objektiin" + }, + "snapshotScore": { + "label": "Tilannekuvan arvosana" + }, + "topScore": { + "label": "Huippuarvosana", + "info": "Ylin pistemäärä on seurattavan kohteen korkein mediaani, joten tämä voi erota hakutuloksen esikatselukuvassa näkyvästä pistemäärästä." + }, + "button": { + "findSimilar": "Etsi samankaltaisia" + }, + "description": { + "label": "Kuvaus" + }, + "score": { + "label": "Pisteet" } }, "exploreIsUnavailable": { @@ -28,7 +66,8 @@ "setup": { "visionModel": "Vision-malli", "textModel": "Tekstimalli", - "textTokenizer": "Tekstin osioija" + "textTokenizer": "Tekstin osioija", + "visionModelFeatureExtractor": "Näkömallin piirreluokkain" }, "tips": { "documentation": "Lue dokumentaatio", @@ -90,6 +129,27 @@ "downloadSnapshot": { "label": "Lataa kuvankaappaus", "aria": "Lataa kuvankaappaus" + }, + "addTrigger": { + "label": "Lisää laukaisin", + "aria": "Lisää laukaisin tälle seurattavalle kohteelle" + }, + "submitToPlus": { + "label": "Lähetä Frigate+:lle" + }, + "downloadVideo": { + "label": "Lataa video", + "aria": "Lataa video" + }, + "viewObjectLifecycle": { + "label": "Tarkastele objektin elinkaarta", + "aria": "Näytä objektin elinkaari" + }, + "findSimilar": { + "label": "Etsi samankaltaisia" } + }, + "aiAnalysis": { + "title": "AI-analyysi" } } diff --git a/web/public/locales/fi/views/faceLibrary.json b/web/public/locales/fi/views/faceLibrary.json index 041c7324f7..dc69f36940 100644 --- a/web/public/locales/fi/views/faceLibrary.json +++ b/web/public/locales/fi/views/faceLibrary.json @@ -26,7 +26,8 @@ "toast": { "success": { "deletedFace_one": "{{count}} kasvo poistettu onnistuneesti.", - "deletedFace_other": "{{count}} kasvoa poistettu onnistuneesti." + "deletedFace_other": "{{count}} kasvoa poistettu onnistuneesti.", + "uploadedImage": "Kuva ladattu onnistuneesti." } }, "selectItem": "Valitse {{item}}", @@ -60,6 +61,22 @@ "desc": "Anna uusi nimi tälle {{name}}" }, "button": { - "deleteFaceAttempts": "Poista kasvot" - } + "deleteFaceAttempts": "Poista kasvot", + "addFace": "Lisää kasvot", + "renameFace": "Uudelleennimeä kasvot", + "deleteFace": "Poista kasvot", + "uploadImage": "Lataa kuva", + "reprocessFace": "Uudelleenprosessointi Kasvot" + }, + "imageEntry": { + "validation": { + "selectImage": "Valitse kuvatiedosto." + }, + "dropActive": "Pudota kuva tähän…", + "dropInstructions": "Vedä ja pudota kuva tähän tai valitse se napsauttamalla", + "maxSize": "Maksimikoko: {{size}}MB" + }, + "nofaces": "Kasvoja ei ole saatavilla", + "pixels": "{{area}}px", + "trainFace": "Kouluta kasvot" } diff --git a/web/public/locales/fi/views/live.json b/web/public/locales/fi/views/live.json index 69c0d23bf7..d38703565a 100644 --- a/web/public/locales/fi/views/live.json +++ b/web/public/locales/fi/views/live.json @@ -43,7 +43,15 @@ "label": "Napsauta kehystä keskittääksesi PTZ-kamera" } }, - "presets": "PTZ-kameroiden esiasetukset" + "presets": "PTZ-kameroiden esiasetukset", + "focus": { + "in": { + "label": "Tarkenna PTZ-kamera sisään" + }, + "out": { + "label": "Tarkenna PTZ-kamera ulos" + } + } }, "camera": { "enable": "Ota kamera käyttöön", @@ -135,7 +143,8 @@ "recording": "Nauhoitus", "snapshots": "Tilannekuvat", "audioDetection": "Äänen tunnistus", - "autotracking": "Automaattinen seuranta" + "autotracking": "Automaattinen seuranta", + "transcription": "Äänitranskriptio" }, "history": { "label": "Näytä historiallista materiaalia" @@ -154,5 +163,9 @@ "label": "Muokkaa kameraryhmää" }, "exitEdit": "Poistu muokkauksesta" + }, + "transcription": { + "enable": "Ota käyttöön reaaliaikainen äänitranskriptio", + "disable": "Poista käytöstä reaaliaikainen äänitranskriptio" } } diff --git a/web/public/locales/fi/views/search.json b/web/public/locales/fi/views/search.json index fab6050889..887c9e09e5 100644 --- a/web/public/locales/fi/views/search.json +++ b/web/public/locales/fi/views/search.json @@ -44,7 +44,14 @@ }, "tips": { "desc": { - "exampleLabel": "Esimerkki:" + "exampleLabel": "Esimerkki:", + "step6": "Poista suodattimet napsauttamalla niiden vieressä olevaa 'x' merkkiä.", + "text": "Suodattimien avulla voit rajata hakutuloksia. Näin käytät niitä syöttökentässä:", + "step1": "Kirjoita suodattimen avaimen nimi ja sen perään kaksoispiste (esim. ”kamerat:”).", + "step2": "Valitse arvo ehdotuksista tai kirjoita oma arvo.", + "step3": "Käytä useita suodattimia lisäämällä ne peräkkäin välilyönnillä erotettuina.", + "step4": "Päivämääräsuodattimet (ennen: ja jälkeen:) käyttävät {{DateFormat}} muotoa.", + "step5": "Aikavälin suodatin käyttää {{exampleTime}} muotoa." }, "title": "Tekstisuodattimien käyttö" }, @@ -58,5 +65,8 @@ "title": "Samankaltaisten kohteiden haku", "active": "Samankaltaisuushaku aktiivinen", "clear": "Poista samankaltaisuushaku" + }, + "placeholder": { + "search": "Hae…" } } diff --git a/web/public/locales/fi/views/settings.json b/web/public/locales/fi/views/settings.json index 23b910dda4..cda27193fa 100644 --- a/web/public/locales/fi/views/settings.json +++ b/web/public/locales/fi/views/settings.json @@ -22,7 +22,8 @@ "debug": "Debuggaus", "motionTuner": "Liikesäädin", "notifications": "Ilmoitukset", - "enrichments": "Rikasteet" + "enrichments": "Rikasteet", + "triggers": "Laukaisimet" }, "dialog": { "unsavedChanges": { @@ -176,7 +177,14 @@ "toast": { "success": "Luokittelumäärityksen tarkistus on tallennettu. Käynnistä Frigate uudelleen muutosten käyttöönottamiseksi." } - } + }, + "cameraConfig": { + "add": "Lisää kamera", + "ffmpeg": { + "addInput": "Lisää tulovirta" + } + }, + "addCamera": "Lisää uusi kamera" }, "masksAndZones": { "filter": { @@ -415,6 +423,11 @@ "placeholder": "Syötä käyttäjätunnus", "title": "Käyttäjätunnus" } + }, + "changeRole": { + "roleInfo": { + "admin": "Ylläpitäjä" + } } } }, @@ -427,5 +440,140 @@ "Threshold": { "title": "Kynnys" } + }, + "triggers": { + "documentTitle": "Laukaisimet", + "management": { + "title": "Laukaisimen hallinta" + }, + "addTrigger": "Lisää laukaisin", + "table": { + "name": "Nimi", + "type": "Tyyppi", + "content": "Sisältö", + "threshold": "Kynnys", + "actions": "Toiminnot", + "noTriggers": "Tälle kameralle ei ole määritetty laukaisimia.", + "edit": "Muokkaa", + "deleteTrigger": "Poista laukaisin", + "lastTriggered": "Viimeksi laukaistu" + }, + "type": { + "thumbnail": "Kuvake", + "description": "Kuvaus" + }, + "actions": { + "notification": "Lähetä ilmoitus", + "alert": "Merkitse hälytykseksi" + }, + "dialog": { + "createTrigger": { + "title": "Luo laukaisin", + "desc": "Luo laukaisin kameralle {{camera}}" + }, + "editTrigger": { + "title": "Muokkaa laukaisinta", + "desc": "Muokkaa laukaisimen asetuksia kamerasta {{camera}}" + }, + "deleteTrigger": { + "title": "Poista laukaisin", + "desc": "Haluatko varmasti poistaa laukaisimen {{triggerName}}? Tätä toimintoa ei voi peruuttaa." + }, + "form": { + "name": { + "title": "Nimi", + "placeholder": "Syötä laukaisimen nimi", + "error": { + "minLength": "Nimen on oltava vähintään 2 merkkiä pitkä.", + "invalidCharacters": "Nimi voi sisältää vain kirjaimia, numeroita, alaviivoja ja väliviivoja.", + "alreadyExists": "Tällä nimellä oleva laukaisin on jo olemassa tälle kameralle." + } + }, + "enabled": { + "description": "Ota tämä laukaisin käyttöön tai pois käytöstä" + }, + "type": { + "title": "Tyyppi", + "placeholder": "Valitse laukaisintyyppi" + }, + "content": { + "title": "Sisältö", + "imagePlaceholder": "Valitse kuva", + "textPlaceholder": "Kirjoita tekstisisältö", + "imageDesc": "Valitse kuva, joka laukaisee tämän toiminnon, kun samankaltainen kuva havaitaan.", + "textDesc": "Syötä teksti, joka laukaisee tämän toiminnon, kun vastaava seurattavan kohteen kuvaus havaitaan.", + "error": { + "required": "Sisältö on pakollinen." + } + }, + "threshold": { + "title": "Kynnys", + "error": { + "min": "Kynnys on oltava vähintään 0", + "max": "Kynnys on oltava enintään 1" + } + }, + "actions": { + "title": "Toiminnot", + "desc": "Oletuksena Frigate lähettää MQTT-viestin kaikille laukaisimille. Valitse lisätoiminto, joka suoritetaan, kun tämä laukaisija laukeaa.", + "error": { + "min": "Vähintään yksi toiminto on valittava." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Laukaisin {{name}} luotu onnistuneesti.", + "updateTrigger": "Laukaisin {{name}} päivitetty onnistuneesti.", + "deleteTrigger": "Laukaisin {{name}} poistettu onnistuneesti." + }, + "error": { + "createTriggerFailed": "Laukaisimen luominen epäonnistui: {{errorMessage}}", + "updateTriggerFailed": "Laukaisimen päivitys epäonnistui: {{errorMessage}}", + "deleteTriggerFailed": "Laukaisimen poistaminen epäonnistui: {{errorMessage}}" + } + } + }, + "enrichments": { + "semanticSearch": { + "modelSize": { + "small": { + "title": "pieni", + "desc": "pieni käyttää kvantisoitua versiota mallista, joka käyttää vähemmän RAM-muistia ja toimii nopeammin CPU:lla, mutta ero upotuksen laadussa on hyvin vähäinen." + }, + "large": { + "title": "suuri", + "desc": "suuri käyttää koko Jina-mallia ja toimii automaattisesti GPU:lla, jos se on mahdollista." + }, + "desc": "Semanttisen haun upotuksiin käytetyn mallin koko." + }, + "title": "Semanttinen haku", + "desc": "Semanttisen haun avulla Frigatessa voit etsiä seurattavia kohteita tarkistettavista kohteista joko kuvan, käyttäjän määrittämän tekstikuvauksen tai automaattisesti luodun kuvauksen avulla.", + "reindexNow": { + "label": "Uudelleenindeksoi nyt", + "desc": "Uudelleindeksointi luo uudelleen upotukset kaikille seuratuille objekteille. Tämä prosessi suoritetaan taustalla ja voi kuormittaa prosessorin maksimiin ja viedä melko paljon aikaa riippuen seurattujen objektien määrästä.", + "confirmTitle": "Vahvista uudelleenindeksointi" + } + }, + "faceRecognition": { + "title": "Kasvojentunnistus", + "desc": "Kasvojentunnistuksen avulla ihmisille voidaan antaa nimiä, ja kun heidän kasvonsa tunnistetaan, Frigate lisää henkilön nimen alaluokaksi. Nämä tiedot näkyvät käyttöliittymässä, suodattimissa ja ilmoituksissa.", + "modelSize": { + "label": "Mallin koko", + "desc": "Kasvojentunnistuksessa käytettävän mallin koko.", + "small": { + "title": "pieni", + "desc": "pieni käyttää FaceNet-kasvojen upotusmallia, joka toimii tehokkaasti useimmilla suorittimilla." + }, + "large": { + "title": "suuri", + "desc": "suuri käyttää ArcFace-kasvojen upotusmallia ja toimii automaattisesti GPU:lla, jos se on mahdollista." + } + } + }, + "licensePlateRecognition": { + "title": "Rekisterikilven tunnistaminen" + } } } diff --git a/web/public/locales/fi/views/system.json b/web/public/locales/fi/views/system.json index 5000e45c6e..04952692e2 100644 --- a/web/public/locales/fi/views/system.json +++ b/web/public/locales/fi/views/system.json @@ -55,6 +55,13 @@ }, "closeInfo": { "label": "Sulje GPU:n tiedot" + }, + "nvidiaSMIOutput": { + "driver": "Ajuri: {{driver}}", + "title": "Nvidia SMI tuloste", + "name": "Nimi: {{name}}", + "cudaComputerCapability": "CUDA laskentakapasiteetti: {{cuda_compute}}", + "vbios": "VBios-tiedot: {{vbios}}" } } }, diff --git a/web/public/locales/fr/common.json b/web/public/locales/fr/common.json index 5ed9f65a95..960dd1d0d8 100644 --- a/web/public/locales/fr/common.json +++ b/web/public/locales/fr/common.json @@ -162,7 +162,15 @@ "vi": "Tiếng Việt (Vietnamien)", "yue": "粵語 (Cantonais)", "th": "ไทย (Thai)", - "ca": "Català (Catalan)" + "ca": "Català (Catalan)", + "ptBR": "Português brasileiro (portugais brésilien)", + "sr": "Српски (Serbe)", + "sl": "Slovenščina (slovène)", + "lt": "Lietuvių (lithuanien)", + "bg": "Български (bulgare)", + "gl": "Galego (galicien)", + "id": "Bahasa Indonesia (indonésien)", + "ur": "اردو (ourdou)" }, "appearance": "Apparence", "darkMode": { @@ -254,6 +262,7 @@ "desc": "Page non trouvée" }, "selectItem": "Sélectionner {{item}}", + "readTheDocumentation": "Lire la documentation", "accessDenied": { "title": "Accès refusé", "documentTitle": "Accès refusé - Frigate", diff --git a/web/public/locales/fr/components/camera.json b/web/public/locales/fr/components/camera.json index 582b211b5a..8595697bc1 100644 --- a/web/public/locales/fr/components/camera.json +++ b/web/public/locales/fr/components/camera.json @@ -66,7 +66,8 @@ }, "stream": "Flux", "placeholder": "Choisissez un flux" - } + }, + "birdseye": "Birdseye" } }, "debug": { diff --git a/web/public/locales/fr/components/dialog.json b/web/public/locales/fr/components/dialog.json index d92e3ff724..111baf88c9 100644 --- a/web/public/locales/fr/components/dialog.json +++ b/web/public/locales/fr/components/dialog.json @@ -122,5 +122,12 @@ "markAsReviewed": "Marquer comme passé en revue", "deleteNow": "Supprimer maintenant" } + }, + "imagePicker": { + "selectImage": "Sélectionnez une vignette d'objet suivi", + "search": { + "placeholder": "Chercher par libellé ou sous-libellé..." + }, + "noImages": "Aucune vignette trouvée pour cette caméra" } } diff --git a/web/public/locales/fr/components/filter.json b/web/public/locales/fr/components/filter.json index 567cf81f53..58a03de934 100644 --- a/web/public/locales/fr/components/filter.json +++ b/web/public/locales/fr/components/filter.json @@ -111,7 +111,9 @@ "loading": "Chargement des plaques d'immatriculation reconnues…", "title": "Plaques d'immatriculation reconnues", "loadFailed": "Échec du chargement des plaques d'immatriculation reconnues.", - "selectPlatesFromList": "Sélectionner une ou plusieurs plaques d'immatriculation dans la liste." + "selectPlatesFromList": "Sélectionner une ou plusieurs plaques d'immatriculation dans la liste.", + "selectAll": "Tout sélectionner", + "clearAll": "Tout effacer" }, "trackedObjectDelete": { "title": "Confirmer la suppression", @@ -123,5 +125,13 @@ }, "zoneMask": { "filterBy": "Filtrer par masque de zone" + }, + "classes": { + "label": "Classes", + "all": { + "title": "Toutes les classes" + }, + "count_one": "{{count}} classe", + "count_other": "{{count}} classes" } } diff --git a/web/public/locales/fr/views/configEditor.json b/web/public/locales/fr/views/configEditor.json index 5f88fb94fa..ce4c3292e8 100644 --- a/web/public/locales/fr/views/configEditor.json +++ b/web/public/locales/fr/views/configEditor.json @@ -12,5 +12,7 @@ "savingError": "Erreur lors de l'enregistrement de la configuration" } }, - "confirm": "Quitter sans enregistrer ?" + "confirm": "Quitter sans enregistrer ?", + "safeConfigEditor": "Éditeur de configuration (mode sans échec)", + "safeModeDescription": "Frigate est en mode sans échec en raison d'une erreur de validation de la configuration." } diff --git a/web/public/locales/fr/views/events.json b/web/public/locales/fr/views/events.json index d8d58332ce..cf4f5e1421 100644 --- a/web/public/locales/fr/views/events.json +++ b/web/public/locales/fr/views/events.json @@ -35,5 +35,7 @@ "selected": "{{count}} sélectionné(s)", "selected_other": "{{count}} sélectionné(s)", "selected_one": "{{count}} sélectionné(s)", - "detected": "détecté" + "detected": "détecté", + "suspiciousActivity": "Activité suspecte", + "threateningActivity": "Activité menaçante" } diff --git a/web/public/locales/fr/views/explore.json b/web/public/locales/fr/views/explore.json index b42cb5f38a..064a71a377 100644 --- a/web/public/locales/fr/views/explore.json +++ b/web/public/locales/fr/views/explore.json @@ -41,12 +41,14 @@ "success": { "regenerate": "Une nouvelle description a été demandée à {{provider}}. Selon la vitesse de votre fournisseur, la régénération de la nouvelle description peut prendre un certain temps.", "updatedSublabel": "Sous-libellé mis à jour avec succès.", - "updatedLPR": "Plaque d'immatriculation mise à jour avec succès." + "updatedLPR": "Plaque d'immatriculation mise à jour avec succès.", + "audioTranscription": "Requête de la transcription audio réussie." }, "error": { "regenerate": "Échec de l'appel de {{provider}} pour une nouvelle description : {{errorMessage}}", "updatedSublabelFailed": "Échec de la mise à jour du sous-libellé : {{errorMessage}}", - "updatedLPRFailed": "Échec de la mise à jour de la plaque d'immatriculation : {{errorMessage}}" + "updatedLPRFailed": "Échec de la mise à jour de la plaque d'immatriculation : {{errorMessage}}", + "audioTranscription": "Échec de la requête de transcription audio : {{errorMessage}}" } }, "tips": { @@ -98,6 +100,9 @@ }, "snapshotScore": { "label": "Score de l'instantané" + }, + "score": { + "label": "Score" } }, "type": { @@ -183,6 +188,14 @@ }, "deleteTrackedObject": { "label": "Supprimer cet objet suivi" + }, + "addTrigger": { + "label": "Ajouter un déclencheur", + "aria": "Ajouter un déclencheur pour cet objet suivi" + }, + "audioTranscription": { + "label": "Transcrire", + "aria": "Demander une transcription audio" } }, "dialog": { @@ -205,5 +218,11 @@ }, "tooltip": "Correspondance : {{type}} à {{confidence}}%" }, - "exploreMore": "Explorer plus d'objets {{label}}" + "exploreMore": "Explorer plus d'objets {{label}}", + "aiAnalysis": { + "title": "Analyse IA" + }, + "concerns": { + "label": "Préoccupations" + } } diff --git a/web/public/locales/fr/views/faceLibrary.json b/web/public/locales/fr/views/faceLibrary.json index fa5de03b21..618185d238 100644 --- a/web/public/locales/fr/views/faceLibrary.json +++ b/web/public/locales/fr/views/faceLibrary.json @@ -47,7 +47,7 @@ "imageEntry": { "dropActive": "Déposez l'image ici…", "dropInstructions": "Glissez et déposez une image ici, ou cliquez pour sélectionner", - "maxSize": "Taille max : {{size}}MB", + "maxSize": "Taille max : {{size}}Mo", "validation": { "selectImage": "Veuillez sélectionner un fichier image." } diff --git a/web/public/locales/fr/views/live.json b/web/public/locales/fr/views/live.json index 8c86039729..4f951be35f 100644 --- a/web/public/locales/fr/views/live.json +++ b/web/public/locales/fr/views/live.json @@ -43,7 +43,15 @@ "label": "Cliquez dans le cadre pour centrer la caméra PTZ" } }, - "presets": "Paramètres prédéfinis pour les caméras PTZ" + "presets": "Paramètres prédéfinis pour les caméras PTZ", + "focus": { + "in": { + "label": "Mise au point rapprochée de la caméra PTZ" + }, + "out": { + "label": "Mise au point éloignée de la caméra PTZ" + } + } }, "camera": { "enable": "Activer la caméra", @@ -126,7 +134,8 @@ "audioDetection": "Détection audio", "autotracking": "Suivi automatique", "cameraEnabled": "Caméra activée", - "title": "Paramètres de {{camera}}" + "title": "Paramètres de {{camera}}", + "transcription": "Transcription audio" }, "history": { "label": "Afficher l'historique de capture" @@ -154,5 +163,9 @@ "label": "Modifier le groupe de caméras" }, "exitEdit": "Quitter l'édition" + }, + "transcription": { + "enable": "Activer la transcription audio en direct", + "disable": "Désactiver la transcription audio en direct" } } diff --git a/web/public/locales/fr/views/settings.json b/web/public/locales/fr/views/settings.json index 67bd50933d..6a3e61462e 100644 --- a/web/public/locales/fr/views/settings.json +++ b/web/public/locales/fr/views/settings.json @@ -10,7 +10,7 @@ "object": "Débogage - Frigate", "frigatePlus": "Paramètres Frigate+ - Frigate", "notifications": "Paramètres de notification - Frigate", - "enrichments": "Paramètres de données augmentées - Frigate" + "enrichments": "Paramètres d'enrichissements - Frigate" }, "menu": { "ui": "Interface utilisateur", @@ -22,7 +22,8 @@ "users": "Utilisateurs", "notifications": "Notifications", "frigateplus": "Frigate+", - "enrichments": "Données augmentées" + "enrichments": "Enrichissements", + "triggers": "Déclencheurs" }, "dialog": { "unsavedChanges": { @@ -278,6 +279,44 @@ "streams": { "title": "Flux", "desc": "Désactive temporairement une caméra jusqu'au redémarrage de Frigate. La désactivation complète d'une caméra interrompt le traitement des flux de cette caméra par Frigate. La détection, l'enregistrement et le débogage seront indisponibles.
Remarque : cela ne désactive pas les rediffusions go2rtc." + }, + "object_descriptions": { + "title": "Description d'objets par IA générative", + "desc": "Activer / désactiver temporairement les descriptions d'objets par IA générative pour cette caméra. Lorsqu'elles sont désactivées, les descriptions générées par IA ne seront pas demandées pour les objets suivis par cette caméra." + }, + "review_descriptions": { + "title": "Revue de descriptions par IA générative", + "desc": "Activer / désactiver temporairement la revue de descriptions d'objets par IA générative pour cette caméra. Lorsqu'elles sont désactivées, les descriptions générées par IA ne seront plus demandées pour la revue d'éléments de cette caméra." + }, + "addCamera": "Ajouter une nouvelle caméra", + "editCamera": "Éditer la caméra :", + "selectCamera": "Sélectionner une caméra", + "backToSettings": "Retour aux paramètres de la caméra", + "cameraConfig": { + "add": "Ajouter une caméra", + "edit": "Éditer la caméra", + "description": "Configurer les paramètres de la caméra y compris les flux et les rôles.", + "name": "Nom de la caméra", + "nameRequired": "Un nom de caméra est nécessaire", + "nameInvalid": "Les noms de caméra peuvent contenir uniquement des lettres, des chiffres, des tirets bas, ou des tirets", + "namePlaceholder": "par exemple, porte_entree", + "enabled": "Activé", + "ffmpeg": { + "inputs": "Flux entrants", + "path": "Chemin d'accès du flux", + "pathRequired": "Un chemin d'accès de flux est nécessaire", + "pathPlaceholder": "rtsp://...", + "roles": "Rôles", + "rolesRequired": "Au moins un rôle est nécessaire", + "rolesUnique": "Chaque rôle (audio, détection, enregistrement) ne peut être assigné qu'à un seul flux", + "addInput": "Ajouter un flux entrant", + "removeInput": "Supprimer le flux entrant", + "inputsRequired": "Au moins un flux entrant est nécessaire" + }, + "toast": { + "success": "Caméra {{cameraName}} enregistrée avec succès" + }, + "nameLength": "Le nom de la caméra doit comporter au plus 24 caractères." } }, "masksAndZones": { @@ -523,7 +562,20 @@ "noObjects": "Aucun objet", "title": "Débogage", "detectorDesc": "Frigate utilise vos détecteurs ({{detectors}}) pour détecter les objets dans le flux vidéo de votre caméra.", - "desc": "La vue de débogage affiche en temps réel les objets suivis et leurs statistiques. La liste des objets affiche un résumé différé des objets détectés." + "desc": "La vue de débogage affiche en temps réel les objets suivis et leurs statistiques. La liste des objets affiche un résumé différé des objets détectés.", + "paths": { + "title": "Chemins", + "desc": "Montrer les points notables du chemin de l'objet suivi", + "tips": "

Chemins


Les lignes et les cercles indiqueront les points notables des déplacements de l'objet suivi pendant son cycle de vie.

" + }, + "audio": { + "title": "Audio", + "noAudioDetections": "Aucune détection audio", + "score": "score", + "currentRMS": "RMS actuel", + "currentdbFS": "dbFS actuel" + }, + "openCameraWebUI": "Ouvre l'interface Web de {{camera}}" }, "users": { "title": "Utilisateurs", @@ -610,7 +662,8 @@ "admin": "Administrateur", "adminDesc": "Accès complet à l'ensemble des fonctionnalités.", "viewer": "Observateur", - "viewerDesc": "Limité aux tableaux de bord Direct, Revue d'événements, Explorer et Exports." + "viewerDesc": "Limité aux tableaux de bord Direct, Revue d'événements, Explorer et Exports.", + "customDesc": "Rôle personnalisé avec accès spécifique à la caméra" }, "select": "Sélectionnez un rôle" }, @@ -623,7 +676,7 @@ } }, "enrichments": { - "title": "Paramètres des données augmentées", + "title": "Paramètres d'enrichissements", "birdClassification": { "title": "Identification des oiseaux", "desc": "L'identification des oiseaux est réalisée à l'aide d'un modèle TensorFlow quantifié. Lorsqu'un oiseau est reconnu, son nom commun est automatiquement ajouté comme sous-libellé. Cette information est intégréesà l'interface utilisateur, aux filtres de recherche et aux notifications." @@ -655,7 +708,7 @@ }, "desc": "La recherche sémantique de Frigate vous permet de retrouver les objets suivis dans votre revue d'évènements en utilisant soit l'image elle-même, soit une description textuelle définie par l'utilisateur, soit une description générée automatiquement." }, - "unsavedChanges": "Modifications non enregistrées des paramètres des données augmentées", + "unsavedChanges": "Modifications non enregistrées des paramètres d'enrichissements", "faceRecognition": { "title": "Reconnaissance faciale", "readTheDocumentation": "Lire la documentation", @@ -680,8 +733,163 @@ }, "toast": { "error": "Échec de l'enregistrement des modifications de configuration : {{errorMessage}}", - "success": "Les paramètres de données augmentées ont été enregistrés. Redémarrez Frigate pour appliquer les modifications." + "success": "Les paramètres d'enrichissements ont été enregistrés. Redémarrez Frigate pour appliquer les modifications." }, - "restart_required": "Redémarrage nécessaire (paramètres des données augmentées modifiés)" + "restart_required": "Redémarrage nécessaire (paramètres d'enrichissements modifiés)" + }, + "triggers": { + "documentTitle": "Déclencheurs", + "management": { + "title": "Gestion des déclencheurs", + "desc": "Gérer les déclencheurs pour {{camera}}. Utilisez le type vignette pour déclencher à partir de vignettes similaires à l'objet suivi sélectionné. Utilisez le type description pour déclencher à partir de textes de description similaires que vous avez spécifiés." + }, + "addTrigger": "Ajouter un déclencheur", + "table": { + "name": "Nom", + "type": "Type", + "content": "Contenu", + "threshold": "Seuil", + "actions": "Actions", + "noTriggers": "Aucun déclencheur configuré pour cette caméra.", + "edit": "Éditer", + "deleteTrigger": "Supprimer le déclencheur", + "lastTriggered": "Dernier déclencheur" + }, + "type": { + "thumbnail": "Vignette", + "description": "Description" + }, + "actions": { + "alert": "Marquer comme alerte", + "notification": "Envoyer une notification" + }, + "dialog": { + "createTrigger": { + "title": "Créer un déclencheur", + "desc": "Créer un déclencheur pour la caméra {{camera}}" + }, + "editTrigger": { + "title": "Éditer le déclencheur", + "desc": "Éditer les paramètres du déclencheur de la caméra {{camera}}" + }, + "deleteTrigger": { + "title": "Supprimer le déclencheur", + "desc": "Êtes-vous sûr de vouloir supprimer le déclencheur {{triggerName}} ? Cette action est irréversible." + }, + "form": { + "name": { + "title": "Nom", + "placeholder": "Entrez le nom du déclencheur", + "error": { + "minLength": "Le nom soit comporter au moins deux caractères.", + "invalidCharacters": "Le nom peut contenir uniquement des lettres, des nombres, des tirets bas, et des tirets.", + "alreadyExists": "Un déclencheur avec le même nom existe déjà pour cette caméra." + } + }, + "enabled": { + "description": "Activer ou désactiver ce déclencheur" + }, + "type": { + "title": "Type", + "placeholder": "Sélectionner un type de déclencheur" + }, + "content": { + "title": "Contenu", + "imagePlaceholder": "Sélectionner une image", + "textPlaceholder": "Saisir le contenu du texte", + "imageDesc": "Sélectionnez une image pour déclencher cette action lorsqu'une image similaire est détectée.", + "textDesc": "Entrez un texte pour déclencher cette action lorsqu'une description similaire d'objet suivi est détectée.", + "error": { + "required": "Le contenu est requis." + } + }, + "threshold": { + "title": "Seuil", + "error": { + "min": "Le seuil doit être au moins 0", + "max": "Le seuil peut être au plus 1" + } + }, + "actions": { + "title": "Actions", + "desc": "Par défaut, Frigate publie un message MQTT à chaque déclenchement. Sélectionnez une action complémentaire à exécuter lors de ce déclenchement.", + "error": { + "min": "Au moins une action doit être sélectionnée." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Le déclencheur {{name}} a été créé avec succès.", + "updateTrigger": "Le déclencheur {{name}} a été mis à jour avec succès.", + "deleteTrigger": "Le déclencheur {{name}} a été supprimé avec succès." + }, + "error": { + "createTriggerFailed": "Échec de la création du déclencheur : {{errorMessage}}", + "updateTriggerFailed": "Échec de la mise à jour du déclencheur : {{errorMessage}}", + "deleteTriggerFailed": "Échec de la suppression du déclencheur : {{errorMessage}}" + } + } + }, + "roles": { + "management": { + "title": "Gestion des rôles de visionnage", + "desc": "Gérer les rôles personnalisés de visionnage et leurs permissions d'accès aux caméras pour cette instance de Frigate." + }, + "addRole": "Ajouter un rôle", + "table": { + "role": "Rôle", + "cameras": "Caméras", + "actions": "Actions", + "noRoles": "Aucun rôle personnalisé trouvé.", + "editCameras": "Éditer les caméras", + "deleteRole": "Supprimer le rôle" + }, + "toast": { + "success": { + "createRole": "Rôle {{role}} créé avec succès", + "updateCameras": "Caméras mis à jour pour le rôle {{role}}", + "deleteRole": "Rôle {{role}} supprimé avec succès", + "userRolesUpdated": "{{count}} utilisateurs affectés à ce rôle ont été mis à jour avec des droits 'visionnage', et ont accès à toutes les caméras." + }, + "error": { + "createRoleFailed": "Échec dans la création du rôle : {{errorMessage}}", + "updateCamerasFailed": "Échec de la mise à jour des caméras : {{errorMessage}}", + "deleteRoleFailed": "Échec lors de la suppression du rôle : {{errorMessage}}", + "userUpdateFailed": "Echec lors de la mise à jour des rôles de l'utilisateur : {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Créer un nouveau rôle", + "desc": "Ajouter un nouveau rôle et définir les permissions d'accès à la caméra." + }, + "editCameras": { + "title": "Editer les caméras du rôle", + "desc": "Mettre à jour les accès aux caméras pour le rôle {{role}}." + }, + "deleteRole": { + "title": "Suppression du rôle", + "desc": "Cette action est irréversible. Elle supprimera définitivement le rôle et tous les utilisateurs associés seront affectés au rôle 'visionnage', avec un accès à toutes les caméras.", + "warn": "Êtes-vous sûr de vouloir supprimer {{role}} ?", + "deleting": "En cours de suppression..." + }, + "form": { + "role": { + "title": "Nom du rôle", + "placeholder": "Saisissez un nom de rôle", + "desc": "Seuls les lettres, nombres, points et tirets bas sont autorisés.", + "roleIsRequired": "Un nom de rôle est requis", + "roleOnlyInclude": "Le nom de rôle peut uniquement contenir des lettres, nombres, . ou _", + "roleExists": "Un rôle avec ce nom existe déjà." + }, + "cameras": { + "title": "Caméras", + "desc": "Sélectionnez les caméras auxquelles ce rôle aura accès. Au moins une caméra est requise.", + "required": "Au moins une caméra doit être sélectionnée." + } + } + } } } diff --git a/web/public/locales/fr/views/system.json b/web/public/locales/fr/views/system.json index 9b3d8a5dc7..cf9c1699ec 100644 --- a/web/public/locales/fr/views/system.json +++ b/web/public/locales/fr/views/system.json @@ -3,7 +3,7 @@ "storage": "Statistiques de stockage - Frigate", "cameras": "Statistiques des caméras - Frigate", "general": "Statistiques générales - Frigate", - "enrichments": "Statistiques de données augmentées - Frigate", + "enrichments": "Statistiques d'enrichissements - Frigate", "logs": { "frigate": "Journaux de Frigate - Frigate", "nginx": "Journaux Nginx - Frigate", @@ -42,7 +42,8 @@ "inferenceSpeed": "Vitesse d'inférence du détecteur", "cpuUsage": "Utilisation processeur du détecteur", "memoryUsage": "Utilisation mémoire du détecteur", - "temperature": "Température du détecteur" + "temperature": "Température du détecteur", + "cpuUsageInformation": "Utilisation du processeur pour préparer les données avant et après leur passage dans les modèles de détection. Cette valeur ne mesure pas les calculs d'inférence, même si un GPU ou un autre accélérateur est utilisé." }, "hardwareInfo": { "title": "Info matériel", @@ -102,7 +103,11 @@ "camera": "Caméra", "unusedStorageInformation": "Information sur le stockage non utilisé" }, - "overview": "Vue d'ensemble" + "overview": "Vue d'ensemble", + "shm": { + "title": "Allocation de mémoire partagée SHM", + "warning": "La taille actuelle de la SHM de {{total}} Mo est trop faible. Augmentez la au moins à {{min_shm}} Mo." + } }, "cameras": { "title": "Caméras", @@ -158,10 +163,11 @@ "reindexingEmbeddings": "Réindexation des représentations numériques ({{processed}}% complété)", "cameraIsOffline": "{{camera}} est hors ligne", "detectIsSlow": "{{detect}} est lent ({{speed}} ms)", - "detectIsVerySlow": "{{detect}} est très lent ({{speed}} ms)" + "detectIsVerySlow": "{{detect}} est très lent ({{speed}} ms)", + "shmTooLow": "L'allocation /dev/shm ({{total}} Mo) devrait être augmentée à au moins {{min}} Mo." }, "enrichments": { - "title": "Données augmentées", + "title": "Enrichissements", "infPerSecond": "Inférences par seconde", "embeddings": { "face_embedding_speed": "Vitesse de capture des données complémentaires de visage", diff --git a/web/public/locales/gl/common.json b/web/public/locales/gl/common.json index 1443cce355..61b9ce58fd 100644 --- a/web/public/locales/gl/common.json +++ b/web/public/locales/gl/common.json @@ -9,5 +9,6 @@ "today": "Hoxe", "untilRestart": "Ata o reinicio", "ago": "Fai {{timeAgo}}" - } + }, + "readTheDocumentation": "Ler a documentación" } diff --git a/web/public/locales/gl/components/auth.json b/web/public/locales/gl/components/auth.json index 2a0bee0d5a..8b0857dac1 100644 --- a/web/public/locales/gl/components/auth.json +++ b/web/public/locales/gl/components/auth.json @@ -5,7 +5,8 @@ "errors": { "passwordRequired": "Contrasinal obrigatorio", "unknownError": "Erro descoñecido. Revisa os logs.", - "usernameRequired": "Usuario/a obrigatorio" + "usernameRequired": "Usuario/a obrigatorio", + "rateLimit": "Excedido o límite. Téntao de novo despois." }, "login": "Iniciar sesión" } diff --git a/web/public/locales/gl/components/dialog.json b/web/public/locales/gl/components/dialog.json index c6519972a1..d2aff40d19 100644 --- a/web/public/locales/gl/components/dialog.json +++ b/web/public/locales/gl/components/dialog.json @@ -15,6 +15,9 @@ "label": "Confirma esta etiqueta para Frigate Plus", "ask_an": "E isto un obxecto {{label}}?" } + }, + "submitToPlus": { + "label": "Enviar a Frigate+" } } } diff --git a/web/public/locales/gl/components/filter.json b/web/public/locales/gl/components/filter.json index 6927e2e510..8ef5f8fd11 100644 --- a/web/public/locales/gl/components/filter.json +++ b/web/public/locales/gl/components/filter.json @@ -6,7 +6,8 @@ "all": { "short": "Etiquetas", "title": "Todas as Etiquetas" - } + }, + "count_other": "{{count}} Etiquetas" }, "zones": { "all": { diff --git a/web/public/locales/gl/views/events.json b/web/public/locales/gl/views/events.json index c5c9cb67b8..56da9d9e53 100644 --- a/web/public/locales/gl/views/events.json +++ b/web/public/locales/gl/views/events.json @@ -6,5 +6,8 @@ "motion": { "only": "Só movemento", "label": "Movemento" + }, + "empty": { + "alert": "Non hai alertas que revisar" } } diff --git a/web/public/locales/he/common.json b/web/public/locales/he/common.json index e6c1d632fa..813d44e51c 100644 --- a/web/public/locales/he/common.json +++ b/web/public/locales/he/common.json @@ -172,9 +172,17 @@ "ja": "יפנית", "de": "גרמנית", "yue": "קנטונזית", - "ca": "קטלה (קטלאנית)" + "ca": "קטלה (קטלאנית)", + "ptBR": "פורטוגזית - ברזיל", + "sr": "סרבית", + "sl": "סלובנית", + "lt": "ליטאית", + "bg": "בולגרית", + "gl": "Galego", + "id": "אינדונזית", + "ur": "اردو" }, - "appearance": "מראה.", + "appearance": "מראה", "darkMode": { "label": "מצב כהה", "light": "בהיר", @@ -261,5 +269,6 @@ "title": "404", "desc": "דף לא נמצא" }, - "selectItem": "בחירה:{{item}}" + "selectItem": "בחירה:{{item}}", + "readTheDocumentation": "קרא את התיעוד" } diff --git a/web/public/locales/he/views/settings.json b/web/public/locales/he/views/settings.json index a628048fc2..1ab0ab917f 100644 --- a/web/public/locales/he/views/settings.json +++ b/web/public/locales/he/views/settings.json @@ -279,7 +279,8 @@ "users": "משתמשים", "notifications": "התראות", "frigateplus": "+Frigate", - "enrichments": "תוספות" + "enrichments": "תוספות", + "triggers": "הפעלות" }, "dialog": { "unsavedChanges": { diff --git a/web/public/locales/hi/audio.json b/web/public/locales/hi/audio.json index afffaf44a4..0705110cff 100644 --- a/web/public/locales/hi/audio.json +++ b/web/public/locales/hi/audio.json @@ -139,5 +139,7 @@ "raindrop": "बारिश की बूंद", "rowboat": "चप्पू वाली नाव", "aircraft": "विमान", - "bicycle": "साइकिल" + "bicycle": "साइकिल", + "bellow": "गर्जना करना", + "motorcycle": "मोटरसाइकिल" } diff --git a/web/public/locales/hi/common.json b/web/public/locales/hi/common.json index 392c9a8441..d4c4335192 100644 --- a/web/public/locales/hi/common.json +++ b/web/public/locales/hi/common.json @@ -2,6 +2,7 @@ "time": { "untilForTime": "{{time}} तक", "untilForRestart": "जब तक फ्रिगेट पुनः रीस्टार्ट नहीं हो जाता।", - "untilRestart": "रीस्टार्ट होने में" + "untilRestart": "रीस्टार्ट होने में", + "ago": "{{timeAgo}} पहले" } } diff --git a/web/public/locales/hi/components/camera.json b/web/public/locales/hi/components/camera.json index 37c5b27ed2..74c05d4693 100644 --- a/web/public/locales/hi/components/camera.json +++ b/web/public/locales/hi/components/camera.json @@ -2,6 +2,9 @@ "group": { "label": "कैमरा समूह", "add": "कैमरा समूह जोड़ें", - "edit": "कैमरा समूह संपादित करें" + "edit": "कैमरा समूह संपादित करें", + "delete": { + "label": "कैमरा समूह हटाएँ" + } } } diff --git a/web/public/locales/hi/components/dialog.json b/web/public/locales/hi/components/dialog.json index dce6983b58..bcf4cd070f 100644 --- a/web/public/locales/hi/components/dialog.json +++ b/web/public/locales/hi/components/dialog.json @@ -3,7 +3,8 @@ "title": "क्या आप निश्चित हैं कि आप फ्रिगेट को रीस्टार्ट करना चाहते हैं?", "button": "रीस्टार्ट", "restarting": { - "title": "फ्रिगेट रीस्टार्ट हो रहा है" + "title": "फ्रिगेट रीस्टार्ट हो रहा है", + "content": "यह पृष्ठ {{countdown}} सेकंड में पुनः लोड होगा।" } } } diff --git a/web/public/locales/hi/components/filter.json b/web/public/locales/hi/components/filter.json index 214179375f..a89133b706 100644 --- a/web/public/locales/hi/components/filter.json +++ b/web/public/locales/hi/components/filter.json @@ -3,7 +3,8 @@ "labels": { "label": "लेबल", "all": { - "title": "सभी लेबल" + "title": "सभी लेबल", + "short": "लेबल" } } } diff --git a/web/public/locales/hi/components/player.json b/web/public/locales/hi/components/player.json index 9b4ed4389e..e5e63a82da 100644 --- a/web/public/locales/hi/components/player.json +++ b/web/public/locales/hi/components/player.json @@ -1,5 +1,8 @@ { "noRecordingsFoundForThisTime": "इस समय का कोई रिकॉर्डिंग नहीं मिला", "noPreviewFound": "कोई प्रीव्यू नहीं मिला", - "noPreviewFoundFor": "{{cameraName}} के लिए कोई पूर्वावलोकन नहीं मिला" + "noPreviewFoundFor": "{{cameraName}} के लिए कोई पूर्वावलोकन नहीं मिला", + "submitFrigatePlus": { + "title": "इस फ्रेम को फ्रिगेट+ पर सबमिट करें?" + } } diff --git a/web/public/locales/hi/objects.json b/web/public/locales/hi/objects.json index 436a576688..a4e93c3ab0 100644 --- a/web/public/locales/hi/objects.json +++ b/web/public/locales/hi/objects.json @@ -13,5 +13,6 @@ "vehicle": "वाहन", "car": "गाड़ी", "person": "व्यक्ति", - "bicycle": "साइकिल" + "bicycle": "साइकिल", + "motorcycle": "मोटरसाइकिल" } diff --git a/web/public/locales/hi/views/events.json b/web/public/locales/hi/views/events.json index b6fba2aa1c..ae2091467e 100644 --- a/web/public/locales/hi/views/events.json +++ b/web/public/locales/hi/views/events.json @@ -1,4 +1,8 @@ { "alerts": "अलर्टस", - "detections": "खोजें" + "detections": "खोजें", + "motion": { + "label": "गति", + "only": "केवल गति" + } } diff --git a/web/public/locales/hi/views/explore.json b/web/public/locales/hi/views/explore.json index bb214ba127..daafb9c2d3 100644 --- a/web/public/locales/hi/views/explore.json +++ b/web/public/locales/hi/views/explore.json @@ -1,4 +1,8 @@ { "documentTitle": "अन्वेषण करें - फ्रिगेट", - "generativeAI": "जनरेटिव ए आई" + "generativeAI": "जनरेटिव ए आई", + "exploreMore": "और अधिक {{label}} वस्तुओं का अन्वेषण करें", + "exploreIsUnavailable": { + "title": "अन्वेषण अनुपलब्ध है" + } } diff --git a/web/public/locales/hi/views/exports.json b/web/public/locales/hi/views/exports.json index 97a0f0e537..b9e86dac15 100644 --- a/web/public/locales/hi/views/exports.json +++ b/web/public/locales/hi/views/exports.json @@ -1,4 +1,6 @@ { "documentTitle": "निर्यात - फ्रिगेट", - "search": "खोजें" + "search": "खोजें", + "noExports": "कोई निर्यात नहीं मिला", + "deleteExport": "निर्यात हटाएँ" } diff --git a/web/public/locales/hi/views/faceLibrary.json b/web/public/locales/hi/views/faceLibrary.json index 5c8de952e6..b30528265c 100644 --- a/web/public/locales/hi/views/faceLibrary.json +++ b/web/public/locales/hi/views/faceLibrary.json @@ -1,6 +1,7 @@ { "description": { "addFace": "फेस लाइब्रेरी में नया संग्रह जोड़ने की प्रक्रिया को आगे बढ़ाएं।", - "placeholder": "इस संग्रह का नाम बताएं" + "placeholder": "इस संग्रह का नाम बताएं", + "invalidName": "अमान्य नाम. नाम में केवल अक्षर, संख्याएँ, रिक्त स्थान, एपॉस्ट्रॉफ़ी, अंडरस्कोर और हाइफ़न ही शामिल हो सकते हैं।" } } diff --git a/web/public/locales/hi/views/live.json b/web/public/locales/hi/views/live.json index 86d2a9235c..9c7edbeab9 100644 --- a/web/public/locales/hi/views/live.json +++ b/web/public/locales/hi/views/live.json @@ -1,4 +1,5 @@ { "documentTitle": "लाइव - फ्रिगेट", - "documentTitle.withCamera": "{{camera}} - लाइव - फ्रिगेट" + "documentTitle.withCamera": "{{camera}} - लाइव - फ्रिगेट", + "lowBandwidthMode": "कम बैंडविड्थ मोड" } diff --git a/web/public/locales/hi/views/search.json b/web/public/locales/hi/views/search.json index b38dd11af0..2ea0c8cfeb 100644 --- a/web/public/locales/hi/views/search.json +++ b/web/public/locales/hi/views/search.json @@ -1,4 +1,5 @@ { "search": "खोजें", - "savedSearches": "सहेजी गई खोजें" + "savedSearches": "सहेजी गई खोजें", + "searchFor": "{{inputValue}} खोजें" } diff --git a/web/public/locales/hi/views/settings.json b/web/public/locales/hi/views/settings.json index 5fe3a32331..d9bf27ffcd 100644 --- a/web/public/locales/hi/views/settings.json +++ b/web/public/locales/hi/views/settings.json @@ -1,6 +1,7 @@ { "documentTitle": { "default": "सेटिंग्स - फ्रिगेट", - "authentication": "प्रमाणीकरण सेटिंग्स - फ्रिगेट" + "authentication": "प्रमाणीकरण सेटिंग्स - फ्रिगेट", + "camera": "कैमरा सेटिंग्स - फ्रिगेट" } } diff --git a/web/public/locales/hi/views/system.json b/web/public/locales/hi/views/system.json index b29ff9abbc..23bafa3fcf 100644 --- a/web/public/locales/hi/views/system.json +++ b/web/public/locales/hi/views/system.json @@ -1,6 +1,7 @@ { "documentTitle": { "cameras": "कैमरा आँकड़े - फ्रिगेट", - "storage": "भंडारण आँकड़े - फ्रिगेट" + "storage": "भंडारण आँकड़े - फ्रिगेट", + "general": "सामान्य आँकड़े - फ्रिगेट" } } diff --git a/web/public/locales/hu/audio.json b/web/public/locales/hu/audio.json index 7d5d49bf9d..cc73f3ccce 100644 --- a/web/public/locales/hu/audio.json +++ b/web/public/locales/hu/audio.json @@ -149,7 +149,7 @@ "car": "Autó", "bus": "Busz", "motorcycle": "Motor", - "train": "Vonat", + "train": "Betanít", "bicycle": "Bicikli", "scream": "Sikoly", "throat_clearing": "Torokköszörülés", diff --git a/web/public/locales/hu/common.json b/web/public/locales/hu/common.json index c45157b38a..f75193fd15 100644 --- a/web/public/locales/hu/common.json +++ b/web/public/locales/hu/common.json @@ -142,7 +142,15 @@ "ro": "Román", "hu": "Magyar", "fi": "Finn", - "th": "Thai" + "th": "Thai", + "ptBR": "Português brasileiro (Brazil portugál)", + "sr": "Српски (Szerb)", + "sl": "Slovenščina (Szlovén)", + "lt": "Lietuvių (Litván)", + "bg": "Български (Bolgár)", + "gl": "Galego (Galíciai)", + "id": "Bahasa Indonesia (Indonéz)", + "ur": "اردو (Urdu)" }, "uiPlayground": "UI játszótér", "faceLibrary": "Arc Könyvtár", @@ -254,5 +262,6 @@ }, "label": { "back": "Vissza" - } + }, + "readTheDocumentation": "Olvassa el a dokumentációt" } diff --git a/web/public/locales/hu/components/camera.json b/web/public/locales/hu/components/camera.json index e53fb9c18d..c5294818d4 100644 --- a/web/public/locales/hu/components/camera.json +++ b/web/public/locales/hu/components/camera.json @@ -1,6 +1,6 @@ { "group": { - "label": "Kamera Csoport", + "label": "Kamera Csoportok", "delete": { "confirm": { "desc": "Biztosan törölni akarja a következő kamera csoportot {{name}}?", @@ -66,7 +66,8 @@ "desc": "Csak akkor engedélyezze ezt az opciót, ha a kamera élő közvetítése képhibás, és a kép jobb oldalán átlós vonal látható." }, "desc": "Változtassa meg az élő adás beállításait ezen kamera csoport kijelzőjén. Ezek a beállítások eszköz/böngésző-specifikusak." - } + }, + "birdseye": "Madártávlat" } }, "debug": { diff --git a/web/public/locales/hu/components/dialog.json b/web/public/locales/hu/components/dialog.json index bff6ade191..8a6a452ed8 100644 --- a/web/public/locales/hu/components/dialog.json +++ b/web/public/locales/hu/components/dialog.json @@ -1,6 +1,6 @@ { "restart": { - "title": "Biztosan újra szeretnéd indítani a Frigate-ot?", + "title": "Biztosan újra szeretnéd indítani a Frigate-et?", "button": "Újraindítás", "restarting": { "title": "A Frigate újraindul", @@ -22,7 +22,7 @@ "ask_a": "Ez a tárgy egy {{label}}?", "label": "Erősítse meg ezt a cimkét a Frigate plus felé", "ask_an": "Ez a tárgy egy {{label}}?", - "ask_full": "Ez a tárgy egy {{untranslatedLabel}} ({{translatedLabel}})?" + "ask_full": "Ez a tárgy egy {{translatedLabel}} ({{untranslatedLabel}})?" } } }, @@ -109,5 +109,12 @@ "deleteNow": "Törlés Most", "export": "Exportálás" } + }, + "imagePicker": { + "selectImage": "Válassza ki egy követett tárgy képét", + "search": { + "placeholder": "Keresés cimke vagy alcimke alapján..." + }, + "noImages": "Nem találhatók bélyegképek ehhez a kamerához" } } diff --git a/web/public/locales/hu/components/filter.json b/web/public/locales/hu/components/filter.json index f4b9b9f39c..8af6361f49 100644 --- a/web/public/locales/hu/components/filter.json +++ b/web/public/locales/hu/components/filter.json @@ -97,7 +97,7 @@ "label": "Keresés Forrás", "options": { "description": "Leírás", - "thumbnailImage": "Bélyegkép" + "thumbnailImage": "Indexkép" }, "desc": "Válassza ki, hogy a követett objektumok bélyegképeiben vagy leírásaiban szeretne keresni." }, @@ -121,6 +121,16 @@ "noLicensePlatesFound": "Rendszámtábla nem található.", "selectPlatesFromList": "Válasszon ki egy vagy több rendszámtáblát a listából.", "loading": "Felismert rendszámtáblák betöltése…", - "placeholder": "Kezdjen gépelni a rendszámok közötti kereséshez…" + "placeholder": "Kezdjen gépelni a rendszámok közötti kereséshez…", + "selectAll": "Mindet kijelöl", + "clearAll": "Mindet törli" + }, + "classes": { + "label": "Osztályok", + "all": { + "title": "Minden Osztály" + }, + "count_one": "{{count}} Osztály", + "count_other": "{{count}} Osztályok" } } diff --git a/web/public/locales/hu/objects.json b/web/public/locales/hu/objects.json index 5bac94fc33..4b53d161b3 100644 --- a/web/public/locales/hu/objects.json +++ b/web/public/locales/hu/objects.json @@ -5,7 +5,7 @@ "motorcycle": "Motor", "airplane": "Repülőgép", "bus": "Busz", - "train": "Vonat", + "train": "Betanít", "boat": "Hajó", "dog": "Kutya", "cat": "Macska", diff --git a/web/public/locales/hu/views/configEditor.json b/web/public/locales/hu/views/configEditor.json index b921c987d0..69fa822e96 100644 --- a/web/public/locales/hu/views/configEditor.json +++ b/web/public/locales/hu/views/configEditor.json @@ -12,5 +12,7 @@ "savingError": "Hiba a konfiguráció mentésekor" } }, - "confirm": "Kilép mentés nélkül?" + "confirm": "Kilép mentés nélkül?", + "safeConfigEditor": "Konfiguráció szerkesztő (Biztosnági Mód)", + "safeModeDescription": "Frigate biztonsági módban van konfigurációs hiba miatt." } diff --git a/web/public/locales/hu/views/events.json b/web/public/locales/hu/views/events.json index 586953de1b..d470abbd5c 100644 --- a/web/public/locales/hu/views/events.json +++ b/web/public/locales/hu/views/events.json @@ -34,5 +34,7 @@ "markTheseItemsAsReviewed": "Ezen elemek megjelölése áttekintettként", "markAsReviewed": "Megjelölés Áttekintettként", "selected_one": "{{count}} kiválasztva", - "selected_other": "{{count}} kiválasztva" + "selected_other": "{{count}} kiválasztva", + "suspiciousActivity": "Gyanús Tevékenység", + "threateningActivity": "Fenyegető Tevékenység" } diff --git a/web/public/locales/hu/views/explore.json b/web/public/locales/hu/views/explore.json index 9f5cd48146..cf811cdef8 100644 --- a/web/public/locales/hu/views/explore.json +++ b/web/public/locales/hu/views/explore.json @@ -27,6 +27,14 @@ "downloadSnapshot": { "aria": "Pillanatfelvétel letöltése", "label": "Pillanatfelvétel letöltése" + }, + "addTrigger": { + "label": "Indító hozzáadása", + "aria": "Indító hozzáadása ehhez a követett tárgyhoz" + }, + "audioTranscription": { + "label": "Átírás", + "aria": "Hangátirat kérése" } }, "details": { @@ -65,12 +73,14 @@ "error": { "updatedLPRFailed": "Rendszám frissítése sikertelen: {{errorMessage}}", "updatedSublabelFailed": "Alcimke frissítése sikertelen: {{errorMessage}}", - "regenerate": "Nem sikerült meghívni a(z) {{provider}} szolgáltatót az új leírásért: {{errorMessage}}" + "regenerate": "Nem sikerült meghívni a(z) {{provider}} szolgáltatót az új leírásért: {{errorMessage}}", + "audioTranscription": "Nem sikerült hangátiratot kérni: {{errorMessage}}" }, "success": { "updatedSublabel": "Az alcimke sikeresen frissítve.", "updatedLPR": "Rendszám sikeresen frissítve.", - "regenerate": "Új leírást kértünk a(z) {{provider}} szolgáltatótól. A szolgáltató sebességétől függően az új leírás előállítása eltarthat egy ideig." + "regenerate": "Új leírást kértünk a(z) {{provider}} szolgáltatótól. A szolgáltató sebességétől függően az új leírás előállítása eltarthat egy ideig.", + "audioTranscription": "Sikeresen kérte a hangátírást." } }, "button": { @@ -97,7 +107,10 @@ }, "findSimilar": "Keress Hasonlót" }, - "expandRegenerationMenu": "Újragenerálási menü kiterjesztése" + "expandRegenerationMenu": "Újragenerálási menü kiterjesztése", + "score": { + "label": "Pontszám" + } }, "searchResult": { "deleteTrackedObject": { @@ -203,5 +216,11 @@ "snapshot": "pillanatfelvétel" }, "trackedObjectDetails": "Követett Tárgy Részletei", - "exploreMore": "Fedezzen fel több {{label}} tárgyat" + "exploreMore": "Fedezzen fel több {{label}} tárgyat", + "aiAnalysis": { + "title": "MI-elemzés" + }, + "concerns": { + "label": "Aggodalmak" + } } diff --git a/web/public/locales/hu/views/faceLibrary.json b/web/public/locales/hu/views/faceLibrary.json index 4aaef392d9..7b12521d5d 100644 --- a/web/public/locales/hu/views/faceLibrary.json +++ b/web/public/locales/hu/views/faceLibrary.json @@ -1,7 +1,7 @@ { "renameFace": { "title": "Arc átnevezése", - "desc": "Adjon meg egy új nevet {{name}}-nak/-nek" + "desc": "Adjon meg egy új nevet neki: {{name}}" }, "details": { "subLabelScore": "Alcimke érték", @@ -90,7 +90,7 @@ "nofaces": "Nincs elérhető arc", "documentTitle": "Arc könyvtár - Frigate", "train": { - "title": "Vonat", + "title": "Tanít", "empty": "Nincs friss arcfelismerés", "aria": "Válassza ki a tanítást" }, diff --git a/web/public/locales/hu/views/live.json b/web/public/locales/hu/views/live.json index 73a8f81f90..04f0836f00 100644 --- a/web/public/locales/hu/views/live.json +++ b/web/public/locales/hu/views/live.json @@ -43,7 +43,15 @@ "label": "Kattinston a képre a PTZ kamera középre igazításához" } }, - "presets": "PTZ kamera előzetes beállításai" + "presets": "PTZ kamera előzetes beállításai", + "focus": { + "in": { + "label": "PTZ kamera fókuszálás BE" + }, + "out": { + "label": "PTZ kamera fókuszálás KI" + } + } }, "camera": { "enable": "Kamera Engedélyezése", @@ -135,7 +143,8 @@ "recording": "Felvétel", "audioDetection": "Hang Észlelés", "snapshots": "Pillanatképek", - "autotracking": "Automatikus követés" + "autotracking": "Automatikus követés", + "transcription": "Hang Feliratozás" }, "history": { "label": "Előzmény felvételek megjelenítése" @@ -154,5 +163,9 @@ "label": "Kameracsoport szerkesztése" }, "exitEdit": "Szerkesztés bezárása" + }, + "transcription": { + "enable": "Élő Audio Feliratozás Engedélyezése", + "disable": "Élő Audio Feliratozás Kikapcsolása" } } diff --git a/web/public/locales/hu/views/settings.json b/web/public/locales/hu/views/settings.json index 5fc972304e..f03fe4058a 100644 --- a/web/public/locales/hu/views/settings.json +++ b/web/public/locales/hu/views/settings.json @@ -22,7 +22,8 @@ "users": "Felhasználók", "notifications": "Értesítések", "frigateplus": "Frigate+", - "enrichments": "Gazdagítások" + "enrichments": "Gazdagítások", + "triggers": "Triggerek" }, "dialog": { "unsavedChanges": { @@ -559,6 +560,19 @@ "title": "Régiók", "desc": "Mutassa a célterület keretét, amelyet az objektumérzékelőhöz küldenek", "tips": "

Célterület keretek


Világoszöld keretek jelennek meg a képkocka azon területein, amelyek az objektumérzékelőnek elküldésre kerülnek.

" + }, + "paths": { + "title": "Útvonalak", + "desc": "A követett objektum útvonalához tartozó jelentősebb pontok megjelenítése", + "tips": "

Útvonalak


A vonalak és körök jelzik a követett objektum életciklusa során érintett jelentősebb pontokat.

" + }, + "openCameraWebUI": "Nyissa meg a {{camera}} webes felületét", + "audio": { + "title": "Hang", + "noAudioDetections": "Nincs hangérzékelés", + "score": "pontszám", + "currentRMS": "Aktuális effektív érték", + "currentdbFS": "Aktuális dbFS" } }, "motionDetectionTuner": { @@ -616,6 +630,139 @@ "success": "Az Ellenőrzési Kategorizálás beállításai elmentésre kerültek. A módosítások alkalmazásához indítsa újra a Frigate-et." } }, - "title": "Kamera Beállítások" + "title": "Kamera Beállítások", + "object_descriptions": { + "title": "Generatív AI Tárgy Leírások", + "desc": "Ideiglenesen engedélyezze/tiltsa le a generatív AI objektumleírásokat ehhez a kamerához. Letiltás esetén a rendszer nem kéri le a mesterséges intelligencia által generált leírásokat a kamerán követett objektumokhoz." + }, + "addCamera": "Új Kamera Hozzáadása", + "editCamera": "Kamera Szerkesztése:", + "selectCamera": "Válasszon ki egy Kamerát", + "backToSettings": "Vissza a Kamera Beállításokhoz", + "cameraConfig": { + "add": "Kamera Hozzáadása", + "edit": "Kamera Szerkesztése", + "name": "Kamera Neve", + "nameRequired": "Kamera nevének megadása szükséges", + "description": "Konfigurálja a kamera beállításait, beleértve a stream bemeneteket és szerepeket.", + "nameInvalid": "A kamera neve csak betűket, számokat, aláhúzásjeleket vagy kötőjeleket tartalmazhat", + "namePlaceholder": "pl: bejarati_ajto", + "enabled": "Engedélyezve", + "ffmpeg": { + "inputs": "Bemeneti Adatfolyamok", + "path": "Adatfolyam útvonal", + "pathRequired": "Adatfolyam útvonal szükséges", + "pathPlaceholder": "rtsp://...", + "roles": "Szerepkörök", + "rolesRequired": "Legalább egy szerepkör megadása kötelező", + "rolesUnique": "Minden szerepkör (hang, érzékelés, rögzítés) csak egy adatfolyamhoz rendelhető hozzá", + "addInput": "Bejövő Adatfolyam Hozzáadása", + "removeInput": "Bejövő Adatfolyam Eltávolítása", + "inputsRequired": "Legalább egy bemeneti adatfolyam szükséges" + }, + "toast": { + "success": "A következő kamera sikeresen mentve: {{cameraName}}" + }, + "nameLength": "A kamera nevének kevesebbnek kell lennie 24 karakternél." + }, + "review_descriptions": { + "title": "Generatív MI Áttekintési Leírások", + "desc": "Ideiglenesen engedélyezze/tiltsa le a generatív mesterséges intelligencia által generált leírásokat ehhez a kamerához. Letiltás esetén a mesterséges intelligencia által generált leírások nem lesznek lekérve a kamerán található elemhez." + } + }, + "triggers": { + "documentTitle": "Trigger-ek", + "management": { + "title": "Triggerek kezelése", + "desc": "A(z) {{camera}} nevű kamera triggereinek kezelése. A bélyegkép típussal a kiválasztott követett objektumhoz hasonló bélyegképekre, a leírás típussal pedig a megadott szöveghez hasonló leírásokra aktiválhatja a funkciót." + }, + "addTrigger": "Trigger hozzáadása", + "table": { + "name": "Név", + "type": "Típus", + "content": "Tartalom", + "threshold": "Határérték", + "actions": "Akciók", + "noTriggers": "Nincsenek konfigurált triggerek ehhez a kamerához.", + "edit": "Szerkesztés", + "deleteTrigger": "Trigger törlése", + "lastTriggered": "Utoljára triggerelve" + }, + "type": { + "thumbnail": "Bélyegkép", + "description": "Leírás" + }, + "actions": { + "alert": "Megjelölés Riasztásként", + "notification": "Értesítés küldése" + }, + "dialog": { + "createTrigger": { + "title": "Trigger létrehozása", + "desc": "Hozz létre egy triggert a(z) {{camera}} kamerához" + }, + "editTrigger": { + "title": "Trigger Szerkesztése", + "desc": "Trigger beállítások szerkesztése a következő kamerán: {{camera}}" + }, + "deleteTrigger": { + "title": "Trigger Törlése", + "desc": "Biztosan törölni szeretné a(z){{triggerName}} triggert? Ez a művelet nem vonható vissza." + }, + "form": { + "name": { + "title": "Név", + "placeholder": "Add meg a trigger nevét", + "error": { + "minLength": "A névnek minimum 2 karakter hosszúnak kell lennie.", + "invalidCharacters": "A név csak betűket, számokat, aláhúzásjeleket és kötőjeleket tartalmazhat.", + "alreadyExists": "Már létezik egy ilyen nevű trigger ehhez a kamerához." + } + }, + "enabled": { + "description": "Engedélyezze vagy tiltsa le ezt a triggert" + }, + "type": { + "title": "Típus", + "placeholder": "Válaszd ki a trigger típusát" + }, + "content": { + "title": "Tartalom", + "imagePlaceholder": "Válassz egy képet", + "textPlaceholder": "Írja be a szöveges tartalmat", + "imageDesc": "Válasszon ki egy képet, amely aktiválja ezt a műveletet, amikor a rendszer hasonló képet észlel.", + "textDesc": "Írjon be egy szöveget, amely aktiválja ezt a műveletet, amikor a rendszer hasonló követett objektumleírást észlel.", + "error": { + "required": "Tartalom megadása kötelező." + } + }, + "threshold": { + "title": "Határérték", + "error": { + "min": "A határértéknek 0-nál nagyobbnak kell lennie", + "max": "A határérték legfeljebb 1 lehet" + } + }, + "actions": { + "title": "Akciók", + "desc": "Alapértelmezés szerint a Frigate minden trigger esetén MQTT üzenetet küld. Válasszon ki egy további műveletet, amelyet a trigger aktiválásakor végre kell hajtani.", + "error": { + "min": "Legalább egy műveletet ki kell választani." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "A trigger sikeresen létrehozva: {{name}}.", + "updateTrigger": "A trigger sikeresen módosítva: {{name}}.", + "deleteTrigger": "A trigger sikeresen törölve: {{name}}." + }, + "error": { + "createTriggerFailed": "A trigger létrehozása sikertelen: {{errorMessage}}", + "updateTriggerFailed": "A trigger módosítása sikertelen: {{errorMessage}}", + "deleteTriggerFailed": "A trigger törlése sikertelen: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/hu/views/system.json b/web/public/locales/hu/views/system.json index 847ac7c831..fffa798a3b 100644 --- a/web/public/locales/hu/views/system.json +++ b/web/public/locales/hu/views/system.json @@ -87,7 +87,8 @@ "inferenceSpeed": "Érzékelők Inferencia Sebessége", "cpuUsage": "Érzékelő CPU Kihasználtság", "memoryUsage": "Érzékelő Memória Kihasználtság", - "temperature": "Érzékelő Hőmérséklete" + "temperature": "Érzékelő Hőmérséklete", + "cpuUsageInformation": "A detektálási modellekbe érkező és onnan távozó bemeneti és kimeneti adatok előkészítéséhez használt CPU. Ez az érték nem méri a következtetési kihasználtságot, még GPU vagy gyorsító használata esetén sem." }, "hardwareInfo": { "title": "Hardver Infó", @@ -147,6 +148,10 @@ "title": "Felhasználatlan", "tips": "Ez az érték nem feltétlenül tükrözi pontosan a Frigate számára elérhető szabad helyet, ha a meghajtón egyéb fájlok is tárolva vannak a Frigate felvételein kívül. A Frigate nem követi a tárhelyhasználatot a saját felvételein kívül." } + }, + "shm": { + "title": "SHM (megosztott memória) kiosztás", + "warning": "A jelenlegi SHM mérete ({{total}}MB) túl kicsi. Növeld meg minimum ennyivel: {{min_shm}}MB." } }, "enrichments": { @@ -174,7 +179,8 @@ "detectIsSlow": "{{detect}} lassú ({{speed}} ms)", "reindexingEmbeddings": "Beágyazások újra indexelése ({{processed}}% kész)", "ffmpegHighCpuUsage": "{{camera}}-nak/-nek magas FFmpeg CPU felhasználása ({{ffmpegAvg}}%)", - "detectHighCpuUsage": "A(z) {{camera}} kameránál magas az észlelési CPU-használat ({{detectAvg}}%)" + "detectHighCpuUsage": "A(z) {{camera}} kameránál magas az észlelési CPU-használat ({{detectAvg}}%)", + "shmTooLow": "A /dev/shm részére foglalt területet ({{total}} MB) legalább {{min}} MB-ra kell növelni." }, "lastRefreshed": "Utoljára frissítve: " } diff --git a/web/public/locales/id/audio.json b/web/public/locales/id/audio.json index 0d46db1e10..d065bf1376 100644 --- a/web/public/locales/id/audio.json +++ b/web/public/locales/id/audio.json @@ -27,5 +27,59 @@ "bicycle": "Sepeda", "bus": "Bis", "train": "Kereta", - "boat": "Kapal" + "boat": "Kapal", + "sneeze": "Bersin", + "run": "Lari", + "footsteps": "Langkah kaki", + "chewing": "Mengunyah", + "biting": "Menggigit", + "stomach_rumble": "Perut Keroncongan", + "burping": "Sendawa", + "hiccup": "Cegukan", + "fart": "Kentut", + "hands": "Tangan", + "heartbeat": "Detak Jantung", + "applause": "Tepuk Tangan", + "chatter": "Obrolan", + "children_playing": "Anak-Anak Bermain", + "animal": "Binatang", + "pets": "Peliharaan", + "dog": "Anjing", + "bark": "Gonggongan", + "howl": "Melolong", + "cat": "Kucing", + "meow": "Meong", + "livestock": "Hewan Ternak", + "horse": "Kuda", + "cattle": "Sapi", + "pig": "Babi", + "goat": "Kambing", + "sheep": "Domba", + "chicken": "Ayam", + "cluck": "Berkokok", + "cock_a_doodle_doo": "Kukuruyuk", + "turkey": "Kalkun", + "duck": "Bebek", + "quack": "Kwek", + "goose": "Angsa", + "wild_animals": "Hewan Liar", + "bird": "Burung", + "pigeon": "Merpati", + "crow": "Gagak", + "owl": "Burung Hantu", + "flapping_wings": "Kepakan Sayap", + "dogs": "Anjing", + "insect": "Serangga", + "cricket": "Jangkrik", + "mosquito": "Nyamuk", + "fly": "Lalat", + "frog": "Katak", + "snake": "Ular", + "music": "Musik", + "musical_instrument": "Alat Musik", + "guitar": "Gitar", + "electric_guitar": "Gitar Elektrik", + "acoustic_guitar": "Gitar Akustik", + "strum": "Genjreng", + "banjo": "Banjo" } diff --git a/web/public/locales/id/common.json b/web/public/locales/id/common.json index afe3a285c8..9072354dce 100644 --- a/web/public/locales/id/common.json +++ b/web/public/locales/id/common.json @@ -10,5 +10,6 @@ "last7": "7 hari terakhir", "last14": "14 hari terakhir", "last30": "30 hari terakhir" - } + }, + "readTheDocumentation": "Baca dokumentasi" } diff --git a/web/public/locales/id/components/camera.json b/web/public/locales/id/components/camera.json index da128850fb..9da7f9f2dd 100644 --- a/web/public/locales/id/components/camera.json +++ b/web/public/locales/id/components/camera.json @@ -15,7 +15,8 @@ "placeholder": "Masukkan nama…", "errorMessage": { "mustLeastCharacters": "Nama grup kamera minimal harus 2 karakter.", - "exists": "Nama grup kamera sudah ada." + "exists": "Nama grup kamera sudah ada.", + "nameMustNotPeriod": "Nama grup kamera tidak boleh ada titik." } } } diff --git a/web/public/locales/id/objects.json b/web/public/locales/id/objects.json index ce7f18a78f..bfeeca8ea8 100644 --- a/web/public/locales/id/objects.json +++ b/web/public/locales/id/objects.json @@ -8,5 +8,13 @@ "train": "Kereta", "boat": "Kapal", "traffic_light": "Lampu Lalu Lintas", - "fire_hydrant": "Hidran Kebakaran" + "fire_hydrant": "Hidran Kebakaran", + "animal": "Binatang", + "dog": "Anjing", + "bark": "Gonggongan", + "cat": "Kucing", + "horse": "Kuda", + "goat": "Kambing", + "sheep": "Domba", + "bird": "Burung" } diff --git a/web/public/locales/id/views/configEditor.json b/web/public/locales/id/views/configEditor.json index 871c351803..3577999abf 100644 --- a/web/public/locales/id/views/configEditor.json +++ b/web/public/locales/id/views/configEditor.json @@ -12,5 +12,6 @@ "savingError": "Gagal menyimpan konfigurasi" } }, - "confirm": "Keluar tanpa menyimpan?" + "confirm": "Keluar tanpa menyimpan?", + "safeModeDescription": "Frigate sedang dalam mode aman karena kesalahan validasi konfigurasi." } diff --git a/web/public/locales/it/audio.json b/web/public/locales/it/audio.json index eb9b98a5b8..9a08719ade 100644 --- a/web/public/locales/it/audio.json +++ b/web/public/locales/it/audio.json @@ -49,7 +49,7 @@ "cat": "Gatto", "chicken": "Pollo", "acoustic_guitar": "Chitarra acustica", - "speech": "Discorso", + "speech": "Parlato", "babbling": "Balbettio", "motorcycle": "Motociclo", "yell": "Urlo", diff --git a/web/public/locales/it/common.json b/web/public/locales/it/common.json index 9e0cb2e77b..f4ee9086f3 100644 --- a/web/public/locales/it/common.json +++ b/web/public/locales/it/common.json @@ -181,7 +181,15 @@ "it": "Italiano (Italiano)", "yue": "粵語 (Cantonese)", "th": "ไทย (Tailandese)", - "ca": "Català (Catalano)" + "ca": "Català (Catalano)", + "ptBR": "Português brasileiro (Portoghese brasiliano)", + "sr": "Српски (Serbo)", + "sl": "Slovenščina (Sloveno)", + "lt": "Lietuvių (Lituano)", + "bg": "Български (Bulgaro)", + "gl": "Galego (Galiziano)", + "id": "Bahasa Indonesia (Indonesiano)", + "ur": "اردو (Urdu)" }, "darkMode": { "label": "Modalità scura", @@ -271,5 +279,6 @@ "title": "Salva" } }, - "selectItem": "Seleziona {{item}}" + "selectItem": "Seleziona {{item}}", + "readTheDocumentation": "Leggi la documentazione" } diff --git a/web/public/locales/it/components/camera.json b/web/public/locales/it/components/camera.json index 830cfd0e8d..a681de1a5b 100644 --- a/web/public/locales/it/components/camera.json +++ b/web/public/locales/it/components/camera.json @@ -29,7 +29,7 @@ "label": "Metodo di trasmissione", "method": { "smartStreaming": { - "label": "Trasmissione intelligente (consigliato)", + "label": "Trasmissione intelligente (consigliata)", "desc": "La trasmissione intelligente aggiorna l'immagine della telecamera una volta al minuto quando non si verifica alcuna attività rilevabile, per risparmiare larghezza di banda e risorse. Quando viene rilevata un'attività, l'immagine passa automaticamente alla trasmissione dal vivo." }, "continuousStreaming": { @@ -60,7 +60,8 @@ "desc": "Modifica le opzioni di trasmissione dal vivo per la schermata di questo gruppo di telecamere. Queste impostazioni sono specifiche del dispositivo/browser.", "stream": "Flusso", "placeholder": "Scegli un flusso" - } + }, + "birdseye": "Birdseye" }, "cameras": { "desc": "Seleziona le telecamere per questo gruppo.", diff --git a/web/public/locales/it/components/dialog.json b/web/public/locales/it/components/dialog.json index 683d4ce2f8..f5a1247794 100644 --- a/web/public/locales/it/components/dialog.json +++ b/web/public/locales/it/components/dialog.json @@ -122,5 +122,12 @@ "error": "Impossibile eliminare: {{error}}" } } + }, + "imagePicker": { + "selectImage": "Seleziona la miniatura di un oggetto tracciato", + "search": { + "placeholder": "Cerca per etichetta o sottoetichetta..." + }, + "noImages": "Nessuna miniatura trovata per questa fotocamera" } } diff --git a/web/public/locales/it/components/filter.json b/web/public/locales/it/components/filter.json index dd6ebc1b71..85dabf7e77 100644 --- a/web/public/locales/it/components/filter.json +++ b/web/public/locales/it/components/filter.json @@ -98,7 +98,9 @@ "loadFailed": "Impossibile caricare le targhe riconosciute.", "loading": "Caricamento targhe riconosciute…", "placeholder": "Digita per cercare le targhe…", - "noLicensePlatesFound": "Nessuna targa trovata." + "noLicensePlatesFound": "Nessuna targa trovata.", + "selectAll": "Seleziona tutto", + "clearAll": "Cancella tutto" }, "timeRange": "Intervallo di tempo", "subLabels": { @@ -122,5 +124,13 @@ }, "zoneMask": { "filterBy": "Filtra per maschera di zona" + }, + "classes": { + "label": "Classi", + "all": { + "title": "Tutte le classi" + }, + "count_one": "{{count}} Classe", + "count_other": "{{count}} Classi" } } diff --git a/web/public/locales/it/views/configEditor.json b/web/public/locales/it/views/configEditor.json index 4ce1a7378b..f53aaed582 100644 --- a/web/public/locales/it/views/configEditor.json +++ b/web/public/locales/it/views/configEditor.json @@ -12,5 +12,7 @@ "savingError": "Errore durante il salvataggio della configurazione" } }, - "confirm": "Vuoi uscire senza salvare?" + "confirm": "Vuoi uscire senza salvare?", + "safeConfigEditor": "Editor di configurazione (modalità provvisoria)", + "safeModeDescription": "Frigate è in modalità provvisoria a causa di un errore di convalida della configurazione." } diff --git a/web/public/locales/it/views/events.json b/web/public/locales/it/views/events.json index e07c7bc6af..791bcc1358 100644 --- a/web/public/locales/it/views/events.json +++ b/web/public/locales/it/views/events.json @@ -1,9 +1,9 @@ { "alerts": "Avvisi", - "detections": "Rilevamento", + "detections": "Rilevamenti", "motion": { - "label": "Movimento", - "only": "Solo movimento" + "label": "Movimenti", + "only": "Solo movimenti" }, "empty": { "alert": "Non ci sono avvisi da rivedere", @@ -35,5 +35,7 @@ "selected": "{{count}} selezionati", "selected_one": "{{count}} selezionati", "selected_other": "{{count}} selezionati", - "detected": "rilevato" + "detected": "rilevato", + "suspiciousActivity": "Attività sospetta", + "threateningActivity": "Attività minacciosa" } diff --git a/web/public/locales/it/views/explore.json b/web/public/locales/it/views/explore.json index 547c6ad0ab..a440e02813 100644 --- a/web/public/locales/it/views/explore.json +++ b/web/public/locales/it/views/explore.json @@ -52,12 +52,14 @@ "success": { "regenerate": "È stata richiesta una nuova descrizione a {{provider}}. A seconda della velocità del tuo provider, la rigenerazione della nuova descrizione potrebbe richiedere del tempo.", "updatedSublabel": "Sottoetichetta aggiornata correttamente.", - "updatedLPR": "Targa aggiornata con successo." + "updatedLPR": "Targa aggiornata con successo.", + "audioTranscription": "Trascrizione audio richiesta con successo." }, "error": { "regenerate": "Impossibile chiamare {{provider}} per una nuova descrizione: {{errorMessage}}", "updatedSublabelFailed": "Impossibile aggiornare la sottoetichetta: {{errorMessage}}", - "updatedLPRFailed": "Impossibile aggiornare la targa: {{errorMessage}}" + "updatedLPRFailed": "Impossibile aggiornare la targa: {{errorMessage}}", + "audioTranscription": "Impossibile richiedere la trascrizione audio: {{errorMessage}}" } } }, @@ -98,6 +100,9 @@ "tips": { "descriptionSaved": "Descrizione salvata correttamente", "saveDescriptionFailed": "Impossibile aggiornare la descrizione: {{errorMessage}}" + }, + "score": { + "label": "Punteggio" } }, "objectLifecycle": { @@ -182,6 +187,14 @@ "submitToPlus": { "label": "Invia a Frigate+", "aria": "Invia a Frigate Plus" + }, + "addTrigger": { + "label": "Aggiungi innesco", + "aria": "Aggiungi un innesco per questo oggetto tracciato" + }, + "audioTranscription": { + "label": "Trascrivere", + "aria": "Richiedi la trascrizione audio" } }, "dialog": { @@ -205,5 +218,11 @@ "trackedObjectsCount_other": "{{count}} oggetti tracciati ", "fetchingTrackedObjectsFailed": "Errore durante il recupero degli oggetti tracciati: {{errorMessage}}", "noTrackedObjects": "Nessun oggetto tracciato trovato", - "exploreMore": "Esplora altri oggetti {{label}}" + "exploreMore": "Esplora altri oggetti {{label}}", + "aiAnalysis": { + "title": "Analisi IA" + }, + "concerns": { + "label": "Preoccupazioni" + } } diff --git a/web/public/locales/it/views/live.json b/web/public/locales/it/views/live.json index b8a44ae27f..b264fecb75 100644 --- a/web/public/locales/it/views/live.json +++ b/web/public/locales/it/views/live.json @@ -37,7 +37,8 @@ "cameraEnabled": "Telecamera abilitata", "objectDetection": "Rilevamento di oggetti", "recording": "Registrazione", - "audioDetection": "Rilevamento audio" + "audioDetection": "Rilevamento audio", + "transcription": "Trascrizione audio" }, "history": { "label": "Mostra filmati storici" @@ -82,7 +83,15 @@ "label": "Fai clic nella cornice per centrare la telecamera PTZ" } }, - "presets": "Preimpostazioni della telecamera PTZ" + "presets": "Preimpostazioni della telecamera PTZ", + "focus": { + "in": { + "label": "Aumenta fuoco della telecamera PTZ" + }, + "out": { + "label": "Diminuisci fuoco della telecamera PTZ" + } + } }, "camera": { "enable": "Abilita telecamera", @@ -154,5 +163,9 @@ "label": "Modifica gruppo telecamere" }, "exitEdit": "Esci dalla modifica" + }, + "transcription": { + "enable": "Abilita la trascrizione audio in tempo reale", + "disable": "Disabilita la trascrizione audio in tempo reale" } } diff --git a/web/public/locales/it/views/settings.json b/web/public/locales/it/views/settings.json index 78b74c3b47..48a1ca0bdd 100644 --- a/web/public/locales/it/views/settings.json +++ b/web/public/locales/it/views/settings.json @@ -18,7 +18,7 @@ "table": { "snapshots": "Istantanee", "camera": "Telecamera", - "cleanCopySnapshots": "clean_copy Istantanee" + "cleanCopySnapshots": "Istantanee clean_copy" }, "desc": "Per inviare a Frigate+ è necessario che nella configurazione siano abilitate sia le istantanee che le istantanee clean_copy.", "documentation": "Leggi la documentazione", @@ -101,7 +101,20 @@ "zones": { "title": "Zone", "desc": "Mostra un contorno di tutte le zone definite" - } + }, + "paths": { + "title": "Percorsi", + "desc": "Mostra i punti significativi del percorso dell'oggetto tracciato", + "tips": "

Percorsi


Linee e cerchi indicheranno i punti significativi in cui l'oggetto tracciato si è spostato durante il suo ciclo di vita.

" + }, + "audio": { + "title": "Audio", + "currentdbFS": "dbFS correnti", + "noAudioDetections": "Nessun rilevamento audio", + "score": "punteggio", + "currentRMS": "RMS attuale" + }, + "openCameraWebUI": "Apri l'interfaccia utente Web di {{camera}}" }, "masksAndZones": { "motionMasks": { @@ -292,7 +305,7 @@ "regardlessOfZoneObjectDetectionsTips": "Tutti gli oggetti {{detectionsLabels}} non categorizzati su {{cameraName}} verranno mostrati come Rilevamenti, indipendentemente dalla zona in cui si trovano." }, "title": "Classificazione della revisione", - "desc": "Frigate categorizza gli elementi di revisione come Avvisi e Rilevamenti. Per impostazione predefinita, tutti gli oggetti persona e auto sono considerati Avvisi. Puoi perfezionare la categorizzazione degli elementi di revisione configurando le zone desiderate.", + "desc": "Frigate categorizza gli elementi di revisione come Avvisi e Rilevamenti. Per impostazione predefinita, tutti gli oggetti persona e automobile sono considerati Avvisi. Puoi perfezionare la categorizzazione degli elementi di revisione configurando le zone desiderate.", "objectAlertsTips": "Tutti gli oggetti {{alertsLabels}} su {{cameraName}} verranno mostrati come Avvisi.", "toast": { "success": "La configurazione della classificazione di revisione è stata salvata. Riavvia Frigate per applicare le modifiche." @@ -305,7 +318,7 @@ "unsavedChanges": "Impostazioni di classificazione delle revisioni non salvate per {{camera}}" }, "streams": { - "desc": "Disattiva temporaneamente una telecamera fino al riavvio di Frigate. La disattivazione completa di una telecamera interrompe l'elaborazione dei flussi da parte di Frigate. Rilevamento, registrazione e correzioni non saranno disponibili.
Nota: questa operazione non disabilita le ritrasmissioni di go2rtc.", + "desc": "Disattiva temporaneamente una telecamera fino al riavvio di Frigate. La disattivazione completa di una telecamera interrompe l'elaborazione dei flussi da parte di Frigate. Rilevamenti, registrazioni e correzioni non saranno disponibili.
Nota: questa operazione non disabilita le ritrasmissioni di go2rtc.", "title": "Flussi" }, "title": "Impostazioni telecamera", @@ -314,6 +327,44 @@ "desc": "Abilita/disabilita temporaneamente avvisi e rilevamenti per questa telecamera fino al riavvio di Frigate. Se disabilitati, non verranno generati nuovi elementi di revisione. ", "alerts": "Avvisi ", "detections": "Rilevamenti " + }, + "object_descriptions": { + "title": "Descrizioni di oggetti di IA generativa", + "desc": "Abilita/disabilita temporaneamente le descrizioni degli oggetti generate dall'IA per questa telecamera. Se disabilitate, le descrizioni generate dall'IA non verranno richieste per gli oggetti tracciati su questa telecamera." + }, + "review_descriptions": { + "title": "Descrizioni delle revisioni dell'IA generativa", + "desc": "Abilita/disabilita temporaneamente le descrizioni delle revisioni dell'IA generativa per questa telecamera. Se disabilitate, le descrizioni generate dall'IA non verranno richieste per gli elementi da recensire su questa telecamera." + }, + "addCamera": "Aggiungi nuova telecamera", + "editCamera": "Modifica telecamera:", + "selectCamera": "Seleziona una telecamera", + "backToSettings": "Torna alle impostazioni della telecamera", + "cameraConfig": { + "add": "Aggiungi telecamera", + "edit": "Modifica telecamera", + "description": "Configura le impostazioni della telecamera, inclusi gli ingressi ed i ruoli della trasmissione.", + "name": "Nome della telecamera", + "nameRequired": "Il nome della telecamera è obbligatorio", + "nameInvalid": "Il nome della telecamera deve contenere solo lettere, numeri, caratteri di sottolineatura o trattini", + "namePlaceholder": "ad esempio: porta_principale", + "enabled": "Abilitata", + "ffmpeg": { + "inputs": "Flussi di ingresso", + "path": "Percorso del flusso", + "pathRequired": "Il percorso del flusso è obbligatorio", + "pathPlaceholder": "rtsp://...", + "roles": "Ruoli", + "rolesRequired": "È richiesto almeno un ruolo", + "rolesUnique": "Ogni ruolo (audio, rilevamento, registrazione) può essere assegnato solo ad un flusso", + "addInput": "Aggiungi flusso di ingresso", + "removeInput": "Rimuovi flusso di ingresso", + "inputsRequired": "È richiesto almeno un flusso di ingresso" + }, + "toast": { + "success": "La telecamera {{cameraName}} è stata salvata correttamente" + }, + "nameLength": "Il nome della telecamera deve contenere meno di 24 caratteri." } }, "menu": { @@ -326,7 +377,8 @@ "debug": "Correzioni", "users": "Utenti", "frigateplus": "Frigate+", - "enrichments": "Miglioramenti" + "enrichments": "Miglioramenti", + "triggers": "Inneschi" }, "users": { "dialog": { @@ -336,7 +388,8 @@ "intro": "Seleziona il ruolo appropriato per questo utente:", "admin": "Amministratore", "adminDesc": "Accesso completo a tutte le funzionalità.", - "viewer": "Spettatore" + "viewer": "Spettatore", + "customDesc": "Ruolo personalizzato con accesso specifico alla telecamera." }, "title": "Cambia ruolo utente", "desc": "Aggiorna i permessi per {{username}}", @@ -428,7 +481,7 @@ "general": { "liveDashboard": { "automaticLiveView": { - "desc": "Passa automaticamente alla visualizzazione dal vivodi una telecamera quando viene rilevata attività. Disattivando questa opzione, le immagini statiche della telecamera nella schermata dal vivo verranno aggiornate solo una volta al minuto.", + "desc": "Passa automaticamente alla visualizzazione dal vivo di una telecamera quando viene rilevata attività. Disattivando questa opzione, le immagini statiche della telecamera nella schermata dal vivo verranno aggiornate solo una volta al minuto.", "label": "Visualizzazione automatica dal vivo" }, "playAlertVideos": { @@ -676,11 +729,166 @@ "title": "Classificazione degli uccelli" }, "licensePlateRecognition": { - "desc": "Frigate può riconoscere le targhe dei veicoli e aggiungere automaticamente i caratteri rilevati al campo recognized_license_plate o un nome noto come sub_label agli oggetti di tipo \"car\". Un caso d'uso comune potrebbe essere la lettura delle targhe delle auto che entrano in un vialetto o che transitano lungo una strada.", + "desc": "Frigate può riconoscere le targhe dei veicoli e aggiungere automaticamente i caratteri rilevati al campo recognized_license_plate o un nome noto come sub_label agli oggetti di tipo automobile (car). Un caso d'uso comune potrebbe essere la lettura delle targhe delle auto che entrano in un vialetto o che transitano lungo una strada.", "title": "Riconoscimento della targa", "readTheDocumentation": "Leggi la documentazione" }, "unsavedChanges": "Modifiche alle impostazioni di miglioramento non salvate", "restart_required": "Riavvio richiesto (impostazioni di miglioramento modificate)" + }, + "triggers": { + "documentTitle": "Inneschi", + "management": { + "title": "Gestione inneschi", + "desc": "Gestisci gli inneschi per {{camera}}. Utilizza il tipo miniatura per attivare miniature simili all'oggetto tracciato selezionato e il tipo descrizione per attivare descrizioni simili al testo specificato." + }, + "addTrigger": "Aggiungi innesco", + "table": { + "name": "Nome", + "type": "Tipo", + "content": "Contenuto", + "threshold": "Soglia", + "actions": "Azioni", + "noTriggers": "Nessun innesco configurato per questa telecamera.", + "edit": "Modifica", + "deleteTrigger": "Elimina innesco", + "lastTriggered": "Ultimo innesco" + }, + "type": { + "thumbnail": "Miniatura", + "description": "Descrizione" + }, + "actions": { + "alert": "Contrassegna come avviso", + "notification": "Invia notifica" + }, + "dialog": { + "createTrigger": { + "title": "Crea innesco", + "desc": "Crea un innesco per la telecamera {{camera}}" + }, + "editTrigger": { + "title": "Modifica innesco", + "desc": "Modifica le impostazioni per l'innesco della telecamera {{camera}}" + }, + "deleteTrigger": { + "title": "Elimina innesco", + "desc": "Vuoi davvero eliminare l'innesco {{triggerName}}? Questa azione non può essere annullata." + }, + "form": { + "name": { + "title": "Nome", + "placeholder": "Inserisci il nome dell'innesco", + "error": { + "minLength": "Il nome deve essere lungo almeno 2 caratteri.", + "invalidCharacters": "Il nome può contenere solo lettere, numeri, caratteri di sottolineatura e trattini.", + "alreadyExists": "Per questa telecamera esiste già un innesco con questo nome." + } + }, + "enabled": { + "description": "Abilita o disabilita questo innesco" + }, + "type": { + "title": "Tipo", + "placeholder": "Seleziona il tipo di innesco" + }, + "content": { + "title": "Contenuto", + "imagePlaceholder": "Seleziona un'immagine", + "textPlaceholder": "Inserisci il contenuto del testo", + "imageDesc": "Seleziona un'immagine per attivare questa azione quando viene rilevata un'immagine simile.", + "textDesc": "Inserisci il testo per attivare questa azione quando viene rilevata una descrizione simile dell'oggetto tracciato.", + "error": { + "required": "Il contenuto è obbligatorio." + } + }, + "threshold": { + "title": "Soglia", + "error": { + "min": "La soglia deve essere almeno 0", + "max": "La soglia deve essere al massimo 1" + } + }, + "actions": { + "title": "Azioni", + "desc": "Per impostazione predefinita, Frigate invia un messaggio MQTT per tutti gli inneschi. Scegli un'azione aggiuntiva da eseguire quando questo innesco si attiva.", + "error": { + "min": "È necessario selezionare almeno un'azione." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "L'innesco {{name}} è stato creato correttamente.", + "updateTrigger": "L'innesco {{name}} è stato aggiornato correttamente.", + "deleteTrigger": "L'innesco {{name}} è stato eliminato correttamente." + }, + "error": { + "createTriggerFailed": "Impossibile creare l'innesco: {{errorMessage}}", + "updateTriggerFailed": "Impossibile aggiornare l'innesco: {{errorMessage}}", + "deleteTriggerFailed": "Impossibile eliminare l'innesco: {{errorMessage}}" + } + } + }, + "roles": { + "management": { + "title": "Gestione del ruolo di spettatore", + "desc": "Gestisci i ruoli di spettatori personalizzati e le relative autorizzazioni di accesso alla telecamera per questa istanza Frigate." + }, + "addRole": "Aggiungi ruolo", + "table": { + "role": "Ruolo", + "cameras": "Telecamere", + "actions": "Azioni", + "noRoles": "Nessun ruolo personalizzato trovato.", + "editCameras": "Modifica telecamere", + "deleteRole": "Elimina ruolo" + }, + "toast": { + "success": { + "createRole": "Ruolo {{role}} creato con successo", + "updateCameras": "Telecamere aggiornate per il ruolo {{role}}", + "deleteRole": "Ruolo {{role}} eliminato con successo", + "userRolesUpdated": "{{count}} utenti assegnati a questo ruolo sono stati aggiornati a \"spettatore\", che ha accesso a tutte le telecamere." + }, + "error": { + "createRoleFailed": "Impossibile creare il ruolo: {{errorMessage}}", + "updateCamerasFailed": "Impossibile aggiornare le telecamere: {{errorMessage}}", + "deleteRoleFailed": "Impossibile eliminare il ruolo: {{errorMessage}}", + "userUpdateFailed": "Impossibile aggiornare i ruoli utente: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Crea nuovo ruolo", + "desc": "Aggiungi un nuovo ruolo e specifica le autorizzazioni di accesso alla telecamera." + }, + "editCameras": { + "title": "Modifica telecamere di ruolo", + "desc": "Aggiorna l'accesso alla telecamera per il ruolo {{role}}." + }, + "deleteRole": { + "title": "Elimina ruolo", + "desc": "Questa azione non può essere annullata. Ciò eliminerà definitivamente il ruolo e assegnerà a tutti gli utenti il ruolo di 'spettatore', che darà loro accesso a tutte le telecamere.", + "warn": "Sei sicuro di voler eliminare {{role}}?", + "deleting": "Eliminazione in corso..." + }, + "form": { + "role": { + "title": "Nome del ruolo", + "placeholder": "Inserisci il nome del ruolo", + "desc": "Sono consentiti solo lettere, numeri, punti e caratteri di sottolineatura.", + "roleIsRequired": "Il nome del ruolo è obbligatorio", + "roleOnlyInclude": "Il nome del ruolo può includere solo lettere, numeri, . o _", + "roleExists": "Esiste già un ruolo con questo nome." + }, + "cameras": { + "title": "Telecamere", + "desc": "Seleziona le telecamere a cui questo ruolo ha accesso. È richiesta almeno una telecamera.", + "required": "È necessario selezionare almeno una telecamera." + } + } + } } } diff --git a/web/public/locales/it/views/system.json b/web/public/locales/it/views/system.json index ee838403ca..695b832131 100644 --- a/web/public/locales/it/views/system.json +++ b/web/public/locales/it/views/system.json @@ -72,7 +72,8 @@ "title": "Rilevatori", "cpuUsage": "Utilizzo CPU rilevatore", "memoryUsage": "Utilizzo memoria rilevatore", - "temperature": "Temperatura del rilevatore" + "temperature": "Temperatura del rilevatore", + "cpuUsageInformation": "CPU utilizzata nella preparazione dei dati di ingresso e uscita da/verso i modelli di rilevamento. Questo valore non misura l'utilizzo dell'inferenza, anche se si utilizza una GPU o un acceleratore." }, "title": "Generale", "otherProcesses": { @@ -83,10 +84,10 @@ }, "enrichments": { "embeddings": { - "face_embedding_speed": "Velocità incorporazione volti", + "face_embedding_speed": "Velocità incorporamento volti", "plate_recognition_speed": "Velocità riconoscimento targhe", - "image_embedding_speed": "Velocità incorporazione immagini", - "text_embedding_speed": "Velocità incorporazione testo", + "image_embedding_speed": "Velocità incorporamento immagini", + "text_embedding_speed": "Velocità incorporamento testo", "face_recognition_speed": "Velocità di riconoscimento facciale", "face_recognition": "Riconoscimento facciale", "plate_recognition": "Riconoscimento delle targhe", @@ -102,7 +103,7 @@ "info": { "fetching": "Recupero dati della telecamera", "streamDataFromFFPROBE": "I dati del flusso vengono ottenuti con ffprobe.", - "cameraProbeInfo": "Informazioni analisi telecamera {{camera}}", + "cameraProbeInfo": "Informazioni flussi telecamera {{camera}}", "stream": "Flusso {{idx}}", "video": "Video:", "codec": "Codec:", @@ -112,7 +113,7 @@ "audio": "Audio:", "error": "Errore: {{error}}", "tips": { - "title": "Informazioni analisi telecamera" + "title": "Informazioni flussi telecamera" }, "aspectRatio": "rapporto d'aspetto" }, @@ -121,15 +122,15 @@ "framesAndDetections": "Fotogrammi / Rilevamenti", "label": { "camera": "telecamera", - "detect": "rileva", - "skipped": "saltato", + "detect": "rilevamento", + "skipped": "saltati", "ffmpeg": "FFmpeg", "capture": "cattura", "overallFramesPerSecond": "fotogrammi totali al secondo", "overallDetectionsPerSecond": "rilevamenti totali al secondo", "overallSkippedDetectionsPerSecond": "rilevamenti totali saltati al secondo", "cameraCapture": "{{camName}} cattura", - "cameraDetect": "{{camName}} rileva", + "cameraDetect": "{{camName}} rilevamento", "cameraFramesPerSecond": "{{camName}} fotogrammi al secondo", "cameraDetectionsPerSecond": "{{camName}} rilevamenti al secondo", "cameraSkippedDetectionsPerSecond": "{{camName}} rilevamenti saltati al secondo", @@ -151,7 +152,8 @@ "reindexingEmbeddings": "Reindicizzazione degli incorporamenti (completata al {{processed}}%)", "cameraIsOffline": "{{camera}} è disconnessa", "detectIsSlow": "{{detect}} è lento ({{speed}} ms)", - "detectIsVerySlow": "{{detect}} è molto lento ({{speed}} ms)" + "detectIsVerySlow": "{{detect}} è molto lento ({{speed}} ms)", + "shmTooLow": "L'allocazione /dev/shm ({{total}} MB) dovrebbe essere aumentata almeno a {{min}} MB." }, "title": "Sistema", "metrics": "Metriche di sistema", @@ -174,6 +176,11 @@ "title": "Liberi", "tips": "Questo valore potrebbe non rappresentare accuratamente lo spazio libero disponibile per Frigate se nel disco sono archiviati altri file oltre alle registrazioni di Frigate. Frigate non tiene traccia dell'utilizzo dello spazio di archiviazione al di fuori delle sue registrazioni." } + }, + "shm": { + "title": "Allocazione SHM (memoria condivisa)", + "warning": "La dimensione SHM attuale di {{total}} MB è troppo piccola. Aumentarla ad almeno {{min_shm}} MB.", + "readTheDocumentation": "Leggi la documentazione" } }, "lastRefreshed": "Ultimo aggiornamento: " diff --git a/web/public/locales/ja/common.json b/web/public/locales/ja/common.json index 44b1d2b514..bf06ac831a 100644 --- a/web/public/locales/ja/common.json +++ b/web/public/locales/ja/common.json @@ -3,5 +3,6 @@ "untilForRestart": "Frigateが再起動するまで.", "untilRestart": "再起動まで", "untilForTime": "{{time}} まで" - } + }, + "readTheDocumentation": "ドキュメントを読む" } diff --git a/web/public/locales/ja/views/configEditor.json b/web/public/locales/ja/views/configEditor.json index b3d523c94a..72e229409b 100644 --- a/web/public/locales/ja/views/configEditor.json +++ b/web/public/locales/ja/views/configEditor.json @@ -1,7 +1,15 @@ { "copyConfig": "設定をコピー", - "configEditor": "Configエディタ", + "configEditor": "設定エディタ", "saveAndRestart": "保存後再起動", "saveOnly": "保存", - "confirm": "保存せずに終了しますか?" + "confirm": "保存せずに終了しますか?", + "documentTitle": "設定エディタ - Frigate", + "safeConfigEditor": "設定エディタ (セーフモード)", + "safeModeDescription": "Frigate は config の検証エラーによるセーフモードです.", + "toast": { + "success": { + "copyToClipboard": "コンフィグをクリップボードにコピー。" + } + } } diff --git a/web/public/locales/ja/views/explore.json b/web/public/locales/ja/views/explore.json index 0dec7d0b9f..4991ff3fae 100644 --- a/web/public/locales/ja/views/explore.json +++ b/web/public/locales/ja/views/explore.json @@ -1,3 +1,4 @@ { - "generativeAI": "生成AI" + "generativeAI": "生成AI", + "documentTitle": "検索 - Frigate" } diff --git a/web/public/locales/ja/views/faceLibrary.json b/web/public/locales/ja/views/faceLibrary.json index 5bb34f410c..8f06485cf2 100644 --- a/web/public/locales/ja/views/faceLibrary.json +++ b/web/public/locales/ja/views/faceLibrary.json @@ -1,5 +1,6 @@ { "description": { - "placeholder": "このコレクションの名前を入力してください" + "placeholder": "このコレクションの名前を入力してください", + "addFace": "フェイスライブラリに新しいコレクションを追加する手順を説明します。" } } diff --git a/web/public/locales/ko/audio.json b/web/public/locales/ko/audio.json index 0967ef424b..8bb3f72822 100644 --- a/web/public/locales/ko/audio.json +++ b/web/public/locales/ko/audio.json @@ -1 +1,13 @@ -{} +{ + "crying": "울음", + "snoring": "코골이", + "singing": "노래하기", + "yell": "비명", + "speech": "발표", + "babbling": "옹알이", + "bicycle": "자전거", + "a_capella": "아카펠라", + "accelerating": "가속", + "accordion": "아코디언", + "acoustic_guitar": "어쿠스틱 기타" +} diff --git a/web/public/locales/ko/common.json b/web/public/locales/ko/common.json index 0967ef424b..db5c83ed79 100644 --- a/web/public/locales/ko/common.json +++ b/web/public/locales/ko/common.json @@ -1 +1,25 @@ -{} +{ + "readTheDocumentation": "문서 읽기", + "time": { + "untilForTime": "{{time}}까지", + "untilForRestart": "Frigate가 재시작될 때 까지.", + "10minutes": "10분", + "12hours": "12시간", + "1hour": "1시간", + "24hours": "24시간", + "30minutes": "30분", + "5minutes": "5분" + }, + "notFound": { + "title": "404" + }, + "accessDenied": { + "title": "접근 거부", + "documentTitle": "접근 거부 - Frigate" + }, + "menu": { + "user": { + "account": "계정" + } + } +} diff --git a/web/public/locales/ko/components/auth.json b/web/public/locales/ko/components/auth.json index 0967ef424b..d0b29a82a3 100644 --- a/web/public/locales/ko/components/auth.json +++ b/web/public/locales/ko/components/auth.json @@ -1 +1,15 @@ -{} +{ + "form": { + "user": "사용자명", + "password": "패스워드", + "login": "로그인", + "errors": { + "usernameRequired": "사용자명은 필수입니다", + "passwordRequired": "패스워드는 필수입니다", + "rateLimit": "너무 많이 시도했습니다. 다음에 다시 시도하세요.", + "loginFailed": "로그인 실패", + "unknownError": "알려지지 않은 에러. 로그를 확인하세요.", + "webUnknownError": "알려지지 않은 에러. 콘솔 로그를 확인하세요." + } + } +} diff --git a/web/public/locales/ko/components/camera.json b/web/public/locales/ko/components/camera.json index 0967ef424b..b1bfdcb595 100644 --- a/web/public/locales/ko/components/camera.json +++ b/web/public/locales/ko/components/camera.json @@ -1 +1,6 @@ -{} +{ + "group": { + "label": "카메라 그룹", + "add": "카메라 그룹 추가" + } +} diff --git a/web/public/locales/ko/components/dialog.json b/web/public/locales/ko/components/dialog.json index 0967ef424b..ec0014a22c 100644 --- a/web/public/locales/ko/components/dialog.json +++ b/web/public/locales/ko/components/dialog.json @@ -1 +1,6 @@ -{} +{ + "restart": { + "title": "Frigate를 정말로 다시 시작할까요?", + "button": "재시작" + } +} diff --git a/web/public/locales/ko/components/filter.json b/web/public/locales/ko/components/filter.json index 0967ef424b..c0d3788393 100644 --- a/web/public/locales/ko/components/filter.json +++ b/web/public/locales/ko/components/filter.json @@ -1 +1,3 @@ -{} +{ + "filter": "필터" +} diff --git a/web/public/locales/ko/components/icons.json b/web/public/locales/ko/components/icons.json index 0967ef424b..fb1b47c03e 100644 --- a/web/public/locales/ko/components/icons.json +++ b/web/public/locales/ko/components/icons.json @@ -1 +1,8 @@ -{} +{ + "iconPicker": { + "selectIcon": "아이콘을 선택해주세요", + "search": { + "placeholder": "아이콘 검색 중…" + } + } +} diff --git a/web/public/locales/ko/components/input.json b/web/public/locales/ko/components/input.json index 0967ef424b..b697d11dae 100644 --- a/web/public/locales/ko/components/input.json +++ b/web/public/locales/ko/components/input.json @@ -1 +1,10 @@ -{} +{ + "button": { + "downloadVideo": { + "label": "비디오 다운로드", + "toast": { + "success": "선택한 비디오들의 다운로드가 시작되었습니다." + } + } + } +} diff --git a/web/public/locales/ko/components/player.json b/web/public/locales/ko/components/player.json index 0967ef424b..d9290ebe4b 100644 --- a/web/public/locales/ko/components/player.json +++ b/web/public/locales/ko/components/player.json @@ -1 +1,24 @@ -{} +{ + "submitFrigatePlus": { + "submit": "제출" + }, + "stats": { + "bandwidth": { + "short": "대역폭", + "title": "대역폭:" + }, + "latency": { + "short": { + "title": "지연" + }, + "title": "지연:" + }, + "streamType": { + "short": "종류", + "title": "스트림 종류:" + }, + "totalFrames": "총 프레임:" + }, + "noRecordingsFoundForThisTime": "이 시간에는 녹화본이 없습니다", + "noPreviewFound": "미리 보기가 없습니다" +} diff --git a/web/public/locales/ko/objects.json b/web/public/locales/ko/objects.json index 0967ef424b..19c8ef9e9e 100644 --- a/web/public/locales/ko/objects.json +++ b/web/public/locales/ko/objects.json @@ -1 +1,4 @@ -{} +{ + "person": "사람", + "bicycle": "자전거" +} diff --git a/web/public/locales/ko/views/configEditor.json b/web/public/locales/ko/views/configEditor.json index 0967ef424b..1a9e8ec9fc 100644 --- a/web/public/locales/ko/views/configEditor.json +++ b/web/public/locales/ko/views/configEditor.json @@ -1 +1,4 @@ -{} +{ + "documentTitle": "설정 편집기 - Frigate", + "configEditor": "설정 편집기" +} diff --git a/web/public/locales/ko/views/events.json b/web/public/locales/ko/views/events.json index 0967ef424b..b8cf930d38 100644 --- a/web/public/locales/ko/views/events.json +++ b/web/public/locales/ko/views/events.json @@ -1 +1,4 @@ -{} +{ + "alerts": "알림", + "detections": "탐지" +} diff --git a/web/public/locales/ko/views/explore.json b/web/public/locales/ko/views/explore.json index 0967ef424b..17aee9232f 100644 --- a/web/public/locales/ko/views/explore.json +++ b/web/public/locales/ko/views/explore.json @@ -1 +1,4 @@ -{} +{ + "documentTitle": "탐색 - Frigate", + "generativeAI": "생성형 AI" +} diff --git a/web/public/locales/ko/views/exports.json b/web/public/locales/ko/views/exports.json index 0967ef424b..47d39eae6d 100644 --- a/web/public/locales/ko/views/exports.json +++ b/web/public/locales/ko/views/exports.json @@ -1 +1,4 @@ -{} +{ + "documentTitle": "내보내기 - Frigate", + "search": "검색" +} diff --git a/web/public/locales/ko/views/faceLibrary.json b/web/public/locales/ko/views/faceLibrary.json index 0967ef424b..af79a003ae 100644 --- a/web/public/locales/ko/views/faceLibrary.json +++ b/web/public/locales/ko/views/faceLibrary.json @@ -1 +1,6 @@ -{} +{ + "description": { + "placeholder": "이 모음집의 이름을 입력해주세요", + "addFace": "얼굴 라이브러리에 새 컬렉션 추가하는 방법을 단계별로 알아보세요." + } +} diff --git a/web/public/locales/ko/views/live.json b/web/public/locales/ko/views/live.json index 0967ef424b..41ddf9b1a0 100644 --- a/web/public/locales/ko/views/live.json +++ b/web/public/locales/ko/views/live.json @@ -1 +1,4 @@ -{} +{ + "documentTitle": "실시간 - Frigate", + "documentTitle.withCamera": "실시간 - {{camera}} - Frigate" +} diff --git a/web/public/locales/ko/views/recording.json b/web/public/locales/ko/views/recording.json index 0967ef424b..aa2715665e 100644 --- a/web/public/locales/ko/views/recording.json +++ b/web/public/locales/ko/views/recording.json @@ -1 +1,5 @@ -{} +{ + "filter": "필터", + "export": "내보내기", + "calendar": "달력" +} diff --git a/web/public/locales/ko/views/search.json b/web/public/locales/ko/views/search.json index 0967ef424b..7e01516e37 100644 --- a/web/public/locales/ko/views/search.json +++ b/web/public/locales/ko/views/search.json @@ -1 +1,4 @@ -{} +{ + "search": "검색", + "savedSearches": "저장된 검색들" +} diff --git a/web/public/locales/ko/views/settings.json b/web/public/locales/ko/views/settings.json index 0967ef424b..152bc8d07b 100644 --- a/web/public/locales/ko/views/settings.json +++ b/web/public/locales/ko/views/settings.json @@ -1 +1,33 @@ -{} +{ + "triggers": { + "dialog": { + "form": { + "threshold": { + "title": "임계치" + }, + "name": { + "title": "이름" + }, + "type": { + "title": "종류", + "placeholder": "트리거 종류 선택" + } + }, + "createTrigger": { + "title": "트리거 생성" + } + }, + "actions": { + "notification": "알림 전송" + } + }, + "documentTitle": { + "default": "설정 - Frigate", + "authentication": "인증 설정 - Frigate" + }, + "users": { + "table": { + "actions": "액션" + } + } +} diff --git a/web/public/locales/ko/views/system.json b/web/public/locales/ko/views/system.json index 0967ef424b..f3c1d746b3 100644 --- a/web/public/locales/ko/views/system.json +++ b/web/public/locales/ko/views/system.json @@ -1 +1,6 @@ -{} +{ + "documentTitle": { + "cameras": "카메라 통계 - Frigate", + "storage": "스토리지 통계 - Frigate" + } +} diff --git a/web/public/locales/lt/audio.json b/web/public/locales/lt/audio.json index 7f9bbc8a4d..a1b599904b 100644 --- a/web/public/locales/lt/audio.json +++ b/web/public/locales/lt/audio.json @@ -34,5 +34,396 @@ "laughter": "Juokas", "snicker": "Kikenimas", "crying": "Verkimas", - "singing": "Dainavimas" + "singing": "Dainavimas", + "sigh": "Atodūsis", + "choir": "Choras", + "yodeling": "Jodliavimas", + "chant": "Giedojimas", + "mantra": "Mantra", + "child_singing": "Dainuoja Vaikas", + "synthetic_singing": "Netikras Dainavimas", + "rapping": "Repavimas", + "humming": "Dūzgimas", + "groan": "Dejuoti", + "grunt": "Niurzgėti", + "whistling": "Švilpti", + "breathing": "Kvepavimas", + "wheeze": "Švokštimas", + "snoring": "Knarkimas", + "gasp": "Aiktelėti", + "pant": "Kelnės", + "snort": "Knarkti", + "cough": "Kosėti", + "throat_clearing": "Atsikrenkšti", + "sneeze": "Čiaudėti", + "sniff": "Uostyti", + "run": "Bėgti", + "shuffle": "Maišyti", + "footsteps": "Žingsniai", + "chewing": "Kramtymas", + "biting": "Kandžiojimas", + "gargling": "Skalavimas", + "stomach_rumble": "Pilvo gurguliavimas", + "burping": "Atsirūgimas", + "hiccup": "Žaksėjimas", + "fart": "Bezdėjimas", + "hands": "Rankos", + "finger_snapping": "Spragsėjimas", + "clapping": "Plojimas", + "heartbeat": "Širdies plakimas", + "heart_murmur": "Širdies Ūžesys", + "cheering": "Džiūgavimas", + "applause": "Aplodismentai", + "chatter": "Plepėti", + "crowd": "Minia", + "children_playing": "Žaidžiantys Vaikai", + "pets": "Gyvūnai", + "yip": "Cyptelėjimas", + "howl": "Kaukimas", + "whimper_dog": "Šuns inkštimas", + "growling": "Urzgimas", + "bow_wow": "Au au", + "purr": "Murkimas", + "meow": "Miaukimas", + "hiss": "Šnypštimas", + "livestock": "Gyvuliai", + "caterwaul": "Kniaukimas", + "clip_clop": "Kanopų Kaukšėjimas", + "neigh": "Prunkštimas", + "moo": "Mūkimas", + "cattle": "Galvijai", + "cowbell": "Karvutės Varpelis", + "pig": "Kiaulė", + "oink": "Kriuksėjimas", + "bleat": "Bliovimas", + "chicken": "Višta", + "cock_a_doodle_doo": "Kakuriakuoti", + "cluck": "Kudakavimas", + "fowl": "Paukščiai", + "turkey": "Kalakutas", + "gobble": "Gargaliavimas", + "duck": "Antis", + "quack": "Kreksėjimas", + "goose": "Žąsis", + "wild_animals": "Laukiniai Gyvūnai", + "honk": "Gagenimas", + "roar": "Riaumoti", + "roaring_cats": "Riaumojančios Katės", + "pigeon": "Balandis", + "chirp": "Čiulbėti", + "crow": "Varna", + "squawk": "Klykimas", + "coo": "Ku", + "owl": "Pelėda", + "caw": "Kranksėjimas", + "hoot": "Ūkti", + "flapping_wings": "Sparnų plazdėjimas", + "dogs": "Šunys", + "rats": "Žiurkės", + "insect": "Vabzdžiai", + "cricket": "Svirpliai", + "mosquito": "Uodai", + "fly": "Musės", + "buzz": "Užėsys", + "patter": "Tekšėjimas", + "frog": "Varlė", + "snake": "Gyvatė", + "croak": "Kvarksėti", + "rattle": "Barškėti", + "whale_vocalization": "Banginio Įgarsinimas", + "music": "Muzika", + "musical_instrument": "Muzikinis Instrumentas", + "plucked_string_instrument": "Sugedęs Styginis Instrumentas", + "guitar": "Gitara", + "electric_guitar": "Elektrinė Gitara", + "bass_guitar": "Bosinė Gitara", + "acoustic_guitar": "Akustinė Gitara", + "steel_guitar": "Metalinė Gitara", + "sitar": "Sitara", + "mandolin": "Mandolina", + "ukulele": "Ukulėle", + "piano": "Pianinas", + "electric_piano": "Elektrinis pianinas", + "organ": "Vargonai", + "banjo": "Bandžia", + "scream": "Rėkti", + "field_recording": "Įrašinėjimas lauke", + "radio": "Radijas", + "television": "Televizija", + "white_noise": "Baltasis triukšmas", + "pink_noise": "Rožinis triukšmas", + "silence": "Tyla", + "shatter": "Dūžimas", + "glass": "Stiklas", + "crack": "Trūkimas", + "wood": "Medis", + "chop": "Kapojimas", + "boom": "Bumtėlti", + "eruption": "Išsiveržimas", + "fireworks": "Fejerverkai", + "artillery_fire": "Artilerinė ugnis", + "explosion": "Sprogimas", + "drill": "Grežimas", + "sanding": "Šveisti", + "power_tool": "Elektriniai įrankiai", + "machine_gun": "Kulkosvaidis", + "filing": "Dildinti", + "sawing": "Pjauti", + "jackhammer": "Kūjis", + "hammer": "Plaktukas", + "tools": "Įrankiai", + "printer": "Spausdintuvas", + "cash_register": "Kasos Aparatas", + "air_conditioning": "Oro Kondicionavimas", + "sewing_machine": "Siuvimo Mašina", + "pulleys": "Skriemulys", + "gears": "Dantračiai", + "tick-tock": "Tiksėjimas", + "tick": "Tik", + "mechanisms": "Mechanizmas", + "whistle": "Švilpimas", + "steam_whistle": "Garinis Švilpimas", + "fire_alarm": "Gaistro Signalas", + "smoke_detector": "Dūmų detektorius", + "siren": "Sirena", + "alarm_clock": "Žadintuvas", + "telephone": "Telefonas", + "writing": "Rašymas", + "shuffling_cards": "Kortų Maišymas", + "zipper": "Užtrauktukas", + "electric_toothbrush": "Elektrinis Dantų Šepetėlis", + "tapping": "Tapsėjimas", + "strum": "Brazdėjimas", + "electronic_organ": "Elektriniai Vargonai", + "hammond_organ": "Hammond Vargonai", + "synthesizer": "Sintezatorius", + "sampler": "Sampleris", + "harpsichord": "Fortepionas", + "percussion": "Perkusija", + "drum_kit": "Būgnų Rinkinys", + "drum_machine": "Būgnų Mašina", + "drum": "Būgnas", + "snare_drum": "Snare Būgnas", + "timpani": "Timpanas", + "tabla": "Tabla", + "cymbal": "Cimbala", + "hi_hat": "Lėkštės", + "wood_block": "Medienos Lentgalis", + "tambourine": "Tamburinas", + "maraca": "Maraka", + "gong": "Gongas", + "tubular_bells": "Vamzdiniai Varpeliai", + "mallet_percussion": "Malet Perkusija", + "vibraphone": "Vibrafonas", + "steelpan": "Metalinė Lėkštė", + "orchestra": "Orkestras", + "brass_instrument": "Variniai Instrumentai", + "trombone": "Trombonas", + "string_section": "Stygų Sekcija", + "violin": "Smuikas", + "double_bass": "Dvigubas Bosas", + "wind_instrument": "Vėjo Instrumentas", + "flute": "Fleita", + "saxophone": "Saksofonas", + "clarinet": "Klarnetas", + "bell": "Varpas", + "church_bell": "Bažnyčios Varpas", + "jingle_bell": "Kalėdinis Varpelis", + "bicycle_bell": "Dviračio Skambutis", + "tuning_fork": "Derinimo Šakutė", + "chime": "Skambesys", + "wind_chime": "Vėjo Skambesys", + "harmonica": "Lūpinė armonika", + "accordion": "Akordionas", + "bagpipes": "Dūdmaišis", + "pop_music": "Pop Muzika", + "hip_hop_music": "Hip-Hop Muzika", + "beatboxing": "Beatboksingas", + "rock_music": "Roko Muzika", + "heavy_metal": "Sunkusis Metalas", + "punk_rock": "Pank Rokas", + "progressive_rock": "Progresyvus Rokas", + "rock_and_roll": "Rokenrolas", + "psychedelic_rock": "Psichodelinis Rokas", + "rhythm_and_blues": "Ritmbliuzas", + "reggae": "Regis", + "swing_music": "Swingas", + "folk_music": "Liaudies Muzikas", + "middle_eastern_music": "Viduriniųjų Rytų Muzika", + "jazz": "Jazas", + "disco": "Disko", + "classical_music": "Klasikinė Muzika", + "opera": "Opera", + "electronic_music": "Elektroninė Muzika", + "house_music": "House Muzika", + "techno": "Techno", + "dubstep": "Dubstepas", + "electronica": "Elektroninė", + "electronic_dance_music": "Elektroninė Šokių Muzika", + "trance_music": "Transo Muzika", + "music_of_latin_america": "Lotynų Amerikos Muzika", + "salsa_music": "Salsa Muzika", + "flamenco": "Flamenko", + "blues": "Bliuzas", + "music_for_children": "Vaikų Muzika", + "vocal_music": "Vokalinė Muzika", + "a_capella": "Akapela", + "music_of_africa": "Afrikietiška Muzika", + "christian_music": "Krikščioniška Muzika", + "gospel_music": "Gospelo Muzika", + "music_of_asia": "Azijietiška Muzika", + "music_of_bollywood": "Bolivudo Muzika", + "traditional_music": "Tradicinė Muzika", + "song": "Daina", + "background_music": "Foninė Muzika", + "theme_music": "Teminė Muzika", + "jingle": "Džinglas", + "soundtrack_music": "Garsotakelio Muzika", + "lullaby": "Lopšinė", + "video_game_music": "Video Žaidimų Muzika", + "christmas_music": "Kalėdinė Muzika", + "dance_music": "Šokių Muzika", + "wedding_music": "Vestuvinė Muzika", + "sad_music": "Liūdna Muzika", + "happy_music": "Laiminga Muzika", + "angry_music": "Pikta Muzika", + "scary_music": "Gązdinanti Muzika", + "wind": "Vėjas", + "rustling_leaves": "Šlamantys Lapai", + "wind_noise": "Vėjo Švilpimas", + "thunderstorm": "Perkūnija", + "thunder": "Griaustinis", + "water": "Vanduo", + "rain": "Lietus", + "raindrop": "Lietaus Lašai", + "rain_on_surface": "Lija ant Paviršiaus", + "stream": "Srovė", + "waterfall": "Krioklys", + "ocean": "Okeanas", + "waves": "Bangos", + "steam": "Garai", + "gurgling": "Gurguliavimas", + "fire": "Ugnis", + "crackle": "Spragėjimas", + "sailboat": "Burlaivis", + "rowboat": "Irklinė valtis", + "motorboat": "Motorinė Valtis", + "ship": "Laivas", + "motor_vehicle": "Motorinis Transportas", + "car_alarm": "Mašinos Signalizacija", + "power_windows": "Elektriniai Langai", + "tire_squeal": "Padangų cypimas", + "car_passing_by": "Pravažiuojanti Mašina", + "race_car": "Lenktyninė Mašina", + "truck": "Sunkvežimis", + "air_brake": "Oro Stabdis", + "reversing_beeps": "Atbulinės Eigos Signalas", + "ice_cream_truck": "Ledų Mašina", + "emergency_vehicle": "Pagalbos Transportas", + "police_car": "Policijos Mašina", + "ambulance": "Greitoji", + "fire_engine": "Užvesti Variklis", + "traffic_noise": "Esimo Triukšmas", + "train_whistle": "Traukinio Švilpimas", + "train_horn": "Traukinio Pypsėjimas", + "subway": "Metro", + "aircraft": "Orlaivis", + "aircraft_engine": "Orlaivio Variklis", + "jet_engine": "Reaktyvinis Variklis", + "propeller": "Propeleris", + "helicopter": "Malūnsparnis", + "fixed-wing_aircraft": "Fiksuotų Sparnų Orlaivis", + "engine": "Variklis", + "light_engine": "Mažas Variklis", + "dental_drill's_drill": "Dantų Gręžimas", + "lawn_mower": "Žoliapjovė", + "chainsaw": "Grandininis Pjūklas", + "medium_engine": "Vidutinis Variklis", + "heavy_engine": "Didelis Variklis", + "engine_knocking": "Variklio Kalimas", + "engine_starting": "Užsikuriantis Variklis", + "idling": "Laisvai Dirbantis", + "accelerating": "Įsibegėjantis", + "doorbell": "Dūrų Skambutis", + "sliding_door": "Slankiojančios Durys", + "slam": "Trenkti", + "knock": "Stuksėti", + "tap": "Tapšnoti", + "cupboard_open_or_close": "Spintelė Atidaryti ar Užsidaryti", + "drawer_open_or_close": "Stalčių Atidaryti ar Uždaryti", + "dishes": "Indai", + "cutlery": "Stalo Įrankiai", + "chopping": "Kapoti", + "static": "Statinis", + "environmental_noise": "Aplinkos Triukšmas", + "sound_effect": "Garso efektai", + "firecracker": "Ugnies Spragėjimas", + "gunshot": "Ginklo Šūvis", + "single-lens_reflex_camera": "Veidrodinis Fotoparatas", + "mechanical_fan": "Mechaninis Fenas", + "ratchet": "Raketė", + "civil_defense_siren": "Civilinės Saugos Sirena", + "busy_signal": "Užimtas Signalas", + "dial_tone": "Numerio Rinkimo Tonas", + "telephone_dialing": "Telefono Rinkimas", + "ringtone": "Skambutis", + "telephone_bell_ringing": "Skamba Telefonas", + "alarm": "Signalizacija", + "computer_keyboard": "Kopiuterio Klaviatūra", + "typewriter": "Spausdinimo Mašina", + "typing": "Spausdinti", + "electric_shaver": "Barzdaskutė", + "coin": "Moneta", + "keys_jangling": "Žvangantys Raktai", + "vacuum_cleaner": "Siurblys", + "toilet_flush": "Tualeto Nuleidimas", + "bathtub": "Vonia", + "water_tap": "Vandens Kranas", + "microwave_oven": "Mikorbangų Krosnelė", + "frying": "Gruzdinimas", + "zither": "Citara", + "rimshot": "Mušimas per kraštą", + "drum_roll": "Būgno dundesys", + "bass_drum": "Bosinis Būgnas", + "marimba": "Marimba", + "glockenspiel": "Varpelis", + "french_horn": "Prancūzų Ragas", + "trumpet": "Trimitas", + "bowed_string_instrument": "Styginiai Instrumentai", + "pizzicato": "Pizikatas", + "cello": "Violončelė", + "harp": "Arfa", + "didgeridoo": "Didžeridū", + "theremin": "Tereminas", + "singing_bowl": "Dainuojantis Dubuo", + "scratching": "Skrečavimas", + "grunge": "Grandžas", + "soul_music": "Soul Muzika", + "country": "Country Muzika", + "bluegrass": "Bluegrass", + "funk": "Funk", + "drum_and_bass": "Drum & Bass", + "ambient_music": "Ambient Muzika", + "new-age_music": "Naujojo Amžiaus Muzika", + "afrobeat": "Afrikietiški Ritmai", + "carnatic_music": "Karnatietiška Muzika", + "ska": "Ska", + "independent_music": "Nepriklausoma Muzika", + "tender_music": "Švelni Muzika", + "exciting_music": "Jaudinanti Muzika", + "toot": "Pyptelėjimas", + "skidding": "Slydimas", + "air_horn": "Klaksonas", + "rail_transport": "Bėginis Transportas", + "railroad_car": "Geležinkelio Vagonas", + "train_wheels_squealing": "Cypiantys Traukino Ratai", + "ding-dong": "Ding-Dong", + "squeak": "Cypimas", + "buzzer": "Skambutis", + "foghorn": "Rūko Sirena", + "fusillade": "Šaudymas", + "cap_gun": "Kapsulinis Pistoletas", + "burst": "Sprogimas", + "splinter": "Skeveldra", + "chink": "Skambėjimas" } diff --git a/web/public/locales/lt/common.json b/web/public/locales/lt/common.json index 0f512e1475..936cb217a3 100644 --- a/web/public/locales/lt/common.json +++ b/web/public/locales/lt/common.json @@ -47,12 +47,43 @@ "second_few": "{{time}} sekundės", "second_other": "{{time}} sekundžių", "formattedTimestamp": { - "12hour": "" + "12hour": "MMM d, h:mm:ss aaa", + "24hour": "MMM d, HH:mm:ss" + }, + "formattedTimestamp2": { + "12hour": "MM/dd h:mm:ssa", + "24hour": "d MMM HH:mm:ss" + }, + "formattedTimestampHourMinute": { + "12hour": "h:mm aaa", + "24hour": "HH:mm" + }, + "formattedTimestampHourMinuteSecond": { + "12hour": "h:mm:ss aaa", + "24hour": "HH:mm:ss" + }, + "formattedTimestampMonthDayHourMinute": { + "12hour": "MMM d, h:mm aaa", + "24hour": "MMM d, HH:mm" + }, + "formattedTimestampMonthDayYear": { + "12hour": "MMM d, yyyy", + "24hour": "MMM d, yyyy" + }, + "formattedTimestampMonthDayYearHourMinute": { + "12hour": "MMM d yyyy, h:mm aaa", + "24hour": "MMM d yyyy, HH:mm" + }, + "formattedTimestampMonthDay": "MMM d", + "formattedTimestampFilename": { + "12hour": "MM-dd-yy-h-mm-ss-a", + "24hour": "MM-dd-yy-HH-mm-ss" } }, "unit": { "speed": { - "kph": "kmh" + "kph": "kmh", + "mph": "mph" }, "length": { "feet": "pėdos", @@ -82,21 +113,22 @@ "pictureInPicture": "Paveikslėlis Paveiksle", "twoWayTalk": "Dvikryptis Kalbėjimas", "cameraAudio": "Kameros Garsas", - "on": "", + "on": "ON", "edit": "Redaguoti", "copyCoordinates": "Kopijuoti koordinates", "delete": "Ištrinti", "yes": "Taip", "no": "Ne", "download": "Atsisiųsti", - "info": "", + "info": "Info", "suspended": "Pristatbdytas", "unsuspended": "Atnaujinti", "play": "Groti", "unselect": "Atžymėti", "export": "Eksportuoti", "deleteNow": "Trinti Dabar", - "next": "Kitas" + "next": "Kitas", + "off": "OFF" }, "menu": { "system": "Sistema", @@ -131,10 +163,24 @@ "hu": "Vengrų", "fi": "Suomių", "da": "Danų", - "sk": "Slovėnų", + "sk": "Slovakų", "withSystem": { "label": "Kalbai naudoti sistemos nustatymus" - } + }, + "hi": "Hindi", + "ptBR": "Brazilietiška Portugalų", + "ko": "Korėjiečių", + "he": "Hebrajų", + "yue": "Kantoniečių", + "th": "Tailandiečių", + "ca": "Kataloniečių", + "sr": "Serbų", + "sl": "Slovėnų", + "lt": "Lietuvių", + "bg": "Bulgarų", + "gl": "Galician", + "id": "Indonesian", + "ur": "Urdu" }, "appearance": "Išvaizda", "darkMode": { @@ -182,7 +228,8 @@ "anonymous": "neidentifikuotas", "logout": "atsijungti", "setPassword": "Nustatyti Slaptažodi" - } + }, + "uiPlayground": "UI Playground" }, "toast": { "copyUrlToClipboard": "URL nukopijuotas į atmintį.", @@ -209,6 +256,19 @@ "next": { "title": "Sekantis", "label": "Eiti į sekantį puslapį" - } - } + }, + "more": "Daugiau puslapių" + }, + "accessDenied": { + "documentTitle": "Priegai Nesuteikta - Frigate", + "title": "Prieiga Nesuteikta", + "desc": "Jūs neturite leidimo žiūrėti šį puslapį." + }, + "notFound": { + "documentTitle": "Nerasta - Frigate", + "title": "404", + "desc": "Puslapis nerastas" + }, + "selectItem": "Pasirinkti {{item}}", + "readTheDocumentation": "Skaityti dokumentaciją" } diff --git a/web/public/locales/lt/components/camera.json b/web/public/locales/lt/components/camera.json index 11639ade2d..7f4f5d8579 100644 --- a/web/public/locales/lt/components/camera.json +++ b/web/public/locales/lt/components/camera.json @@ -7,7 +7,7 @@ "label": "Ištrinti Kamerų Grupę", "confirm": { "title": "Patvirtinti ištrynimą", - "desc": "Ar tikrai norite ištrinti šią kamerų grupę {{name}}?" + "desc": "Esate įsitikinę, kad norite ištrinti šią kamerų grupę {{name}}?" } }, "name": { @@ -15,8 +15,73 @@ "placeholder": "Įveskite pavadinimą…", "errorMessage": { "mustLeastCharacters": "Kamerų grupės pavadinimas turi būti bent 2 simbolių.", - "exists": "Kamerų grupės pavadinimas jau egzistuoja." + "exists": "Kamerų grupės pavadinimas jau egzistuoja.", + "nameMustNotPeriod": "Kamerų grupės pavadinime negali būti taško.", + "invalid": "Nepriimtinas kamera grupės pavadinimas." } + }, + "cameras": { + "label": "Kameros", + "desc": "Pasirinkite kameras šiai grupei." + }, + "icon": "Ikona", + "success": "Kameraų grupė {{name}} išsaugota.", + "camera": { + "setting": { + "label": "Kamerų Transliacijos Nustatymai", + "title": "{{cameraName}} Transliavimo Nustatymai", + "desc": "Keisti tiesioginės tranliacijos nustatymus šiai kamerų grupės valdymo lentai. Šie nustatymai yra specifiniai įrenginiui/ naršyklei.", + "audioIsAvailable": "Šiai transliacijai yra garso takelis", + "audioIsUnavailable": "Šiai transliacijai nėra garso takelio", + "audio": { + "tips": { + "title": "Šiai transliacijai garsas turi būti teikiamas iš kameros ir konfiguruojamas naudojant go2rtc.", + "document": "Skaityti dokumentaciją " + } + }, + "stream": "Transliacija", + "placeholder": "Pasirinkti transliaciją", + "streamMethod": { + "label": "Transliacijos Metodas", + "placeholder": "Pasirinkti transliacijos metodą", + "method": { + "noStreaming": { + "label": "Nėra transliacijos", + "desc": "Kameros vaizdas atsinaujins tik kartą per mintuę ir nebus tiesioginės transliacijos." + }, + "smartStreaming": { + "label": "Išmanus Transliavimas (rekomenduojama)", + "desc": "Išmanus transliavimas atnaujins jūsų kameros vaizdą kartą per minutę jei nebus aptinkama jokia veikla tam kad saugoti tinklo pralaiduma ir kitus resursus. Aptikus veiklą atvaizdavimas nepertraukiamai persijungs į tiesioginę transliaciją." + }, + "continuousStreaming": { + "label": "Nuolatinė Transliacija", + "desc": { + "title": "Kameros vaizdas visada bus tiesioginė transliacija, jei jis bus matomas valdymo lentoje, net jei jokia veikla nėra aptinkama.", + "warning": "Nepertraukiama transliacija gali naudoti daug tinklo duomenų bei sukelti našumo problemų. Naudoti su atsarga." + } + } + } + }, + "compatibilityMode": { + "desc": "Šį nustatymą naudoti tik jei jūsų kameros tiesioginėje transliacijoje matomi spalvų neatitikimai arba matoma įstriža linija dešinėje vaizdo pusėje.", + "label": "Suderinamumo rėžimas" + } + }, + "birdseye": "Birdseye" } + }, + "debug": { + "options": { + "label": "Nustatymai", + "title": "Pasirinkimai", + "showOptions": "Rodyti Pasirinkimus", + "hideOptions": "Slėpti Pasirinkimus" + }, + "boundingBox": "Ribojantis Kvadratas", + "timestamp": "Laiko žymė", + "zones": "Zonos", + "mask": "Maskuotė", + "motion": "Judesys", + "regions": "Regionas" } } diff --git a/web/public/locales/lt/components/dialog.json b/web/public/locales/lt/components/dialog.json index 4feb8d5832..84b98af864 100644 --- a/web/public/locales/lt/components/dialog.json +++ b/web/public/locales/lt/components/dialog.json @@ -1,6 +1,6 @@ { "restart": { - "title": "Ar įsitikinę kad norite perkrauti Frigate?", + "title": "Esate įsitikinę, kad norite perkrauti Frigate?", "button": "Perkrauti", "restarting": { "title": "Frigate Persikrauna", @@ -14,12 +14,107 @@ "question": { "ask_a": "Ar šis objektas yra {{label}}?", "ask_an": "Ar šis objektas yra {{label}}?", - "label": "Patvirtinti šią etiketę į Frigate Plus" + "label": "Patvirtinti šią etiketę į Frigate Plus", + "ask_full": "Ar šis objektas yra {{untranslatedLabel}} ({{translatedLabel}})?" + }, + "state": { + "submitted": "Pateikta" } }, "submitToPlus": { - "label": "Pateiktį į Frigate+" + "label": "Pateiktį į Frigate+", + "desc": "Objektai vietose kurių norite vengti nėra klaidingai teigiami. Pateikiant juos kaip klaidingai teigiamus įneš neatitikimų į modelį." + } + }, + "video": { + "viewInHistory": "Pažiūrėti Istorijoje" + } + }, + "streaming": { + "restreaming": { + "disabled": "Šiai kamerai pertransliavimas nėra įjungtas.", + "desc": { + "title": "Nustatyti go2rtc papildomoms tiesioginės transliacijos galimybėms ir šios kameros garsui." + } + }, + "label": "Srautas", + "showStats": { + "label": "Rodyti transliacijos statistiką", + "desc": "Įjungti šią galimybę rodyti transliacijos statistiką kaip pridėtinę informaciją kameros vaizde." + }, + "debugView": "Derinimo Vaizdas" + }, + "export": { + "time": { + "lastHour_one": "Paskutinė {{count}} Valanda", + "lastHour_few": "Paskutinės {{count}} Valandos", + "lastHour_other": "Paskutinės {{count}} Valandų", + "fromTimeline": "Pasirinkit iš Laiko juostos", + "custom": "Pasirinkimas", + "start": { + "title": "Pradžios Laikas", + "label": "Pasirinkti Pradžios Laiką" + }, + "end": { + "title": "Pabaigos Laikas", + "label": "Pasirinkti Pabaigos Laiką" + } + }, + "fromTimeline": { + "previewExport": "Peržiūrėti Eksportuotus", + "saveExport": "Išsaugoti Exportuojamą" + }, + "name": { + "placeholder": "Pavadinti eksportuojamą įrašą" + }, + "select": "Pasirinkti", + "export": "Eksportuoti", + "selectOrExport": "Pasirinkti ar Eksportuoti", + "toast": { + "success": "Sėkmingai pradėtas eksportavimas. Įrašą galima peržiūrėti /exports kataloge.", + "error": { + "failed": "Nepavyko pradėti eksportavimo: {{error}}", + "endTimeMustAfterStartTime": "Pabaigos Laikas privalo būti vėliau nei pradžios laikas", + "noVaildTimeSelected": "Nėra pasirinkto tinkamo laikotarpio" } } + }, + "recording": { + "button": { + "markAsReviewed": "Žymėti kaip peržiūrėtą", + "export": "Eksportuoti", + "deleteNow": "Ištrinti Dabar" + }, + "confirmDelete": { + "desc": { + "selected": "Ar esate įsitikinę, kad norite ištrinti visus įrašytus vaizdo įrašus susijusius su šiuo peržiūros elementu?

LaikykiteShift norint ateityje praleisti šį pranešimą." + }, + "title": "Patvirtinti Ištrynimą", + "toast": { + "success": "Vaizdo įrašas susijęs su pasirinkta peržiūra buvo sėkmingai ištrintas.", + "error": "Nepavyko ištrinti: {{error}}" + } + } + }, + "search": { + "saveSearch": { + "label": "Išsaugoti Paiešką", + "desc": "Suteikite vardą šiai išsaugotai paieškai.", + "placeholder": "Įveskite pavadinima savo paieškai", + "overwrite": "{{searchName}} jau egzistuoja. Jei išsaugosite esamas įrašas bus perrašytas.", + "success": "Paieška ({{searchName}}) buvo išsaugota.", + "button": { + "save": { + "label": "Išsaugoti šią paiešką" + } + } + } + }, + "imagePicker": { + "selectImage": "Pasirinkti miniatiūrą sekamam objektui", + "search": { + "placeholder": "Ieškoti pagal etiketę arba sub etiketę..." + }, + "noImages": "Šiai kamerai miniatiūrų nerasta" } } diff --git a/web/public/locales/lt/components/filter.json b/web/public/locales/lt/components/filter.json index fff4ed16b5..f5beaf8d2d 100644 --- a/web/public/locales/lt/components/filter.json +++ b/web/public/locales/lt/components/filter.json @@ -15,5 +15,122 @@ "title": "Visos Zonos", "short": "Zonos" } + }, + "review": { + "showReviewed": "Rodyti Peržiūrėtus" + }, + "trackedObjectDelete": { + "desc": "Trinant šiuos {{objectLength}} sekamus objektus taip pat pašalins momentines iškarpas, išsaugotus įterpius, priskirtus objekto gyvavimo ciklo įrašus. Šių sekamų objektų įrašyta filmuota medžiaga Istorijos vaizde ištrinta NEBUS.

Ar esate įsitikinę, kad norite tęsti?

Laikykite Shift norint ateityje praleisti šį pranešimą.", + "title": "Patvirtinkite Ištrynimą", + "toast": { + "success": "Sekami objektai sėkmingai ištrinti.", + "error": "Nepavyko ištrinti sekamų objektų: {{errorMessage}}" + } + }, + "classes": { + "label": "Klasės", + "all": { + "title": "Visos Klasės" + }, + "count_one": "{{count}} Klasė", + "count_other": "{{count}} Klasių" + }, + "dates": { + "selectPreset": "Pasirinkti Nustatytą poziciją…", + "all": { + "title": "Visos Datos", + "short": "Datos" + } + }, + "more": "Daugiau Filtrų", + "reset": { + "label": "Atstatyti bazines filtrų reikšmes" + }, + "timeRange": "Laiko Rėžis", + "subLabels": { + "label": "Sub Etiketės", + "all": "Visos Sub Etiktės" + }, + "score": "Balas", + "estimatedSpeed": "Nustatytas Greitis ({{unit}})", + "features": { + "label": "Funkcijos", + "hasSnapshot": "Turi Momentinę Nuotrauką", + "hasVideoClip": "Turi vaizdo klipą", + "submittedToFrigatePlus": { + "label": "Pateikta į Frigate+", + "tips": "Pradžioje turite išfiltruoti sekamus objektus su momentinėmis nuotraukomis.

Sekami Objektai be momentinių nuotraukų negali būti pateikti į Frigate+." + } + }, + "sort": { + "label": "Rikiuoti", + "dateAsc": "Datos (Didėjančiai)", + "dateDesc": "Datos (Mažėjančiai)", + "scoreAsc": "Objekto Balai (Didėjančiai)", + "scoreDesc": "Objekto Balai (Mažėjančiai)", + "speedAsc": "Įvertintas Greitis (Didėjančiai)", + "speedDesc": "Įvertintas Greitis (Mažėjančiai)", + "relevance": "Aktualumą" + }, + "cameras": { + "label": "Kamerų Filtrai", + "all": { + "title": "Visos Kameros", + "short": "Kameros" + } + }, + "motion": { + "showMotionOnly": "Rodyti Tik Judesius" + }, + "explore": { + "settings": { + "title": "Nustatymai", + "defaultView": { + "title": "Bazinis Vaizdas", + "summary": "Santrauka", + "unfilteredGrid": "Nefiltruotas Tinklelis", + "desc": "Kai jokie filtrai nėra parinkti, rodom santrauka naujienų sekamiems objektas pagal etiketę arba nefiltruotas tinklelis." + }, + "gridColumns": { + "title": "Tiklelio Stulpeliai", + "desc": "Pasirinkti kiekį stulpelių atvaizduojant tinkleliu." + }, + "searchSource": { + "label": "Paiškos Šaltinis", + "desc": "Pasirinkite kaip jūsų sekamiems objektams bus vykdoma paieška, naudojant miniatiūras ar tekstinius aprašymus.", + "options": { + "thumbnailImage": "Miniatiūros Paveikslėlis", + "description": "Aprašymas" + } + } + }, + "date": { + "selectDateBy": { + "label": "Pasirinkite datą filtravimui" + } + } + }, + "logSettings": { + "label": "Filtruoti sekimo įrašų lygį", + "filterBySeverity": "Filtruoti įrašus pagal svarbą", + "loading": { + "title": "Kraunama", + "desc": "Kai įrašų puslapyje pasiekiama įrašų pabaiga, nauji įrašai atsiras automatiškai." + }, + "disableLogStreaming": "Išjungti įrašų transliavimą", + "allLogs": "Visi įrašai" + }, + "zoneMask": { + "filterBy": "Filtruoti naudojant zonų maskavimus" + }, + "recognizedLicensePlates": { + "title": "Atpažinti Registracijos Numeriai", + "loadFailed": "Nepavyko pateikti atpažintų registracijos numerių.", + "loading": "Ištraukiami atpažinti registracijos numeriai…", + "placeholder": "Įveskite norėdami ieškoti registracijos numerių…", + "noLicensePlatesFound": "Registracjos numerių nerasta.", + "selectPlatesFromList": "Pasirinkti vieną ar daugiau numerių iš sąrašo.", + "selectAll": "Pasirinkti viską", + "clearAll": "Išvalyti viską" } } diff --git a/web/public/locales/lt/objects.json b/web/public/locales/lt/objects.json index bc7499b61f..9aa9b5d706 100644 --- a/web/public/locales/lt/objects.json +++ b/web/public/locales/lt/objects.json @@ -105,14 +105,16 @@ "license_plate": "Registracijos Numeris", "package": "Pakuotė", "bbq_grill": "BBQ kepsninė", - "amazon": "", - "usps": "", - "ups": "", - "fedex": "", - "dhl": "", - "an_post": "", - "purolator": "", - "postnl": "", - "nzpost": "", - "postnord": "" + "amazon": "Amazon", + "usps": "USPS", + "ups": "UPS", + "fedex": "FedEx", + "dhl": "DHL", + "an_post": "An Post", + "purolator": "Purolator", + "postnl": "PostNL", + "nzpost": "NZPost", + "postnord": "PostNord", + "gls": "GLS", + "dpd": "DPD" } diff --git a/web/public/locales/lt/views/configEditor.json b/web/public/locales/lt/views/configEditor.json index 996612c225..e9c67dcff6 100644 --- a/web/public/locales/lt/views/configEditor.json +++ b/web/public/locales/lt/views/configEditor.json @@ -12,5 +12,7 @@ "error": { "savingError": "Klaida išsaugant konfiguraciją" } - } + }, + "safeConfigEditor": "Konfiguracijos Redaktorius (Saugus Rėžimas)", + "safeModeDescription": "Frigate yra saugiame rėžime dėl konfiguracijos tinkamumo klaidos." } diff --git a/web/public/locales/lt/views/events.json b/web/public/locales/lt/views/events.json index 97ad492558..79a2223717 100644 --- a/web/public/locales/lt/views/events.json +++ b/web/public/locales/lt/views/events.json @@ -34,5 +34,7 @@ "label": "Pamatyti naujus peržiūros įrašus", "button": "Nauji Įrašai Peržiūrėjimui" }, - "detected": "aptikta" + "detected": "aptikta", + "suspiciousActivity": "Įtartina Veikla", + "threateningActivity": "Grėsminga Veikla" } diff --git a/web/public/locales/lt/views/explore.json b/web/public/locales/lt/views/explore.json index 0681c40c70..0186e7365b 100644 --- a/web/public/locales/lt/views/explore.json +++ b/web/public/locales/lt/views/explore.json @@ -5,10 +5,222 @@ "exploreIsUnavailable": { "embeddingsReindexing": { "startingUp": "Paleidžiama…", - "estimatedTime": "Apytikris likęs laikas:" + "estimatedTime": "Apytikris likęs laikas:", + "context": "Tyrinėjimai gali būti naudojami po to kai sekamų objektų įterpiai bus užbaigti indeksuoti.", + "finishingShortly": "Paigiama netrukus", + "step": { + "thumbnailsEmbedded": "Įterptos Miniatiūros: ", + "descriptionsEmbedded": "Įterpti aprašymai: ", + "trackedObjectsProcessed": "Apdorota sekamų objektų: " + } + }, + "title": "Tyrinėjimai Negalimi", + "downloadingModels": { + "context": "Frigate siunčiasi reikalingus įterpimo modelius, kad būtų palaikoma Semantic Paieškos funkcija. Tai gali užtrukti priklausomai nuo duomenų srauto greičio.", + "setup": { + "visionModel": "Vaizdo modelis", + "visionModelFeatureExtractor": "Vaizdo modelio funkcijų išgavimas", + "textModel": "Teksto modelis", + "textTokenizer": "Teksto tekenizatorius" + }, + "tips": { + "context": "Galimai norėsite iš naujo indeksuoti savo sekamų objektų įterpius po to kai modeliai parsisiųs." + }, + "error": "Įvyko klaida. Patikrinkite Frigate įrašus." } }, "details": { - "timestamp": "Laiko žyma" + "timestamp": "Laiko žyma", + "item": { + "tips": { + "mismatch_one": "{{count}} neesamas objektas aptiktas ir pridėtas į šį peržiuros įrašą. Tie objektai arba neatitinką įspėjimų ar aptikimų sąlygų arba jau buvo išvalyti/ištrinti.", + "mismatch_few": "{{count}} neesami objektai aptikti ir pridėti į šį peržiuros įrašą. Tie objektai arba neatitinką įspėjimų ar aptikimų sąlygų arba jau buvo išvalyti/ištrinti.", + "mismatch_other": "{{count}} neesamų objektų aptiktų ir pridėtų į šį peržiuros įrašą. Tie objektai arba neatitinką įspėjimų ar aptikimų sąlygų arba jau buvo išvalyti/ištrinti.", + "hasMissingObjects": "Koreguokite savo nustatymus jeigu norite, kad Frigate saugoti objektus su šiomis etiketėmis: {{objects}}" + }, + "title": "Peržiūrėti Įrašo Detales", + "desc": "Peržiūrėti Įrašo detales", + "button": { + "share": "Dalintis šiuo peržiūros įrašu", + "viewInExplore": "Žiūrėti Tyrinėjime" + }, + "toast": { + "success": { + "regenerate": "Gauta nauja užklausa iš {{provider}} naujam aprašymui. Priklausomai nuo jūsų tiekėjo greičio, naują aprašymą sukurti gali užtrukti.", + "updatedSublabel": "Sėkmingai atnaujinta sub etiketė.", + "updatedLPR": "Sėkmingai atnaujinti registracijos numeriai.", + "audioTranscription": "Sėkmingai užklausta garso aprašymo." + }, + "error": { + "regenerate": "Nepavyko pakviesti {{provider}} naujam aprašymui: {{errorMessage}}", + "updatedSublabelFailed": "Nepavyko atnaujinti sub etikečių: {{errorMessage}}", + "updatedLPRFailed": "Nepavyko atnaujinti registracijos numerių: {{errorMessage}}", + "audioTranscription": "Nepavyko užklausti garso aprašymo: {{errorMessage}}" + } + } + }, + "label": "Etiketė", + "editSubLabel": { + "title": "Koreguoti sub etiketę", + "desc": "Įveskite naują sub etiketę šiai etiketei {{label}}", + "descNoLabel": "Įveskite naują sub etiketę šiam sekamam objektui" + }, + "editLPR": { + "title": "Redaguoti registracijos numerį", + "desc": "Įvesti naują registracijos numerio reikšmę šiai etiketei {{label}}", + "descNoLabel": "Įvesti naują registracijos numerio reikšmę šiam objektui" + }, + "snapshotScore": { + "label": "Momentinės nuotraukos balas" + }, + "topScore": { + "label": "Top Balas", + "info": "Aukščiausias balas yra didžiausia medianos reikšmė sekamam objektui, taigi tai gali skirtis nuo balų pateiktų miniatiūrų paieškos rezultatuose." + }, + "score": { + "label": "Balas" + }, + "recognizedLicensePlate": "Atpažinti Registracijos Numeriai", + "estimatedSpeed": "Nustatytas Greitis", + "objects": "Objektai", + "camera": "Kamera", + "zones": "Zonos", + "button": { + "findSimilar": "Rasti Panašų", + "regenerate": { + "title": "Regeneruoti", + "label": "Regeneruoti sekamų objektų aprašymus" + } + }, + "description": { + "label": "Aprašymas", + "placeholder": "Sekamo objekto aprašymas", + "aiTips": "Iki kol sekamo objekto gyvavimo ciklas užsibaigs Frigate neklaus aprašymo iš jūsų Generatyvinio DI tiekėjo." + }, + "expandRegenerationMenu": "Išskleisti regeneravimo meniu", + "regenerateFromSnapshot": "Regeneruoti iš Momentinės Nuotraukos", + "regenerateFromThumbnails": "Regenruoti iš Miniatiūros", + "tips": { + "descriptionSaved": "Aprašymas sėkmingai išsaugotas", + "saveDescriptionFailed": "Nepavyko atnaujinti aprašymo: {{errorMessage}}" + } + }, + "trackedObjectsCount_one": "{{count}} sekamas objektas ", + "trackedObjectsCount_few": "{{count}} sekami objektai ", + "trackedObjectsCount_other": "{{count}} sekamų objektų ", + "objectLifecycle": { + "lifecycleItemDesc": { + "visible": "{{label}} aptikta", + "attribute": { + "faceOrLicense_plate": "{{attribute}} aptiktas etiketei {{label}}", + "other": "{{label}} atpažintas kaip {{attribute}}" + }, + "external": "{{label}} aptiktas", + "entered_zone": "{{label}} pateko į {{zones}}", + "active": "{{label}} tapo aktyvus", + "stationary": "{{label}} nebejuda", + "gone": "{{label}} paliko", + "heard": "{{label}} girdėta", + "header": { + "zones": "Zonos", + "ratio": "Santykis", + "area": "Plotas" + } + }, + "annotationSettings": { + "offset": { + "desc": "Šie duomenys gaunami iš jūsų kameros aptikimo srauto bet yra užkeliami ant vaizdo gaunamo iš įrašymo srauto. Mažai tikėtina kad abeji srautais bus tobulai sinchronizuoti. Rezultate, apibrėžimo dėžutė ir įrašas nesilygiuos tobulai. Tačiau, annotation_offset reikšmė gali būti naudojama tai koreguoti.", + "millisecondsToOffset": "Praslinkti aptikimų anotacijas per mili-sekundes. Bazinis: 0", + "label": "Anotacijos Perstūmimas", + "tips": "Patarimas: Įsivaizduokite kad yra įvykio klipas kur žmogus eina iš kairės į dešinę. Jei apibrėžimo dėžutė nuolatos yra žmogui iš kairės tuomet reikšmę sumažinkite. Analogiškai, jei dėžutė piešiama priekyje žmogaus tuomet reikšmę padidinkite.", + "toast": { + "success": "Anotacijos perslinkimas kamerai {{camera}} buvo išsaugota konfiguracijoje. Perkraukite Frigate, kad pritaikytumėte pokyčius." + } + }, + "title": "Anotacijų Nustatymai", + "showAllZones": { + "title": "Rodyti Visas Zonas", + "desc": "Visada rodyti zonas tuose kadruose, kuriuose objektas pateko į zoną." + } + }, + "title": "Objekto Gyvavimo Ciklas", + "noImageFound": "Šiam laikotarpiui vaizdų nerasta.", + "createObjectMask": "Sukurta Objekto Maskuotė", + "adjustAnnotationSettings": "Koreguoti anotacijų nustatymus", + "scrollViewTips": "Peržiūrėti šio objekto gyvavimo cikle esančius reikšmingus momentus.", + "autoTrackingTips": "Automatiškai sekančių kamerų apibrėžiančios dėžutės pozicija bus netiksli.", + "count": "{{first}} iš {{second}}", + "trackedPoint": "Sekamas Taškas", + "carousel": { + "previous": "Ankstesnė skaidrė", + "next": "Sekanti skaidrė" + } + }, + "dialog": { + "confirmDelete": { + "desc": "Trinant šį sekamą objektą taip pat bus pašalintos momentinės iškarpos, išsaugoti įterpiai, priskirti objekto gyvavimo ciklo įrašai. Šių sekamų objektų įrašyta filmuota medžiaga Istorijos vaizde ištrinta NEBUS.

Ar esate įsitikinę, kad norite tęsti?", + "title": "Patvirtinti Ištrynimą" + } + }, + "trackedObjectDetails": "Sekamų Objektų Detalės", + "type": { + "details": "detalės", + "snapshot": "momentinės nuotraukos", + "video": "vaizdas", + "object_lifecycle": "objekto gyvavimo ciklas" + }, + "itemMenu": { + "downloadVideo": { + "label": "Atsisiųsti video", + "aria": "Atsisiųsti video" + }, + "downloadSnapshot": { + "label": "Atsisiųsti momentinę nuotrauką", + "aria": "Atsisiųsti momentinę nuotrauką" + }, + "viewObjectLifecycle": { + "label": "Peržiūrėti objekto gyvavimo ciklą", + "aria": "Rodyti objekto gyvavimo ciklą" + }, + "findSimilar": { + "label": "Rasti panašų", + "aria": "Rasti panašius sekamus objektus" + }, + "addTrigger": { + "label": "Pridėti trigerį", + "aria": "Šiam sekamam objektui pridėti trigerį" + }, + "audioTranscription": { + "label": "Aprašyti", + "aria": "Užklausti garso aprašymo" + }, + "submitToPlus": { + "label": "Pateikti į Frigate+", + "aria": "Pateikti į Frigate Plius" + }, + "viewInHistory": { + "label": "Žiūrėti Istorijoje", + "aria": "Žiūrėti Istorijoje" + }, + "deleteTrackedObject": { + "label": "Ištrinti šį sekamą objektą" + } + }, + "noTrackedObjects": "Sekamų Objektų Nerasta", + "fetchingTrackedObjectsFailed": "Sekamų objektų ištraukti nepavyko: {{errorMessage}}", + "searchResult": { + "tooltip": "Sutapo {{type}} su {{confidence}}% patikimumu", + "deleteTrackedObject": { + "toast": { + "success": "Sekami objektai sėkmingai ištrinti.", + "error": "Sekamų objektų nepavyko ištrinti: {{errorMessage}}" + } + } + }, + "aiAnalysis": { + "title": "DI Analizė" + }, + "concerns": { + "label": "Rūpesčiai" } } diff --git a/web/public/locales/lt/views/exports.json b/web/public/locales/lt/views/exports.json index bc2fb25554..c8b257a54f 100644 --- a/web/public/locales/lt/views/exports.json +++ b/web/public/locales/lt/views/exports.json @@ -3,7 +3,7 @@ "documentTitle": "Eksportuoti - Frigate", "noExports": "Eksportuotų įrašų nerasta", "deleteExport": "Ištrinti Eksportuotą Įrašą", - "deleteExport.desc": "Esate įsitikine, kad norite ištrinti {{exportName}}?", + "deleteExport.desc": "Esate įsitikinę, kad norite ištrinti {{exportName}}?", "editExport": { "title": "Pervadinti Eksportuojamą įrašą", "desc": "Įveskite nauja pavadinimą šiam eksportuojamam įrašui.", diff --git a/web/public/locales/lt/views/faceLibrary.json b/web/public/locales/lt/views/faceLibrary.json index d4dce21f3a..b2cb8d3324 100644 --- a/web/public/locales/lt/views/faceLibrary.json +++ b/web/public/locales/lt/views/faceLibrary.json @@ -8,6 +8,94 @@ "person": "Žmogus", "face": "Veido detelės", "timestamp": "Laiko žyma", - "unknown": "Nežinoma" - } + "unknown": "Nežinoma", + "subLabelScore": "Sub Etiketės Balas", + "scoreInfo": "Sub etiketės balas yra pasvertas balas pagal visų atpažintų veidų užtikrintumą, taigi gali skirtis nuo balo rodomo momentinėje nuotraukoje.", + "faceDesc": "Papildoma informacija sekamo objekto, kuris sugeneravo šį veidą" + }, + "selectItem": "Pasirinkti {{item}}", + "deleteFaceAttempts": { + "desc_one": "Esate įsitikine, kad norite ištrinti {{count}} veidą? Šio veiksmo sugrąžinimas negalimas.", + "desc_few": "Esate įsitikine, kad norite ištrinti {{count}} veidus? Šio veiksmo sugrąžinimas negalimas.", + "desc_other": "Esate įsitikine, kad norite ištrinti {{count}} veidų? Šio veiksmo sugrąžinimas negalimas.", + "title": "Ištrinti Veidus" + }, + "toast": { + "success": { + "deletedFace_one": "Sėkmingai ištrintas{{count}} veidas.", + "deletedFace_few": "Sėkmingai ištrinti {{count}} veidai.", + "deletedFace_other": "Sėkmingai ištrinta {{count}} veidų.", + "deletedName_one": "{{count}} veidas buvo sėkmingai ištrintas.", + "deletedName_few": "{{count}} veidai buvo sėkmingai ištrinti.", + "deletedName_other": "{{count}} veidų buvo sėkmingai ištrinta.", + "uploadedImage": "Nuotrauka sėkmingai įkelta.", + "addFaceLibrary": "{{name}} vardas buvo sėkmingai pridėtas į Veidų Katalogą!", + "renamedFace": "Sėkmingai veidas pervadintas į {{name}}", + "trainedFace": "Veidas apmokytas sėkmingai.", + "updatedFaceScore": "Veido balas atnaujintas sėkmingai." + }, + "error": { + "uploadingImageFailed": "Nepavyko įkelti nuotraukos: {{errorMessage}}", + "addFaceLibraryFailed": "Nepavyko priskirti vardo veidui: {{errorMessage}}", + "deleteFaceFailed": "Nepavyko ištrinti: {{errorMessage}}", + "deleteNameFailed": "Vardo ištrinti nepavyko: {{errorMessage}}", + "renameFaceFailed": "Nepavyko pervardinti veido: {{errorMessage}}", + "trainFailed": "Nepavyko apmokinti: {{errorMessage}}", + "updateFaceScoreFailed": "Veido balų atnaujinti nepavyko: {{errorMessage}}" + } + }, + "createFaceLibrary": { + "nextSteps": "Kad sukurtumėte stiprų pagrindą:
  • Naudokite Apmokymų skirtuką pasirinkti ir paveikslėliais apmokyti kiekvieną aptiktą asmenį.
  • Norint pasiekti geriausią režultatą, susitelkite prie nuotraukų iš priekio; Venkite naudoti veidų nuotraukas kampu pasuktu veidu.
  • ", + "title": "Sukurti Kolekciją", + "desc": "Sukurti naują kolekciją", + "new": "Sukurti Naują Veidą" + }, + "deleteFaceLibrary": { + "desc": "Esate įsitikinę, kad norite ištrinti kolenkciją vardu {{name}}? Visi susiję veidai bus ištrinti negražinamai.", + "title": "Ištrinti Vardą" + }, + "documentTitle": "Veidų Katalogas - Frigate", + "uploadFaceImage": { + "title": "Įkelti Veido Nuotrauką", + "desc": "Įkelti nuotrauką veidų skanavimui ir įtraukti {{pageToggle}}" + }, + "collections": "Kolekcijos", + "steps": { + "faceName": "Įveskite Vardą Veidui", + "uploadFace": "Įkelti Veido Nuotrauką", + "nextSteps": "Sekantis Žingsnis", + "description": { + "uploadFace": "Įkelti nuotrauką {{name}} kuri atvaizduoja veidą iš priekio. Nuotraukos kadruoti nereikia." + } + }, + "train": { + "title": "Traukinys", + "aria": "Pasirinkti traukinį", + "empty": "Pastaruoju metu nebuvo atliktas veidų atpažinimas" + }, + "selectFace": "Pasirinkti Veidą", + "renameFace": { + "title": "Pervadinti Veidą", + "desc": "Įveskite {{name}} naują vardą" + }, + "button": { + "deleteFaceAttempts": "Ištrinti Veidus", + "addFace": "Pridėti Veidą", + "renameFace": "Pervadinti Veidą", + "deleteFace": "Ištrinti Veidą", + "uploadImage": "Įkelti Nuotrauką", + "reprocessFace": "Patikrinti Veidą" + }, + "imageEntry": { + "validation": { + "selectImage": "Prašome pasirinkti nuotraukos bylą." + }, + "dropActive": "Įkelkite nuotrauką čia…", + "dropInstructions": "Drag and drop nuotrauką čia, arba spragtelkite pasirinkti", + "maxSize": "Max dydis: {{size}}MB" + }, + "nofaces": "Nėra veidų", + "pixels": "{{area}}px", + "trainFaceAs": "Apmokyti Veidą kaip:", + "trainFace": "Apmokyti Veidą" } diff --git a/web/public/locales/lt/views/live.json b/web/public/locales/lt/views/live.json index 5779ff4c92..5aa388aaf0 100644 --- a/web/public/locales/lt/views/live.json +++ b/web/public/locales/lt/views/live.json @@ -9,5 +9,161 @@ "twoWayTalk": { "enable": "Įgalinti Dvipusį Pokalbį", "disable": "Išjungti Dvipusį Pokalbį" + }, + "detect": { + "enable": "Įjungti Aptikimą", + "disable": "Išjungti Aptikimą" + }, + "audioDetect": { + "enable": "Įjungti Garso Aptikimą", + "disable": "Išjungti Garso Aptikimą" + }, + "cameraSettings": { + "objectDetection": "Objektų Aptikimai", + "audioDetection": "Garso Aptikimai", + "title": "{{camera}} Nustatymai", + "cameraEnabled": "Kamera įjungta", + "recording": "Įrašinėjimas", + "snapshots": "Momentinės Nuotraukos", + "transcription": "Garso Aprašymas", + "autotracking": "Autosekimas" + }, + "ptz": { + "move": { + "clickMove": { + "label": "Norint išcentruoti kamerą, spragtelti kadre", + "enable": "Įjungti paspaudimą, kad judinti", + "disable": "Išjungti paspaudimą kad judinti" + }, + "left": { + "label": "Pasukti PTZ kamerą į kairę" + }, + "up": { + "label": "Pasukti PTZ kamerą į viršų" + }, + "down": { + "label": "Pasukti PTZ kamera žemyn" + }, + "right": { + "label": "Pasukti PTZ kamerą į dešinę" + } + }, + "zoom": { + "in": { + "label": "Priartinti PTZ kamerą" + }, + "out": { + "label": "Atitolinti PTZ kamerą" + } + }, + "focus": { + "in": { + "label": "Sutelkti PTZ kameros fokusą" + }, + "out": { + "label": "Išleisti PTZ kameros fokusą" + } + }, + "frame": { + "center": { + "label": "Spragtelkite kadre norint centruoti PTZ kamerą" + } + }, + "presets": "PTZ kameros nustatytos pozicijos" + }, + "camera": { + "enable": "Įjungti Kamerą", + "disable": "Išjungti Kamerą" + }, + "muteCameras": { + "enable": "Užtildyti Visas Kameras", + "disable": "Aktyvuoti Garsą Visoms Kameroms" + }, + "recording": { + "enable": "Įjungti Įrašymus", + "disable": "Įšjungti Įrašymus" + }, + "snapshots": { + "enable": "Įjungti Momentines Nuotraukas", + "disable": "Išjungti Momentines Nuotraukas" + }, + "transcription": { + "enable": "Įjungti Gyvą Garso Aprašymą", + "disable": "Išjungti Gyvą Garso Aprašymą" + }, + "autotracking": { + "enable": "Įjungti Autosekimą", + "disable": "Išjungti Autosekimą" + }, + "streamStats": { + "enable": "Rodyti Transliacijos Stats", + "disable": "Paslėpti Transliacijos Stats" + }, + "manualRecording": { + "title": "Įrašymai pagal poreikį", + "tips": "Kurti manualaus saugojimo nustatymus pagal įvykius iš šios kameros.", + "playInBackground": { + "label": "Paleisti fone", + "desc": "Įjungti šią funkciją kad transliaciją išliktų net paslėpus grotuvą." + }, + "showStats": { + "label": "Rodytis Stats", + "desc": "Įjungti šią funkciją, kad matytumėte transliacijos statistiką kameros vaizde." + }, + "debugView": "Derinimo Vaizdas", + "start": "Pradėti įrašymą pagal poreikį", + "started": "Pradėtas rankinis pagal poreikį įrašinėjimas.", + "failedToStart": "Nepavyko pradėti rankinio įrašinėjimo pagal poreikį.", + "recordDisabledTips": "Tik momentinės nuotraukos bus saugomos, nes įrašinėjimai šiai kamera yra išjungti.", + "end": "Baigti įrašymą pagal poreikį", + "ended": "Baigtas rankinis įrašymas pagal poreikį.", + "failedToEnd": "Nepavyko nutraukti rankinio įrašinėjimo pagal poreikį." + }, + "streamingSettings": "Transliacijos Nustatymai", + "notifications": "Pranešimai", + "audio": "Garsas", + "suspend": { + "forTime": "Sustabdymo laikas: " + }, + "stream": { + "title": "Transliacija", + "audio": { + "tips": { + "title": "Šiai transliacijai garso išvestis turi būti sukonfiguruota naudojant go2rtc." + }, + "available": "Šioje transliacijoje yra garsas", + "unavailable": "Šioje transliacijoje nėra garso" + }, + "twoWayTalk": { + "tips": "Jūsų įranga turi palaikyti šią funkciją, taip pat dvipusiam pokalbiui reikia sukonfiguruoti WebRTC.", + "available": "Šioje transliacijoje galimas dvipusis pokalbis", + "unavailable": "Šioje transliacijoje dvipusio pokalbio galimybių nėra" + }, + "lowBandwidth": { + "tips": "Dėl buffering ar transliacijos klaidų tiesioginė transliacija yra mažos reiškos rėžime.", + "resetStream": "Atstatyti transliaciją" + }, + "playInBackground": { + "label": "Paleisti fone", + "tips": "Norėdami kad transliacija tęstūsi kai grotuvas paslėpiamas įjunkite šią funkciją." + } + }, + "history": { + "label": "Rodyti istorinius įrašus" + }, + "effectiveRetainMode": { + "modes": { + "all": "Visi", + "motion": "Judesys", + "active_objects": "Aktyvūs Objektai" + }, + "notAllTips": "Jūsų {{source}} įrašų saugojimo pasirinkimas nustatytas rėžime: {{effectiveRetainMode}}, taigi įrašai pagal poreikį irgi bus saugomi pritaikant {{effectiveRetainModeName}}." + }, + "editLayout": { + "label": "Redaguoti Išdėstymą", + "group": { + "label": "Koreguoti Kamerų Grupę" + }, + "exitEdit": "Palikti Redagavimą" } } diff --git a/web/public/locales/lt/views/search.json b/web/public/locales/lt/views/search.json index d970b3d2d0..054efd004b 100644 --- a/web/public/locales/lt/views/search.json +++ b/web/public/locales/lt/views/search.json @@ -12,7 +12,61 @@ "trackedObjectId": "Sekamo Objekto ID", "filter": { "label": { - "cameras": "Kameros" + "cameras": "Kameros", + "labels": "Etiketės", + "zones": "Zonos", + "search_type": "Paieškos Tipas", + "time_range": "Laiko rėžis", + "before": "Prieš", + "after": "Po", + "min_score": "Min Balas", + "max_score": "Max Balas", + "min_speed": "Min Greitis", + "max_speed": "Max Greitis", + "recognized_license_plate": "Atpažinti Registracijos Numeriai", + "has_clip": "Turi Klipą", + "has_snapshot": "Turi Nuotrauką", + "sub_labels": "Sub Etiketės" + }, + "searchType": { + "thumbnail": "Miniatiūra", + "description": "Aprašymas" + }, + "toast": { + "error": { + "beforeDateBeLaterAfter": "Data 'prieš' turi būti vėliau nei data 'po'.", + "afterDatebeEarlierBefore": "Data 'po' turi būti anksčiau nei data 'prieš'.", + "minScoreMustBeLessOrEqualMaxScore": "'min balas' turi būti mažesnis arba lygus 'max balui'.", + "maxScoreMustBeGreaterOrEqualMinScore": "'max balas' turi būti didesnis arba lygus 'min balui'.", + "minSpeedMustBeLessOrEqualMaxSpeed": "'min greitis' privalo būti mažesnis arba lygus 'max greičiui'.", + "maxSpeedMustBeGreaterOrEqualMinSpeed": "'max greitis' privalo būti didesnis arba lygus 'min greičiui'." + } + }, + "tips": { + "title": "Kaip naudoti tekstinius filtrus", + "desc": { + "text": "Filtrai leidžia susiaurinti paieškos rezultatus. Štai kaip juos naudoti įvesties laukelyje:", + "step1": "Įveskite filtravimo raktą po kurio seks dvitaškis (pvz., \"cameras:\").", + "step2": "Pasirinkite reikšmę iš siūlomų arba įveskite savo sugalvotą.", + "step3": "Naudokite kelis filtrus įvesdami juos vieną paskui kitą su tarpu tarp jų.", + "step5": "Laiko rėžio filtro naudojamas {{exampleTime}} formatas.", + "step6": "Pašalinti filtrus spaudžiant 'x' šalia jų.", + "exampleLabel": "Pavyzdys:", + "step4": "Datų filtrai (before: and after:) naudoti {{DateFormat}} formatą." + } + }, + "header": { + "currentFilterType": "Filtruoti Reikšmes", + "noFilters": "Filtrai", + "activeFilters": "Aktyvūs Filtrai" } + }, + "similaritySearch": { + "title": "Panašumų Paieška", + "active": "Panašumų paieška aktyvi", + "clear": "Išvalyti panašumų paiešką" + }, + "placeholder": { + "search": "Ieškoma…" } } diff --git a/web/public/locales/lt/views/settings.json b/web/public/locales/lt/views/settings.json index 15a9e53c7e..bef8b85193 100644 --- a/web/public/locales/lt/views/settings.json +++ b/web/public/locales/lt/views/settings.json @@ -7,6 +7,812 @@ "general": "Bendrieji Nustatymai - Frigate", "frigatePlus": "Frigate+ Nustatymai - Frigate", "notifications": "Pranešimų Nustatymai - Frigate", - "motionTuner": "Judesio Derinimas - Frigate" + "motionTuner": "Judesio Derinimas - Frigate", + "enrichments": "Patobulinimų Nustatymai - Frigate", + "masksAndZones": "Maskavimo ir Zonų redaktorius - Frigate" + }, + "menu": { + "ui": "UI", + "enrichments": "Patobulinimai", + "cameras": "Kameros Nustatymai", + "masksAndZones": "Maskavimai / Zonos", + "motionTuner": "Judesio Derintojas", + "debug": "Derinimas", + "users": "Vartotojai", + "notifications": "Pranešimai", + "frigateplus": "Frigate+", + "triggers": "Trigeriai" + }, + "dialog": { + "unsavedChanges": { + "title": "Yra neišsaugotų pakeitimų.", + "desc": "Ar norite išsaugoti savo pakeitimus prieš tęsdami?" + } + }, + "cameraSetting": { + "camera": "Kamera", + "noCamera": "Nėra Kameros" + }, + "general": { + "title": "Bendri Nustatymai", + "liveDashboard": { + "title": "Tiesioginės Transliacijos Skydelis", + "automaticLiveView": { + "label": "Automatinis Tiesioginis Vaizdas", + "desc": "Automatiškai perjungti į kameros tiesioginį vaizdą kai aptinkama veikla. Išjungus šią funkciją tiesioginės transliacijos skydelyje kamerų vaizdai atsinaujis tik kartą per minutę." + }, + "playAlertVideos": { + "label": "Leist Įspejimų Vaizdus", + "desc": "Pagal nutylėjimą, paskutinieji įspėjimai rodomį kaip maži cikliški vaizdo įrašai. Šią funkciją išjunkite jei norite matyti statinius įspėjimų paveiksliukus šiame įrenginyje/naršyklėje." + } + }, + "storedLayouts": { + "title": "Išsaugoti Išdėstymai", + "desc": "Kamerų išdėstymai kamerų grupėje gali būti perkeliami/keičiami dydžiai. Pozicijos išsaugomos jūsų naršyklės vietinėje atmintyje.", + "clearAll": "Išvalyti Visus Išdėstymus" + }, + "cameraGroupStreaming": { + "title": "Kamerų Grupės Transliacijos Nustatymai", + "desc": "Transliacijos nustatymai kiekvienai kamerų grupei yra saugomi jūsų naršyklės vietinėje atmintyje.", + "clearAll": "Išvalyti Visus Transliavimo Nustatymus" + }, + "recordingsViewer": { + "title": "Įrašų Naršyklė", + "defaultPlaybackRate": { + "label": "Numatytasis Atkūrimo Dažnis", + "desc": "Numatytas atkūrimo dažnis įrašų atkūrimui." + } + }, + "calendar": { + "title": "Kalendorius", + "firstWeekday": { + "label": "Pirma Savaitės Diena", + "desc": "Diena kuria prasideda savaitės peržiūrų kalendoriuje.", + "sunday": "Sekmadienis", + "monday": "Pirmadienis" + } + }, + "toast": { + "success": { + "clearStoredLayout": "Saugoti išdėstimai kamerai{{cameraName}} išvalyti", + "clearStreamingSettings": "Visų kamerų grupių transliavimo nustatymai išvalyti." + }, + "error": { + "clearStoredLayoutFailed": "Nepavyko išvalyti išsaugotų pozicijų išdėstymų: {{errorMessage}}", + "clearStreamingSettingsFailed": "Nepavyko išvalyti transliavimo nustatymų: {{errorMessage}}" + } + } + }, + "enrichments": { + "title": "Pagerinimų Nustatymai", + "unsavedChanges": "Neišsaugoti Pagerinimų nustatymų pakeitimai", + "birdClassification": { + "title": "Paukščių Klasifikatorius", + "desc": "Paukščių klasifikatorius identifikuoja žinomus paukščius naudojant kvantinizuotą Tensorflow modelį. Kai žinomas paukštis atpažįstamas, jo bendrinis pavadinimas bus pridėtas prie sub_etikečių. Ši informacija yra pridedama vartotojo sąsajoje, filtruose, taip pat ir pranešimuose." + }, + "semanticSearch": { + "title": "Semantic Paieška", + "desc": "Frigate Semantic Paieška leidžia jums atrasti sekamus objektus tarp peržiūrų, naudojant arba pačius paveiksliuks, vartotojo pateiktus tekstinius aprašymus arba automatiškai sugeneruotas reikšmes.", + "reindexNow": { + "label": "Perindeksuoti Dabar", + "desc": "Perindeksavimas sugeneruos įterpinius visiems sekamiems objektams. Šis procesas veiks fone ir priklausomai nuo jūsų turimo sekamų objektų kiekio gali maksimaliai apkrauti jūsų CPU bei užtrukti nemažai laiko.", + "confirmTitle": "Patvirtinti Reindeksavimą", + "confirmDesc": "Ar esate įsitikinę, kad norite reindeksuoti visų sekamų objektų įterpius? Šis processas veiks fone ir gali maksimaliai apkrauti jūsų CPU bei užtrukti nemažai laiko. Progresą jūs galėsite stebėti Tyrinėjimo puslapyje.", + "confirmButton": "Reindeksuoti", + "success": "Reindeksavimas pradėtas sėkmingai.", + "alreadyInProgress": "Redindeksavimas jau vykdomas.", + "error": "Nepavyko pradėti reindeksavimo: {{errorMessage}}" + }, + "modelSize": { + "label": "Modelio Dydis", + "desc": "Modelio dydis naudojamas semantic paieškos įterpiuose.", + "small": { + "title": "mažas", + "desc": "Naudojant mažą pasitelkiama kvantizuota modelio versija kuri reikalauja mažiau RAM, naudojant CPU veikia greičiau su nežįmiu skirtumu įterpių kokybei." + }, + "large": { + "title": "didelis", + "desc": "Naudojant didelį pasitelkiamas pilnas Jina modelis ir automatiškai naudos GPU jei yra galimas." + } + } + }, + "faceRecognition": { + "title": "Veidų Atpažinimas", + "desc": "Veidų atpažinimas leidžia priskirti vardus žmonėms ir kai jų veidai atpažįstami Frigate priskirs žmogaus vardą kaip sub etiketę. Ši informacija prieinama vartotojo sąsajoje, filtruose, taip ir pranešimuose.", + "modelSize": { + "label": "Modelio Dydis", + "desc": "Modelio dydis naudojamas veidų atpažinimui.", + "small": { + "title": "mažas", + "desc": "Naudojant mažą pasitelkiamas FaceNet veidų įterpių modelis kuris efektyviai veikia su daugeliu CPU." + }, + "large": { + "title": "didelis", + "desc": "Naudojant didelį pasitelkiamas ArcFace face embedding modelis ir jei yra galimybė automatiškai naudos GPU." + } + } + }, + "licensePlateRecognition": { + "title": "Registracijos Numerių Atpažinimas", + "desc": "Frigate gali atpažinti automobilių registracijos numerius ir automatiškai pridėti aptikitus simbolius į \"recognized_license_plate\" laukelį arba žinoma pavadinima kaip sub_etiketę \"mašina\" tipo objektams. Dažnas panaudojimas būtų nuskaityti numerius mašinų įvažiuojančių į įvažiavimą arba mašinų pravažiuojančių gatve." + }, + "restart_required": "Privaloma perkrauti (Patobulinimų nustatymai pakeisti)", + "toast": { + "success": "Patobulinimų nustaty buvo pakeisti. Kad pkyčiai būtų pritaikyti perkraukite Frigate.", + "error": "Nepavyko išsaugoti konfiguracijos pakeitimų: {{errorMessage}}" + } + }, + "camera": { + "title": "Kamerų Nustatymai", + "streams": { + "title": "Transliacijos", + "desc": "Laikinai išjunkite kamerą kol Frigate bus perkrautas. Išjungiant kamerą visiškai sustabdo Frigate veiklą šiai kamerai. Nebus aptikimų, įrašų ar derinimų.
    Pastaba: Tai neišjungs go2rtc sratų." + }, + "review": { + "desc": "Trumpam įjungti/išjungti įspėjimus ir aptikimus šiai kamerai iki kol Frigate bus perkrautas. Kai išjungta, naujos peržiūros nebus kuriamos. ", + "detections": "Aptikimai ", + "title": "Peržiūra", + "alerts": "Įspėjimai " + }, + "reviewClassification": { + "desc": "Frigate kategorizuoja peržiūras į Įspėjimus ir Aptikimus. Pagal nutylėjimą, visi žmonių ir mašinų objektai yra vertinami kaip įspėjimai. Jūs galite detalizuoti peržiūrų kategorizavimą priskiriant objektas privalomas zonas.", + "zoneObjectAlertsTips": "Visi {{alertsLabels}} objektai aptikti {{zone}} ir {{cameraName}} bus rodomi kaip Įspėjimai.", + "objectDetectionsTips": "Visi {{detectionsLabels}} objektai nekategorizuoti {{cameraName}} bus rodomi kaip Aptikimai nepriklausomai kurioje zonoje ji yra.", + "zoneObjectDetectionsTips": { + "text": "Visi {{detectionsLabels}} objektai nekategorizuoti {{zone}} ir {{cameraName}} bus rodomi kaip Aptikimai.", + "notSelectDetections": "Visi {{detectionsLabels}} objektai aptikti {{zone}} ir {{cameraName}} nekategorizuojami kaip Įspėjimai bus rodomi kaip Aptikimai nepriklausomai kurioje zonoje ji yra.", + "regardlessOfZoneObjectDetectionsTips": "Visi {{detectionsLabels}} objektai nekategorizuoti {{cameraName}} bus rodomi kaip Aptikimai nepriklausomai kurioje zonoje ji yra." + }, + "selectDetectionsZones": "Pasirinkti zonas Aptikimams", + "limitDetections": "Apriboti aptikimus specifinėms zonoms", + "title": "Apžvalgų Kalsifikavimas", + "noDefinedZones": "Šiai kamerai sukurtų zonų nėra.", + "objectAlertsTips": "Visi {{alertsLabels}} objektai kemeroje {{cameraName}} bus rodomi kaip Įspėjimai.", + "unsavedChanges": "Neišsaugoti Apžvalgos Klasifikavimo nustatymai kamerai {{camera}}", + "selectAlertsZones": "Pasirinkti zonas Įspėjimams", + "toast": { + "success": "Apžvalgų Klasifikavimo konfiguracija buvo išsaugota. Restartuoti Frigate kad pokyčiai būtų pritaikyti." + } + }, + "cameraConfig": { + "ffmpeg": { + "rolesUnique": "Kiekviena role (garso, aptikimo, įrašymo) gali buti priskirta tik vienam srautui", + "inputs": "Įvesties Srautas", + "path": "Srauto Kelias", + "pathRequired": "Srauto Kelias yra privalomas", + "pathPlaceholder": "rtsp://...", + "roles": "Rolės", + "rolesRequired": "Privaloma bent viena rolė", + "addInput": "Pridėti Įvesties Srautą", + "removeInput": "Pašalinti Įvesties Srautą", + "inputsRequired": "Privalomas bent vienas įvesties srautas" + }, + "add": "Pridėti Kamerą", + "edit": "Koreguoti Kamerą", + "description": "Konfiguruoti kameros nustatymus įskaitant įvesties srautus ir roles.", + "name": "Kamera Pavadinimas", + "nameRequired": "Kamera pavadinimas yra privalomas", + "nameLength": "Kamera pavadininas privalo būti trumpesnis nei 24 simboliai.", + "namePlaceholder": "pvz., priekinės_durys", + "enabled": "Šjungti", + "toast": { + "success": "Kamera {{cameraName}} sėkmingai išsaugota" + } + }, + "object_descriptions": { + "title": "Generatyvinio DI Objektų Aprašymai", + "desc": "Laikinai įjungti/ išjungti Ganaratyvinio DI objektų aprašymus šiai kamerai. Kai išjungta, šios kameros sekamiems objektams nebus generuojami aprašymai." + }, + "review_descriptions": { + "title": "Generatyvinio DI Apžvalgų Aprašymai", + "desc": "Laikinai įjungti/ išjungti Ganaratyvinio DI apžvalgų aprašymus šiai kamerai. Kai išjungta, šios kameros apžvalgoms nebus generuojami aprašymai." + }, + "addCamera": "Pridėti Naują Kamerą", + "editCamera": "Koreguoti Kamerą:", + "selectCamera": "Pasirinkti Kamera", + "backToSettings": "Atgal į Kameros Nustatymus" + }, + "masksAndZones": { + "zones": { + "point_one": "{{count}} taškas", + "point_few": "{{count}} taškai", + "point_other": "{{count}} taškų", + "label": "Zonos", + "documentTitle": "Redaguoti Zonas - Frigate", + "desc": { + "title": "Zonos leidžia apibrėžti specifinį kadro plotą tam, kad galėtumėte įvardinti ar objektas yra tam tikrame plote.", + "documentation": "Dokumentacija" + }, + "add": "Pridėti Zoną", + "edit": "Redaguoti Zoną", + "clickDrawPolygon": "Spragtelkite ant paveiksliuko kad pradėtumėte piešti poligoną.", + "name": { + "title": "Pavadinimas", + "inputPlaceHolder": "Įveskite pavadinimą …", + "tips": "Pavadinimas privalo būti bent 2 simboliai ir negali būti toks pat kaip kita kamera ar zona." + }, + "inertia": { + "title": "Inercija", + "desc": "Nurodo kiek kadrų objektas turi būti zonoje, kad užskaitytu kaip esantį zonoje. Bazinis: 3" + }, + "loiteringTime": { + "title": "Delsos Laikas", + "desc": "Nurodo minimalų laiką sekundėmis, kurį objektas turi būti zonoje, kad aktyvuotūsi. Bazinis: 0" + }, + "objects": { + "title": "Objektai", + "desc": "Objektų sąrašas kurie taikomi šiai zonai." + }, + "allObjects": "Visi Objektai", + "speedEstimation": { + "title": "Greičio Vertinimas", + "desc": "Įjungti greičio vertinimą objektams šioje zonoje. Zona privalo turėti būtent 4 taškus.", + "lineADistance": "Linijos A atstumas ({{unit}})", + "lineBDistance": "Linijos B atstumas ({{unit}})", + "lineCDistance": "Linijos C atstumas ({{unit}})", + "lineDDistance": "Linijos D atstumas ({{unit}})" + }, + "speedThreshold": { + "title": "Greičio Riba ({{unit}})", + "desc": "Nurodo mnimalų objekto greitį, kad užskaityti esantį zonoje.", + "toast": { + "error": { + "pointLengthError": "Greičio vertinimas buvo išjungtas šiai zonai. Zonos su greičio vertinimu privalo turėti tiksliai 4 taškus.", + "loiteringTimeError": "Zonos su delsos laiku didesniu nei 0 turėtų būti nenaudojamos su greičio vertinimu." + } + } + }, + "toast": { + "success": "Zona ({{zoneName}}) buvo išsaugota. Perkrauti Frigate kad įgalinti pokyčius." + } + }, + "motionMasks": { + "point_one": "{{count}} taškas", + "point_few": "{{count}} taškai", + "point_other": "{{count}} taškų", + "desc": { + "title": "Judesių maskavimai yra naudojami sumažinti aptikimų užklausoms dėl nepageidajamų judesių. Objektų sekimas taps kėblesnis jei maskuosite per daug.", + "documentation": "Dokumentacija" + }, + "context": { + "title": "Judesių maskavimai yra naudojami sumažinti aptikimų užklausoms dėl nepageidajamų judesių (pvz: medžių šakos, kameros laiko užrašas). Judesių maskavimas turi būti naudojamas labai saikingai, bjektų sekimas taps kėblesnis jei maskuosite per daug." + }, + "polygonAreaTooLarge": { + "tips": "Judesių maskavimas netrukdo objektų aptikimui. Vietoj to turėtumėte naudoti privalomas zonas.", + "title": "Judesio maskuoti dengia {{polygonArea}}% kameros ploto. Didelės judesio maskuotės nerekomenduojamos." + }, + "label": "Judesio Maskuotė", + "documentTitle": "Redaguoti Judesio Maskuotę - Frigate", + "add": "Nauja Judesio Maskuotė", + "edit": "Redaguoti Judesio Maskuotę", + "clickDrawPolygon": "Spragtelti kad piešti poligoną ant atvaizdo.", + "toast": { + "success": { + "title": "{{polygonName}} išsaugotas. Perkrauti Frigate, kad pritaikyti pokyčius.", + "noName": "Judesio Maskuotė buvo išsaugota. Perkrauti Frigate, kad pritaikyti pokyčius." + } + } + }, + "objectMasks": { + "point_one": "{{count}} taškas", + "point_few": "{{count}} taškai", + "point_other": "{{count}} taškų", + "label": "Objekto Maskuotė", + "documentTitle": "Redaguoti Objekto Maskuotę - Frigate", + "desc": { + "title": "Objektų filtravimo maskuotės naudojamos išfiltruoti klaidingus teigiamus rezultatus pagal vietą parinktam objekto tipui.", + "documentation": "Dokumentacija" + }, + "add": "Pridėti Objekto Maskuotę", + "edit": "Redaguoti Objekto Maskuotę", + "context": "Objektų filtravimo maskuotės naudojamos išfiltruoti klaidingus teigiamus rezultatus pagal vietą parinktam objekto tipui.", + "clickDrawPolygon": "Spragtelti kad piešti poligoną ant atvaizdo.", + "objects": { + "title": "Objektai", + "desc": "Objekto tipai, kurie taikomi šiai objekto maskuotei.", + "allObjectTypes": "Visi objektų tipai" + }, + "toast": { + "success": { + "title": "{{polygonName}} buvo išsaugotas. Perkrauti Frigate, kad pritaikyti pokyčius.", + "noName": "Objektų Maskuotė buvo išsaugota. Perkrauti Frigate, kad pritaikyti pokyčius." + } + } + }, + "filter": { + "all": "Visos Maskuotės ir Zonos" + }, + "restart_required": "Reikalingas perkrovimas (maskavimai/ zonos pakeisti)", + "toast": { + "success": { + "copyCoordinates": "Poligono {{polyName}} koordinatės nukopijuotos į iškarpinę." + }, + "error": { + "copyCoordinatesFailed": "Nepavyko koordinačių nukopijuoti į iškarpinę." + } + }, + "motionMaskLabel": "Judesio Maskuotė {{number}}", + "objectMaskLabel": "Obejkto Maskuotė {{number}} {{label}}", + "form": { + "zoneName": { + "error": { + "mustBeAtLeastTwoCharacters": "Zonos pavadinime turi būti bent 2 simboliai.", + "mustNotBeSameWithCamera": "Zonos pavadinimas privalo skirtis nuo kameros pavadinimo.", + "alreadyExists": "Šiai kamerai zona šiuo pavadinimu jau egzistuoja.", + "mustNotContainPeriod": "Zonos pavadinimas negali turėti taško.", + "hasIllegalCharacter": "Zonos pavadinime yra neleistinų simbolių." + } + }, + "distance": { + "error": { + "text": "Atstumas privalo būti didesnis arba lygu 0.1.", + "mustBeFilled": "Norint naudoti greičio nustatymą visi atstumų laukai privalo būti užpildyti." + } + }, + "inertia": { + "error": { + "mustBeAboveZero": "Incerciją privalo būti virš 0." + } + }, + "loiteringTime": { + "error": { + "mustBeGreaterOrEqualZero": "Delsos laikas privalo būti didesnis arba lygus 0." + } + }, + "speed": { + "error": { + "mustBeGreaterOrEqualTo": "Greičio riba privalo būti didesnė arba lygi 0.1." + } + }, + "polygonDrawing": { + "removeLastPoint": "Pašalinti paskutinį tašką", + "reset": { + "label": "Išvalyti visus taškus" + }, + "snapPoints": { + "true": "Prikabinti taškus", + "false": "Neprikabinti taškų" + }, + "delete": { + "title": "Patvirtinti Trynimą", + "desc": "Ar esate įsitikinę, kad norite ištrinti {{type}} {{name}}?", + "success": "{{name}} buvo ištrintas." + }, + "error": { + "mustBeFinished": "Poligono brėžinys privalo būti užbaigtas prieš išsaugant." + } + } + } + }, + "motionDetectionTuner": { + "title": "Judesių Aptikimų Derinimas", + "unsavedChanges": "Neišsaugoti Judesių Derinimo pokyčiai ({{camera}})", + "desc": { + "title": "Frigate naudoja judesių aptikimą kaip pirmos eilės patikrinimą įvertinti ar yra kadre kažkas, kam verta būtų atlikti objektų atpažinimą.", + "documentation": "Skaityti Judesių Derinimo Gidą" + }, + "Threshold": { + "title": "Riba", + "desc": "Ribos reikšmė diktuoja kiek pokyčio pikselio apšvietime turi būti, kad būtų traktuojama kaip judesys. Bazinis: 30" + }, + "contourArea": { + "title": "Kontūro Plotas", + "desc": "Kontūro ploto reikšmė yra naudojama įvertinti kurios grupės pasikeitusių pikselių bus vertinami kaip judesys. Bazinis: 10" + }, + "improveContrast": { + "title": "Pagerinti Kontrastą", + "desc": "Pagerinti kontrastą tamsiose scenose. Bazinis: Įjungta" + }, + "toast": { + "success": "Judesių nustatymai buvo išsaugoti." + } + }, + "debug": { + "detectorDesc": "Frigate naudoja jūsų detektorius ({{detectors}}) objektų aptikimui jūsų kameros transliacijoje.", + "audio": { + "noAudioDetections": "Nėra Garso aptikimų", + "title": "Garsas", + "score": "balai", + "currentRMS": "Dabartinis RMS", + "currentdbFS": "Dabartinis dbFS" + }, + "title": "Derinti", + "desc": "Derinimo vaizde rodomas tiesioginis vaizdas sekamų objektų ir statistikos. Objektų sąrašas rodo užvėlintą santrauką aptiktų objektų.", + "openCameraWebUI": "Atverti {{camera}} kameros Web prieigą", + "debugging": "Derinama", + "objectList": "Objektų sąrašas", + "noObjects": "Objektų nėra", + "boundingBoxes": { + "title": "Apibrėžiančios dėžutės", + "desc": "Rodyti apibrėžiančias dėžutes aplink sekamus objektus", + "colors": { + "label": "Objektus Apibrėžiančių Dėžučių Spalvos", + "info": "
  • Pradžioje, skirtingos spalvos bus priskirtos kiekvienai objekto etiketei
  • Tamsiai mėlyna plona linija simbolizuoja, kad objektas esamu momentu dar nėra aptiktas
  • Pilka linija nurodo kad objektas yra aptiktas kaip nejudantis
  • Stora linija nurodo kad objektas yra automatiškai sekamas (kai įjungta)
  • " + } + }, + "timestamp": { + "title": "Laiko Žyma", + "desc": "Atvaizduoti laiko žymą vaizde" + }, + "zones": { + "title": "Zonos", + "desc": "Rodyti bet kurios zonos ribas" + }, + "mask": { + "title": "Judesio maskuotės", + "desc": "Rodyti judesio maskavimo poligonus" + }, + "motion": { + "title": "Judesio dėžutės", + "desc": "Rodyti dėžutes aplink vietas kur yra aptiktas judesys", + "tips": "

    Judesio Dėžutės


    Raudonos dėžutės bus kadro vietose kur judesys yra aptiktas

    " + }, + "regions": { + "title": "Regionai", + "desc": "Rodyti dėžutes regionų kurie yra parduoti į detektorių", + "tips": "

    Regionų Dėžutės


    Ryškiai žalios dėžutės atvaizduojamos vietose kurios yra perduotos objektų detektoriui.

    " + }, + "paths": { + "title": "Keliai", + "desc": "Rodyti sekamo objekto kelio išskirtinius taškus", + "tips": "

    Keliai


    Linijos ir apskritimai nurodo sekamo objekto judėjimo išskirtinius taškus

    " + }, + "objectShapeFilterDrawing": { + "title": "Filtrų Brėžiniai Objektų Formoms", + "desc": "Norėdami sužinoti atvaizdo plotą ir santykio detales nubrėžkite keturkampį ant atvaizdo", + "score": "Balai", + "ratio": "Santykis", + "area": "Plotas", + "tips": "Įjunkite šią funkciją nupiešti keturkampį ant kameros vaizdo, kad parodyti plotą ir santykį. Šios reikšmės gali būti naudojamos objekto formos filtro parametrams jūsų konfiguracijoje." + } + }, + "users": { + "dialog": { + "deleteUser": { + "warn": "Ar esate įsitikinę, kad norite ištrinti {{username}}?", + "title": "Ištrinti Vartotoją", + "desc": "Šis veiksmas negalės būti atkurtas. Tai visam laikui ištrins vartotojo paskyrą ir ištrins visą susijusią informaciją." + }, + "form": { + "user": { + "title": "Vartotojo vardas", + "desc": "Leidžiamos tik raidės, skaičiai, taškai ir pabraukimai.", + "placeholder": "Įvesti vartotojo vardą" + }, + "password": { + "title": "Slaptažodis", + "placeholder": "Įvesti slaptažodį", + "confirm": { + "title": "Patvirtinti Slaptažodį", + "placeholder": "Patvirtinti Slaptažodį" + }, + "strength": { + "title": "Slaptažodžio sudėtingumas: ", + "weak": "Silpnas", + "medium": "Vidutinis", + "strong": "Stiprus", + "veryStrong": "Labai Stiprus" + }, + "match": "Slaptažodžiai sutampa", + "notMatch": "Slaptažodžiai nesutampa" + }, + "newPassword": { + "title": "Naujas Slaptažodis", + "placeholder": "Įveskite naują slaptažodį", + "confirm": { + "placeholder": "Pakartokite naują slaptažodį" + } + }, + "usernameIsRequired": "Vartotojo vardas yra privalomas", + "passwordIsRequired": "Slaptažodis yra privalomas" + }, + "createUser": { + "title": "Sukurti Naują Vartotoją", + "desc": "Pridėti naują vartotojo paskyrą ir nurodyti prieigos roles prie Frigate funkcijų.", + "usernameOnlyInclude": "Vartotojo vardas gali būti sudarytas iš raidžių, skaičių, . arba _", + "confirmPassword": "Prašome patvirtinti slaptažodį" + }, + "passwordSetting": { + "cannotBeEmpty": "Slaptažodis negali būti tuščias", + "doNotMatch": "Slaptažodžiai nesutampa", + "updatePassword": "Atnaujinkite Spaltažodį vartotojui {{username}}", + "setPassword": "Sukurti Slaptažodį", + "desc": "Sukurti stiprų slaptažodį kad apsaugoti paskyrą." + }, + "changeRole": { + "title": "Pakeisti vartotojo Rolę", + "select": "Pasirinkti rolę", + "desc": "Atnaujinti leidimus vartotojui {{username}}", + "roleInfo": { + "intro": "Pasirinkti tinkama rolę šiam vartotojui:", + "admin": "Admin", + "adminDesc": "Pilna prieiga prie visų funkcijų.", + "viewer": "Žiūrovas", + "viewerDesc": "Leidžiama prie Tiesioginio vaizdo tinklelio, Peržiūrų, Paieškų ir Eksportavimo funkcijų.", + "customDesc": "Specializuota rolė su prieiga prie konkrečios kameros." + } + } + }, + "title": "Vartotojai", + "management": { + "title": "Vartotojų Valdymas", + "desc": "Valdyti šios Frigate aplinkos vartotojų paskyras." + }, + "addUser": "Pridėti Vartotoją", + "updatePassword": "Atnaujinti Slaptažodį", + "toast": { + "success": { + "createUser": "Vartotojas {{user}} sėkmingai sukurtas", + "deleteUser": "Vartotojas {{user}} sėkmingai ištrintas", + "updatePassword": "Slaptažodis atnaujintas sėkmingai.", + "roleUpdated": "Vartotojui {{user}} rolė sėkmingai atnaujinta" + }, + "error": { + "setPasswordFailed": "nepavyko išsaugoti slaptažodžio: {{errorMessage}}", + "createUserFailed": "Nepavyko sukurti vartotojo: {{errorMessage}}", + "deleteUserFailed": "Nepavyko ištrinti vartotojo: {{errorMessage}}", + "roleUpdateFailed": "Nepavyko atnaujinti rolės: {{errorMessage}}" + } + }, + "table": { + "username": "Vartotojo vardas", + "actions": "Veiksmai", + "role": "Rolė", + "noUsers": "Vartotojų nerasta.", + "changeRole": "Pakeisti vartotojo rolę", + "password": "Slaptažodis", + "deleteUser": "Ištrinti vartotoją" + } + }, + "triggers": { + "dialog": { + "deleteTrigger": { + "desc": "Ar esate įsitikinę, kad norite ištrinti trigerį {{triggerName}}? Šis veiksmas negalės būti atstatytas.", + "title": "Ištrinti Trigerį" + }, + "createTrigger": { + "title": "Sukurti Trigerį", + "desc": "Sukurti trigerį kamerai {{camera}}" + }, + "editTrigger": { + "title": "Koreguoti Trigerį", + "desc": "Koreguoti trigerio nustatymus kamerai {{camera}}" + }, + "form": { + "name": { + "title": "Pavadinimas", + "placeholder": "Įvesti trigerio pavadinimą", + "error": { + "minLength": "Pavadinimas turi būti bent dviejų simbolių ilgio.", + "invalidCharacters": "Pavadinime gali būti tik raidės, skaičiai, pabraukimai ir brūkšnelis.", + "alreadyExists": "Trigeris su tokiu vardu jau yra šiai kamerai." + } + }, + "enabled": { + "description": "Įjungti ar išjungti šį trigerį" + }, + "type": { + "title": "Tipas", + "placeholder": "Pasirinkti trigerio tipą" + }, + "content": { + "title": "Turinys", + "imagePlaceholder": "Pasirinkti paveikslėlį", + "textPlaceholder": "Įvesti teksto turinį", + "imageDesc": "Pasirinkite paveikslėli kad inicijuotumėte veiksmą kai panašus vaizdas bus aptiktas.", + "textDesc": "Įveskite tekstą kad inicijuotumėte veiksmą kai panašus sekamo objekto aprašymas bus aptiktas.", + "error": { + "required": "Turinys privalomas." + } + }, + "threshold": { + "title": "Riba", + "error": { + "min": "Riba privalo būti bent jau 0", + "max": "Riba privalo būti daugiausiai 1" + } + }, + "actions": { + "title": "Veiksmai", + "desc": "Pagal nutylėjimą, Frigate sukuria MQTT žinutę visiem trigeriams. Pasirinkite kokius papildomus veiksmus atlikti kai trigeris suveiks.", + "error": { + "min": "Bent vienas veiksmas privalo būti parinktas." + } + } + } + }, + "documentTitle": "Trigeriai", + "management": { + "title": "Trigerių Valdymas", + "desc": "Valdykite trigerius kamerai {{camera}}. Naudokite miniatiūros tipą, kad panašios miniatiūros būtų jūsų pasirinkto objekto trigeris, o aprašymo trigerį kad panašūs aprašymai būtų trigeris pagal jūsų parašytą tekstą." + }, + "addTrigger": "Pridėti Trigerį", + "table": { + "name": "Pavadinimas", + "type": "Tipas", + "content": "Turinys", + "threshold": "Riba", + "actions": "Veiksmai", + "noTriggers": "Šiai kamerai nėra sukonfiguruotų trigerių.", + "edit": "Koreguoti", + "deleteTrigger": "Trinti Trigerį", + "lastTriggered": "Paskutinį kartą suveikė" + }, + "type": { + "thumbnail": "Miniatiūra", + "description": "Aprašymas" + }, + "actions": { + "alert": "Pažymėti kaip įspėjimą", + "notification": "Siųsti Pranešimą" + }, + "toast": { + "success": { + "createTrigger": "Trigeris {{name}} sėkmingai sukurtas.", + "updateTrigger": "Trigeris {{name}} sėkmingai atnaujintas.", + "deleteTrigger": "Trigeris {{name}} sėkmingai ištrintas." + }, + "error": { + "createTriggerFailed": "Nepavyko sukurti trigerio: {{errorMessage}}", + "updateTriggerFailed": "Nepavyko atnaujinti trigerio: {{errorMessage}}", + "deleteTriggerFailed": "Nepavyko ištrinti trigerio: {{errorMessage}}" + } + } + }, + "notification": { + "title": "Pranešimai", + "notificationSettings": { + "title": "Pranešimų Nustatymai", + "desc": "Frigate praneįimai sukurti veikti su push pranešimais į įrenginį kai naršoma per naršyklę arba įdiegta kaip PWA." + }, + "notificationUnavailable": { + "title": "Pranešimai Negalimi", + "desc": "Web push pranešimai reikalauja saugios aplinkos (https://...). Tai yra naršyklės apribojimai. Atsidarykit Frigate saugiu kanalu kad galėtumėte naudotis pranešimais." + }, + "globalSettings": { + "title": "Visuotiniai Nustatymai", + "desc": "Laikinai sustabdyti pranešimus iš konkrečios kameros į registruotus įrenginius." + }, + "email": { + "title": "El.paštas", + "placeholder": "pvz.: laiskas@laiskas.com", + "desc": "El paštas turi būti veikiantis ir jums prieinamas, kad jus pasiektų informacija jei bus problemų su push pranešimų paslauga." + }, + "cameras": { + "title": "Kameros", + "noCameras": "Nėra kamerų", + "desc": "Pasirinkite kurioms kameroms norite įjungti pranešimus." + }, + "deviceSpecific": "Įrenginio Specifiniai Nustatymai", + "registerDevice": "Registruoti Šį Įrenginį", + "unregisterDevice": "Išregistruoti Šį Įrenginį", + "sendTestNotification": "Siųsti bandomąjį pranešimą", + "unsavedRegistrations": "Neišsaugotos Pranešimo registracijos", + "unsavedChanges": "Neišsaugoti Pranešimų pakeitimai", + "active": "Aktyvūs Pranešimai", + "suspended": "Pranešimai sustabdyti {{time}}", + "suspendTime": { + "suspend": "Sustabdyti", + "5minutes": "Sustabdyti 5 minutėms", + "10minutes": "Sustabdyti 10 minučių", + "30minutes": "Sustabdyti 30 minučių", + "1hour": "Sustabdyti 1 valandai", + "12hours": "Sustabdyti 12 valandų", + "24hours": "Sustabdyti 24 valandoms", + "untilRestart": "Sustabdyti iki perkrovimo" + }, + "cancelSuspension": "Atšaukti Sustabdymą", + "toast": { + "success": { + "registered": "Sėkmingai užregistruota pranešimams. Frigate perkrovimas yra būtinas, kad nors kokie pranešimai būtų išsiųsti (net ir bandomieji).", + "settingSaved": "Pranešimų nustatymai buvo išsaugoti." + }, + "error": { + "registerFailed": "Nepavyko išsaugoti pranešimų registravimo." + } + } + }, + "frigatePlus": { + "title": "Frigate+ Nustatymai", + "apiKey": { + "title": "Frigate+ API raktas", + "validated": "Frigate+ API raktas aptiktas ir patvirtintas", + "notValidated": "Frigate+ API raktas neaptiktas ir nepatvirtintas", + "desc": "Frigate+ API raktas įgalina integraciją su Frigate+ paslauga.", + "plusLink": "Skaityti daugiau apie Frigate+" + }, + "snapshotConfig": { + "title": "Momentinių kadrų Konfiguravimas", + "desc": "Pateikti į Frigate+ reikalauja abiejų, momentinių kadrų ir švarios_kopijosmomentinių kadrų įjungimo jūsų konfiguracijoje.", + "cleanCopyWarning": "Kai kurios kameros turi momentinius kadrus įjungtus tačiau švari kopija išjungta. Turite įjungti švarią_kopiją savo momentinės kopijos nustatymuose, kad galėtumėte teikti paveikslėlius į Frigate+.", + "table": { + "camera": "Kamera", + "snapshots": "Momentiniai Kadrai", + "cleanCopySnapshots": "Momentiniai kadrai švari_kopija" + } + }, + "modelInfo": { + "title": "Modelio Informacija", + "modelType": "Modelio Tipas", + "trainDate": "Apmokymo Data", + "baseModel": "Bazinis Modelis", + "plusModelType": { + "baseModel": "Bazinis Modelis", + "userModel": "Priderinta" + }, + "supportedDetectors": "Palaikomi Detektoriai", + "cameras": "Kameros", + "loading": "Užkraunama modelio informacija…", + "error": "Nepavyko užkrauti modelio informacijos", + "availableModels": "Pireinami Modeliai", + "loadingAvailableModels": "Kraunami prieinami modeliai…", + "modelSelect": "Jūsų prieinami modeliai iš Frigate+ gali būti pasirinkti čia. Pastaba, pasirinkiti galite modelius tik tuos kurie suderinami su esamu detektoriumi." + }, + "unsavedChanges": "Neišsaugoti Frigate+ nustatymų pokyčiai", + "restart_required": "Perkrovimas privalomas (Frigate+ modeliai pakeisti)", + "toast": { + "success": "Frigate+ nustatymai buvo išsaugoti. Perkraukite Frigate kad pritaikytumėte pokyčius.", + "error": "Nepavyko išsaugoti konfiguraijos pokyčių: {{errorMessage}}" + } + }, + "roles": { + "addRole": "Pridėti rolę", + "table": { + "role": "Rolė", + "cameras": "Kameros", + "actions": "Veiksmai", + "deleteRole": "Pašalinti rolę", + "noRoles": "Specializuotų rolių nerasta.", + "editCameras": "Koreguoti Kameras" + }, + "toast": { + "success": { + "deleteRole": "Rolė {{role}} sėkmingai pašalinta", + "userRolesUpdated": "{{count}} šios rolės vartotojai buvo priskirti rolei 'žiūrovas', kuri turi prieigą prie visų kamerų.", + "createRole": "Rolė {{role}} sėkmingai sukurta", + "updateCameras": "Atnaujintos kameros rolei {{role}}" + }, + "error": { + "createRoleFailed": "Nepavyko sukurti rolės: {{errorMessage}}", + "updateCamerasFailed": "Nepavyko atnaujinti kamerų: {{errorMessage}}", + "deleteRoleFailed": "Nepavyko ištrinti rolės: {{errorMessage}}", + "userUpdateFailed": "Nepavyko atnaujinti vartotojo rolių: {{errorMessage}}" + } + }, + "dialog": { + "deleteRole": { + "title": "Pašalinti rolę", + "deleting": "Šalinama...", + "desc": "Šis veiksmas neatkuriamas. Rolė bus ištrinta, likusiems jos turėtojams bus priskirta 'žiūrovo' rolė, kuri leis vartotojui matyti visas kameras.", + "warn": "Ar esate įsitikinę, kad norite ištrinti {{role}}?" + }, + "form": { + "cameras": { + "title": "Kameros", + "required": "Mažiausiai viena kamera turi būti pažymėta.", + "desc": "Pasirinkinte kamerą prie kurios ši rolė suteiks prieigą. Privaloma nurodyti bent vieną." + }, + "role": { + "title": "Rolės pavadinimas", + "placeholder": "Įveskite rolės pavadinimą", + "roleIsRequired": "Rolės pavadinimas yra privalomas", + "roleExists": "Toks rolės pavadinimas jau egzistuoja.", + "desc": "Ledžiama naudoti tik raides, skaičius, taškus ir pabraukimus.", + "roleOnlyInclude": "Rolės pavadinime gali būti tik raides, skaičius, . ar _" + } + }, + "createRole": { + "title": "Sukurti Naują Rolę", + "desc": "Pridėti naują rolę ir priskirti prieigas prie kamerų." + }, + "editCameras": { + "title": "Koreguoti Rolės Kameras", + "desc": "Atnaujinti prieigą prie kameros rolei {{role}}." + } + }, + "management": { + "title": "Žiūrovo Rolės Valdymas", + "desc": "Valdyti šios Frigate aplinkos specializuotas žiūrovo roles ir kamerų prieigos leidimus." + } } } diff --git a/web/public/locales/lt/views/system.json b/web/public/locales/lt/views/system.json index fb9784cf77..8918ad32da 100644 --- a/web/public/locales/lt/views/system.json +++ b/web/public/locales/lt/views/system.json @@ -7,13 +7,180 @@ "go2rtc": "Go2RTC Žurnalas - Frigate", "nginx": "Nginx Žurnalas - Frigate" }, - "general": "Bendroji Statistika - Frigate" + "general": "Bendroji Statistika - Frigate", + "enrichments": "Pagerinimų Statistika - Frigate" }, "title": "Sistema", "metrics": "Sistemos metrikos", "logs": { "download": { "label": "Parsisiųsti Žurnalą" + }, + "copy": { + "label": "Kopijuoti į iškarpinę", + "success": "Nukopijuoti įrašai į iškarpinę", + "error": "Nepavyko nukopijuoti įrašų į iškarpinę" + }, + "type": { + "label": "Tipas", + "timestamp": "Laiko žymė", + "tag": "Žyma", + "message": "Žinutė" + }, + "tips": "Įrašai yra transliuojami iš serverio", + "toast": { + "error": { + "fetchingLogsFailed": "Klaida nuskaitant įrašus: {{errorMessage}}", + "whileStreamingLogs": "Klaidai transliuojant įrašus: {{errorMessage}}" + } } + }, + "general": { + "title": "Bendrinis", + "detector": { + "title": "Detektoriai", + "inferenceSpeed": "Detektorių darbo greitis", + "temperature": "Detektorių Temperatūra", + "cpuUsage": "Detektorių CPU Naudojimas", + "memoryUsage": "Detektorių Atminties Naudojimas", + "cpuUsageInformation": "CPU vartojimas ruošiant duomenis detektorių modeliams. Ši reikšmė nevertina inference vartojimo, net jei yra naudojamas GPU akseleratorius." + }, + "hardwareInfo": { + "title": "Techninės įrangos Info", + "gpuUsage": "GPU Naudojimas", + "gpuMemory": "GPU Atmintis", + "gpuEncoder": "GPU Kodavimas", + "gpuDecoder": "GPU Dekodavimas", + "gpuInfo": { + "vainfoOutput": { + "title": "Vainfo Išvestis", + "returnCode": "Grįžtamas Kodas: {{code}}", + "processOutput": "Proceso Išvestis:", + "processError": "Proceso Klaida:" + }, + "nvidiaSMIOutput": { + "title": "Nvidia SMI Išvestis", + "name": "Pavadinimas: {{name}}", + "driver": "Tvarkyklė: {{driver}}", + "cudaComputerCapability": "CUDA Compute Galimybės: {{cuda_compute}}", + "vbios": "VBios Info: {{vbios}}" + }, + "closeInfo": { + "label": "Užverti GPU info" + }, + "copyInfo": { + "label": "Kopijuoti GPU Info" + }, + "toast": { + "success": "Nukopijuota GPU info į iškarpinę" + } + }, + "npuUsage": "NPU Naudojimas", + "npuMemory": "NPU Atmintis" + }, + "otherProcesses": { + "title": "Kiti Procesai", + "processCpuUsage": "Procesų CPU Naudojimas", + "processMemoryUsage": "Procesu Atminties Naudojimas" + } + }, + "storage": { + "title": "Saugykla", + "overview": "Apžvalga", + "recordings": { + "title": "Įrašai", + "tips": "Ši reikšmė nurodo kiek iš viso Frigate duombazėje esantys įrašai užima vietos saugykloje. Frigate neseka kiek vietos užima visi kiti failai esantys laikmenoje.", + "earliestRecording": "Anksčiausias esantis įrašas:" + }, + "cameraStorage": { + "title": "Kameros Saugykla", + "camera": "Kamera", + "unusedStorageInformation": "Neišnaudotos Saugyklos Informacija", + "storageUsed": "Saugykla", + "percentageOfTotalUsed": "Procentas nuo Viso", + "bandwidth": "Pralaidumas", + "unused": { + "title": "Nepanaudota", + "tips": "Jei saugykloje turite daugiau failų apart Frigate įrašų, ši reikšmė neatspindės tikslios likusios laisvos vietos Frigate panaudojimui. Frigate neseka saugyklos panaudojimo už savo įrašų ribų." + } + }, + "shm": { + "title": "SHM (bendrinama atmintis) priskyrimas", + "warning": "Esamas SHM dydis {{total}}MB yra per mažas. Pridėkite bent jau {{min_shm}}MB." + } + }, + "cameras": { + "title": "Kameros", + "overview": "Apžvalga", + "info": { + "aspectRatio": "formato santykis", + "cameraProbeInfo": "{{camera}} Kameros srauto informacija", + "streamDataFromFFPROBE": "Transliacijos duomenys yra surenkami su ffprobe.", + "fetching": "Gaunamai Kameros Duomenys", + "stream": "Transliacija {{idx}}", + "video": "Vaizdas:", + "codec": "Kodekas:", + "resolution": "Raiška:", + "fps": "FPS:", + "unknown": "Nežinoma", + "audio": "Garsas:", + "error": "Klaida:{{error}}", + "tips": { + "title": "Kameros Srauto Informacija" + } + }, + "framesAndDetections": "Kadrai / Aptikimai", + "label": { + "camera": "kamera", + "detect": "aptikti", + "skipped": "praleista", + "ffmpeg": "FFmpeg", + "capture": "užfiksuota", + "overallFramesPerSecond": "viso kadrų per sekundę", + "overallDetectionsPerSecond": "viso aptikimų per sekundę", + "overallSkippedDetectionsPerSecond": "viso praleista aptikimų per sekundę", + "cameraFfmpeg": "{{camName}} FFmpeg", + "cameraCapture": "{{camName}} ufiksuota", + "cameraDetect": "{{camName}} susekta", + "cameraFramesPerSecond": "{{camName}} kadrai per sekundę", + "cameraDetectionsPerSecond": "{{camName}} aptikimai per sekundę", + "cameraSkippedDetectionsPerSecond": "{{camName}} praleista aptikimų per sekundę" + }, + "toast": { + "success": { + "copyToClipboard": "Srauto informacija nukopijuotą į iškarpinę." + }, + "error": { + "unableToProbeCamera": "Negalima gauti kameros mėginio: {{errorMessage}}" + } + } + }, + "lastRefreshed": "Paskutinį kartą atnaujinta: ", + "stats": { + "ffmpegHighCpuUsage": "{{camera}} turi aukštą CPU suvartojimą FFmpeg ({{ffmpegAvg}}%)", + "detectHighCpuUsage": "{{camera}} turi auktšą CPU vartojimą aptikimams ({{detectAvg}}%)", + "healthy": "Sistemos būklė sveika", + "reindexingEmbeddings": "Įterpinių reideksavimas ({{processed}}% baigtas)", + "cameraIsOffline": "{{camera}} yra nepasiekiama", + "detectIsSlow": "{{detect}} yra lėtas ({{speed}}ms)", + "detectIsVerySlow": "{{detect}} yra labai lėtas ({{speed}}ms)", + "shmTooLow": "/dev/shm priskirta ({{total}} MB) turi būti padidinta bent jau iki {{min}} MB." + }, + "enrichments": { + "title": "Patobulinimai", + "embeddings": { + "yolov9_plate_detection": "YOLOv9 Numerių Aptikimai", + "yolov9_plate_detection_speed": "YOLOv9 Numerių Aptikimų Greitis", + "text_embedding_speed": "Teksto Įterpimų Greitis", + "plate_recognition_speed": "Numerių Atpažinimo Greitis", + "face_recognition_speed": "Veidų Atpažinimo Greitis", + "face_embedding_speed": "Veidų Įterpimų Greitis", + "image_embedding_speed": "Vaizdo Įterpimo Greitis", + "plate_recognition": "Numerių Atpažinimas", + "face_recognition": "Veido Atpažinimas", + "text_embedding": "Teksto Įterpimas", + "image_embedding": "Vaizdo Įterpimas" + }, + "infPerSecond": "Išvadų Per Sekundę" } } diff --git a/web/public/locales/nb-NO/common.json b/web/public/locales/nb-NO/common.json index df446387f5..af3d8b7b72 100644 --- a/web/public/locales/nb-NO/common.json +++ b/web/public/locales/nb-NO/common.json @@ -190,7 +190,15 @@ "uk": "Українська (Ukrainsk)", "yue": "粵語 (Kantonesisk)", "th": "ไทย (Thai)", - "ca": "Català (Katalansk)" + "ca": "Català (Katalansk)", + "ptBR": "Português brasileiro (Brasiliansk portugisisk)", + "sr": "Српски (Serbisk)", + "sl": "Slovenščina (Slovensk)", + "lt": "Lietuvių (Litauisk)", + "bg": "Български (Bulgarsk)", + "gl": "Galego (Galisisk)", + "id": "Bahasa Indonesia (Indonesisk)", + "ur": "اردو (Urdu)" }, "appearance": "Utseende", "darkMode": { @@ -264,5 +272,6 @@ "title": "404", "desc": "Siden ble ikke funnet" }, - "selectItem": "Velg {{item}}" + "selectItem": "Velg {{item}}", + "readTheDocumentation": "Se dokumentasjonen" } diff --git a/web/public/locales/nb-NO/components/camera.json b/web/public/locales/nb-NO/components/camera.json index d8735926e2..6c7e9966b6 100644 --- a/web/public/locales/nb-NO/components/camera.json +++ b/web/public/locales/nb-NO/components/camera.json @@ -51,7 +51,8 @@ }, "stream": "Strøm", "placeholder": "Velg en strøm" - } + }, + "birdseye": "Fugleperspektiv" }, "add": "Legg til kameragruppe", "edit": "Rediger kameragruppe", diff --git a/web/public/locales/nb-NO/components/dialog.json b/web/public/locales/nb-NO/components/dialog.json index 93a65c99d7..58ff2e3f9a 100644 --- a/web/public/locales/nb-NO/components/dialog.json +++ b/web/public/locales/nb-NO/components/dialog.json @@ -119,5 +119,12 @@ "markAsReviewed": "Merk som inspisert", "deleteNow": "Slett nå" } + }, + "imagePicker": { + "selectImage": "Velg et sporet objekts miniatyrbilde", + "search": { + "placeholder": "Søk etter (under-)merkelapp..." + }, + "noImages": "Ingen miniatyrbilder funnet for dette kameraet" } } diff --git a/web/public/locales/nb-NO/components/filter.json b/web/public/locales/nb-NO/components/filter.json index 5bcbf5d08b..b7b81da9b9 100644 --- a/web/public/locales/nb-NO/components/filter.json +++ b/web/public/locales/nb-NO/components/filter.json @@ -84,7 +84,9 @@ "title": "Gjenkjente kjennemerker", "loadFailed": "Kunne ikke laste inn gjenkjente kjennemerker.", "loading": "Laster inn gjenkjente kjennemerker…", - "placeholder": "Skriv for å søke etter kjennemerker…" + "placeholder": "Skriv for å søke etter kjennemerker…", + "selectAll": "Velg alle", + "clearAll": "Fjern alle" }, "dates": { "all": { @@ -123,5 +125,13 @@ "title": "Alle soner", "short": "Soner" } + }, + "classes": { + "label": "Klasser", + "all": { + "title": "Alle klasser" + }, + "count_one": "{{count}} Klasse", + "count_other": "{{count}} Klasser" } } diff --git a/web/public/locales/nb-NO/views/configEditor.json b/web/public/locales/nb-NO/views/configEditor.json index 09f0b1c692..c0c9253faf 100644 --- a/web/public/locales/nb-NO/views/configEditor.json +++ b/web/public/locales/nb-NO/views/configEditor.json @@ -12,5 +12,7 @@ "copyConfig": "Kopier konfigurasjonen", "saveAndRestart": "Lagre og omstart", "saveOnly": "Kun lagre", - "confirm": "Avslutt uten å lagre?" + "confirm": "Avslutt uten å lagre?", + "safeConfigEditor": "Konfigurasjonsredigering (Sikker modus)", + "safeModeDescription": "Frigate er i sikker modus grunnet en feil i validering av konfigurasjonen." } diff --git a/web/public/locales/nb-NO/views/events.json b/web/public/locales/nb-NO/views/events.json index 70d24e20e0..e333fb5efa 100644 --- a/web/public/locales/nb-NO/views/events.json +++ b/web/public/locales/nb-NO/views/events.json @@ -34,5 +34,7 @@ "markTheseItemsAsReviewed": "Merk disse elementene som inspiserte", "selected_one": "{{count}} valgt", "selected_other": "{{count}} valgt", - "detected": "detektert" + "detected": "detektert", + "suspiciousActivity": "Mistenkelig aktivitet", + "threateningActivity": "Truende aktivitet" } diff --git a/web/public/locales/nb-NO/views/explore.json b/web/public/locales/nb-NO/views/explore.json index e95dbfda2e..9f14df89f9 100644 --- a/web/public/locales/nb-NO/views/explore.json +++ b/web/public/locales/nb-NO/views/explore.json @@ -65,7 +65,7 @@ "millisecondsToOffset": "Millisekunder å forskyve annoteringsdata. Standard: 0", "tips": "TIPS: Tenk deg et hendelsesklipp med en person som går fra venstre til høyre. Hvis den omsluttende boksen i hendelsestidslinjen konsekvent er til venstre for personen, bør verdien reduseres. Tilsvarende, hvis en person går fra venstre til høyre og den omsluttende boksen konsekvent er foran personen, bør verdien økes.", "toast": { - "success": "Annoteringsforskyvning for {{camera}} er lagret i konfigurasjonsfilen. Start Frigate på nytt for å bruke endringene dine." + "success": "Annoteringsforskyvning for {{camera}} er lagret i konfigurasjonsfilen. Start Frigate på nytt for å aktivere endringene dine." } } }, @@ -89,12 +89,14 @@ "success": { "updatedSublabel": "Under-merkelapp oppdatert med suksess.", "updatedLPR": "Vellykket oppdatering av kjennemerke.", - "regenerate": "En ny beskrivelse har blitt anmodet fra {{provider}}. Avhengig av hastigheten til leverandøren din, kan den nye beskrivelsen ta litt tid å regenerere." + "regenerate": "En ny beskrivelse har blitt anmodet fra {{provider}}. Avhengig av hastigheten til leverandøren din, kan den nye beskrivelsen ta litt tid å regenerere.", + "audioTranscription": "Lydtranskripsjon ble forespurt." }, "error": { "regenerate": "Feil ved anrop til {{provider}} for en ny beskrivelse: {{errorMessage}}", "updatedLPRFailed": "Oppdatering av kjennemerke feilet: {{errorMessage}}", - "updatedSublabelFailed": "Feil ved oppdatering av under-merkelapp: {{errorMessage}}" + "updatedSublabelFailed": "Feil ved oppdatering av under-merkelapp: {{errorMessage}}", + "audioTranscription": "Forespørsel om lydtranskripsjon feilet: {{errorMessage}}" } }, "desc": "Detaljer for inspeksjonselement", @@ -146,6 +148,9 @@ }, "snapshotScore": { "label": "Øyeblikksbilde poengsum" + }, + "score": { + "label": "Poengsum" } }, "itemMenu": { @@ -175,6 +180,14 @@ "submitToPlus": { "label": "Send til Frigate+", "aria": "Send til Frigate Plus" + }, + "addTrigger": { + "label": "Legg til utløser", + "aria": "Legg til en utløser for dette sporede objektet" + }, + "audioTranscription": { + "label": "Transkriber", + "aria": "Forespør lydtranskripsjon" } }, "searchResult": { @@ -203,5 +216,11 @@ "fetchingTrackedObjectsFailed": "Feil ved henting av sporede objekter: {{errorMessage}}", "trackedObjectsCount_one": "{{count}} sporet objekt ", "trackedObjectsCount_other": "{{count}} sporede objekter ", - "exploreMore": "Utforsk flere {{label}} objekter" + "exploreMore": "Utforsk flere {{label}} objekter", + "aiAnalysis": { + "title": "AI-Analyse" + }, + "concerns": { + "label": "Bekymringer" + } } diff --git a/web/public/locales/nb-NO/views/live.json b/web/public/locales/nb-NO/views/live.json index 2183cebb98..70d0086578 100644 --- a/web/public/locales/nb-NO/views/live.json +++ b/web/public/locales/nb-NO/views/live.json @@ -35,6 +35,14 @@ "center": { "label": "Klikk i rammen for å sentrere PTZ-kameraet" } + }, + "focus": { + "in": { + "label": "Fokuser inn på PTZ kamera" + }, + "out": { + "label": "Fokuser ut på PTZ kamera" + } } }, "camera": { @@ -153,6 +161,11 @@ "recording": "Opptak", "snapshots": "Øyeblikksbilder", "audioDetection": "Lydregistrering", - "autotracking": "Automatisk sporing" + "autotracking": "Automatisk sporing", + "transcription": "Lydtranskripsjon" + }, + "transcription": { + "enable": "Aktiver direkte lydtranskripsjon", + "disable": "Deaktiver direkte lydtranskripsjon" } } diff --git a/web/public/locales/nb-NO/views/settings.json b/web/public/locales/nb-NO/views/settings.json index f98f80b231..5638fd9219 100644 --- a/web/public/locales/nb-NO/views/settings.json +++ b/web/public/locales/nb-NO/views/settings.json @@ -22,7 +22,8 @@ "frigateplus": "Frigate+", "ui": "Brukergrensesnitt", "notifications": "Meldingsvarsler", - "enrichments": "Utvidelser" + "enrichments": "Utvidelser", + "triggers": "Utløsere" }, "dialog": { "unsavedChanges": { @@ -178,7 +179,45 @@ "desc": "Aktiver/deaktiver varsler og deteksjoner midlertidig for dette kameraet til Frigate startes på nytt. Når deaktivert, vil det ikke genereres nye inspeksjonselementer. ", "alerts": "Varsler ", "detections": "Deteksjoner " - } + }, + "object_descriptions": { + "desc": "Midlertidig aktiver/deaktiver generative KI-objektbeskrivelser for dette kameraet. Når deaktivert, vil KI-genererte beskrivelser ikke bli forespurt for sporede objekter på dette kameraet.", + "title": "Generative KI-objektbeskrivelser" + }, + "cameraConfig": { + "nameInvalid": "Kameranavnet kan bare inneholde bokstaver, tall, understreker eller bindestreker", + "add": "Legg til kamera", + "edit": "Rediger kamera", + "description": "Konfigurer kamerainnstillinger, inkludert strømmeinnganger og roller.", + "name": "Kamera navn", + "nameRequired": "Kamera navn er påkrevd", + "nameLength": "Kamera navn må være mindre enn 24 tegn.", + "namePlaceholder": "f.eks front_dør", + "enabled": "Aktivert", + "ffmpeg": { + "inputs": "Inngangsstrømmer", + "path": "Lenke til strøm", + "pathRequired": "Lenke til strøm er påkrevd", + "pathPlaceholder": "rtsp://...", + "roles": "Roller", + "rolesRequired": "Minst én rolle er påkrevd", + "rolesUnique": "Hver rolle (lyd, gjenkjenning, opptak) kan bare tildeles én strøm", + "addInput": "Legg til inngangsstrøm", + "removeInput": "Fjern inngangsstrøm", + "inputsRequired": "Minst én inngangsstrøm er påkrevd" + }, + "toast": { + "success": "Kamera {{cameraName}} ble lagret" + } + }, + "review_descriptions": { + "title": "Generative KI beskrivelser for inspeksjon", + "desc": "Midlertidig aktiver/deaktiver inspeksjonsbeskrivelser med generativ KI for dette kameraet. Når deaktivert, vil det ikke bli bedt om KI-genererte beskrivelser for inspeksjonselementer på dette kameraet." + }, + "addCamera": "Legg til nytt kamera", + "editCamera": "Rediger kamera:", + "selectCamera": "Velg et kamera", + "backToSettings": "Tilbake til kamerainnstillinger" }, "masksAndZones": { "filter": { @@ -318,8 +357,8 @@ }, "toast": { "success": { - "title": "{{polygonName}} er lagret. Start Frigate på nytt for å bruke endringene.", - "noName": "Bevegelsesmasken er lagret. Start Frigate på nytt for å bruke endringene." + "title": "{{polygonName}} er lagret. Start Frigate på nytt for å aktivere endringene.", + "noName": "Bevegelsesmasken er lagret. Start Frigate på nytt for å aktivere endringene." } } }, @@ -343,8 +382,8 @@ }, "toast": { "success": { - "title": "{{polygonName}} er lagret. Start Frigate på nytt for å bruke endringene.", - "noName": "Objektmasken er lagret. Start Frigate på nytt for å bruke endringene." + "title": "{{polygonName}} er lagret. Start Frigate på nytt for å aktivere endringene.", + "noName": "Objektmasken er lagret. Start Frigate på nytt for å aktivere endringene." } } }, @@ -420,6 +459,19 @@ "mask": { "title": "Bevegelsesmasker", "desc": "Vis polygoner for bevegelsesmasker" + }, + "openCameraWebUI": "Åpne {{camera}} sitt nettgrensesnitt", + "audio": { + "title": "Lyd", + "noAudioDetections": "Ingen lyddeteksjoner", + "score": "poengsum", + "currentRMS": "Nåværende RMS", + "currentdbFS": "Nåværende dbFS" + }, + "paths": { + "title": "Stier", + "desc": "Vis betydningsfulle punkter på det sporede objektets sti", + "tips": "

    Stier


    Linjer og sirkler vil indikere viktige punkter som det sporede objektet har beveget seg gjennom i løpet av sin livssyklus.

    " } }, "users": { @@ -613,7 +665,7 @@ "cleanCopyWarning": "Noen kameraer har øyeblikksbilder aktivert, men ren kopi er deaktivert. Du må aktivere clean_copy i øyeblikksbilde-konfigurasjonen for å kunne sende bilder fra disse kameraene til Frigate+." }, "toast": { - "success": "Frigate+ innstillingene er lagret. Start Frigate på nytt for å bruke endringene.", + "success": "Frigate+ innstillingene er lagret. Start Frigate på nytt for å aktivere endringene.", "error": "Kunne ikke lagre konfigurasjonsendringer: {{errorMessage}}" }, "restart_required": "Omstart påkrevd (Frigate+ modell endret)", @@ -677,8 +729,103 @@ "unsavedChanges": "Ulagrede endringer i innstillinger for utvidelser", "restart_required": "Omstart påkrevd (Innstillinger for utvidelser er endret)", "toast": { - "success": "Innstillinger for utvidelser har blitt lagret. Start Frigate på nytt for å bruke endringene.", + "success": "Innstillinger for utvidelser har blitt lagret. Start Frigate på nytt for å aktivere endringene.", "error": "Kunne ikke lagre konfigurasjonsendringer: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Utløsere", + "management": { + "title": "Utløserhåndtering", + "desc": "Administrer utløsere for {{camera}}. Bruk miniatyrbilde-type for å utløse på lignende miniatyrbilder som det sporede objektet du har valgt, og beskrivelsestype for å utløse på lignende beskrivelser basert på teksten du spesifiserer." + }, + "addTrigger": "Legg til utløs­er", + "table": { + "name": "Navn", + "type": "Type", + "content": "Innhold", + "threshold": "Terskel", + "actions": "Handlinger", + "noTriggers": "Ingen utløsere er konfigurert for dette kameraet.", + "edit": "Rediger", + "deleteTrigger": "Slett utløser", + "lastTriggered": "Sist utløst" + }, + "type": { + "thumbnail": "Miniatyrbilde", + "description": "Beskrivelse" + }, + "actions": { + "alert": "Marker som varsel", + "notification": "Send meldingsvarsel" + }, + "dialog": { + "createTrigger": { + "title": "Opprett utløser", + "desc": "Opprett en utløser for kamera {{camera}}" + }, + "editTrigger": { + "title": "Rediger utløser", + "desc": "Rediger innstillingene for utløser på kamera {{camera}}" + }, + "deleteTrigger": { + "title": "Slett utløser", + "desc": "Er du sikker på at du vil slette utløseren {{triggerName}}? Denne handlingen kan ikke angres." + }, + "form": { + "name": { + "title": "Navn", + "placeholder": "Skriv inn navn på utløser", + "error": { + "minLength": "Navnet må være minst 2 tegn langt.", + "invalidCharacters": "Navnet kan bare inneholde bokstaver, tall, understreker og bindestreker.", + "alreadyExists": "En utløser med dette navnet finnes allerede for dette kameraet." + } + }, + "enabled": { + "description": "Aktiver eller deaktiver denne utløseren" + }, + "type": { + "title": "Type", + "placeholder": "Velg utløsertype" + }, + "content": { + "title": "Innhold", + "imagePlaceholder": "Velg et bilde", + "textPlaceholder": "Skriv inn tekstinnhold", + "imageDesc": "Velg et bilde for å utløse denne handlingen når et lignende bilde oppdages.", + "textDesc": "Skriv inn tekst for å utløse denne handlingen når en lignende beskrivelse av et sporet objekt oppdages.", + "error": { + "required": "Innhold er påkrevd." + } + }, + "threshold": { + "title": "Terskel", + "error": { + "min": "Terskelverdien må minst være 0", + "max": "Terskelverdien kan maksimum være 1" + } + }, + "actions": { + "title": "Handlinger", + "desc": "Som standard sender Frigate en MQTT-melding for alle utløsere. Velg en ekstra handling som skal utføres når denne utløseren aktiveres.", + "error": { + "min": "Minst én handling må velges." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Utløseren {{name}} ble opprettet.", + "updateTrigger": "Utløseren {{name}} ble oppdatert.", + "deleteTrigger": "Utløseren {{name}} ble slettet." + }, + "error": { + "createTriggerFailed": "Kunne ikke opprette utløser: {{errorMessage}}", + "updateTriggerFailed": "Kunne ikke oppdatere utløser: {{errorMessage}}", + "deleteTriggerFailed": "Kunne ikke slette utløser: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/nb-NO/views/system.json b/web/public/locales/nb-NO/views/system.json index 884949bd97..d242d69d87 100644 --- a/web/public/locales/nb-NO/views/system.json +++ b/web/public/locales/nb-NO/views/system.json @@ -40,7 +40,8 @@ "title": "Detektorer", "cpuUsage": "Detektor CPU-belastning", "memoryUsage": "Detektor minnebruk", - "temperature": "Detektor temperatur" + "temperature": "Detektor temperatur", + "cpuUsageInformation": "CPU brukt til å forberede inn- og utdata til/fra deteksjonsmodeller. Denne verdien måler ikke bruk under inferens, selv om en GPU eller akselerator benyttes." }, "hardwareInfo": { "gpuMemory": "GPU-minne", @@ -100,7 +101,11 @@ "tips": "Denne verdien representerer kanskje ikke nøyaktig den ledige plassen Frigate har tilgang til, dersom det finnes andre filer lagret på disken. Frigate sporer kun lagring brukt av egne opptak." } }, - "title": "Lagring" + "title": "Lagring", + "shm": { + "title": "SHM (delt minne) allokering", + "warning": "Den nåværende SHM-størrelsen på {{total}} MB er for liten. Øk den til minst {{min_shm}} MB." + } }, "cameras": { "info": { @@ -175,6 +180,7 @@ "reindexingEmbeddings": "Reindeksering av vektorrepresentasjoner ({{processed}}% fullført)", "cameraIsOffline": "{{camera}} er frakoblet", "detectIsSlow": "{{detect}} er treg ({{speed}} ms)", - "detectIsVerySlow": "{{detect}} er veldig treg ({{speed}} ms)" + "detectIsVerySlow": "{{detect}} er veldig treg ({{speed}} ms)", + "shmTooLow": "/dev/shm-allokeringen ({{total}} MB) bør økes til minst {{min}} MB." } } diff --git a/web/public/locales/nl/common.json b/web/public/locales/nl/common.json index 0af38d8a5b..166dee4b57 100644 --- a/web/public/locales/nl/common.json +++ b/web/public/locales/nl/common.json @@ -175,7 +175,15 @@ "ja": "日本語 (Japans)", "yue": "粵語 (Kantonees)", "th": "ไทย (Thais)", - "ca": "Català (Catalaans)" + "ca": "Català (Catalaans)", + "ptBR": "Português brasileiro (Braziliaans Portugees)", + "sr": "Српски (Servisch)", + "sl": "Slovenščina (Sloveens)", + "lt": "Lietuvių (Litouws)", + "bg": "Български (Bulgaars)", + "gl": "Galego (Galicisch)", + "id": "Bahasa Indonesia (Indonesisch)", + "ur": "اردو (Urdu)" }, "darkMode": { "label": "Donkere modus", @@ -264,5 +272,6 @@ "title": "404", "documentTitle": "Niet gevonden - Frigate" }, - "selectItem": "Selecteer {{item}}" + "selectItem": "Selecteer {{item}}", + "readTheDocumentation": "Lees de documentatie" } diff --git a/web/public/locales/nl/components/camera.json b/web/public/locales/nl/components/camera.json index 251e57a254..1b840478d5 100644 --- a/web/public/locales/nl/components/camera.json +++ b/web/public/locales/nl/components/camera.json @@ -65,7 +65,8 @@ "title": "{{cameraName}} Streaming-instellingen", "stream": "Stream", "placeholder": "Kies een stream" - } + }, + "birdseye": "Birdseye" }, "icon": "Icon" }, diff --git a/web/public/locales/nl/components/dialog.json b/web/public/locales/nl/components/dialog.json index 0c1e8aaf34..65ee012fd9 100644 --- a/web/public/locales/nl/components/dialog.json +++ b/web/public/locales/nl/components/dialog.json @@ -119,5 +119,12 @@ "success": "De videobeelden die aan de geselecteerde beoordelingsitems zijn gekoppeld, zijn succesvol verwijderd." } } + }, + "imagePicker": { + "selectImage": "Kies thumbnail van gevolgd object", + "noImages": "Geen thumbnails gevonden voor deze camera", + "search": { + "placeholder": "Zoeken op label of sub label..." + } } } diff --git a/web/public/locales/nl/components/filter.json b/web/public/locales/nl/components/filter.json index fa2ecd9d0a..95a98146f6 100644 --- a/web/public/locales/nl/components/filter.json +++ b/web/public/locales/nl/components/filter.json @@ -75,7 +75,9 @@ "title": "Herkende kentekenplaten", "noLicensePlatesFound": "Geen kentekenplaten gevonden.", "selectPlatesFromList": "Selecteer een of meer kentekens uit de lijst.", - "loading": "Herkende kentekenplaten laden…" + "loading": "Herkende kentekenplaten laden…", + "selectAll": "Selecteer alles", + "clearAll": "Alles wissen" }, "score": "Score", "sort": { @@ -123,5 +125,13 @@ "label": "Filters resetten naar standaardwaarden" }, "more": "Meer filters", - "estimatedSpeed": "Geschatte snelheid ({{unit}})" + "estimatedSpeed": "Geschatte snelheid ({{unit}})", + "classes": { + "label": "Klassen", + "all": { + "title": "Alle klassen" + }, + "count_one": "{{count}} klasse", + "count_other": "{{count}} Klassen" + } } diff --git a/web/public/locales/nl/views/configEditor.json b/web/public/locales/nl/views/configEditor.json index 5bd94a242b..50a146cb63 100644 --- a/web/public/locales/nl/views/configEditor.json +++ b/web/public/locales/nl/views/configEditor.json @@ -12,5 +12,7 @@ }, "configEditor": "Configuratie Bewerken", "saveOnly": "Alleen opslaan", - "confirm": "Afsluiten zonder op te slaan?" + "confirm": "Afsluiten zonder op te slaan?", + "safeConfigEditor": "Configuratie-editor (veilige modus)", + "safeModeDescription": "Frigate is in veilige modus vanwege een configuratievalidatiefout." } diff --git a/web/public/locales/nl/views/events.json b/web/public/locales/nl/views/events.json index 269cadffc6..3f42b6c290 100644 --- a/web/public/locales/nl/views/events.json +++ b/web/public/locales/nl/views/events.json @@ -34,5 +34,7 @@ "markTheseItemsAsReviewed": "Markeer deze items als beoordeeld", "selected_other": "{{count}} geselecteerd", "selected_one": "{{count}} geselecteerd", - "detected": "gedetecteerd" + "detected": "gedetecteerd", + "suspiciousActivity": "Verdachte activiteit", + "threateningActivity": "Bedreigende activiteit" } diff --git a/web/public/locales/nl/views/explore.json b/web/public/locales/nl/views/explore.json index 78c2c7116d..a7a8f41e41 100644 --- a/web/public/locales/nl/views/explore.json +++ b/web/public/locales/nl/views/explore.json @@ -102,12 +102,14 @@ "success": { "regenerate": "Er is een nieuwe beschrijving aangevraagd bij {{provider}}. Afhankelijk van de snelheid van je provider kan het regenereren van de nieuwe beschrijving enige tijd duren.", "updatedSublabel": "Sublabel succesvol bijgewerkt.", - "updatedLPR": "Kenteken succesvol bijgewerkt." + "updatedLPR": "Kenteken succesvol bijgewerkt.", + "audioTranscription": "Audiotranscriptie succesvol aangevraagd." }, "error": { "updatedSublabelFailed": "Het is niet gelukt om het sublabel bij te werken: {{errorMessage}}", "regenerate": "Het is niet gelukt om {{provider}} aan te roepen voor een nieuwe beschrijving: {{errorMessage}}", - "updatedLPRFailed": "Kentekenplaat bijwerken mislukt: {{errorMessage}}" + "updatedLPRFailed": "Kentekenplaat bijwerken mislukt: {{errorMessage}}", + "audioTranscription": "Audiotranscriptie aanvragen mislukt: {{errorMessage}}" } } }, @@ -153,6 +155,9 @@ "recognizedLicensePlate": "Erkende kentekenplaat", "snapshotScore": { "label": "Snapshot scoren" + }, + "score": { + "label": "Score" } }, "itemMenu": { @@ -182,6 +187,14 @@ "downloadSnapshot": { "label": "Download snapshot", "aria": "Download snapshot" + }, + "addTrigger": { + "label": "Trigger toevoegen", + "aria": "Voeg een trigger toe voor dit gevolgde object" + }, + "audioTranscription": { + "label": "Transcriberen", + "aria": "Audiotranscriptie aanvragen" } }, "noTrackedObjects": "Geen gevolgde objecten gevonden", @@ -203,5 +216,11 @@ } }, "fetchingTrackedObjectsFailed": "Fout bij het ophalen van gevolgde objecten: {{errorMessage}}", - "exploreMore": "Verken meer {{label}} objecten" + "exploreMore": "Verken meer {{label}} objecten", + "aiAnalysis": { + "title": "AI-analyse" + }, + "concerns": { + "label": "Zorgen" + } } diff --git a/web/public/locales/nl/views/live.json b/web/public/locales/nl/views/live.json index d09f4c6994..00bb28a92c 100644 --- a/web/public/locales/nl/views/live.json +++ b/web/public/locales/nl/views/live.json @@ -41,7 +41,15 @@ "label": "Klik in het frame om de PTZ-camera te centreren" } }, - "presets": "PTZ-camerapresets" + "presets": "PTZ-camerapresets", + "focus": { + "in": { + "label": "Focus PTZ-camera in" + }, + "out": { + "label": "Focus PTZ-camera uit" + } + } }, "camera": { "enable": "Camera inschakelen", @@ -135,7 +143,8 @@ "audioDetection": "Audiodetectie", "autotracking": "Automatisch volgen", "snapshots": "Momentopnames", - "cameraEnabled": "Camera ingeschakeld" + "cameraEnabled": "Camera ingeschakeld", + "transcription": "Audiotranscriptie" }, "history": { "label": "Historische beelden weergeven" @@ -154,5 +163,9 @@ "group": { "label": "Cameragroep bewerken" } + }, + "transcription": { + "enable": "Live audiotranscriptie inschakelen", + "disable": "Live audiotranscriptie uitschakelen" } } diff --git a/web/public/locales/nl/views/settings.json b/web/public/locales/nl/views/settings.json index 5189f57bfd..e60e3af39a 100644 --- a/web/public/locales/nl/views/settings.json +++ b/web/public/locales/nl/views/settings.json @@ -22,7 +22,8 @@ "notifications": "Meldingen", "cameras": "Camera-instellingen", "frigateplus": "Frigate+", - "enrichments": "Verrijkingen" + "enrichments": "Verrijkingen", + "triggers": "Triggers" }, "dialog": { "unsavedChanges": { @@ -178,7 +179,45 @@ "desc": "Schakel een camera tijdelijk uit totdat Frigate opnieuw wordt gestart. Het uitschakelen van een camera stopt de verwerking van de streams van deze camera volledig door Frigate. Detectie, opname en foutopsporing zijn dan niet beschikbaar.
    Let op: dit schakelt go2rtc-restreams niet uit.", "title": "Streams" }, - "title": "Camera-instellingen" + "title": "Camera-instellingen", + "object_descriptions": { + "title": "AI-gegenereerde objectomschrijvingen", + "desc": "AI-gegenereerde objectomschrijvingen tijdelijk uitschakelen voor deze camera. Wanneer uitgeschakeld, zullen omschrijvingen van gevolgde objecten op deze camera niet aangevraagd worden." + }, + "review_descriptions": { + "title": "Generatieve-AI Beoordelingsbeschrijvingen", + "desc": "Tijdelijk generatieve-AI-beoordelingsbeschrijvingen voor deze camera in- of uitschakelen. Wanneer dit is uitgeschakeld, worden er geen door AI gegenereerde beschrijvingen opgevraagd voor beoordelingsitems van deze camera." + }, + "addCamera": "Nieuwe camera toevoegen", + "editCamera": "Camera bewerken:", + "selectCamera": "Selecteer een camera", + "backToSettings": "Terug naar camera-instellingen", + "cameraConfig": { + "add": "Camera toevoegen", + "edit": "Camera bewerken", + "description": "Configureer de camera-instellingen, inclusief streaming-inputs en functies.", + "name": "Cameranaam", + "nameRequired": "Cameranaam is vereist", + "nameInvalid": "De cameranaam mag alleen letters, cijfers, onderstrepingstekens of koppeltekens bevatten", + "namePlaceholder": "bijv. voor_deur", + "enabled": "Ingeschakeld", + "ffmpeg": { + "inputs": "Streams-Input", + "path": "Stroompad", + "pathRequired": "Streampad is vereist", + "pathPlaceholder": "rtsp://...", + "roles": "Functie", + "rolesRequired": "Er is ten minste één functie vereist", + "rolesUnique": "Elke functie (audio, detecteren, opnemen) kan slechts aan één stream worden toegewezen", + "addInput": "Inputstream toevoegen", + "removeInput": "Inputstream verwijderen", + "inputsRequired": "Er is ten minste één stream-input vereist" + }, + "toast": { + "success": "Camera {{cameraName}} is succesvol opgeslagen" + }, + "nameLength": "Cameranaam mag niet langer zijn dan 24 tekens." + } }, "masksAndZones": { "filter": { @@ -420,7 +459,20 @@ "score": "Score", "ratio": "Verhouding" }, - "detectorDesc": "Frigate gebruikt je detectoren ({{detectors}}) om objecten in de videostream van je camera te detecteren." + "detectorDesc": "Frigate gebruikt je detectoren ({{detectors}}) om objecten in de videostream van je camera te detecteren.", + "paths": { + "title": "Paden", + "desc": "Toon belangrijke punten van het pad van het gevolgde object", + "tips": "

    Paden


    Lijnen en cirkels geven belangrijke punten aan waar het gevolgde object zich tijdens zijn levensduur heeft verplaatst.

    " + }, + "openCameraWebUI": "Open de webinterface van {{camera}}", + "audio": { + "title": "Audio", + "noAudioDetections": "Geen audiodetecties", + "score": "score", + "currentRMS": "Huidige RMS", + "currentdbFS": "Huidige dbFS" + } }, "users": { "title": "Gebruikers", @@ -506,7 +558,8 @@ "admin": "Beheerder", "adminDesc": "Volledige toegang tot alle functies.", "viewer": "Gebruiker", - "viewerDesc": "Alleen toegang tot Live-dashboards, Beoordelen, Verkennen en Exports." + "viewerDesc": "Alleen toegang tot Live-dashboards, Beoordelen, Verkennen en Exports.", + "customDesc": "Aangepaste rol met specifieke cameratoegang." }, "select": "Selecteer een rol" }, @@ -680,5 +733,160 @@ "success": "Verrijkingsinstellingen zijn opgeslagen. Start Frigate opnieuw op om je wijzigingen toe te passen.", "error": "Configuratiewijzigingen konden niet worden opgeslagen: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Triggers", + "management": { + "title": "Triggerbeheer", + "desc": "Beheer triggers voor {{camera}}. Gebruik een thumbnail om te triggeren op vergelijkbare thumbnails van het door jou gevolgde object, of gebruik een objectbeschrijving om te triggeren op vergelijkbare beschrijvingen van de door jou opgegeven tekst." + }, + "addTrigger": "Trigger toevoegen", + "table": { + "name": "Naam", + "type": "Type", + "content": "Inhoud", + "threshold": "Drempel", + "actions": "Acties", + "noTriggers": "Er zijn geen triggers geconfigureerd voor deze camera.", + "edit": "Bewerken", + "deleteTrigger": "Trigger verwijderen", + "lastTriggered": "Laatst geactiveerd" + }, + "type": { + "thumbnail": "Thumbnail", + "description": "Beschrijving" + }, + "actions": { + "alert": "Markeren als waarschuwing", + "notification": "Melding verzenden" + }, + "dialog": { + "createTrigger": { + "title": "Trigger aanmaken", + "desc": "Maak een trigger voor camera {{camera}}" + }, + "editTrigger": { + "title": "Trigger bewerken", + "desc": "Wijzig de instellingen voor de trigger op camera {{camera}}" + }, + "deleteTrigger": { + "title": "Trigger verwijderen", + "desc": "Weet u zeker dat u de trigger {{triggerName}} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." + }, + "form": { + "name": { + "title": "Naam", + "placeholder": "Voer de naam van de trigger in", + "error": { + "minLength": "De naam moet minimaal 2 tekens lang zijn.", + "invalidCharacters": "De naam mag alleen letters, cijfers, onderstrepingstekens en koppeltekens bevatten.", + "alreadyExists": "Er bestaat al een trigger met deze naam voor deze camera." + } + }, + "enabled": { + "description": "Deze trigger in- of uitschakelen" + }, + "type": { + "title": "Type", + "placeholder": "Selecteer het type trigger" + }, + "content": { + "title": "Inhoud", + "imagePlaceholder": "Selecteer een afbeelding", + "textPlaceholder": "Tekst invoeren", + "imageDesc": "Selecteer een afbeelding om deze actie te activeren wanneer een vergelijkbare afbeelding wordt gedetecteerd.", + "textDesc": "Voer tekst in om deze actie te activeren wanneer een vergelijkbare beschrijving van een gevolgd object wordt gedetecteerd.", + "error": { + "required": "Inhoud is vereist." + } + }, + "threshold": { + "title": "Drempel", + "error": { + "min": "De drempelwaarde moet minimaal 0 zijn", + "max": "De drempelwaarde mag maximaal 1 zijn" + } + }, + "actions": { + "title": "Acties", + "desc": "Standaard verstuurt Frigate een MQTT-bericht voor alle triggers. Kies een extra actie die moet worden uitgevoerd wanneer deze trigger wordt geactiveerd.", + "error": { + "min": "Er moet ten minste één actie worden geselecteerd." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Trigger {{name}} is succesvol aangemaakt.", + "updateTrigger": "Trigger {{name}} is succesvol bijgewerkt.", + "deleteTrigger": "Trigger {{name}} succesvol verwijderd." + }, + "error": { + "createTriggerFailed": "Trigger kan niet worden gemaakt: {{errorMessage}}", + "updateTriggerFailed": "Trigger kan niet worden bijgewerkt: {{errorMessage}}", + "deleteTriggerFailed": "Trigger kan niet worden verwijderd: {{errorMessage}}" + } + } + }, + "roles": { + "management": { + "title": "Beheer van kijkersrollen", + "desc": "Beheer aangepaste kijkersrollen en hun camera-toegangsrechten voor deze Frigate-instantie." + }, + "addRole": "Rol toevoegen", + "table": { + "role": "Rol", + "cameras": "Camera's", + "actions": "Acties", + "noRoles": "Er zijn geen aangepaste rollen gevonden.", + "editCameras": "Camera's bewerken", + "deleteRole": "Rol verwijderen" + }, + "toast": { + "success": { + "createRole": "Rol {{role}} succesvol aangemaakt", + "updateCameras": "Camera's bijgewerkt voor rol {{role}}", + "deleteRole": "Rol {{role}} succesvol verwijderd", + "userRolesUpdated": "{{count}} gebruiker(s) die aan deze rol waren toegewezen, zijn bijgewerkt naar ‘kijker’, die toegang heeft tot alle camera’s." + }, + "error": { + "createRoleFailed": "Kan rol niet aanmaken: {{errorMessage}}", + "updateCamerasFailed": "Het is niet gelukt om de camera's bij te werken: {{errorMessage}}", + "deleteRoleFailed": "Kan rol niet verwijderen: {{errorMessage}}", + "userUpdateFailed": "Het bijwerken van gebruikersrollen is mislukt: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Nieuwe rol maken", + "desc": "Voeg een nieuwe rol toe en specificeer de camera-toegangsrechten." + }, + "editCameras": { + "title": "Camera’s voor rol bewerken", + "desc": "Werk de camera-toegang bij voor de rol {{role}}." + }, + "deleteRole": { + "title": "Rol verwijderen", + "desc": "Deze actie kan niet ongedaan worden gemaakt. De rol wordt permanent verwijderd en alle gebruikers met deze rol worden toegewezen aan de rol ‘kijker’, die toegang geeft tot alle camera’s.", + "warn": "Weet u zeker dat u {{role}} wilt verwijderen?", + "deleting": "Verwijderen..." + }, + "form": { + "role": { + "title": "Rolnaam", + "placeholder": "Voer rolnaam in", + "desc": "Alleen letters, cijfers, punten en underscores zijn toegestaan.", + "roleIsRequired": "Rolnaam is vereist", + "roleOnlyInclude": "De rolnaam mag alleen letters, cijfers, . of _ bevatten", + "roleExists": "Er bestaat al een rol met deze naam." + }, + "cameras": { + "title": "Camera's", + "desc": "Selecteer de camera's waartoe deze rol toegang heeft. Er is minimaal één camera vereist.", + "required": "Er moet minimaal één camera worden geselecteerd." + } + } + } } } diff --git a/web/public/locales/nl/views/system.json b/web/public/locales/nl/views/system.json index 7d039d08ef..2a0fe9e8dd 100644 --- a/web/public/locales/nl/views/system.json +++ b/web/public/locales/nl/views/system.json @@ -11,7 +11,7 @@ "enrichments": "Verrijkings Statistieken - Frigate" }, "title": "Systeem", - "metrics": "Systeem statistieken", + "metrics": "Systeemstatistieken", "logs": { "download": { "label": "Logs Downloaden" @@ -41,10 +41,11 @@ "cpuUsage": "Detector CPU-verbruik", "memoryUsage": "Detector Geheugen Gebruik", "inferenceSpeed": "Detector Interferentie Snelheid", - "temperature": "Detectortemperatuur" + "temperature": "Detectortemperatuur", + "cpuUsageInformation": "CPU-gebruik bij het voorbereiden van in en uitvoer van gegevens voor detectiemodellen. Deze waarde geeft geen inferentie gebruik weer, ook niet wanneer een GPU of accelerator wordt gebruikt." }, "hardwareInfo": { - "title": "Systeem Gegevens", + "title": "Systeemgegevens", "gpuUsage": "GPU-verbruik", "gpuInfo": { "vainfoOutput": { @@ -102,7 +103,12 @@ "camera": "Camera", "bandwidth": "Bandbreedte" }, - "title": "Opslag" + "title": "Opslag", + "shm": { + "title": "SHM (gedeeld geheugen) toewijzing", + "warning": "De huidige SHM-grootte van {{total}} MB is te klein. Vergroot deze tot minimaal {{min_shm}} MB.", + "readTheDocumentation": "Lees de documentatie" + } }, "cameras": { "title": "Cameras", @@ -154,11 +160,12 @@ "stats": { "ffmpegHighCpuUsage": "{{camera}} zorgt voor hoge FFmpeg CPU belasting ({{ffmpegAvg}}%)", "detectHighCpuUsage": "{{camera}} zorgt voor hoge detectie CPU belasting ({{detectAvg}}%)", - "healthy": "Systeem is gezond", + "healthy": "Geen problemen", "reindexingEmbeddings": "Herindexering van inbeddingen ({{processed}}% compleet)", "detectIsSlow": "{{detect}} is traag ({{speed}} ms)", "detectIsVerySlow": "{{detect}} is erg traag ({{speed}} ms)", - "cameraIsOffline": "{{camera}} is offline" + "cameraIsOffline": "{{camera}} is offline", + "shmTooLow": "Vergroot de /dev/shm toewijzing van {{total}} MB naar minimaal {{min}} MB." }, "enrichments": { "title": "Verrijkingen", diff --git a/web/public/locales/pl/common.json b/web/public/locales/pl/common.json index 00f14d2462..058c74733e 100644 --- a/web/public/locales/pl/common.json +++ b/web/public/locales/pl/common.json @@ -179,7 +179,15 @@ "fi": "Suomi (Fiński)", "yue": "粵語 (Kantoński)", "th": "ไทย (Tajski)", - "ca": "Català (Kataloński)" + "ca": "Català (Kataloński)", + "ptBR": "Português brasileiro (portugalski - Brazylia)", + "sr": "Српски (Serbski)", + "sl": "Slovenščina (Słowacki)", + "lt": "Lietuvių (Litewski)", + "bg": "Български (Bułgarski)", + "gl": "Galego (Galicyjski)", + "id": "Bahasa Indonesia (Indonezyjski)", + "ur": "اردو (Urdu)" }, "appearance": "Wygląd", "darkMode": { @@ -271,5 +279,6 @@ }, "title": "Zapisz" } - } + }, + "readTheDocumentation": "Przeczytaj dokumentację" } diff --git a/web/public/locales/pl/components/filter.json b/web/public/locales/pl/components/filter.json index 30b19bee36..0ba008d2d8 100644 --- a/web/public/locales/pl/components/filter.json +++ b/web/public/locales/pl/components/filter.json @@ -122,5 +122,11 @@ }, "zoneMask": { "filterBy": "Filtruj według maski strefy" + }, + "classes": { + "label": "Klasy", + "all": { + "title": "Wszystkie Klasy" + } } } diff --git a/web/public/locales/pl/views/configEditor.json b/web/public/locales/pl/views/configEditor.json index 2ebc8c6132..a8c3740441 100644 --- a/web/public/locales/pl/views/configEditor.json +++ b/web/public/locales/pl/views/configEditor.json @@ -12,5 +12,7 @@ }, "saveOnly": "Tylko zapisz", "saveAndRestart": "Zapisz i uruchom ponownie", - "confirm": "Zamknąć bez zapisu?" + "confirm": "Zamknąć bez zapisywania?", + "safeConfigEditor": "Edytor Konfiguracji (tryb bezpieczny)", + "safeModeDescription": "Frigate jest w trybie bezpiecznym przez błąd walidacji konfiguracji." } diff --git a/web/public/locales/pl/views/settings.json b/web/public/locales/pl/views/settings.json index fd45430f35..9661160a79 100644 --- a/web/public/locales/pl/views/settings.json +++ b/web/public/locales/pl/views/settings.json @@ -640,7 +640,7 @@ } } }, - "title": "Ustawienia wzbogacania", + "title": "Ustawienia wzbogacające", "unsavedChanges": "Niezapisane zmiany ustawień wzbogacania", "birdClassification": { "title": "Klasyfikacja ptaków", diff --git a/web/public/locales/pt-BR/audio.json b/web/public/locales/pt-BR/audio.json index 04ee37d6b5..74140c0393 100644 --- a/web/public/locales/pt-BR/audio.json +++ b/web/public/locales/pt-BR/audio.json @@ -1,7 +1,7 @@ { "mantra": "Mantra", "child_singing": "Criança cantando", - "speech": "Discurso", + "speech": "Fala", "yell": "Gritar", "chant": "Canto", "babbling": "Balbuciando", diff --git a/web/public/locales/pt-BR/common.json b/web/public/locales/pt-BR/common.json index c5b789ccc2..69e50231c2 100644 --- a/web/public/locales/pt-BR/common.json +++ b/web/public/locales/pt-BR/common.json @@ -3,7 +3,7 @@ "untilForTime": "Até {{time}}", "untilForRestart": "Até o Frigate reiniciar.", "untilRestart": "Até reiniciar", - "ago": "{{timeAgo}} antes", + "ago": "{{timeAgo}} atrás", "justNow": "Agora mesmo", "today": "Hoje", "yesterday": "Ontem", @@ -80,7 +80,7 @@ "24hour": "dd-MM-yy-HH-mm-ss" } }, - "selectItem": "Selecione {{item}}", + "selectItem": "Selecionar {{item}}", "unit": { "speed": { "mph": "mi/h", @@ -169,7 +169,15 @@ "ca": "Català (Catalão)", "withSystem": { "label": "Usar as configurações de sistema para o idioma" - } + }, + "ptBR": "Português Brasileiro (Português Brasileiro)", + "sr": "Српски (Sérvio)", + "sl": "Slovenščina (Esloveno)", + "lt": "Lietuvių (Lituano)", + "bg": "Български (Búlgaro)", + "gl": "Galego (Galego)", + "id": "Bahasa Indonesia (Indonésio)", + "ur": "اردو (Urdu)" }, "systemLogs": "Logs de sistema", "settings": "Configurações", @@ -210,7 +218,7 @@ "count_other": "{{count}} Câmeras" } }, - "review": "Revisão", + "review": "Revisar", "explore": "Explorar", "export": "Exportar", "uiPlayground": "Playground da UI", @@ -261,5 +269,6 @@ "documentTitle": "Não Encontrado - Frigate", "title": "404", "desc": "Página não encontrada" - } + }, + "readTheDocumentation": "Leia a documentação" } diff --git a/web/public/locales/pt-BR/components/camera.json b/web/public/locales/pt-BR/components/camera.json index 322e63522d..03ee52b587 100644 --- a/web/public/locales/pt-BR/components/camera.json +++ b/web/public/locales/pt-BR/components/camera.json @@ -66,7 +66,8 @@ "label": "Modo de compatibilidade", "desc": "Habilite essa opção somente se a transmissão ao vivo da sua câmera estiver exibindo artefatos de cor e possui uma linha diagonal no canto esquerdo da imagem." } - } + }, + "birdseye": "Visão Panorâmica" } }, "debug": { diff --git a/web/public/locales/pt-BR/components/dialog.json b/web/public/locales/pt-BR/components/dialog.json index f180fe5130..2e06706224 100644 --- a/web/public/locales/pt-BR/components/dialog.json +++ b/web/public/locales/pt-BR/components/dialog.json @@ -110,5 +110,12 @@ "export": "Exportar", "deleteNow": "Deletar Agora" } + }, + "imagePicker": { + "selectImage": "Selecionar a miniatura de um objeto rastreado", + "search": { + "placeholder": "Pesquisar por rótulo ou sub-rótulo…" + }, + "noImages": "Nenhuma miniatura encontrada para essa câmera" } } diff --git a/web/public/locales/pt-BR/components/filter.json b/web/public/locales/pt-BR/components/filter.json index d503d3f13b..ee84e75d69 100644 --- a/web/public/locales/pt-BR/components/filter.json +++ b/web/public/locales/pt-BR/components/filter.json @@ -23,7 +23,7 @@ "short": "Datas" } }, - "more": "Mais filtros", + "more": "Mais Filtros", "reset": { "label": "Resetar filtros para valores padrão" }, @@ -71,7 +71,7 @@ "title": "Configurações", "defaultView": { "title": "Visualização Padrão", - "desc": "Quando nenhum filtro é selecionado, exibir um sumário dos objetos mais recentes rastreados por categoria, ou exiba uma grade sem filtro.", + "desc": "Quando nenhum filtro é selecionado, exibe um sumário dos objetos mais recentes rastreados por rótulo, ou exibe uma grade sem filtro.", "summary": "Sumário", "unfilteredGrid": "Grade Sem Filtros" }, @@ -106,7 +106,7 @@ }, "trackedObjectDelete": { "title": "Confirmar Exclusão", - "desc": "Deletar esses {{objectLength}} objetos rastreados remove as capturas de imagem, qualquer embeddings salvos, e quaisquer entradas do ciclo de vida associadas do objeto. Gravações desses objetos rastreados na visualização de Histórico NÃO irão ser deletadas.

    Tem certeza que quer proceder?

    Segure a tecla Shift para pular esse diálogo no futuro.", + "desc": "Deletar esses {{objectLength}} objetos rastreados remove as capturas de imagem, quaisquer embeddings salvos, e quaisquer entradas do ciclo de vida associadas do objeto. Gravações desses objetos rastreados na visualização de Histórico NÃO irão ser deletadas.

    Tem certeza que quer proceder?

    Segure a tecla Shift para pular esse diálogo no futuro.", "toast": { "success": "Objetos rastreados deletados com sucesso.", "error": "Falha ao deletar objeto rastreado: {{errorMessage}}" @@ -121,6 +121,16 @@ "loading": "Carregando placas de identificação reconhecidas…", "placeholder": "Digite para pesquisar por placas de identificação…", "noLicensePlatesFound": "Nenhuma placa de identificação encontrada.", - "selectPlatesFromList": "Seleciona uma ou mais placas da lista." + "selectPlatesFromList": "Seleciona uma ou mais placas da lista.", + "selectAll": "Selecionar todos", + "clearAll": "Limpar todos" + }, + "classes": { + "label": "Classes", + "all": { + "title": "Todas as Classes" + }, + "count_one": "{{count}} Classe", + "count_other": "{{count}} Classes" } } diff --git a/web/public/locales/pt-BR/views/configEditor.json b/web/public/locales/pt-BR/views/configEditor.json index 1bd110a6f1..46c4808cbc 100644 --- a/web/public/locales/pt-BR/views/configEditor.json +++ b/web/public/locales/pt-BR/views/configEditor.json @@ -12,5 +12,7 @@ "error": { "savingError": "Erro ao salvar configuração" } - } + }, + "safeConfigEditor": "Editor de Configuração (Modo Seguro)", + "safeModeDescription": "O Frigate está no modo seguro devido a um erro de validação de configuração." } diff --git a/web/public/locales/pt-BR/views/events.json b/web/public/locales/pt-BR/views/events.json index 1cd63daf01..2e7eac4cb4 100644 --- a/web/public/locales/pt-BR/views/events.json +++ b/web/public/locales/pt-BR/views/events.json @@ -26,13 +26,15 @@ }, "markTheseItemsAsReviewed": "Marque estes itens como revisados", "newReviewItems": { - "button": "Novos Itens para Revisão", + "button": "Novos Itens para Revisar", "label": "Ver novos itens para revisão" }, "selected_one": "{{count}} selecionado(s)", - "documentTitle": "Revisão - Frigate", + "documentTitle": "Revisar - Frigate", "markAsReviewed": "Marcar como Revisado", "selected_other": "{{count}} selecionado(s)", "camera": "Câmera", - "detected": "detectado" + "detected": "detectado", + "suspiciousActivity": "Atividade Suspeita", + "threateningActivity": "Atividade de Ameaça" } diff --git a/web/public/locales/pt-BR/views/explore.json b/web/public/locales/pt-BR/views/explore.json index a43ee2b176..9de511f9de 100644 --- a/web/public/locales/pt-BR/views/explore.json +++ b/web/public/locales/pt-BR/views/explore.json @@ -3,15 +3,15 @@ "generativeAI": "IA Generativa", "exploreMore": "Explorar mais objetos {{label}}", "exploreIsUnavailable": { - "title": "Explorar não está disponível", + "title": "A seção Explorar está indisponível", "embeddingsReindexing": { - "context": "Explorar pode ser usado depois da incorporação do objeto rastreado terminar a reindexação.", - "startingUp": "Começando…", - "estimatedTime": "Time estimado faltando:", - "finishingShortly": "Terminando em breve", + "context": "O menu explorar pode ser usado após os embeddings de objetos rastreados terem terminado de reindexar.", + "startingUp": "Iniciando…", + "estimatedTime": "Tempo estimado restante:", + "finishingShortly": "Finalizando em breve", "step": { - "thumbnailsEmbedded": "Miniaturas incorporadas: ", - "descriptionsEmbedded": "Descrições incorporadas: ", + "thumbnailsEmbedded": "Miniaturas embedded: ", + "descriptionsEmbedded": "Descrições embedded: ", "trackedObjectsProcessed": "Objetos rastreados processados: " } }, @@ -24,7 +24,7 @@ "visionModelFeatureExtractor": "Extrator de características do modelo de visão" }, "tips": { - "context": "Você pode querer reindexar as incorporações de seus objetos rastreados uma vez que os modelos forem baixados.", + "context": "Você pode querer reindexar os embeddings de seus objetos rastreados uma vez que os modelos forem baixados.", "documentation": "Leia a documentação" }, "error": "Um erro ocorreu. Verifique os registos do Frigate." @@ -43,26 +43,28 @@ "mismatch_one": "{{count}} objeto indisponível foi detectado e incluido nesse item de revisão. Esse objeto ou não se qualifica para um alerta ou detecção, ou já foi limpo/deletado.", "mismatch_many": "{{count}} objetos indisponíveis foram detectados e incluídos nesse item de revisão. Esses objetos ou não se qualificam para um alerta ou detecção, ou já foram limpos/deletados.", "mismatch_other": "{{count}} objetos indisponíveis foram detectados e incluídos nesse item de revisão. Esses objetos ou não se qualificam para um alerta ou detecção, ou já foram limpos/deletados.", - "hasMissingObjects": "Ajustar a sua configuração se quiser que o Frigate salve objetos rastreados com as seguintes categorias: {{objects}}" + "hasMissingObjects": "Ajustar a sua configuração se quiser que o Frigate salve objetos rastreados com os seguintes rótulos: {{objects}}" }, "toast": { "success": { "regenerate": "Uma nova descrição foi solicitada do {{provider}}. Dependendo da velocidade do seu fornecedor, a nova descrição pode levar algum tempo para regenerar.", - "updatedSublabel": "Sub-categoria atualizada com sucesso.", - "updatedLPR": "Placa de identificação atualizada com sucesso." + "updatedSublabel": "Sub-rótulo atualizado com sucesso.", + "updatedLPR": "Placa de identificação atualizada com sucesso.", + "audioTranscription": "Transcrição de áudio requisitada com sucesso." }, "error": { "regenerate": "Falha ao ligar para {{provider}} para uma descrição nova: {{errorMessage}}", - "updatedSublabelFailed": "Falha ao atualizar sub-categoria: {{errorMessage}}", - "updatedLPRFailed": "Falha ao atualizar placa de identificação: {{errorMessage}}" + "updatedSublabelFailed": "Falha ao atualizar sub-rótulo: {{errorMessage}}", + "updatedLPRFailed": "Falha ao atualizar placa de identificação: {{errorMessage}}", + "audioTranscription": "Falha ao requisitar transcrição de áudio: {{errorMessage}}" } } }, - "label": "Categoria", + "label": "Rótulo", "editSubLabel": { - "title": "Editar sub-categoria", - "desc": "Nomeie uma nova sub categoria para esse(a) {{label}}", - "descNoLabel": "Nomeie uma nova sub-categoria para esse objeto rastreado" + "title": "Editar sub-rótulo", + "desc": "Nomeie um novo sub-rótulo para esse(a) {{label}}", + "descNoLabel": "Nomeie um sub-rótulo para esse objeto rastreado" }, "editLPR": { "title": "Editar placa de identificação", @@ -99,6 +101,9 @@ "tips": { "descriptionSaved": "Descrição salva com sucesso", "saveDescriptionFailed": "Falha ao atualizar a descrição: {{errorMessage}}" + }, + "score": { + "label": "Pontuação" } }, "trackedObjectDetails": "Detalhes do Objeto Rastreado", @@ -106,7 +111,7 @@ "details": "detalhes", "snapshot": "captura de imagem", "video": "vídeo", - "object_lifecycle": "ciclo de vida do obejto" + "object_lifecycle": "ciclo de vida do objeto" }, "objectLifecycle": { "title": "Ciclo de Vida do Objeto", @@ -184,12 +189,20 @@ }, "deleteTrackedObject": { "label": "Deletar esse objeto rastreado" + }, + "addTrigger": { + "label": "Adicionar gatilho", + "aria": "Adicionar um gatilho para esse objeto rastreado" + }, + "audioTranscription": { + "label": "Transcrever", + "aria": "Solicitar transcrição de áudio" } }, "dialog": { "confirmDelete": { "title": "Confirmar Exclusão", - "desc": "Deletar esse objeto rastreado remove a captura de imagem, qualquer embedding salvo, e quaisquer entradas de ciclo de vida de objeto associadas. Gravações desse objeto rastreado na visualização de Histórico NÃO serão deletadas.

    Tem certeza que quer prosseguir?" + "desc": "Deletar esse objeto rastreado remove a captura de imagem, quaisquer embeddings salvos, e quaisquer entradas de ciclo de vida de objeto associadas. Gravações desse objeto rastreado na visualização de Histórico NÃO serão deletadas.

    Tem certeza que quer prosseguir?" } }, "noTrackedObjects": "Nenhum Objeto Rastreado Encontrado", @@ -205,5 +218,11 @@ "error": "Falha ao detectar objeto rastreado {{errorMessage}}" } } + }, + "aiAnalysis": { + "title": "Análise de IA" + }, + "concerns": { + "label": "Preocupações" } } diff --git a/web/public/locales/pt-BR/views/faceLibrary.json b/web/public/locales/pt-BR/views/faceLibrary.json index d08b381102..912236cf45 100644 --- a/web/public/locales/pt-BR/views/faceLibrary.json +++ b/web/public/locales/pt-BR/views/faceLibrary.json @@ -38,8 +38,8 @@ "deleteFaceAttempts": { "title": "Apagar Rostos", "desc_one": "Você tem certeza que quer deletar {{count}} rosto? Essa ação não pode ser desfeita.", - "desc_many": "Você tem certeza que quer deletar {{count}} rostos? Essa ação não pode ser desfeita.", - "desc_other": "" + "desc_many": "Você tem certeza que quer deletar os {{count}} rostos? Essa ação não pode ser desfeita.", + "desc_other": "Você tem certeza que quer deletar os {{count}} rostos? Essa ação não pode ser desfeita." }, "renameFace": { "title": "Renomear Rosto", diff --git a/web/public/locales/pt-BR/views/live.json b/web/public/locales/pt-BR/views/live.json index 97ca4675ce..71f5ad625d 100644 --- a/web/public/locales/pt-BR/views/live.json +++ b/web/public/locales/pt-BR/views/live.json @@ -43,6 +43,14 @@ "out": { "label": "Diminuir Zoom na câmera PTZ" } + }, + "focus": { + "in": { + "label": "Aumentar foco da câmera PTZ" + }, + "out": { + "label": "Tirar foco da câmera PTZ" + } } }, "camera": { @@ -135,7 +143,8 @@ "recording": "Gravação", "snapshots": "Capturas de Imagem", "audioDetection": "Detecção de Áudio", - "autotracking": "Auto Rastreamento" + "autotracking": "Auto Rastreamento", + "transcription": "Transcrição de Áudio" }, "history": { "label": "Exibir gravação histórica" @@ -154,5 +163,9 @@ "label": "Editar Grupo de Câmera" }, "exitEdit": "Sair da Edição" + }, + "transcription": { + "enable": "Habilitar Transcrição de Áudio em Tempo Real", + "disable": "Desabilitar Transcrição de Áudio em Tempo Real" } } diff --git a/web/public/locales/pt-BR/views/settings.json b/web/public/locales/pt-BR/views/settings.json index c5e0af4385..9f755ab2d6 100644 --- a/web/public/locales/pt-BR/views/settings.json +++ b/web/public/locales/pt-BR/views/settings.json @@ -20,7 +20,8 @@ "frigateplus": "Frigate+", "motionTuner": "Ajuste de Movimento", "debug": "Depurar", - "enrichments": "Melhorias" + "enrichments": "Enriquecimentos", + "triggers": "Gatilhos" }, "dialog": { "unsavedChanges": { @@ -37,12 +38,12 @@ "liveDashboard": { "title": "Painel em Tempo Real", "automaticLiveView": { - "label": "Visão em Tempo Real Automática", - "desc": "Automaticamente alterar para a visão em tempo real da câmera quando alguma atividade for detectada. Desativar essa opção faz com que as imagens estáticas da câmera no Painel em Tempo Real atualizem apenas uma vez por minuto." + "label": "Visualização em Tempo Real Automática", + "desc": "Automaticamente alterar para a visualização em tempo real da câmera quando alguma atividade for detectada. Desativar essa opção faz com que as imagens estáticas da câmera no Painel em Tempo Real atualizem apenas uma vez por minuto." }, "playAlertVideos": { "label": "Reproduzir Alertas de Video", - "desc": "Por padrão, alertas recentes no Painel em Tempo Real sejam reproduzidos como vídeos em loop. Desative essa opção para mostrar apenas a imagens estáticas de alertas recentes nesse dispositivo / navegador." + "desc": "Por padrão, alertas recentes no Painel em Tempo Real são reproduzidos como vídeos em loop. Desative essa opção para mostrar apenas a imagens estáticas de alertas recentes nesse dispositivo / navegador." } }, "storedLayouts": { @@ -58,8 +59,8 @@ "recordingsViewer": { "title": "Visualizador de Gravações", "defaultPlaybackRate": { - "label": "Taxa Padrão de Reprodução", - "desc": "Taxa Padrão de Reprodução para Gravações." + "label": "Velocidade Padrão de Reprodução", + "desc": "Velocidade padrão de reprodução para gravações." } }, "calendar": { @@ -87,7 +88,7 @@ "unsavedChanges": "Alterações de configurações de Enriquecimento não salvas", "birdClassification": { "title": "Classificação de Pássaros", - "desc": "A classificação de pássaros identifica pássaros conhecidos usando o modelo Tensorflow quantizado. Quando um pássaro é reconhecido, o seu nome commum será adicionado como uma subcategoria. Essa informação é incluida na UI, filtros e notificações." + "desc": "A classificação de pássaros identifica pássaros conhecidos usando o modelo Tensorflow quantizado. Quando um pássaro é reconhecido, o seu nome comum será adicionado como um sub-rótulo. Essa informação é incluida na UI, filtros e notificações." }, "semanticSearch": { "title": "Busca Semântica", @@ -95,7 +96,7 @@ "readTheDocumentation": "Leia a Documentação", "reindexNow": { "label": "Reindexar Agora", - "desc": "A reindexação irá regenerar os embeddings para todos os objetos rastreados. Esse processo roda em segundo plano e pode 100% da CPU e levar um tempo considerável dependendo do número de objetos rastreados que você possui.", + "desc": "A reindexação irá regenerar os embeddings para todos os objetos rastreados. Esse processo roda em segundo plano e pode demandar 100% da CPU e levar um tempo considerável dependendo do número de objetos rastreados que você possui.", "confirmTitle": "Confirmar Reindexação", "confirmDesc": "Tem certeza que quer reindexar todos os embeddings de objetos rastreados? Esse processo rodará em segundo plano porém utilizará 100% da CPU e levará uma quantidade de tempo considerável. Você pode acompanhar o progresso na página Explorar.", "confirmButton": "Reindexar", @@ -108,7 +109,7 @@ "desc": "O tamanho do modelo usado para embeddings de pesquisa semântica.", "small": { "title": "pequeno", - "desc": "Usandopequeno emprega a versão quantizada do modelo que utiliza menos RAM e roda mais rápido na CPU, com diferenças negligíveis na qualidade dos embeddings." + "desc": "Usando pequeno emprega a versão quantizada do modelo que utiliza menos RAM e roda mais rápido na CPU, com diferenças negligíveis na qualidade dos embeddings." }, "large": { "title": "grande", @@ -118,24 +119,24 @@ }, "faceRecognition": { "title": "Reconhecimento Facial", - "desc": "O reconhecimento facial permite que pessoas sejam associadas a nomes e quando seus rostos forem reconhecidos, o Frigate associará o nome da pessoa como uma sub-categoria. Essa informação é inclusa na UI, filtros e notificações.", + "desc": "O reconhecimento facial permite que pessoas sejam associadas a nomes e quando seus rostos forem reconhecidos, o Frigate associará o nome da pessoa como um sub-rótulo. Essa informação é inclusa na UI, filtros e notificações.", "readTheDocumentation": "Leia a Documentação", "modelSize": { "label": "Tamanho do Modelo", "desc": "O tamanho do modelo usado para reconhecimento facial.", "small": { "title": "pequeno", - "desc": "Usar pequeno emprega o modelo de embedding de rosto FaceNet, que roda de maneira eficiente na maioria das CPUs." + "desc": "Usar o pequeno emprega o modelo de embedding de rosto FaceNet, que roda de maneira eficiente na maioria das CPUs." }, "large": { "title": "grande", - "desc": "Usando o grande emprega um modelo de embedding de rosto ArcFace e irá automáticamente roda pela GPU se aplicável." + "desc": "Usar o grande emprega um modelo de embedding de rosto ArcFace e irá automáticamente rodar pela GPU se aplicável." } } }, "licensePlateRecognition": { "title": "Reconhecimento de Placa de Identificação", - "desc": "O Frigate pode reconhecer placas de identificação em veículos e automáticamente adicionar os caracteres detectados ao campo placas_de_identificação_reconhecidas ou um nome conhecido como uma sub-categoria a objetos que são do tipo carro. Um uso típico é ler a placa de carros entrando em uma garagem ou carros passando pela rua.", + "desc": "O Frigate pode reconhecer placas de identificação em veículos e automáticamente adicionar os caracteres detectados ao campo placas_de_identificação_reconhecidas ou um nome conhecido como um sub-rótulo a objetos que são do tipo carro. Um uso típico é ler a placa de carros entrando em uma garagem ou carros passando pela rua.", "readTheDocumentation": "Leia a Documentação" }, "restart_required": "Necessário reiniciar (configurações de enriquecimento foram alteradas)", @@ -148,22 +149,22 @@ "title": "Configurações de Câmera", "streams": { "title": "Transmissões", - "desc": "Temporáriamente desativar a câmera até o Frigate reiniciar. Desatiar a câmera completamente impede o processamento da transmissão dessa câmera pelo Frigate. Detecções, gravações e depuração estarão indisponíveis.
    Nota: Isso não desativa as retransmissões do go2rtc." + "desc": "Temporáriamente desativa a câmera até o Frigate reiniciar. Desativar a câmera completamente impede o processamento da transmissão dessa câmera pelo Frigate. Detecções, gravações e depuração estarão indisponíveis.
    Nota: Isso não desativa as retransmissões do go2rtc." }, "review": { "title": "Revisar", - "desc": "Temporariamente habilitar/desabilitar alertas e detecções para essa câmera até o Frigate reiniciar. Quando desabilitado, nenhum novo item de revisão será gerado. ", + "desc": "Temporariamente habilita/desabilita alertas e detecções para essa câmera até o Frigate reiniciar. Quando desabilitado, nenhum novo item de revisão será gerado. ", "alerts": "Alertas ", "detections": "Detecções " }, "reviewClassification": { - "title": "Revisar Classificação", - "desc": "O Frigate categoriza itens de revisão como Alertas e Detecções. Por padrão, todas as pessoa e carros são considerados alertas. Você pode refinar a categorização dos seus itens revisados configurando as zonas requeridas para eles.", + "title": "Classificação de Revisões", + "desc": "O Frigate categoriza itens de revisão como Alertas e Detecções. Por padrão, todas as pessoas e carros são considerados alertas. Você pode refinar a categorização dos seus itens revisados configurando as zonas requeridas para eles.", "readTheDocumentation": "Leia a Documentação", "noDefinedZones": "Nenhuma zona definida para essa câmera.", "selectAlertsZones": "Selecionar as zonas para Alertas", "selectDetectionsZones": "Selecionar as zonas para Detecções", - "objectAlertsTips": "Todos os {{alertsLabels}} objetos em {{cameraName}} serão exibidos como Alertas.", + "objectAlertsTips": "Todos os objetos {{alertsLabels}} em {{cameraName}} serão exibidos como Alertas.", "zoneObjectAlertsTips": "Todos os {{alertsLabels}} objetos detectados em {{zone}} em {{cameraName}} serão exibidos como Alertas.", "objectDetectionsTips": "Todos os objetos {{detectionsLabels}} não categorizados em {{cameraName}} serão exibidos como Detecções independente de qual zona eles estiverem.", "zoneObjectDetectionsTips": { @@ -176,6 +177,44 @@ "toast": { "success": "A configuração de Revisão de Classificação foi salva. Reinicie o Frigate para aplicar as mudanças." } + }, + "object_descriptions": { + "title": "Descrições de Objeto por IA Generativa", + "desc": "Habilitar descrições por IA Generativa temporariamente para essa câmera. Quando desativada, as descrições geradas por IA não serão requisitadas para objetos rastreados para essa câmera." + }, + "review_descriptions": { + "title": "Revisar Descrições de IA Generativa", + "desc": "Habilitar/desabilitar temporariamente descrições de revisão de IA Generativa para essa câmera. Quando desativada, as descrições de IA Generativa não serão solicitadas para revisão para essa câmera." + }, + "addCamera": "Adicionar Câmera Nova", + "editCamera": "Editar Câmera:", + "selectCamera": "Selecione uma Câmera", + "backToSettings": "Voltar para as Configurções de Câmera", + "cameraConfig": { + "add": "Adicionar Câmera", + "edit": "Editar Câmera", + "description": "Configure as opções da câmera incluindo as de transmissão e papéis.", + "name": "Nome da Câmera", + "nameRequired": "Nome para a câmera é requerido", + "nameInvalid": "O nome da câmera deve contar apenas letras, números, sublinhado ou hífens", + "namePlaceholder": "ex: porta_da_frente", + "enabled": "Habilitado", + "ffmpeg": { + "inputs": "Transmissões de Entrada", + "path": "Caminho da Transmissão", + "pathRequired": "Um caminho para a transmissão é requerido", + "pathPlaceholder": "rtsp://...", + "roles": "Regras", + "rolesRequired": "Ao menos um papel é requerido", + "rolesUnique": "Cada papel (áudio, detecção, gravação) pode ser atribuído a uma única transmissão", + "addInput": "Adicionar Transmissão de Entrada", + "removeInput": "Remover Transmissão de Entrada", + "inputsRequired": "Ao menos uma transmissão de entrada é requerida" + }, + "toast": { + "success": "Câmera {{cameraName}} salva com sucesso" + }, + "nameLength": "O nome da câmera deve ter ao menos 24 caracteres." } }, "masksAndZones": { @@ -359,16 +398,16 @@ "documentation": "Leia o Guia de Ajuste de Movimento" }, "Threshold": { - "title": "Limite", + "title": "Limiar", "desc": "O valor do limiar dita o quanto de mudança na luminância de um pixel é requerida para ser considerada movimento. Padrão: 30" }, "contourArea": { "title": "Área de contorno", - "desc": "O valor do contorno da área é usado para decidir quais grupos de mudança de pixel se qualificam como movimento. Padrão: 10" + "desc": "O valor da área de contorno é usado para decidir quais grupos de mudança de pixel se qualificam como movimento. Padrão: 10" }, "improveContrast": { "title": "Melhorar o contraste", - "desc": "Melhorar contraste para cenas escuras. Padrão: ON" + "desc": "Melhorar contraste para cenas escuras. Padrão: Ativado" }, "toast": { "success": "As configurações de movimento foram salvas." @@ -420,14 +459,27 @@ "noObjects": "Nenhum Objeto", "timestamp": { "title": "Timestamp", - "desc": "Sobreponha um timestamp na imagem" - } + "desc": "Sobrepor um timestamp na imagem" + }, + "paths": { + "title": "Caminho", + "desc": "Mostrar pontos significantes do caminho do objeto rastreado", + "tips": "

    Caminhos


    Linhas e círculos indicarão pontos significantes por onde o objeto rastreado se moveu durante o seu ciclo de vida.

    " + }, + "audio": { + "title": "Áudio", + "noAudioDetections": "Nenhuma detecção de áudio", + "score": "pontuanção", + "currentRMS": "RMS Atual", + "currentdbFS": "dbFS Atual" + }, + "openCameraWebUI": "Abrir a Interface Web de {{camera}}" }, "users": { "title": "Usuários", "management": { "title": "Gerenciamento de Usuário", - "desc": "Gerencias as contas de usuário dessa instância do Frigate." + "desc": "Gerenciar as contas de usuário dessa instância do Frigate." }, "addUser": "Adicionar Usuário", "updatePassword": "Atualizar Senha", @@ -506,7 +558,8 @@ "admin": "Administrador", "adminDesc": "Acesso total a todos os recursos.", "viewer": "Espectador", - "viewerDesc": "Limitado aos Painéis ao Vivo, Revisar, Explorar, e Exportar somente." + "viewerDesc": "Limitado aos Painéis ao Vivo, Revisar, Explorar, e Exportar somente.", + "customDesc": "Papel customizado com acesso a câmeras específicas." } } }, @@ -580,7 +633,7 @@ "apiKey": { "title": "Chave de API do Frigate+", "validated": "A chave de API do Frigate+ detectada e validada", - "notValidated": "A chave de API do Frigate+ não detectada ou não validada", + "notValidated": "Chave de API do Frigate+ não detectada ou não validada", "desc": "A chave de API do Frigate+ habilita a integração com o serviço do Frigate+.", "plusLink": "Leia mais sobre o Frigate+" }, @@ -603,7 +656,7 @@ }, "snapshotConfig": { "title": "Configuração de Captura de Imagem", - "desc": "Enviar ao Frigate+ requer tanto a captura de imagem quanto a captura de imagem clean_copy estarem habilitadas na sua configuração.", + "desc": "Envios ao Frigate+ requerem tanto a captura de imagem normais quanto a captura de imagem clean_copy estarem habilitadas na sua configuração.", "documentation": "Leia a documentação", "cleanCopyWarning": "Algumas câmeras possuem captura de imagem habilitada porém têm a cópia limpa desabilitada. Você precisa habilitar a clean_copy nas suas configurações de captura de imagem para poder submeter imagems dessa câmera ao Frigate+.", "table": { @@ -618,5 +671,160 @@ "success": "As configurações do Frigate+ foram salvas. Reinicie o Frigate para aplicar as alterações.", "error": "Falha ao salvar as alterações de configuração: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Gatilhos", + "management": { + "title": "Gerenciamento de Gatilhos", + "desc": "Gerenciar gatilhos para {{camera}}. Use o tipo de miniatura para acionar miniaturas semelhantes para os seus objetos rastreados selecionados, e o tipo de descrição para acionar descrições semelhantes para textos que você especifica." + }, + "addTrigger": "Adicionar Gatilho", + "table": { + "name": "Nome", + "type": "Tipo", + "content": "Conteúdo", + "threshold": "Limiar", + "actions": "Ações", + "noTriggers": "Nenhum gatilho configurado para essa câmera.", + "edit": "Editar", + "deleteTrigger": "Apagar Gatilho", + "lastTriggered": "Acionado pela última vez" + }, + "type": { + "thumbnail": "Miniatura", + "description": "Descrição" + }, + "actions": { + "alert": "Marcar como Alerta", + "notification": "Enviar Notificação" + }, + "dialog": { + "createTrigger": { + "title": "Criar Gatilho", + "desc": "Criar gatilho para a câmera {{camera}}" + }, + "editTrigger": { + "title": "Editar Gatilho", + "desc": "Editar as configurações de gatilho na câmera {{camera}}" + }, + "deleteTrigger": { + "title": "Apagar Gatilho", + "desc": "Tem certeza que quer deletar o gatilho {{triggerName}}? Essa ação não pode ser desfeita." + }, + "form": { + "name": { + "title": "Nome", + "placeholder": "Digite o nome do gatilho", + "error": { + "minLength": "O nome precisa ter no mínimo 2 caracteres.", + "invalidCharacters": "O nome pode contar apenas letras, números, sublinhados, e hífens.", + "alreadyExists": "Um gatilho com esse nome já existe para essa câmera." + } + }, + "enabled": { + "description": "Habilitar ou desabilitar esse gatilho" + }, + "type": { + "title": "Tipo", + "placeholder": "Selecionar o tipo de gatilho" + }, + "content": { + "title": "Conteúdo", + "imagePlaceholder": "Selecionar uma imagem", + "textPlaceholder": "Digitar conteúdo do texto", + "imageDesc": "Selecionar uma imagem para acionar essa ação quando uma imagem semelhante for detectada.", + "textDesc": "Digite o texto para ativar essa ação quando uma descrição semelhante de objeto rastreado for detectada.", + "error": { + "required": "Um conteúdo é requerido." + } + }, + "threshold": { + "title": "Limiar", + "error": { + "min": "O limitar deve ser no mínimo 0", + "max": "O limiar deve ser no mínimo 1" + } + }, + "actions": { + "title": "Ações", + "desc": "Por padrão, o Frigate dispara uma mensagem MQTT para todos os gatilhos. Escolha uma ação adicional para realizar quando uma ação for disparada.", + "error": { + "min": "Ao menos uma ação deve ser selecionada." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Gatilho {{name}} criado com sucesso.", + "updateTrigger": "Gatilho {{name}} atualizado com sucesso.", + "deleteTrigger": "Gatilho {{name}} apagado com sucesso." + }, + "error": { + "createTriggerFailed": "Falha ao criar gatilho: {{errorMessage}}", + "updateTriggerFailed": "Falha ao atualizar gatilho: {{errorMessage}}", + "deleteTriggerFailed": "Falha ao apagar gatilho: {{errorMessage}}" + } + } + }, + "roles": { + "management": { + "title": "Gerenciamento do Papel de Visualizador", + "desc": "Gerenciar papéis de visualizador customizados e suas permissões de acesso para essa instância do Frigate." + }, + "addRole": "Adicionar Papel", + "table": { + "role": "Papel", + "cameras": "Câmeras", + "actions": "Ações", + "noRoles": "Nenhum papel customizado encontrado.", + "editCameras": "Editar Câmeras", + "deleteRole": "Apagar Papel" + }, + "toast": { + "success": { + "createRole": "Papel {{role}} criado com sucesso", + "updateCameras": "Câmeras atualizados para o papel {{role}}", + "deleteRole": "Papel {{role}} apagado com sucesso", + "userRolesUpdated": "{{count}} usuário(os) atribuídos a esse papel foram atualizados para 'visualizador', que possui acesso a todas as câmeras." + }, + "error": { + "createRoleFailed": "Falha ao criar papel: {{errorMessage}}", + "updateCamerasFailed": "Falha ao atualizar câmeras: {{errorMessage}}", + "deleteRoleFailed": "Falha ao apagar papel: {{errorMessage}}", + "userUpdateFailed": "Falha ao atualizar papel do usuário: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Criar Novo Papel", + "desc": "Adicionar um novo papel e especificar permissões de acesso." + }, + "editCameras": { + "title": "Editar Câmeras de Papéis", + "desc": "Atualizar acesso da câmera para o papel {{role}}." + }, + "deleteRole": { + "title": "Deletar Papel", + "desc": "Essa ação não pode ser desfeita. Isso irá apagar permanentemente o papel e atribuir a quaisquer usuários com esse papel como 'visualizador', o que dará acesso de visualização para todas as câmeras.", + "warn": "Tem certeza que quer apagar {{role}}?", + "deleting": "Apagando…" + }, + "form": { + "role": { + "title": "Nome do Papel", + "placeholder": "Digitar nome do papel", + "desc": "Apenas letras, números, pontos e sublinhados são permitidos.", + "roleIsRequired": "Nome para o papel é requerido", + "roleOnlyInclude": "O nome do papel pode conter apenas letras, números, pontos ou sublinhados", + "roleExists": "Um papel com esse nome já existe." + }, + "cameras": { + "title": "Câmeras", + "desc": "Selecione as câmeras que esse papel terá acesso. Ao menos uma câmera é requerida.", + "required": "Ao menos uma câmera deve ser selecionada." + } + } + } } } diff --git a/web/public/locales/pt-BR/views/system.json b/web/public/locales/pt-BR/views/system.json index 74a2c4564b..4875d80150 100644 --- a/web/public/locales/pt-BR/views/system.json +++ b/web/public/locales/pt-BR/views/system.json @@ -42,7 +42,8 @@ "inferenceSpeed": "Velocidade de Inferência do Detector", "temperature": "Detector Temperatura", "cpuUsage": "Utilização de CPU de Detecção", - "memoryUsage": "Utilização de Memória do Detector" + "memoryUsage": "Utilização de Memória do Detector", + "cpuUsageInformation": "CPU utilizado para preparar os dados de entrada e saída de/para os modelos de detecção. Esse valor não mede a utilização da inferência, mesmo se estiver usando um GPU ou acelerador." }, "hardwareInfo": { "title": "Informações de Hardware", @@ -102,6 +103,10 @@ "title": "Não Utilizado", "tips": "Esse valor por não representar com precisão o espaço livre disponí®el para o Frigate se você possui outros arquivos armazenados no seu drive além das gravações do Frigate. O Frigate não rastreia a utilização do armazenamento além de suas próprias gravações." } + }, + "shm": { + "title": "Alocação de memória compartilhada (SHM)", + "warning": "O tamanho de {{total}}MB de memória compartilhada (SHM) é insuficiente. Aumente para ao menos {{min_shm}}MB." } }, "cameras": { @@ -157,8 +162,9 @@ "detectHighCpuUsage": "{{camera}} possui alta utilização de CPU para detecção ({{detectAvg}}%)", "healthy": "O sistema está saudável", "cameraIsOffline": "{{camera}} está offline", - "reindexingEmbeddings": "Reindexando os vetores de característica de imagens ({{processed}}% completado)", - "detectIsSlow": "{{detect}} está lento ({{speed}} ms)" + "reindexingEmbeddings": "Reindexando os embeddings ({{processed}}% completado)", + "detectIsSlow": "{{detect}} está lento ({{speed}} ms)", + "shmTooLow": "A alocação ({{total}} MB) para a pasta /dev/shm deve ser aumentada para ao menos {{min}} MB." }, "enrichments": { "title": "Enriquecimentos", @@ -167,13 +173,13 @@ "face_recognition": "Reconhecimento Facial", "plate_recognition": "Reconhecimento de Placa", "plate_recognition_speed": "Velocidade de Reconhecimento de Placas", - "text_embedding_speed": "Velocidade de Geração de Vetores de Texto", + "text_embedding_speed": "Velocidade de Embeddings de Texto", "yolov9_plate_detection_speed": "Velocidade de Reconhecimento de Placas do YOLOv9", "yolov9_plate_detection": "Detecção de Placas do YOLOv9", - "image_embedding": "Vetores de Características de Imagens", - "text_embedding": "Vetor de Característica de Texto", - "image_embedding_speed": "Velocidade de Geração de Vetores de Imagem", - "face_embedding_speed": "Velocidade de Geração de Vetores de Rostos", + "image_embedding": "Embeddings de Imagens", + "text_embedding": "Embeddings de Texto", + "image_embedding_speed": "Velocidade de Embeddings de Imagens", + "face_embedding_speed": "Velocidade de Embedding de Rostos", "face_recognition_speed": "Velocidade de Reconhecimento de Rostos" } } diff --git a/web/public/locales/pt/audio.json b/web/public/locales/pt/audio.json index 36b4147160..3bf1ba60b4 100644 --- a/web/public/locales/pt/audio.json +++ b/web/public/locales/pt/audio.json @@ -1,8 +1,8 @@ { - "babbling": "Balbuciar", + "babbling": "Falador", "speech": "Discurso", "whoop": "Grito de Alegria", - "bellow": "Abaixo", + "bellow": "Debaixo", "yell": "Gritar", "whispering": "Sussurrar", "child_singing": "Criança a Cantar", @@ -14,7 +14,7 @@ "meow": "Miau", "run": "Correr", "sheep": "Ovelha", - "motorcycle": "Motociclo", + "motorcycle": "Mota", "car": "Carro", "cat": "Gato", "horse": "Cavalo", @@ -33,15 +33,15 @@ "whistling": "Assobiar", "wheeze": "Chiadeira", "gasp": "Ofegar", - "cough": "Tosse", - "sneeze": "Espirro", + "cough": "Tossir", + "sneeze": "Espirrar", "footsteps": "Passos", "chewing": "Mastigar", "biting": "Morder", "gargling": "Gargarejar", "stomach_rumble": "Ronco de Estômago", "burping": "Arroto", - "hiccup": "Solavanco", + "hiccup": "Soluço", "fart": "Pum", "hands": "Mãos", "finger_snapping": "Estalar os Dedos", @@ -109,7 +109,7 @@ "helicopter": "Helicóptero", "engine": "Motor", "coin": "Moeda", - "scissors": "Tesoura", + "scissors": "Tesouras", "electric_shaver": "Barbeador Elétrico", "computer_keyboard": "Teclado de Computador", "alarm": "Alarme", @@ -145,12 +145,12 @@ "owl": "Coruja", "mouse": "Rato", "vehicle": "Veículo", - "hair_dryer": "Secador de cabelo", - "toothbrush": "Escova de dentes", - "sink": "Pia", + "hair_dryer": "Secador de Cabelo", + "toothbrush": "Escova de Dentes", + "sink": "Banca", "blender": "Liquidificador", "pant": "Ofegar", - "snort": "Espirrar pelo Nariz", + "snort": "Resfolegar", "throat_clearing": "Limpar a Garganta", "sniff": "Cheirar", "shuffle": "Embaralhar", diff --git a/web/public/locales/pt/common.json b/web/public/locales/pt/common.json index ad63195c1f..97d543802f 100644 --- a/web/public/locales/pt/common.json +++ b/web/public/locales/pt/common.json @@ -2,13 +2,13 @@ "time": { "last30": "Últimos 30 dias", "12hours": "12 horas", - "justNow": "Agora", + "justNow": "Agora mesmo", "yesterday": "Ontem", "today": "Hoje", "last7": "Últimos 7 dias", "last14": "Últimos 14 dias", - "thisWeek": "Essa semana", - "lastWeek": "Semana passada", + "thisWeek": "Esta Semana", + "lastWeek": "Semana Passada", "5minutes": "5 minutos", "10minutes": "10 minutos", "30minutes": "30 minutos", @@ -24,8 +24,8 @@ "day_one": "{{time}} dia", "day_many": "{{time}} dias", "day_other": "{{time}} dias", - "thisMonth": "Esse mês", - "lastMonth": "Mês passado", + "thisMonth": "Este Mês", + "lastMonth": "Mês Passado", "1hour": "1 hora", "hour_one": "{{time}} hora", "hour_many": "{{time}} horas", @@ -39,7 +39,7 @@ "untilForTime": "Até {{time}}", "untilForRestart": "Até que o Frigate reinicie.", "untilRestart": "Até reiniciar", - "ago": "{{timeAgo}} atrás", + "ago": "há {{timeAgo}}", "d": "{{time}}d", "h": "{{time}}h", "m": "{{time}}m", @@ -91,64 +91,64 @@ }, "unit": { "speed": { - "kph": "kph", + "kph": "km/h", "mph": "mph" }, "length": { - "feet": "pé", + "feet": "pés", "meters": "metros" } }, "button": { - "enabled": "Habilitado", - "enable": "Habilitar", + "enabled": "Ativado", + "enable": "Ativar", "done": "Feito", "reset": "Reiniciar", - "disabled": "Desabilitado", - "saving": "Salvando…", + "disabled": "Desativado", + "saving": "A guardar…", "apply": "Aplicar", - "disable": "Desabilitar", - "save": "Salvar", - "copy": "Cópia", + "disable": "Desativar", + "save": "Guardar", + "copy": "Copiar", "cancel": "Cancelar", "close": "Fechar", "history": "Histórico", "back": "Voltar", "fullscreen": "Ecrã Completo", "exitFullscreen": "Sair do Ecrã Completo", - "twoWayTalk": "Conversa bidirecional", - "cameraAudio": "Áudio da câmera", + "twoWayTalk": "Conversa Bidirecional", + "cameraAudio": "Áudio da Câmera", "edit": "Editar", "off": "DESLIGADO", "copyCoordinates": "Copiar coordenadas", "on": "LIGADO", - "delete": "Excluir", - "download": "Download", - "info": "Informações", + "delete": "Eliminar", + "download": "Transferir", + "info": "Informação", "no": "Não", "suspended": "Suspenso", "yes": "Sim", - "unselect": "Desmarcar", + "unselect": "Desselecionar", "unsuspended": "Dessuspender", - "deleteNow": "Excluir agora", + "deleteNow": "Eliminar Agora", "export": "Exportar", - "next": "Próximo", + "next": "Seguinte", "play": "Tocar", - "pictureInPicture": "Sobrepor Imagem" + "pictureInPicture": "Imagem sobre Imagem" }, "label": { "back": "Voltar" }, "menu": { "user": { - "logout": "Sair", + "logout": "Terminar sessão", "account": "Conta", "current": "Utilizador atual: {{user}}", - "setPassword": "Definir senha", + "setPassword": "Definir Palavra-passe", "title": "Utilizador", - "anonymous": "anônimo" + "anonymous": "anónimo" }, - "faceLibrary": "Biblioteca de rostos", + "faceLibrary": "Biblioteca de Rostos", "withSystem": "Sistema", "theme": { "label": "Tema", @@ -156,58 +156,66 @@ "green": "Verde", "red": "Vermelho", "contrast": "Alto contraste", - "default": "Padrão", + "default": "Predefinição", "highcontrast": "Alto Contraste", "nord": "Nord" }, "system": "Sistema", "systemMetrics": "Métricas do sistema", "configuration": "Configuração", - "systemLogs": "Logs do sistema", - "settings": "Configurações", - "configurationEditor": "Editor de configuração", + "systemLogs": "Registos do sistema", + "settings": "Definições", + "configurationEditor": "Editor de Configuração", "languages": "Idiomas", "language": { - "en": "Inglês (English)", - "zhCN": "Chinês simplificado", + "en": "Inglês (EUA)", + "zhCN": "Chinês (Chinês Simplificado)", "withSystem": { - "label": "Use as configurações do sistema para idioma" + "label": "Utilizar as definições do sistema para o idioma" }, - "fr": "Français (Francês)", - "es": "Español (Espanhol)", - "ru": "Русский (Russo)", - "de": "Deutsch (Alemão)", - "ja": "日本語 (Japonês)", - "yue": "Cantonês (粵語)", - "ar": "العربية (Arabic)", - "uk": "Ucraniano (Українська)", - "el": "Grego (Ελληνικά)", - "hi": "हिन्दी (Hindi)", - "pt": "Português (Portuguese)", - "tr": "Türkçe (Turkish)", - "it": "Italiano (Italian)", - "nb": "Norueguês Bokmål (Norsk Bokmål)", - "ko": "Coreano (한국어)", - "vi": "Vietnamita (Tiếng Việt)", - "nl": "Nederlands (Dutch)", - "sv": "Svenska (Swedish)", - "cs": "Tcheco (Čeština)", - "fa": "Persa (فارسی)", - "pl": "Polonês (Polski)", - "he": "Hebraico (עברית)", - "fi": "Finlandês (Suomi)", - "da": "Dinamarquês (Dansk)", - "ro": "Romeno (Română)", - "hu": "Húngaro (Magyar)", - "sk": "Eslovaco (Slovenčina)", + "fr": "Francês (França)", + "es": "Espanhol (Espanha)", + "ru": "Russo", + "de": "Alemão (Alemanha)", + "ja": "Japonês", + "yue": "Cantonês", + "ar": "Árabe", + "uk": "Ucraniano", + "el": "Grego", + "hi": "Híndi (Índia)", + "pt": "Português (Portugal)", + "tr": "Turco (Turquia)", + "it": "Italiano (Itália)", + "nb": "Norueguês Bokmål", + "ko": "Coreano", + "vi": "Vietnamita", + "nl": "Holandês (Holanda)", + "sv": "Sueco", + "cs": "Checo", + "fa": "Persa", + "pl": "Polaco", + "he": "Hebraico", + "fi": "Finlandês", + "da": "Dinamarquês", + "ro": "Romeno", + "hu": "Húngaro", + "sk": "Eslovaco", "th": "Tailandês", - "ca": "Català (Catalão)" + "ca": "Catalão", + "ptBR": "Português (Brazil)", + "sr": "Sérvio", + "sl": "Esloveno", + "lt": "Lituano", + "bg": "Búlgaro", + "gl": "Galego", + "id": "Indonésio Bahasa", + "ur": "Urdu" }, "appearance": "Aparência", "darkMode": { - "label": "Modo escuro", + "label": "Modo Escuro", "withSystem": { - "label": "Use as configurações do sistema para o modo claro ou escuro" + "label": "Utilizar as definições do sistema para o modo claro ou escuro" }, "light": "Claro", "dark": "Escuro" @@ -220,7 +228,7 @@ "restart": "Reiniciar Frigate", "live": { "title": "Ao vivo", - "allCameras": "Todas as câmaras", + "allCameras": "Todas as Câmaras", "cameras": { "title": "Câmaras", "count_one": "{{count}} Câmera", @@ -230,8 +238,8 @@ }, "export": "Exportar", "explore": "Explorar", - "review": "Análise", - "uiPlayground": "Área de Testes da Interface" + "review": "Rever", + "uiPlayground": "Área de Testes da IU" }, "pagination": { "previous": { @@ -240,36 +248,37 @@ }, "label": "paginação", "next": { - "title": "Próximo", - "label": "Ir para a próxima página" + "title": "Seguinte", + "label": "Ir para a página seguinte" }, "more": "Mais páginas" }, "role": { "admin": "Administrador", "viewer": "Visualizador", - "title": "Regra", - "desc": "Administradores têm acesso total a todos os recursos da interface do Frigate. Visualizadores estão limitados a visualizar câmeras, revisar itens e assistir o histórico de gravaçoes na interface." + "title": "Função", + "desc": "Os administradores têm acesso completo a todas as funcionalidades da IU do Frigate. Os visualizadores estão limitados a visualizar as câmeras, rever itens, e o histórico de gravaçoes na IU." }, "toast": { - "copyUrlToClipboard": "URL copiada para a área de transferência.", + "copyUrlToClipboard": "URL copiado para a área de transferência.", "save": { - "title": "Salvar", + "title": "Guardar", "error": { - "noMessage": "Falha ao salvar as alterações de configuração", - "title": "Falha ao salvar as alterações de configuração: {{errorMessage}}" + "noMessage": "Não foi possível guardar as alterações da configuração", + "title": "Não foi possível guardar as alterações da configuração: {{errorMessage}}" } } }, "accessDenied": { - "documentTitle": "Acesso negado - Frigate", - "title": "Acesso negado", - "desc": "Você não tem permissão para visualizar esta página." + "documentTitle": "Frigate - Acesso Negado", + "title": "Acesso Negado", + "desc": "Não tem permissão para ver esta página." }, "notFound": { - "documentTitle": "Não encontrado - Frigate", + "documentTitle": "Frigate - Não Encontrado", "desc": "Página não encontrada", "title": "404" }, - "selectItem": "Selecionar {{item}}" + "selectItem": "Selecionar {{item}}", + "readTheDocumentation": "Leia a documentação" } diff --git a/web/public/locales/pt/components/auth.json b/web/public/locales/pt/components/auth.json index 5dcccd7d67..fc00399b14 100644 --- a/web/public/locales/pt/components/auth.json +++ b/web/public/locales/pt/components/auth.json @@ -1,15 +1,15 @@ { "form": { "user": "Nome do utilizador", - "login": "Login", + "login": "Iniciar sessão", "errors": { "usernameRequired": "O nome do utilizador é obrigatório", - "passwordRequired": "Senha é necessária", + "passwordRequired": "A palavra-passe é obrigatória", "rateLimit": "Limite de taxa excedido. Tente novamente mais tarde.", - "loginFailed": "Falha no login", - "unknownError": "Erro desconhecido. Verifique os logs.", - "webUnknownError": "Erro desconhecido. Verifique os logs da consola." + "loginFailed": "Autenticação falhou", + "unknownError": "Erro desconhecido. Verifique os registos.", + "webUnknownError": "Erro desconhecido. Verifique os registos da consola." }, - "password": "Senha" + "password": "Palavra-passe" } } diff --git a/web/public/locales/pt/components/camera.json b/web/public/locales/pt/components/camera.json index fa4a5fdc15..3f7052c817 100644 --- a/web/public/locales/pt/components/camera.json +++ b/web/public/locales/pt/components/camera.json @@ -1,27 +1,27 @@ { "group": { - "label": "Grupos de câmaras", - "add": "Adicionar grupo de câmaras", - "edit": "Editar grupo de câmaras", + "label": "Grupos de Câmaras", + "add": "Adicionar Gupo de Câmaras", + "edit": "Editar Grupo de Câmaras", "delete": { - "label": "Excluir grupo de câmaras", + "label": "Eliminar Grupo de Câmaras", "confirm": { - "title": "Confirmar exclusão", - "desc": "Tem certeza de que deseja excluir o grupo de câmaras {{name}}?" + "title": "Confirmar Eliminar", + "desc": "Tem a certeza que deseja eliminar o grupo de câmaras {{name}}?" } }, "name": { "label": "Nome", - "placeholder": "Digita um nome…", + "placeholder": "Inserir um nome…", "errorMessage": { "exists": "O nome do grupo de câmaras já existe.", "nameMustNotPeriod": "O nome do grupo de câmaras não deve conter pontos.", - "mustLeastCharacters": "O nome do grupo de câmaras deve ter pelo menos 2 caracteres.", - "invalid": "Nome de grupo de câmaras inválido." + "mustLeastCharacters": "O nome do grupo de câmaras deve ter pelo menos 2 carateres.", + "invalid": "Nome do grupo de câmaras inválido." } }, "cameras": { - "desc": "Selecione câmaras para este grupo.", + "desc": "Selecione as câmaras para este grupo.", "label": "Câmaras" }, "icon": "Ícone", @@ -37,17 +37,17 @@ } }, "streamMethod": { - "label": "Método de transmissão", + "label": "Método de Transmissão", "method": { "smartStreaming": { - "label": "Transmissão inteligente (recomendado)", - "desc": "A transmissão inteligente atualizará a imagem da sua câmara uma vez por minuto quando nenhuma atividade detectável estiver ocorrendo para conservar largura de banda e recursos. Quando a atividade é detectada, a imagem muda perfeitamente para uma transmissão ao vivo." + "label": "Transmissão Inteligente (recomendado)", + "desc": "A transmissão inteligente atualizará a imagem da sua câmara uma vez por minuto quando não ocorrer nenhuma atividade detetável para conservar largura de banda e recursos. Quando a atividade é detetada, a imagem muda perfeitamente para uma transmissão ao vivo." }, "continuousStreaming": { - "label": "Transmissão contínua", + "label": "Transmissão Contínua", "desc": { - "warning": "A transmissão contínua pode causar alto uso de largura de banda e problemas de desempenho. Use com precaução.", - "title": "A imagem da câmara sempre será uma transmissão ao vivo quando visível no painel, mesmo que nenhuma atividade esteja sendo detectada." + "warning": "A transmissão contínua pode causar a utilização alta da largura de banda e problemas de desempenho. Utilize com precaução.", + "title": "A imagem da câmara será sempre uma transmissão ao vivo quando visível no painel, mesmo que não esteja a ser detetada nenhuma atividade." } }, "noStreaming": { @@ -59,24 +59,25 @@ }, "compatibilityMode": { "label": "Modo de compatibilidade", - "desc": "Habilite esta opção somente se a transmissão ao vivo da sua câmara estiver exibindo artefatos de cor e tiver uma linha diagonal no lado direito da imagem." + "desc": "Ative esta opção apenas se a transmissão ao vivo da sua câmara estiver a exibir artefatos de cor e tiver uma linha diagonal no lado direito da imagem." }, - "label": "Configurações de transmissão da câmara", - "desc": "Altere as opções de transmissão ao vivo para o painel deste grupo de câmaras. Essas configurações são específicas do dispositivo/navegador.", - "title": "{{cameraName}} configurações de transmissão", + "label": "Definições de Transmissão da Câmara", + "desc": "Altere as opções de transmissão ao vivo para o painel deste grupo de câmaras. Estas definições são específicas do dispositivo/navegador.", + "title": "{{cameraName}} Definições de Transmissão", "placeholder": "Escolha uma transmissão", "stream": "Transmissão" - } + }, + "birdseye": "Vista Aérea" } }, "debug": { "options": { - "label": "Configurações", + "label": "Definições", "title": "Opções", - "hideOptions": "Ocultar opções", - "showOptions": "Mostrar opções" + "hideOptions": "Ocultar Opções", + "showOptions": "Mostrar Opções" }, - "boundingBox": "Caixa delimitadora", + "boundingBox": "Caixa Delimitadora", "timestamp": "Carimbo de hora", "zones": "Zonas", "mask": "Máscara", diff --git a/web/public/locales/pt/components/dialog.json b/web/public/locales/pt/components/dialog.json index 766711539f..b1aeb06c16 100644 --- a/web/public/locales/pt/components/dialog.json +++ b/web/public/locales/pt/components/dialog.json @@ -2,17 +2,17 @@ "restart": { "button": "Reiniciar", "restarting": { - "title": "Frigate está reiniciando", + "title": "Frigate está a reiniciar", "content": "Esta página será recarregada em {{countdown}} segundos.", - "button": "Forçar atualização agora" + "button": "Forçar Recarregar Agora" }, - "title": "Tem certeza de que deseja reiniciar o Frigate?" + "title": "Tem a certeza que deseja reiniciar o Frigate?" }, "explore": { "plus": { "submitToPlus": { - "label": "Enviar para Frigate+", - "desc": "Objetos em locais que você quer evitar não são falsos positivos. Enviá-los como falsos positivos confundirá o modelo." + "label": "Submeter para Frigate+", + "desc": "Os objetos nas localizações que quer evitar não são falsos positivos. Submete-los como falsos positivos confundirá o modelo." }, "review": { "true": { @@ -22,7 +22,7 @@ "true_other": "Estão são {{label}}" }, "state": { - "submitted": "Enviado" + "submitted": "Submetido" }, "false": { "label": "Não confirmar esta etiqueta para Frigate Plus", @@ -31,15 +31,15 @@ "false_other": "Estes não são {{label}}" }, "question": { - "label": "Confirme este rótulo para Frigate Plus", + "label": "Confirme esta etiqueta para Frigate Plus", "ask_a": "Este objeto é um {{label}}?", "ask_an": "Este objeto é um {{label}}?", - "ask_full": "Este objeto é um(a) {{untranslatedLabel}} ({{translatedLabel}})?" + "ask_full": "Este objeto é um {{untranslatedLabel}} ({{translatedLabel}})?" } } }, "video": { - "viewInHistory": "Ver no histórico" + "viewInHistory": "Ver no Histórico" } }, "export": { @@ -60,67 +60,74 @@ }, "export": "Exportar", "toast": { - "success": "Exportação iniciada com sucesso. Veja o arquivo na pasta /exports.", + "success": "Exportação iniciada com sucesso. Veja o ficheiro na pasta de exportações.", "error": { - "failed": "Falha ao iniciar a exportação: {{error}}", + "failed": "Não foi possível iniciar a exportação: {{error}}", "endTimeMustAfterStartTime": "O horário de término deve ser posterior ao horário de início", "noVaildTimeSelected": "Nenhum intervalo de tempo válido selecionado" } }, "selectOrExport": "Selecionar ou Exportar", "fromTimeline": { - "saveExport": "Salvar exportação", - "previewExport": "Visualizar exportação" + "saveExport": "Guardar Exportação", + "previewExport": "Pré-visualizar Exportação" }, - "select": "Selecione", + "select": "Selecionar", "name": { - "placeholder": "Nome da exportação" + "placeholder": "Nome da Exportação" } }, "streaming": { "showStats": { "label": "Mostrar estatísticas de transmissão", - "desc": "Habilite esta opção para mostrar estatísticas de transmissão como uma sobreposição no feed da câmara." + "desc": "Ative esta opção para mostrar as estatísticas de transmissão como uma sobreposição na feed da câmara." }, "restreaming": { "desc": { - "title": "Configure o go2rtc para obter opções adicionais de visualização ao vivo e áudio para esta câmara.", + "title": "Configure go2rtc para obter opções adicionais da visualização ao vivo e o áudio para esta câmara.", "readTheDocumentation": "Leia a documentação" }, - "disabled": "A retransmissão não está habilitada para esta câmara." + "disabled": "A retransmissão não está ativada para esta câmara." }, "label": "Transmissão", - "debugView": "Exibição de depuração" + "debugView": "Ver Depuração" }, "search": { "saveSearch": { - "label": "Salvar pesquisa", - "overwrite": "{{searchName}} já existe. Salvar substituirá o valor existente.", - "success": "A pesquisa ({{searchName}}) foi salva.", + "label": "Guardar Procura", + "overwrite": "{{searchName}} já existe. Ao guardar irá substituir o valor existente.", + "success": "A procura ({{searchName}}) foi guardada.", "button": { "save": { - "label": "Salvar esta pesquisa" + "label": "Guardar esta procura" } }, - "placeholder": "Digite um nome para sua pesquisa", - "desc": "Forneça um nome para esta pesquisa salva." + "placeholder": "Insira um nome para a sua procura", + "desc": "Forneça um nome para esta procura guardada." } }, "recording": { "confirmDelete": { - "title": "Confirmar exclusão", + "title": "Confirmar Eliminar", "desc": { - "selected": "Tem certeza de que deseja excluir todos os vídeos gravados associados a este item de analise?

    Segure a tecla Shift para ignorar esta caixa de diálogo no futuro." + "selected": "Tem a certeza que deseja eliminar todos os vídeos guardados associados com este item de análise?

    Pressione a tecla Shift para ignorar esta janela no futuro." }, "toast": { - "success": "As imagens de vídeo associadas aos itens de analise selecionados foram excluídas com êxito.", - "error": "Falhou a apagar: {{error}}" + "success": "As imagens de vídeo associadas com os itens de análise selecionados foram elimiandos com sucesso.", + "error": "Não foi possível eliminar: {{error}}" } }, "button": { "export": "Exportar", "markAsReviewed": "Marcar como analisado", - "deleteNow": "Excluir agora" + "deleteNow": "Eliminar Agora" } + }, + "imagePicker": { + "selectImage": "Selecione a miniatura de um objeto rastreado", + "search": { + "placeholder": "Pesquisar por etiqueta ou sub-etiqueta..." + }, + "noImages": "Nenhuma miniatura encontrada para esta câmera" } } diff --git a/web/public/locales/pt/components/filter.json b/web/public/locales/pt/components/filter.json index 53f56241f6..3f7fce7b8a 100644 --- a/web/public/locales/pt/components/filter.json +++ b/web/public/locales/pt/components/filter.json @@ -13,18 +13,18 @@ "zones": { "label": "Zonas", "all": { - "title": "Todas as zonas", + "title": "Todas as Zonas", "short": "Zonas" } }, "dates": { "all": { - "title": "Todas as datas", + "title": "Todas as Datas", "short": "Datas" }, - "selectPreset": "Escolhe uma predefinição…" + "selectPreset": "Selecionar um Pré-ajuste…" }, - "more": "Mais filtros", + "more": "Mais Filtros", "reset": { "label": "Redefinir filtros para valores padrão" }, @@ -35,28 +35,28 @@ "score": "Pontuação", "features": { "label": "Funcionalidades", - "hasSnapshot": "Tem um snapshot", + "hasSnapshot": "Tem uma captura", "hasVideoClip": "Tem um videoclipe", "submittedToFrigatePlus": { - "label": "Enviado para Frigate+", - "tips": "Primeiro, você deve filtrar os objetos rastreados que têm um snapshot.

    Objetos rastreados sem um snapshot não podem ser enviados ao Frigate+." + "label": "Submetido para Frigate+", + "tips": "Primeiro, deve filtrar os objetos rastreados que têm uma captura.

    Os objetos rastreados sem uma captura não podem ser submetidos para Frigate+." } }, "sort": { - "label": "Organizar", + "label": "Ordenar", "dateAsc": "Data (Ascendente)", - "scoreAsc": "Pontuação do objeto (Crescente)", - "scoreDesc": "Pontuação do objeto (Decrescente)", - "speedDesc": "Velocidade estimada (Decrescente)", - "speedAsc": "Velocidade estimada (Crescente)", + "scoreAsc": "Pontuação do Objeto (Ascendente)", + "scoreDesc": "Pontuação do Objeto (Descendente)", + "speedDesc": "Velocidade Estimada (Descendente)", + "speedAsc": "Velocidade Estimada (Ascendente)", "dateDesc": "Data (Decrescente)", "relevance": "Relevância" }, "cameras": { - "label": "Filtro de câmaras", + "label": "Filtro de Câmaras", "all": { "short": "Câmaras", - "title": "Todas as câmaras" + "title": "Todas as Câmaras" } }, "review": { @@ -67,22 +67,22 @@ }, "explore": { "settings": { - "title": "Configurações", + "title": "Definições", "defaultView": { - "title": "Exibição padrão", - "summary": "Sumário", - "unfilteredGrid": "Grade não filtrada", - "desc": "Quando nenhum filtro for selecionado, exiba um resumo dos objetos rastreados mais recentemente por etiqueta ou exiba uma grade não filtrada." + "title": "Visualização Predefinida", + "summary": "Resumo", + "unfilteredGrid": "Grelha não Filtrada", + "desc": "Quando não for selecionado nenhum filtro, exiba um resumo dos objetos rastreados mais recentes por etiqueta, ou exiba uma grelha não filtrada." }, "gridColumns": { - "title": "Colunas da grade", - "desc": "Selecione o número de colunas na visualização em grade." + "title": "Colunas da Grelha", + "desc": "Selecione o número de colunas na visualização em grelha." }, "searchSource": { - "label": "Pesquisar fonte", - "desc": "Escolha se deseja pesquisar nas miniaturas ou descrições dos seus objetos rastreados.", + "label": "Procurar Fonte", + "desc": "Escolha se deseja procurar nas miniaturas ou descrições dos seus objetos rastreados.", "options": { - "thumbnailImage": "Imagem em miniatura", + "thumbnailImage": "Imagem em Miniatura", "description": "Descrição" } } @@ -94,14 +94,14 @@ } }, "logSettings": { - "label": "Nível de log do filtro", + "label": "Nível de registo do filtro", "loading": { - "title": "Carregando", - "desc": "Ao fazer scroll até ao fundo no painel de logs, novos registos são automaticamente apresentados à medida que são adicionados." + "title": "A carregar", + "desc": "Quando desliza até ao fundo no painel de registos, os novos registos são apresentados automaticamente à medida que são adicionados." }, - "filterBySeverity": "Filtrar logs por gravidade", - "disableLogStreaming": "Desativar transmissão de logs", - "allLogs": "Todos os logs" + "filterBySeverity": "Filtrar registos por gravidade", + "disableLogStreaming": "Desativar transmissão de registos", + "allLogs": "Todos os registos" }, "estimatedSpeed": "Velocidade estimada ({{unit}})", "timeRange": "Intervalo de tempo", @@ -109,19 +109,29 @@ "filterBy": "Filtrar por máscara de zona" }, "trackedObjectDelete": { - "title": "Confirmar exclusão", + "title": "Confirmar Eliminar", "toast": { - "success": "Objetos rastreados excluídos com sucesso.", - "error": "Falha ao excluir os objetos rastreados: {{errorMessage}}" + "success": "Objetos rastreados eliminados com sucesso.", + "error": "Não foi possível eliminar os objetos rastreados: {{errorMessage}}" }, - "desc": "Excluir estes {{objectLength}} objetos rastreados remove a captura de imagem, quaisquer embeddings salvos e todas as entradas associadas ao ciclo de vida do objeto. As gravações desses objetos rastreados na visualização do Histórico NÃO serão excluídas.

    Tem certeza de que deseja continuar?

    Mantenha pressionada a tecla Shift para ignorar este diálogo no futuro." + "desc": "Ao eliminar estes {{objectLength}} objetos rastreados remove a captura de imagem, quaisquer integrações guardadas, e todas as entradas associadas ao ciclo de vida do objeto. As gravações desses objetos rastreados na visualização do Histórico NÃO serão eliminadas.

    Tem a certeza que deseja continuar?

    Mantenha pressionada a tecla Shift para ignorar esta janela no futuro." }, "recognizedLicensePlates": { - "title": "Placas Reconhecidas", - "noLicensePlatesFound": "Nenhuma matrícula encontrada.", - "selectPlatesFromList": "Selecione uma ou mais placas da lista.", - "loadFailed": "Falha ao carregar as placas reconhecidas.", - "loading": "Carregando placas reconhecidas…", - "placeholder": "Digite para procurar placas…" + "title": "Matrículas Reconhecidas", + "noLicensePlatesFound": "Não foram encontradas matrículas.", + "selectPlatesFromList": "Selecione uma ou mais matrículas da lista.", + "loadFailed": "Não foi possível carregar as matrículas reconhecidas.", + "loading": "A carregar as matrículas reconhecidas…", + "placeholder": "Digite para procurar matrículas…", + "selectAll": "Selecionar tudo", + "clearAll": "Limpar tudo" + }, + "classes": { + "label": "Classes", + "all": { + "title": "Todas as Classes" + }, + "count_one": "{{count}} Classe", + "count_other": "{{count}} Classes" } } diff --git a/web/public/locales/pt/components/icons.json b/web/public/locales/pt/components/icons.json index ddd38e84c5..71b767a1df 100644 --- a/web/public/locales/pt/components/icons.json +++ b/web/public/locales/pt/components/icons.json @@ -2,7 +2,7 @@ "iconPicker": { "selectIcon": "Selecione um ícone", "search": { - "placeholder": "Pesquisar por um ícone…" + "placeholder": "Procurar por um ícone…" } } } diff --git a/web/public/locales/pt/components/input.json b/web/public/locales/pt/components/input.json index 3332f08202..1324ed188a 100644 --- a/web/public/locales/pt/components/input.json +++ b/web/public/locales/pt/components/input.json @@ -1,9 +1,9 @@ { "button": { "downloadVideo": { - "label": "Descarregar vídeo", + "label": "Transferir Vídeo", "toast": { - "success": "O vídeo do seu item de análise começou a ser descarregado." + "success": "O vídeo do seu item de análise começou a ser transferido." } } } diff --git a/web/public/locales/pt/components/player.json b/web/public/locales/pt/components/player.json index 301e3f60de..741d37ef01 100644 --- a/web/public/locales/pt/components/player.json +++ b/web/public/locales/pt/components/player.json @@ -1,12 +1,12 @@ { - "noPreviewFound": "Nenhuma visualização encontrada", - "noPreviewFoundFor": "Nenhuma visualização encontrada para {{cameraName}}", + "noPreviewFound": "Nenhuma pré-visualização encontrada", + "noPreviewFoundFor": "Nenhuma pré-visualização encontrada para {{cameraName}}", "submitFrigatePlus": { - "title": "Enviar este quadro para o Frigate+?", - "submit": "Enviar" + "title": "Submeter esta imagem para Frigate+?", + "submit": "Submeter" }, "streamOffline": { - "title": "Transmissão offline", + "title": "Transmissão Off-line", "desc": "Nenhum quadro foi recebido na transmissão de detecção {{cameraName}}, verifique os logs de erro" }, "cameraDisabled": "A câmara está desativada", @@ -29,23 +29,23 @@ }, "totalFrames": "Total de quadros:", "droppedFrames": { - "title": "Quadros perdidos:", + "title": "Imagens perdidas:", "short": { - "title": "Perdido", - "value": "{{droppedFrames}} quadros" + "title": "Perdida", + "value": "{{droppedFrames}} imagens" } }, "decodedFrames": "Quadros decodificados:", - "droppedFrameRate": "Taxa de Quadros Perdidos:" + "droppedFrameRate": "Taxa de imagem perdida:" }, "noRecordingsFoundForThisTime": "Nenhuma gravação encontrada para este momento", - "livePlayerRequiredIOSVersion": "iOS 17.1 ou superior é necessário para este tipo de transmissão ao vivo.", + "livePlayerRequiredIOSVersion": "É necessário o iOS 17.1 ou superior para este tipo de transmissão ao vivo.", "toast": { "success": { - "submittedFrigatePlus": "Quadro enviado com sucesso para o Frigate+" + "submittedFrigatePlus": "Imagem submetida com sucesso para Frigate+" }, "error": { - "submitFrigatePlusFailed": "Falha ao enviar o quadro para o Frigate+" + "submitFrigatePlusFailed": "Não foi possível submeter a imagem para Frigate+" } } } diff --git a/web/public/locales/pt/objects.json b/web/public/locales/pt/objects.json index ada61d1849..88762a7f31 100644 --- a/web/public/locales/pt/objects.json +++ b/web/public/locales/pt/objects.json @@ -2,16 +2,16 @@ "giraffe": "Girafa", "cup": "Chávena", "person": "Pessoa", - "stop_sign": "Sinal de Stop", + "stop_sign": "Sinal de Parar", "sheep": "Ovelha", - "sandwich": "Sandes", + "sandwich": "Sande", "carrot": "Cenoura", - "dining_table": "Mesa de jantar", - "motorcycle": "Motociclo", + "dining_table": "Mesa de Jantar", + "motorcycle": "Mota", "bicycle": "Bicicleta", - "street_sign": "Sinal de rua", + "street_sign": "Sinal de Rua", "pizza": "Pizza", - "parking_meter": "Parquímetro", + "parking_meter": "Parcómetro", "skateboard": "Skate", "bottle": "Garrafa", "car": "Carro", @@ -23,19 +23,19 @@ "fire_hydrant": "Boca de Incêndio", "bird": "Pássaro", "cat": "Gato", - "bench": "Banco de jardim/rua", + "bench": "Banco de Jardim", "elephant": "Elefante", "hat": "Chapéu", "backpack": "Mochila", "shoe": "Sapato", - "handbag": "Bolsa de mão", + "handbag": "Carteira", "tie": "Gravata", - "suitcase": "Mala de viagem", + "suitcase": "Mala de Viagem", "frisbee": "Disco de Frisbee", "skis": "Esquis", - "kite": "Kite", - "baseball_bat": "Taco basebol", - "tennis_racket": "Raquete de Tenis", + "kite": "Papagaio de Papel", + "baseball_bat": "Taco de Basebol", + "tennis_racket": "Raquete de Ténis", "plate": "Prato", "wine_glass": "Copo de Vinho", "fork": "Garfo", @@ -43,17 +43,17 @@ "bowl": "Tijela", "banana": "Banana", "apple": "Maça", - "hot_dog": "Cachorro quente", + "hot_dog": "Cachorro Quente", "donut": "Donut", "cake": "Bolo", "chair": "Cadeira", - "potted_plant": "Planta em vaso", + "potted_plant": "Planta em Vaso", "mirror": "Espelho", - "desk": "Mesa", + "desk": "Escrivaninha", "toilet": "Casa de Banho", "door": "Porta", - "baseball_glove": "Luva de beisebol", - "surfboard": "Prancha de surf", + "baseball_glove": "Luva de Basebol", + "surfboard": "Prancha de Surf", "broccoli": "Brócolos", "snowboard": "Snowboard", "dog": "Cão", @@ -74,22 +74,22 @@ "bark": "Latido", "goat": "Cabra", "vehicle": "Veículo", - "scissors": "Tesoura", + "scissors": "Tesouras", "mouse": "Rato", - "teddy_bear": "Urso de peluche", - "hair_dryer": "Secador de cabelo", - "toothbrush": "Escova de dentes", + "teddy_bear": "Urso de Peluche", + "hair_dryer": "Secador de Cabelo", + "toothbrush": "Escova de Dentes", "hair_brush": "Escova de Cabelo", "squirrel": "Esquilo", "couch": "Sofá", "tv": "TV", "laptop": "Portátil", - "remote": "Controlo Remoto", + "remote": "Comando", "cell_phone": "Telemóvel", "microwave": "Microondas", "oven": "Forno", "toaster": "Torradeira", - "sink": "Pia", + "sink": "Banca", "refrigerator": "Frigorífico", "blender": "Liquidificador", "book": "Livro", @@ -98,7 +98,7 @@ "fox": "Raposa", "rabbit": "Coelho", "raccoon": "Guaxinim", - "robot_lawnmower": "Robô corta relva", + "robot_lawnmower": "Robô de Cortar Relva", "waste_bin": "Contentor do Lixo", "on_demand": "On Demand", "face": "Rosto", diff --git a/web/public/locales/pt/views/configEditor.json b/web/public/locales/pt/views/configEditor.json index 6d6c981664..fb1d3377af 100644 --- a/web/public/locales/pt/views/configEditor.json +++ b/web/public/locales/pt/views/configEditor.json @@ -1,16 +1,18 @@ { - "configEditor": "Editor de configuração", - "copyConfig": "Copiar configuração", - "saveAndRestart": "Salvar e reiniciar", - "saveOnly": "Salvar Apenas", + "configEditor": "Editor de Configuração", + "copyConfig": "Copiar Configuração", + "saveAndRestart": "Guardar e Reiniciar", + "saveOnly": "Guardar Apenas", "toast": { "success": { "copyToClipboard": "Configuração copiada para a área de transferência." }, "error": { - "savingError": "Erro ao salvar configuração" + "savingError": "Erro ao guardar a configuração" } }, - "documentTitle": "Editor de configuração - Frigate", - "confirm": "Sair sem salvar?" + "documentTitle": "Frigate - Editor de Configuração", + "confirm": "Sair sem guardar?", + "safeConfigEditor": "Editor de Configuração (Modo de Segurança)", + "safeModeDescription": "O Frigate está no modo de segurança devido a um erro de validação da configuração." } diff --git a/web/public/locales/pt/views/events.json b/web/public/locales/pt/views/events.json index 6478001c60..bb9b2e0ff8 100644 --- a/web/public/locales/pt/views/events.json +++ b/web/public/locales/pt/views/events.json @@ -1,10 +1,10 @@ { - "detections": "Detecções", + "detections": "Deteções", "motion": { "label": "Movimento", - "only": "Somente movimento" + "only": "Apenas movimento" }, - "allCameras": "Todas as câmaras", + "allCameras": "Todas as Câmaras", "empty": { "motion": "Nenhum dado de movimento encontrado", "alert": "Não há alertas para análise", @@ -20,7 +20,7 @@ "alerts": "Alertas", "documentTitle": "Análise - Frigate", "recordings": { - "documentTitle": "Gravações - Frigate" + "documentTitle": "Frigate - Gravações" }, "calendarFilter": { "last24Hours": "Últimas 24 horas" @@ -32,7 +32,9 @@ "button": "Novos itens para analisar" }, "camera": "Câmara", - "detected": "detectado", + "detected": "detetado", "selected_one": "{{count}} selecionado", - "selected_other": "{{count}} selecionados" + "selected_other": "{{count}} selecionados", + "suspiciousActivity": "Atividade Suspeita", + "threateningActivity": "Atividade Ameaçadora" } diff --git a/web/public/locales/pt/views/explore.json b/web/public/locales/pt/views/explore.json index a271d1df74..721508174e 100644 --- a/web/public/locales/pt/views/explore.json +++ b/web/public/locales/pt/views/explore.json @@ -2,7 +2,7 @@ "generativeAI": "IA Generativa", "exploreIsUnavailable": { "embeddingsReindexing": { - "startingUp": "Iniciando…", + "startingUp": "A iniciar…", "estimatedTime": "Tempo restante estimado:", "finishingShortly": "Terminando em breve", "step": { @@ -17,14 +17,14 @@ "visionModel": "Modelo de visão", "textModel": "Modelo de texto", "textTokenizer": "Tokenizador de texto", - "visionModelFeatureExtractor": "Extrator de características de modelo de visão" + "visionModelFeatureExtractor": "Extrator de funcionalidade de modelo de visão" }, - "context": "O Frigate está descarregando os modelos de incorporação necessários para dar suporte a funcionalidade de pesquisa semântica. Isso pode levar vários minutos, dependendo da velocidade da sua conexão de rede.", + "context": "O Frigate está a transferir os modelos de incorporação necessários para suportar a funcionalidade de \"Procura Semântica\". Isto pode levar vários minutos, dependendo da velocidade da sua ligação de rede.", "tips": { - "context": "Talvez você queira reindexar as incorporações dos seus objetos rastreados depois que os modelos forem descarregados.", + "context": "Talvez queira reindexar as incorporações dos seus objetos rastreados depois de os modelos serem transferidos.", "documentation": "Leia a documentação" }, - "error": "Ocorreu um erro. Verifique os logs do Frigate." + "error": "Ocorreu um erro. Verifique os registos do Frigate." }, "title": "Explorar não está disponível" }, @@ -43,12 +43,14 @@ "success": { "regenerate": "Uma nova descrição foi solicitada pelo {{provider}}. Dependendo da velocidade do seu fornecedor, a nova descrição pode levar algum tempo para ser regenerada.", "updatedSublabel": "Sub-rotulo atualizado com sucesso.", - "updatedLPR": "Matrícula atualizada com sucesso." + "updatedLPR": "Matrícula atualizada com sucesso.", + "audioTranscription": "Transcrição de áudio solicitada com sucesso." }, "error": { "regenerate": "Falha ao chamar {{provider}} para uma nova descrição: {{errorMessage}}", "updatedSublabelFailed": "Falha ao atualizar o sub-rotulo: {{errorMessage}}", - "updatedLPRFailed": "Falha ao atualizar a matrícula: {{errorMessage}}" + "updatedLPRFailed": "Falha ao atualizar a matrícula: {{errorMessage}}", + "audioTranscription": "Falha ao solicitar transcrição de áudio: {{errorMessage}}" } }, "button": { @@ -97,27 +99,30 @@ "tips": { "descriptionSaved": "Descrição salva com sucesso", "saveDescriptionFailed": "Falha ao atualizar a descrição: {{errorMessage}}" + }, + "score": { + "label": "Classificação" } }, - "documentTitle": "Explorar - Frigate", + "documentTitle": "Frigate - Explorar", "trackedObjectDetails": "Detalhes do objeto rastreado", "type": { "details": "detalhes", "video": "vídeo", "object_lifecycle": "ciclo de vida do objeto", - "snapshot": "snapshot" + "snapshot": "captura de ecrã" }, "objectLifecycle": { "title": "Ciclo de vida do objeto", "lifecycleItemDesc": { "attribute": { "other": "{{label}} reconhecido como {{attribute}}", - "faceOrLicense_plate": "{{attribute}} detectado por {{label}}" + "faceOrLicense_plate": "{{attribute}} detetado por {{label}}" }, "gone": "{{label}} saiu", "heard": "{{label}} ouvido", "visible": "{{label}} detectado", - "external": "{{label}} detectado", + "external": "{{label}} detetado", "entered_zone": "{{label}} entrou em {{zones}}", "active": "{{label}} se tornou ativo", "stationary": "{{label}} se tornou estacionário", @@ -128,7 +133,7 @@ } }, "annotationSettings": { - "title": "Configurações de anotação", + "title": "Definições de Anotação", "offset": { "documentation": "Leia a documentação ", "desc": "Esses dados vêm do feed de detecção da sua câmara, mas são sobrepostos nas imagens do feed de gravação. É improvável que os dois streams estejam perfeitamente sincronizados. Como resultado, a caixa delimitadora e o vídeo não se alinharão perfeitamente. No entanto, o campo annotation_offset pode ser usado para ajustar isso.", @@ -140,8 +145,8 @@ } }, "showAllZones": { - "title": "Mostrar todas as zonas", - "desc": "Sempre mostrar zonas nos quadros onde os objetos entraram em uma zona." + "title": "Mostrar Todas as Zonas", + "desc": "Mostrar sempre as zonas nas imagens onde os objetos entraram numa zona." } }, "carousel": { @@ -150,9 +155,9 @@ }, "noImageFound": "Nenhuma imagem encontrada para este carimbo de data/hora.", "createObjectMask": "Criar Máscara de Objeto", - "adjustAnnotationSettings": "Ajustar configurações de anotação", - "autoTrackingTips": "As posições da caixa delimitadora serão imprecisas para câmeras com rastreamento automático.", - "scrollViewTips": "Faça scroll para ver os momentos significativos do ciclo de vida deste objeto.", + "adjustAnnotationSettings": "Ajustar definições de anotação", + "autoTrackingTips": "As posições da caixa delimitadora serão imprecisas para as câmaras com rastreamento automático.", + "scrollViewTips": "Deslize para ver os momentos significativos do ciclo de vida deste objeto.", "count": "{{first}} de {{second}}", "trackedPoint": "Ponto Rastreado" }, @@ -183,6 +188,14 @@ }, "deleteTrackedObject": { "label": "Excluir este objeto rastreado" + }, + "addTrigger": { + "label": "Adicionar gatilho", + "aria": "Adicione um gatilho para este objeto rastreado" + }, + "audioTranscription": { + "label": "Transcrever", + "aria": "Solicitar transcrição de áudio" } }, "searchResult": { @@ -205,5 +218,11 @@ "trackedObjectsCount_one": "{{count}} objeto rastreado ", "trackedObjectsCount_many": "{{count}} objetos rastreados ", "trackedObjectsCount_other": "", - "exploreMore": "Explora mais objetos {{label}}" + "exploreMore": "Explora mais objetos {{label}}", + "aiAnalysis": { + "title": "Análise IA" + }, + "concerns": { + "label": "Preocupações" + } } diff --git a/web/public/locales/pt/views/live.json b/web/public/locales/pt/views/live.json index eb0330a973..770028a85b 100644 --- a/web/public/locales/pt/views/live.json +++ b/web/public/locales/pt/views/live.json @@ -42,6 +42,14 @@ "center": { "label": "Clique no quadro para centralizar a câmara PTZ" } + }, + "focus": { + "in": { + "label": "Em foco da câmera PTZ" + }, + "out": { + "label": "Fora foco da câmera PTZ em" + } } }, "lowBandwidthMode": "Modo de baixa largura de banda", @@ -130,7 +138,8 @@ "recording": "Gravando", "audioDetection": "Detecção de áudio", "autotracking": "Rastreamento automático", - "snapshots": "Snapshots" + "snapshots": "Snapshots", + "transcription": "Transcrição de áudio" }, "effectiveRetainMode": { "modes": { @@ -154,5 +163,9 @@ }, "history": { "label": "Mostrar filmagens históricas" + }, + "transcription": { + "enable": "Habilitar transcrição de áudio ao vivo", + "disable": "Desabilitar transcrição de áudio ao vivo" } } diff --git a/web/public/locales/pt/views/settings.json b/web/public/locales/pt/views/settings.json index f453e6a5b4..8b4f92cf5d 100644 --- a/web/public/locales/pt/views/settings.json +++ b/web/public/locales/pt/views/settings.json @@ -6,7 +6,7 @@ "motionTuner": "Ajuste de movimento - Frigate", "object": "Depuração - Frigate", "authentication": "Configurações de autenticação - Frigate", - "general": "Configurações Gerais - Frigate", + "general": "Configurações gerais - Frigate", "frigatePlus": "Configurações do Frigate+ - Frigate", "default": "Configurações - Frigate", "notifications": "Configuração de Notificações - Frigate", @@ -22,7 +22,8 @@ "users": "Utilizadores", "notifications": "Notificações", "frigateplus": "Frigate+", - "enrichments": "Avançado" + "enrichments": "Avançado", + "triggers": "Gatilhos" }, "dialog": { "unsavedChanges": { @@ -465,6 +466,19 @@ "mask": { "title": "Máscaras de movimento", "desc": "Mostrar polígonos de máscara de movimento" + }, + "paths": { + "title": "Caminhos", + "desc": "Mostrar pontos significativos do caminho do objeto rastreado", + "tips": "

    Paths


    Linhas e círculos indicarão pontos significativos que o objeto rastreado moveu durante seu ciclo de vida.

    " + }, + "openCameraWebUI": "Abrir a Interface Web de {{camera}}", + "audio": { + "title": "Áudio", + "noAudioDetections": "Nenhuma detecção de áudio", + "score": "pontuanção", + "currentRMS": "RMS Atual", + "currentdbFS": "dbFS Atual" } }, "camera": { @@ -499,6 +513,44 @@ "desc": "Ative ou desative alertas e detecções para esta câmara. Quando desativado, nenhum novo item de análise será gerado. ", "alerts": "Alertas ", "detections": "Detecções " + }, + "object_descriptions": { + "title": "Descrições de objetos de IA generativa", + "desc": "Ative/desative temporariamente as descrições de objetos de IA generativa para esta câmera. Quando desativadas, as descrições geradas por IA não serão solicitadas para objetos rastreados nesta câmera." + }, + "review_descriptions": { + "title": "Descrições de análises de IA generativa", + "desc": "Ative/desative temporariamente as descrições de avaliação geradas por IA para esta câmera. Quando desativadas, as descrições geradas por IA não serão solicitadas para itens de avaliação nesta câmera." + }, + "addCamera": "Adicionar Nova Câmera", + "editCamera": "Editar Câmera:", + "selectCamera": "Selecione uma Câmera", + "backToSettings": "Voltar para as Configurações da Câmera", + "cameraConfig": { + "add": "Adicionar Câmera", + "edit": "Editar Câmera", + "description": "Configure as definições da câmera, incluindo entradas de transmissão e funções.", + "name": "Nome da Câmera", + "nameRequired": "O nome da câmera é obrigatório", + "nameInvalid": "O nome da câmera deve conter apenas letras, números, sublinhados ou hifens", + "namePlaceholder": "e.g., porta_da_frente", + "enabled": "Habilitado", + "ffmpeg": { + "inputs": "Entrada de Streams", + "path": "Caminho da Stream", + "pathRequired": "Caminho da Stream é obrigatória", + "pathPlaceholder": "rtsp://...", + "roles": "Funções", + "rolesRequired": "Pelo menos uma função é necessária", + "rolesUnique": "Cada função (áudio, detecção, gravação) só pode ser atribuída a uma stream", + "addInput": "Adicionar Entrada de Stream", + "removeInput": "Remover Entrada de Stream", + "inputsRequired": "É necessário pelo menos uma stream de entrada" + }, + "toast": { + "success": "Câmera {{cameraName}} guardada com sucesso" + }, + "nameLength": "O nome da câmara deve ter ao menos 24 caracteres." } }, "motionDetectionTuner": { @@ -594,7 +646,8 @@ "adminDesc": "Acesso total a todos os recursos.", "viewer": "Visualização", "viewerDesc": "Limitado apenas a painéis ao vivo, análise, exploração e exportações.", - "intro": "Selecione a função apropriada para este utilizador:" + "intro": "Selecione a função apropriada para este utilizador:", + "customDesc": "Papel customizado com acesso a câmaras específicas." }, "title": "Alterar função do utilizador", "desc": "Atualizar permissões para {{username}}", @@ -682,5 +735,160 @@ "roleUpdateFailed": "Falha ao atualizar a função: {{errorMessage}}" } } + }, + "triggers": { + "documentTitle": "Triggers (gatilhos)", + "management": { + "title": "Gestão de Triggers", + "desc": "Gira triggers para {{camera}}. Use o tipo de miniatura para acionar miniaturas semelhantes ao objeto rastreado selecionado e o tipo de descrição para acionar descrições semelhantes ao texto especificado." + }, + "addTrigger": "Adicionar Trigger", + "table": { + "name": "Nome", + "type": "Tipo", + "content": "Conteúdo", + "threshold": "Limite", + "actions": "Ações", + "noTriggers": "Nenhum trigger configurado para esta câmera.", + "edit": "Editar", + "deleteTrigger": "Apagar Trigger", + "lastTriggered": "Último acionado" + }, + "type": { + "thumbnail": "Miniatura", + "description": "Descrição" + }, + "actions": { + "alert": "Marcar como Alerta", + "notification": "Enviar Notificação" + }, + "dialog": { + "createTrigger": { + "title": "Criar Trigger", + "desc": "Crie um trigger para a câmera {{camera}}" + }, + "editTrigger": { + "title": "Editar Trigger", + "desc": "Editar as definições do trigger na câmera {{camera}}" + }, + "deleteTrigger": { + "title": "Apagar Trigger", + "desc": "Tem certeza de que deseja apagar o trigger {{triggerName}}? Esta ação não pode ser desfeita." + }, + "form": { + "name": { + "title": "Nome", + "placeholder": "Insira o nome do trigger", + "error": { + "minLength": "O nome deve ter pelo menos 2 caracteres.", + "invalidCharacters": "O nome só pode conter letras, números, sublinhados e hifens.", + "alreadyExists": "Já existe um trigger com este nome para esta câmera." + } + }, + "enabled": { + "description": "Habilitar ou desabilitar este trigger" + }, + "type": { + "title": "Tipo", + "placeholder": "Selecione o tipo de trigger" + }, + "content": { + "title": "Conteúdo", + "imagePlaceholder": "Selecione uma imagem", + "textPlaceholder": "Insira o conteúdo do texto", + "imageDesc": "Selecione uma imagem para acionar esta ação quando uma imagem semelhante for detectada.", + "textDesc": "Insira um texto para acionar esta ação quando uma descrição de objeto rastreado semelhante for detectada.", + "error": { + "required": "O Conteúdo é obrigatório." + } + }, + "threshold": { + "title": "Limite", + "error": { + "min": "Limite deve ser pelo menos 0", + "max": "Limite deve ser no máximo 1" + } + }, + "actions": { + "title": "Ações", + "desc": "Por padrão, o Frigate envia uma mensagem MQTT para todos os triggers. Escolha uma ação adicional a ser executada quando este trigger for disparado.", + "error": { + "min": "Pelo menos uma ação deve ser selecionada." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Trigger {{name}} criado com sucesso.", + "updateTrigger": "Trigger {{name}} atualizado com sucesso.", + "deleteTrigger": "Trigger {{name}} apagado com sucesso." + }, + "error": { + "createTriggerFailed": "Falha ao criar trigger: {{errorMessage}}", + "updateTriggerFailed": "Falha ao atualizar o trigger: {{errorMessage}}", + "deleteTriggerFailed": "Falha ao apagar o trigger: {{errorMessage}}" + } + } + }, + "roles": { + "management": { + "title": "Gestão do Papel de Visualizador", + "desc": "Gerir papéis de visualizador customizados e as suas permissões de acesso para esta instância do Frigate." + }, + "addRole": "Adicionar Papel", + "table": { + "role": "Papel", + "cameras": "Câmaras", + "actions": "Ações", + "noRoles": "Nenhum papel customizado encontrado.", + "editCameras": "Editar Câmaras", + "deleteRole": "Apagar Papel" + }, + "toast": { + "success": { + "createRole": "Papel {{role}} criado com sucesso", + "updateCameras": "Câmaras atualizados para o papel {{role}}", + "deleteRole": "Papel {{role}} apagado com sucesso", + "userRolesUpdated": "{{count}} utilizador(os) atribuídos a este papel foram atualizados para 'visualizador', que possui acesso a todas as câmaras." + }, + "error": { + "createRoleFailed": "Falha ao criar papel: {{errorMessage}}", + "updateCamerasFailed": "Falha ao atualizar câmaras: {{errorMessage}}", + "deleteRoleFailed": "Falha ao apagar papel: {{errorMessage}}", + "userUpdateFailed": "Falha ao atualizar papel do utilizador: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Criar Novo Papel", + "desc": "Adicionar um novo papel e especificar permissões de acesso." + }, + "editCameras": { + "title": "Editar Câmaras de Papéis", + "desc": "Atualizar acesso da câmara para o papel {{role}}." + }, + "deleteRole": { + "title": "Apagar Papel", + "desc": "Esta ação não pode ser desfeita. Isto irá apagar permanentemente o papel e atribuir a quaisquer utilizadores com este papel como 'visualizador', o que dará acesso de visualização para todas as câmaras.", + "warn": "Tem certeza que quer apagar {{role}}?", + "deleting": "A apagar…" + }, + "form": { + "role": { + "title": "Nome do Papel", + "placeholder": "Digitar nome do papel", + "desc": "Apenas letras, números, pontos e sublinhados são permitidos.", + "roleIsRequired": "Nome para o papel é requerido", + "roleOnlyInclude": "O nome do papel pode conter apenas letras, números, pontos ou sublinhados", + "roleExists": "Um papel com este nome já existe." + }, + "cameras": { + "title": "Câmaras", + "desc": "Selecione as câmaras que este papel terá acesso. Ao menos uma câmara é requerida.", + "required": "Ao menos uma câmara deve ser selecionada." + } + } + } } } diff --git a/web/public/locales/pt/views/system.json b/web/public/locales/pt/views/system.json index 2826a9dd92..9f90073c98 100644 --- a/web/public/locales/pt/views/system.json +++ b/web/public/locales/pt/views/system.json @@ -1,14 +1,14 @@ { "documentTitle": { - "storage": "Estatísticas de armazenamento - Frigate", - "general": "Estatísticas gerais - Frigate", - "enrichments": "Estatísticas de enriquecimento - Frigate", + "storage": "Frigate - Estatísticas de Armazenamento", + "general": "Frigate - Estatísticas Gerais", + "enrichments": "Frigate - Estatísticas de Enriquecimento", "logs": { - "frigate": "Logs do Frigate - Frigate", - "go2rtc": "Logs do Go2RTC - Frigate", - "nginx": "Logs do Nginx - Frigate" + "frigate": "Frigate - Registos de Eventos do Frigate", + "go2rtc": "Frigate - Registos de Eventos do Go2RTC", + "nginx": "Frigate - Registos de Eventos do Nginx" }, - "cameras": "Estatísticas das câmaras - Frigate" + "cameras": "Frigate - Estatísticas das Câmaras" }, "title": "Sistema", "metrics": "Métricas do sistema", @@ -16,22 +16,22 @@ "type": { "label": "Tipo", "timestamp": "Carimbo de hora", - "tag": "Tag", + "tag": "Etiqueta", "message": "Mensagem" }, "copy": { - "success": "Logs copiados para a área de transferência", - "label": "Copiar para a área de transferência", - "error": "Não foi possível copiar os logs para a área de transferência" + "success": "Registos copiados para a área de transferência", + "label": "Copiar para a Área de Transferência", + "error": "Não foi possível copiar os registos para a área de transferência" }, "download": { - "label": "Descarregar logs" + "label": "Transferir Registos" }, - "tips": "Os logs estão a ser transmitidos do servidor", + "tips": "Os registos estão a ser transmitidos do servidor", "toast": { "error": { - "fetchingLogsFailed": "Erro ao buscar logs: {{errorMessage}}", - "whileStreamingLogs": "Erro ao transmitir logs: {{errorMessage}}" + "fetchingLogsFailed": "Erro ao obter os registos: {{errorMessage}}", + "whileStreamingLogs": "Erro enquanto transmitia os registos: {{errorMessage}}" } } }, @@ -49,11 +49,15 @@ "title": "Armazenamento da câmara" }, "title": "Armazenamento", - "overview": "Visão geral", + "overview": "Sinopse", "recordings": { "title": "Gravações", "earliestRecording": "Primeira gravação disponível:", - "tips": "Esse valor representa o armazenamento total usado pelas gravações na base de dados do Frigate. O Frigate não acompanha o uso de armazenamento de todos os ficheiros no seu disco." + "tips": "Este valor representa o armazenamento total utilizado pelas gravações na base de dados do Frigate. O Frigate não acompanha a utilização do armazenamento de todos os ficheiros no seu disco." + }, + "shm": { + "title": "Alocação SHM (memória partilhada)", + "warning": "A tamanho atual de SHM de {{total}} MB é muito pequeno. Aumente-o para pelo menos {{min_shm}} MB." } }, "cameras": { @@ -83,19 +87,19 @@ "skipped": "ignorado", "ffmpeg": "FFmpeg", "cameraFfmpeg": "{{camName}} FFmpeg", - "cameraFramesPerSecond": "quadros por segundo de {{camName}}", + "cameraFramesPerSecond": "imagens por segundo de {{camName}}", "cameraCapture": "captura de {{camName}}", - "cameraDetectionsPerSecond": "detecções por segundo de {{camName}}", - "overallFramesPerSecond": "quadros por segundo totais (FPS)", - "overallDetectionsPerSecond": "detecções por segundo totais", - "overallSkippedDetectionsPerSecond": "detecções ignoradas por segundo totais", - "cameraDetect": "detecção de {{camName}}", - "cameraSkippedDetectionsPerSecond": "detecções ignoradas por segundo de {{camName}}" + "cameraDetectionsPerSecond": "deteções por segundo de {{camName}}", + "overallFramesPerSecond": "imagens por segundo totais (FPS)", + "overallDetectionsPerSecond": "deteções por segundo totais", + "overallSkippedDetectionsPerSecond": "deteções ignoradas por segundo totais", + "cameraDetect": "deteção de {{camName}}", + "cameraSkippedDetectionsPerSecond": "deteções ignoradas por segundo de {{camName}}" }, "overview": "Visão geral", "toast": { "success": { - "copyToClipboard": "Dados de Exploração copiados para a área de transferência." + "copyToClipboard": "Dados de exploração copiados para a área de transferência." }, "error": { "unableToProbeCamera": "Não foi possível explorar a câmara: {{errorMessage}}" @@ -104,43 +108,45 @@ }, "lastRefreshed": "Última atualização: ", "stats": { - "ffmpegHighCpuUsage": "{{camera}} tem alto uso de CPU FFmpeg ({{ffmpegAvg}}%)", - "detectHighCpuUsage": "{{camera}} tem alto uso de CPU de detecção ({{detectAvg}}%)", + "ffmpegHighCpuUsage": "{{camera}} tem alta utilização da CPU FFmpeg ({{ffmpegAvg}}%)", + "detectHighCpuUsage": "{{camera}} tem alta utilização da CPU de deteção ({{detectAvg}}%)", "healthy": "O sistema está saudável", "reindexingEmbeddings": "Reindexando incorporações ({{processed}}% completo)", "detectIsVerySlow": "{{detect}} está muito lento ({{speed}} ms)", - "cameraIsOffline": "{{camera}} está offline", - "detectIsSlow": "{{detect}} está lento ({{speed}} ms)" + "cameraIsOffline": "{{camera}} está off-line", + "detectIsSlow": "{{detect}} está lento ({{speed}} ms)", + "shmTooLow": "/dev/shm alocação ({{total}} MB) deveria ser aumentada pelo menos {{min}} MB." }, "general": { "title": "Geral", "detector": { - "title": "Detectores", - "cpuUsage": "Utilização do CPU do Detector", - "memoryUsage": "Utilização da memória do Detector", - "inferenceSpeed": "Velocidade de Inferência do Detector", - "temperature": "Temperatura do Detector" + "title": "Detetores", + "cpuUsage": "Utilização do CPU do Detetor", + "memoryUsage": "Utilização da Memória do Detetor", + "inferenceSpeed": "Velocidade de Inferência do Detetor", + "temperature": "Temperatura do Detetor", + "cpuUsageInformation": "CPU utilizada na preparação de dados de entrada e saída de/para os modelos de deteção. Este valor não mede oa utilização da inferência, mesmo se estiver a utilizar uma GPU ou acelerador." }, "hardwareInfo": { - "title": "Informações de hardware", - "gpuUsage": "Utilização GPU", - "gpuMemory": "Memória GPU", + "title": "Informação de Hardware", + "gpuUsage": "Utilização da GPU", + "gpuMemory": "Memória da GPU", "gpuInfo": { "nvidiaSMIOutput": { - "driver": "Driver: {{driver}}", + "driver": "Controlador: {{driver}}", "vbios": "Informação VBios: {{vbios}}", "name": "Nome: {{name}}", "cudaComputerCapability": "Capacidade de computação CUDA: {{cuda_compute}}", "title": "Saída Nvidia SMI" }, "copyInfo": { - "label": "Copiar informações do GPU" + "label": "Copiar informação da GPU" }, "closeInfo": { - "label": "Fechar informações do GPU" + "label": "Fechar informação da GPU" }, "toast": { - "success": "Informações do GPU copiadas para a área de transferência" + "success": "Informação da GPU copiada para a área de transferência" }, "vainfoOutput": { "title": "Saída do Vainfo", @@ -149,32 +155,32 @@ "processError": "Erro no processo:" } }, - "gpuEncoder": "GPU Encoder", - "gpuDecoder": "GPU Decoder", + "gpuEncoder": "Codificador da GPU", + "gpuDecoder": "Descodificador da GPU", "npuUsage": "Utilização NPU", "npuMemory": "Memória NPU" }, "otherProcesses": { - "title": "Outros processos", - "processCpuUsage": "Uso de CPU do processo", - "processMemoryUsage": "Uso de memória do processo" + "title": "Outros Processos", + "processCpuUsage": "Utilização da CPU do Processo", + "processMemoryUsage": "Utilização da Memória do Processo" } }, "enrichments": { "title": "Enriquecimentos", - "infPerSecond": "Inferências por segundo", + "infPerSecond": "Inferências por Segundo", "embeddings": { - "image_embedding_speed": "Velocidade de incorporação de imagem", - "face_embedding_speed": "Velocidade de incorporação facial", - "plate_recognition_speed": "Velocidade de reconhecimento de placas", - "text_embedding_speed": "Velocidade de incorporação de texto", + "image_embedding_speed": "Velocidade de Incorporação de Imagem", + "face_embedding_speed": "Velocidade de Incorporação Facial", + "plate_recognition_speed": "Velocidade de Reconhecimento de Placas", + "text_embedding_speed": "Velocidade de Incorporação de Texto", "face_recognition_speed": "Velocidade de Reconhecimento Facial", "plate_recognition": "Reconhecimento de Placas", "image_embedding": "Incorporação de Imagem", "text_embedding": "Incorporação de Texto", "face_recognition": "Reconhecimento Facial", - "yolov9_plate_detection_speed": "Velocidade de Detecção de Placas YOLOv9", - "yolov9_plate_detection": "Detecção de Placas YOLOv9" + "yolov9_plate_detection_speed": "Velocidade de Deteção de Placas YOLOv9", + "yolov9_plate_detection": "Deteção de Placas YOLOv9" } } } diff --git a/web/public/locales/ro/common.json b/web/public/locales/ro/common.json index 145d511a4c..1e36296f66 100644 --- a/web/public/locales/ro/common.json +++ b/web/public/locales/ro/common.json @@ -42,7 +42,7 @@ "24hour": "dd-MM-yy-HH-mm-ss" }, "30minutes": "30 de minute", - "1hour": "O oră", + "1hour": "1 oră", "12hours": "12 ore", "24hours": "24 de ore", "pm": "PM", @@ -123,7 +123,15 @@ "ro": "Română (Română)", "hu": "Magyar (Maghiară)", "fi": "Suomi (Finlandeză)", - "th": "ไทย (Thailandeză)" + "th": "ไทย (Thailandeză)", + "ptBR": "Português brasileiro (Portugheză braziliană)", + "sr": "Српски (Sârbă)", + "sl": "Slovenščina (Slovenă)", + "lt": "Lietuvių (Lituaniană)", + "bg": "Български (Bulgară)", + "gl": "Galego (Galiciană)", + "id": "Bahasa Indonesia (Indoneziană)", + "ur": "اردو (Urdu)" }, "theme": { "default": "Implicit", @@ -261,5 +269,6 @@ "documentTitle": "Nu a fost găsit - Frigate", "title": "404", "desc": "Pagină negăsită" - } + }, + "readTheDocumentation": "Citește documentația" } diff --git a/web/public/locales/ro/components/camera.json b/web/public/locales/ro/components/camera.json index d93a81dccd..55396367d2 100644 --- a/web/public/locales/ro/components/camera.json +++ b/web/public/locales/ro/components/camera.json @@ -66,7 +66,8 @@ "label": "Mod compatibilitate", "desc": "Activează această opțiune doar dacă stream-ul live al camerei afișează artefacte de culoare și are o linie diagonală pe partea dreaptă a imaginii." } - } + }, + "birdseye": "Vedere de ansamblu" } }, "debug": { diff --git a/web/public/locales/ro/components/dialog.json b/web/public/locales/ro/components/dialog.json index c07b2cee06..626c307273 100644 --- a/web/public/locales/ro/components/dialog.json +++ b/web/public/locales/ro/components/dialog.json @@ -122,5 +122,12 @@ } } } + }, + "imagePicker": { + "selectImage": "Selectează miniatura unui obiect urmărit", + "search": { + "placeholder": "Caută după etichetă sau subetichetă..." + }, + "noImages": "Nu s-au găsit miniaturi pentru această cameră" } } diff --git a/web/public/locales/ro/components/filter.json b/web/public/locales/ro/components/filter.json index 40c0c593cf..9130c01f20 100644 --- a/web/public/locales/ro/components/filter.json +++ b/web/public/locales/ro/components/filter.json @@ -121,6 +121,16 @@ "selectPlatesFromList": "Selectează una sau mai multe plăcuțe din listă.", "loading": "Se încarcă numerele de înmatriculare recunoscute…", "placeholder": "Caută plăcuțe de înmatriculare…", - "loadFailed": "Nu s-au putut încărca numerele de înmatriculare recunoscute." + "loadFailed": "Nu s-au putut încărca numerele de înmatriculare recunoscute.", + "selectAll": "Selectează tot", + "clearAll": "Elimină tot" + }, + "classes": { + "label": "Clase", + "all": { + "title": "Toate clasele" + }, + "count_one": "{{count}} Clasă", + "count_other": "{{count}} Clase" } } diff --git a/web/public/locales/ro/views/configEditor.json b/web/public/locales/ro/views/configEditor.json index cecfb7cc73..21f7d4769d 100644 --- a/web/public/locales/ro/views/configEditor.json +++ b/web/public/locales/ro/views/configEditor.json @@ -1,5 +1,5 @@ { - "documentTitle": "Editor configurație - Frigate", + "documentTitle": "Editor de configurație - Frigate", "configEditor": "Editor de configurație", "copyConfig": "Copiază setările", "saveAndRestart": "Salvează și repornește", @@ -12,5 +12,7 @@ "savingError": "Eroare la salvarea setărilor" } }, - "confirm": "Ieși fără să salvezi?" + "confirm": "Ieși fără să salvezi?", + "safeConfigEditor": "Editor de configurație (mod de siguranță)", + "safeModeDescription": "Frigate este în modul de siguranță din cauza unei erori de validare a configurației." } diff --git a/web/public/locales/ro/views/events.json b/web/public/locales/ro/views/events.json index 30ae1ecb1a..f250d6feb1 100644 --- a/web/public/locales/ro/views/events.json +++ b/web/public/locales/ro/views/events.json @@ -34,5 +34,7 @@ "detections": "Detecții", "detected": "detectat", "selected_one": "{{count}} selectate", - "selected_other": "{{count}} selectate" + "selected_other": "{{count}} selectate", + "suspiciousActivity": "Activitate suspectă", + "threateningActivity": "Activitate amenințătoare" } diff --git a/web/public/locales/ro/views/explore.json b/web/public/locales/ro/views/explore.json index f9b4b0867f..66e3abc296 100644 --- a/web/public/locales/ro/views/explore.json +++ b/web/public/locales/ro/views/explore.json @@ -103,12 +103,14 @@ "success": { "regenerate": "O nouă descriere a fost solicitată de la {{provider}}. În funcție de viteza furnizorului tău, regenerarea noii descrieri poate dura ceva timp.", "updatedSublabel": "Subeticheta a fost actualizată cu succes.", - "updatedLPR": "Plăcuța de înmatriculare a fost actualizată cu succes." + "updatedLPR": "Plăcuța de înmatriculare a fost actualizată cu succes.", + "audioTranscription": "Transcrierea audio a fost solicitată cu succes." }, "error": { "updatedSublabelFailed": "Nu s-a putut actualiza sub-etichetarea: {{errorMessage}}", "updatedLPRFailed": "Plăcuța de înmatriculare nu a putut fi actualizată: {{errorMessage}}", - "regenerate": "Eroare la apelarea {{provider}} pentru o nouă descriere: {{errorMessage}}" + "regenerate": "Eroare la apelarea {{provider}} pentru o nouă descriere: {{errorMessage}}", + "audioTranscription": "Solicitarea transcrierii audio a eșuat: {{errorMessage}}" } } }, @@ -153,7 +155,10 @@ }, "expandRegenerationMenu": "Extinde meniul de regenerare", "regenerateFromSnapshot": "Regenerează din snapshot", - "regenerateFromThumbnails": "Regenerează din miniaturi" + "regenerateFromThumbnails": "Regenerează din miniaturi", + "score": { + "label": "Scor" + } }, "exploreMore": "Explorează mai multe obiecte cu {{label}}", "trackedObjectDetails": "Detalii despre obiectul urmărit", @@ -187,6 +192,14 @@ "submitToPlus": { "label": "Trimite către Frigate+", "aria": "Trimite către Frigate Plus" + }, + "addTrigger": { + "label": "Adaugă declanșator", + "aria": "Adaugă un declanșator pentru acest obiect urmărit" + }, + "audioTranscription": { + "label": "Transcrie", + "aria": "Solicită transcrierea audio" } }, "dialog": { @@ -205,5 +218,11 @@ } }, "tooltip": "Potrivire {{type}} cu {{confidence}}%" + }, + "aiAnalysis": { + "title": "Analiză AI" + }, + "concerns": { + "label": "Îngrijorări" } } diff --git a/web/public/locales/ro/views/live.json b/web/public/locales/ro/views/live.json index 39ce37747b..a998c49a8f 100644 --- a/web/public/locales/ro/views/live.json +++ b/web/public/locales/ro/views/live.json @@ -39,7 +39,15 @@ "label": "Fă clic în cadru pentru a centra camera PTZ" } }, - "presets": "Presetări cameră PTZ" + "presets": "Presetări cameră PTZ", + "focus": { + "in": { + "label": "Focalizează camera PTZ în interior" + }, + "out": { + "label": "Focalizează camera PTZ în exterior" + } + } }, "cameraAudio": { "enable": "Activează sunetul camerei", @@ -135,7 +143,8 @@ "recording": "Înregistrare", "snapshots": "Snapshot-uri", "audioDetection": "Detectare sunet", - "autotracking": "Urmărire automată" + "autotracking": "Urmărire automată", + "transcription": "Transcriere audio" }, "history": { "label": "Afișează înregistrările istorice" @@ -154,5 +163,9 @@ "label": "Editează grupul de camere" }, "exitEdit": "Ieși din modul de editare" + }, + "transcription": { + "enable": "Activează transcrierea audio în timp real", + "disable": "Dezactivează transcrierea audio în timp real" } } diff --git a/web/public/locales/ro/views/settings.json b/web/public/locales/ro/views/settings.json index fad64bd918..2dcb1a4ed9 100644 --- a/web/public/locales/ro/views/settings.json +++ b/web/public/locales/ro/views/settings.json @@ -21,7 +21,8 @@ "debug": "Depanare", "users": "Utilizatori", "notifications": "Notificări", - "frigateplus": "Frigate+" + "frigateplus": "Frigate+", + "triggers": "Declanșatoare" }, "dialog": { "unsavedChanges": { @@ -177,6 +178,44 @@ "selectAlertsZones": "Selectează zone pentru alerte", "noDefinedZones": "Nu sunt definite zone pentru această cameră.", "objectAlertsTips": "Toate obiectele {{alertsLabels}} de pe {{cameraName}} vor fi afișate ca alerte." + }, + "object_descriptions": { + "title": "Descrieri de obiecte generate de AI", + "desc": "Activează/dezactivează temporar descrierile de obiecte generate de AI pentru această cameră. Când această funcție este dezactivată, descrierile generate de AI nu vor fi solicitate pentru obiectele urmărite pe această cameră." + }, + "review_descriptions": { + "title": "Descrieri de revizuiri generate de AI", + "desc": "Activează/dezactivează temporar descrierile recenziilor generate de AI pentru această cameră. Când această funcție este dezactivată, descrierile generate de AI nu vor fi solicitate pentru elementele de recenzie de pe această cameră." + }, + "addCamera": "Adaugă cameră nouă", + "editCamera": "Editează camera:", + "selectCamera": "Selectează camera", + "backToSettings": "Înapoi la setările camerei", + "cameraConfig": { + "add": "Adaugă cameră", + "edit": "Editează camera", + "description": "Configurează setările camerei, inclusiv intrările de flux și rolurile.", + "name": "Numele camerei", + "nameRequired": "Numele camerei este obligatoriu", + "nameInvalid": "Numele camerei trebuie să conțină doar litere, cifre, underscore-uri sau cratime", + "namePlaceholder": "de ex.: usa_principala", + "enabled": "Activat", + "ffmpeg": { + "inputs": "Stream-uri de intrare", + "path": "Cale stream", + "pathRequired": "Calea stream-ului este obligatorie", + "pathPlaceholder": "rtsp://...", + "roles": "Roluri", + "rolesRequired": "Este necesar cel puțin un rol", + "rolesUnique": "Fiecare rol (audio, detectare, înregistrare) poate fi atribuit doar unui singur stream", + "addInput": "Adaugă stream de intrare", + "removeInput": "Elimină stream-ul de intrare", + "inputsRequired": "Este necesar cel puțin un stream de intrare" + }, + "toast": { + "success": "Camera {{cameraName}} a fost salvată cu succes" + }, + "nameLength": "Numele camerei trebuie să aibă mai puțin de 24 de caractere." } }, "masksAndZones": { @@ -399,7 +438,20 @@ "zones": { "title": "Zone", "desc": "Afișează conturul oricăror zone definite" - } + }, + "paths": { + "title": "Căi", + "desc": "Afișează punctele semnificative ale traseului obiectului urmărit", + "tips": "

    Căi


    Liniile și cercurile vor indica punctele semnificative prin care obiectul urmărit s-a deplasat pe parcursul ciclului său de viață.

    " + }, + "audio": { + "title": "Audio", + "noAudioDetections": "Nicio detecție audio", + "score": "scor", + "currentRMS": "RMS curent", + "currentdbFS": "dbFS curent" + }, + "openCameraWebUI": "Deschide interfața web pentru {{camera}}" }, "users": { "dialog": { @@ -415,7 +467,8 @@ "admin": "Administrator", "adminDesc": "Acces complet la toate funcțiile.", "viewer": "Vizualizator", - "viewerDesc": "Limitat doar la tablourile de bord Live, Revizuire, Explorare și Exporturi." + "viewerDesc": "Limitat doar la tablourile de bord Live, Revizuire, Explorare și Exporturi.", + "customDesc": "Rol personalizat cu acces specific la cameră." }, "select": "Selectează un rol", "title": "Schimbă rolul utilizatorului" @@ -619,5 +672,160 @@ "success": "Setările de mișcare au fost salvate." }, "title": "Reglaj detecție mișcare" + }, + "triggers": { + "documentTitle": "Declanșatoare", + "management": { + "title": "Gestionarea declanșatoarelor", + "desc": "Gestionează declanșatoarele pentru {{camera}}. Folosește tipul miniatură pentru a declanșa pe miniaturi similare cu obiectul urmărit selectat și tipul descriere pentru a declanșa pe descrieri similare textului pe care îl specifici." + }, + "addTrigger": "Adaugă declanșator", + "table": { + "name": "Nume", + "type": "Tip", + "content": "Conținut", + "threshold": "Prag", + "actions": "Acțiuni", + "noTriggers": "Nu sunt configurate declanșatoare pentru această cameră.", + "edit": "Editează", + "deleteTrigger": "Elimină declanșatorul", + "lastTriggered": "Ultima declanșare" + }, + "type": { + "thumbnail": "Miniatură", + "description": "Descriere" + }, + "actions": { + "alert": "Marchează ca alertă", + "notification": "Trimite notificare" + }, + "dialog": { + "createTrigger": { + "title": "Crează declanșator", + "desc": "Creează un declanșator pentru camera {{camera}}" + }, + "editTrigger": { + "title": "Editează declanșatorul", + "desc": "Editează setările pentru declanșatorul de pe camera {{camera}}" + }, + "deleteTrigger": { + "title": "Elimină declanșatorul", + "desc": "Ești sigur că vrei să ștergi declanșatorul {{triggerName}}? Această acțiune nu poate fi anulată." + }, + "form": { + "name": { + "title": "Nume", + "placeholder": "Introdu numele declanșatorului", + "error": { + "minLength": "Numele trebuie să aibă cel puțin 2 caractere.", + "invalidCharacters": "Numele poate conține doar litere, cifre, underscore-uri și cratime.", + "alreadyExists": "Un declanșator cu acest nume există deja pentru această cameră." + } + }, + "enabled": { + "description": "Activează sau dezactivează acest declanșator" + }, + "type": { + "title": "Tip", + "placeholder": "Selectează tipul de declanșator" + }, + "content": { + "title": "Conținut", + "imagePlaceholder": "Selectează o imagine", + "textPlaceholder": "Introdu conținutul textului", + "imageDesc": "Selectează o imagine pentru a declanșa această acțiune atunci când o imagine similară este detectată.", + "textDesc": "Introduceți textul pentru a declanșa această acțiune atunci când este detectată o descriere de obiect urmărit similară.", + "error": { + "required": "Conținutul este obligatoriu." + } + }, + "threshold": { + "title": "Prag", + "error": { + "min": "Pragul trebuie să fie cel puțin 0", + "max": "Pragul trebuie să fie cel mult 1" + } + }, + "actions": { + "title": "Acțiuni", + "desc": "Implicit, Frigate trimite un mesaj MQTT pentru toate declanșatoarele. Alegeți o acțiune suplimentară de efectuat atunci când acest declanșator se activează.", + "error": { + "min": "Trebuie selectată cel puțin o acțiune." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Declanșatorul {{name}} a fost creat cu succes.", + "updateTrigger": "Declanșatorul {{name}} a fost actualizat cu succes.", + "deleteTrigger": "Declanșatorul {{name}} a fost eliminat cu succes." + }, + "error": { + "createTriggerFailed": "Crearea declanșatorului a eșuat: {{errorMessage}}", + "updateTriggerFailed": "Actualizarea declanșatorului a eșuat: {{errorMessage}}", + "deleteTriggerFailed": "Eliminarea declanșatorului a eșuat: {{errorMessage}}" + } + } + }, + "roles": { + "management": { + "title": "Gestionare rol vizualizator", + "desc": "Gestionează rolurile personalizate de vizualizator și permisiunile lor de acces la cameră pentru această instanță Frigate." + }, + "addRole": "Adaugă rol", + "table": { + "role": "Rol", + "cameras": "Camere", + "actions": "Acțiuni", + "noRoles": "Nu au fost găsite roluri personalizate.", + "editCameras": "Editează camerele", + "deleteRole": "Șterge rol" + }, + "toast": { + "success": { + "createRole": "Rolul {{role}} a fost creat cu succes", + "updateCameras": "Camerele au fost actualizate pentru rolul {{role}}", + "deleteRole": "Rolul {{role}} a fost șters cu succes", + "userRolesUpdated": "{{count}} utilizator(i) atribuiți acestui rol au fost actualizați la „vizualizator”, care are acces la toate camerele." + }, + "error": { + "createRoleFailed": "Crearea rolului a eșuat: {{errorMessage}}", + "updateCamerasFailed": "Actualizarea camerelor a eșuat: {{errorMessage}}", + "deleteRoleFailed": "Ștergerea rolului a eșuat: {{errorMessage}}", + "userUpdateFailed": "Actualizarea rolurilor utilizatorilor a eșuat: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Creează rol nou", + "desc": "Adaugă un rol nou și specifică permisiunile de acces la camere." + }, + "editCameras": { + "title": "Editează camerele rolului", + "desc": "Actualizează accesul la camere pentru rolul {{role}}." + }, + "deleteRole": { + "title": "Șterge rolul", + "desc": "Această acțiune nu poate fi anulată. Aceasta va șterge permanent rolul și va atribui orice utilizatori cu acest rol la rolul „vizualizator”, care va oferi acces vizualizator la toate camerele.", + "warn": "Ești sigur că vrei să ștergi {{role}}?", + "deleting": "Se șterge..." + }, + "form": { + "role": { + "title": "Nume rol", + "placeholder": "Introduceți numele rolului", + "desc": "Sunt permise doar litere, cifre, puncte și linii de subliniere.", + "roleIsRequired": "Numele rolului este obligatoriu", + "roleOnlyInclude": "Numele rolului poate include doar litere, cifre, . sau _", + "roleExists": "Un rol cu acest nume există deja." + }, + "cameras": { + "title": "Camere", + "desc": "Selectați camerele la care acest rol are acces. Este necesară cel puțin o cameră.", + "required": "Trebuie selectată cel puțin o cameră." + } + } + } } } diff --git a/web/public/locales/ro/views/system.json b/web/public/locales/ro/views/system.json index 5ba80df9cb..057370448c 100644 --- a/web/public/locales/ro/views/system.json +++ b/web/public/locales/ro/views/system.json @@ -49,7 +49,8 @@ "title": "Detectori", "cpuUsage": "Utilizarea procesorului", "inferenceSpeed": "Viteza de inferență", - "memoryUsage": "Utilizare memorie detector" + "memoryUsage": "Utilizare memorie detector", + "cpuUsageInformation": "Procesorul utilizat pentru pregătirea datelor de intrare și ieșire către/dinspre modelele de detecție. Această valoare nu măsoară utilizarea în timpul inferenței, chiar dacă este folosit un GPU sau un accelerator." }, "otherProcesses": { "title": "Alte Procese", @@ -77,7 +78,12 @@ }, "bandwidth": "Lățime de bandă" }, - "overview": "Prezentare generală" + "overview": "Prezentare generală", + "shm": { + "title": "Alocare SHM (memorie partajată)", + "warning": "Dimensiunea curentă a SHM de {{total}}MB este prea mică. Măriți-o la cel puțin {{min_shm}}MB.", + "readTheDocumentation": "Citește documentația" + } }, "title": "Sistem", "logs": { @@ -174,7 +180,8 @@ "detectHighCpuUsage": "Camera {{camera}} are o utilizare ridicată a procesorului pentru detecție ({{detectAvg}}%)", "ffmpegHighCpuUsage": "Camera {{camera}} are o utilizare ridicată a procesorului FFmpeg ({{ffmpegAvg}}%)", "cameraIsOffline": "{{camera}} este offline", - "healthy": "Sistemul funcționează normal" + "healthy": "Sistemul funcționează normal", + "shmTooLow": "Alocarea /dev/shm ({{total}} MB) ar trebui mărită la cel puțin {{min}} MB." }, "lastRefreshed": "Ultima reîmprospătare: " } diff --git a/web/public/locales/ru/common.json b/web/public/locales/ru/common.json index 92ee6cf942..ee4a0df107 100644 --- a/web/public/locales/ru/common.json +++ b/web/public/locales/ru/common.json @@ -182,7 +182,15 @@ }, "yue": "粵語 (Кантонский)", "th": "ไทย (Тайский)", - "ca": "Català (Каталонский)" + "ca": "Català (Каталонский)", + "ptBR": "Português brasileiro (Бразильский португальский)", + "sr": "Српски (Сербский)", + "sl": "Slovenščina (Словенский)", + "lt": "Lietuvių (Литовский)", + "bg": "Български (Болгарский)", + "gl": "Galego (Галисийский)", + "id": "Bahasa Indonesia (Индонезийский)", + "ur": "اردو (Урду)" }, "darkMode": { "withSystem": { @@ -271,5 +279,7 @@ "admin": "Администратор", "viewer": "Наблюдатель", "desc": "Администраторы имеют полный доступ ко всем функциям в интерфейсе Frigate. Наблюдатели ограничены просмотром камер, элементов просмотра и архивных записей." - } + }, + "selectItem": "Выбрать {{item}}", + "readTheDocumentation": "Читать документацию" } diff --git a/web/public/locales/ru/components/camera.json b/web/public/locales/ru/components/camera.json index 3059b83f08..8a8c1a4922 100644 --- a/web/public/locales/ru/components/camera.json +++ b/web/public/locales/ru/components/camera.json @@ -66,7 +66,8 @@ "title": "Настройки видеопотока {{cameraName}}", "stream": "Поток", "placeholder": "Выбрать поток" - } + }, + "birdseye": "Birdseye" } }, "debug": { diff --git a/web/public/locales/ru/components/dialog.json b/web/public/locales/ru/components/dialog.json index 078a37a976..748d079db2 100644 --- a/web/public/locales/ru/components/dialog.json +++ b/web/public/locales/ru/components/dialog.json @@ -122,5 +122,12 @@ "markAsReviewed": "Пометить как просмотренное", "deleteNow": "Удалить сейчас" } + }, + "imagePicker": { + "search": { + "placeholder": "Искать по метке..." + }, + "selectImage": "Выбор миниатюры отслеживаемого объекта", + "noImages": "Не обнаружено миниатюр для этой камеры" } } diff --git a/web/public/locales/ru/components/filter.json b/web/public/locales/ru/components/filter.json index 024ebe02c8..0b75d2347d 100644 --- a/web/public/locales/ru/components/filter.json +++ b/web/public/locales/ru/components/filter.json @@ -116,12 +116,22 @@ "title": "Распознанные номерные знаки", "loadFailed": "Не удалось загрузить распознанные номерные знаки.", "loading": "Загрузка распознанных номерных знаков…", - "selectPlatesFromList": "Выберите один или более знаков из списка." + "selectPlatesFromList": "Выберите один или более знаков из списка.", + "selectAll": "Выбрать все", + "clearAll": "Очистить все" }, "review": { "showReviewed": "Показать просмотренные" }, "motion": { "showMotionOnly": "Показывать только движение" + }, + "classes": { + "label": "Классы", + "all": { + "title": "Все классы" + }, + "count_one": "{{count}} класс", + "count_other": "{{count}} классы" } } diff --git a/web/public/locales/ru/views/configEditor.json b/web/public/locales/ru/views/configEditor.json index 73b566a08a..0dd775b241 100644 --- a/web/public/locales/ru/views/configEditor.json +++ b/web/public/locales/ru/views/configEditor.json @@ -12,5 +12,7 @@ "savingError": "Ошибка сохранения конфигурации" } }, - "confirm": "Выйти без сохранения?" + "confirm": "Выйти без сохранения?", + "safeConfigEditor": "Редактор конфигурации (безопасный режим)", + "safeModeDescription": "Frigate находится в безопасном режиме из-за ошибки проверки конфигурации." } diff --git a/web/public/locales/ru/views/events.json b/web/public/locales/ru/views/events.json index 6c8bebb6e8..3104cac409 100644 --- a/web/public/locales/ru/views/events.json +++ b/web/public/locales/ru/views/events.json @@ -35,5 +35,7 @@ "selected": "{{count}} выбрано", "selected_one": "{{count}} выбрано", "selected_other": "{{count}} выбрано", - "detected": "обнаружен" + "detected": "обнаружен", + "suspiciousActivity": "Подозрительная активность", + "threateningActivity": "Угрожающая активность" } diff --git a/web/public/locales/ru/views/explore.json b/web/public/locales/ru/views/explore.json index 63f6c28673..833dc3095b 100644 --- a/web/public/locales/ru/views/explore.json +++ b/web/public/locales/ru/views/explore.json @@ -48,12 +48,14 @@ "success": { "updatedSublabel": "Успешно обновлена дополнительная метка.", "updatedLPR": "Номерной знак успешно обновлён.", - "regenerate": "Новое описание запрошено у {{provider}}. В зависимости от скорости работы вашего провайдера, генерация нового описания может занять некоторое время." + "regenerate": "Новое описание запрошено у {{provider}}. В зависимости от скорости работы вашего провайдера, генерация нового описания может занять некоторое время.", + "audioTranscription": "Запрос на транскрипцию звука успешно выполнен." }, "error": { "updatedSublabelFailed": "Не удалось обновить дополнительную метку: {{errorMessage}}", "updatedLPRFailed": "Не удалось обновить номерной знак: {{errorMessage}}", - "regenerate": "Не удалось запросить новое описание у {{provider}}: {{errorMessage}}" + "regenerate": "Не удалось запросить новое описание у {{provider}}: {{errorMessage}}", + "audioTranscription": "Не удалось запросить транскрипцию аудио: {{errorMessage}}" } } }, @@ -98,6 +100,9 @@ "regenerateFromThumbnails": "Перегенерировать из миниатюры", "snapshotScore": { "label": "Оценка снимка" + }, + "score": { + "label": "Балл" } }, "trackedObjectDetails": "Детали объекта", @@ -183,6 +188,14 @@ }, "deleteTrackedObject": { "label": "Удалить этот отслеживаемый объект" + }, + "addTrigger": { + "label": "Добавить триггер", + "aria": "Добавить триггер для этого отслеживаемого объекта" + }, + "audioTranscription": { + "label": "Транскрибировать", + "aria": "Запросить аудиотранскрипцию" } }, "dialog": { @@ -205,5 +218,11 @@ }, "tooltip": "Соответствие с {{type}} на {{confidence}}%" }, - "exploreMore": "Просмотреть больше объектов {{label}}" + "exploreMore": "Просмотреть больше объектов {{label}}", + "aiAnalysis": { + "title": "Анализ при помощи ИИ" + }, + "concerns": { + "label": "Требуют внимания" + } } diff --git a/web/public/locales/ru/views/live.json b/web/public/locales/ru/views/live.json index e7960d58f8..cd32e82eb6 100644 --- a/web/public/locales/ru/views/live.json +++ b/web/public/locales/ru/views/live.json @@ -43,7 +43,15 @@ "label": "Кликните в кадре для центрирования PTZ-камеры" } }, - "presets": "Предустановки PTZ-камеры" + "presets": "Предустановки PTZ-камеры", + "focus": { + "in": { + "label": "Сфокусировать PTZ камеру на" + }, + "out": { + "label": "Отдалить фокус PTZ камеры" + } + } }, "camera": { "enable": "Включить камеру", @@ -133,7 +141,8 @@ "audioDetection": "Детекция аудио", "snapshots": "Снимки", "autotracking": "Автотрекинг", - "cameraEnabled": "Камера активирована" + "cameraEnabled": "Камера активирована", + "transcription": "Транскрипция аудио" }, "history": { "label": "Отобразить архивные записи" @@ -154,5 +163,9 @@ "exitEdit": "Выход из редактирования" }, "audio": "Аудио", - "notifications": "Уведомления" + "notifications": "Уведомления", + "transcription": { + "enable": "Включить транскрипцию звука в реальном времени", + "disable": "Выключить транскрипцию звука" + } } diff --git a/web/public/locales/ru/views/settings.json b/web/public/locales/ru/views/settings.json index 130b566196..ef0021e1c6 100644 --- a/web/public/locales/ru/views/settings.json +++ b/web/public/locales/ru/views/settings.json @@ -22,7 +22,8 @@ "frigateplus": "Frigate+", "ui": "Интерфейс", "classification": "Распознавание", - "enrichments": "Обогащения" + "enrichments": "Обогащения", + "triggers": "Триггеры" }, "dialog": { "unsavedChanges": { @@ -330,6 +331,44 @@ "streams": { "title": "Потоки", "desc": "Временно отключить камеру до перезапуска Frigate. Отключение камеры полностью останавливает обработку потоков этой камеры в Frigate. Обнаружение, запись и отладка будут недоступны.
    Примечание: Это не отключает рестриминг go2rtc." + }, + "object_descriptions": { + "title": "Сгенерировать описания объектов при помощи ИИ", + "desc": "Временно включить/отключить описание объектов при помощи генеративного ИИ для этой камеры. При отключении описания, описание объектов при помощи генеративного ИИ не будут запрашиваться для отслеживаемых объектов на этой камере." + }, + "review_descriptions": { + "title": "Описания обзоров генеративного ИИ", + "desc": "Временно включить/отключить описания обзоров с помощью генеративного ИИ для этой камеры. Если отключено, описания, описания обзоров с помощью генеративного ИИ, не будут запрашиваться для элементов обзора для этой камеры." + }, + "addCamera": "Добавить новую камеру", + "editCamera": "Редактировать камеру:", + "selectCamera": "Выбрать камеру", + "backToSettings": "Вернуться к настройкам камеры", + "cameraConfig": { + "add": "Добавить камеру", + "edit": "Редактировать камеру", + "description": "Настройте параметры камеры, включая входные трансляции и роли.", + "name": "Название камеры", + "nameRequired": "Требуется имя камеры", + "nameInvalid": "Имя камеры должно содержать только буквы, цифры, подчеркивания или дефисы", + "namePlaceholder": "например, front_door", + "enabled": "Включено", + "ffmpeg": { + "inputs": "Входные трансляции", + "path": "Путь трансляции", + "pathRequired": "Требуется путь трансляции", + "pathPlaceholder": "rtsp://...", + "roles": "Роли", + "rolesRequired": "Требуется хотя бы одна роль", + "rolesUnique": "Каждая роль (аудио, обнаружение, запись) может быть назначена только одной трансляции", + "addInput": "Добавить входной поток", + "removeInput": "Удалить входной поток", + "inputsRequired": "Требуется хотя бы 1 входной поток" + }, + "toast": { + "success": "Камера {{cameraName}} успешно сохранена" + }, + "nameLength": "Название камеры должно содержать не более 24 символов." } }, "masksAndZones": { @@ -575,6 +614,19 @@ "title": "Регионы", "desc": "Показать рамку области интереса, отправленной детектору объектов", "tips": "

    Рамки областей интереса


    Ярко-зелёные рамки будут наложены на области интереса в кадре, которые отправляются детектору объектов.

    " + }, + "paths": { + "title": "Пути", + "desc": "Показывать значимые точки пути отслеживаемого объекта", + "tips": "

    Пути


    Линии и круги будут обозначать важные точки, которые отслеживаемый объект посетил в течение своего жизненного цикла.

    " + }, + "openCameraWebUI": "Открыть веб-интерфейс {{camera}}", + "audio": { + "title": "Аудио", + "noAudioDetections": "Аудиообнаружений нет", + "score": "оценка", + "currentRMS": "Текущий RMS", + "currentdbFS": "Текущий dbFS" } }, "frigatePlus": { @@ -683,5 +735,100 @@ "success": "Настройки обогащений сохранены. Перезапустите Frigate, чтобы применить изменения.", "error": "Не удалось сохранить изменения: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Триггеры", + "management": { + "title": "Управление триггерами", + "desc": "Управление триггерами для камеры {{camera}}. Используйте тип миниатюры для срабатывания по миниатюрам, похожим на выбранный отслеживаемый объект, и тип описания для срабатывания по описаниям, похожим на указанный вами текст." + }, + "addTrigger": "Добавить Триггер", + "table": { + "name": "Имя", + "type": "Тип", + "content": "Содержимое", + "threshold": "Порог", + "actions": "Действия", + "noTriggers": "Для этой камеры не настроены триггеры.", + "edit": "Редактировать", + "deleteTrigger": "Удалить триггер", + "lastTriggered": "Последний сработавший" + }, + "type": { + "thumbnail": "Миниатюра", + "description": "Описание" + }, + "actions": { + "alert": "Отметить как предупреждение", + "notification": "Отправить оповещение" + }, + "dialog": { + "createTrigger": { + "title": "Создать триггер", + "desc": "Создать триггер для камеры {{camera}}" + }, + "editTrigger": { + "title": "Изменить триггер", + "desc": "Изменить настройки триггера для камеры {{camera}}" + }, + "deleteTrigger": { + "title": "Удалить триггер", + "desc": "Вы уверены, что хотите удалить триггер {{triggerName}}? Это действие не может быть отменено." + }, + "form": { + "name": { + "title": "Имя", + "placeholder": "Введите имя триггера", + "error": { + "minLength": "Имя должно быть длиной не менее 2 символов.", + "invalidCharacters": "Имя может содержать только буквы, цифры, символы подчеркивания и дефисы.", + "alreadyExists": "Триггер с таким именем уже существует для этой камеры." + } + }, + "enabled": { + "description": "Включить или отключить этот триггер" + }, + "type": { + "title": "Тип", + "placeholder": "Выберите тип триггера" + }, + "content": { + "title": "Содержимое", + "imagePlaceholder": "Выберите изображение", + "textPlaceholder": "Введите текстовое содержимое", + "imageDesc": "Выберите изображение, чтобы активировать это действие при обнаружении похожего изображения.", + "textDesc": "Введите текст, чтобы активировать это действие при обнаружении похожего описания отслеживаемого объекта.", + "error": { + "required": "Требуется содержимое." + } + }, + "threshold": { + "title": "Порог", + "error": { + "min": "Порог должен быть не менее 0", + "max": "Порог должен быть не более 1" + } + }, + "actions": { + "title": "Действия", + "desc": "По умолчанию Frigate отправляет MQTT-сообщение для всех триггеров. Выберите дополнительное действие, которое будет выполняться при срабатывании этого триггера.", + "error": { + "min": "Необходимо выбрать хотя бы одно действие." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Триггер {{name}} успешно создан.", + "updateTrigger": "Триггер {{name}} успешно обновлен.", + "deleteTrigger": "Триггер {{name}} успешно удален." + }, + "error": { + "createTriggerFailed": "Не удалось создать триггер: {{errorMessage}}", + "updateTriggerFailed": "Не удалось обновить триггер: {{errorMessage}}", + "deleteTriggerFailed": "Не удалось удалить триггер: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/ru/views/system.json b/web/public/locales/ru/views/system.json index 3e0052a88f..ad2b914ac3 100644 --- a/web/public/locales/ru/views/system.json +++ b/web/public/locales/ru/views/system.json @@ -42,7 +42,8 @@ "inferenceSpeed": "Скорость вывода детектора", "cpuUsage": "Использование CPU детектором", "memoryUsage": "Использование памяти детектором", - "temperature": "Температура детектора" + "temperature": "Температура детектора", + "cpuUsageInformation": "CPU используется при подготовке входных и выходных данных к/от моделей обнаружения. Это значение не измеряет использование вывода, даже если использовать GPU или ускоритель." }, "hardwareInfo": { "title": "Информация об оборудовании", @@ -102,6 +103,10 @@ "title": "Не используется", "tips": "Это значение может неточно отражать свободное место, доступное Frigate, если на вашем диске есть другие файлы помимо записей Frigate. Frigate не отслеживает использование хранилища за пределами своих записей." } + }, + "shm": { + "title": "Выделение разделяемой памяти", + "warning": "Текущеее значение разделяемой памяти в {{total}}MB слишком мало. Увеличьте его хотя бы до {{min_shm}}MB." } }, "cameras": { @@ -158,7 +163,8 @@ "reindexingEmbeddings": "Переиндексация эмбеддингов (выполнено {{processed}} %)", "cameraIsOffline": "{{camera}} отключена", "detectIsVerySlow": "{{detect}} идёт очень медленно ({{speed}} мс)", - "detectIsSlow": "{{detect}} идёт медленно ({{speed}} мс)" + "detectIsSlow": "{{detect}} идёт медленно ({{speed}} мс)", + "shmTooLow": "Объем выделенной памяти /dev/shm ({{total}} МБ) должен быть увеличен как минимум до {{min}} МБ." }, "enrichments": { "title": "Обогащение данных", diff --git a/web/public/locales/sk/audio.json b/web/public/locales/sk/audio.json index 8a10ee24a2..511ea3846b 100644 --- a/web/public/locales/sk/audio.json +++ b/web/public/locales/sk/audio.json @@ -2,7 +2,7 @@ "speech": "Reč", "babbling": "Bľabotanie", "yell": "Krik", - "bellow": "Rev", + "bellow": "Pod", "whispering": "Šepkanie", "whoop": "Výskanie", "laughter": "Smiech", @@ -47,5 +47,61 @@ "horse": "Kôň", "sheep": "Ovce", "camera": "Kamera", - "pant": "Oddychávanie" + "pant": "Oddychávanie", + "gargling": "Grganie", + "stomach_rumble": "Škvŕkanie v žalúdku", + "burping": "Grganie", + "skateboard": "Skateboard", + "hiccup": "Škytavka", + "fart": "Prd", + "hands": "Ruky", + "finger_snapping": "Lusknutie prstom", + "clapping": "Tlieskanie", + "heartbeat": "Tlkot srdca", + "heart_murmur": "Srdcový šelest", + "cheering": "Fandenie", + "applause": "Potlesk", + "chatter": "Chatárčenie", + "crowd": "Dav", + "children_playing": "Deti hrajúce sa", + "animal": "Zviera", + "pets": "Domáce zvieratá", + "bark": "Kôra", + "yip": "Áno", + "howl": "Zavýjať", + "bow_wow": "Hlasitého protestu", + "growling": "Vrčanie", + "whimper_dog": "Psie kňučanie", + "purr": "Pradenie", + "meow": "Mňau", + "hiss": "Syčanie", + "caterwaul": "Kričať", + "livestock": "Hospodárske zvieratá", + "clip_clop": "Klepanie kopyt", + "neigh": "Eržanie", + "door": "Dvere", + "cattle": "Hovädzí dobytok", + "moo": "Búčanie", + "cowbell": "Kravský zvonec", + "mouse": "Myška", + "pig": "Prasa", + "oink": "Chrčanie", + "keyboard": "Klávesnica", + "goat": "Koza", + "bleat": "nariekať", + "fowl": "Sliepky", + "chicken": "Slepica", + "sink": "Umývadlo", + "cluck": "Kvákanie", + "cock_a_doodle_doo": "Kykyryký", + "blender": "Mixér", + "turkey": "Morka", + "gobble": "Hltať", + "clock": "Hodiny", + "duck": "Kačica", + "wild_animals": "Divoké zvieratá", + "toothbrush": "Zubná kefka", + "roaring_cats": "Revúce mačky", + "roar": "Revať", + "vehicle": "Vozidlo" } diff --git a/web/public/locales/sk/common.json b/web/public/locales/sk/common.json index 28812e2470..70765d8578 100644 --- a/web/public/locales/sk/common.json +++ b/web/public/locales/sk/common.json @@ -39,7 +39,97 @@ "hour_few": "{{time}}hodiny", "hour_other": "{{time}}hodin", "m": "{{time}} min", - "s": "{{time}}s" + "s": "{{time}}s", + "minute_one": "{{time}}minuta", + "minute_few": "{{time}}minuty", + "minute_other": "{{time}}minut", + "second_one": "{{time}}sekunda", + "second_few": "{{time}}sekundy", + "second_other": "{{time}}sekund", + "formattedTimestamp": { + "12hour": "Deň MMM, h:mm:ss aaa", + "24hour": "Deň MMM, HH:mm:ss" + }, + "formattedTimestamp2": { + "12hour": "MM/dd h:mm:ssa", + "24hour": "d MMM HH:mm:ss" + }, + "formattedTimestampHourMinute": { + "12hour": "h:mm aaa", + "24hour": "HH:mm" + }, + "formattedTimestampHourMinuteSecond": { + "12hour": "h:mm:ss aaa", + "24hour": "HH:mm:ss" + }, + "formattedTimestampMonthDayHourMinute": { + "12hour": "MMM d, h:mm aaa", + "24hour": "MMM d, HH:mm" + }, + "formattedTimestampMonthDayYear": { + "12hour": "MMM d, yyyy", + "24hour": "MMM d, yyyy" + }, + "formattedTimestampMonthDayYearHourMinute": { + "12hour": "MMM d yyyy, h:mm aaa", + "24hour": "MMM d yyyy, HH:mm" + }, + "formattedTimestampMonthDay": "MMM d", + "formattedTimestampFilename": { + "12hour": "MM-dd-yy-h-mm-ss-a", + "24hour": "MM-dd-yy-HH-mm-ss" + } }, - "selectItem": "Vyberte {{item}}" + "selectItem": "Vyberte {{item}}", + "unit": { + "speed": { + "mph": "mph", + "kph": "Km/h" + }, + "length": { + "feet": "nohy", + "meters": "metrov" + } + }, + "readTheDocumentation": "Prečítajte si dokumentáciu", + "label": { + "back": "Choď späť" + }, + "button": { + "apply": "Použiť", + "reset": "Resetovať", + "done": "Hotovo", + "enabled": "Povolené", + "enable": "Povoliť", + "disabled": "Zakázané", + "disable": "Zakázať", + "save": "Uložiť", + "saving": "Ukladá sa…", + "cancel": "Zrušiť", + "close": "Zavrieť", + "copy": "Kopírovať", + "back": "Späť", + "history": "História", + "fullscreen": "Celá obrazovka", + "exitFullscreen": "Opustiť režim celú obrazovku", + "pictureInPicture": "Obraz v obraze", + "twoWayTalk": "Obojsmerná komunikácia", + "cameraAudio": "Zvuk kamery", + "on": "ON", + "off": "OFF", + "edit": "Upraviť", + "copyCoordinates": "Kopírovať súradnice", + "delete": "Odstrániť", + "yes": "Ano", + "no": "Nie", + "download": "Stiahnuť", + "info": "Informacie", + "suspended": "Pozastavené", + "export": "Exportovať", + "deleteNow": "Odstrániť teraz", + "next": "Ďalej" + }, + "menu": { + "system": "Systém" + } } diff --git a/web/public/locales/sk/components/camera.json b/web/public/locales/sk/components/camera.json index 151199d9a8..e2245bd071 100644 --- a/web/public/locales/sk/components/camera.json +++ b/web/public/locales/sk/components/camera.json @@ -56,12 +56,32 @@ "continuousStreaming": { "label": "Nepretržité streamovanie", "desc": { - "title": "Obraz z kamery bude vždy vysielaný naživo, keď bude viditeľný na palubnej doske, aj keď nebude detekovaná žiadna aktivita." + "title": "Obraz z kamery bude vždy vysielaný naživo, keď bude viditeľný na palubnej doske, aj keď nebude detekovaná žiadna aktivita.", + "warning": "Nepretržité streamovanie môže spôsobiť vysoké využitie šírky pásma a problémy s výkonom. Používajte opatrne." } } } + }, + "compatibilityMode": { + "label": "Režim kompatibility", + "desc": "Túto možnosť povoľte iba v prípade, že živý prenos z vašej kamery zobrazuje farebné artefakty a na pravej strane obrazu sa nachádza diagonálna čiara." } - } + }, + "birdseye": "Vtáčie oko" } + }, + "debug": { + "options": { + "label": "Nastavenia", + "title": "Možnosti", + "showOptions": "Zobraziť možnosti", + "hideOptions": "Skryť možnosti" + }, + "boundingBox": "Hranica", + "timestamp": "Časová pečiatka", + "zones": "Zóny", + "mask": "Maska", + "motion": "Pohyb", + "regions": "Kraje" } } diff --git a/web/public/locales/sk/components/dialog.json b/web/public/locales/sk/components/dialog.json index a254150e22..c1afb63f5d 100644 --- a/web/public/locales/sk/components/dialog.json +++ b/web/public/locales/sk/components/dialog.json @@ -41,7 +41,10 @@ "end": { "title": "Čas ukončenia", "label": "Vybrat čas ukončenia" - } + }, + "lastHour_one": "Minulu hodinu", + "lastHour_few": "Minule{{count}}hodiny", + "lastHour_other": "Minulych{{count}}hodin" }, "name": { "placeholder": "Pomenujte Export" @@ -67,8 +70,52 @@ "restreaming": { "disabled": "Opätovné streamovanie nie je pre túto kameru povolené.", "desc": { - "title": "Pre ďalšie možnosti živého náhľadu a zvuku pre túto kameru nastavte go2rtc." + "title": "Pre ďalšie možnosti živého náhľadu a zvuku pre túto kameru nastavte go2rtc.", + "readTheDocumentation": "Prečítajte si dokumentáciu" + } + }, + "showStats": { + "label": "Zobraziť štatistiky streamu", + "desc": "Povoľte túto možnosť, ak chcete zobraziť štatistiky streamu ako prekrytie na obraze z kamery." + }, + "debugView": "Zobrazenie ladenia" + }, + "search": { + "saveSearch": { + "label": "Uložiť vyhľadávanie", + "desc": "Zadajte názov pre toto uložené vyhľadávanie.", + "placeholder": "Zadajte názov pre vyhľadávanie", + "overwrite": "{{searchName}} už existuje. Uložením sa prepíše existujúca hodnota.", + "success": "Hľadanie ({{searchName}}) bolo uložené.", + "button": { + "save": { + "label": "Uložte toto vyhľadávanie" + } } } + }, + "recording": { + "confirmDelete": { + "title": "Potvrďte Odstrániť", + "desc": { + "selected": "Naozaj chcete odstrániť všetky nahrané videá spojené s touto položkou recenzie?

    Podržte kláves Shift, aby ste v budúcnosti toto dialógové okno obišli." + }, + "toast": { + "success": "Videozáznam spojený s vybranými položkami recenzie bol úspešne odstránený.", + "error": "Nepodarilo sa odstrániť: {{error}}" + } + }, + "button": { + "export": "Exportovať", + "markAsReviewed": "Označiť ako skontrolované", + "deleteNow": "Odstrániť teraz" + } + }, + "imagePicker": { + "selectImage": "Výber miniatúry sledovaného objektu", + "search": { + "placeholder": "Hľadať podľa štítku alebo podštítku..." + }, + "noImages": "Pre tuto kameru sa nenašli žiadne miniatúry" } } diff --git a/web/public/locales/sk/components/filter.json b/web/public/locales/sk/components/filter.json index e1c1eb4727..83305f9219 100644 --- a/web/public/locales/sk/components/filter.json +++ b/web/public/locales/sk/components/filter.json @@ -54,6 +54,83 @@ "relevance": "Relevantnosť" }, "cameras": { - "label": "Filter kamier" + "label": "Filter kamier", + "all": { + "title": "Všetky kamery", + "short": "Kamery" + } + }, + "classes": { + "label": "Triedy", + "all": { + "title": "Všetky triedy" + }, + "count_one": "Trieda {{count}}", + "count_other": "Triedy {{count}}" + }, + "review": { + "showReviewed": "Zobraziť skontrolované" + }, + "motion": { + "showMotionOnly": "Zobraziť len pohyb" + }, + "explore": { + "settings": { + "title": "Nastavenia", + "defaultView": { + "title": "Predvolené zobrazenie", + "desc": "Ak nie sú vybraté žiadne filtre, zobrazte súhrn naposledy sledovaných objektov pre každý štítok alebo zobrazte nefiltrovanú mriežku.", + "summary": "Zhrnutie", + "unfilteredGrid": "Nefiltrovaná mriežka" + }, + "gridColumns": { + "title": "Stĺpce mriežky", + "desc": "Vyberte počet stĺpcov v mriežkovom zobrazení." + }, + "searchSource": { + "label": "Vyhľadať zdroj", + "desc": "Vyberte, či chcete vyhľadávať v miniatúrach alebo v popisoch sledovaných objektov.", + "options": { + "thumbnailImage": "Obrázok miniatúry", + "description": "Popis" + } + } + }, + "date": { + "selectDateBy": { + "label": "Vyberte dátum, podľa ktorého chcete filtrovať" + } + } + }, + "logSettings": { + "label": "Úroveň denníka filtra", + "filterBySeverity": "Filtrujte protokoly podľa závažnosti", + "loading": { + "title": "Načítava sa", + "desc": "Keď sa panel protokolov posunie nadol, nové protokoly sa automaticky streamujú hneď po ich pridaní." + }, + "disableLogStreaming": "Zakázať streamovanie denníka", + "allLogs": "Všetky denníky" + }, + "trackedObjectDelete": { + "title": "Potvrďte Odstrániť", + "desc": "Odstránením týchto sledovaných objektov ({{objectLength}}) sa odstráni snímka, všetky uložené vnorenia a všetky súvisiace položky životného cyklu objektu. Zaznamenané zábery týchto sledovaných objektov v zobrazení História NEBUDÚ odstránené.

    Naozaj chcete pokračovať?

    Podržte kláves Shift, aby ste v budúcnosti toto dialógové okno obišli.", + "toast": { + "success": "Sledované objekty boli úspešne odstránené.", + "error": "Nepodarilo sa odstrániť sledované objekty: {{errorMessage}}" + } + }, + "zoneMask": { + "filterBy": "Filtrujte podľa masky zóny" + }, + "recognizedLicensePlates": { + "title": "Rozpoznané evidenčné čísla vozidiel", + "loadFailed": "Nepodarilo sa načítať rozpoznané evidenčné čísla vozidiel.", + "loading": "Načítavajú sa rozpoznané evidenčné čísla…", + "placeholder": "Zadajte text pre vyhľadávanie evidenčných čísel…", + "noLicensePlatesFound": "Neboli nájdené SPZ.", + "selectPlatesFromList": "Vyberte jeden alebo viacero tanierov zo zoznamu.", + "selectAll": "Vybrať všetko", + "clearAll": "Vymazať všetko" } } diff --git a/web/public/locales/sk/objects.json b/web/public/locales/sk/objects.json index 2b3199df78..7f0dca0835 100644 --- a/web/public/locales/sk/objects.json +++ b/web/public/locales/sk/objects.json @@ -31,5 +31,64 @@ "handbag": "Kabelka", "tie": "Kravata", "suitcase": "Kufor", - "frisbee": "Frisbee" + "frisbee": "Frisbee", + "skis": "Lyže", + "snowboard": "Snowboard", + "sports_ball": "Športová lopta", + "kite": "Drak", + "baseball_bat": "Bejzbalová pálka", + "baseball_glove": "Baseballová rukavica", + "skateboard": "Skateboard", + "surfboard": "Surfová doska", + "tennis_racket": "Tenisová raketa", + "bottle": "Fľaša", + "plate": "Doska", + "wine_glass": "Pohár na víno", + "cup": "Pohár", + "fork": "Vidlička", + "knife": "Nôž", + "spoon": "Lyžica", + "bowl": "Misa", + "banana": "Banán", + "apple": "Jablko", + "animal": "Zviera", + "sandwich": "Sendvič", + "orange": "Pomaranč", + "broccoli": "Brokolica", + "bark": "Kôra", + "carrot": "Mrkva", + "hot_dog": "Hot Dog", + "pizza": "Pizza", + "donut": "Donut", + "cake": "Koláč", + "chair": "Stolička", + "couch": "Gauč", + "potted_plant": "Rastlina v kvetináči", + "bed": "Posteľ", + "mirror": "Zrkadlo", + "dining_table": "Jedálenský stôl", + "window": "okno", + "desk": "Stôl", + "toilet": "Toaleta", + "door": "Dvere", + "tv": "TV", + "laptop": "Laptop", + "mouse": "Myška", + "remote": "Diaľkové ovládanie", + "keyboard": "Klávesnica", + "goat": "Koza", + "cell_phone": "Mobilný telefón", + "microwave": "Mikrovlnná rúra", + "oven": "Rúra", + "toaster": "Hriankovač", + "sink": "Umývadlo", + "refrigerator": "Chladnička", + "blender": "Mixér", + "book": "Kniha", + "clock": "Hodiny", + "vase": "Váza", + "toothbrush": "Zubná kefka", + "hair_brush": "Kefa na vlasy", + "vehicle": "Vozidlo", + "squirrel": "Veverička" } diff --git a/web/public/locales/sk/views/configEditor.json b/web/public/locales/sk/views/configEditor.json index 7bfafd0095..c10f789a82 100644 --- a/web/public/locales/sk/views/configEditor.json +++ b/web/public/locales/sk/views/configEditor.json @@ -12,5 +12,7 @@ "error": { "savingError": "Chyba ukladaní konfigurácie" } - } + }, + "safeConfigEditor": "Editor konfigurácie (núdzový režim)", + "safeModeDescription": "Frigate je v núdzovom režime kvôli chybe overenia konfigurácie." } diff --git a/web/public/locales/sk/views/events.json b/web/public/locales/sk/views/events.json index 32d23889d7..7f886645c5 100644 --- a/web/public/locales/sk/views/events.json +++ b/web/public/locales/sk/views/events.json @@ -34,5 +34,7 @@ "selected_one": "{{count}} vybraných", "selected_other": "{{count}} vybraných", "camera": "Kamera", - "detected": "Detekované" + "detected": "Detekované", + "suspiciousActivity": "Podozrivá aktivita", + "threateningActivity": "Ohrozujúca činnosť" } diff --git a/web/public/locales/sk/views/explore.json b/web/public/locales/sk/views/explore.json index 5de31b69fc..ba860b4221 100644 --- a/web/public/locales/sk/views/explore.json +++ b/web/public/locales/sk/views/explore.json @@ -2,7 +2,69 @@ "documentTitle": "Preskúmať - Frigate", "generativeAI": "Generatívna AI", "details": { - "timestamp": "Časová pečiatka" + "timestamp": "Časová pečiatka", + "item": { + "title": "Skontrolujte podrobnosti položky", + "desc": "Skontrolujte podrobnosti položky", + "button": { + "share": "Zdieľajte túto recenziu", + "viewInExplore": "Zobraziť v Preskúmať" + }, + "tips": { + "mismatch_one": "Bol zistený a zahrnutý do tejto položky kontroly nedostupný objekt ({{count}}). Tieto objekty buď neboli kvalifikované ako upozornenie alebo detekcia, alebo už boli vyčistené/odstránené.", + "mismatch_few": "Bolo zistených a zahrnutých do tejto položky kontroly {{count}} nedostupných objektov. Tieto objekty buď neboli kvalifikované ako upozornenie alebo detekcia, alebo už boli vyčistené/odstránené.", + "mismatch_other": "Bolo zistených a zahrnutých do tejto položky kontroly {{count}} nedostupných objektov. Tieto objekty buď neboli kvalifikované ako upozornenie alebo detekcia, alebo už boli vyčistené/odstránené.", + "hasMissingObjects": "Upravte si konfiguráciu, ak chcete, aby Frigate ukladal sledované objekty pre nasledujúce označenia: {{objects}}" + }, + "toast": { + "success": { + "regenerate": "Od poskytovateľa {{provider}} bol vyžiadaný nový popis. V závislosti od rýchlosti vášho poskytovateľa môže jeho obnovenie chvíľu trvať.", + "updatedSublabel": "Podštítok bol úspešne aktualizovaný.", + "updatedLPR": "ŠPZ bola úspešne aktualizovaná.", + "audioTranscription": "Úspešne požiadané o prepis zvuku." + }, + "error": { + "regenerate": "Nepodarilo sa zavolať od {{provider}} pre nový popis: {{errorMessage}}", + "updatedSublabelFailed": "Nepodarilo sa aktualizovať podštítok: {{errorMessage}}", + "updatedLPRFailed": "Nepodarilo sa aktualizovať evidenčné číslo vozidla: {{errorMessage}}", + "audioTranscription": "Nepodarilo sa vyžiadať prepis zvuku: {{errorMessage}}" + } + } + }, + "label": "Označenie", + "editSubLabel": { + "title": "Upraviť vedľajší štítok", + "desc": "Zadajte nový podštítok pre tento {{label}}", + "descNoLabel": "Zadajte nový podštítok pre tento sledovaný objekt" + }, + "editLPR": { + "title": "Upraviť ŠPZ", + "desc": "Zadajte novú hodnotu evidenčného čísla vozidla pre toto {{label}}", + "descNoLabel": "Zadajte novú hodnotu evidenčného čísla vozidla pre tento sledovaný objekt" + }, + "snapshotScore": { + "label": "Snímka skóre" + }, + "topScore": { + "label": "Najlepšie skóre", + "info": "Najvyššie skóre je najvyššie mediánové skóre sledovaného objektu, takže sa môže líšiť od skóre zobrazeného na miniatúre výsledkov vyhľadávania." + }, + "score": { + "label": "Skóre" + }, + "recognizedLicensePlate": "Uznaná SPZ", + "estimatedSpeed": "Odhadovaná rýchlosť", + "objects": "Objekty", + "camera": "Kamera", + "zones": "Zóny", + "button": { + "findSimilar": "Nájsť podobné" + }, + "description": { + "placeholder": "Popis sledovaného objektu", + "aiTips": "Frigate si od vášho poskytovateľa generatívnej umelej inteligencie nevyžiada popis, kým sa neukončí životný cyklus sledovaného objektu." + }, + "expandRegenerationMenu": "Rozbaľte ponuku regenerácie" }, "exploreMore": "Preskumať viac {{label}} objektov", "exploreIsUnavailable": { @@ -48,6 +110,45 @@ "scrollViewTips": "Posúvaním zobrazíte významné momenty životného cyklu tohto objektu.", "autoTrackingTips": "Pozície ohraničujúcich rámčekov budú pre kamery s automatickým sledovaním nepresné.", "count": "{{first}} z {{second}}", - "trackedPoint": "Sledovaný bod" + "trackedPoint": "Sledovaný bod", + "lifecycleItemDesc": { + "visible": "Zistený {{label}}", + "entered_zone": "{{label}} vstúpil do {{zones}}", + "active": "{{label}} sa stal aktívnym", + "stationary": "{{label}} sa zastavil", + "attribute": { + "faceOrLicense_plate": "Pre {{label}} bol zistený {{attribute}}", + "other": "{{label}} rozpoznané ako {{attribute}}" + }, + "gone": "{{label}} zostalo", + "heard": "{{label}} počul", + "external": "Zistený {{label}}", + "header": { + "zones": "Zóny", + "ratio": "Pomer", + "area": "Oblasť" + } + }, + "annotationSettings": { + "title": "Nastavenia anotácií", + "showAllZones": { + "title": "Zobraziť všetky zóny", + "desc": "Vždy zobrazovať zóny na rámoch, do ktorých objekty vstúpili." + }, + "offset": { + "label": "Odsadenie anotácie", + "desc": "Tieto údaje pochádzajú z detekčného kanála vašej kamery, ale prekrývajú sa s obrázkami zo záznamového kanála. Je nepravdepodobné, že tieto dva streamy sú dokonale synchronizované. V dôsledku toho sa ohraničujúci rámček a zábery nebudú dokonale zarovnané. Na úpravu tohto posunu je však možné použiť pole annotation_offset.", + "documentation": "Prečítajte si dokumentáciu ", + "millisecondsToOffset": "Milisekundy na posunutie detekcie anotácií. Predvolené: 0", + "tips": "TIP: Predstavte si klip udalosti, v ktorom osoba kráča zľava doprava. Ak je ohraničujúci rámček časovej osi udalosti stále naľavo od osoby, hodnota by sa mala znížiť. Podobne, ak osoba kráča zľava doprava a ohraničujúci rámček je stále pred ňou, hodnota by sa mala zvýšiť.", + "toast": { + "success": "Odsadenie anotácie pre {{camera}} bolo uložené do konfiguračného súboru. Reštartujte Frigate, aby sa zmeny prejavili." + } + } + }, + "carousel": { + "previous": "Predchádzajúca snímka", + "next": "Ďalšia snímka" + } } } diff --git a/web/public/locales/sk/views/live.json b/web/public/locales/sk/views/live.json index ebb12d4cd3..e892ef1220 100644 --- a/web/public/locales/sk/views/live.json +++ b/web/public/locales/sk/views/live.json @@ -43,10 +43,18 @@ "label": "Kliknite do rámčeka pre vycentrovanie PTZ kamery" } }, - "presets": "Predvoľby PTZ kamery" + "presets": "Predvoľby PTZ kamery", + "focus": { + "in": { + "label": "Zaostrenie PTZ kamery v" + }, + "out": { + "label": "Výstup zaostrenia PTZ kamery" + } + } }, "camera": { - "enable": "Povoliť fotoaparát", + "enable": "Povoliť kameru", "disable": "Zakázať kameru" }, "muteCameras": { @@ -72,5 +80,90 @@ "autotracking": { "enable": "Povoliť automatické sledovanie", "disable": "Zakázať automatické sledovanie" + }, + "transcription": { + "enable": "Povoliť živý prepis zvuku", + "disable": "Zakázať živý prepis zvuku" + }, + "streamStats": { + "enable": "Zobraziť štatistiky streamu", + "disable": "Skryť štatistiky streamu" + }, + "manualRecording": { + "title": "Nahrávanie na požiadanie", + "tips": "Spustiť manuálnu udalosť na základe nastavení uchovávania záznamu tejto kamery.", + "playInBackground": { + "label": "Hrať na pozadí", + "desc": "Povoľte túto možnosť, ak chcete pokračovať v streamovaní, aj keď je prehrávač skrytý." + }, + "showStats": { + "label": "Zobraziť štatistiky", + "desc": "Povoľte túto možnosť, ak chcete zobraziť štatistiky streamu ako prekrytie na obraze z kamery." + }, + "debugView": "Zobrazenie ladenia", + "start": "Spustiť nahrávanie na požiadanie", + "started": "Spustené manuálne nahrávanie na požiadanie.", + "failedToStart": "Nepodarilo sa spustiť manuálne nahrávanie na požiadanie.", + "recordDisabledTips": "Keďže nahrávanie je v konfigurácii tejto kamery zakázané alebo obmedzené, uloží sa iba snímka.", + "end": "Ukončiť nahrávanie na požiadanie", + "ended": "Manuálne nahrávanie na požiadanie bolo ukončené.", + "failedToEnd": "Nepodarilo sa ukončiť manuálne nahrávanie na požiadanie." + }, + "streamingSettings": "Nastavenia streamovania", + "notifications": "Notifikacie", + "audio": "Zvuk", + "suspend": { + "forTime": "Pozastaviť na: " + }, + "stream": { + "title": "Stream", + "audio": { + "tips": { + "title": "Zvuk musí byť vyvedený z vašej kamery a nakonfigurovaný v go2rtc pre tento stream." + }, + "available": "Pre tento stream je k dispozícii zvuk", + "unavailable": "Zvuk nie je pre tento stream k dispozícii" + }, + "twoWayTalk": { + "tips": "Vaše zariadenie musí túto funkciu podporovať a WebRTC musí byť nakonfigurované na obojsmernú komunikáciu.", + "available": "Pre tento stream je k dispozícii obojsmerná komunikácia", + "unavailable": "Obojsmerná komunikácia nie je pre tento stream k dispozícii" + }, + "lowBandwidth": { + "tips": "Živý náhľad je v režime nízkej šírky pásma z dôvodu chýb načítavania do vyrovnávacej pamäte alebo streamu.", + "resetStream": "Obnoviť stream" + }, + "playInBackground": { + "label": "Hrať na pozadí", + "tips": "Povoľte túto možnosť, ak chcete pokračovať v streamovaní, aj keď je prehrávač skrytý." + } + }, + "cameraSettings": { + "title": "Nastavenia {{camera}}", + "cameraEnabled": "Kamera povolená", + "objectDetection": "Detekcia objektov", + "recording": "Nahrávanie", + "snapshots": "Snímky", + "audioDetection": "Detekcia zvuku", + "transcription": "Zvukový prepis", + "autotracking": "Automatické sledovanie" + }, + "history": { + "label": "Zobraziť historické zábery" + }, + "effectiveRetainMode": { + "modes": { + "all": "Všetko", + "motion": "Pohyb", + "active_objects": "Aktívne objekty" + }, + "notAllTips": "Vaša konfigurácia uchovávania nahrávok {{source}} je nastavená na režim : {{effectiveRetainMode}}, takže táto nahrávka na požiadanie uchová iba segmenty s nastavením {{effectiveRetainModeName}}." + }, + "editLayout": { + "label": "Upraviť rozloženie", + "group": { + "label": "Upraviť skupinu kamier" + }, + "exitEdit": "Ukončiť úpravy" } } diff --git a/web/public/locales/sk/views/search.json b/web/public/locales/sk/views/search.json index cc567af26c..a368ca1233 100644 --- a/web/public/locales/sk/views/search.json +++ b/web/public/locales/sk/views/search.json @@ -41,6 +41,32 @@ "minSpeedMustBeLessOrEqualMaxSpeed": "Hodnota „min_speed“ musí byť menšia alebo rovná hodnote „max_speed“.", "maxSpeedMustBeGreaterOrEqualMinSpeed": "Hodnota „max_speed“ musí byť väčšia alebo rovná hodnote „min_speed“." } + }, + "tips": { + "title": "Ako používať textové filtre", + "desc": { + "text": "Filtre vám pomôžu zúžiť výsledky vyhľadávania. Tu je postup, ako ich použiť vo vstupnom poli:", + "step1": "Zadajte názov kľúča filtra, za ktorým nasleduje dvojbodka (napr. „kamery:“).", + "step2": "Vyberte hodnotu z návrhov alebo zadajte vlastnú.", + "step3": "Použite viacero filtrov tak, že ich pridáte jeden po druhom s medzerou medzi nimi.", + "step4": "Filtre dátumu (pred: a po:) používajú formát {{DateFormat}}.", + "step5": "Filter časového rozsahu používa formát {{exampleTime}}.", + "step6": "Filtre odstránite kliknutím na „x“ vedľa nich.", + "exampleLabel": "Príklad:" + } + }, + "header": { + "currentFilterType": "Hodnoty filtra", + "noFilters": "Filtre", + "activeFilters": "Aktívne filtre" } + }, + "similaritySearch": { + "title": "Vyhľadávanie podobností", + "active": "Vyhľadávanie podobnosti je aktívne", + "clear": "Jasné vyhľadávanie podobnosti" + }, + "placeholder": { + "search": "Hľadať…" } } diff --git a/web/public/locales/sk/views/settings.json b/web/public/locales/sk/views/settings.json index 27013c1974..0d0a1fda72 100644 --- a/web/public/locales/sk/views/settings.json +++ b/web/public/locales/sk/views/settings.json @@ -2,7 +2,7 @@ "documentTitle": { "default": "Nastavenia - Frigate", "authentication": "Nastavenie autentifikácie- Frigate", - "camera": "Nastavenia fotoaparátu – Frigate", + "camera": "Nastavenia Kamier– Frigate", "enrichments": "Nastavenia obohatenia – Frigate", "masksAndZones": "Editor masky a zón - Frigate", "motionTuner": "Ladič detekcie pohybu - Frigate", @@ -20,7 +20,8 @@ "debug": "Ladenie", "users": "Uživatelia", "notifications": "Notifikacie", - "frigateplus": "Frigate+" + "frigateplus": "Frigate+", + "triggers": "Spúšťače" }, "dialog": { "unsavedChanges": { @@ -49,6 +50,108 @@ "title": "Uložené rozloženia", "desc": "Rozloženie kamier v skupine kamier je možné presúvať/zmeniť jeho veľkosť. Pozície sú uložené v lokálnom úložisku vášho prehliadača.", "clearAll": "Vymazať všetky rozloženia" + }, + "cameraGroupStreaming": { + "title": "Nastavenia streamovania skupiny kamier", + "desc": "Nastavenia streamovania pre každú skupinu kamier sú uložené v lokálnom úložisku vášho prehliadača.", + "clearAll": "Vymazať všetky nastavenia streamovania" + }, + "recordingsViewer": { + "title": "Prehliadač nahrávok", + "defaultPlaybackRate": { + "label": "Predvolená rýchlosť prehrávania", + "desc": "Predvolená rýchlosť prehrávania nahrávok." + } + }, + "calendar": { + "title": "Kalendár", + "firstWeekday": { + "label": "Prvý pracovný deň", + "desc": "Deň, kedy začínajú týždne v kalendári kontroly.", + "sunday": "Nedeľa", + "monday": "Pondelok" + } + }, + "toast": { + "success": { + "clearStoredLayout": "Uložené rozloženie pre {{cameraName}} bolo vymazané", + "clearStreamingSettings": "Nastavenia streamovania pre všetky skupiny kamier boli vymazané." + }, + "error": { + "clearStoredLayoutFailed": "Nepodarilo sa vymazať uložené rozloženie: {{errorMessage}}", + "clearStreamingSettingsFailed": "Nepodarilo sa vymazať nastavenia streamovania: {{errorMessage}}" + } + } + }, + "enrichments": { + "title": "Nastavenia obohatení", + "unsavedChanges": "Zmeny nastavení neuložených obohatení", + "birdClassification": { + "title": "Klasifikácia vtákov", + "desc": "Klasifikácia vtákov identifikuje známe vtáky pomocou kvantizovaného modelu Tensorflow. Keď je známy vták rozpoznaný, jeho bežný názov sa pridá ako podoznačenie (sub_label). Tieto informácie sú zahrnuté v používateľskom rozhraní, filtroch, ako aj v oznámeniach." + }, + "semanticSearch": { + "title": "Sémantické vyhľadávanie", + "desc": "Sémantické vyhľadávanie vo Frigate vám umožňuje nájsť sledované objekty v rámci vašich recenzovaných položiek pomocou samotného obrázka, textového popisu definovaného používateľom alebo automaticky vygenerovaného popisu.", + "reindexNow": { + "label": "Preindexovať teraz", + "desc": "Reindexovanie obnoví vložené súbory pre všetky sledované objekty. Tento proces beží na pozadí a môže maximálne zaťažiť váš procesor a trvať pomerne dlho v závislosti od počtu sledovaných objektov, ktoré máte.", + "confirmTitle": "Potvrďte opätovné indexovanie", + "confirmDesc": "Naozaj chcete preindexovať všetky sledované vložené objekty? Tento proces bude bežať na pozadí, ale môže maximálne zaťažiť váš procesor a trvať pomerne dlho. Priebeh si môžete pozrieť na stránke Preskúmať.", + "confirmButton": "Preindexovať", + "success": "Reindexovanie sa úspešne spustilo.", + "alreadyInProgress": "Reindexovanie už prebieha.", + "error": "Nepodarilo sa spustiť reindexáciu: {{errorMessage}}" + }, + "modelSize": { + "label": "Veľkosť modelu", + "desc": "Veľkosť modelu použitého pre vkladanie sémantického vyhľadávania.", + "small": { + "title": "malý", + "desc": "Použitie funkcie small využíva kvantizovanú verziu modelu, ktorá spotrebuje menej pamäte RAM a beží rýchlejšie na CPU s veľmi zanedbateľným rozdielom v kvalite vkladania." + }, + "large": { + "title": "veľký", + "desc": "Použitie parametra large využíva celý model Jina a v prípade potreby sa automaticky spustí na GPU." + } + } + }, + "faceRecognition": { + "title": "Rozpoznávanie tváre", + "desc": "Rozpoznávanie tváre umožňuje priradiť ľuďom mená a po rozpoznaní ich tváre Frigate priradí meno osoby ako podštítok. Tieto informácie sú zahrnuté v používateľskom rozhraní, filtroch, ako aj v upozorneniach.", + "modelSize": { + "label": "Veľkosť modelu", + "desc": "Veľkosť modelu použitého na rozpoznávanie tváre.", + "small": { + "title": "malý", + "desc": "Použitie funkcie small využíva model vkladania tvárí FaceNet, ktorý efektívne beží na väčšine procesorov." + }, + "large": { + "title": "veľký", + "desc": "Použitie funkcie large využíva model vkladania tvárí ArcFace a v prípade potreby sa automaticky spustí na grafickom procesore." + } + } + }, + "licensePlateRecognition": { + "title": "Rozpoznávanie ŠPZ", + "desc": "Frigate dokáže rozpoznávať evidenčné čísla vozidiel a automaticky pridávať detekované znaky do poľa recognized_license_plate alebo známy názov ako podradený štítok k objektom typu car. Bežným prípadom použitia môže byť čítanie evidenčných čísel áut vchádzajúcich na príjazdovú cestu alebo áut prechádzajúcich po ulici." + }, + "restart_required": "Vyžaduje sa reštart (zmenené nastavenia obohatenia)", + "toast": { + "success": "Nastavenia obohatenia boli uložené. Reštartujte Frigate, aby sa zmeny prejavili.", + "error": "Nepodarilo sa uložiť zmeny konfigurácie: {{errorMessage}}" + } + }, + "camera": { + "title": "Nastavenie kamier", + "streams": { + "title": "Streamy", + "desc": "Dočasne deaktivujte kameru, kým sa Frigate nereštartuje. Deaktivácia kamery úplne zastaví spracovanie streamov z tejto kamery aplikáciou Frigate. Detekcia, nahrávanie a ladenie nebudú k dispozícii.
    Poznámka: Toto nezakáže restreamy go2rtc." + }, + "review": { + "title": "Recenzia", + "desc": "Dočasne povoliť/zakázať upozornenia a detekcie pre túto kameru, kým sa Frigate nereštartuje. Po zakázaní sa nebudú generovať žiadne nové položky kontroly. ", + "alerts": "Upozornenia " } } } diff --git a/web/public/locales/sk/views/system.json b/web/public/locales/sk/views/system.json index ea3a3927e9..2ff91c9ee3 100644 --- a/web/public/locales/sk/views/system.json +++ b/web/public/locales/sk/views/system.json @@ -42,7 +42,8 @@ "inferenceSpeed": "Detekčná rýchlosť", "temperature": "Detekčná teplota", "cpuUsage": "Detektor využitia CPU", - "memoryUsage": "Detektor využitia pamäte" + "memoryUsage": "Detektor využitia pamäte", + "cpuUsageInformation": "CPU použitý na prípravu vstupných a výstupných údajov do/z detekčných modelov. Táto hodnota nemeria využitie inferencie, a to ani v prípade použitia GPU alebo akcelerátora." }, "hardwareInfo": { "title": "Informácie o hardvéri", @@ -52,9 +53,92 @@ "gpuDecoder": "GPU dekodér", "gpuInfo": { "vainfoOutput": { - "title": "Výstup Vainfo" + "title": "Výstup Vainfo", + "returnCode": "Návratový kód: {{code}}", + "processOutput": "Výstup procesu:", + "processError": "Chyba procesu:" + }, + "nvidiaSMIOutput": { + "title": "Výstup Nvidia SMI", + "name": "Meno: {{name}}", + "driver": "Vodič: {{driver}}", + "cudaComputerCapability": "Výpočtové možnosti CUDA: {{cuda_compute}}", + "vbios": "Informácie o VBiose: {{vbios}}" + }, + "closeInfo": { + "label": "Zatvorte informácie o GPU" + }, + "copyInfo": { + "label": "Kopírovať informácie o GPU" + }, + "toast": { + "success": "Informácie o grafickej karte boli skopírované do schránky" } + }, + "npuUsage": "Použitie NPU", + "npuMemory": "Pamäť NPU" + }, + "otherProcesses": { + "title": "Iné procesy", + "processCpuUsage": "Proces využitia CPU", + "processMemoryUsage": "Procesné využitie pamäte" + } + }, + "storage": { + "title": "Skladovanie", + "overview": "Prehľad", + "recordings": { + "title": "Nahrávky", + "tips": "Táto hodnota predstavuje celkové úložisko, ktoré používajú nahrávky v databáze Frigate. Frigate nesleduje využitie úložiska pre všetky súbory na vašom disku.", + "earliestRecording": "Najstaršia dostupná nahrávka:" + }, + "shm": { + "title": "Alokácia SHM (zdieľanej pamäte)", + "warning": "Aktuálna veľkosť SHM {{total}}MB je príliš malá. Zvýšte ju aspoň na {{min_shm}}MB." + }, + "cameraStorage": { + "title": "Úložisko kamery", + "camera": "Kamera", + "unusedStorageInformation": "Nepoužité informácie o úložisku", + "storageUsed": "Skladovanie", + "percentageOfTotalUsed": "Percento z celkového počtu", + "bandwidth": "Šírka pásma", + "unused": { + "title": "Nepoužité", + "tips": "Táto hodnota nemusí presne zodpovedať voľnému miestu dostupnému pre Frigate, ak máte na disku uložené aj iné súbory okrem nahrávok Frigate. Frigate nesleduje využitie úložiska mimo svojich nahrávok." } } + }, + "cameras": { + "title": "Kamery", + "overview": "Prehľad", + "info": { + "aspectRatio": "pomer strán", + "cameraProbeInfo": "{{camera}} Informácie o sonde kamery", + "streamDataFromFFPROBE": "Údaje zo streamu sa získavajú pomocou príkazu ffprobe.", + "fetching": "Načítavajú sa údaje z kamery", + "stream": "Stream {{idx}}", + "video": "Video:", + "codec": "Kodek:", + "resolution": "Rozlíšenie:", + "fps": "FPS:", + "unknown": "Neznámy", + "audio": "Zvuk:", + "error": "Chyba: {{error}}", + "tips": { + "title": "Informácie o kamerovej sonde" + } + }, + "framesAndDetections": "Rámy / Detekcie", + "label": { + "camera": "kamera", + "detect": "odhaliť", + "skipped": "preskočené", + "ffmpeg": "FFmpeg", + "capture": "zachytiť", + "cameraFfmpeg": "{{camName}} FFmpeg", + "cameraCapture": "zachytiť{{camName}}", + "cameraDetect": "Detekcia {{camName}}" + } } } diff --git a/web/public/locales/sl/audio.json b/web/public/locales/sl/audio.json index 31562e8c99..bf5482cabf 100644 --- a/web/public/locales/sl/audio.json +++ b/web/public/locales/sl/audio.json @@ -106,5 +106,39 @@ "piano": "Klavir", "electric_piano": "Digitalni klavir", "organ": "Orgle", - "electronic_organ": "Digitalne orgle" + "electronic_organ": "Digitalne orgle", + "chant": "Spev", + "mantra": "Mantra", + "child_singing": "Otroško petje", + "synthetic_singing": "Sintetično petje", + "humming": "Brenčanje", + "groan": "Stok", + "grunt": "Godrnjanje", + "wheeze": "Zadihan izdih", + "gasp": "Glasen Vzdih", + "pant": "Sopihanje", + "snort": "Smrkanje", + "throat_clearing": "Odkašljevanje", + "sneeze": "Kihanje", + "sniff": "Vohljaj", + "chewing": "Žvečenje", + "biting": "Grizenje", + "gargling": "Grgranje", + "stomach_rumble": "Grmotanje v Želodcu", + "heart_murmur": "Šum na Srcu", + "chatter": "Klepetanje", + "yip": "Jip", + "growling": "Rjovenje", + "whimper_dog": "Pasje Cviljenje", + "oink": "Oink", + "gobble": "Zvok Purana", + "wild_animals": "Divje Živali", + "roaring_cats": "Rjoveče Mačke", + "roar": "Rjovenje Živali", + "squawk": "Krik", + "patter": "Klepetanje", + "croak": "Kvakanje", + "rattle": "Ropotanje", + "whale_vocalization": "Kitova Vokalizacija", + "plucked_string_instrument": "Trgani Godalni Instrument" } diff --git a/web/public/locales/sl/common.json b/web/public/locales/sl/common.json index ff21c10cec..4468781f53 100644 --- a/web/public/locales/sl/common.json +++ b/web/public/locales/sl/common.json @@ -51,7 +51,40 @@ "h": "{{time}}h", "m": "{{time}}m", "s": "{{time}}s", - "yr": "le" + "yr": "le", + "formattedTimestamp": { + "12hour": "d MMM, h:mm:ss aaa", + "24hour": "d MMM, HH:mm:ss" + }, + "formattedTimestamp2": { + "12hour": "dd/MM h:mm:ssa", + "24hour": "d MMM HH:mm:ss" + }, + "formattedTimestampHourMinute": { + "12hour": "h:mm aaa", + "24hour": "HH:mm" + }, + "formattedTimestampHourMinuteSecond": { + "12hour": "h:mm:ss aaa", + "24hour": "HH:mm:ss" + }, + "formattedTimestampMonthDayHourMinute": { + "12hour": "d MMM, h:mm aaa", + "24hour": "d MMM, HH:mm" + }, + "formattedTimestampMonthDayYear": { + "12hour": "d MMM, yyyy", + "24hour": "d MMM, yyyy" + }, + "formattedTimestampMonthDayYearHourMinute": { + "12hour": "d MMM yyyy, h:mm aaa", + "24hour": "d MMM yyyy, HH:mm" + }, + "formattedTimestampMonthDay": "d MMM", + "formattedTimestampFilename": { + "12hour": "dd-MM-yy-h-mm-ss-a", + "24hour": "dd-MM-yy-HH-mm-ss" + } }, "menu": { "live": { @@ -67,9 +100,86 @@ }, "explore": "Brskanje", "theme": { - "nord": "Nord" + "nord": "Nord", + "label": "Teme", + "blue": "Modra", + "green": "Zelena", + "red": "Rdeča", + "highcontrast": "Visok Kontrast", + "default": "Privzeto" }, - "review": "Pregled" + "review": "Pregled", + "system": "Sistem", + "systemMetrics": "Sistemske metrike", + "configuration": "Konfiguracija", + "systemLogs": "Sistemski dnevniki", + "settings": "Nastavitve", + "configurationEditor": "Urejevalnik Konfiguracije", + "languages": "Jeziki", + "language": { + "en": "English (angleščina)", + "es": "Español (španščina)", + "zhCN": "简体中文 (poenostavljena kitajščina)", + "hi": "हिन्दी (hindijščina)", + "fr": "Français (francoščina)", + "ar": "العربية (arabščina)", + "pt": "Português (portugalščina)", + "ru": "Русский (ruščina)", + "de": "Deutsch (nemščina)", + "ja": "日本語 (japonščina)", + "tr": "Türkçe (turščina)", + "it": "Italiano (italijanščina)", + "nl": "Nederlands (nizozemščina)", + "sv": "Svenska (švedščina)", + "cs": "Čeština (češčina)", + "nb": "Norsk Bokmål (norveščina, bokmal)", + "ko": "한국어 (korejščina)", + "vi": "Tiếng Việt (vietnamščina)", + "fa": "فارسی (perzijščina)", + "pl": "Polski (poljščina)", + "uk": "Українська (ukrajinščina)", + "he": "עברית (hebrejščina)", + "el": "Ελληνικά (grščina)", + "ro": "Română (romunščina)", + "hu": "Magyar (madžarščina)", + "fi": "Suomi (finščina)", + "da": "Dansk (danščina)", + "sk": "Slovenčina (slovaščina)", + "yue": "粵語 (kantonščina)", + "th": "ไทย (tajščina)", + "sr": "Српски (srbščina)", + "sl": "Slovenščina (Slovenščina )", + "bg": "Български (bulgarščina)", + "withSystem": { + "label": "Uporabi sistemske nastavitve za jezik" + } + }, + "appearance": "Izgled", + "darkMode": { + "label": "Temni Način", + "light": "Svetlo", + "dark": "Temno", + "withSystem": { + "label": "Uporabi sistemske nastavitve za svetel ali temen način" + } + }, + "withSystem": "Sistem", + "help": "Pomoč", + "documentation": { + "title": "Dokumentacija", + "label": "Frigate dokumentacija" + }, + "restart": "Znova Zaženi Frigate", + "export": "Izvoz", + "faceLibrary": "Zbirka Obrazov", + "user": { + "title": "Uporabnik", + "account": "Račun", + "current": "Trenutni Uporabnik: {{user}}", + "anonymous": "anonimen", + "logout": "Odjava", + "setPassword": "Nastavi Geslo" + } }, "button": { "apply": "Uporabi", @@ -80,7 +190,7 @@ "back": "Nazaj", "pictureInPicture": "Slika v Sliki", "history": "Zgodovina", - "disabled": "Izklopljeno", + "disabled": "Onemogočeno", "copy": "Kopiraj", "exitFullscreen": "Izhod iz Celozaslonskega načina", "enabled": "Omogočen", @@ -88,7 +198,25 @@ "save": "Shrani", "saving": "Shranjevanje …", "cancel": "Prekliči", - "fullscreen": "Celozaslonski način" + "fullscreen": "Celozaslonski način", + "twoWayTalk": "Dvosmerni Pogovor", + "cameraAudio": "Zvok Kamere", + "on": "Vključen", + "off": "Izključen", + "edit": "Uredi", + "copyCoordinates": "Kopiraj koordinate", + "delete": "Izbriši", + "yes": "Da", + "no": "Ne", + "download": "Prenesi", + "info": "Info", + "suspended": "Začasno ustavljeno", + "unsuspended": "Obnovi", + "play": "Predvajaj", + "unselect": "Odznači", + "export": "Izvoz", + "deleteNow": "Izbriši Zdaj", + "next": "Naprej" }, "unit": { "speed": { @@ -105,7 +233,42 @@ }, "pagination": { "next": { - "label": "Pojdi na naslednjo stran" + "label": "Pojdi na naslednjo stran", + "title": "Naprej" + }, + "label": "paginacija", + "previous": { + "title": "Prejšnji", + "label": "Pojdi na prejšnjo stran" + }, + "more": "Več strani" + }, + "selectItem": "Izberi {{item}}", + "toast": { + "copyUrlToClipboard": "Povezava kopirana v odložišče.", + "save": { + "title": "Shrani", + "error": { + "title": "Napaka pri shranjevanju sprememb: {{errorMessage}}", + "noMessage": "Napaka pri shranjevanju sprememb konfiguracije" + } } - } + }, + "role": { + "title": "Vloga", + "admin": "Administrator", + "viewer": "Gledalec", + "desc": "Administratorji imajo poln dostop do vseh funkcij Frigate uporabniškega vmesnika. Gledalci so omejeni na gledanje kamer, zgodovine posnetkov in pregledovanje dogodkov." + }, + "accessDenied": { + "documentTitle": "Dostop zavrnjen - Frigate", + "title": "Dostop Zavrnjen", + "desc": "Nimate pravic za ogled te strani." + }, + "notFound": { + "documentTitle": "Ni Najdeno - Frigate", + "title": "404", + "desc": "Stran ni najdena" + }, + "readTheDocumentation": "Preberite dokumentacijo" } diff --git a/web/public/locales/sl/components/camera.json b/web/public/locales/sl/components/camera.json index 9ee8f4046e..10414fed1d 100644 --- a/web/public/locales/sl/components/camera.json +++ b/web/public/locales/sl/components/camera.json @@ -50,7 +50,8 @@ }, "placeholder": "Izberite tok", "stream": "Tok" - } + }, + "birdseye": "Ptičji pogled" }, "name": { "label": "Ime", diff --git a/web/public/locales/sl/components/dialog.json b/web/public/locales/sl/components/dialog.json index e63f7c34b7..46d1dfad8c 100644 --- a/web/public/locales/sl/components/dialog.json +++ b/web/public/locales/sl/components/dialog.json @@ -12,11 +12,18 @@ "plus": { "review": { "question": { - "ask_full": "Ali je ta objekt {{untranslatedLabel}} ({{translatedLabel}})?" + "ask_full": "Ali je ta objekt {{untranslatedLabel}} ({{translatedLabel}})?", + "label": "Potrdi to oznako za Frigate Plus", + "ask_a": "Ali je ta objekt {{label}}?", + "ask_an": "Ali je ta objekt {{label}}?" }, "state": { "submitted": "Oddano" } + }, + "submitToPlus": { + "label": "Pošlji v Frigate+", + "desc": "Predmeti na lokacijah, ki se jim želite izogniti, niso lažni alarmi. Če jih označite kot lažne alarme, boste zmedli model." } }, "video": { @@ -28,7 +35,88 @@ "lastHour_one": "Zadnja ura", "lastHour_two": "Zadnji {{count}} uri", "lastHour_few": "Zadnje {{count}} ure", - "lastHour_other": "Zadnjih {{count}} ur" + "lastHour_other": "Zadnjih {{count}} ur", + "fromTimeline": "Izberi s Časovnice", + "custom": "Po meri", + "start": { + "title": "Začetni čas", + "label": "Izberi Začetni Čas" + }, + "end": { + "title": "Končni Čas", + "label": "Izberi Končni Čas" + } + }, + "name": { + "placeholder": "Poimenujte Izvoz" + }, + "select": "Izberi", + "export": "Izvoz", + "selectOrExport": "Izberi ali Izvozi", + "toast": { + "success": "Izvoz se je uspešno začel. Datoteko si oglejte v mapi /exports.", + "error": { + "failed": "Npaka pri začetku izvoza: {{error}}", + "endTimeMustAfterStartTime": "Končni čas mora biti po začetnem čase", + "noVaildTimeSelected": "Ni izbranega veljavnega časovnega obdobja" + } + }, + "fromTimeline": { + "saveExport": "Shrani Izvoz", + "previewExport": "Predogled Izvoza" } + }, + "streaming": { + "label": "Pretakanje", + "restreaming": { + "disabled": "Ponovno pretakanje za to kamero ni omogočeno.", + "desc": { + "title": "Za dodatne možnosti ogleda v živo in zvoka za to kamero nastavite go2rtc.", + "readTheDocumentation": "Preberi dokumentacijo" + } + }, + "showStats": { + "label": "Prikaži statistiko pretoka", + "desc": "Omogočite to možnost, če želite prikazati statistiko pretoka videa kamere." + }, + "debugView": "Pogled za Odpravljanje Napak" + }, + "search": { + "saveSearch": { + "label": "Varno Iskanje", + "desc": "Vnesite ime za to shranjeno iskanje.", + "placeholder": "Vnesite ime za iskanje", + "overwrite": "{{searchName}} že obstaja. Shranjevanje bo prepisalo obstoječo vrednost.", + "success": "Iskanje ({{searchName}}) je bilo shranjeno.", + "button": { + "save": { + "label": "Shrani to iskanje" + } + } + } + }, + "recording": { + "confirmDelete": { + "title": "Potrdi Brisanje", + "desc": { + "selected": "Ali ste prepričani, da želite izbrisati vse posnete videoposnetke, povezane s tem elementom pregleda?

    Držite tipko Shift, da se v prihodnje izognete temu pogovornemu oknu." + }, + "toast": { + "success": "Videoposnetek, povezan z izbranimi elementi pregleda, je bil uspešno izbrisan.", + "error": "Brisanje ni uspelo: {{error}}" + } + }, + "button": { + "export": "Izvoz", + "markAsReviewed": "Označi kot pregledano", + "deleteNow": "Izbriši Zdaj" + } + }, + "imagePicker": { + "selectImage": "Izberite sličico sledenega predmeta", + "search": { + "placeholder": "Iskanje po oznaki ali podoznaki..." + }, + "noImages": "Za to kamero ni bilo najdenih sličic" } } diff --git a/web/public/locales/sl/components/filter.json b/web/public/locales/sl/components/filter.json index b202e1554e..1f4962b002 100644 --- a/web/public/locales/sl/components/filter.json +++ b/web/public/locales/sl/components/filter.json @@ -20,7 +20,28 @@ "explore": { "settings": { "defaultView": { - "summary": "Povzetek" + "summary": "Povzetek", + "title": "Privzeti Pogled", + "desc": "Če filtri niso izbrani, prikaži povzetek najnovejših sledenih objektov na oznako ali prikaži nefiltrirano mrežo.", + "unfilteredGrid": "Nefiltrirana Mreža" + }, + "title": "Nastavitve", + "gridColumns": { + "title": "Mrežni Stolpci", + "desc": "Izberite število stolpcev v pogledu mreže." + }, + "searchSource": { + "label": "Iskanje Vira", + "desc": "Izberite, ali želite iskati po sličicah ali opisih sledenih objektov.", + "options": { + "thumbnailImage": "Sličica", + "description": "Opis" + } + } + }, + "date": { + "selectDateBy": { + "label": "Izberite datum za filtriranje" } } }, @@ -30,7 +51,13 @@ }, "sort": { "relevance": "Ustreznost", - "dateAsc": "Datum (naraščajoče)" + "dateAsc": "Datum (naraščajoče)", + "label": "Sortiraj", + "dateDesc": "Datum (Padajoče)", + "scoreAsc": "Ocena Predmeta (Naraščajoče)", + "scoreDesc": "Ocena predmeta (Padajoče)", + "speedAsc": "Ocenjena Hitrost (Naraščajoče)", + "speedDesc": "Ocenjena Hitrost (Padajoče)" }, "zones": { "label": "Cone", @@ -45,7 +72,13 @@ }, "logSettings": { "disableLogStreaming": "Izklopite zapisovanje dnevnika", - "allLogs": "Vsi dnevniki" + "allLogs": "Vsi dnevniki", + "label": "Level Filtra Dnevnika", + "filterBySeverity": "Filtriraj dnevnike po resnosti", + "loading": { + "title": "Nalaganje", + "desc": "Ko se podokno dnevnika pomakne čisto na dno, se novi dnevniki samodejno prikažejo, ko so dodani." + } }, "trackedObjectDelete": { "title": "Potrdite brisanje", @@ -57,5 +90,45 @@ }, "zoneMask": { "filterBy": "Filtrirajte po maski območja" + }, + "classes": { + "label": "Razredi", + "all": { + "title": "Vsi Razredi" + }, + "count_one": "{{count}} Razred", + "count_other": "{{count}} Razredov" + }, + "score": "Ocena", + "estimatedSpeed": "Ocenjena Hitrost ({{unit}})", + "features": { + "label": "Lastnosti", + "hasSnapshot": "Ima sliko", + "hasVideoClip": "Ima posnetek", + "submittedToFrigatePlus": { + "label": "Poslano na Frigate+", + "tips": "Najprej morate filtrirati po sledenih objektih, ki imajo sliko.

    Slednih objektov brez slike ni mogoče poslati v Frigate+." + } + }, + "cameras": { + "label": "Filtri Kamere", + "all": { + "title": "Vse Kamere", + "short": "Kamere" + } + }, + "review": { + "showReviewed": "Prikaži Pregledano" + }, + "motion": { + "showMotionOnly": "Prikaži Samo Gibanje" + }, + "recognizedLicensePlates": { + "title": "Prepoznane Registrske Tablice", + "loadFailed": "Prepoznanih registrskih tablic ni bilo mogoče naložiti.", + "loading": "Nalaganje prepoznanih registrskih tablic…", + "placeholder": "Iskanje registrskih tablic…", + "noLicensePlatesFound": "Nobena registrska tablica ni bila najdena.", + "selectPlatesFromList": "Na seznamu izberite eno ali več registrskih tablic." } } diff --git a/web/public/locales/sl/views/configEditor.json b/web/public/locales/sl/views/configEditor.json index b8f76525dd..5c69cc1b41 100644 --- a/web/public/locales/sl/views/configEditor.json +++ b/web/public/locales/sl/views/configEditor.json @@ -12,5 +12,7 @@ "savingError": "Napaka pri shranjevanju konfiguracije" } }, - "confirm": "Izhod brez shranjevanja?" + "confirm": "Izhod brez shranjevanja?", + "safeConfigEditor": "Urejevalnik konfiguracij (Varni Način)", + "safeModeDescription": "Frigate je v varnem načinu zaradi napake pri preverjanju konfiguracije." } diff --git a/web/public/locales/sl/views/explore.json b/web/public/locales/sl/views/explore.json index 97e7ca664f..70fee301ed 100644 --- a/web/public/locales/sl/views/explore.json +++ b/web/public/locales/sl/views/explore.json @@ -3,15 +3,28 @@ "title": "Funkcija razišči ni na voljo", "downloadingModels": { "setup": { - "visionModel": "Model vida" + "visionModel": "Model vida", + "visionModelFeatureExtractor": "Pridobivanje lastnosti modela vida", + "textModel": "Besedilni model", + "textTokenizer": "Tokenizator besedila" }, - "context": "Frigate prenaša potrebne modele vdelave za podporo funkcije semantičnega iskanja. To lahko traja nekaj minut, odvisno od hitrosti vaše omrežne povezave." + "context": "Frigate prenaša potrebne modele vdelave za podporo funkcije semantičnega iskanja. To lahko traja nekaj minut, odvisno od hitrosti vaše omrežne povezave.", + "tips": { + "context": "Morda boste želeli ponovno indeksirati vdelave (embeddings) svojih sledenih objektov, ko bodo modeli preneseni.", + "documentation": "Preberi dokumentacijo" + }, + "error": "Prišlo je do napake. Preverite dnevnike Frigate." }, "embeddingsReindexing": { "step": { "descriptionsEmbedded": "Vdelani opisi: ", - "trackedObjectsProcessed": "Obdelani sledeni predmeti: " - } + "trackedObjectsProcessed": "Obdelani sledeni predmeti: ", + "thumbnailsEmbedded": "Vdelane sličice: " + }, + "context": "Funkcija Explore se lahko uporablja, ko je ponovno indeksiranje vgraditev(embeddings) sledenih objektov končano.", + "startingUp": "Zagon…", + "estimatedTime": "Ocenjeni preostali čas:", + "finishingShortly": "Kmalu končano" } }, "documentTitle": "Razišči - Frigate", @@ -29,12 +42,61 @@ "estimatedSpeed": "Ocenjena hitrost", "description": { "placeholder": "Opis sledenega predmeta", - "label": "Opis" + "label": "Opis", + "aiTips": "Frigate od vašega ponudnika generativne UI ne bo zahteval opisa, dokler se življenjski cikel sledenega objekta ne konča." }, "recognizedLicensePlate": "Prepoznana registrska tablica", "objects": "Predmeti", "zones": "Območja", - "timestamp": "Časovni žig" + "timestamp": "Časovni žig", + "item": { + "button": { + "share": "Deli ta element mnenja", + "viewInExplore": "Poglej v Razišči Pogledu" + }, + "tips": { + "hasMissingObjects": "Prilagodite konfiguracijo, če želite, da Frigate shranjuje sledene objekte za naslednje oznake: {{objects}}" + }, + "toast": { + "success": { + "regenerate": "Od ponudnika {{provider}} je bil zahtevan nov opis. Glede na hitrost vašega ponudnika lahko regeneracija novega opisa traja nekaj časa.", + "updatedSublabel": "Podoznaka je bila uspešno posodobljena.", + "updatedLPR": "Registrska tablica je bila uspešno posodobljena.", + "audioTranscription": "Zahteva za zvočni prepis je bila uspešno izvedena." + }, + "error": { + "regenerate": "Klic ponudniku {{provider}} za nov opis ni uspel: {{errorMessage}}", + "updatedSublabelFailed": "Posodobitev podoznake ni uspela: {{errorMessage}}", + "updatedLPRFailed": "Posodobitev registrske tablice ni uspela: {{errorMessage}}", + "audioTranscription": "Zahteva za prepis zvoka ni uspela: {{errorMessage}}" + } + }, + "title": "Preglej Podrobnosti Elementa", + "desc": "Preglej podrobnosti elementa" + }, + "label": "Oznaka", + "editSubLabel": { + "title": "Uredi podoznako", + "desc": "Vnesite novo podoznako za {{label}}", + "descNoLabel": "Vnesite novo podoznako za ta sledeni objekt" + }, + "editLPR": { + "title": "Uredi registrsko tablico", + "desc": "Vnesite novo vrednost registrske tablice za {{label}}", + "descNoLabel": "Vnesite novo vrednost registrske tablice za ta sledeni objekt" + }, + "snapshotScore": { + "label": "Ocena Slike" + }, + "topScore": { + "label": "Najboljša Ocena", + "info": "Najboljša ocena je najvišji mediani rezultat za sledeni objekt, zato se lahko razlikuje od rezultata, prikazanega na sličici rezultata iskanja." + }, + "expandRegenerationMenu": "Razširi meni regeneracije", + "tips": { + "descriptionSaved": "Opis uspešno shranjen", + "saveDescriptionFailed": "Opisa ni bilo mogoče posodobiti: {{errorMessage}}" + } }, "itemMenu": { "findSimilar": { @@ -63,11 +125,85 @@ "downloadSnapshot": { "label": "Prenesi posnetek", "aria": "Prenesi posnetek" + }, + "addTrigger": { + "label": "Dodaj sprožilec", + "aria": "Dodaj sprožilec za ta sledeni objekt" + }, + "audioTranscription": { + "label": "Prepis", + "aria": "Zahtevajte prepis zvoka" } }, "dialog": { "confirmDelete": { "title": "Potrdi brisanje" } + }, + "trackedObjectDetails": "Podrobnosti Sledenega Objekta", + "type": { + "details": "podrobnosti", + "snapshot": "posnetek", + "video": "video", + "object_lifecycle": "življenjski cikel objekta" + }, + "objectLifecycle": { + "title": "Življenjski Cikel Objekta", + "noImageFound": "Za ta čas ni bila najdena nobena slika.", + "createObjectMask": "Ustvarite Masko Objekta", + "adjustAnnotationSettings": "Prilagodi nastavitve opomb", + "scrollViewTips": "Pomaknite se, da si ogledate pomembne trenutke življenjskega cikla tega predmeta.", + "count": "{{first}} od {{second}}", + "trackedPoint": "Sledena točka", + "lifecycleItemDesc": { + "visible": "{{label}} zaznan", + "entered_zone": "{{label}} je vstopil/a v {{zones}}", + "active": "{{label}} je postal aktiven", + "stationary": "{{label}} je postal nepremičen", + "attribute": { + "faceOrLicense_plate": "{{attribute}} je bil zaznan za {{label}}", + "other": "{{label}} zaznan kot {{attribute}}" + }, + "gone": "{{label}} levo", + "heard": "{{label}} slišano", + "external": "{{label}} zaznan", + "header": { + "zones": "Cone", + "ratio": "Razmerje", + "area": "Območje" + } + }, + "annotationSettings": { + "title": "Nastavitve Anotacij", + "showAllZones": { + "title": "Prikaži Vse Cone", + "desc": "Vedno prikaži območja na okvirjih, kjer so predmeti vstopili v območje." + }, + "offset": { + "label": "Anotacijski Odmik", + "documentation": "Preberi dokumentacijo ", + "millisecondsToOffset": "Odmik zaznanih anotacij v milisekundah. Privzeto: 0", + "tips": "NASVET: Predstavljajte si posnetek dogodka, v katerem oseba hodi od leve proti desni. Če je okvir dogodka na časovnici preveč levo od osebe, je treba vrednost zmanjšati. Podobno je treba vrednost povečati, če oseba hodi od leve proti desni in je okvir preveč pred njo.", + "toast": { + "success": "Odmik anotacij za {{camera}} je bil shranjen v konfiguracijsko datoteko. Znova zaženite Frigate, da uveljavite spremembe." + } + } + }, + "carousel": { + "previous": "Prejšnji diapozitiv", + "next": "Naslednji diapozitiv" + }, + "autoTrackingTips": "Položaji okvirjev bodo za kamere s samodejnim sledenjem netočni." + }, + "noTrackedObjects": "Ni Najdenih Sledenih Objektov", + "fetchingTrackedObjectsFailed": "Napaka pri pridobivanju sledenih objektov: {{errorMessage}}", + "searchResult": { + "tooltip": "Ujemanje {{type}} pri {{confidence}}%", + "deleteTrackedObject": { + "toast": { + "success": "Sledeni objekt je bil uspešno izbrisan.", + "error": "Brisanje sledenega predmeta ni uspelo: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/sl/views/faceLibrary.json b/web/public/locales/sl/views/faceLibrary.json index d59acc47e9..9d53247593 100644 --- a/web/public/locales/sl/views/faceLibrary.json +++ b/web/public/locales/sl/views/faceLibrary.json @@ -7,16 +7,22 @@ "details": { "person": "Oseba", "unknown": "Nenznano", - "timestamp": "Časovni žig" + "timestamp": "Časovni žig", + "subLabelScore": "Ocena Podoznake", + "scoreInfo": "Rezultat podoznake je utežena ocena vseh stopenj gotovosti prepoznanih obrazov, zato se lahko razlikuje od ocene, prikazane na posnetku.", + "face": "Podrobnosti Obraza", + "faceDesc": "Podrobnosti sledenega objekta, ki je ustvaril ta obraz" }, "uploadFaceImage": { - "title": "Naloži nov obraz" + "title": "Naloži nov obraz", + "desc": "Naloži sliko za iskanje obrazov in vključitev v {{pageToggle}}" }, "deleteFaceAttempts": { "desc_one": "Ali ste prepričani, da želite izbrisati {{count}} obraz? Tega dejanja ni mogoče razveljaviti.", "desc_two": "Ali ste prepričani, da želite izbrisati {{count}} obraza? Tega dejanja ni mogoče razveljaviti.", "desc_few": "Ali ste prepričani, da želite izbrisati {{count}} obraze? Tega dejanja ni mogoče razveljaviti.", - "desc_other": "Ali ste prepričani, da želite izbrisati {{count}} obrazov? Tega dejanja ni mogoče razveljaviti." + "desc_other": "Ali ste prepričani, da želite izbrisati {{count}} obrazov? Tega dejanja ni mogoče razveljaviti.", + "title": "Izbriši Obraze" }, "toast": { "success": { @@ -27,8 +33,72 @@ "deletedName_one": "{{count}} je bil uspešno izbrisan.", "deletedName_two": "{{count}} obraza sta bila uspešno izbrisana.", "deletedName_few": "{{count}} obrazi so bili uspešno izbrisani.", - "deletedName_other": "{{count}} obrazov je bilo uspešno izbrisanih." + "deletedName_other": "{{count}} obrazov je bilo uspešno izbrisanih.", + "uploadedImage": "Slika je bila uspešno naložena.", + "addFaceLibrary": "Oseba {{name}} je bila uspešno dodana v Knjižnico Obrazov!", + "renamedFace": "Obraz uspešno preimenovan v {{name}}", + "trainedFace": "Uspešno treniran obraz.", + "updatedFaceScore": "Ocena obraza je bila uspešno posodobljena." + }, + "error": { + "uploadingImageFailed": "Nalaganje slike ni uspelo: {{errorMessage}}", + "addFaceLibraryFailed": "Neuspešno nastavljanje imena obraza: {{errorMessage}}", + "deleteFaceFailed": "Brisanje ni uspelo: {{errorMessage}}", + "deleteNameFailed": "Brisanje imena ni uspelo: {{errorMessage}}", + "renameFaceFailed": "Preimenovanje obraza ni uspelo: {{errorMessage}}", + "trainFailed": "Treniranje ni uspelo: {{errorMessage}}", + "updateFaceScoreFailed": "Posodobitev ocene obraza ni uspela: {{errorMessage}}" } }, - "documentTitle": "Knjižnica obrazov - Frigate" + "documentTitle": "Knjižnica obrazov - Frigate", + "collections": "Zbirke", + "createFaceLibrary": { + "title": "Ustvari Zbirko", + "desc": "Ustvari novo zbirko", + "new": "Ustvari Nov Obraz" + }, + "steps": { + "faceName": "Vnesi Ime Obraza", + "uploadFace": "Naloži Sliko Obraza", + "nextSteps": "Naslednji koraki", + "description": { + "uploadFace": "Naložite sliko osebe {{name}}, ki prikazuje obraz (slikan naravnost in ne iz kota). Slike ni treba obrezati samo na obraz." + } + }, + "train": { + "title": "Vlak", + "aria": "Izberite treniranje", + "empty": "Ni nedavnih poskusov prepoznavanja obrazov" + }, + "selectItem": "Izberi {{item}}", + "selectFace": "Izberi Obraz", + "deleteFaceLibrary": { + "title": "Izbriši Ime", + "desc": "Ali ste prepričani, da želite izbrisati zbirko {{name}}? S tem boste trajno izbrisali vse povezane obraze." + }, + "renameFace": { + "title": "Preimenuj Obraz", + "desc": "Vnesi novo ime za {{name}}" + }, + "button": { + "deleteFaceAttempts": "Izbriši Obraze", + "addFace": "Dodaj Obraz", + "renameFace": "Preimenuj Obraz", + "deleteFace": "Izbriši Obraz", + "uploadImage": "Naloži Sliko", + "reprocessFace": "Ponovna Obdelava Obraza" + }, + "imageEntry": { + "validation": { + "selectImage": "Izberite slikovno datoteko." + }, + "dropActive": "Sliko spustite tukaj…", + "dropInstructions": "Povlecite in spustite sliko sem ali kliknite za izbiro", + "maxSize": "Največja velikost: {{size}}MB" + }, + "nofaces": "Noben obraz ni na voljo", + "pixels": "{{area}}px", + "readTheDocs": "Preberi dokumentacijo", + "trainFaceAs": "Treniraj obraz kot:", + "trainFace": "Treniraj Obraz" } diff --git a/web/public/locales/sl/views/live.json b/web/public/locales/sl/views/live.json index 212137ba79..5b52618288 100644 --- a/web/public/locales/sl/views/live.json +++ b/web/public/locales/sl/views/live.json @@ -9,14 +9,163 @@ "ptz": { "move": { "clickMove": { - "disable": "Onemogoči funkcijo klikni in premakni" + "disable": "Onemogoči funkcijo klikni in premakni", + "label": "Kliknite v okvir, da postavite kamero na sredino", + "enable": "Omogoči premik s klikom" }, "left": { "label": "Premakni PTZ kamero v levo" }, "up": { "label": "Premakni PTZ kamero gor" + }, + "down": { + "label": "Premakni PTZ kamero navzdol" + }, + "right": { + "label": "Premakni PTZ kamero desno" } + }, + "zoom": { + "in": { + "label": "Povečaj PTZ kamero" + }, + "out": { + "label": "Pomanjšaj PTZ kamero" + } + }, + "focus": { + "in": { + "label": "Izostri PTZ kamero" + }, + "out": { + "label": "Razostri PTZ kamero" + } + }, + "frame": { + "center": { + "label": "Kliknite v okvir, da postavite PTZ kamero na sredino" + } + }, + "presets": "Prednastavitve PTZ kamere" + }, + "cameraAudio": { + "enable": "Omogoči Zvok Kamere", + "disable": "Onemogoči Zvok Kamere" + }, + "camera": { + "enable": "Omogoči Kamero", + "disable": "Onemogoči Kamero" + }, + "muteCameras": { + "enable": "Utišaj vse kamere", + "disable": "Vklopi Zvok Vsem Kameram" + }, + "detect": { + "enable": "Omogoči Detekcijo", + "disable": "Onemogoči Detekcijo" + }, + "recording": { + "enable": "Omogoči Snemanje", + "disable": "Onemogoči Snemanje" + }, + "snapshots": { + "enable": "Omogoči Slike", + "disable": "Onemogoči Slike" + }, + "audioDetect": { + "enable": "Omogoči Zvočno Detekcijo", + "disable": "Onemogoči Zvočno Detekcijo" + }, + "transcription": { + "enable": "Omogoči Prepisovanje Zvoka v Živo", + "disable": "Onemogoči Prepisovanje Zvoka v Živo" + }, + "autotracking": { + "enable": "Omogoči Samodejno Sledenje", + "disable": "Onemogoči Samodejno Sledenje" + }, + "streamStats": { + "enable": "Prikaži Statistiko Pretočnega Predvajanja", + "disable": "Skrij Statistiko Pretočnega Predvajanja" + }, + "manualRecording": { + "title": "Snemanje na Zahtevo", + "tips": "Začni ročni dogodek na podlagi nastavitev hranjenja posnetkov te kamere.", + "playInBackground": { + "label": "Predvajaj v ozadju", + "desc": "Omogočite to možnost, če želite nadaljevati s pretakanjem, ko je predvajalnik skrit." + }, + "showStats": { + "label": "Prikaži Statistiko", + "desc": "Omogočite to možnost, če želite statistiko pretoka prikazati kot prekrivni sloj na viru kamere." + }, + "debugView": "Pogled za Odpravljanje Napak", + "start": "Začni snemanje na zahtevo", + "started": "Začelo se je ročno snemanje na zahtevo.", + "failedToStart": "Ročnega snemanja na zahtevo ni bilo mogoče začeti.", + "recordDisabledTips": "Ker je snemanje v nastavitvah te kamere onemogočeno ali omejeno, bo shranjena samo slika.", + "end": "Končaj snemanje na zahtevo", + "ended": "Ročno snemanje na zahtevo je končano.", + "failedToEnd": "Ročnega snemanja na zahtevo ni bilo mogoče končati." + }, + "streamingSettings": "Nastavitve Pretakanja", + "notifications": "Obvestila", + "audio": "Zvok", + "suspend": { + "forTime": "Začasno ustavi za: " + }, + "stream": { + "title": "Pretok", + "audio": { + "tips": { + "title": "Zvok mora biti predvajan iz vaše kamere in konfiguriran v go2rtc za ta pretok.", + "documentation": "Preberi Dokumentacijo " + }, + "available": "Za ta pretok je na voljo zvok", + "unavailable": "Zvok za ta pretok ni na voljo" + }, + "twoWayTalk": { + "tips": "Vaša naprava mora podpirati to funkcijo, WebRTC pa mora biti konfiguriran za dvosmerni pogovor.", + "tips.documentation": "Preberi dokumentacijo ", + "available": "Za ta tok je na voljo dvosmerni pogovor", + "unavailable": "Dvosmerni pogovor ni na voljo za ta pretok" + }, + "lowBandwidth": { + "tips": "Pogled v živo je v načinu nizke pasovne širine zaradi napak v nalaganju ali pretoku.", + "resetStream": "Ponastavi pretok" + }, + "playInBackground": { + "label": "Predvajaj v ozadju", + "tips": "Omogočite to možnost, če želite nadaljevati s pretakanjem, ko je predvajalnik skrit." } + }, + "cameraSettings": { + "title": "{{camera}} Nastavitve", + "cameraEnabled": "Kamera Omogočena", + "objectDetection": "Zaznavanje Objektov", + "recording": "Snemanje", + "snapshots": "Slike", + "audioDetection": "Zvočna Detekcija", + "transcription": "Zvočni Prepis", + "autotracking": "Samodejno Sledenje" + }, + "history": { + "label": "Prikaži stare posnetke" + }, + "effectiveRetainMode": { + "modes": { + "all": "Vse", + "motion": "Gibanje", + "active_objects": "Aktivni Objekti" + }, + "notAllTips": "Vaša konfiguracija hranjenja posnetkov {{source}} je nastavljena na način : {{effectiveRetainMode}}, zato bo ta posnetek na zahtevo hranil samo segmente z {{effectiveRetainModeName}}." + }, + "editLayout": { + "label": "Uredi Postavitev", + "group": { + "label": "Uredi Skupino Kamere" + }, + "exitEdit": "Izhod iz Urejanja" } } diff --git a/web/public/locales/sl/views/settings.json b/web/public/locales/sl/views/settings.json index af8f70748e..14bdefc28a 100644 --- a/web/public/locales/sl/views/settings.json +++ b/web/public/locales/sl/views/settings.json @@ -8,7 +8,8 @@ "object": "Odpravljanje napak - Frigate", "general": "Splošne Nastavitve - Frigate", "frigatePlus": "Frigate+ Nastavitve - Frigate", - "enrichments": "Nastavitve Obogatitev - Frigate" + "enrichments": "Nastavitve Obogatitev - Frigate", + "motionTuner": "Nastavitev gibanja - Frigate" }, "menu": { "ui": "Uporabniški vmesnik", @@ -18,7 +19,9 @@ "debug": "Razhroščevanje", "users": "Uporabniki", "notifications": "Obvestila", - "frigateplus": "Frigate+" + "frigateplus": "Frigate+", + "motionTuner": "Nastavitev Gibanja", + "triggers": "Prožilniki" }, "masksAndZones": { "zones": { @@ -59,12 +62,146 @@ "desc": "Samodejno preklopite na pogled kamere v živo, ko je zaznana aktivnost. Če onemogočite to možnost, se statične slike kamere na nadzorni plošči v živo posodobijo le enkrat na minuto." }, "playAlertVideos": { - "label": "Predvajajte opozorilne videoposnetke" + "label": "Predvajajte opozorilne videoposnetke", + "desc": "Privzeto se nedavna opozorila na nadzorni plošči predvajajo kot kratki ponavljajoči videoposnetki . To možnost onemogočite, če želite, da se v tej napravi/brskalniku prikaže samo statična slika nedavnih opozoril." } }, "storedLayouts": { "title": "Sharnjene Postavitve", - "desc": "Postaviteve kamer v skupini kamer je mogoče povleči/prilagoditi. Položaji so shranjeni v lokalnem pomnilniku vašega brskalnika." + "desc": "Postaviteve kamer v skupini kamer je mogoče povleči/prilagoditi. Položaji so shranjeni v lokalnem pomnilniku vašega brskalnika.", + "clearAll": "Počisti Vse Postavitve" + }, + "cameraGroupStreaming": { + "title": "Nastavitve Pretakanja Skupine Kamer", + "desc": "Nastavitve pretakanja za vsako skupino kamer so shranjene v lokalnem pomnilniku vašega brskalnika.", + "clearAll": "Počisti Vse Nastavitve Pretakanja" + }, + "recordingsViewer": { + "title": "Pregledovalnik Posnetkov", + "defaultPlaybackRate": { + "label": "Privzeta Hitrost Predvajanja", + "desc": "Privzeta Hitrost Predvajanja za Shranjene Posnetke." + } + }, + "calendar": { + "title": "Koledar", + "firstWeekday": { + "label": "Prvi Delovni Dan", + "desc": "Dan, na katerega se začnejo tedni v koledarju za preglede.", + "sunday": "Nedelja", + "monday": "Ponedeljek" + } + }, + "toast": { + "success": { + "clearStoredLayout": "Shranjena postavitev za {{cameraName}} je bila izbrisana", + "clearStreamingSettings": "Nastavitve pretakanja za vse skupine kamer so bile izbrisane." + }, + "error": { + "clearStoredLayoutFailed": "Shranjene postavitve ni bilo mogoče izbrisati: {{errorMessage}}", + "clearStreamingSettingsFailed": "Nastavitev pretakanja ni bilo mogoče izbrisati: {{errorMessage}}" + } + } + }, + "enrichments": { + "title": "Nastavitve Obogatitev", + "unsavedChanges": "Neshranjene Spremembe Nastavitev Obogatitev", + "birdClassification": { + "title": "Klasifikacija ptic", + "desc": "Klasifikacija ptic identificira znane ptice z uporabo kvantiziranega Tensorflow modela. Ko je znana ptica prepoznana, se njeno splošno ime doda kot podoznaka. Te informacije so vključene v uporabniški vmesnik, filtre in obvestila." + }, + "semanticSearch": { + "title": "Semantično Iskanje", + "desc": "Semantično iskanje v Frigate vam omogoča iskanje sledenih objektov znotraj vaših pregledov, pri čemer lahko uporabite izvorno sliko, uporabniško določen besedilni opis ali samodejno ustvarjen opis.", + "readTheDocumentation": "Preberi Dokumentacijo", + "reindexNow": { + "label": "Ponovno Indeksiraj Zdaj", + "desc": "Ponovno indeksiranje bo regeneriralo vdelave (embeddings) za vse sledene objekte. Ta postopek se izvaja v ozadju in lahko zelo obremeni vaš procesor ter traja precej časa, odvisno od števila sledenih objektov, ki jih imate.", + "confirmTitle": "Potrdi Ponovno Indeksiranje", + "confirmDesc": "Ali ste prepričani, da želite ponovno indeksirati vse vdelave (embeddings) sledenih objektov? Ta postopek se bo izvajal v ozadju, vendar lahko zelo obremeni vaš procesor in traja kar nekaj časa. Napredek si lahko ogledate na strani Razišči.", + "confirmButton": "Ponovno Indeksiranje", + "success": "Ponovno indeksiranje se je uspešno začelo.", + "alreadyInProgress": "Ponovno indeksiranje je že v teku.", + "error": "Ponovnega indeksiranja ni bilo mogoče začeti: {{errorMessage}}" + }, + "modelSize": { + "label": "Velikost Modela", + "desc": "Velikost modela, uporabljenega za vdelave (embeddings) semantičnih iskanj.", + "small": { + "title": "majhen", + "desc": "Uporaba načina small uporablja kvantizirano različico modela, ki porabi manj RAM-a in deluje hitreje na procesorju z zelo zanemarljivo razliko v kakovosti vdelave (embedding)." + }, + "large": { + "title": "velik", + "desc": "Uporaba možnosti large uporablja celoten model Jina in se bo, če je mogoče, samodejno izvajal na grafičnem procesorju." + } + } + }, + "faceRecognition": { + "title": "Prepoznavanje Obrazov", + "desc": "Prepoznavanje obrazov omogoča, da se ljudem dodelijo imena, in ko Frigate prepozna njihov obraz, se detekciji dodeli ime kot podoznako. Te informacije so vključene v uporabniški vmesnik, filtre in obvestila.", + "readTheDocumentation": "Preberi Dokumentacijo", + "modelSize": { + "label": "Velikost Modela", + "desc": "Velikost modela, uporabljenega za prepoznavanje obrazov.", + "small": { + "title": "majhen", + "desc": "Uporaba small uporablja model vdelave (embedding) obrazov FaceNet, ki učinkovito deluje na večini procesorjev." + }, + "large": { + "title": "velik", + "desc": "Uporaba large uporablja model vdelave (embedding) obrazov ArcFace in se bo samodejno zagnala na grafičnem procesorju, če bo to mogoče." + } + } + }, + "licensePlateRecognition": { + "title": "Prepoznavanje Registrskih Tablic", + "desc": "Frigate lahko prepozna registrske tablice na vozilih in samodejno doda zaznane znake v polje recognized_license_plate ali znano ime kot podoznako objektom tipa car. Pogost primer uporabe je lahko branje registrskih tablic avtomobilov, ki se ustavijo na dovozu, ali avtomobilov, ki se peljejo mimo po ulici.", + "readTheDocumentation": "Preberi Dokumentacijo" + }, + "restart_required": "Potreben je ponovni zagon (Nastavitve Obogatitve so bile spremenjene)", + "toast": { + "success": "Nastavitve Obogatitev so shranjene. Znova zaženite Frigate, da uveljavite spremembe.", + "error": "Shranjevanje sprememb konfiguracije ni uspelo: {{errorMessage}}" + } + }, + "camera": { + "title": "Nastavitve Kamere", + "streams": { + "title": "Pretoki" + }, + "object_descriptions": { + "title": "Opisi objektov z uporabo generativne UI", + "desc": "Začasno omogoči/onemogoči opise objektov z uporabo generativne UI za to kamero. Ko so onemogočeni, opisi, ki jih ustvari UI, ne bodo zahtevani za sledene objekte na tej kameri." + }, + "review": { + "title": "Pregled", + "desc": "Začasno omogoči/onemogoči opozorila in zaznavanja za to kamero, dokler se Frigate ne zažene znova. Ko je onemogočeno, ne bodo ustvarjeni novi elementi pregleda. ", + "alerts": "Opozorila ", + "detections": "Detekcije " + }, + "reviewClassification": { + "title": "Pregled Klasifikacij", + "readTheDocumentation": "Preberi Dokumentacijo", + "noDefinedZones": "Za to kamero ni določenih nobenih con.", + "objectAlertsTips": "Vsi objekti {{alertsLabels}} na {{cameraName}} bodo prikazani kot Opozorila.", + "unsavedChanges": "Neshranjene nastavitve Pregleda Klasifikacije za {{camera}}", + "selectAlertsZones": "Izberite cone za Opozorila", + "selectDetectionsZones": "Izberite cone za Zaznavanje", + "limitDetections": "Omejite zaznavanje na določene cone" + }, + "addCamera": "Dodaj Novo Kamero", + "editCamera": "Uredi Kamero:", + "selectCamera": "Izberi Kamero", + "backToSettings": "Nazaj na Nastavitve Kamere", + "cameraConfig": { + "add": "Dodaj Kamero", + "edit": "Uredi Kamero", + "description": "Konfigurirajte nastavitve kamere, vključno z pretočnimi vhodi in vlogami.", + "name": "Ime Kamere", + "nameRequired": "Ime kamere je obvezno", + "nameInvalid": "Ime kamere mora vsebovati samo črke, številke, podčrtaje ali vezaje", + "namePlaceholder": "npr. vhodna_vrata" } } } diff --git a/web/public/locales/sl/views/system.json b/web/public/locales/sl/views/system.json index 4a19721c0c..6562321a2e 100644 --- a/web/public/locales/sl/views/system.json +++ b/web/public/locales/sl/views/system.json @@ -7,7 +7,8 @@ "frigate": "Frigate dnevniki - Frigate", "go2rtc": "Go2RTC dnevniki - Frigate", "nginx": "Nginx dnevniki - Frigate" - } + }, + "enrichments": "Statistika Obogatitev - Frigate" }, "logs": { "download": { @@ -23,6 +24,13 @@ "timestamp": "Časovni žig", "message": "Sporočilo", "tag": "Oznaka" + }, + "tips": "Dnevniki se pretakajo s strežnika", + "toast": { + "error": { + "fetchingLogsFailed": "Napaka pri pridobivanju dnevnikov: {{errorMessage}}", + "whileStreamingLogs": "Napaka med pretakanjem dnevnikov: {{errorMessage}}" + } } }, "storage": { @@ -100,7 +108,67 @@ "title": "Kamere", "overview": "Pregled", "info": { - "aspectRatio": "razmerje stranic" + "aspectRatio": "razmerje stranic", + "cameraProbeInfo": "{{camera}} Podrobne Informacije Kamere", + "streamDataFromFFPROBE": "Podatki o pretoku se pridobijo z ukazom ffprobe.", + "fetching": "Pridobivanje Podatkov Kamere", + "stream": "Pretok {{idx}}", + "video": "Video:", + "codec": "Kodek:", + "resolution": "Ločljivost:", + "fps": "FPS:", + "unknown": "Neznano", + "audio": "Zvok:", + "error": "Napaka: {{error}}", + "tips": { + "title": "Podrobne Informacije Kamere" + } + }, + "framesAndDetections": "Okvirji / Zaznave", + "label": { + "camera": "kamera", + "detect": "zaznaj", + "skipped": "preskočeno", + "ffmpeg": "FFmpeg", + "capture": "zajemanje", + "overallFramesPerSecond": "skupno število sličic na sekundo (FPS)", + "overallDetectionsPerSecond": "skupno število zaznav na sekundo", + "overallSkippedDetectionsPerSecond": "skupno število preskočenih zaznav na sekundo", + "cameraFfmpeg": "{{camName}} FFmpeg", + "cameraCapture": "{{camName}} zajem", + "cameraDetect": "{{camName}} zaznavanje", + "cameraFramesPerSecond": "{{camName}} sličic na sekundo (FPS)", + "cameraDetectionsPerSecond": "{{camName}} detekcij na sekundo", + "cameraSkippedDetectionsPerSecond": "{{camName}} preskočenih zaznav na sekundo" + }, + "toast": { + "success": { + "copyToClipboard": "Podatki sonde so bili kopirani v odložišče." + }, + "error": { + "unableToProbeCamera": "Ni mogoče preveriti podrobnosti kamere: {{errorMessage}}" + } + } + }, + "lastRefreshed": "Zadnja osvežitev: ", + "stats": { + "ffmpegHighCpuUsage": "{{camera}} ima visoko porabo procesorja FFmpeg ({{ffmpegAvg}} %)", + "detectHighCpuUsage": "{{camera}} ima visoko porabo procesorja za zaznavanje ({{detectAvg}} %)", + "healthy": "Sistem je zdrav", + "reindexingEmbeddings": "Ponovno indeksiranje vdelanih elementov (embeddings) ({{processed}}% končano)", + "cameraIsOffline": "{{camera}} je nedosegljiva", + "detectIsSlow": "{{detect}} je počasen ({{speed}} ms)", + "detectIsVerySlow": "{{detect}} je zelo počasen ({{speed}} ms)" + }, + "enrichments": { + "title": "Obogatitve", + "infPerSecond": "Inference Na Sekundo", + "embeddings": { + "face_recognition": "Prepoznavanje Obrazov", + "plate_recognition": "Prepoznavanje Registrskih Tablic", + "face_recognition_speed": "Hitrost Prepoznavanja Obrazov", + "plate_recognition_speed": "Hitrost Prepoznavanja Registrskih Tablic", + "yolov9_plate_detection": "YOLOv9 Zaznavanje Registrskih Tablic" } } } diff --git a/web/public/locales/sr/audio.json b/web/public/locales/sr/audio.json index a9e52ade64..63c1c25f08 100644 --- a/web/public/locales/sr/audio.json +++ b/web/public/locales/sr/audio.json @@ -11,5 +11,7 @@ "whispering": "Šaptanje", "bus": "Autobus", "laughter": "Smeh", - "train": "Voz" + "train": "Voz", + "boat": "Brod", + "crying": "Plač" } diff --git a/web/public/locales/sr/common.json b/web/public/locales/sr/common.json index a68b332486..06557f2ecd 100644 --- a/web/public/locales/sr/common.json +++ b/web/public/locales/sr/common.json @@ -27,5 +27,6 @@ "year_few": "2,3,4,22,23,24,32,33,34,42,...", "year_other": "", "mo": "{{time}}mes" - } + }, + "readTheDocumentation": "Прочитајте документацију" } diff --git a/web/public/locales/sr/components/auth.json b/web/public/locales/sr/components/auth.json index f601ec61a9..ecaa132ac4 100644 --- a/web/public/locales/sr/components/auth.json +++ b/web/public/locales/sr/components/auth.json @@ -7,7 +7,9 @@ "usernameRequired": "Korisničko ime je obavezno", "passwordRequired": "Lozinka je obavezna", "rateLimit": "Prekoračeno ograničenje brzine. Pokušajte ponovo kasnije.", - "loginFailed": "Prijava nije uspela" + "loginFailed": "Prijava nije uspela", + "unknownError": "Nepoznata greška. Proveri logove.", + "webUnknownError": "Nepoznata greška. Proveri logove u konzoli." } } } diff --git a/web/public/locales/sr/components/camera.json b/web/public/locales/sr/components/camera.json index 6be8272ec6..1bb6c30200 100644 --- a/web/public/locales/sr/components/camera.json +++ b/web/public/locales/sr/components/camera.json @@ -11,7 +11,11 @@ } }, "name": { - "label": "Ime" + "label": "Ime", + "placeholder": "Unesite ime…", + "errorMessage": { + "mustLeastCharacters": "Naziv grupe kamera mora imati bar 2 karaktera." + } } } } diff --git a/web/public/locales/sr/components/dialog.json b/web/public/locales/sr/components/dialog.json index 8c5a7c1c44..ead50e869b 100644 --- a/web/public/locales/sr/components/dialog.json +++ b/web/public/locales/sr/components/dialog.json @@ -13,6 +13,11 @@ "submitToPlus": { "label": "Pošalji na Frigate+", "desc": "Objekti na lokacijama koje želite da izbegnete nisu lažno pozitivni. Slanje lažno pozitivnih rezultata će zbuniti model." + }, + "review": { + "question": { + "ask_a": "Da li je ovaj objekat {{label}}?" + } } } } diff --git a/web/public/locales/sr/components/filter.json b/web/public/locales/sr/components/filter.json index e00ac754d7..d7b8323f62 100644 --- a/web/public/locales/sr/components/filter.json +++ b/web/public/locales/sr/components/filter.json @@ -10,6 +10,10 @@ "count_other": "{{count}} Oznake" }, "zones": { - "label": "Zone" + "label": "Zone", + "all": { + "title": "Sve zone", + "short": "Zone" + } } } diff --git a/web/public/locales/sr/objects.json b/web/public/locales/sr/objects.json index 75f353ded8..4edf4728b6 100644 --- a/web/public/locales/sr/objects.json +++ b/web/public/locales/sr/objects.json @@ -5,5 +5,6 @@ "motorcycle": "Motor", "airplane": "Avion", "bus": "Autobus", - "train": "Voz" + "train": "Voz", + "boat": "Brod" } diff --git a/web/public/locales/sr/views/events.json b/web/public/locales/sr/views/events.json index 8a1b76e455..4097e56668 100644 --- a/web/public/locales/sr/views/events.json +++ b/web/public/locales/sr/views/events.json @@ -8,6 +8,7 @@ "allCameras": "Sve Kamere", "empty": { "alert": "Nema upozorenja za pregled", - "detection": "Nema detekcija za pregled" + "detection": "Nema detekcija za pregled", + "motion": "Nema podataka o pokretu" } } diff --git a/web/public/locales/sr/views/exports.json b/web/public/locales/sr/views/exports.json index a12e061633..ff71c75d5d 100644 --- a/web/public/locales/sr/views/exports.json +++ b/web/public/locales/sr/views/exports.json @@ -6,6 +6,7 @@ "deleteExport.desc": "Da li zaista želite obrisati {{exportName}}?", "editExport": { "title": "Preimenuj izvoz", - "desc": "Unesite novo ime za ovaj izvoz." + "desc": "Unesite novo ime za ovaj izvoz.", + "saveExport": "Sačuvaj izvoz" } } diff --git a/web/public/locales/sr/views/faceLibrary.json b/web/public/locales/sr/views/faceLibrary.json index 766a52aa97..c2aa8367b3 100644 --- a/web/public/locales/sr/views/faceLibrary.json +++ b/web/public/locales/sr/views/faceLibrary.json @@ -7,6 +7,8 @@ "details": { "person": "Osoba", "subLabelScore": "Sub Label Skor", - "scoreInfo": "Rezultat podoznake je otežan rezultat za sve prepoznate pouzdanosti lica, tako da se može razlikovati od rezultata prikazanog na snimku." + "scoreInfo": "Rezultat podoznake je otežan rezultat za sve prepoznate pouzdanosti lica, tako da se može razlikovati od rezultata prikazanog na snimku.", + "face": "Detalji lica", + "faceDesc": "Detalji praćenog objekta koji je generisao ovo lice" } } diff --git a/web/public/locales/sr/views/live.json b/web/public/locales/sr/views/live.json index fe19046a32..1374fe1637 100644 --- a/web/public/locales/sr/views/live.json +++ b/web/public/locales/sr/views/live.json @@ -7,6 +7,14 @@ "disable": "Onemogućite dvosmerni razgovor" }, "cameraAudio": { - "enable": "Omogući zvuk kamere" + "enable": "Omogući zvuk kamere", + "disable": "Onemogući zvuk kamere" + }, + "ptz": { + "move": { + "clickMove": { + "label": "Kliknite na sliku da bi centrirali kameru" + } + } } } diff --git a/web/public/locales/sr/views/search.json b/web/public/locales/sr/views/search.json index 3ab007f602..d72036c665 100644 --- a/web/public/locales/sr/views/search.json +++ b/web/public/locales/sr/views/search.json @@ -5,6 +5,8 @@ "button": { "clear": "Obriši pretragu", "save": "Sačuvaj pretragu", - "delete": "Izbrišite sačuvanu pretragu" + "delete": "Izbrišite sačuvanu pretragu", + "filterInformation": "Filtriraj informacije", + "filterActive": "Aktivni filteri" } } diff --git a/web/public/locales/sr/views/settings.json b/web/public/locales/sr/views/settings.json index 07a4ea59d8..2957af0f22 100644 --- a/web/public/locales/sr/views/settings.json +++ b/web/public/locales/sr/views/settings.json @@ -5,6 +5,7 @@ "camera": "Podešavanje kamera - Frigate", "enrichments": "Podešavanja obogaćivanja - Frigate", "masksAndZones": "Uređivač maski i zona - Frigate", - "motionTuner": "Tjuner pokreta - Frigate" + "motionTuner": "Tjuner pokreta - Frigate", + "general": "Generalna podešavanja - Frigate" } } diff --git a/web/public/locales/sr/views/system.json b/web/public/locales/sr/views/system.json index 07f260401c..5cd6faa232 100644 --- a/web/public/locales/sr/views/system.json +++ b/web/public/locales/sr/views/system.json @@ -6,7 +6,9 @@ "enrichments": "Statistika obogaćivanja - Frigate", "logs": { "frigate": "Frigate logovi - Frigate", - "go2rtc": "Go2RTC dnevnici - Frigate" + "go2rtc": "Go2RTC dnevnici - Frigate", + "nginx": "Nginx logovi - Frigate" } - } + }, + "title": "Sistem" } diff --git a/web/public/locales/sv/audio.json b/web/public/locales/sv/audio.json index 2e685096cc..6918ff2df5 100644 --- a/web/public/locales/sv/audio.json +++ b/web/public/locales/sv/audio.json @@ -3,7 +3,7 @@ "bicycle": "Cykel", "speech": "Tal", "car": "Bil", - "bellow": "Under", + "bellow": "Vrål", "motorcycle": "Motorcykel", "whispering": "Viskning", "bus": "Buss", @@ -150,7 +150,7 @@ "vehicle": "Fordon", "skateboard": "Skatebord", "door": "Dörr", - "blender": "Mixer", + "blender": "Blandare", "sink": "Vask", "hair_dryer": "Hårfön", "toothbrush": "Tandborste", @@ -158,5 +158,272 @@ "strum": "Anslag", "zither": "Citer", "ukulele": "Ukulele", - "piano": "Piano" + "piano": "Piano", + "electric_piano": "Elpiano", + "organ": "Orgel", + "electronic_organ": "Elektronisk orgel", + "hammond_organ": "Hammondorgel", + "synthesizer": "Synthesizer", + "sampler": "Provtagare", + "harpsichord": "Cembalo", + "percussion": "Slagverk", + "drum_kit": "Trumset", + "drum_machine": "Trummaskin", + "drum": "Trumma", + "french_horn": "Franskt horn", + "trumpet": "Trumpet", + "flute": "Flöjt", + "gong": "Gonggong", + "tubular_bells": "Rörklockor", + "mallet_percussion": "Malletinstrument", + "marimba": "Marimba", + "glockenspiel": "Klockspel", + "vibraphone": "Vibrafon", + "steelpan": "Stålpanna", + "orchestra": "Orkester", + "brass_instrument": "Bleckblåsinstrument", + "trombone": "Trombon", + "string_section": "Stråkinstrument", + "violin": "Fiol", + "pizzicato": "Pizzicato", + "cello": "Cello", + "double_bass": "Kontrabas", + "wind_instrument": "Blåsinstrument", + "saxophone": "Saxofon", + "clarinet": "Klarinett", + "harp": "Harpa", + "bell": "Klocka", + "church_bell": "Kyrkklocka", + "jingle_bell": "Bjällerklang", + "bicycle_bell": "Cykelklocka", + "tuning_fork": "Stämgaffel", + "chime": "Klämta", + "wind_chime": "Vindspel", + "harmonica": "Munspel", + "accordion": "Dragspel", + "bagpipes": "Säckpipor", + "didgeridoo": "Didjeridu", + "theremin": "Teremin", + "singing_bowl": "Sjungande skål", + "scratching": "Repa", + "pop_music": "Popmusik", + "hip_hop_music": "Hiphopmusik", + "beatboxing": "Beatboxning", + "rock_music": "Rockmusik", + "heavy_metal": "Heavy Metal musik", + "punk_rock": "Punkrock", + "grunge": "Grunge", + "progressive_rock": "Progressiv rock", + "rock_and_roll": "Rock and roll", + "psychedelic_rock": "Psykedelisk rock", + "rhythm_and_blues": "Rytm och blues", + "soul_music": "Soulmusik", + "reggae": "Reggae", + "country": "Land", + "swing_music": "Swingmusik", + "bluegrass": "Bluegrass", + "funk": "Funk", + "folk_music": "Folkmusik", + "middle_eastern_music": "Mellanösternmusik", + "jazz": "Jazz", + "disco": "Disko", + "classical_music": "Klassisk musik", + "opera": "Opera", + "electronic_music": "Elektronisk musik", + "house_music": "Housemusik", + "techno": "Tekno", + "dubstep": "Dubstep", + "drum_and_bass": "Trumma och bas", + "electronica": "Elektronisk musik", + "electronic_dance_music": "Elektronisk dansmusik", + "ambient_music": "Ambientmusik", + "trance_music": "Trancemusik", + "music_of_latin_america": "Latinamerikansk musik", + "salsa_music": "Salsamusik", + "flamenco": "Flamenco", + "blues": "Blues", + "music_for_children": "Musik för barn", + "new-age_music": "New Age-musik", + "vocal_music": "Vokalmusik", + "a_capella": "A cappella", + "music_of_africa": "Afrikansk musik", + "afrobeat": "Afrobeat", + "christian_music": "Kristen musik", + "gospel_music": "Gospelmusik", + "music_of_asia": "Asiens musik", + "carnatic_music": "Karnatisk musik", + "music_of_bollywood": "Bollywoods musik", + "ska": "Ska", + "traditional_music": "Traditionell musik", + "independent_music": "Oberoende musik", + "song": "Låt", + "background_music": "Bakgrundsmusik", + "theme_music": "Temamusik", + "jingle": "Klingande", + "soundtrack_music": "Soundtrackmusik", + "lullaby": "Vaggvisa", + "video_game_music": "Videospelsmusik", + "christmas_music": "Julmusik", + "dance_music": "Dansmusik", + "wedding_music": "Bröllopsmusik", + "happy_music": "Glad musik", + "sad_music": "Sorglig musik", + "tender_music": "Öm musik", + "exciting_music": "Spännande musik", + "angry_music": "Arg musik", + "scary_music": "Skräckmusik", + "wind": "Vind", + "rustling_leaves": "Prasslande löv", + "wind_noise": "Vindbrus", + "thunderstorm": "Åskväder", + "thunder": "Åska", + "water": "Vatten", + "rain": "Regn", + "raindrop": "Regndroppe", + "rain_on_surface": "Regn på ytan", + "stream": "Strömma", + "waterfall": "Vattenfall", + "ocean": "Hav", + "waves": "Vågor", + "steam": "Ånga", + "gurgling": "Gurglande", + "fire": "Brand", + "crackle": "Spraka", + "sailboat": "Segelbåt", + "rowboat": "Roddbåt", + "motorboat": "Motorbåt", + "ship": "Fartyg", + "motor_vehicle": "Motorfordon", + "power_windows": "Elfönster", + "skidding": "Slirning", + "tire_squeal": "Däckskrik", + "toot": "Tuta", + "car_alarm": "Billarm", + "car_passing_by": "Bil som passerar", + "race_car": "Racerbil", + "truck": "Lastbil", + "air_brake": "Luftbroms", + "air_horn": "Lufthorn", + "reversing_beeps": "Backningljud", + "ice_cream_truck": "Glassbil", + "emergency_vehicle": "Akutbil", + "police_car": "Polisbil", + "ambulance": "Ambulans", + "fire_engine": "Brandbil", + "traffic_noise": "Trafikbuller", + "rail_transport": "Järnvägstransport", + "train_whistle": "Tågvissla", + "train_horn": "Tåghorn", + "railroad_car": "Järnvägsvagn", + "train_wheels_squealing": "Tåghjul skriker", + "subway": "Tunnelbana", + "aircraft": "Flygplan", + "aircraft_engine": "Flygmotor", + "jet_engine": "Jetmotor", + "propeller": "Propeller", + "helicopter": "Helikopter", + "fixed-wing_aircraft": "Flygplan med fasta vingar", + "engine": "Motor", + "light_engine": "Ljusmotor", + "lawn_mower": "Gräsklippare", + "chainsaw": "Motorsåg", + "doorbell": "Dörrklocka", + "electric_toothbrush": "Eltandborste", + "computer_keyboard": "Tangentbord", + "alarm": "Larm", + "telephone": "Telefon", + "ringtone": "Ringsignal", + "dial_tone": "Rington", + "busy_signal": "Upptagetsignal", + "alarm_clock": "Alarmklocka", + "smoke_detector": "Brandvarnare", + "fire_alarm": "Brandlarm", + "dental_drill's_drill": "Tandläkarborr", + "medium_engine": "Medelstor motor", + "heavy_engine": "Tung motor", + "engine_knocking": "Motorknackning", + "engine_starting": "Motor startar", + "idling": "Tomgång", + "accelerating": "Accelererar", + "ding-dong": "Ring-ring", + "sliding_door": "Skjutdörr", + "slam": "Smäll", + "knock": "Knack", + "tap": "Knacka", + "squeak": "Gnissla", + "cupboard_open_or_close": "Skåp öppnas eller stängs", + "drawer_open_or_close": "Låda öppnas eller stängs", + "dishes": "Tallrikar", + "cutlery": "Bestick", + "chopping": "Hackning", + "frying": "Steka", + "microwave_oven": "Mikrovågsugn", + "water_tap": "Vattenkran", + "bathtub": "Badkar", + "toilet_flush": "Toalettspolning", + "vacuum_cleaner": "Dammsugare", + "zipper": "Dragkedja", + "keys_jangling": "Nycklar som klirrar", + "coin": "Mynt", + "electric_shaver": "Elektrisk rakhyvel", + "shuffling_cards": "Blanda kort", + "typing": "Skrivar", + "typewriter": "Skrivmaskin", + "writing": "Skriva", + "telephone_bell_ringing": "Telefonen ringer", + "telephone_dialing": "Ljud för telefonuppringning", + "siren": "Siren", + "civil_defense_siren": "Civilförsvarssiren", + "buzzer": "Summer", + "foghorn": "Mistlur", + "whistle": "Vissla", + "steam_whistle": "Ångvissla", + "mechanisms": "Mekanismer", + "ratchet": "Spärrhake", + "tick": "Tick", + "tick-tock": "Tick Tack", + "gears": "Kugghjul", + "pulleys": "Remskivor", + "sewing_machine": "Symaskin", + "printer": "Skrivare", + "mechanical_fan": "Mekanisk fläkt", + "air_conditioning": "Luftkonditionering", + "cash_register": "Kassaapparat", + "single-lens_reflex_camera": "Enkellinsreflexkamera", + "tools": "Verktyg", + "hammer": "Hammare", + "jackhammer": "Tryckluftsborr", + "sawing": "Sågning", + "filing": "Filning", + "sanding": "Sandning", + "power_tool": "Elverktyg", + "drill": "Borra", + "explosion": "Explosion", + "gunshot": "Skottlossning", + "machine_gun": "Kulspruta", + "fusillade": "Fusillad", + "artillery_fire": "Artillerieeld", + "cap_gun": "Kapsylpistol", + "fireworks": "Fyrverkeri", + "firecracker": "Smällare", + "burst": "Brista", + "eruption": "Utbrott", + "boom": "Pang", + "wood": "Trä", + "chop": "Hugga", + "splinter": "Flisa", + "crack": "Spricka", + "glass": "Glas", + "chink": "Skaka", + "shatter": "Splittras", + "silence": "Tystnad", + "sound_effect": "Ljudeffekt", + "environmental_noise": "Miljöbuller", + "static": "Statisk", + "white_noise": "Vitt brus", + "pink_noise": "Rosa brus", + "television": "Tv", + "radio": "Radio", + "field_recording": "Fältinspelning", + "scream": "Skrika" } diff --git a/web/public/locales/sv/common.json b/web/public/locales/sv/common.json index a220db5856..8a783d6f8d 100644 --- a/web/public/locales/sv/common.json +++ b/web/public/locales/sv/common.json @@ -113,39 +113,48 @@ }, "menu": { "language": { - "yue": "Kantonesiska", - "it": "Italienska", - "fr": "Franska", - "nl": "Nederländska (Dutch)", - "hi": "Hindi", - "pt": "Portugisiska", - "ru": "Ryska", - "pl": "Polska", - "el": "Grekiska", - "sk": "Slovenska", - "tr": "Turkiska", - "uk": "Ukrainska", - "he": "Hebreiska", - "ro": "Romänska", - "hu": "Ungerska", - "fi": "Finska", - "da": "Danska", - "ar": "Arabiska", - "es": "Spanska", - "zhCN": "Kinesiska", - "de": "Tyska", - "ja": "Japanska", - "sv": "Svenska (Swedish)", - "cs": "Tjeckiska (Czech)", + "yue": "粵語 (Kantonesiska)", + "it": "Italiano (Italienska)", + "fr": "Français (Franska)", + "nl": "Nederlands (Nederländska)", + "hi": "हिन्दी (Hindi)", + "pt": "Português (Portugisiska)", + "ru": "Русский (Ryska)", + "pl": "Polski (Polska)", + "el": "Ελληνικά (Grekiska)", + "sk": "Slovenčina (Slovenska)", + "tr": "Türkçe (Turkiska)", + "uk": "Українська (Ukrainska)", + "he": "עברית (Hebreiska)", + "ro": "Română (Romänska)", + "hu": "Magyar (Ungerska)", + "fi": "Suomi (Finska)", + "da": "Dansk (Danska)", + "ar": "العربية (Arabiska)", + "es": "Español (Spanska)", + "zhCN": "简体中文 (Kinesiska)", + "de": "Deutsch (Tyska)", + "ja": "日本語 (Japanska)", + "sv": "Svenska (Svenska)", + "cs": "Čeština (Tjeckiska)", "nb": "Norsk Bokmål (Norsk Bokmål)", - "ko": "Koreanska", - "vi": "Vietnamesiska", - "fa": "Persiska", - "th": "Thailändska", + "ko": "한국어 (Koreanska)", + "vi": "Tiếng Việt (Vietnamesiska)", + "fa": "فارسی (Persiska)", + "th": "ไทย (Thailändska)", "withSystem": { "label": "Använd systeminställningarna för språk" }, - "en": "Engelska" + "en": "English (Engelska)", + "ptBR": "Português brasileiro (Brasiliansk Portugisiska)", + "ca": "Català (Katalanska)", + "sr": "Српски (Serbiska)", + "sl": "Slovenščina (Slovenska)", + "lt": "Lietuvių (Litauiska)", + "bg": "Български (Bulgariska)", + "gl": "Galego (Galiciska)", + "id": "Bahasa Indonesia (Indonesiska)", + "ur": "اردو (Urdu)" }, "darkMode": { "withSystem": { @@ -253,5 +262,6 @@ "meters": "meter" } }, - "selectItem": "Välj {{item}}" + "selectItem": "Välj {{item}}", + "readTheDocumentation": "Läs dokumentationen" } diff --git a/web/public/locales/sv/components/camera.json b/web/public/locales/sv/components/camera.json index f4a3448dbe..23de9471f6 100644 --- a/web/public/locales/sv/components/camera.json +++ b/web/public/locales/sv/components/camera.json @@ -62,7 +62,8 @@ "label": "Kompatibilitetsläge", "desc": "Aktivera endast det här alternativet om kamerans livestream visar färgartefakter och har en diagonal linje på höger sida av bilden." } - } + }, + "birdseye": "Fågelöga" }, "cameras": { "desc": "Välj kameror för denna guppen.", diff --git a/web/public/locales/sv/components/dialog.json b/web/public/locales/sv/components/dialog.json index 42af6ea41f..e41779b172 100644 --- a/web/public/locales/sv/components/dialog.json +++ b/web/public/locales/sv/components/dialog.json @@ -3,21 +3,26 @@ "button": "Starta om", "restarting": { "title": "Frigate startar om", - "content": "Sidan uppdateras om {{countdown}} seconds.", - "button": "Tvinga uppdatering nu" + "content": "Sidan uppdateras om {{countdown}} sekunder.", + "button": "Tvinga omladdning nu" }, "title": "Är du säker på att du vill starta om Frigate?" }, "explore": { "plus": { "submitToPlus": { - "label": "Skicka till Frigate+" + "label": "Skicka till Frigate+", + "desc": "Objekt på platser du vill undvika är inte falska positiva resultat. Att skicka in dem som falska positiva resultat kommer att förvirra modellen." }, "review": { "question": { "ask_a": "Är detta objektet {{label}}?", "ask_an": "Är detta objektet en {{label}}?", - "ask_full": "Är detta objektet {{untranslatedLabel}} ({{translatedLabel}})?" + "ask_full": "Är detta objektet {{untranslatedLabel}} ({{translatedLabel}})?", + "label": "Bekräfta denna etikett för Frigate Plus" + }, + "state": { + "submitted": "Inskickad" } } }, @@ -37,12 +42,79 @@ "end": { "title": "Slut Tid", "label": "Välj Sluttid" - } + }, + "custom": "Anpassad" }, "name": { "placeholder": "Ge exporten ett namn" }, "select": "Välj", - "export": "Eksport" + "export": "Eksport", + "selectOrExport": "Välj eller exportera", + "toast": { + "success": "Exporten har startats. Visa filen i mappen /exports.", + "error": { + "failed": "Misslyckades med att starta exporten: {{error}}", + "endTimeMustAfterStartTime": "Sluttiden måste vara efter starttiden", + "noVaildTimeSelected": "Inget giltigt tidsintervall valt" + } + }, + "fromTimeline": { + "saveExport": "Spara export", + "previewExport": "Förhandsgranska export" + } + }, + "streaming": { + "label": "Videoström", + "restreaming": { + "disabled": "Omströmning är inte aktiverad för den här kameran.", + "desc": { + "title": "Konfigurera go2rtc för ytterligare livevisningsalternativ och ljud för den här kameran.", + "readTheDocumentation": "Läs dokumentationen" + } + }, + "showStats": { + "label": "Visa strömstatistik", + "desc": "Aktivera det här alternativet för att visa strömstatistik som ett överlägg över kameraflödet." + }, + "debugView": "Felsöknings vy" + }, + "search": { + "saveSearch": { + "overwrite": "{{searchName}} finns redan. Om du sparar skrivs det befintliga värdet över.", + "success": "Sökningen ({{searchName}}) har sparats.", + "button": { + "save": { + "label": "Spara den här sökningen" + } + }, + "label": "Spara Sökning", + "desc": "Ange ett namn för den här sparade sökningen.", + "placeholder": "Ange ett namn för din sökning" + } + }, + "recording": { + "confirmDelete": { + "title": "Bekräfta radering", + "desc": { + "selected": "Är du säker på att du vill radera all inspelad video som är kopplad till det här granskningsobjektet?

    Håll ner Shift-tangenten för att hoppa över den här dialogrutan i framtiden." + }, + "toast": { + "success": "Videoklipp som är kopplade till de valda granskningsobjekten har raderats.", + "error": "Misslyckades med att ta bort: {{error}}" + } + }, + "button": { + "export": "Exportera", + "markAsReviewed": "Markera som granskad", + "deleteNow": "Ta bort nu" + } + }, + "imagePicker": { + "selectImage": "Välj miniatyrbilden för ett spårat objekt", + "search": { + "placeholder": "Sök efter etikett eller underetikett..." + }, + "noImages": "Inga miniatyrbilder hittades för den här kameran" } } diff --git a/web/public/locales/sv/components/filter.json b/web/public/locales/sv/components/filter.json index c29110cc77..90eda7c5a1 100644 --- a/web/public/locales/sv/components/filter.json +++ b/web/public/locales/sv/components/filter.json @@ -9,17 +9,22 @@ "count_one": "{{count}} Etikett", "count_other": "{{count}} Etiketter" }, - "filter": "Filter", + "filter": "Filtrera", "zones": { "label": "Zoner", "all": { "title": "Alla zoner", - "short": "Soner" + "short": "Zoner" } }, "features": { "hasSnapshot": "Har ögonblicksbild", - "hasVideoClip": "Har ett video klipp" + "hasVideoClip": "Har ett video klipp", + "submittedToFrigatePlus": { + "label": "Skickat till Frigate+", + "tips": "Du måste först filtrera på spårade objekt som har en ögonblicksbild.

    Spårade objekt utan ögonblicksbild kan inte skickas till Frigate+." + }, + "label": "Detaljer" }, "sort": { "dateAsc": "Datum (Stigande)", @@ -42,12 +47,22 @@ "settings": { "title": "Inställningar", "defaultView": { - "title": "Standard Vy" + "title": "Standard Vy", + "summary": "Sammanfattning", + "desc": "När inga filter är valda, visa en översikt av de senaste spårade objekten per etikett-typ eller visa ett ofiltrerat rutnät.", + "unfilteredGrid": "Ofiltrerat Rutnät" }, "searchSource": { "options": { - "description": "Beskrivning" - } + "description": "Beskrivning", + "thumbnailImage": "Miniatyrbild" + }, + "label": "Sökkälla", + "desc": "Välj om du vill söka miniatyrbilderna eller beskrivningarna av de spårade objekten." + }, + "gridColumns": { + "desc": "Välj antal kolumner i rutnätsvy.", + "title": "Kolumner i Rutnät" } }, "date": { @@ -67,11 +82,18 @@ "all": { "short": "Datum", "title": "Alla datum" - } + }, + "selectPreset": "Välj Förval…" }, "recognizedLicensePlates": { "noLicensePlatesFound": "Inga registreringsplåtar hittade.", - "selectPlatesFromList": "Välj en eller flera registreringsplåtar från listan." + "selectPlatesFromList": "Välj en eller flera registreringsplåtar från listan.", + "title": "Igenkända Registreringsskyltar", + "loadFailed": "Misslyckades med att ladda igenkända registreringsskyltar.", + "placeholder": "Skriv för att söka registreringsskyltar…", + "loading": "Laddar igenkända registreringsskyltar…", + "selectAll": "Välj alla", + "clearAll": "Rensa alla" }, "more": "Flera filter", "reset": { @@ -81,5 +103,35 @@ "label": "Under kategori", "all": "Alla under kategorier" }, - "estimatedSpeed": "Estimerad hastighet ({{unit}})" + "estimatedSpeed": "Estimerad hastighet ({{unit}})", + "classes": { + "all": { + "title": "Alla Klasser" + }, + "count_one": "{{count}} Klass", + "count_other": "{{count}} Klasser", + "label": "Klasser" + }, + "timeRange": "Tidsspann", + "logSettings": { + "loading": { + "title": "Laddar", + "desc": "När loggvyn är rullad till slutet, strömmas automatiskt nya loggar till vyn." + }, + "filterBySeverity": "Filtrera logg på allvarlighetsgrad", + "disableLogStreaming": "Inaktivera strömning av logg", + "allLogs": "Alla loggar", + "label": "Filter loggnivå" + }, + "trackedObjectDelete": { + "title": "Bekräfta Borttagning", + "toast": { + "success": "Spårade objekt borttagna.", + "error": "Misslyckades med att ta bort spårade objekt: {{errorMessage}}" + }, + "desc": "Borttagning av dessa {{objectLength}} spårade objekt tar bort ögonblicksbild, sparade inbäddningar, och tillhörande livscykelposter. Inspelat material av dessa spårade objekt i Historievyn kommer INTE att tas bort.

    Vill du verkligen fortsätta?

    Håll ner Skift-tangenten för att hoppa över denna dialog i framtiden." + }, + "zoneMask": { + "filterBy": "Filtrera på zonmaskering" + } } diff --git a/web/public/locales/sv/components/icons.json b/web/public/locales/sv/components/icons.json index e15428582c..afdcfb7d9e 100644 --- a/web/public/locales/sv/components/icons.json +++ b/web/public/locales/sv/components/icons.json @@ -1,7 +1,7 @@ { "iconPicker": { "search": { - "placeholder": "Sök efter ikon…" + "placeholder": "Sök efter en ikon…" }, "selectIcon": "Välj en ikon" } diff --git a/web/public/locales/sv/components/player.json b/web/public/locales/sv/components/player.json index b41c5dd65c..24f1b1e962 100644 --- a/web/public/locales/sv/components/player.json +++ b/web/public/locales/sv/components/player.json @@ -39,7 +39,7 @@ "decodedFrames": "Avkodade bildrutor:", "droppedFrameRate": "Frekvens för bortfallna bildrutor:" }, - "cameraDisabled": "Kameran är disablead", + "cameraDisabled": "Kameran är inaktiverad", "toast": { "error": { "submitFrigatePlusFailed": "Bildruta har skickats till Frigate+ med misslyckat resultat" diff --git a/web/public/locales/sv/objects.json b/web/public/locales/sv/objects.json index 4b4da2cf98..73133105c5 100644 --- a/web/public/locales/sv/objects.json +++ b/web/public/locales/sv/objects.json @@ -80,7 +80,7 @@ "desk": "Skrivbord", "toilet": "Toalett", "tv": "TV", - "laptop": "Laptop", + "laptop": "Bärbar dator", "remote": "Fjärrkontroll", "keyboard": "Tangentbord", "cell_phone": "Mobiltelefon", @@ -110,7 +110,7 @@ "plate": "Tallrik", "door": "Dörr", "oven": "Ugn", - "blender": "Mixer", + "blender": "Blandare", "book": "Bok", "waste_bin": "Papperskorg", "license_plate": "Nummerplåt", diff --git a/web/public/locales/sv/views/configEditor.json b/web/public/locales/sv/views/configEditor.json index 27409c9689..7b96ff9fe8 100644 --- a/web/public/locales/sv/views/configEditor.json +++ b/web/public/locales/sv/views/configEditor.json @@ -12,5 +12,7 @@ }, "documentTitle": "Ändra konfiguration - Frigate", "configEditor": "Ändra konfiguration", - "confirm": "Avsluta utan att spara?" + "confirm": "Avsluta utan att spara?", + "safeConfigEditor": "Konfigurationsredigeraren (felsäkert läge)", + "safeModeDescription": "Fregate är i felsäkert läge på grund av ett konfigurationsvalideringsfel." } diff --git a/web/public/locales/sv/views/events.json b/web/public/locales/sv/views/events.json index 9536f9b3d2..89b9d1dd3b 100644 --- a/web/public/locales/sv/views/events.json +++ b/web/public/locales/sv/views/events.json @@ -34,5 +34,7 @@ "markTheseItemsAsReviewed": "Markera dessa objekt som granskade", "detected": "upptäckt", "selected_one": "{{count}} valda", - "selected_other": "{{count}} valda" + "selected_other": "{{count}} valda", + "suspiciousActivity": "Misstänkt aktivitet", + "threateningActivity": "Hotande aktivitet" } diff --git a/web/public/locales/sv/views/explore.json b/web/public/locales/sv/views/explore.json index e8cecd73fb..d4f3db8f95 100644 --- a/web/public/locales/sv/views/explore.json +++ b/web/public/locales/sv/views/explore.json @@ -5,25 +5,222 @@ "embeddingsReindexing": { "startingUp": "Startar upp…", "estimatedTime": "Beräknad återstående tid:", - "finishingShortly": "Snart klar" + "finishingShortly": "Snart klar", + "context": "Utforskaren kan användas efter inbäddade spårade objekt har slutat återindexerat.", + "step": { + "thumbnailsEmbedded": "Miniatyrbilder inbäddad: ", + "descriptionsEmbedded": "Beskrivningar inbäddade: ", + "trackedObjectsProcessed": "Spårade objekt bearbetad: " + } }, "title": "Utforska är inte tillgänglig", "downloadingModels": { "setup": { - "textModel": "Text modell" + "textModel": "Text modell", + "visionModel": "Visionsmodell", + "visionModelFeatureExtractor": "Funktionsutdragare för visionsmodell", + "textTokenizer": "Texttokeniserare" }, "tips": { - "documentation": "Läs dokumentationen" + "documentation": "Läs dokumentationen", + "context": "Du kanske vill omindexera inbäddningarna av dina spårade objekt när modellerna har laddats ner." }, - "error": "Ett fel har inträffat. Kontrollera Frigate loggarna." + "error": "Ett fel har inträffat. Kontrollera Frigate loggarna.", + "context": "Frigate laddar ner de nödvändiga inbäddningsmodellerna för att stödja den semantiska sökfunktionen. Detta kan ta flera minuter beroende på hastigheten på din nätverksanslutning." } }, "details": { - "timestamp": "tidsstämpel" + "timestamp": "tidsstämpel", + "item": { + "title": "Granska objektinformation", + "desc": "Granska objektinformation", + "button": { + "share": "Dela den här recensionen", + "viewInExplore": "Visa i Utforska" + }, + "tips": { + "mismatch_one": "{{count}} otillgängligt objekt upptäcktes och inkluderades i detta granskningsobjekt. Dessa objekt kvalificerade sig antingen inte som en varning eller detektering, eller så har de redan rensats/raderats.", + "mismatch_other": "{{count}} otillgängliga objekt upptäcktes och inkluderades i detta granskningsobjekt. Dessa objekt kvalificerade sig antingen inte som en varning eller upptäckt, eller så har de redan rensats/raderats.", + "hasMissingObjects": "Justera din konfiguration om du vill att Frigate ska spara spårade objekt för följande etiketter: {{objects}}" + }, + "toast": { + "success": { + "regenerate": "En ny beskrivning har begärts från {{provider}}. Beroende på din leverantörs hastighet kan det ta lite tid att generera den nya beskrivningen.", + "updatedSublabel": "Underetiketten har uppdaterats.", + "updatedLPR": "Nummerplåt har uppdaterats.", + "audioTranscription": "Ljudtranskription har begärts." + }, + "error": { + "regenerate": "Kunde inte ringa {{provider}} för en ny beskrivning: {{errorMessage}}", + "updatedSublabelFailed": "Misslyckades med att uppdatera underetiketten: {{errorMessage}}", + "audioTranscription": "Misslyckades med att begära ljudtranskription: {{errorMessage}}", + "updatedLPRFailed": "Misslyckades med att uppdatera nummerplåten: {{errorMessage}}" + } + } + }, + "label": "Märka", + "editSubLabel": { + "title": "Redigera underetikett", + "desc": "Ange en ny underetikett för denna {{label}}", + "descNoLabel": "Ange en ny underetikett för det här spårade objektet" + }, + "editLPR": { + "title": "Redigera nummerplåt", + "desc": "Ange ett nytt nummerplåt för denna {{label}}", + "descNoLabel": "Ange ett nytt nummerplåt för detta spårade objekt" + }, + "snapshotScore": { + "label": "Ögonblicksbildspoäng" + }, + "topScore": { + "label": "Högsta poäng", + "info": "Topppoängen är den högsta medianpoängen för det spårade objektet, så denna kan skilja sig från poängen som visas på miniatyrbilden av sökresultatet." + }, + "score": { + "label": "Poäng" + }, + "recognizedLicensePlate": "Erkänd nummerplåt", + "estimatedSpeed": "Uppskattad hastighet", + "objects": "Objekt", + "camera": "Kamera", + "zones": "Zoner", + "button": { + "findSimilar": "Hitta liknande", + "regenerate": { + "title": "Regenerera", + "label": "Återskapa beskrivningen av spårat objekt" + } + }, + "description": { + "label": "Beskrivning", + "placeholder": "Beskrivning av det spårade objektet", + "aiTips": "Frigate kommer inte att begära en beskrivning från din generativa AI-leverantör förrän det spårade objektets livscykel har avslutats." + }, + "expandRegenerationMenu": "Expandera regenereringsmenyn", + "regenerateFromSnapshot": "Återskapa från ögonblicksbild", + "regenerateFromThumbnails": "Återskapa från miniatyrbilder", + "tips": { + "descriptionSaved": "Beskrivningen har sparats", + "saveDescriptionFailed": "Misslyckades med att uppdatera beskrivningen: {{errorMessage}}" + } }, "exploreMore": "Utforska fler {{label}} objekt", "type": { "details": "detaljer", - "video": "video" + "video": "video", + "snapshot": "ögonblicksbild", + "object_lifecycle": "objektets livscykel" + }, + "trackedObjectDetails": "Detaljer om spårade objekt", + "objectLifecycle": { + "title": "Objektets livscykel", + "noImageFound": "Ingen bild hittades för denna tidsstämpel.", + "createObjectMask": "Skapa objektmask", + "adjustAnnotationSettings": "Justera annoteringsinställningar", + "scrollViewTips": "Scrolla för att se de viktiga ögonblicken i detta objekts livscykel.", + "autoTrackingTips": "Begränsningsrutornas positioner kommer att vara felaktiga för autospårningskameror.", + "count": "{{first}} av {{second}}", + "lifecycleItemDesc": { + "external": "{{label}} upptäckt", + "header": { + "zones": "Zoner", + "ratio": "Proportion", + "area": "Område" + }, + "visible": "{{label}} upptäckt", + "entered_zone": "{{label}} gick in i {{zones}}", + "active": "{{label}} blev aktiv", + "stationary": "{{label}} blev stationär", + "attribute": { + "faceOrLicense_plate": "{{attribute}} upptäckt för {{label}}", + "other": "{{label}} igenkänd som {{attribute}}" + }, + "gone": "{{label}} vänster", + "heard": "{{label}} hört" + }, + "annotationSettings": { + "title": "Annoteringsinställningar", + "showAllZones": { + "title": "Visa alla zoner", + "desc": "Visa alltid zoner på ramar där objekt har kommit in i en zon." + }, + "offset": { + "label": "Annoteringsförskjutning", + "desc": "Denna data kommer från din kameras detekteringsflöde men läggs ovanpå bilder från inspelningsflödet. Det är osannolikt att de två strömmarna är helt synkroniserade. Som ett resultat kommer avgränsningsramen och filmmaterialet inte att radas upp perfekt. Fältet annotation_offset kan dock användas för att justera detta.", + "documentation": "Läs dokumentationen ", + "millisecondsToOffset": "Millisekunder för att förskjuta detektera annoteringar med. Standard: 0", + "tips": "TIPS: Föreställ dig ett händelseklipp med en person som går från vänster till höger. Om tidslinjens avgränsningsram konsekvent är till vänster om personen bör värdet minskas. På samma sätt, om en person går från vänster till höger och avgränsningsramen konsekvent är framför personen bör värdet ökas.", + "toast": { + "success": "Annoterings förskjutningen för {{camera}} har sparats i konfigurationsfilen. Starta om Frigate för att tillämpa dina ändringar." + } + } + }, + "trackedPoint": "Spårad punkt", + "carousel": { + "previous": "Föregående bild", + "next": "Nästa bild" + } + }, + "itemMenu": { + "downloadVideo": { + "label": "Ladda ner video", + "aria": "Ladda ner video" + }, + "downloadSnapshot": { + "label": "Ladda ner ögonblicksbild", + "aria": "Ladda ner ögonblicksbild" + }, + "viewObjectLifecycle": { + "label": "Visa objektets livscykel", + "aria": "Visa objektets livscykel" + }, + "findSimilar": { + "label": "Hitta liknande", + "aria": "Hitta liknande spårade objekt" + }, + "addTrigger": { + "label": "Lägg till utlösare", + "aria": "Lägg till en utlösare för det här spårade objektet" + }, + "audioTranscription": { + "label": "Transkribera", + "aria": "Begär ljudtranskribering" + }, + "submitToPlus": { + "label": "Skicka till Frigate+", + "aria": "Skicka till Frigate Plus" + }, + "viewInHistory": { + "label": "Visa i historik", + "aria": "Visa i historik" + }, + "deleteTrackedObject": { + "label": "Ta bort det här spårade objektet" + } + }, + "dialog": { + "confirmDelete": { + "title": "Bekräfta radering", + "desc": "Om du tar bort det här spårade objektet tas bort ögonblicksbilden, alla sparade inbäddningar och alla tillhörande objektlivscykelposter bort. Inspelade bilder av det här spårade objektet i historikvyn kommer INTE att raderas.

    Är du säker på att du vill fortsätta?" + } + }, + "noTrackedObjects": "Inga spårade objekt hittades", + "fetchingTrackedObjectsFailed": "Fel vid hämtning av spårade objekt: {{errorMessage}}", + "trackedObjectsCount_one": "{{count}} spårat objekt ", + "trackedObjectsCount_other": "{{count}} spårade objekt ", + "searchResult": { + "tooltip": "Matchade {{type}} vid {{confidence}}%", + "deleteTrackedObject": { + "toast": { + "success": "Spårat objekt har raderats.", + "error": "Misslyckades med att ta bort spårat objekt: {{errorMessage}}" + } + } + }, + "aiAnalysis": { + "title": "AI-analys" + }, + "concerns": { + "label": "Oro" } } diff --git a/web/public/locales/sv/views/faceLibrary.json b/web/public/locales/sv/views/faceLibrary.json index 763f7533cb..7bb84d1678 100644 --- a/web/public/locales/sv/views/faceLibrary.json +++ b/web/public/locales/sv/views/faceLibrary.json @@ -4,8 +4,10 @@ "confidence": "Säkerhet", "face": "Ansiktsdetaljer", "timestamp": "tidsstämpel", - "faceDesc": "Detaljer för ansiktet och tillhörande objekt", - "unknown": "Okänt" + "faceDesc": "Detaljer om det spårade objektet som genererade detta ansikte", + "unknown": "Okänt", + "subLabelScore": "Underetikettpoäng", + "scoreInfo": "Underetikettpoängen är den viktade poängen för alla igenkända ansiktskonfidenser, så detta kan skilja sig från poängen som visas på ögonblicksbilden." }, "description": { "placeholder": "Ange ett namn för denna samling", @@ -16,7 +18,10 @@ "steps": { "faceName": "Ange namn", "uploadFace": "Ladda upp bild på ansikte", - "nextSteps": "Nästa steg" + "nextSteps": "Nästa steg", + "description": { + "uploadFace": "Ladda upp en bild på {{name}} som visar deras ansikte framifrån. Bilden behöver inte beskäras till bara deras ansikte." + } }, "createFaceLibrary": { "title": "Skapa samling", @@ -25,12 +30,71 @@ "new": "Skapa nytt ansikte" }, "train": { - "title": "Träna" + "title": "Träna", + "aria": "Välj träna", + "empty": "Det finns inga ny försök till ansiktsigenkänning" }, "uploadFaceImage": { "title": "Ladda upp ansiktsbild", "desc": "Ladda upp en bild för att skanna efter ansikte och inkludera {{pageToggle}}" }, "selectItem": "Välj {{item}}", - "collections": "Samlingar" + "collections": "Samlingar", + "selectFace": "Välj ansikte", + "deleteFaceLibrary": { + "title": "Ta bort namn", + "desc": "Är du säker på att du vill ta bort samlingen {{name}}? Detta kommer att ta bort alla associerade ansikten permanent." + }, + "deleteFaceAttempts": { + "title": "Ta bort ansikten", + "desc_one": "Är du säker på att du vill ta bort {{count}} ansikte? Den här åtgärden kan inte ångras.", + "desc_other": "Är du säker på att du vill ta bort {{count}} ansikten? Den här åtgärden kan inte ångras." + }, + "imageEntry": { + "dropActive": "Släpp bilden här…", + "dropInstructions": "Dra och släpp en bild här, eller klicka för att välja", + "maxSize": "Maxstorlek: {{size}}MB", + "validation": { + "selectImage": "Välj en bildfil." + } + }, + "nofaces": "Inga ansikten tillgängliga", + "pixels": "{{area}}px", + "readTheDocs": "Läs dokumentationen", + "trainFaceAs": "Träna ansikte som:", + "trainFace": "Träna ansikte", + "toast": { + "success": { + "uploadedImage": "Bilden har laddats upp.", + "addFaceLibrary": "{{name}} har lagts till i ansiktsbiblioteket!", + "deletedFace_one": "{{count}} ansikte har raderats.", + "deletedFace_other": "{{count}} ansikten har raderats.", + "deletedName_one": "{{count}} ansikte har raderats.", + "deletedName_other": "{{count}} ansikten har raderats.", + "renamedFace": "Ansiktet har bytt namn till {{name}}", + "trainedFace": "Ansikte är tränant.", + "updatedFaceScore": "Ansikts poängen har uppdaterats." + }, + "error": { + "uploadingImageFailed": "Misslyckades med att ladda upp bilden: {{errorMessage}}", + "addFaceLibraryFailed": "Misslyckades med att ange ansiktsnamn: {{errorMessage}}", + "deleteFaceFailed": "Misslyckades med att ta bort: {{errorMessage}}", + "deleteNameFailed": "Misslyckades med att ta bort namnet: {{errorMessage}}", + "renameFaceFailed": "Misslyckades med att byta namn på ansikte: {{errorMessage}}", + "trainFailed": "Misslyckades med att träna: {{errorMessage}}", + "updateFaceScoreFailed": "Misslyckades med att uppdatera ansiktspoäng: {{errorMessage}}" + } + }, + "renameFace": { + "title": "Byt namn på ansikte", + "desc": "Ange ett nytt namn för {{name}}" + }, + "button": { + "deleteFaceAttempts": "Ta bort ansikten", + "addFace": "Lägg till ansikte", + "renameFace": "Byt namn på ansikte", + "deleteFace": "Ta bort ansikte", + "uploadImage": "Ladda upp bild", + "reprocessFace": "Återbearbeta ansiktet" + } } diff --git a/web/public/locales/sv/views/live.json b/web/public/locales/sv/views/live.json index ff6d2a4c2c..25fb12598d 100644 --- a/web/public/locales/sv/views/live.json +++ b/web/public/locales/sv/views/live.json @@ -2,8 +2,8 @@ "documentTitle": "Live - Frigate", "documentTitle.withCamera": "{{camera}} - Live - Frigate", "twoWayTalk": { - "enable": "Aktivera Two Way Talk", - "disable": "Avaktivera Two Way Talk" + "enable": "Aktivera tvåvägssamtal", + "disable": "Avaktivera tvåvägssamtal" }, "cameraAudio": { "disable": "Inaktivera kameraljud", @@ -42,7 +42,15 @@ "label": "Klicka i bilden för att centrera PTZ kamera" } }, - "presets": "PTZ kamera förinställningar" + "presets": "PTZ kamera förinställningar", + "focus": { + "in": { + "label": "Fokusera PTZ-kameran in" + }, + "out": { + "label": "Fokusera PTZ-kameran ut" + } + } }, "streamStats": { "enable": "Visa videostatistik", @@ -98,7 +106,8 @@ "objectDetection": "Objektsdetektering", "recording": "Inspelning", "snapshots": "Ögonblicksbilder", - "autotracking": "Autospårning" + "autotracking": "Autospårning", + "transcription": "Ljudtranskription" }, "effectiveRetainMode": { "modes": { @@ -154,5 +163,9 @@ }, "history": { "label": "Visa historiskt videomaterial" + }, + "transcription": { + "enable": "Aktivera live-ljudtranskription", + "disable": "Inaktivera live-ljudtranskription" } } diff --git a/web/public/locales/sv/views/recording.json b/web/public/locales/sv/views/recording.json index 6e9e231a3e..b4bfaf2ec9 100644 --- a/web/public/locales/sv/views/recording.json +++ b/web/public/locales/sv/views/recording.json @@ -1,6 +1,6 @@ { "export": "Export", - "filter": "Filter", + "filter": "Filtrera", "calendar": "Kalender", "filters": "Filter", "toast": { diff --git a/web/public/locales/sv/views/settings.json b/web/public/locales/sv/views/settings.json index df1de30e0c..1d477e6451 100644 --- a/web/public/locales/sv/views/settings.json +++ b/web/public/locales/sv/views/settings.json @@ -8,7 +8,9 @@ "masksAndZones": "Maskerings- och zonverktyg - Frigate", "enrichments": "Förbättringsinställningar - Frigate", "frigatePlus": "Frigate+ Inställningar - Frigate", - "notifications": "Notifikations Inställningar - Frigate" + "notifications": "Notifikations Inställningar - Frigate", + "motionTuner": "Rörelse inställning - Frigate", + "object": "Felsöka - Frigate" }, "general": { "title": "Allmänna Inställningar", @@ -44,7 +46,8 @@ "firstWeekday": { "sunday": "Söndag", "monday": "Måndag", - "label": "Första Veckodag" + "label": "Första Veckodag", + "desc": "Den dag då veckorna i översynskalendern börjar." }, "title": "Kalender" }, @@ -66,23 +69,752 @@ "enrichments": { "unsavedChanges": "Osparade Förbättringsinställningar", "birdClassification": { - "title": "Fågel klassificering" + "title": "Fågel klassificering", + "desc": "Fågelklassificering identifierar kända fåglar med hjälp av en kvantiserad Tensorflow-modell. När en känd fågel känns igen läggs dess vanliga namn till som en underetikett. Denna information inkluderas i användargränssnittet, filter och i aviseringar." }, - "title": "Förbättringsinställningar" + "title": "Förbättringsinställningar", + "semanticSearch": { + "title": "Semantisk sökning", + "desc": "Semantisk sökning i Frigate låter dig hitta spårade objekt i dina granskningsobjekt med hjälp av antingen själva bilden, en användardefinierad textbeskrivning eller en automatiskt genererad.", + "readTheDocumentation": "Läs dokumentationen", + "reindexNow": { + "label": "Omindexera nu", + "desc": "Omindexering kommer att generera inbäddningar för alla spårade objekt. Den här processen körs i bakgrunden och kan maximera din CPU och ta en hel del tid beroende på antalet spårade objekt du har.", + "confirmTitle": "Bekräfta omindexering", + "confirmDesc": "Är du säker på att du vill omindexera alla spårade objektinbäddningar? Den här processen körs i bakgrunden men den kan maximera din processor och ta en hel del tid. Du kan se förloppet på Utforska-sidan.", + "confirmButton": "Omindexera", + "success": "Omindexeringen har startat.", + "alreadyInProgress": "Omindexering pågår redan.", + "error": "Misslyckades med att starta omindexering: {{errorMessage}}" + }, + "modelSize": { + "label": "Modellstorlek", + "desc": "Storleken på modellen som används för semantiska sökinbäddningar.", + "small": { + "title": "små", + "desc": "Att använda small använder en kvantiserad version av modellen som använder mindre RAM och körs snabbare på CPU med en mycket försumbar skillnad i inbäddningskvalitet." + }, + "large": { + "title": "stor", + "desc": "Att använda large använder hela Jina-modellen och körs automatiskt på GPU:n om tillämpligt." + } + } + }, + "faceRecognition": { + "desc": "Ansiktsigenkänning gör att personer kan tilldelas namn och när deras ansikte känns igen kommer Frigate att tilldela personens namn som en underetikett. Denna information finns i användargränssnittet, filter och i aviseringar.", + "readTheDocumentation": "Läs dokumentationen", + "modelSize": { + "label": "Modellstorlek", + "desc": "Storleken på modellen som används för ansiktsigenkänning.", + "small": { + "title": "små", + "desc": "Att använda small använder en FaceNet-modell för ansiktsinbäddning som körs effektivt på de flesta processorer." + }, + "large": { + "title": "stor", + "desc": "Att använda large använder en ArcFace-modell för ansiktsinbäddning och körs automatiskt på GPU:n om tillämpligt." + } + }, + "title": "Ansikts igenkänning" + }, + "licensePlateRecognition": { + "title": "Nummerplåt Erkännande", + "desc": "Frigate kan känna igen nummerplåt på fordon och automatiskt lägga till de upptäckta tecknen i fältet recognized_license_plate eller ett känt namn som en underetikett till objekt av typen bil. Ett vanligt användningsfall kan vara att läsa nummerplåtor på bilar som kör in på en uppfart eller bilar som passerar på en gata.", + "readTheDocumentation": "Läs dokumentationen" + }, + "restart_required": "Omstart krävs (berikningsinställningar har ändrats)", + "toast": { + "success": "Inställningarna för berikning har sparats. Starta om Frigate för att tillämpa dina ändringar.", + "error": "Kunde inte spara konfigurationsändringarna: {{errorMessage}}" + } }, "menu": { - "ui": "UI", + "ui": "Användargränssnitt", "cameras": "Kamera Inställningar", "masksAndZones": "Masker / Områden", "users": "Användare", "notifications": "Notifikationer", "frigateplus": "Frigate+", - "enrichments": "Förbättringar" + "enrichments": "Förbättringar", + "motionTuner": "Rörelsemottagare", + "debug": "Felsök", + "triggers": "Utlösare" }, "dialog": { "unsavedChanges": { "title": "Du har osparade ändringar.", "desc": "Vill du spara dina ändringar innan du fortsätter?" } + }, + "camera": { + "title": "Kamera inställningar", + "streams": { + "title": "Videoströmmar", + "desc": "Inaktivera tillfälligt en kamera tills Frigate startar om. Om du inaktiverar en kamera helt stoppas Frigates bearbetning av kamerans strömmar. Detektering, inspelning och felsökning kommer inte att vara tillgängliga.
    Obs! Detta inaktiverar inte go2rtc-återströmmar." + }, + "object_descriptions": { + "title": "Generativa AI-objektbeskrivningar", + "desc": "Aktivera/inaktivera tillfälligt generativa AI-objektbeskrivningar för den här kameran. När den är inaktiverad kommer AI-genererade beskrivningar inte att begäras för spårade objekt på den här kameran." + }, + "review_descriptions": { + "title": "Beskrivningar av generativa AI-granskningar", + "desc": "Aktivera/inaktivera tillfälligt genererade AI-granskningsbeskrivningar för den här kameran. När det är inaktiverat kommer AI-genererade beskrivningar inte att begäras för granskningsobjekt på den här kameran." + }, + "review": { + "title": "Recensera", + "desc": "Tillfälligt Aktivera/avaktivera varningar och detekteringar för den här kameran tills Frigate startar om. När den är avaktiverad genereras inga nya granskningsobjekt. ", + "alerts": "Aviseringar ", + "detections": "Detektioner " + }, + "reviewClassification": { + "title": "Granska klassificering", + "desc": "Frigate kategoriserar granskningsobjekt som varningar och detekteringar. Som standard betraktas alla person- och bil-objekt som varningar. Du kan förfina kategoriseringen av dina granskningsobjekt genom att konfigurera obligatoriska zoner för dem.", + "noDefinedZones": "Inga zoner är definierade för den här kameran.", + "objectAlertsTips": "Alla {{alertsLabels}}-objekt på {{cameraName}} kommer att visas som varningar.", + "zoneObjectAlertsTips": "Alla {{alertsLabels}} objekt som upptäcks i {{zone}} på {{cameraName}} kommer att visas som varningar.", + "objectDetectionsTips": "Alla {{detectionsLabels}}-objekt som inte kategoriseras på {{cameraName}} kommer att visas som detektioner oavsett vilken zon de befinner sig i.", + "zoneObjectDetectionsTips": { + "text": "Alla {{detectionsLabels}}-objekt som inte kategoriseras i {{zone}} på {{cameraName}} kommer att visas som detektioner.", + "notSelectDetections": "Alla {{detectionsLabels}} objekt som upptäckts i {{zone}} på {{cameraName}} och som inte kategoriserats som varningar kommer att visas som detekteringar oavsett vilken zon de befinner sig i.", + "regardlessOfZoneObjectDetectionsTips": "Alla {{detectionsLabels}}-objekt som inte kategoriseras på {{cameraName}} kommer att visas som detektioner oavsett vilken zon de befinner sig i." + }, + "unsavedChanges": "Osparade inställningar för granskningsklassificering för {{camera}}", + "selectAlertsZones": "Välj zoner för aviseringar", + "selectDetectionsZones": "Välj zoner för detektioner", + "limitDetections": "Begränsa detektioner till specifika zoner", + "toast": { + "success": "Konfigurationen för granskning av klassificering har sparats. Starta om Frigate för att tillämpa ändringarna." + } + }, + "addCamera": "Lägg till ny kamera", + "editCamera": "Redigera kamera:", + "selectCamera": "Välj en kamera", + "backToSettings": "Tillbaka till kamera inställningar", + "cameraConfig": { + "add": "Lägg till kamera", + "edit": "Redigera kamera", + "description": "Konfigurera kamerainställningar inklusive strömingångar och roller.", + "name": "Kamera namn", + "nameRequired": "Kamera namn krävs", + "nameInvalid": "Kamera namnet får endast innehålla bokstäver, siffror, understreck, eller bindestreck", + "namePlaceholder": "t.ex. fram_dörr", + "enabled": "Aktiverad", + "ffmpeg": { + "inputs": "Ingångsströmmar", + "path": "Strömväg", + "pathRequired": "Strömningsväg krävs", + "pathPlaceholder": "rtsp://...", + "roles": "Roller", + "rolesRequired": "Minst en roll krävs", + "rolesUnique": "Varje roll (ljud, detektering, inspelning) kan bara tilldelas en ström", + "addInput": "Lägg till inmatningsström", + "removeInput": "Ta bort inmatningsström", + "inputsRequired": "Minst en indataström krävs" + }, + "toast": { + "success": "Kamera {{cameraName}} sparades" + }, + "nameLength": "Namnet på kameran måste vara kortare än 24 tecken." + } + }, + "masksAndZones": { + "filter": { + "all": "Alla masker och zoner" + }, + "restart_required": "Omstart krävs (masker/zoner har ändrats)", + "toast": { + "success": { + "copyCoordinates": "Kopierade koordinaterna för {{polyName}} till urklipp." + }, + "error": { + "copyCoordinatesFailed": "Kunde inte kopiera koordinaterna till urklipp." + } + }, + "motionMaskLabel": "Rörelsemask {{number}}", + "objectMaskLabel": "Objektmask {{number}} ({{label}})", + "form": { + "zoneName": { + "error": { + "mustBeAtLeastTwoCharacters": "Zonnamnet måste vara minst 2 tecken långt.", + "mustNotBeSameWithCamera": "Zonnamnet får inte vara detsamma som kameranamnet.", + "alreadyExists": "En zon med detta namn finns redan för den här kameran.", + "mustNotContainPeriod": "Zonnamnet får inte innehålla punkter.", + "hasIllegalCharacter": "Zonnamnet innehåller ogiltiga tecken." + } + }, + "distance": { + "error": { + "text": "Avståndet måste vara större än eller lika med 0,1.", + "mustBeFilled": "Alla avståndsfält måste fyllas i för att hastighetsuppskattning ska kunna användas." + } + }, + "inertia": { + "error": { + "mustBeAboveZero": "Trögheten måste vara över 0." + } + }, + "loiteringTime": { + "error": { + "mustBeGreaterOrEqualZero": "Uppehållstiden måste vara större än eller lika med 0." + } + }, + "speed": { + "error": { + "mustBeGreaterOrEqualTo": "Gränsvärdet för hastigheten måste vara större eller lika med 0.1." + } + }, + "polygonDrawing": { + "removeLastPoint": "Ta bort senaste punkten", + "reset": { + "label": "Rensa alla punkter" + }, + "snapPoints": { + "true": "Fäst punkter", + "false": "Fäst inte punkter" + }, + "delete": { + "title": "Bekräfta borttagning", + "desc": "Är du säker på att du vill ta bort {{type}} {{name}}?", + "success": "{{name}} har raderats." + }, + "error": { + "mustBeFinished": "Polygonritningen måste vara klar innan du sparar." + } + } + }, + "zones": { + "label": "Zoner", + "documentTitle": "Redigera zon - Frigate", + "desc": { + "documentation": "Dokumentation", + "title": "Zoner låter dig definiera ett specifikt område av bilden så att du kan avgöra om ett objekt befinner sig inom ett visst område eller inte." + }, + "add": "Lägg till zon", + "edit": "Redigera zon", + "name": { + "title": "Namn", + "inputPlaceHolder": "Ange ett namn…", + "tips": "Namnet måste vara minst 2 tecken långt och får inte vara namnet på en kamera eller en annan zon." + }, + "inertia": { + "title": "Momentum", + "desc": "Anger hur många bildrutor ett objekt måste finnas i en zon innan de räknas som en del av zonen. Standard: 3" + }, + "objects": { + "title": "Objekt", + "desc": "Lista över objekt som gäller för den här zonen." + }, + "allObjects": "Alla objekt", + "point_one": "{{count}} poäng", + "point_other": "{{count}} poäng", + "clickDrawPolygon": "Klicka för att rita en polygon på bilden.", + "loiteringTime": { + "title": "Tid någon hänger omkring", + "desc": "Ställer in en minsta tid i sekunder som objektet måste vara i zonen för att det ska aktiveras. Standard: 0" + }, + "speedEstimation": { + "title": "Hastighetsuppskattning", + "desc": "Aktivera hastighetsuppskattning för objekt i den här zonen. Zonen måste ha exakt fyra punkter.", + "lineADistance": "Avstånd till linje A ({{unit}})", + "lineBDistance": "Avstånd till linje B ({{unit}})", + "lineCDistance": "Avstånd till linje C ({{unit}})", + "lineDDistance": "Avstånd till linje D ({{unit}})" + }, + "speedThreshold": { + "title": "Hastighetsgräns ({{unit}})", + "desc": "Anger en lägsta hastighet för objekt som ska beaktas i denna zon.", + "toast": { + "error": { + "pointLengthError": "Hastighetsuppskattning har inaktiverats för den här zonen. Zoner med hastighetsuppskattning måste ha exakt 4 punkter.", + "loiteringTimeError": "Zoner med uppehållstider större än 0 bör inte användas vid hastighetsuppskattning." + } + } + }, + "toast": { + "success": "Zon ({{zoneName}}) har sparats. Starta om Frigate för att tillämpa ändringarna." + } + }, + "motionMasks": { + "label": "Rörelsemask", + "documentTitle": "Redigera rörelsemask - Frigate", + "desc": { + "title": "Rörelsemasker används för att förhindra att oönskade typer av rörelser utlöser detektering. Övermaskering gör det svårare att spåra objekt.", + "documentation": "Dokumentation" + }, + "add": "Ny rörelsemask", + "edit": "Redigera rörelsemask", + "context": { + "title": "Rörelsemasker används för att förhindra att oönskade typer av rörelser utlöser detektering (till exempel: trädgrenar, kameratidsstämplar). Rörelsemasker bör användas mycket sparsamt, övermaskering gör det svårare att spåra objekt." + }, + "point_one": "{{count}} poäng", + "point_other": "{{count}} poäng", + "clickDrawPolygon": "Klicka för att rita en polygon på bilden.", + "polygonAreaTooLarge": { + "title": "Rörelsemasken täcker {{polygonArea}}% av kamerabilden. Stora rörelsemasker rekommenderas inte.", + "tips": "Rörelsemasker förhindrar inte att objekt upptäcks. Du bör använda en obligatorisk zon istället." + }, + "toast": { + "success": { + "title": "{{polygonName}} har sparats. Starta om Frigate för att tillämpa ändringarna.", + "noName": "Rörelsemasken har sparats. Starta om Frigate för att tillämpa ändringarna." + } + } + }, + "objectMasks": { + "label": "Objektmasker", + "documentTitle": "Redigera objektmask - Frigate", + "point_one": "{{count}} poäng", + "point_other": "{{count}} poäng", + "desc": { + "title": "Objektfiltermasker används för att filtrera bort falska positiva resultat för en given objekttyp baserat på plats.", + "documentation": "Dokumentation" + }, + "add": "Lägg till objektmask", + "edit": "Redigera objektmask", + "context": "Objektfiltermasker används för att filtrera bort falska positiva resultat för en given objekttyp baserat på plats.", + "clickDrawPolygon": "Klicka för att rita en polygon på bilden.", + "objects": { + "title": "Objekt", + "desc": "Objekttypen som gäller för den här objektmasken.", + "allObjectTypes": "Alla objekttyper" + }, + "toast": { + "success": { + "title": "{{polygonName}} har sparats. Starta om Frigate för att tillämpa ändringarna.", + "noName": "Objektmasken har sparats. Starta om Frigate för att tillämpa ändringarna." + } + } + } + }, + "motionDetectionTuner": { + "title": "Rörelsedetekteringstuner", + "unsavedChanges": "Osparade ändringar i Motion Tuner ({{camera}})", + "desc": { + "title": "Frigate använder rörelsedetektering som en första kontroll för att se om det händer något i bilden som är värt att kontrollera med objektdetektering.", + "documentation": "Läs guiden för rörelsejustering" + }, + "Threshold": { + "title": "Tröskel", + "desc": "Tröskelvärdet anger hur mycket förändring i en pixels luminans som krävs för att betraktas som rörelse. Standard: 30" + }, + "contourArea": { + "title": "Konturområde", + "desc": "Konturareans värde används för att avgöra vilka grupper av ändrade pixlar som kvalificeras som rörelse. Standard: 10" + }, + "improveContrast": { + "title": "Förbättra kontrasten", + "desc": "Förbättra kontrasten för mörkare scener. Standard: PÅ" + }, + "toast": { + "success": "Rörelseinställningarna har sparats." + } + }, + "debug": { + "title": "Felsök", + "detectorDesc": "Fregate använder dina detektorer ({{detectors}}) för att upptäcka objekt i din kameras videoström.", + "desc": "Felsökningsvyn visar en realtidsvy av spårade objekt och deras statistik. Objektlistan visar en tidsfördröjd sammanfattning av upptäckta objekt.", + "openCameraWebUI": "Öppna {{camera}}s webbgränssnitt", + "debugging": "Felsökning", + "objectList": "Objektlista", + "noObjects": "Inga föremål", + "audio": { + "title": "Ljud", + "noAudioDetections": "Inga ljuddetekteringar", + "score": "betyg", + "currentRMS": "Nuvarande RMS", + "currentdbFS": "Nuvarande dbFS" + }, + "boundingBoxes": { + "title": "Avgränsande rutor", + "desc": "Visa avgränsningsrutor runt spårade objekt", + "colors": { + "label": "Färger för objektgränser", + "info": "
  • Vid uppstart tilldelas olika färger till varje objektetikett
  • En mörkblå tunn linje indikerar att objektet inte detekteras vid denna aktuella tidpunkt
  • En grå tunn linje indikerar att objektet detekteras som stillastående
  • En tjock linje indikerar att objektet är föremål för automatisk spårning (när det är aktiverat)
  • " + } + }, + "timestamp": { + "title": "Tidsstämpel", + "desc": "Lägg en tidsstämpel över bilden" + }, + "zones": { + "title": "Zoner", + "desc": "Visa en översikt över alla definierade zoner" + }, + "mask": { + "title": "Rörelsemasker", + "desc": "Visa rörelsemaskpolygoner" + }, + "motion": { + "title": "Rörelseboxar", + "desc": "Visa rutor runt områden där rörelse detekteras", + "tips": "

    Rörelserutor


    Röda rutor kommer att läggas över områden i bilden där rörelse för närvarande detekteras

    " + }, + "regions": { + "title": "Regioner", + "desc": "Visa en ruta med det intresseområde som skickats till objektdetektorn", + "tips": "

    Regionsrutor


    Ljusgröna rutor kommer att läggas över intressanta områden i bilden som skickas till objektdetektorn.

    " + }, + "paths": { + "title": "Vägar", + "desc": "Visa viktiga punkter i det spårade objektets bana", + "tips": "

    Vägar


    Linjer och cirklar indikerar viktiga punkter som det spårade objektet har flyttat under sin livscykel.

    " + }, + "objectShapeFilterDrawing": { + "title": "Ritning av objektformfilter", + "desc": "Rita en rektangel på bilden för att visa detaljer om area och förhållande", + "tips": "Aktivera det här alternativet för att rita en rektangel på kamerabilden för att visa dess area och förhållande. Dessa värden kan sedan användas för att ställa in parametrar för objektformsfilter i din konfiguration.", + "score": "Betyg", + "ratio": "Förhållandet", + "area": "Område" + } + }, + "users": { + "title": "Användare", + "management": { + "title": "Användarhantering", + "desc": "Hantera användarkonton för denna Frigate-instans." + }, + "addUser": "Lägg till användare", + "updatePassword": "Uppdatera lösenord", + "toast": { + "success": { + "createUser": "Användaren {{user}} har skapats", + "deleteUser": "Användaren {{user}} har raderats", + "updatePassword": "Lösenordet har uppdaterats.", + "roleUpdated": "Rollen uppdaterades för {{user}}" + }, + "error": { + "setPasswordFailed": "Misslyckades med att spara lösenordet: {{errorMessage}}", + "createUserFailed": "Misslyckades med att skapa användare: {{errorMessage}}", + "deleteUserFailed": "Misslyckades med att ta bort användaren: {{errorMessage}}", + "roleUpdateFailed": "Misslyckades med att uppdatera rollen: {{errorMessage}}" + } + }, + "table": { + "username": "Användarnamn", + "actions": "Åtgärder", + "role": "Roll", + "noUsers": "Inga användare hittades.", + "changeRole": "Ändra användarroll", + "password": "Lösenord", + "deleteUser": "Ta bort användare" + }, + "dialog": { + "form": { + "user": { + "title": "Användarnamn", + "desc": "Endast bokstäver, siffror, punkter och understreck är tillåtna.", + "placeholder": "Ange användarnamn" + }, + "password": { + "title": "Lösenord", + "strength": { + "title": "Lösenordsstyrka: ", + "weak": "Svag", + "medium": "Mellanstark", + "strong": "Stark", + "veryStrong": "Mycket stark" + }, + "match": "Lösenorden matchar", + "notMatch": "Lösenorden matchar inte", + "placeholder": "Ange lösenord", + "confirm": { + "title": "Bekräfta lösenord", + "placeholder": "Bekräfta lösenord" + } + }, + "newPassword": { + "title": "Nytt lösenord", + "placeholder": "Ange nytt lösenord", + "confirm": { + "placeholder": "Ange nytt lösenord igen" + } + }, + "usernameIsRequired": "Användarnamn krävs", + "passwordIsRequired": "Lösenord krävs" + }, + "createUser": { + "title": "Skapa ny användare", + "desc": "Lägg till ett nytt användarkonto och ange en roll för åtkomst till områden i Frigate gränssnittet.", + "usernameOnlyInclude": "Användarnamnet får endast innehålla bokstäver, siffror, . eller _", + "confirmPassword": "Vänligen bekräfta ditt lösenord" + }, + "deleteUser": { + "title": "Ta bort användare", + "desc": "Den här åtgärden kan inte ångras. Detta kommer att permanent radera användarkontot och all tillhörande data.", + "warn": "Är du säker på att du vill ta bort {{username}}?" + }, + "passwordSetting": { + "cannotBeEmpty": "Lösenordet får inte vara tomt", + "doNotMatch": "Lösenorden matchar inte", + "updatePassword": "Uppdatera lösenord för {{username}}", + "setPassword": "Ange lösenord", + "desc": "Skapa ett starkt lösenord för att säkra det här kontot." + }, + "changeRole": { + "title": "Ändra användarroll", + "select": "Välj en roll", + "desc": "Uppdatera behörigheter för {{username}}", + "roleInfo": { + "intro": "Välj lämplig roll för den här användaren:", + "admin": "Administratör", + "adminDesc": "Full åtkomst till alla funktioner.", + "viewer": "Åskådare", + "viewerDesc": "Begränsat till Live-dashboards, Review, Explore, och Exports bara.", + "customDesc": "Anpassad roll med specifik kameraåtkomst." + } + } + } + }, + "notification": { + "title": "Aviseringar", + "notificationSettings": { + "title": "Aviseringsinställningar", + "desc": "Frigate kan skicka push-notiser till din enhet när den körs i webbläsare eller installerad som PWA." + }, + "globalSettings": { + "title": "Övergripande inställningar", + "desc": "Stäng tillfälligt av aviseringar för specifika kameror på alla registrerade enheter." + }, + "email": { + "title": "E-post", + "placeholder": "t.ex. exempel@epost.se", + "desc": "En giltig e-postadress krävs och kommer att användas för att meddela dig om det uppstår problem med push-tjänsten." + }, + "cameras": { + "title": "Kameror", + "noCameras": "Inga kameror tillgängliga", + "desc": "Välj vilka kameror som notifikationer ska aktiveras för." + }, + "unregisterDevice": "Avregistrera enheten", + "sendTestNotification": "Skicka testnotis", + "active": "Notifieringar aktiva", + "notificationUnavailable": { + "title": "Meddelanden otillgängliga", + "desc": "Webb push-meddelanden kräver en säker kontext (https://…). Detta är en begränsning i webbläsaren. Få säker åtkomst till Frigate för att använda meddelanden." + }, + "deviceSpecific": "Enhetsspecifika inställningar", + "registerDevice": "Registrera den här enheten", + "unsavedRegistrations": "Osparade aviseringsregistreringar", + "unsavedChanges": "Osparade ändringar till aviseringar", + "suspended": "Aviseringar avstängda {{time}}", + "suspendTime": { + "suspend": "Pausa", + "5minutes": "Pausa i 5 minuter", + "10minutes": "Pausa i 10 minuter", + "30minutes": "Pausa i 30 minuter", + "1hour": "Pausa i 1 timme", + "12hours": "Pausa i 12 timmar", + "24hours": "Pausa i 24 timmar", + "untilRestart": "Pausa tills omstart" + }, + "cancelSuspension": "Avbryt pausning", + "toast": { + "success": { + "registered": "Registreringen för aviseringar har lyckats. Omstart av Frigate krävs innan några aviseringar (inklusive en testavisering) kan skickas.", + "settingSaved": "Aviseringsinställningarna har sparats." + }, + "error": { + "registerFailed": "Det gick inte att spara aviseringsregistreringen." + } + } + }, + "roles": { + "addRole": "Lägg till roll", + "table": { + "role": "Roll", + "cameras": "Kameror", + "noRoles": "Inga anpassade roller hittades.", + "editCameras": "Redigera kameror", + "deleteRole": "Radera roll", + "actions": "Åtgärder" + }, + "toast": { + "success": { + "createRole": "Roll {{role}} skapad", + "updateCameras": "Kameror uppdaterade för roll {{role}}", + "deleteRole": "Roll {{role}} raderad", + "userRolesUpdated": "{{count}} användare som tilldelats den här rollen har uppdaterats till 'tittare', vilket har åtkomst till alla kameror." + }, + "error": { + "createRoleFailed": "Misslyckades att skapa roll: {{errorMessage}}", + "updateCamerasFailed": "Misslyckades att uppdatera kameror: {{errorMessage}}", + "deleteRoleFailed": "Misslyckades att radera roll: {{errorMessage}}", + "userUpdateFailed": "Misslyckades att uppdatera användar-roller: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Skapa ny roll", + "desc": "Skapa en ny roll och ange kamera åtkomstbehörigheter." + }, + "deleteRole": { + "title": "Radera roll", + "deleting": "Raderar...", + "desc": "Den här åtgärden kan inte ångras. Detta kommer att ta bort rollen permanent och tilldela alla användare med rollen 'tittare', vilket ger tittaren åtkomst till alla kameror.", + "warn": "Är du säker på att du vill ta bort {{role}}?" + }, + "form": { + "role": { + "placeholder": "Ange rollens namn", + "desc": "Enbart bokstäver, siffror, punkter och understreck tillåtna.", + "roleIsRequired": "Rollens namn krävs", + "roleExists": "En roll med detta namn finns redan.", + "title": "Rollnamn", + "roleOnlyInclude": "Rollnamnet får endast innehålla bokstäver, siffror, . eller _" + }, + "cameras": { + "title": "Kameror", + "required": "Minst en kamera måste väljas.", + "desc": "Välj kameror som den här rollen har åtkomst till. Minst en kamera krävs." + } + }, + "editCameras": { + "title": "Redigera rollkameror", + "desc": "Uppdatera kameraåtkomst för rollen {{role}}." + } + }, + "management": { + "title": "Hantering av tittarroller", + "desc": "Hantera anpassade tittarroller och deras kameraåtkomstbehörigheter för den här Frigate instansen." + } + }, + "frigatePlus": { + "title": "Frigate+ Inställningar", + "apiKey": { + "notValidated": "Frigate+ API-nyckeln upptäcktes inte eller validerades inte", + "desc": "Frigate+ API-nyckeln möjliggör integration med Frigate+-tjänsten.", + "plusLink": "Läs mer om Frigate+", + "title": "Frigate+ API-nyckel", + "validated": "Frigate+ API-nyckeln har upptäckts och validerats" + }, + "snapshotConfig": { + "title": "Ögonblicksbild konfiguration", + "desc": "Att skicka till Frigate+ kräver att både snapshots och clean_copy snapshots är aktiverade i din konfiguration.", + "cleanCopyWarning": "Vissa kameror har aktiverade ögonblicksbilder men har ren kopia inaktiverad. Du måste aktivera clean_copy i din ögonblicksbild konfiguration för att kunna skicka bilder från dessa kameror till Frigate+.", + "table": { + "camera": "Kamera", + "snapshots": "Ögonblicksbilder", + "cleanCopySnapshots": "clean_copy Ögonblicksbilder" + } + }, + "modelInfo": { + "title": "Modellinformation", + "modelType": "Modelltyp", + "trainDate": "Träningsdatum", + "baseModel": "Basmodell", + "plusModelType": { + "baseModel": "Basmodell", + "userModel": "Finjusterad" + }, + "supportedDetectors": "Detektorer som stöds", + "cameras": "Kameror", + "loading": "Laddar modellinformation…", + "error": "Misslyckades med att ladda modellinformationen", + "availableModels": "Tillgängliga modeller", + "loadingAvailableModels": "Laddar tillgängliga modeller…", + "modelSelect": "Dina tillgängliga modeller på Frigate+ kan väljas här. Observera att endast modeller som är kompatibla med din nuvarande detektorkonfiguration kan väljas." + }, + "unsavedChanges": "Osparade ändringar av inställningar för Frigate+", + "restart_required": "Omstart krävs (Frigate+ modell ändrad)", + "toast": { + "success": "Inställningarna för Frigate+ har sparats. Starta om Frigate för att tillämpa ändringarna.", + "error": "Kunde inte spara konfigurationsändringarna: {{errorMessage}}" + } + }, + "triggers": { + "documentTitle": "Utlösare", + "management": { + "title": "Utlösare hantering", + "desc": "Hantera utlösare för {{camera}}. Använd miniatyrtypen för att utlösa liknande miniatyrer som ditt valda spårade objekt och beskrivningstypen för att utlösa liknande beskrivningar av text du anger." + }, + "addTrigger": "Lägg till utlösare", + "table": { + "name": "Namn", + "type": "Typ", + "content": "Innehåll", + "threshold": "Tröskel", + "actions": "Åtgärder", + "noTriggers": "Inga utlösare konfigurerade för den här kameran.", + "edit": "Redigera", + "deleteTrigger": "Ta bort utlösare", + "lastTriggered": "Senast utlöst" + }, + "type": { + "thumbnail": "Miniatyrbild", + "description": "Beskrivning" + }, + "actions": { + "notification": "Skicka avisering", + "alert": "Markera som varning" + }, + "dialog": { + "createTrigger": { + "title": "Skapa utlösare", + "desc": "Skapa en utlösare för kamera {{camera}}" + }, + "editTrigger": { + "title": "Redigera utlösare", + "desc": "Redigera inställningarna för utlösare på kameran {{camera}}" + }, + "deleteTrigger": { + "title": "Ta bort utlösare", + "desc": "Är du säker på att du vill ta bort utlösaren {{triggerName}}? Den här åtgärden kan inte ångras." + }, + "form": { + "name": { + "title": "Namn", + "placeholder": "Ange utlösarens namn", + "error": { + "minLength": "Namnet måste vara minst 2 tecken lång.", + "invalidCharacters": "Namnet får bara innehålla bokstäver, siffror, understreck, och bindestreck.", + "alreadyExists": "En utlösare med detta namn finns redan för den här kameran." + } + }, + "enabled": { + "description": "Aktivera eller inaktivera den här utlösaren" + }, + "type": { + "title": "Typ", + "placeholder": "Välj utlösartyp" + }, + "content": { + "title": "Innehåll", + "imagePlaceholder": "Välj en bild", + "textPlaceholder": "Ange textinnehåll", + "imageDesc": "Välj en bild för att utlösa den här åtgärden när en liknande bild upptäcks.", + "textDesc": "Ange text för att utlösa den här åtgärden när en liknande beskrivning av spårat objekt upptäcks.", + "error": { + "required": "Innehåll krävs." + } + }, + "threshold": { + "title": "Tröskel", + "error": { + "min": "Tröskelvärdet måste vara minst 0", + "max": "Tröskelvärdet får vara högst 1" + } + }, + "actions": { + "title": "Åtgärder", + "desc": "Som standard utlöser Frigate ett MQTT-meddelande för alla utlösare. Välj en ytterligare åtgärd att utföra när den här utlösaren utlöses.", + "error": { + "min": "Minst en åtgärd måste väljas." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Utlösaren {{name}} har skapats.", + "updateTrigger": "Utlösaren {{name}} har uppdaterats.", + "deleteTrigger": "Utlösaren {{name}} har raderats." + }, + "error": { + "createTriggerFailed": "Misslyckades med att skapa utlösaren: {{errorMessage}}", + "updateTriggerFailed": "Misslyckades med att uppdatera utlösaren: {{errorMessage}}", + "deleteTriggerFailed": "Misslyckades med att ta bort utlösaren: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/sv/views/system.json b/web/public/locales/sv/views/system.json index d10bf2e1de..d8f482898d 100644 --- a/web/public/locales/sv/views/system.json +++ b/web/public/locales/sv/views/system.json @@ -4,9 +4,11 @@ "general": "Allmän statistik - Frigate", "cameras": "Kamerastatistik - Frigate", "logs": { - "frigate": "Frigate loggar - Frigate", - "go2rtc": "Go2RTC Loggar - Frigate" - } + "frigate": "Frigate-loggar - Frigate", + "go2rtc": "Go2RTC loggar - Frigate", + "nginx": "Nginx loggar - Frigate" + }, + "enrichments": "Förbättringsstatistik - Frigate" }, "logs": { "copy": { @@ -32,19 +34,54 @@ } }, "title": "System", - "metrics": "System detaljer", + "metrics": "Systemdetaljer", "general": { "title": "Generellt", "detector": { - "title": "Detektorer" + "title": "Detektorer", + "inferenceSpeed": "Detektorns inferenshastighet", + "temperature": "Detektor temperatur", + "cpuUsage": "Detektorns CPU-användning", + "memoryUsage": "Detektor minnes användning", + "cpuUsageInformation": "CPU som används för att förbereda in- och utdata till/från detekteringsmodeller. Detta värde mäter inte inferensanvändning, även om en GPU eller accelerator används." }, "hardwareInfo": { "title": "Hårdvaruinformation", "gpuUsage": "GPU-användning", - "gpuMemory": "GPU-minne" + "gpuMemory": "GPU-minne", + "gpuEncoder": "GPU-kodare", + "gpuDecoder": "GPU-avkodare", + "gpuInfo": { + "nvidiaSMIOutput": { + "vbios": "VBios-information: {{vbios}}", + "title": "Nvidia SMI utdata", + "name": "Namn: {{name}}", + "driver": "Drivrutin: {{driver}}", + "cudaComputerCapability": "CUDA beräknings kapacitet: {{cuda_compute}}" + }, + "closeInfo": { + "label": "Stäng GPU-info" + }, + "copyInfo": { + "label": "Kopiera GPU-info" + }, + "toast": { + "success": "Kopierade GPU-info till urklipp" + }, + "vainfoOutput": { + "title": "Vainfo resultat", + "returnCode": "Returkod: {{code}}", + "processOutput": "Bearbeta utdata:", + "processError": "Processfel:" + } + }, + "npuUsage": "NPU-användning", + "npuMemory": "NPU-minne" }, "otherProcesses": { - "title": "Övriga processer" + "title": "Övriga processer", + "processCpuUsage": "Process CPU-användning", + "processMemoryUsage": "Processminnesanvändning" } }, "storage": { @@ -55,17 +92,95 @@ "unused": { "title": "Oanvänt", "tips": "Det här värdet kanske inte korrekt representerar det lediga utrymmet tillgängligt för Frigate om du har andra filer lagrade på din hårddisk utöver Frigates inspelningar. Frigate spårar inte lagringsanvändning utanför sina egna inspelningar." - } + }, + "title": "Kamera lagring", + "camera": "Kamera", + "unusedStorageInformation": "Information om oanvänd lagring" + }, + "title": "Lagring", + "overview": "Översikt", + "recordings": { + "title": "Inspelningar", + "tips": "Detta värde representerar den totala lagringsmängden som används av inspelningarna i Frigates databas. Frigate spårar inte lagringsanvändningen för alla filer på din disk.", + "earliestRecording": "Tidigast tillgängliga inspelning:" + }, + "shm": { + "title": "SHM-allokering (delat minne)", + "warning": "Den nuvarande SHM-storleken på {{total}}MB är för liten. Öka den till minst {{min_shm}}MB." } }, "cameras": { "title": "Kameror", "overview": "Översikt", "info": { - "aspectRatio": "bildförhållande" + "aspectRatio": "bildförhållande", + "cameraProbeInfo": "{{camera}} Kamerasondinformation", + "streamDataFromFFPROBE": "Strömdata erhålls med ffprobe.", + "codec": "Codec:", + "resolution": "Upplösning:", + "fps": "FPS:", + "unknown": "Okänd", + "audio": "Audio:", + "error": "Fel: {{error}}", + "tips": { + "title": "Kamera sond information" + }, + "fetching": "Hämtar kamera data", + "stream": "Ström {{idx}}", + "video": "Video:" }, "label": { - "detect": "detektera" + "detect": "detektera", + "camera": "kamera", + "skipped": "hoppade över", + "ffmpeg": "FFmpeg", + "capture": "spela in", + "overallFramesPerSecond": "totalt antal bilder per sekund", + "overallDetectionsPerSecond": "totala detektioner per sekund", + "overallSkippedDetectionsPerSecond": "totalt antal hoppade detekteringar per sekund", + "cameraFfmpeg": "{{camName}} FFmpeg", + "cameraCapture": "{{camName}} inspelning", + "cameraDetect": "{{camName}} upptäcka", + "cameraFramesPerSecond": "{{camName}} bildrutor per sekund", + "cameraDetectionsPerSecond": "{{camName}} detekteringar per sekund", + "cameraSkippedDetectionsPerSecond": "{{camName}} hoppade över detekteringar per sekund" + }, + "framesAndDetections": "Ramar / Detektioner", + "toast": { + "success": { + "copyToClipboard": "Kopierade probdata till urklipp." + }, + "error": { + "unableToProbeCamera": "Kunde inte undersöka kameran: {{errorMessage}}" + } + } + }, + "lastRefreshed": "Senast uppdaterad: ", + "stats": { + "ffmpegHighCpuUsage": "{{camera}} har hög FFmpeg CPU-användning ({{ffmpegAvg}}%)", + "detectHighCpuUsage": "{{camera}} har hög CPU-användning vid detektering ({{detectAvg}}%)", + "healthy": "Systemet är hälsosamt", + "reindexingEmbeddings": "Omindexering av inbäddningar ({{processed}}% klar)", + "cameraIsOffline": "{{camera}} är urkopplad", + "detectIsSlow": "{{detect}} är långsam ({{speed}} ms)", + "detectIsVerySlow": "{{detect}} är väldigt långsam ({{speed}} ms)", + "shmTooLow": "/dev/shm allokeringen ({{total}} MB) bör ökas till minst {{min}} MB." + }, + "enrichments": { + "title": "Berikningar", + "infPerSecond": "Slutsatser per sekund", + "embeddings": { + "image_embedding": "Bildinbäddning", + "text_embedding": "Textinbäddning", + "face_recognition": "Ansiktsigenkänning", + "plate_recognition": "Nummerplåt igenkänning", + "image_embedding_speed": "Bildinbäddningshastighet", + "face_embedding_speed": "Ansikts inbäddnings hastighet", + "face_recognition_speed": "Ansiktsigenkänningshastighet", + "plate_recognition_speed": "Hastighet för igenkänning av nummerplåtar", + "text_embedding_speed": "Textinbäddningshastighet", + "yolov9_plate_detection_speed": "YOLOv9 nummerplåt detekterings hastighet", + "yolov9_plate_detection": "YOLOv9 nummerplåt detektering" } } } diff --git a/web/public/locales/ta/audio.json b/web/public/locales/ta/audio.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/audio.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/common.json b/web/public/locales/ta/common.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/common.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/components/auth.json b/web/public/locales/ta/components/auth.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/components/auth.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/components/camera.json b/web/public/locales/ta/components/camera.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/components/camera.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/components/dialog.json b/web/public/locales/ta/components/dialog.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/components/dialog.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/components/filter.json b/web/public/locales/ta/components/filter.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/components/filter.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/components/icons.json b/web/public/locales/ta/components/icons.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/components/icons.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/components/input.json b/web/public/locales/ta/components/input.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/components/input.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/components/player.json b/web/public/locales/ta/components/player.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/components/player.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/objects.json b/web/public/locales/ta/objects.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/objects.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/configEditor.json b/web/public/locales/ta/views/configEditor.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/configEditor.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/events.json b/web/public/locales/ta/views/events.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/events.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/explore.json b/web/public/locales/ta/views/explore.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/explore.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/exports.json b/web/public/locales/ta/views/exports.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/exports.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/faceLibrary.json b/web/public/locales/ta/views/faceLibrary.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/faceLibrary.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/live.json b/web/public/locales/ta/views/live.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/live.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/recording.json b/web/public/locales/ta/views/recording.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/recording.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/search.json b/web/public/locales/ta/views/search.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/search.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/settings.json b/web/public/locales/ta/views/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/settings.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ta/views/system.json b/web/public/locales/ta/views/system.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ta/views/system.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/th/common.json b/web/public/locales/th/common.json index c7044976d0..b920787973 100644 --- a/web/public/locales/th/common.json +++ b/web/public/locales/th/common.json @@ -246,5 +246,6 @@ "feet": "ฟุต", "meters": "เมตร" } - } + }, + "readTheDocumentation": "อ่านเอกสาร" } diff --git a/web/public/locales/tr/common.json b/web/public/locales/tr/common.json index e23b402caa..977ef88cfd 100644 --- a/web/public/locales/tr/common.json +++ b/web/public/locales/tr/common.json @@ -101,14 +101,14 @@ "export": "Dışa aktar", "download": "İndir", "edit": "Düzenle", - "fullscreen": "Tam ekran", + "fullscreen": "Tam Ekran", "deleteNow": "Şimdi Sil", "apply": "Uygula", "reset": "Sıfırla", "done": "Bitti", "enabled": "Açık", "save": "Kaydet", - "exitFullscreen": "Tam ekrandan çık", + "exitFullscreen": "Tam Ekrandan Çık", "pictureInPicture": "Pencere içinde pencere", "copyCoordinates": "Koordinatları kopyala", "yes": "Evet", @@ -166,7 +166,15 @@ "ru": "Русский (Rusça)", "yue": "粵語 (Kantonca)", "th": "ไทย (Tayca)", - "ca": "Català (Katalanca)" + "ca": "Català (Katalanca)", + "ptBR": "Português brasileiro (Brezilya Portekizcesi)", + "sr": "Српски (Sırpça)", + "sl": "Slovenščina (Slovence)", + "lt": "Lietuvių (Litvanyaca)", + "bg": "Български (Bulgarca)", + "gl": "Galego (Galiçyaca)", + "id": "Bahasa Indonesia (Endonezce)", + "ur": "اردو (Urduca)" }, "withSystem": "Sistem", "theme": { @@ -264,5 +272,6 @@ "viewer": "Görüntüleyici", "admin": "Yönetici", "desc": "Yöneticiler Frigate arayüzündeki bütün özelliklere tam erişim sahibidir. Görüntüleyiciler ise yalnızca kameraları, eski görüntüleri ve inceleme öğelerini görüntülemekle sınırlıdır." - } + }, + "readTheDocumentation": "Dökümantasyonu oku" } diff --git a/web/public/locales/tr/components/camera.json b/web/public/locales/tr/components/camera.json index 8471d7c849..7885c2653c 100644 --- a/web/public/locales/tr/components/camera.json +++ b/web/public/locales/tr/components/camera.json @@ -51,7 +51,8 @@ }, "placeholder": "Bir yayın seçin", "stream": "Yayın" - } + }, + "birdseye": "Kuş Bakışı" }, "icon": "Simge", "add": "Kamera Grubu Ekle", diff --git a/web/public/locales/tr/components/dialog.json b/web/public/locales/tr/components/dialog.json index acdb8ef1ee..a4d7df98c5 100644 --- a/web/public/locales/tr/components/dialog.json +++ b/web/public/locales/tr/components/dialog.json @@ -119,5 +119,12 @@ "markAsReviewed": "İncelendi olarak işaretle", "deleteNow": "Şimdi Sil" } + }, + "imagePicker": { + "selectImage": "Takip edilen nesnenin küçük resmini seçin", + "noImages": "Bu kamera için küçük resim bulunamadı", + "search": { + "placeholder": "Etiket/alt etiket kullanarak arama yapın..." + } } } diff --git a/web/public/locales/tr/components/filter.json b/web/public/locales/tr/components/filter.json index 96565946f5..c71110bebc 100644 --- a/web/public/locales/tr/components/filter.json +++ b/web/public/locales/tr/components/filter.json @@ -108,7 +108,7 @@ "error": "Takip edilen nesneler silinemedi: {{errorMessage}}" }, "title": "Silmeyi onayla", - "desc": "Bu {{objectLength}} adet izlenen nesneyi sildiğinizde ilgili tüm fotoğraflar, kaydedilmiş tüm gömüler ve ilişkili tüm Nesne Geçmişi kayıtları kaldırılır. Bu izlenen nesnelere ait Geçmiş görünümündeki kayıtlı görüntüler SİLİNMEYECEKTİR.

    Devam etmek istediğinize emin misiniz?

    Gelecekte bu diyaloğu pas geçmek için Shift tuşuna basılı tutarak tıklayın." + "desc": "Bu {{objectLength}} adet izlenen nesneyi sildiğinizde ilgili tüm fotoğraflar, kaydedilmiş tüm gömüler ve ilişkili tüm nesne yaşam döngüsü kayıtları kaldırılır. Bu izlenen nesnelere ait Geçmiş görünümündeki kayıtlı görüntüler SİLİNMEYECEKTİR.

    Devam etmek istediğinize emin misiniz?

    Gelecekte bu diyaloğu pas geçmek için Shift tuşuna basılı tutarak tıklayın." }, "recognizedLicensePlates": { "selectPlatesFromList": "Listeden bir veya birden fazla plaka seçin.", @@ -116,12 +116,22 @@ "loading": "Tanınan plakalar yükleniyor…", "title": "Tanınan Plakalar", "noLicensePlatesFound": "Plaka bulunamadı.", - "loadFailed": "Tanınan plakalar yüklenemedi." + "loadFailed": "Tanınan plakalar yüklenemedi.", + "selectAll": "Tümünü seç", + "clearAll": "Tümünü temizle" }, "motion": { "showMotionOnly": "Yalnızca Hareket Olanları Göster" }, "zoneMask": { "filterBy": "Alana göre filtrele" + }, + "classes": { + "count_one": "{{count}} Sınıf", + "count_other": "{{count}} Sınıf", + "label": "Sınıflar", + "all": { + "title": "Tüm Sınıflar" + } } } diff --git a/web/public/locales/tr/views/configEditor.json b/web/public/locales/tr/views/configEditor.json index c4aa01b6bd..32ffdb2cb7 100644 --- a/web/public/locales/tr/views/configEditor.json +++ b/web/public/locales/tr/views/configEditor.json @@ -12,5 +12,7 @@ "configEditor": "Yapılandırma Düzenleyicisi", "documentTitle": "Yapılandırma Düzenleyicisi - Frigate", "saveAndRestart": "Kaydet & Yeniden Başlat", - "confirm": "Kaydetmeden çıkılsın mı?" + "confirm": "Kaydetmeden çıkılsın mı?", + "safeConfigEditor": "Yapılandırma Düzenleyicisi (Güvenli Mod)", + "safeModeDescription": "Frigate, yapılandırmanızdaki bir hata nedeniyle güvenli moda geçti." } diff --git a/web/public/locales/tr/views/events.json b/web/public/locales/tr/views/events.json index 3f363c70fe..c1a8214071 100644 --- a/web/public/locales/tr/views/events.json +++ b/web/public/locales/tr/views/events.json @@ -34,5 +34,7 @@ "allCameras": "Tüm Kameralar", "selected_one": "{{count}} seçildi", "selected_other": "{{count}} seçildi", - "detected": "algılandı" + "detected": "algılandı", + "suspiciousActivity": "Şüpheli Etkinlik", + "threateningActivity": "Tehlikeli Etkinlik" } diff --git a/web/public/locales/tr/views/explore.json b/web/public/locales/tr/views/explore.json index 485fe9b43c..ce9466741e 100644 --- a/web/public/locales/tr/views/explore.json +++ b/web/public/locales/tr/views/explore.json @@ -18,12 +18,14 @@ "success": { "updatedSublabel": "Alt etiket başarıyla gücellendi.", "regenerate": "Yeni bir açıklama {{provider}} sağlayıcısından talep edildi. Sağlayıcının hızına bağlı olarak yeni açıklamanın oluşturulması biraz zaman alabilir.", - "updatedLPR": "Plaka başarıyla güncellendi." + "updatedLPR": "Plaka başarıyla güncellendi.", + "audioTranscription": "Ses çözümlemesi başarıyla talep edildi." }, "error": { "updatedSublabelFailed": "Alt etiket güncellenemedi: {{errorMessage}}", "regenerate": "{{provider}} sağlayıcısından yeni açıklama talep edilemedi: {{errorMessage}}", - "updatedLPRFailed": "Plaka güncellenemedi: {{errorMessage}}" + "updatedLPRFailed": "Plaka güncellenemedi: {{errorMessage}}", + "audioTranscription": "Ses çözümlemesi talep edilemedi: {{errorMessage}}" } } }, @@ -68,6 +70,9 @@ "recognizedLicensePlate": "Tanınan Plaka", "snapshotScore": { "label": "Fotoğraf Skoru" + }, + "score": { + "label": "Skor" } }, "generativeAI": "Üretken Yapay Zeka", @@ -102,12 +107,12 @@ "trackedObjectDetails": "Takip Edilen Nesne Detayları", "type": { "details": "detaylar", - "object_lifecycle": "nesne geçmişi", + "object_lifecycle": "nesne yaşam döngüsü", "snapshot": "fotoğraf", "video": "video" }, "objectLifecycle": { - "title": "Nesne Geçmişi", + "title": "Nesne Yaşam Döngüsü", "noImageFound": "Bu zaman damgası için bir resim bulunamadı.", "createObjectMask": "Nesne Maskesi Oluştur", "adjustAnnotationSettings": "Belirteç ayarları", @@ -150,7 +155,7 @@ "next": "Sonraki sayfa", "previous": "Önceki sayfa" }, - "scrollViewTips": "Bu nesnenin geçmişindeki önemli noktaları görmek için kaydırın.", + "scrollViewTips": "Bu nesnenin yaşam döngüsündeki önemli noktaları görmek için kaydırın.", "autoTrackingTips": "Otomatik takip yapılan kameralarda gösterilen çerçeveler hatalı olacaktır.", "count": "Toplam {{second}} kerede {{first}} kez", "trackedPoint": "Takip Edilen Nokta" @@ -182,6 +187,14 @@ "downloadSnapshot": { "aria": "Fotoğrafı indir", "label": "Fotoğrafı indir" + }, + "addTrigger": { + "label": "Tetik ekle", + "aria": "Bu takip edilen nesne için bir tetik ekle" + }, + "audioTranscription": { + "label": "Çözümle", + "aria": "Ses çözümlemesi iste" } }, "noTrackedObjects": "Takip Edilen Nesne Bulunamadı", @@ -203,5 +216,8 @@ }, "trackedObjectsCount_one": "{{count}} adet takip edilen nesne ", "trackedObjectsCount_other": "{{count}} adet takip edilen nesne ", - "exploreMore": "Daha fazla {{label}} nesnesini keşfet" + "exploreMore": "Daha fazla {{label}} nesnesini keşfet", + "aiAnalysis": { + "title": "Yapay Zeka Analizi" + } } diff --git a/web/public/locales/tr/views/live.json b/web/public/locales/tr/views/live.json index 88f0408566..ae3b0cd1cd 100644 --- a/web/public/locales/tr/views/live.json +++ b/web/public/locales/tr/views/live.json @@ -10,7 +10,7 @@ "enable": "Otomatik Takibi Aç" }, "manualRecording": { - "start": "Talep üzerine kaydı başlat", + "start": "Talep Üzerine Kaydı Başlat", "failedToEnd": "Manuel talep üzerine kayıt bitirilemedi.", "recordDisabledTips": "Kamera konfigürasyonunda kayıtlar devre dışı bırakıldığı veya kısıtlandığı için yalnızca bir fotoğraf kaydedilcektir.", "showStats": { @@ -20,7 +20,7 @@ "started": "Manuel talep üzerine kayıt başlatıldı.", "failedToStart": "Manuel talep üzerine kayıt başlatılamadı.", "title": "İsteğe Bağlı Kayıt", - "end": "Talep üzerine kaydı bitir", + "end": "Talep Üzerine Kaydı Bitir", "debugView": "Hata Ayıklama Görünümü", "ended": "Manuel talep üzerine kayıt bitirildi.", "tips": "Bu kameranın kayıt tutma ayarları kapsamında manuel olarak bir olay başlatın.", @@ -60,8 +60,9 @@ "title": "{{camera}} Ayarları", "autotracking": "Otomatik Takip", "cameraEnabled": "Kamera Açık", - "objectDetection": "Nesne Algılama", - "audioDetection": "Ses Algılama" + "objectDetection": "Nesne Tespiti", + "audioDetection": "Ses Algılama", + "transcription": "Ses Çözümlemesi" }, "effectiveRetainMode": { "modes": { @@ -115,14 +116,22 @@ "center": { "label": "PTZ kamerayı ortalamak için görüntüye tıklatın" } + }, + "focus": { + "in": { + "label": "PTZ kamera odağını yakınlaştır" + }, + "out": { + "label": "PTZ kamera odağını uzaklaştır" + } } }, "history": { "label": "Geçmiş görüntüleri göster" }, "camera": { - "enable": "Kamerayı aç", - "disable": "Kamerayı kapat" + "enable": "Kamerayı Aç", + "disable": "Kamerayı Kapat" }, "suspend": { "forTime": "Askıya alınma süresi: " @@ -154,5 +163,9 @@ "detect": { "disable": "Tespiti Kapat", "enable": "Tespiti Aç" + }, + "transcription": { + "enable": "Canlı Ses Çözümlemeyi Aç", + "disable": "Canlı Ses Çözümlemeyi Kapat" } } diff --git a/web/public/locales/tr/views/search.json b/web/public/locales/tr/views/search.json index 0590233081..17ba98899b 100644 --- a/web/public/locales/tr/views/search.json +++ b/web/public/locales/tr/views/search.json @@ -19,7 +19,7 @@ "time_range": "Zaman Aralığı", "before": "Önce", "zones": "Alanlar", - "after": "Sonras", + "after": "Sonra", "has_clip": "Klibi var", "min_speed": "Min. Hız", "sub_labels": "Alt Etiketler", diff --git a/web/public/locales/tr/views/settings.json b/web/public/locales/tr/views/settings.json index 590702370c..714f6a4fc1 100644 --- a/web/public/locales/tr/views/settings.json +++ b/web/public/locales/tr/views/settings.json @@ -22,7 +22,8 @@ "classification": "Sınıflandırma", "debug": "Hata Ayıklama", "cameras": "Kamera Ayarları", - "enrichments": "Zenginleştirmeler" + "enrichments": "Zenginleştirmeler", + "triggers": "Tetikler" }, "general": { "title": "Genel Ayarlar", @@ -177,6 +178,44 @@ "streams": { "desc": "Frigate yeniden başlataılana kadar bir kamerayı devre dışı bırakın. Bir kameranın devre dışı bırakılması, Frigate'in bu kamerayı işlemesini tamamen durdurur. Algılama, kayıt ve hata ayıklama özellikleri kullanılamaz.
    Not: Bu eylem, go2rtc'deki yeniden akışları devre dışı bırakmaz.", "title": "Akışlar" + }, + "object_descriptions": { + "title": "Üretken AI Nesne Açıklamaları", + "desc": "Bu kamera için Üretken Yapay Zeka kullanarak nesne açıklamaları oluşturmayı geçici olarak etkinleştirin/devre dışı bırakın. Devre dışı bırakıldığında, bu kamerada takip edilen nesneler için yapay zekadan nesne açıklamaları talep edilmeyecektir." + }, + "review_descriptions": { + "title": "Üretken AI İnceleme Öğesi Açıklamaları", + "desc": "Bu kamera için Üretken Yapay Zeka kullanarak inceleme öğelerinin açıklamalarını oluşturmayı geçici olarak etkinleştirin/devre dışı bırakın. Devre dışı bırakıldığında, bu kameraya bağlı inceleme öğeleri için yapay zekadan açıklama metni talep edilmeyecektir." + }, + "addCamera": "Yeni Kamera Ekle", + "editCamera": "Kamerayı Düzenle:", + "selectCamera": "Kamera Seç", + "backToSettings": "Kamera Ayarlarına Dön", + "cameraConfig": { + "add": "Kamera Ekle", + "edit": "Kamerayı Düzenle", + "description": "Kameranızın ayarlarını, kameraların akışları ve roller de dahil olacak şekilde yapılandırın.", + "name": "Kamera İsmi", + "nameRequired": "Kamera adı gereklidir", + "nameInvalid": "Kamera adı yalnızca harf, rakam, alt çizgi veya tire içerebilir", + "namePlaceholder": "örn: onkapi", + "enabled": "Açık", + "ffmpeg": { + "inputs": "Kamera Girdi Akışları", + "path": "Akış Yolu", + "pathRequired": "Akış yolu gereklidir", + "pathPlaceholder": "rtsp://...", + "roles": "Roller", + "rolesRequired": "En az bir rol gereklidir", + "rolesUnique": "Her rol (ses, tespit, kayıt) yalnızca bir adet yayına atanabilir. Her rol aynı akışı kullanabilir, lakin bir rol birden fazla akışa atanamaz.", + "addInput": "Girdi Akışı Ekle", + "removeInput": "Girdi Akışını Kaldır", + "inputsRequired": "En az bir girdi akışı gereklidir" + }, + "toast": { + "success": "Kamera {{cameraName}} başarıyla kaydedildi" + }, + "nameLength": "Kamera ismi 24 karakterden kısa olmalıdır." } }, "masksAndZones": { @@ -396,7 +435,7 @@ "regions": { "title": "Tespit Bölgeleri", "desc": "Nesne algılayıcıya gönderilen tespit alanlarını göster", - "tips": "

    Bölge Kutuları


    Görüntüdeki nesne dedektörüne gönderilen tespit alanları parlak yeşil renk çerçeve ile gösterilir.

    " + "tips": "

    Bölge Kutuları


    Nesne dedektörüne gönderilen tespit alanları görüntüde parlak yeşil renk çerçeve ile gösterilir.

    " }, "objectShapeFilterDrawing": { "title": "Nesne Şekil Filtresi Çizimi", @@ -419,7 +458,20 @@ "desc": "Tanımlanmış alanların sınırlarını göster" }, "objectList": "Nesne Listesi", - "desc": "Hata ayıklama görünümü, izlenen nesnelerin ve istatistiklerinin gerçek zamanlı bir görünümünü gösterir. Nesne listesi algılanan nesnelerin zaman gecikmeli bir özetini gösterir." + "desc": "Hata ayıklama görünümü, izlenen nesnelerin ve istatistiklerinin gerçek zamanlı bir görünümünü gösterir. Nesne listesi algılanan nesnelerin zaman gecikmeli bir özetini gösterir.", + "paths": { + "title": "Hareket İzi", + "desc": "Takip edilen nesnenin hareket izi üzerindeki önemli noktaları göster", + "tips": "

    Hareket İzi


    Çizgiler ve daireler, takip edilen nesnenin yaşam döngüsü boyunca hareket ettiği önemli noktaları gösterir.

    " + }, + "openCameraWebUI": "{{camera}}'nın Web Arayüzünü Aç", + "audio": { + "title": "Ses", + "noAudioDetections": "Ses tespiti yok", + "score": "skor", + "currentRMS": "Şu Anki RMS", + "currentdbFS": "Şu Anki dbFS" + } }, "users": { "title": "Kullanıcılar", @@ -679,5 +731,100 @@ "success": "Zenginleştirme ayarları kaydedildi. Değişiklikleri uygulamak için Frigate'i yeniden başlatın.", "error": "Yapılandırma değişiklikleri kaydedilemedi: {{errorMessage}}" } + }, + "triggers": { + "dialog": { + "form": { + "name": { + "error": { + "invalidCharacters": "İsim yalnızca harf, rakam, alt çizgi veya tire içerebilir.", + "minLength": "Bu isim en az iki karakterden oluşmalıdır.", + "alreadyExists": "Bu kamerada aynı isimle bir tetik zaten mevcut." + }, + "title": "İsim", + "placeholder": "Tetik için bir isim girin" + }, + "enabled": { + "description": "Bu tetiği açın veya kapatın" + }, + "type": { + "title": "Tetik Türü", + "placeholder": "Tetik türünü seçin" + }, + "content": { + "title": "İçerik", + "imagePlaceholder": "Bir resim seçin", + "textPlaceholder": "Metin içeriği girin", + "imageDesc": "Benzer bir resim tespit edildiğinde tetiklenilmesi için bir resim seçin.", + "textDesc": "Benzer bir takip edilen nesne açıklaması algılandığında bu eylemi tetiklemek için metin girin.", + "error": { + "required": "İçerik gereklidir." + } + }, + "threshold": { + "title": "Tetik Eşiği", + "error": { + "min": "Tetik eşiği 0 ile 1 arasında olmalıdır", + "max": "Tetik eşiği 0 ile 1 arasında olmalıdır" + } + }, + "actions": { + "title": "Eylemler", + "desc": "Varsayılan olarak Frigate bütün tetkler için MQTT'ye bir mesaj atar. İsterseniz yapılacak ek bir eylem de belirleyebilirsiniz.", + "error": { + "min": "En az bir eylem seçilmelidir." + } + } + }, + "createTrigger": { + "title": "Tetik Oluştur", + "desc": "{{camera}} kamerası için tetik oluşturun" + }, + "editTrigger": { + "title": "Tetiği Düzenle", + "desc": "{{camera}} kamerasındaki tetiğin ayarlarını düzenleyin" + }, + "deleteTrigger": { + "title": "Tetiği Sil", + "desc": "{{triggerName}} isimli tetiği silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." + } + }, + "documentTitle": "Tetikler", + "management": { + "title": "Tetik Yönetimi", + "desc": "{{camera}} için tetikleri yönetin. Seçtiğiniz takip edilen nesneye benzer küçük resimlerde tetiklemek için küçük resmi kullanın veya belirlediğiniz metne benzer açıklamalar çıkması durumunda tetiklemek için ise açıklama seçeneğini kullanın." + }, + "addTrigger": "Tetik Ekle", + "table": { + "name": "İsim", + "type": "Tetik Türü", + "content": "İçerik", + "threshold": "Tetik Eşiği", + "actions": "Eylemler", + "noTriggers": "Bu kamera için hiç bir tetik ayarlanmadı.", + "edit": "Düzenle", + "deleteTrigger": "Tetiği Sil", + "lastTriggered": "En son tetikleme" + }, + "type": { + "thumbnail": "Küçük Resim", + "description": "Açıklama" + }, + "actions": { + "alert": "Alarm Olarak İşaretle", + "notification": "Bildirim Gönder" + }, + "toast": { + "success": { + "createTrigger": "Tetik {{name}} başarıyla oluşturuldu.", + "updateTrigger": "Tetik {{name}} başarıyla güncellendi.", + "deleteTrigger": "Tetik {{name}} başarıyla silindi." + }, + "error": { + "createTriggerFailed": "Tetik oluşturulamadı: {{errorMessage}}", + "updateTriggerFailed": "Tetik güncellenemedi: {{errorMessage}}", + "deleteTriggerFailed": "Tetik silinemedi: {{errorMessage}}" + } + } } } diff --git a/web/public/locales/tr/views/system.json b/web/public/locales/tr/views/system.json index 9124e3e08d..9052b6d4ae 100644 --- a/web/public/locales/tr/views/system.json +++ b/web/public/locales/tr/views/system.json @@ -55,7 +55,8 @@ "inferenceSpeed": "Algılayıcı Çıkarım Hızı", "memoryUsage": "Algılayıcı Bellek Kullanımı", "cpuUsage": "Algılayıcı İşlemci Kullanımı", - "temperature": "Algılayıcı Sıcaklığı" + "temperature": "Algılayıcı Sıcaklığı", + "cpuUsageInformation": "Tespit modellerine giriş ve çıkış verilerini hazırlarken kullanılan işlemci yoğunluğu. Bu değer, grafik işlemci veya benzeri bir hızlandırıcı kullanılsa bile çıkarım yükünü ölçmek için kullanılmamalıdır." }, "title": "Genel" }, @@ -78,6 +79,10 @@ "storageUsed": "Depolama", "bandwidth": "Saatlik Veri Kullanımı", "unusedStorageInformation": "Kullanılmayan Depolama Bilgisi" + }, + "shm": { + "warning": "Şu anki {{total}}MB'lik SHM boyutu yetersiz. Bu boyutu en az {{min_shm}}MB'a çıkartın.", + "title": "Ayrılan SHM (paylaşımlı bellek)" } }, "cameras": { @@ -134,7 +139,8 @@ "healthy": "Sistem sağlıklı", "detectIsVerySlow": "{{detect}} çok yavaş çalışıyor ({{speed}} ms)", "cameraIsOffline": "{{camera}} çevrimdışı", - "detectIsSlow": "{{detect}} yavaş çalışıyor ({{speed}} ms)" + "detectIsSlow": "{{detect}} yavaş çalışıyor ({{speed}} ms)", + "shmTooLow": "Ayrılan /dev/shm belleği (şu anda {{total}} MB), en az {{min}} MB'a çıkartılmalıdır." }, "enrichments": { "embeddings": { diff --git a/web/public/locales/uk/common.json b/web/public/locales/uk/common.json index 0293649713..542ebb27a2 100644 --- a/web/public/locales/uk/common.json +++ b/web/public/locales/uk/common.json @@ -152,7 +152,15 @@ "en": "Англійська", "yue": "粵語 (Кантонська)", "th": "ไทย (Тайська)", - "ca": "Català (Каталанська)" + "ca": "Català (Каталанська)", + "ptBR": "Português brasileiro (Бразильська португальська)", + "sr": "Српски (Сербська)", + "sl": "Slovenščina (Словенська)", + "lt": "Lietuvių (Литовська)", + "bg": "Български (Болгарська)", + "gl": "Galego (Галісійська)", + "id": "Bahasa Indonesia (Індонезійська)", + "ur": "اردو (Урду)" }, "system": "Система", "systemMetrics": "Системна метріка", @@ -178,7 +186,7 @@ }, "restart": "Перезапустити Frigate", "live": { - "title": "Живи", + "title": "Пряма трансляція", "allCameras": "Всi камери", "cameras": { "title": "Камери", @@ -262,5 +270,6 @@ "desc": "Сторінка не знайдена", "title": "404" }, - "selectItem": "Вибрати {{item}}" + "selectItem": "Вибрати {{item}}", + "readTheDocumentation": "Прочитати документацію" } diff --git a/web/public/locales/uk/components/camera.json b/web/public/locales/uk/components/camera.json index 76886a7b8e..0836510e14 100644 --- a/web/public/locales/uk/components/camera.json +++ b/web/public/locales/uk/components/camera.json @@ -50,7 +50,8 @@ }, "stream": "Потік", "placeholder": "Виберіть потік" - } + }, + "birdseye": "Бердсай" }, "edit": "Редагувати групу камер", "delete": { diff --git a/web/public/locales/uk/components/dialog.json b/web/public/locales/uk/components/dialog.json index 43cb9bd9b2..fadbb19e08 100644 --- a/web/public/locales/uk/components/dialog.json +++ b/web/public/locales/uk/components/dialog.json @@ -110,5 +110,12 @@ "content": "Цю сторінку буде перезавантажено за {{countdown}} секунд.", "button": "Примусово перезавантажити" } + }, + "imagePicker": { + "selectImage": "Вибір мініатюри відстежуваного об'єкта", + "search": { + "placeholder": "Пошук за міткою або підміткою..." + }, + "noImages": "Для цієї камери не знайдено мініатюр" } } diff --git a/web/public/locales/uk/components/filter.json b/web/public/locales/uk/components/filter.json index 95c01f3495..5a29434afc 100644 --- a/web/public/locales/uk/components/filter.json +++ b/web/public/locales/uk/components/filter.json @@ -97,7 +97,7 @@ "score": "Рахунок", "estimatedSpeed": "Розрахункова швидкість ({{unit}})", "review": { - "showReviewed": "Показати переглянув" + "showReviewed": "Показувати переглянуті" }, "motion": { "showMotionOnly": "Показати тiльки рух" @@ -121,6 +121,16 @@ "loading": "Завантаження визнаних номерів…", "placeholder": "Введіть для пошуку номерні знаки…", "noLicensePlatesFound": "Номерних знаків не знайдено.", - "selectPlatesFromList": "Виберіть одну або кілька пластин зі списку." + "selectPlatesFromList": "Виберіть одну або кілька пластин зі списку.", + "selectAll": "Вибрати все", + "clearAll": "Очистити все" + }, + "classes": { + "label": "Заняття", + "all": { + "title": "Усі класи" + }, + "count_one": "Клас {{count}}", + "count_other": "{{count}} Класи" } } diff --git a/web/public/locales/uk/views/configEditor.json b/web/public/locales/uk/views/configEditor.json index c9a6641131..0e3ef13cba 100644 --- a/web/public/locales/uk/views/configEditor.json +++ b/web/public/locales/uk/views/configEditor.json @@ -12,5 +12,7 @@ "copyConfig": "Скопіювати конфігурацію", "saveOnly": "Тільки зберегти", "configEditor": "Налаштування редактора", - "confirm": "Вийти без збереження?" + "confirm": "Вийти без збереження?", + "safeConfigEditor": "Редактор конфігурації (безпечний режим)", + "safeModeDescription": "Фрегат перебуває в безпечному режимі через помилку перевірки конфігурації." } diff --git a/web/public/locales/uk/views/events.json b/web/public/locales/uk/views/events.json index e84c418ec8..4496b39151 100644 --- a/web/public/locales/uk/views/events.json +++ b/web/public/locales/uk/views/events.json @@ -34,5 +34,7 @@ "label": "Переглянути нові елементи огляду", "button": "Нові матеріали для перегляду" }, - "detected": "виявлено" + "detected": "виявлено", + "suspiciousActivity": "Підозріла активність", + "threateningActivity": "Загрозлива діяльність" } diff --git a/web/public/locales/uk/views/explore.json b/web/public/locales/uk/views/explore.json index cdbcdb6ee1..f4691c2eed 100644 --- a/web/public/locales/uk/views/explore.json +++ b/web/public/locales/uk/views/explore.json @@ -101,12 +101,14 @@ "success": { "updatedLPR": "Номерний знак успішно оновлено.", "updatedSublabel": "Підмітку успішно оновлено.", - "regenerate": "Новий опис було запрошено від {{provider}}. Залежно від швидкості вашого провайдера, його перегенерація може зайняти деякий час." + "regenerate": "Новий опис було запрошено від {{provider}}. Залежно від швидкості вашого провайдера, його перегенерація може зайняти деякий час.", + "audioTranscription": "Запит на аудіотранскрипцію успішно надіслано." }, "error": { "regenerate": "Не вдалося звернутися до {{provider}} для отримання нового опису: {{errorMessage}}", "updatedSublabelFailed": "Не вдалося оновити підмітку: {{errorMessage}}", - "updatedLPRFailed": "Не вдалося оновити номерний знак: {{errorMessage}}" + "updatedLPRFailed": "Не вдалося оновити номерний знак: {{errorMessage}}", + "audioTranscription": "Не вдалося надіслати запит на транскрипцію аудіо: {{errorMessage}}" } }, "button": { @@ -158,7 +160,10 @@ } }, "expandRegenerationMenu": "Розгорнути меню регенерації", - "regenerateFromSnapshot": "Відновити зі знімка" + "regenerateFromSnapshot": "Відновити зі знімка", + "score": { + "label": "Оцінка" + } }, "dialog": { "confirmDelete": { @@ -193,6 +198,14 @@ }, "deleteTrackedObject": { "label": "Видалити цей відстежуваний об'єкт" + }, + "addTrigger": { + "label": "Додати тригер", + "aria": "Додати тригер для цього відстежуваного об'єкта" + }, + "audioTranscription": { + "label": "Транскрибувати", + "aria": "Запит на аудіотранскрипцію" } }, "noTrackedObjects": "Відстежуваних об'єктів не знайдено", @@ -205,5 +218,11 @@ "video": "відео", "object_lifecycle": "життєвий цикл об'єкта" }, - "exploreMore": "Дослідіть більше об'єктів {{label}}" + "exploreMore": "Дослідіть більше об'єктів {{label}}", + "aiAnalysis": { + "title": "Аналіз ШІ" + }, + "concerns": { + "label": "Проблеми" + } } diff --git a/web/public/locales/uk/views/live.json b/web/public/locales/uk/views/live.json index 27a8c518a4..e8ce0aa8d8 100644 --- a/web/public/locales/uk/views/live.json +++ b/web/public/locales/uk/views/live.json @@ -85,6 +85,14 @@ "center": { "label": "Клацніть у кадрі, щоб відцентрувати камеру PTZ" } + }, + "focus": { + "in": { + "label": "Фокус PTZ-камери" + }, + "out": { + "label": "Вихід PTZ-камери для фокусування" + } } }, "editLayout": { @@ -94,7 +102,7 @@ "label": "Редагувати групу камер" } }, - "documentTitle": "Прямий трансляція - Frigate", + "documentTitle": "Пряма трансляція - Frigate", "documentTitle.withCamera": "{{camera}} - Пряма трансляція - Frigate", "lowBandwidthMode": "Економічний режим", "twoWayTalk": { @@ -142,7 +150,8 @@ "recording": "Записування", "snapshots": "Знімки", "audioDetection": "Виявлення звуку", - "autotracking": "Автотрекiнг" + "autotracking": "Автотрекiнг", + "transcription": "Аудіотранскрипція" }, "history": { "label": "Показати історичні кадри" @@ -154,5 +163,9 @@ "active_objects": "Активні об'єкти" }, "notAllTips": "Ваш {{source}} конфігурацію збереження записів встановлено на режим: {{effectiveRetainMode}}, тому цей запис на вимогу збереже лише сегменти з {{effectiveRetainModeName}}." + }, + "transcription": { + "enable": "Увімкнути транскрипцію аудіо в реальному часі", + "disable": "Вимкнути транскрипцію аудіо в реальному часі" } } diff --git a/web/public/locales/uk/views/settings.json b/web/public/locales/uk/views/settings.json index a5e7d511f6..24ff6db8b7 100644 --- a/web/public/locales/uk/views/settings.json +++ b/web/public/locales/uk/views/settings.json @@ -86,7 +86,45 @@ "title": "Огляд", "desc": "Тимчасово ввімкнути/вимкнути сповіщення та виявлення для цієї камери до перезавантаження Frigate. Якщо вимкнено, нові елементи огляду не створюватимуться. " }, - "title": "Налаштування камери" + "title": "Налаштування камери", + "object_descriptions": { + "title": "Генеративні описи об'єктів штучного інтелекту", + "desc": "Тимчасово ввімкнути/вимкнути генеративні описи об'єктів ШІ для цієї камери. Якщо вимкнено, згенеровані ШІ описи не запитуватимуться для об'єктів, що відстежуються на цій камері." + }, + "review_descriptions": { + "title": "Описи генеративного ШІ-огляду", + "desc": "Тимчасово ввімкнути/вимкнути генеративні описи огляду за допомогою штучного інтелекту для цієї камери. Якщо вимкнено, для елементів огляду на цій камері не запитуватимуться згенеровані штучним інтелектом описи." + }, + "addCamera": "Додати нову камеру", + "editCamera": "Редагувати камеру:", + "selectCamera": "Виберіть камеру", + "backToSettings": "Назад до налаштувань камери", + "cameraConfig": { + "add": "Додати камеру", + "edit": "Редагувати камеру", + "description": "Налаштуйте параметри камери, включаючи потокові входи та ролі.", + "name": "Назва камери", + "nameRequired": "Потрібно вказати назву камери", + "nameInvalid": "Назва камери повинна містити лише літери, цифри, символи підкреслення або дефіси", + "namePlaceholder": "наприклад, вхідні_двері", + "enabled": "Увімкнено", + "ffmpeg": { + "inputs": "Вхідні потоки", + "path": "Шлях потоку", + "pathRequired": "Шлях потоку обов'язковий", + "pathPlaceholder": "'rtsp://...", + "roles": "Ролі", + "rolesRequired": "Потрібна хоча б одна роль", + "rolesUnique": "Кожна роль (аудіо, виявлення, запис) може бути призначена лише одному потоку", + "addInput": "Додати вхідний потік", + "removeInput": "Вилучити вхідний потік", + "inputsRequired": "Потрібен принаймні один вхідний потік" + }, + "toast": { + "success": "Камеру {{cameraName}} успішно збережено" + }, + "nameLength": "Назва камери має містити менше 24 символів." + } }, "masksAndZones": { "motionMasks": { @@ -308,7 +346,20 @@ "tips": "

    Поля руху


    Червоні поля будуть накладені на області кадру, де наразі виявляється рух

    " }, "objectList": "Список об'єктів", - "noObjects": "Без об'єктів" + "noObjects": "Без об'єктів", + "paths": { + "title": "Шляхи", + "desc": "Показувати важливі точки шляху відстежуваного об'єкта", + "tips": "

    Шляхи


    Лінії та кола позначатимуть важливі точки, які відстежуваний об'єкт переміщував протягом свого життєвого циклу.

    " + }, + "audio": { + "title": "Аудіо", + "noAudioDetections": "Немає виявлення звуку", + "score": "рахунок", + "currentRMS": "Поточне середньоквадратичне значення", + "currentdbFS": "Поточний dbFS" + }, + "openCameraWebUI": "Відкрийте веб-інтерфейс {{camera}}" }, "classification": { "licensePlateRecognition": { @@ -383,7 +434,7 @@ "supportedDetectors": "Підтримувані детектори", "error": "Не вдалося завантажити інформацію про модель", "availableModels": "Доступні моделі", - "trainDate": "Дата поїзда", + "trainDate": "Дата тренування", "baseModel": "Базова модель", "modelSelect": "Тут можна вибрати доступні моделі на Frigate+. Зверніть увагу, що можна вибрати лише моделі, сумісні з вашою поточною конфігурацією детектора.", "title": "Інформація про модель", @@ -498,7 +549,7 @@ "classification": "Налаштування класифікації – Фрегат", "masksAndZones": "Редактор масок та зон – Фрегат", "motionTuner": "Тюнер руху - Фрегат", - "general": "Основна налаштування – Frigate", + "general": "Основна Налаштуваннях – Frigate", "frigatePlus": "Налаштування Frigate+ – Frigate", "enrichments": "Налаштуваннях збагачення – Frigate" }, @@ -512,7 +563,8 @@ "debug": "Налагодження", "notifications": "Сповіщення", "frigateplus": "Frigate+", - "enrichments": "Збагачення" + "enrichments": "Збагачення", + "triggers": "Тригери" }, "dialog": { "unsavedChanges": { @@ -594,7 +646,8 @@ "intro": "Виберіть відповідну роль для цього користувача:", "adminDesc": "Повний доступ до всіх функцій.", "viewer": "Глядач", - "viewerDesc": "Обмежено лише активними інформаційними панелями, функціями «Огляд», «Дослідження» та «Експорт»." + "viewerDesc": "Обмежено лише активними інформаційними панелями, функціями «Огляд», «Дослідження» та «Експорт».", + "customDesc": "Особлива роль з доступом до певної камери." }, "title": "Змінити роль користувача", "desc": "Оновити дозволи для {{username}}", @@ -681,6 +734,161 @@ "desc": "Класифікація птахів ідентифікує відомих птахів за допомогою квантованої моделі тензорного потоку. Коли відомого птаха розпізнано, його загальну назву буде додано як підмітку. Ця інформація відображається в інтерфейсі, фільтрах, а також у сповіщеннях.", "title": "Класифікація птахів" }, - "title": "Налаштуваннях збагачення" + "title": "Налаштуваннях Збагаченням" + }, + "triggers": { + "documentTitle": "Тригери", + "management": { + "title": "Управління тригерами", + "desc": "Керуйте тригерами для {{camera}}. Використовуйте тип мініатюри для спрацьовування на схожих мініатюрах до вибраного об’єкта відстеження, а тип опису – для спрацьовування на схожих описах до вказаного вами тексту." + }, + "addTrigger": "Додати Тригер", + "table": { + "name": "Ім'я", + "type": "Тип", + "content": "Зміст", + "threshold": "Поріг", + "actions": "Дії", + "noTriggers": "Для цієї камери не налаштовано жодних тригерів.", + "edit": "Редагувати", + "deleteTrigger": "Видалити тригер", + "lastTriggered": "Остання активація" + }, + "type": { + "thumbnail": "Мініатюра", + "description": "Опис" + }, + "actions": { + "alert": "Позначити як сповіщення", + "notification": "Надіслати сповіщення" + }, + "dialog": { + "createTrigger": { + "title": "Створити тригер", + "desc": "Створіть тригер для камери {{camera}}" + }, + "editTrigger": { + "title": "Редагувати тригер", + "desc": "Редагувати налаштування для тригера на камері {{camera}}" + }, + "deleteTrigger": { + "title": "Видалити тригер", + "desc": "Ви впевнені, що хочете видалити тригер {{triggerName}}? Цю дію не можна скасувати." + }, + "form": { + "name": { + "title": "Ім'я", + "placeholder": "Введіть назву тригера", + "error": { + "minLength": "Ім'я має містити щонайменше 2 символи.", + "invalidCharacters": "Ім'я може містити лише літери, цифри, символи підкреслення та дефіси.", + "alreadyExists": "Тригер із такою назвою вже існує для цієї камери." + } + }, + "enabled": { + "description": "Увімкнути або вимкнути цей тригер" + }, + "type": { + "title": "Тип", + "placeholder": "Виберіть тип тригера" + }, + "content": { + "title": "Зміст", + "imagePlaceholder": "Виберіть зображення", + "textPlaceholder": "Введіть текстовий вміст", + "imageDesc": "Виберіть зображення, щоб запустити цю дію, коли буде виявлено схоже зображення.", + "textDesc": "Введіть текст, щоб запустити цю дію, коли буде виявлено схожий опис відстежуваного об’єкта.", + "error": { + "required": "Контент обов'язковий." + } + }, + "threshold": { + "title": "Поріг", + "error": { + "min": "Поріг має бути щонайменше 0", + "max": "Поріг має бути не більше 1" + } + }, + "actions": { + "title": "Дії", + "desc": "За замовчуванням Frigate надсилає повідомлення MQTT для всіх тригерів. Виберіть додаткову дію, яку потрібно виконати, коли цей тригер спрацьовує.", + "error": { + "min": "Потрібно вибрати принаймні одну дію." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Тригер {{name}} успішно створено.", + "updateTrigger": "Тригер {{name}} успішно оновлено.", + "deleteTrigger": "Тригер {{name}} успішно видалено." + }, + "error": { + "createTriggerFailed": "Не вдалося створити тригер: {{errorMessage}}", + "updateTriggerFailed": "Не вдалося оновити тригер: {{errorMessage}}", + "deleteTriggerFailed": "Не вдалося видалити тригер: {{errorMessage}}" + } + } + }, + "roles": { + "addRole": "Додати роль", + "table": { + "role": "Роль", + "cameras": "Камери", + "actions": "Дії", + "noRoles": "Не знайдено користувацьких ролей.", + "editCameras": "Редагувати камери", + "deleteRole": "Видалити роль" + }, + "toast": { + "success": { + "createRole": "Роль {{role}} успішно створена", + "updateCameras": "Камери оновлено для ролі {{role}}", + "deleteRole": "Роль {{role}} успішно видалено", + "userRolesUpdated": "Користувачів ({{count}}), яким призначено цю роль, оновлено до ролі «глядача», що має доступ до всіх камер." + }, + "error": { + "createRoleFailed": "Не вдалося створити роль: {{errorMessage}}", + "updateCamerasFailed": "Не вдалося оновити камери: {{errorMessage}}", + "deleteRoleFailed": "Не вдалося видалити роль: {{errorMessage}}", + "userUpdateFailed": "Не вдалося оновити ролі користувачів: {{errorMessage}}" + } + }, + "management": { + "title": "Керування ролями глядача", + "desc": "Керуйте ролями глядачів та їхніми дозволами на доступ до камери для цього екземпляра Frigate." + }, + "dialog": { + "createRole": { + "title": "Створити нову роль", + "desc": "Додайте нову роль і вкажіть дозволи доступу до камери." + }, + "editCameras": { + "title": "Редагувати рольові камери", + "desc": "Оновіть доступ до камери для цієї ролі {{role}}." + }, + "deleteRole": { + "title": "Видалити роль", + "desc": "Цю дію не можна скасувати. Це призведе до остаточного видалення ролі та призначення всім користувачам із цією роллю ролі «глядач», що надасть глядачеві доступ до всіх камер.", + "warn": "Ви впевнені, що хочете видалити {{role}}?", + "deleting": "Видалення..." + }, + "form": { + "role": { + "title": "Назва ролі", + "placeholder": "Введіть назву ролі", + "desc": "Дозволено використовувати лише літери, цифри, крапки та символи підкреслення.", + "roleIsRequired": "Потрібно вказати назву ролі", + "roleOnlyInclude": "Назва ролі може містити лише літери, цифри, символи *.* або *.*", + "roleExists": "Роль із такою назвою вже існує." + }, + "cameras": { + "title": "Камери", + "desc": "Виберіть камери, до яких ця роль має доступ. Потрібна принаймні одна камера.", + "required": "Потрібно вибрати принаймні одну камеру." + } + } + } } } diff --git a/web/public/locales/uk/views/system.json b/web/public/locales/uk/views/system.json index b1472f7a78..5898da3eba 100644 --- a/web/public/locales/uk/views/system.json +++ b/web/public/locales/uk/views/system.json @@ -107,7 +107,8 @@ "title": "Детектори", "inferenceSpeed": "Швидкість виведення детектора", "cpuUsage": "Використання процесора детектора", - "memoryUsage": "Використання пам'яті детектора" + "memoryUsage": "Використання пам'яті детектора", + "cpuUsageInformation": "Процесор, що використовується для підготовки вхідних та вихідних даних до/з моделей виявлення. Це значення не вимірює використання логічного висновку, навіть якщо використовується графічний процесор або прискорювач." } }, "storage": { @@ -129,7 +130,12 @@ "tips": "Це значення відображає загальний обсяг пам’яті, що використовується записами в базі даних Frigate. Frigate не відстежує використання пам’яті для всіх файлів на вашому диску.", "earliestRecording": "Найдавніший доступний запис:" }, - "title": "Зберігання" + "title": "Зберігання", + "shm": { + "title": "Розподіл спільної пам'яті (SHM)", + "warning": "Поточний розмір SHM, що становить {{total}} МБ, замалий. Збільште його принаймні до {{min_shm}} МБ.", + "readTheDocumentation": "Прочитайте документацію" + } }, "lastRefreshed": "Останнє оновлення: ", "stats": { @@ -139,7 +145,8 @@ "reindexingEmbeddings": "Переіндексація вбудовування (виконано {{processed}}%)", "cameraIsOffline": "{{camera}} не в мережі", "detectIsSlow": "{{detect}} повільний ({{speed}} мс)", - "detectIsVerySlow": "{{detect}} дуже повільний ({{speed}} мс)" + "detectIsVerySlow": "{{detect}} дуже повільний ({{speed}} мс)", + "shmTooLow": "Розмір /dev/shm ({{total}} МБ) слід збільшити щонайменше до {{min}} МБ." }, "documentTitle": { "cameras": "Статистика камер - Фрегат", diff --git a/web/public/locales/ur/common.json b/web/public/locales/ur/common.json index dbf35b3b6c..37ff068c53 100644 --- a/web/public/locales/ur/common.json +++ b/web/public/locales/ur/common.json @@ -34,5 +34,6 @@ "month_other": "{{time}} مہینے", "hour_one": "{{time}} گھنٹہ", "hour_other": "{{time}} گھنٹے" - } + }, + "readTheDocumentation": "دستاویز پڑھیں" } diff --git a/web/public/locales/vi/common.json b/web/public/locales/vi/common.json index af34c5ee34..843675f425 100644 --- a/web/public/locales/vi/common.json +++ b/web/public/locales/vi/common.json @@ -121,7 +121,15 @@ }, "yue": "粵語 (Tiếng Quảng Đông)", "ca": "Català (Tiếng Catalan)", - "th": "ไทย (Tiếng Thái)" + "th": "ไทย (Tiếng Thái)", + "ptBR": "Português brasileiro (Brazilian Portuguese)", + "sr": "Српски (Serbian)", + "sl": "Slovenščina (Slovenian)", + "lt": "Lietuvių (Lithuanian)", + "bg": "Български (Bulgarian)", + "gl": "Galego (Galician)", + "id": "Bahasa Indonesia (Indonesian)", + "ur": "اردو (Urdu)" }, "system": "Hệ thống", "systemMetrics": "Thông số hệ thống", @@ -257,5 +265,6 @@ "title": "Không tìm thấy", "desc": "Trang bạn đang tìm không tồn tại" }, - "selectItem": "Chọn mục {{item}}" + "selectItem": "Chọn mục {{item}}", + "readTheDocumentation": "Đọc tài liệu" } diff --git a/web/public/locales/vi/components/camera.json b/web/public/locales/vi/components/camera.json index 07617eb473..e67824e7bc 100644 --- a/web/public/locales/vi/components/camera.json +++ b/web/public/locales/vi/components/camera.json @@ -49,7 +49,8 @@ "audioIsAvailable": "Âm thanh có sẵn cho luồng này", "audioIsUnavailable": "Âm thanh không có sẵn cho luồng này", "label": "Cài đặt trực tiếp Camera" - } + }, + "birdseye": "Toàn cảnh" }, "name": { "label": "Tên", diff --git a/web/public/locales/vi/components/dialog.json b/web/public/locales/vi/components/dialog.json index 53b1226b10..5cef1da16c 100644 --- a/web/public/locales/vi/components/dialog.json +++ b/web/public/locales/vi/components/dialog.json @@ -108,5 +108,12 @@ "placeholder": "Nhập tên cho tìm kiếm của bạn", "overwrite": "{{searchName}} đã tồn tại. Lưu sẽ ghi đè lên giá trị hiện có." } + }, + "imagePicker": { + "selectImage": "Chọn hình thu nhỏ của đối tượng cần theo dõi", + "search": { + "placeholder": "Tìm theo nhãn hoặc nhãn phụ..." + }, + "noImages": "Không tìm thấy hình thu nhỏ cho camera này" } } diff --git a/web/public/locales/vi/components/filter.json b/web/public/locales/vi/components/filter.json index 1570067ab2..3678ba1ab8 100644 --- a/web/public/locales/vi/components/filter.json +++ b/web/public/locales/vi/components/filter.json @@ -93,7 +93,9 @@ "loadFailed": "Không thể tải biển số xe được nhận dạng.", "loading": "Đang tải biển số xe được nhận dạng…", "placeholder": "Nhập để tìm kiếm biển số xe…", - "noLicensePlatesFound": "Không tìm thấy biển số xe nào." + "noLicensePlatesFound": "Không tìm thấy biển số xe nào.", + "selectAll": "Chọn tất cả", + "clearAll": "Xóa tất cả" }, "more": "Thêm Bộ lọc", "reset": { @@ -122,5 +124,13 @@ "title": "Tất cả Khu vực", "short": "Khu vực" } + }, + "classes": { + "label": "Các nhãn nhận diện", + "all": { + "title": "Tất cả nhãn nhận diện" + }, + "count_one": "{{count}} Nhãn nhận diện", + "count_other": "{{count}} Các nhãn nhận diện" } } diff --git a/web/public/locales/vi/views/configEditor.json b/web/public/locales/vi/views/configEditor.json index a9a0c4f829..a2ffce4a9d 100644 --- a/web/public/locales/vi/views/configEditor.json +++ b/web/public/locales/vi/views/configEditor.json @@ -12,5 +12,7 @@ } }, "configEditor": "Trình chỉnh sửa cấu hình", - "documentTitle": "Trình chỉnh sửa - Frigate" + "documentTitle": "Trình chỉnh sửa - Frigate", + "safeConfigEditor": "Chỉnh sửa cấu hình (Chế độ an toàn)", + "safeModeDescription": "Frigate đang ở chế độ an toàn do lỗi kiểm tra cấu hình." } diff --git a/web/public/locales/vi/views/events.json b/web/public/locales/vi/views/events.json index 4259ab2cc7..c3bdad4975 100644 --- a/web/public/locales/vi/views/events.json +++ b/web/public/locales/vi/views/events.json @@ -34,5 +34,7 @@ "button": "Các mục mới cần xem xét" }, "markAsReviewed": "Đánh dấu là đã xem xét", - "markTheseItemsAsReviewed": "Đánh dấu các mục này là đã xem xét" + "markTheseItemsAsReviewed": "Đánh dấu các mục này là đã xem xét", + "suspiciousActivity": "Hoạt động đáng ngờ", + "threateningActivity": "Hoạt động đe dọa" } diff --git a/web/public/locales/vi/views/explore.json b/web/public/locales/vi/views/explore.json index 99e4a65d59..82b3b7ad04 100644 --- a/web/public/locales/vi/views/explore.json +++ b/web/public/locales/vi/views/explore.json @@ -60,12 +60,14 @@ "error": { "updatedSublabelFailed": "Không thể cập nhật nhãn phụ: {{errorMessage}}", "updatedLPRFailed": "Không thể cập nhật biển số xe: {{errorMessage}}", - "regenerate": "Không thể gọi {{provider}} để lấy mô tả mới: {{errorMessage}}" + "regenerate": "Không thể gọi {{provider}} để lấy mô tả mới: {{errorMessage}}", + "audioTranscription": "Không thể yêu cầu phiên âm: {{errorMessage}}" }, "success": { "regenerate": "Một mô tả mới đã được yêu cầu từ {{provider}}. Tùy thuộc vào tốc độ của nhà cung cấp của bạn, mô tả mới có thể mất một chút thời gian để tạo lại.", "updatedLPR": "Cập nhật biển số xe thành công.", - "updatedSublabel": "Cập nhật nhãn phụ thành công." + "updatedSublabel": "Cập nhật nhãn phụ thành công.", + "audioTranscription": "Đã yêu cầu phiên âm thành công." } }, "tips": { @@ -115,6 +117,9 @@ "title": "Chỉnh sửa biển số xe", "desc": "Nhập một giá trị biển số xe mới cho {{label}} này", "descNoLabel": "Nhập một giá trị biển số xe mới cho đối tượng được theo dõi này" + }, + "score": { + "label": "Điểm tin cậy" } }, "itemMenu": { @@ -144,6 +149,14 @@ }, "deleteTrackedObject": { "label": "Xóa đối tượng được theo dõi này" + }, + "addTrigger": { + "label": "Thêm sự kiện kích hoạt", + "aria": "Thêm sự kiện kích hoạt cho đối tượng này." + }, + "audioTranscription": { + "label": "Phiên âm", + "aria": "Yêu cầu phiên âm" } }, "exploreIsUnavailable": { @@ -201,5 +214,11 @@ "fetchingTrackedObjectsFailed": "Lỗi khi tìm nạp các đối tượng được theo dõi: {{errorMessage}}", "documentTitle": "Khám phá - Frigate", "generativeAI": "AI Tạo sinh", - "trackedObjectsCount_other": "{{count}} đối tượng được theo dõi " + "trackedObjectsCount_other": "{{count}} đối tượng được theo dõi ", + "aiAnalysis": { + "title": "Phân tích bằng AI" + }, + "concerns": { + "label": "Mối lo ngại" + } } diff --git a/web/public/locales/vi/views/live.json b/web/public/locales/vi/views/live.json index 3e8ab44f68..ec194ba323 100644 --- a/web/public/locales/vi/views/live.json +++ b/web/public/locales/vi/views/live.json @@ -71,7 +71,15 @@ "label": "Di chuyển camera PTZ sang phải" } }, - "presets": "Các thiết lập sẵn cho camera PTZ" + "presets": "Các thiết lập sẵn cho camera PTZ", + "focus": { + "in": { + "label": "Lấy nét gần (camera PTZ)" + }, + "out": { + "label": "Lấy nét xa (camera PTZ)" + } + } }, "manualRecording": { "playInBackground": { @@ -142,7 +150,8 @@ "recording": "Ghi hình", "snapshots": "Ảnh chụp", "audioDetection": "Phát hiện âm thanh", - "autotracking": "Tự động theo dõi" + "autotracking": "Tự động theo dõi", + "transcription": "Phiên âm" }, "history": { "label": "Hiện cảnh quay lịch sử" @@ -154,5 +163,9 @@ "active_objects": "Đối tượng hoạt động" }, "notAllTips": "Cấu hình giữ lại ghi hình {{source}} của bạn được đặt là mode: {{effectiveRetainMode}}, vì vậy lần ghi hình theo yêu cầu này chỉ giữ lại các đoạn có {{effectiveRetainModeName}}." + }, + "transcription": { + "enable": "Bật phiên âm trực tiếp", + "disable": "Tắt phiên âm trực tiếp" } } diff --git a/web/public/locales/vi/views/settings.json b/web/public/locales/vi/views/settings.json index 4f09724253..03b17d4ae2 100644 --- a/web/public/locales/vi/views/settings.json +++ b/web/public/locales/vi/views/settings.json @@ -77,7 +77,7 @@ }, "snapshotConfig": { "table": { - "camera": "Camera", + "camera": "Máy quay", "cleanCopySnapshots": "Ảnh chụp nhanh clean_copy", "snapshots": "Ảnh chụp nhanh" }, @@ -142,6 +142,44 @@ "streams": { "title": "Luồng phát", "desc": "Tạm thời vô hiệu hóa một camera cho đến khi Frigate khởi động lại. Vô hiệu hóa một camera sẽ dừng hoàn toàn quá trình xử lý các luồng của camera này của Frigate. Việc phát hiện, ghi hình và gỡ lỗi sẽ không khả dụng.
    Lưu ý: Điều này không vô hiệu hóa các luồng phát lại của go2rtc." + }, + "object_descriptions": { + "title": "Mô tả đối tượng bằng AI tạo sinh", + "desc": "Tạm thời bật/tắt mô tả đối tượng bằng AI tạo sinh cho camera này. Khi tắt, mô tả do AI tạo sinh sẽ không được yêu cầu cho các đối tượng được theo dõi trên camera này." + }, + "review_descriptions": { + "title": "Mô tả đánh giá bằng AI tạo sinh", + "desc": "Tạm thời bật/tắt mô tả xem lại bằng AI tạo sinh cho camera này. Khi tắt, mô tả do AI tạo sinh sẽ không được yêu cầu cho các mục xem lại trên camera này." + }, + "addCamera": "Thêm Camera mới", + "editCamera": "Chỉnh sửa Camera:", + "selectCamera": "Chọn Camera", + "backToSettings": "Quay lại cài đặt Camera", + "cameraConfig": { + "add": "Thêm Camera", + "edit": "Chỉnh sửa Camera", + "description": "Cấu hình Camera, bao gồm luồng đầu vào và vai trò.", + "name": "Tên Camera", + "nameRequired": "Yêu cầu nhập tên Camera", + "nameInvalid": "Tên Camera chỉ được chứa chữ cái, số, dấu gạch dưới hoặc dấu gạch ngang", + "namePlaceholder": "Ví dụ: front_door", + "enabled": "Bật", + "ffmpeg": { + "inputs": "Luồng đầu vào", + "path": "Đường dẫn luồng", + "pathRequired": "Yêu cầu nhập đường dẫn luồng", + "pathPlaceholder": "rtsp://...", + "roles": "Vai trò", + "rolesRequired": "Cần ít nhất một vai trò", + "rolesUnique": "Mỗi vai trò (âm thanh, phát hiện, ghi hình) chỉ có thể được gán cho một luồng duy nhất", + "addInput": "Thêm luồng đầu vào", + "removeInput": "Xóa luồng đầu vào", + "inputsRequired": "Cần ít nhất một luồng đầu vào" + }, + "toast": { + "success": "Camera {{cameraName}} đã được lưu thành công" + }, + "nameLength": "Tên của camera phải dưới 24 ký tự." } }, "masksAndZones": { @@ -381,6 +419,19 @@ "desc": "Hiển thị các hộp xung quanh các khu vực phát hiện có chuyển động", "tips": "

    Hộp chuyển động


    Các hộp màu đỏ sẽ được chồng lên các khu vực của khung hình nơi chuyển động đang được phát hiện

    ", "title": "Hộp chuyển động" + }, + "paths": { + "title": "Đường dẫn", + "desc": "Hiển thị các điểm quan trọng trên đường đi của đối tượng được theo dõi", + "tips": "

    Đường đi


    Đường thẳng và vòng tròn sẽ hiển thị các điểm quan trọng mà đối tượng được theo dõi đã di chuyển trong suốt quá trình theo dõi.

    " + }, + "openCameraWebUI": "Đang mở giao diện Web của {{camera}}", + "audio": { + "title": "Âm thanh", + "noAudioDetections": "Không phát hiện âm thanh", + "score": "điểm", + "currentRMS": "RMS hiện tại", + "currentdbFS": "dbFS hiện tại" } }, "users": { @@ -607,10 +658,106 @@ "notifications": "Thông báo", "motionTuner": "Tinh chỉnh Chuyển động", "cameras": "Cài đặt Camera", - "enrichments": "Làm giàu Dữ liệu" + "enrichments": "Làm giàu Dữ liệu", + "triggers": "Sự kiện kích hoạt" }, "cameraSetting": { - "camera": "Camera", + "camera": "Máy quay", "noCamera": "Không có Camera" + }, + "triggers": { + "documentTitle": "Sự kiện kích hoạt", + "management": { + "title": "Quản lý sự kiện kích hoạt", + "desc": "Quản lý sự kiện kích hoạt cho {{camera}}. Sử dụng kiểu \"ảnh xem trước\" để kích hoạt dựa trên ảnh xem trước tương tự cho đối tượng cần theo dõi đã chọn, và kiểu \"mô tả\" để kích hoạt dựa trên những mô tả tương tự cho đoạn văn bản bạn đã chỉ định." + }, + "addTrigger": "Thêm sự kiện kích hoạt", + "table": { + "content": "Nội dung", + "threshold": "Ngưỡng", + "actions": "Hành động", + "noTriggers": "Không có sự kiện kích hoạt được cài đặt cho máy quay này.", + "type": "Kiểu", + "name": "Tên", + "deleteTrigger": "Xóa sự kiện kích hoạt", + "lastTriggered": "Lần kích hoạt gần nhất", + "edit": "Chỉnh sửa" + }, + "type": { + "description": "Mô tả", + "thumbnail": "Ảnh xem trước" + }, + "dialog": { + "form": { + "enabled": { + "description": "Kích hoạt hoặc vô hiệu hóa sự kiện kích hoạt này" + }, + "actions": { + "title": "Các hành động", + "desc": "Theo mặc định, hệ thống đã luôn tự động ghi nhận lại mọi sự kiện. Bạn có thể chọn thêm một hành động khác để thực hiện khi sự kiện này xảy ra.", + "error": { + "min": "Phải chọn ít nhất một hành động." + } + }, + "name": { + "title": "Tên", + "placeholder": "Nhập tên sự kiện kích hoạt", + "error": { + "minLength": "Tên phải có độ dài ít nhất 2 ký tự.", + "invalidCharacters": "Tên chỉ có thể chứa ký tự, chữ số, gạch chân, và gạch nối.", + "alreadyExists": "Một sự kiện kích hoạt trùng tên đã tồn tại cho máy quay này." + } + }, + "type": { + "title": "Kiểu", + "placeholder": "Chọn kiểu cho sự kiện kích hoạt" + }, + "content": { + "title": "Nội dung", + "imagePlaceholder": "Chọn một hình ảnh", + "textPlaceholder": "Nhập nội dung văn bản", + "imageDesc": "Chọn một hình ảnh để kích hoạt hành động này khi một hình ảnh tương tự được phát hiện.", + "textDesc": "Nhập vẵn bản để kích hoạt hành động này khi một đối tượng theo dõi với mô tả tương tự được phát hiện.", + "error": { + "required": "Nội dung bắt buộc." + } + }, + "threshold": { + "title": "Ngưỡng", + "error": { + "min": "Ngưỡng phải ít nhất bằng 0", + "max": "Ngưỡng lớn nhất phải bé hơn 1" + } + } + }, + "createTrigger": { + "title": "Tạo sự kiện kích hoạt", + "desc": "Tạo sự kiện kích hoạt cho máy quay {{camera}}" + }, + "editTrigger": { + "title": "Chỉnh sửa Sự kiện kích hoạt", + "desc": "Chỉnh sửa cài đặt cho sự kiện kích hoạt trên máy quay {{camera}}" + }, + "deleteTrigger": { + "title": "Xóa Sự kiện kích hoạt", + "desc": "Bạn có chắc chắn muốn xóa sự kịn kích hoạt {{triggerName}}? Thao tác này không thể khôi phục được." + } + }, + "toast": { + "success": { + "createTrigger": "Sự kiện kích hoạt {{name}} đã được tạo thành công.", + "updateTrigger": "Sự kiện kích hoạt {{name}} đã được cập nhật thành công.", + "deleteTrigger": "Sự kiện kích hoạt {{name}} đã được xóa thành công." + }, + "error": { + "createTriggerFailed": "Tạo sự kiện kích hoạt thất bại: {{errorMessage}}", + "updateTriggerFailed": "Cập nhật sự kiện kích hoạt thất bại: {{errorMessage}}", + "deleteTriggerFailed": "Xóa sự kiện kích hoạt thất bại: {{errorMessage}}" + } + }, + "actions": { + "alert": "Gắn nhãn Cảnh báo", + "notification": "Gửi thông báo" + } } } diff --git a/web/public/locales/vi/views/system.json b/web/public/locales/vi/views/system.json index 31da0a0865..c1549033a4 100644 --- a/web/public/locales/vi/views/system.json +++ b/web/public/locales/vi/views/system.json @@ -54,7 +54,8 @@ "memoryUsage": "Mức sử dụng Bộ nhớ của Bộ phát hiện", "title": "Bộ phát hiện", "inferenceSpeed": "Tốc độ Suy luận của Bộ phát hiện", - "cpuUsage": "Mức sử dụng CPU của Bộ phát hiện" + "cpuUsage": "Mức sử dụng CPU của Bộ phát hiện", + "cpuUsageInformation": "Dùng CPU để chuẩn bị đầu vào và ngõ ra dữ liệu dùng cho mẫu nhận dạng. Giá trị này không đo lường mức sử dụng suy luận, ngay cả khi sử dụng GPU hoặc bộ tăng tốc." }, "title": "Chung" }, @@ -77,6 +78,10 @@ "title": "Bản ghi", "tips": "Giá trị này thể hiện tổng dung lượng lưu trữ được sử dụng bởi các bản ghi trong cơ sở dữ liệu của Frigate. Frigate không theo dõi việc sử dụng dung lượng lưu trữ cho tất cả các tệp trên đĩa của bạn.", "earliestRecording": "Bản ghi sớm nhất hiện có:" + }, + "shm": { + "title": "Sắp xếp bộ nhớ được chia sẻ (SHM)", + "warning": "Bộ nhớ chia sẻ hiện tại quá thấp {{total}}MB. Tăng lên tối thiểu là {{min_shm}}MB." } }, "cameras": { @@ -133,7 +138,8 @@ "ffmpegHighCpuUsage": "{{camera}} có mức sử dụng CPU FFmpeg cao ({{ffmpegAvg}}%)", "detectHighCpuUsage": "{{camera}} có mức sử dụng CPU phát hiện cao ({{detectAvg}}%)", "healthy": "Hệ thống đang hoạt động tốt", - "reindexingEmbeddings": "Đang lập chỉ mục lại các embedding (hoàn thành {{processed}}%)" + "reindexingEmbeddings": "Đang lập chỉ mục lại các embedding (hoàn thành {{processed}}%)", + "shmTooLow": "/dev/shm ({{total}} MB) cần được tăng lên tối thiểu {{min}} MB." }, "enrichments": { "embeddings": { diff --git a/web/public/locales/yue-Hant/common.json b/web/public/locales/yue-Hant/common.json index 03f4f89b4e..60bb2a2c6e 100644 --- a/web/public/locales/yue-Hant/common.json +++ b/web/public/locales/yue-Hant/common.json @@ -248,5 +248,6 @@ "documentTitle": "找不到頁面 - Frigate", "desc": "找不到頁面", "title": "404" - } + }, + "readTheDocumentation": "閱讀文件" } diff --git a/web/public/locales/zh-CN/common.json b/web/public/locales/zh-CN/common.json index 1c253aee47..2c028bcb29 100644 --- a/web/public/locales/zh-CN/common.json +++ b/web/public/locales/zh-CN/common.json @@ -181,7 +181,15 @@ "cs": "捷克语 (Čeština)", "yue": "粤语 (粵語)", "th": "泰语(ไทย)", - "ca": "加泰罗尼亚语 (Català )" + "ca": "加泰罗尼亚语 (Català )", + "ptBR": "巴西葡萄牙语 (Português brasileiro)", + "sr": "塞尔维亚语 (Српски)", + "sl": "斯洛文尼亚语 (Slovenščina)", + "lt": "立陶宛语 (Lietuvių)", + "bg": "保加利亚语 (Български)", + "gl": "加利西亚语 (Galego)", + "id": "印度尼西亚语 (Bahasa Indonesia)", + "ur": "乌尔都语 (اردو)" }, "appearance": "外观", "darkMode": { @@ -257,5 +265,6 @@ "title": "404", "desc": "页面未找到" }, - "selectItem": "选择 {{item}}" + "selectItem": "选择 {{item}}", + "readTheDocumentation": "阅读文档" } diff --git a/web/public/locales/zh-CN/components/dialog.json b/web/public/locales/zh-CN/components/dialog.json index e7670d1e6c..3cc58c5416 100644 --- a/web/public/locales/zh-CN/components/dialog.json +++ b/web/public/locales/zh-CN/components/dialog.json @@ -28,9 +28,9 @@ }, "question": { "label": "为 Frigate Plus 确认此标签", - "ask_a": "这个对象是 {{label}} 吗?", - "ask_an": "这个对象是 {{label}} 吗?", - "ask_full": "这个对象是 {{untranslatedLabel}} ({{translatedLabel}}) 吗?" + "ask_a": "这个目标/物体是 {{label}} 吗?", + "ask_an": "这个目标/物体是 {{label}} 吗?", + "ask_full": "这个目标/物体是 {{untranslatedLabel}} ({{translatedLabel}}) 吗?" } } }, @@ -116,5 +116,12 @@ "markAsReviewed": "标记为已核查", "deleteNow": "立即删除" } + }, + "imagePicker": { + "selectImage": "选择追踪目标的缩略图", + "search": { + "placeholder": "通过标签或子标签搜索……" + }, + "noImages": "未在此摄像头找到缩略图" } } diff --git a/web/public/locales/zh-CN/components/filter.json b/web/public/locales/zh-CN/components/filter.json index 5824a421d7..42a732f8ee 100644 --- a/web/public/locales/zh-CN/components/filter.json +++ b/web/public/locales/zh-CN/components/filter.json @@ -41,15 +41,15 @@ "hasVideoClip": "包含视频片段", "submittedToFrigatePlus": { "label": "提交至 Frigate+", - "tips": "你必须要先筛选具有快照的检测对象。

    没有快照的跟踪对象无法提交至 Frigate+." + "tips": "你必须要先筛选有快照的追踪目标。

    没有快照的追踪目标无法提交至 Frigate+。" } }, "sort": { "label": "排序", "dateAsc": "日期 (正序)", "dateDesc": "日期 (倒序)", - "scoreAsc": "对象分值 (正序)", - "scoreDesc": "对象分值 (倒序)", + "scoreAsc": "目标分值 (正序)", + "scoreDesc": "目标分值 (倒序)", "speedAsc": "预计速度 (正序)", "speedDesc": "预计速度 (倒序)", "relevance": "关联性" @@ -72,7 +72,7 @@ "title": "设置", "defaultView": { "title": "默认视图", - "desc": "当未选择任何过滤器时,显示每个标签最近跟踪对象的摘要,或显示未过滤的网格。", + "desc": "当未选择任何筛选条件时,将显示每个标签下最近追踪目标的汇总信息,或者显示未筛选的网格视图。", "summary": "摘要", "unfilteredGrid": "未过滤网格" }, @@ -82,7 +82,7 @@ }, "searchSource": { "label": "搜索源", - "desc": "选择是搜索缩略图还是跟踪对象的描述。", + "desc": "选择是搜索缩略图还是追踪目标的描述。", "options": { "thumbnailImage": "缩略图", "description": "描述" @@ -107,10 +107,10 @@ }, "trackedObjectDelete": { "title": "确认删除", - "desc": "删除这 {{objectLength}} 个跟踪对象将移除快照、任何已保存的嵌入和任何相关的对象生命周期条目。历史视图中这些跟踪对象的录制片段将不会被删除。

    您确定要继续吗?

    按住 Shift 键可在将来跳过此对话框。", + "desc": "删除这 {{objectLength}} 个已追踪目标将移除它们的快照、所有已保存的嵌入向量数据以及任何相关的目标全周期条目,但在 历史 页面中这些追踪目标的录制视频片段将不会被删除。

    您确定要继续吗?

    以后按住 Shift 键进行删除可跳过此提醒。", "toast": { - "success": "跟踪对象删除成功。", - "error": "删除跟踪对象失败:{{errorMessage}}" + "success": "删除追踪目标成功。", + "error": "删除追踪目标失败:{{errorMessage}}" } }, "zoneMask": { @@ -123,5 +123,13 @@ "placeholder": "输入以搜索车牌…", "noLicensePlatesFound": "未找到车牌。", "selectPlatesFromList": "从列表中选择一个或多个车牌。" + }, + "classes": { + "label": "分类", + "all": { + "title": "所有分类" + }, + "count_one": "{{count}} 个分类", + "count_other": "{{count}} 个分类" } } diff --git a/web/public/locales/zh-CN/components/player.json b/web/public/locales/zh-CN/components/player.json index df6648048e..0336c32a12 100644 --- a/web/public/locales/zh-CN/components/player.json +++ b/web/public/locales/zh-CN/components/player.json @@ -11,7 +11,7 @@ "title": "视频流离线", "desc": "未在 {{cameraName}} 的 detect 流上接收到任何帧,请检查错误日志" }, - "cameraDisabled": "摄像机已禁用", + "cameraDisabled": "摄像头已禁用", "stats": { "streamType": { "title": "流类型:", diff --git a/web/public/locales/zh-CN/views/configEditor.json b/web/public/locales/zh-CN/views/configEditor.json index 79e9b398ce..a4ca5c5b78 100644 --- a/web/public/locales/zh-CN/views/configEditor.json +++ b/web/public/locales/zh-CN/views/configEditor.json @@ -12,5 +12,7 @@ "savingError": "保存配置时出错" } }, - "confirm": "是否退出并不保存?" + "confirm": "是否退出并不保存?", + "safeConfigEditor": "配置编辑器(安全模式)", + "safeModeDescription": "由于验证配置出现错误,Frigate目前为安全模式。" } diff --git a/web/public/locales/zh-CN/views/events.json b/web/public/locales/zh-CN/views/events.json index 72a93104f6..71d00848b9 100644 --- a/web/public/locales/zh-CN/views/events.json +++ b/web/public/locales/zh-CN/views/events.json @@ -35,5 +35,7 @@ "selected": "已选择 {{count}} 个", "selected_one": "已选择 {{count}} 个", "selected_other": "已选择 {{count}} 个", - "detected": "已检测" + "detected": "已检测", + "suspiciousActivity": "可疑活动", + "threateningActivity": "威胁性活动" } diff --git a/web/public/locales/zh-CN/views/explore.json b/web/public/locales/zh-CN/views/explore.json index 7db391e4df..a81fa96f4c 100644 --- a/web/public/locales/zh-CN/views/explore.json +++ b/web/public/locales/zh-CN/views/explore.json @@ -4,14 +4,14 @@ "exploreIsUnavailable": { "title": "浏览功能不可用", "embeddingsReindexing": { - "context": "跟踪对象嵌入重新索引完成后,可以使用浏览功能。", + "context": "完成跟踪目标嵌入重新索引后,才可以使用 浏览 功能。", "startingUp": "启动中…", "estimatedTime": "预计剩余时间:", "finishingShortly": "即将完成", "step": { "thumbnailsEmbedded": "缩略图嵌入:", "descriptionsEmbedded": "描述嵌入:", - "trackedObjectsProcessed": "跟踪对象已处理:" + "trackedObjectsProcessed": "跟踪目标已处理: " } }, "downloadingModels": { @@ -23,25 +23,25 @@ "textTokenizer": "文本分词器" }, "tips": { - "context": "模型下载完成后,您可能需要重新索引跟踪对象的嵌入。", + "context": "模型下载完成后,您可能需要重新索引跟踪目标的嵌入。", "documentation": "阅读文档" }, "error": "发生错误。请检查Frigate日志。" } }, - "trackedObjectDetails": "跟踪对象详情", + "trackedObjectDetails": "目标追踪详情", "type": { "details": "详情", "snapshot": "快照", "video": "视频", - "object_lifecycle": "对象生命周期" + "object_lifecycle": "目标全周期" }, "objectLifecycle": { - "title": "对象生命周期", + "title": "目标全周期", "noImageFound": "未找到此时间戳的图像。", - "createObjectMask": "创建对象遮罩", + "createObjectMask": "创建目标/物体遮罩", "adjustAnnotationSettings": "调整标注设置", - "scrollViewTips": "滚动查看此对象生命周期的重要时刻。", + "scrollViewTips": "滚动查看此目标全周期的关键节点。", "autoTrackingTips": "自动跟踪摄像头的边界框位置可能不准确。", "lifecycleItemDesc": { "visible": "检测到 {{label}}", @@ -65,7 +65,7 @@ "title": "标注设置", "showAllZones": { "title": "显示所有区域", - "desc": "在对象进入区域的帧上始终显示区域。" + "desc": "始终在目标进入区域的帧上显示区域标记。" }, "offset": { "label": "标注偏移", @@ -94,19 +94,21 @@ "viewInExplore": "在 浏览 中查看" }, "tips": { - "mismatch_other": "检测到 {{count}} 个不可用的对象,并已包含在此核查项中。这些对象可能未达到警报或检测标准,或者已被清理/删除。", - "hasMissingObjects": "如果希望 Frigate 保存以下标签的跟踪对象,请调整您的配置:{{objects}}" + "mismatch_other": "检测到 {{count}} 个不可用的目标,并已包含在此核查项中。这些目标可能未达到警报或检测标准,或者已被清理/删除。", + "hasMissingObjects": "如果希望 Frigate 保存以下标签的跟踪目标,请调整您的配置:{{objects}}" }, "toast": { "success": { "regenerate": "已向 {{provider}} 请求新的描述。根据提供商的速度,生成新描述可能需要一些时间。", "updatedSublabel": "成功更新子标签。", - "updatedLPR": "成功更新车牌。" + "updatedLPR": "成功更新车牌。", + "audioTranscription": "成功请求音频转录。" }, "error": { "regenerate": "调用 {{provider}} 生成新描述失败:{{errorMessage}}", "updatedSublabelFailed": "更新子标签失败:{{errorMessage}}", - "updatedLPRFailed": "更新车牌失败:{{errorMessage}}" + "updatedLPRFailed": "更新车牌失败:{{errorMessage}}", + "audioTranscription": "请求音频转录失败:{{errorMessage}}" } } }, @@ -114,14 +116,14 @@ "editSubLabel": { "title": "编辑子标签", "desc": "为 {{label}} 输入新的子标签", - "descNoLabel": "为此跟踪对象输入新的子标签" + "descNoLabel": "为此跟踪目标输入新的子标签" }, "topScore": { "label": "最高得分", - "info": "最高分是跟踪对象的最高中位数得分,因此可能与搜索结果缩略图上显示的得分不同。" + "info": "最高分是追踪目标的中位分数最高值,因此可能与搜索结果缩略图中显示的分数有所不同。" }, "estimatedSpeed": "预计速度", - "objects": "对象", + "objects": "目标/物体", "camera": "摄像头", "zones": "区域", "timestamp": "时间戳", @@ -129,13 +131,13 @@ "findSimilar": "查找相似项", "regenerate": { "title": "重新生成", - "label": "重新生成跟踪对象描述" + "label": "重新生成追踪目标的描述" } }, "description": { "label": "描述", - "placeholder": "跟踪对象的描述", - "aiTips": "在跟踪对象的生命周期结束之前,Frigate 不会向您的生成式 AI 提供商请求描述。" + "placeholder": "追踪目标的描述", + "aiTips": "在追踪目标的目标全周期结束之前,Frigate 不会向您的生成式 AI 提供商请求描述。" }, "expandRegenerationMenu": "展开重新生成菜单", "regenerateFromSnapshot": "从快照重新生成", @@ -146,12 +148,15 @@ }, "editLPR": { "desc": "为 {{label}} 输入新的车牌值", - "descNoLabel": "为检测到的对象输入新的车牌值", + "descNoLabel": "为检测到的目标输入新的车牌值", "title": "编辑车牌" }, "recognizedLicensePlate": "识别的车牌", "snapshotScore": { "label": "快照得分" + }, + "score": { + "label": "分值" } }, "itemMenu": { @@ -164,12 +169,12 @@ "aria": "下载快照" }, "viewObjectLifecycle": { - "label": "查看对象生命周期", - "aria": "显示对象的生命周期" + "label": "查看目标全周期", + "aria": "显示目标的全周期" }, "findSimilar": { "label": "查找相似项", - "aria": "查看相似的对象" + "aria": "查看相似的目标/物体" }, "submitToPlus": { "label": "提交至 Frigate+", @@ -180,26 +185,37 @@ "aria": "在历史记录中查看" }, "deleteTrackedObject": { - "label": "删除此跟踪对象" + "label": "删除此追踪目标" + }, + "addTrigger": { + "label": "添加触发器", + "aria": "为该追踪目标添加触发器" + }, + "audioTranscription": { + "label": "转录", + "aria": "请求音频转录" } }, "dialog": { "confirmDelete": { "title": "确认删除", - "desc": "删除此跟踪对象将移除快照、所有已保存的嵌入数据以及任何关联的对象生命周期条目。但在历史视图中的录制视频不会被删除。

    你确定要继续删除吗?" + "desc": "删除此追踪目标后将移除快照、所有已保存的嵌入向量数据以及任何相关的目标全周期条目,但在 历史 页面中跟踪对象的录制视频片段不会被删除。

    你确定要继续删除该追踪目标吗?" } }, - "noTrackedObjects": "未找到跟踪对象", - "fetchingTrackedObjectsFailed": "获取跟踪对象失败:{{errorMessage}}", - "trackedObjectsCount_other": "{{count}} 个跟踪对象 ", + "noTrackedObjects": "未找到追踪目标", + "fetchingTrackedObjectsFailed": "获取追踪目标失败:{{errorMessage}}", + "trackedObjectsCount_other": "{{count}} 个追踪目标 ", "searchResult": { "deleteTrackedObject": { "toast": { - "success": "跟踪对象删除成功。", - "error": "删除跟踪对象失败:{{errorMessage}}" + "success": "删除追踪目标成功。", + "error": "删除追踪目标失败:{{errorMessage}}" } }, "tooltip": "与 {{type}} 匹配度为 {{confidence}}%" }, - "exploreMore": "浏览更多的 {{label}}" + "exploreMore": "浏览更多的 {{label}}", + "aiAnalysis": { + "title": "AI分析" + } } diff --git a/web/public/locales/zh-CN/views/faceLibrary.json b/web/public/locales/zh-CN/views/faceLibrary.json index 7fb906bcd5..66eb4e2044 100644 --- a/web/public/locales/zh-CN/views/faceLibrary.json +++ b/web/public/locales/zh-CN/views/faceLibrary.json @@ -8,7 +8,7 @@ "person": "人", "confidence": "置信度", "face": "人脸详情", - "faceDesc": "生成此人脸特征的跟踪对象详细信息", + "faceDesc": "生成此人脸特征的追踪目标详细信息", "timestamp": "时间戳", "subLabelScore": "子标签得分", "scoreInfo": "子标签分数是基于所有识别到的人脸置信度的加权评分,因此可能与快照中显示的分数有所不同。", diff --git a/web/public/locales/zh-CN/views/live.json b/web/public/locales/zh-CN/views/live.json index 505781c4e8..372c200e08 100644 --- a/web/public/locales/zh-CN/views/live.json +++ b/web/public/locales/zh-CN/views/live.json @@ -43,7 +43,15 @@ "label": "点击将PTZ摄像头画面居中" } }, - "presets": "PTZ摄像头预设" + "presets": "PTZ摄像头预设", + "focus": { + "in": { + "label": "PTZ摄像头聚焦" + }, + "out": { + "label": "PTZ摄像头拉远" + } + } }, "camera": { "enable": "开启摄像头", @@ -135,7 +143,8 @@ "recording": "录制", "snapshots": "快照", "audioDetection": "音频检测", - "autotracking": "自动跟踪" + "autotracking": "自动跟踪", + "transcription": "音频转录" }, "history": { "label": "显示历史录像" @@ -151,8 +160,12 @@ "editLayout": { "label": "编辑布局", "group": { - "label": "编辑摄像机分组" + "label": "编辑摄像头分组" }, "exitEdit": "退出编辑" + }, + "transcription": { + "enable": "启用实时音频转录", + "disable": "关闭实时音频转录" } } diff --git a/web/public/locales/zh-CN/views/search.json b/web/public/locales/zh-CN/views/search.json index b2f8c6d12a..8a25c11f52 100644 --- a/web/public/locales/zh-CN/views/search.json +++ b/web/public/locales/zh-CN/views/search.json @@ -9,10 +9,10 @@ "filterInformation": "筛选信息", "filterActive": "筛选器已激活" }, - "trackedObjectId": "跟踪对象 ID", + "trackedObjectId": "追踪目标 ID", "filter": { "label": { - "cameras": "摄像机", + "cameras": "摄像头", "labels": "标签", "zones": "区域", "sub_labels": "子标签", diff --git a/web/public/locales/zh-CN/views/settings.json b/web/public/locales/zh-CN/views/settings.json index fb92e6b7b4..6cf6baad9a 100644 --- a/web/public/locales/zh-CN/views/settings.json +++ b/web/public/locales/zh-CN/views/settings.json @@ -22,7 +22,8 @@ "users": "用户", "notifications": "通知", "frigateplus": "Frigate+", - "enrichments": "增强功能" + "enrichments": "增强功能", + "triggers": "触发器" }, "dialog": { "unsavedChanges": { @@ -164,12 +165,12 @@ "readTheDocumentation": "阅读文档", "noDefinedZones": "该摄像头没有设置区域。", "objectAlertsTips": "所有 {{alertsLabels}} 对象在 {{cameraName}} 下都将显示为警报。", - "zoneObjectAlertsTips": "所有 {{alertsLabels}} 对象在 {{cameraName}} 下的 {{zone}} 区内都将显示为警报。", - "objectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区,都将显示为检测。", + "zoneObjectAlertsTips": "所有 {{alertsLabels}} 类的目标或物体在 {{cameraName}} 下的 {{zone}} 区内都将显示为警报。", + "objectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 目标或物体,无论它位于哪个区,都将显示为检测。", "zoneObjectDetectionsTips": { - "text": "所有未在 {{cameraName}} 上归类为 {{detectionsLabels}} 的对象在 {{zone}} 区都将显示为检测。", - "notSelectDetections": "所有在 {{cameraName}} 下的 {{zone}} 区内检测到的 {{detectionsLabels}} 对象,如果它未归类为警报,无论它位于哪个区,都将显示为检测。", - "regardlessOfZoneObjectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。" + "text": "所有未在 {{cameraName}} 上归类为 {{detectionsLabels}} 的目标或物体在 {{zone}} 区内都将显示为检测。", + "notSelectDetections": "所有在 {{cameraName}} 下的 {{zone}} 区内检测到的 {{detectionsLabels}} 目标或物体,如果它未归类为警报,无论它位于哪个区,都将显示为检测。", + "regardlessOfZoneObjectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 目标或物体,无论它位于哪个区域,都将显示为检测。" }, "selectAlertsZones": "选择警报区", "selectDetectionsZones": "选择检测区域", @@ -178,6 +179,44 @@ "success": "核查分级配置已保存。请重启 Frigate 以应用更改。" }, "unsavedChanges": "{{camera}} 的核查分类设置未保存" + }, + "object_descriptions": { + "title": "生成式AI对象描述", + "desc": "临时启用/禁用此摄像头的生成式AI对象描述功能。禁用后,系统将不再请求该摄像头追踪对象的AI生成描述。" + }, + "review_descriptions": { + "title": "生成式AI核查描述", + "desc": "临时启用/禁用本摄像头的生成式AI核查描述功能。禁用后,系统将不再为该摄像头的核查项目请求AI生成的描述内容。" + }, + "addCamera": "添加新摄像头", + "editCamera": "编辑摄像头:", + "selectCamera": "选择摄像头", + "backToSettings": "返回摄像头设置", + "cameraConfig": { + "add": "添加摄像头", + "edit": "编辑摄像头", + "description": "配置摄像头设置,包括视频流输入和视频流功能选择。", + "name": "摄像头名称", + "nameRequired": "摄像头名称为必填项", + "nameInvalid": "摄像头名称只能包含字母、数字、下划线或连字符", + "namePlaceholder": "比如:front_door", + "enabled": "开启", + "ffmpeg": { + "inputs": "视频流输入", + "path": "视频流路径", + "pathRequired": "视频流路径为必填项", + "pathPlaceholder": "rtsp://...", + "roles": "功能", + "rolesRequired": "至少需要指定一个功能", + "rolesUnique": "每个功能(音频、检测、录制)只能用于一个视频流,不能够重复分配到多个视频流", + "addInput": "添加视频流输入", + "removeInput": "移除视频流输入", + "inputsRequired": "至少需要一个视频流" + }, + "toast": { + "success": "摄像头 {{cameraName}} 保存已保存" + }, + "nameLength": "摄像头名称必须少于24个字符。" } }, "masksAndZones": { @@ -246,7 +285,7 @@ "label": "区域", "documentTitle": "编辑区域 - Frigate", "desc": { - "title": "该功能允许你定义特定区域,以便你可以确定特定对象是否在该区域内。", + "title": "该功能允许你定义特定区域,以便你可以确定特定目标或物体是否在该区域内。", "documentation": "文档" }, "add": "添加区域", @@ -260,17 +299,17 @@ }, "inertia": { "title": "惯性", - "desc": "识别指定对象前该对象必须在这个区域内出现了多少帧。默认值:3" + "desc": "识别指定目标前该目标必须在这个区域内出现了多少帧。默认值:3" }, "loiteringTime": { "title": "停留时间", - "desc": "设置对象必须在区域中活动的最小时间(单位为秒)。默认值:0" + "desc": "设置目标必须在区域中至少要活动多少时间(单位为秒)。默认值:0" }, "objects": { - "title": "对象", - "desc": "将在此区域应用的对象列表。" + "title": "目标/物体", + "desc": "将在此区域应用的目标/物体类别列表。" }, - "allObjects": "所有对象", + "allObjects": "所有目标/物体", "speedEstimation": { "title": "速度估算", "desc": "启用此区域内物体的速度估算。该区域必须恰好包含 4 个点。", @@ -298,7 +337,7 @@ "label": "运动遮罩", "documentTitle": "编辑运动遮罩 - Frigate", "desc": { - "title": "运动遮罩用于防止触发不必要的运动类型。过度的设置遮罩将使对象更加难以被追踪。", + "title": "画面变动遮罩用于防止触发不必要的画面变动检测。过度的设置遮罩将使目标更加难以被追踪。", "documentation": "文档" }, "add": "添加运动遮罩", @@ -311,7 +350,7 @@ "clickDrawPolygon": "在图像上点击添加点绘制多边形区域。", "polygonAreaTooLarge": { "title": "运动遮罩的大小达到了摄像头画面的{{polygonArea}}%。不建议设置太大的运动遮罩。", - "tips": "运动遮罩不会阻止检测到对象,你应该使用区域来限制检测对象。", + "tips": "画面变动遮罩并不会使该区域无法检测到指定目标/物体,如有需要,你应该使用 区域 来限制检测的目标/物体类型。", "documentation": "阅读文档" }, "toast": { @@ -322,37 +361,37 @@ } }, "objectMasks": { - "label": "对象遮罩", - "documentTitle": "编辑对象遮罩 - Frigate", + "label": "目标遮罩", + "documentTitle": "编辑目标遮罩 - Frigate", "desc": { - "title": "对象过滤器用于防止特定位置的指定对象被误报。", + "title": "目标过滤器用于防止特定位置出现对某个目标/物体的误报。", "documentation": "文档" }, - "add": "添加对象遮罩", - "edit": "编辑对象遮罩", - "context": "对象过滤器用于防止特定位置的指定对象被误报。", + "add": "添加目标遮罩", + "edit": "编辑目标遮罩", + "context": "目标过滤器用于防止特定位置的指定目标会误报。", "point_other": "{{count}} 点", "clickDrawPolygon": "在图像上点击添加点绘制多边形区域。", "objects": { - "title": "对象", - "desc": "将应用于此对象遮罩的对象类型。", - "allObjectTypes": "所有对象类型" + "title": "目标/物体", + "desc": "将应用于此目标遮罩的目标或物体类型。", + "allObjectTypes": "所有目标或物体类型" }, "toast": { "success": { "title": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。", - "noName": "对象遮罩已保存。请重启 Frigate 以应用更改。" + "noName": "目标遮罩已保存。请重启 Frigate 以应用更改。" } } }, "restart_required": "需要重启(遮罩与区域已修改)", "motionMaskLabel": "运动遮罩 {{number}}", - "objectMaskLabel": "对象遮罩 {{number}}({{label}})" + "objectMaskLabel": "目标/物体遮罩 {{number}}({{label}})" }, "motionDetectionTuner": { "title": "运动检测调整器", "desc": { - "title": "Frigate 将使用运动检测作为首个步骤,以确认一帧画面中是否有对象需要使用对象检测。", + "title": "Frigate 将使用画面变化检测作为首个步骤,以确认一帧画面中是否有目标或物体需要使用目标检测。", "documentation": "阅读有关运动检测的文档" }, "Threshold": { @@ -374,17 +413,17 @@ }, "debug": { "title": "调试", - "detectorDesc": "Frigate 将使用检测器({{detectors}})来检测摄像头视频流中的对象。", - "desc": "调试界面将实时显示被追踪的对象以及统计信息,对象列表将显示检测到的对象和延迟显示的概览。", + "detectorDesc": "Frigate 将使用检测器({{detectors}})来检测摄像头视频流中的目标或物体。", + "desc": "调试界面将实时显示被追踪的目标以及统计信息,目标列表将显示检测到的目标和延迟显示的概览。", "debugging": "调试选项", - "objectList": "对象列表", - "noObjects": "没有对象", + "objectList": "目标列表", + "noObjects": "没有目标", "boundingBoxes": { "title": "边界框", - "desc": "将在被追踪的对象周围显示边界框", + "desc": "将在被追踪的目标周围显示边界框", "colors": { - "label": "对象边界框颜色定义", - "info": "
  • 启用后,将会为每个对象标签分配不同的颜色
  • 深蓝色细线代表该对象在当前时间点未被检测到
  • 灰色细线代表检测到的物体静止不动
  • 粗线表示该对象为自动跟踪的主体(在启动时)
  • " + "label": "目标边界框颜色定义", + "info": "
  • 启用后,将会为每个目标的标签分配不同的颜色
  • 深蓝色细线代表该目标或物体在当前时间点未被检测到
  • 灰色细线代表检测到的目标或物体静止不动
  • 粗线表示该目标或物体为自动跟踪的主体(在启动时)
  • " } }, "timestamp": { @@ -417,7 +456,20 @@ "score": "分数", "ratio": "比例", "area": "区域" - } + }, + "paths": { + "title": "运动轨迹", + "desc": "显示被追踪目标的运动轨迹关键点", + "tips": "

    运动轨迹

    将使用线条和圆圈标示被追踪目标在其活动周期内移动的关键位置点。

    " + }, + "audio": { + "title": "音频", + "noAudioDetections": "未检测到音频事件", + "score": "分值", + "currentRMS": "当前均方根值(RMS)", + "currentdbFS": "当前满量程相对分贝值(dbFS)" + }, + "openCameraWebUI": "打开 {{camera}} 的管理页面" }, "users": { "title": "用户", @@ -510,7 +562,8 @@ "viewer": "成员", "viewerDesc": "仅能够查看实时监控面板、核查、浏览和导出功能。", "adminDesc": "完全功能与访问权限。", - "intro": "为该用户选择一个合适的权限组:" + "intro": "为该用户选择一个合适的权限组:", + "customDesc": "自定义特定摄像头的访问规则。" }, "select": "选择权限组" } @@ -624,10 +677,10 @@ }, "semanticSearch": { "reindexNow": { - "desc": "重建索引将为所有跟踪对象重新生成特征向量。该过程将在后台运行,可能会使CPU满载,所需时间取决于跟踪对象的数量。", + "desc": "重建索引将为所有追踪目标重新生成特征向量信息。该过程将在后台进行,可能会使CPU满载,所需时间取决于追踪目标的数量。", "label": "立即重建索引", "confirmTitle": "确认重建索引", - "confirmDesc": "确定要为所有跟踪对象重建特征向量索引吗?此过程将在后台运行,但可能会导致CPU满载并耗费较长时间。您可以在 浏览 页面查看进度。", + "confirmDesc": "确定要为所有追踪目标重建特征向量索引信息吗?此过程将在后台进行,但可能会导致CPU满载并耗费较长时间。您可以在 浏览 页面查看进度。", "confirmButton": "重建索引", "success": "重建索引已成功启动。", "alreadyInProgress": "重建索引已在执行中。", @@ -646,11 +699,11 @@ } }, "title": "分类搜索", - "desc": "Frigate中的语义搜索功能允许您通过使用图像本身、用户自定义的文本描述,或自动生成的文本描述等方式在核查项目中查找被追踪对象。", + "desc": "Frigate中的语义搜索功能允许您通过图片、用户自定义的文本描述,或自动生成的文本描述等方式在核查项目中查找目标/物体。", "readTheDocumentation": "阅读文档" }, "licensePlateRecognition": { - "desc": "Frigate 可以识别车辆的车牌,并自动将检测到的字符添加到 recognized_license_plate 字段中,或将已知名称作为子标签添加到汽车类型的对象中。常见的使用场景可能是读取驶入车道的汽车车牌或经过街道的汽车车牌。", + "desc": "Frigate 可以识别车辆的车牌,并自动将检测到的字符添加到 recognized_license_plate 字段中,或将已知车牌对应的名称作为子标签添加到该车辆目标中。一般常用于读取驶入车道的车辆车牌或经过街道的车辆车牌。", "title": "车牌识别", "readTheDocumentation": "阅读文档" }, @@ -677,5 +730,158 @@ }, "unsavedChanges": "增强功能设置未保存", "restart_required": "需要重启(增强功能设置已保存)" + }, + "triggers": { + "documentTitle": "触发器", + "management": { + "title": "触发器管理", + "desc": "管理 {{camera}} 的触发器。您可以使用“缩略图”类型,基于与所选追踪对象相似的缩略图来触发;也可以使用“描述”类型,基于与您指定的文本相似的描述来触发。" + }, + "addTrigger": "添加触发器", + "table": { + "name": "名称", + "type": "类型", + "content": "触发内容", + "threshold": "阈值", + "actions": "动作", + "noTriggers": "此摄像头未配置任何触发器。", + "edit": "编辑", + "deleteTrigger": "删除触发器", + "lastTriggered": "最后一个触发项" + }, + "type": { + "thumbnail": "缩略图", + "description": "描述" + }, + "actions": { + "alert": "标记为警报", + "notification": "发送通知" + }, + "dialog": { + "createTrigger": { + "title": "创建触发器", + "desc": "为摄像头 {{camera}} 创建触发器" + }, + "editTrigger": { + "title": "编辑触发器", + "desc": "编辑摄像头 {{camera}} 的触发器设置" + }, + "deleteTrigger": { + "title": "删除触发器", + "desc": "你确定要删除触发器 {{triggerName}} 吗?此操作不可撤销。" + }, + "form": { + "name": { + "title": "名称", + "placeholder": "输入触发器名称", + "error": { + "minLength": "名称至少要两个字符。", + "invalidCharacters": "名称只能包含字母、数字、下划线和连字符。", + "alreadyExists": "此摄像头已存在同名触发器。" + } + }, + "enabled": { + "description": "开启/关闭此触发器" + }, + "type": { + "title": "类型", + "placeholder": "选择触发类型" + }, + "content": { + "title": "内容", + "imagePlaceholder": "选择图片", + "textPlaceholder": "输入文字内容", + "imageDesc": "选择一张图片,当检测到相似图片时触发此操作。", + "textDesc": "输入文本,当检测到相似的追踪对象描述时触发此操作。", + "error": { + "required": "内容为必填项。" + } + }, + "threshold": { + "title": "阈值", + "error": { + "min": "阈值必须大于 0", + "max": "阈值必须小于 1" + } + }, + "actions": { + "title": "动作", + "desc": "默认情况下,Frigate 会为所有触发器发送 MQTT 消息。请选择此触发器触发时需要执行的附加操作。", + "error": { + "min": "必须至少选择一项动作。" + } + } + } + }, + "toast": { + "success": { + "createTrigger": "触发器 {{name}} 创建成功。", + "updateTrigger": "触发器 {{name}} 更新成功。", + "deleteTrigger": "触发器 {{name}} 已删除。" + }, + "error": { + "createTriggerFailed": "创建触发器失败:{{errorMessage}}", + "updateTriggerFailed": "更新触发器失败:{{errorMessage}}", + "deleteTriggerFailed": "删除触发器失败:{{errorMessage}}" + } + } + }, + "roles": { + "management": { + "title": "成员权限组管理", + "desc": "管理此 Frigate 实例的自定义权限组及其摄像头访问权限。" + }, + "addRole": "添加权限组", + "table": { + "role": "权限组", + "cameras": "摄像头", + "actions": "操作", + "noRoles": "没有找到自定义权限组。", + "editCameras": "编辑摄像头", + "deleteRole": "删除权限组" + }, + "toast": { + "success": { + "createRole": "权限组 {{role}} 创建成功", + "updateCameras": "已更新摄像头至 {{role}} 权限组", + "deleteRole": "已删除 {{role}} 权限组", + "userRolesUpdated": "已将分配到此权限组的 {{count}} 位用户更新为 “成员”,该权限组可访问所有摄像头。" + }, + "error": { + "createRoleFailed": "创建权限组失败:{{errorMessage}}", + "updateCamerasFailed": "更新摄像头失败:{{errorMessage}}", + "deleteRoleFailed": "删除权限组失败:{{errorMessage}}", + "userUpdateFailed": "更新用户权限组失败:{{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "创建新权限组", + "desc": "添加新权限组并分配摄像头访问权限。" + }, + "editCameras": { + "title": "编辑权限组的摄像头", + "desc": "为权限组 {{role}} 更新摄像头访问权限。" + }, + "deleteRole": { + "title": "删除权限组", + "desc": "此操作无法撤销。这将永久删除该权限组,并将所有拥有此角色的用户分配到 “成员” 权限组,该权限组将赋予用户查看所有摄像头的权限。", + "warn": "你确定要删除权限组 {{role}} 吗?", + "deleting": "删除中…" + }, + "form": { + "role": { + "title": "权限组名称", + "placeholder": "输入权限组名称", + "desc": "仅允许使用字母、数字、句点和下划线。", + "roleIsRequired": "必须输入权限组名称", + "roleOnlyInclude": "权限组名称仅支持字母、数字、英文句号和下划线", + "roleExists": "该权限组名称已存在。" + }, + "cameras": { + "title": "摄像头" + } + } + } } } diff --git a/web/public/locales/zh-CN/views/system.json b/web/public/locales/zh-CN/views/system.json index befc4bc503..27d0c91aa3 100644 --- a/web/public/locales/zh-CN/views/system.json +++ b/web/public/locales/zh-CN/views/system.json @@ -102,6 +102,10 @@ "title": "未使用", "tips": "如果您的驱动器上存储了除 Frigate 录制内容之外的其他文件,该值可能无法准确反映 Frigate 可用的剩余空间。Frigate 不会追踪录制内容以外的存储使用情况。" } + }, + "shm": { + "title": "共享内存(SHM)分配", + "warning": "当前共享内存(SHM)容量过小( {{total}}MB),请将其至少增加到 {{min_shm}}MB。" } }, "cameras": { diff --git a/web/public/locales/zh-Hant/audio.json b/web/public/locales/zh-Hant/audio.json index bb37e6bd48..78613a56e5 100644 --- a/web/public/locales/zh-Hant/audio.json +++ b/web/public/locales/zh-Hant/audio.json @@ -35,5 +35,35 @@ "vehicle": "車輛", "animal": "動物", "bark": "樹皮", - "goat": "山羊" + "goat": "山羊", + "whoop": "大叫", + "whispering": "講話", + "laughter": "笑聲", + "snicker": "竊笑", + "child_singing": "小孩歌聲", + "synthetic_singing": "合成音樂聲", + "rapping": "饒舌聲", + "humming": "哼歌聲", + "groan": "呻吟聲", + "grunt": "咕噥聲", + "whistling": "口哨聲", + "breathing": "呼吸聲", + "wheeze": "喘息聲", + "snoring": "打呼聲", + "gasp": "倒抽一口氣", + "pant": "喘氣聲", + "snort": "鼻息聲", + "cough": "咳嗽聲", + "throat_clearing": "清喉嚨聲", + "sneeze": "打噴嚏聲", + "sniff": "嗅聞聲", + "run": "跑步聲", + "shuffle": "拖著腳走路聲", + "footsteps": "腳步聲", + "chewing": "咀嚼聲", + "biting": "咬", + "gargling": "漱口", + "stomach_rumble": "腸胃蠕動", + "burping": "打嗝", + "hiccup": "打噎" } diff --git a/web/public/locales/zh-Hant/common.json b/web/public/locales/zh-Hant/common.json index acc7a0a084..41659ba919 100644 --- a/web/public/locales/zh-Hant/common.json +++ b/web/public/locales/zh-Hant/common.json @@ -247,5 +247,6 @@ "title": "404", "desc": "找不到頁面" }, - "selectItem": "選擇 {{item}}" + "selectItem": "選擇 {{item}}", + "readTheDocumentation": "閱讀文件" } diff --git a/web/public/locales/zh-Hant/components/camera.json b/web/public/locales/zh-Hant/components/camera.json index d07662c7e7..3bace4d9de 100644 --- a/web/public/locales/zh-Hant/components/camera.json +++ b/web/public/locales/zh-Hant/components/camera.json @@ -66,7 +66,8 @@ "label": "相容模式", "desc": "只有在鏡頭的串流影像中出現色彩異常及右側有斜線時才啟用此選項。" } - } + }, + "birdseye": "鳥瞰" } }, "debug": { diff --git a/web/public/locales/zh-Hant/components/filter.json b/web/public/locales/zh-Hant/components/filter.json index a1192ac59d..29ccaa5c23 100644 --- a/web/public/locales/zh-Hant/components/filter.json +++ b/web/public/locales/zh-Hant/components/filter.json @@ -122,5 +122,13 @@ "placeholder": "輸入以搜尋車牌…", "noLicensePlatesFound": "未找到車牌。", "selectPlatesFromList": "從列表中選擇一個或多個車牌。" + }, + "classes": { + "label": "類別", + "all": { + "title": "所有類別" + }, + "count_one": "{{count}} 個類別", + "count_other": "{{count}} 個類別" } } diff --git a/web/public/locales/zh-Hant/views/configEditor.json b/web/public/locales/zh-Hant/views/configEditor.json index 3788bace0e..f1943edbb7 100644 --- a/web/public/locales/zh-Hant/views/configEditor.json +++ b/web/public/locales/zh-Hant/views/configEditor.json @@ -12,5 +12,7 @@ } }, "saveOnly": "僅保存", - "confirm": "是否不保存就離開?" + "confirm": "是否不保存就離開?", + "safeConfigEditor": "設定編輯器(安全模式)", + "safeModeDescription": "由於設定驗證有誤,Frigate 進入安全模式。" } diff --git a/web/public/locales/zh-Hant/views/events.json b/web/public/locales/zh-Hant/views/events.json index 8f840aab1c..8571ea39f0 100644 --- a/web/public/locales/zh-Hant/views/events.json +++ b/web/public/locales/zh-Hant/views/events.json @@ -34,5 +34,7 @@ "selected_one": "已選擇 {{count}} 個", "selected_other": "已選擇 {{count}} 個", "camera": "鏡頭", - "detected": "已偵測" + "detected": "已偵測", + "suspiciousActivity": "可疑的活動", + "threateningActivity": "有威脅性的活動" } diff --git a/web/public/locales/zh-Hant/views/live.json b/web/public/locales/zh-Hant/views/live.json index 55947b9f2f..89163c6772 100644 --- a/web/public/locales/zh-Hant/views/live.json +++ b/web/public/locales/zh-Hant/views/live.json @@ -39,7 +39,15 @@ "label": "點擊畫面以置中 PTZ 鏡頭" } }, - "presets": "PTZ 鏡頭預設" + "presets": "PTZ 鏡頭預設", + "focus": { + "in": { + "label": "聚焦 PTZ 鏡頭" + }, + "out": { + "label": "離焦 PTZ 鏡頭" + } + } }, "cameraAudio": { "enable": "啟用鏡頭音訊", @@ -154,5 +162,9 @@ "label": "編輯鏡頭群組" }, "exitEdit": "結束編輯" + }, + "transcription": { + "enable": "啟用即時語音轉錄", + "disable": "停用即時語音轉錄" } } diff --git a/web/public/locales/zh-Hant/views/settings.json b/web/public/locales/zh-Hant/views/settings.json index d252250e9b..5574bf0f0e 100644 --- a/web/public/locales/zh-Hant/views/settings.json +++ b/web/public/locales/zh-Hant/views/settings.json @@ -20,7 +20,8 @@ "debug": "除錯", "users": "使用者", "notifications": "通知", - "frigateplus": "Frigate+" + "frigateplus": "Frigate+", + "triggers": "觸發" }, "dialog": { "unsavedChanges": { @@ -39,7 +40,51 @@ "automaticLiveView": { "label": "自動即時檢視", "desc": "在偵測到移動時自動切換至即時影像。停用此設定將使得在即時監控面板上的靜態畫面每分鐘更新一次。" + }, + "playAlertVideos": { + "label": "播放警報影片", + "desc": "最近的警報影片預設會在即時監控面板中連續循環播放。取消這個選項,可以只顯示靜態的最近警報擷圖(僅套用於該裝置/瀏覽器)。" + } + }, + "storedLayouts": { + "title": "儲存的排版", + "desc": "在鏡頭群組內的鏡頭排版可以拖拉或縮放調整。這個排版設定儲存於目前瀏覽器的本機儲存空間。", + "clearAll": "清除所有排版" + }, + "cameraGroupStreaming": { + "title": "鏡頭群組串流播放設定", + "desc": "每個鏡頭群組的串流播放設定都儲存在目前瀏覽器的本機儲存空間。", + "clearAll": "清除所有串流播放設定" + }, + "recordingsViewer": { + "title": "錄影檢視器", + "defaultPlaybackRate": { + "label": "預設播放速度", + "desc": "錄影回放的預設播放速度。" + } + }, + "calendar": { + "title": "月曆", + "firstWeekday": { + "label": "第一個工作天", + "desc": "在檢視月曆中,每個禮拜從禮拜幾開始。", + "sunday": "禮拜天", + "monday": "禮拜一" + } + }, + "toast": { + "success": { + "clearStoredLayout": "清除 {{cameraName}} 儲存的排版", + "clearStreamingSettings": "清除所有鏡頭群組的串流播放設定。" + }, + "error": { + "clearStoredLayoutFailed": "清除儲存的排版設定失敗: {{errorMessage}}", + "clearStreamingSettingsFailed": "清除串流播放設定失敗: {{errorMessage}}" } } + }, + "enrichments": { + "title": "強化設定", + "unsavedChanges": "尚未儲存的強化設定變更" } } diff --git a/web/public/locales/zh-Hant/views/system.json b/web/public/locales/zh-Hant/views/system.json index b3d7610478..9f208baab9 100644 --- a/web/public/locales/zh-Hant/views/system.json +++ b/web/public/locales/zh-Hant/views/system.json @@ -42,7 +42,8 @@ "inferenceSpeed": "偵測器推理速度", "temperature": "偵測器溫度", "cpuUsage": "偵測器 CPU 使用率", - "memoryUsage": "偵測器記憶體使用量" + "memoryUsage": "偵測器記憶體使用量", + "cpuUsageInformation": "用於準備輸入和輸出數據至/從偵測模型的CPU。此值不衡量推論使用量,即使使用GPU或加速器。" }, "hardwareInfo": { "title": "硬體資訊", diff --git a/web/src/App.tsx b/web/src/App.tsx index a0062549fa..2fbfa4c99d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,8 @@ import { cn } from "./lib/utils"; import { isPWA } from "./utils/isPWA"; import ProtectedRoute from "@/components/auth/ProtectedRoute"; import { AuthProvider } from "@/context/auth-context"; +import useSWR from "swr"; +import { FrigateConfig } from "./types/frigateConfig"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -22,56 +24,21 @@ const System = lazy(() => import("@/pages/System")); const Settings = lazy(() => import("@/pages/Settings")); const UIPlayground = lazy(() => import("@/pages/UIPlayground")); const FaceLibrary = lazy(() => import("@/pages/FaceLibrary")); +const Classification = lazy(() => import("@/pages/ClassificationModel")); const Logs = lazy(() => import("@/pages/Logs")); const AccessDenied = lazy(() => import("@/pages/AccessDenied")); function App() { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + return ( -
    - {isDesktop && } - {isDesktop && } - {isMobile && } -
    - - - - } - > - } /> - } /> - } /> - } /> - } /> - - } - > - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - - -
    -
    + {config?.safe_mode ? : }
    @@ -79,4 +46,73 @@ function App() { ); } +function DefaultAppView() { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + return ( +
    + {isDesktop && } + {isDesktop && } + {isMobile && } +
    + + + + } + > + } /> + } /> + } /> + } /> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + +
    +
    + ); +} + +function SafeAppView() { + return ( +
    +
    + + + +
    +
    + ); +} + export default App; diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 3e9c8c14f8..8ebc017275 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -8,6 +8,9 @@ import { FrigateReview, ModelState, ToggleableSetting, + TrackedObjectUpdateReturnType, + TriggerStatus, + FrigateAudioDetections, } from "@/types/ws"; import { FrigateStats } from "@/types/stats"; import { createContainer } from "react-tracked"; @@ -60,17 +63,23 @@ function useValue(): useValueReturn { enabled, snapshots, audio, + audio_transcription, notifications, notifications_suspended, autotracking, alerts, detections, + object_descriptions, + review_descriptions, } = 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"; + cameraStates[`${name}/audio_transcription/state`] = audio_transcription + ? "ON" + : "OFF"; cameraStates[`${name}/notifications/state`] = notifications ? "ON" : "OFF"; @@ -83,6 +92,12 @@ function useValue(): useValueReturn { cameraStates[`${name}/review_detections/state`] = detections ? "ON" : "OFF"; + cameraStates[`${name}/object_descriptions/state`] = object_descriptions + ? "ON" + : "OFF"; + cameraStates[`${name}/review_descriptions/state`] = review_descriptions + ? "ON" + : "OFF"; }); setWsState((prevState) => ({ @@ -220,6 +235,20 @@ export function useAudioState(camera: string): { return { payload: payload as ToggleableSetting, send }; } +export function useAudioTranscriptionState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/audio_transcription/state`, + `${camera}/audio_transcription/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} + export function useAutotrackingState(camera: string): { payload: ToggleableSetting; send: (payload: ToggleableSetting, retain?: boolean) => void; @@ -256,6 +285,34 @@ export function useDetectionsState(camera: string): { return { payload: payload as ToggleableSetting, send }; } +export function useObjectDescriptionState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/object_descriptions/state`, + `${camera}/object_descriptions/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} + +export function useReviewDescriptionState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/review_descriptions/state`, + `${camera}/review_descriptions/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} + export function usePtzCommand(camera: string): { payload: string; send: (payload: string, retain?: boolean) => void; @@ -285,6 +342,13 @@ export function useFrigateEvents(): { payload: FrigateEvent } { return { payload: JSON.parse(payload as string) }; } +export function useAudioDetections(): { payload: FrigateAudioDetections } { + const { + value: { payload }, + } = useWs("audio_detections", ""); + return { payload: JSON.parse(payload as string) }; +} + export function useFrigateReviews(): FrigateReview { const { value: { payload }, @@ -407,6 +471,40 @@ export function useEmbeddingsReindexProgress( return { payload: data }; } +export function useBirdseyeLayout(revalidateOnFocus: boolean = true): { + payload: string; +} { + const { + value: { payload }, + send: sendCommand, + } = useWs("birdseye_layout", "birdseyeLayout"); + + const data = useDeepMemo(JSON.parse(payload as string)); + + useEffect(() => { + let listener = undefined; + if (revalidateOnFocus) { + sendCommand("birdseyeLayout"); + listener = () => { + if (document.visibilityState == "visible") { + sendCommand("birdseyeLayout"); + } + }; + addEventListener("visibilitychange", listener); + } + + return () => { + if (listener) { + removeEventListener("visibilitychange", listener); + } + }; + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [revalidateOnFocus]); + + return { payload: data }; +} + export function useMotionActivity(camera: string): { payload: string } { const { value: { payload }, @@ -421,6 +519,15 @@ export function useAudioActivity(camera: string): { payload: number } { return { payload: payload as number }; } +export function useAudioLiveTranscription(camera: string): { + payload: string; +} { + const { + value: { payload }, + } = useWs(`${camera}/audio/transcription`, ""); + return { payload: payload as string }; +} + export function useMotionThreshold(camera: string): { payload: string; send: (payload: number, retain?: boolean) => void; @@ -463,11 +570,16 @@ export function useImproveContrast(camera: string): { return { payload: payload as ToggleableSetting, send }; } -export function useTrackedObjectUpdate(): { payload: string } { +export function useTrackedObjectUpdate(): { + payload: TrackedObjectUpdateReturnType; +} { const { value: { payload }, } = useWs("tracked_object_update", ""); - return useDeepMemo(JSON.parse(payload as string)); + const parsed = payload + ? JSON.parse(payload as string) + : { type: "", id: "", camera: "" }; + return { payload: useDeepMemo(parsed) }; } export function useNotifications(camera: string): { @@ -505,3 +617,13 @@ export function useNotificationTest(): { } = useWs("notification_test", "notification_test"); return { payload: payload as string, send }; } + +export function useTriggers(): { payload: TriggerStatus } { + const { + value: { payload }, + } = useWs("triggers", ""); + const parsed = payload + ? JSON.parse(payload as string) + : { name: "", camera: "", event_id: "", type: "", score: 0 }; + return { payload: useDeepMemo(parsed) }; +} diff --git a/web/src/components/audio/AudioLevelGraph.tsx b/web/src/components/audio/AudioLevelGraph.tsx new file mode 100644 index 0000000000..4f0e75722e --- /dev/null +++ b/web/src/components/audio/AudioLevelGraph.tsx @@ -0,0 +1,165 @@ +import { useEffect, useMemo, useState, useCallback } from "react"; +import { MdCircle } from "react-icons/md"; +import Chart from "react-apexcharts"; +import { useTheme } from "@/context/theme-provider"; +import { useWs } from "@/api/ws"; +import { useDateLocale } from "@/hooks/use-date-locale"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useTranslation } from "react-i18next"; + +const GRAPH_COLORS = ["#3b82f6", "#ef4444"]; // RMS, dBFS + +interface AudioLevelGraphProps { + cameraName: string; +} + +export function AudioLevelGraph({ cameraName }: AudioLevelGraphProps) { + const [audioData, setAudioData] = useState< + { timestamp: number; rms: number; dBFS: number }[] + >([]); + const [maxDataPoints] = useState(50); + + // config for time formatting + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + const locale = useDateLocale(); + const { t } = useTranslation(["common"]); + + const { + value: { payload: audioRms }, + } = useWs(`${cameraName}/audio/rms`, ""); + const { + value: { payload: audioDBFS }, + } = useWs(`${cameraName}/audio/dBFS`, ""); + + useEffect(() => { + if (typeof audioRms === "number") { + const now = Date.now(); + setAudioData((prev) => { + const next = [ + ...prev, + { + timestamp: now, + rms: audioRms, + dBFS: typeof audioDBFS === "number" ? audioDBFS : 0, + }, + ]; + return next.slice(-maxDataPoints); + }); + } + }, [audioRms, audioDBFS, maxDataPoints]); + + const series = useMemo( + () => [ + { + name: "RMS", + data: audioData.map((p) => ({ x: p.timestamp, y: p.rms })), + }, + { + name: "dBFS", + data: audioData.map((p) => ({ x: p.timestamp, y: p.dBFS })), + }, + ], + [audioData], + ); + + const lastValues = useMemo(() => { + if (!audioData.length) return undefined; + const last = audioData[audioData.length - 1]; + return [last.rms, last.dBFS]; + }, [audioData]); + + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + const formatString = useMemo( + () => + t(`time.formattedTimestampHourMinuteSecond.${timeFormat}`, { + ns: "common", + }), + [t, timeFormat], + ); + + const formatTime = useCallback( + (val: unknown) => { + const seconds = Math.round(Number(val) / 1000); + return formatUnixTimestampToDateTime(seconds, { + timezone: config?.ui.timezone, + date_format: formatString, + locale, + }); + }, + [config?.ui.timezone, formatString, locale], + ); + + const { theme, systemTheme } = useTheme(); + + const options = useMemo(() => { + return { + chart: { + id: `${cameraName}-audio`, + selection: { enabled: false }, + toolbar: { show: false }, + zoom: { enabled: false }, + animations: { enabled: false }, + }, + colors: GRAPH_COLORS, + grid: { + show: true, + borderColor: "#374151", + strokeDashArray: 3, + xaxis: { lines: { show: true } }, + yaxis: { lines: { show: true } }, + }, + legend: { show: false }, + dataLabels: { enabled: false }, + stroke: { width: 1 }, + markers: { size: 0 }, + tooltip: { + theme: systemTheme || theme, + x: { formatter: (val: number) => formatTime(val) }, + y: { formatter: (v: number) => v.toFixed(1) }, + }, + xaxis: { + type: "datetime", + labels: { + rotate: 0, + formatter: formatTime, + style: { colors: "#6B6B6B", fontSize: "10px" }, + }, + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + yaxis: { + show: true, + labels: { + formatter: (val: number) => Math.round(val).toString(), + style: { colors: "#6B6B6B", fontSize: "10px" }, + }, + }, + } as ApexCharts.ApexOptions; + }, [cameraName, theme, systemTheme, formatTime]); + + return ( +
    + {lastValues && ( +
    + {["RMS", "dBFS"].map((label, idx) => ( +
    + +
    {label}
    +
    + {lastValues[idx].toFixed(1)} +
    +
    + ))} +
    + )} + +
    + ); +} diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx index c35fdaebca..18dc50d53a 100644 --- a/web/src/components/auth/ProtectedRoute.tsx +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -6,7 +6,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; export default function ProtectedRoute({ requiredRoles, }: { - requiredRoles: ("admin" | "viewer")[]; + requiredRoles: string[]; }) { const { auth } = useContext(AuthContext); diff --git a/web/src/components/camera/CameraNameLabel.tsx b/web/src/components/camera/CameraNameLabel.tsx new file mode 100644 index 0000000000..ab022f5c87 --- /dev/null +++ b/web/src/components/camera/CameraNameLabel.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; +import { CameraConfig } from "@/types/frigateConfig"; + +interface CameraNameLabelProps + extends React.ComponentPropsWithoutRef { + camera?: string | CameraConfig; +} + +const CameraNameLabel = React.forwardRef< + React.ElementRef, + CameraNameLabelProps +>(({ className, camera, ...props }, ref) => { + const displayName = useCameraFriendlyName(camera); + return ( + + {displayName} + + ); +}); +CameraNameLabel.displayName = LabelPrimitive.Root.displayName; + +export { CameraNameLabel }; diff --git a/web/src/components/camera/DebugCameraImage.tsx b/web/src/components/camera/DebugCameraImage.tsx index 3d840d0d32..bc3b6a8c38 100644 --- a/web/src/components/camera/DebugCameraImage.tsx +++ b/web/src/components/camera/DebugCameraImage.tsx @@ -158,6 +158,16 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) { /> +
    + { + handleSetOption("paths", isChecked); + }} + /> + +
    ); } diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index d46509eb62..a048046411 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -91,7 +91,10 @@ export function AnimatedEventCard({ // image behavior - const [alertVideos] = usePersistence("alertVideos", true); + const [alertVideos, _, alertVideosLoaded] = usePersistence( + "alertVideos", + true, + ); const aspectRatio = useMemo(() => { if ( @@ -135,7 +138,7 @@ export function AnimatedEventCard({ {t("markAsReviewed")} )} - {previews != undefined && ( + {previews != undefined && alertVideosLoaded && (
    void; showObjectLifecycle: () => void; showSnapshot: () => void; + addTrigger: () => void; }; export default function SearchThumbnailFooter({ @@ -24,6 +25,7 @@ export default function SearchThumbnailFooter({ refreshResults, showObjectLifecycle, showSnapshot, + addTrigger, }: SearchThumbnailProps) { const { t } = useTranslation(["views/search"]); const { data: config } = useSWR("config"); @@ -61,6 +63,7 @@ export default function SearchThumbnailFooter({ refreshResults={refreshResults} showObjectLifecycle={showObjectLifecycle} showSnapshot={showSnapshot} + addTrigger={addTrigger} />
    diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 6d9ea78568..365fc8ccf1 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -71,12 +71,14 @@ import { MobilePageTitle, } from "../mobile/MobilePage"; -import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; import { CameraStreamingDialog } from "../settings/CameraStreamingDialog"; import { DialogTrigger } from "@radix-ui/react-dialog"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { Trans, useTranslation } from "react-i18next"; +import { CameraNameLabel } from "../camera/CameraNameLabel"; +import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; +import { useIsCustomRole } from "@/hooks/use-is-custom-role"; type CameraGroupSelectorProps = { className?: string; @@ -650,6 +652,9 @@ export function CameraGroupEdit({ allGroupsStreamingSettings[editingGroup?.[0] ?? ""], ); + const allowedCameras = useAllowedCameras(); + const isCustomRole = useIsCustomRole(); + const [openCamera, setOpenCamera] = useState(); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); @@ -837,21 +842,25 @@ export function CameraGroupEdit({ {t("group.cameras.desc")} {[ - ...(birdseyeConfig?.enabled ? ["birdseye"] : []), - ...Object.keys(config?.cameras ?? {}).sort( - (a, b) => - (config?.cameras[a]?.ui?.order ?? 0) - - (config?.cameras[b]?.ui?.order ?? 0), - ), + ...(birdseyeConfig?.enabled && + (!isCustomRole || "birdseye" in allowedCameras) + ? ["birdseye"] + : []), + ...Object.keys(config?.cameras ?? {}) + .filter((camera) => allowedCameras.includes(camera)) + .sort( + (a, b) => + (config?.cameras[a]?.ui?.order ?? 0) - + (config?.cameras[b]?.ui?.order ?? 0), + ), ].map((camera) => (
    - + camera={camera} + />
    {camera !== "birdseye" && ( diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index 247555a0a1..93b8a8651a 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -189,7 +189,8 @@ export function CamerasFilterContent({ void; }; export default function FilterSwitch({ label, disabled = false, isChecked, + isCameraName = false, onCheckedChange, }: FilterSwitchProps) { return (
    - + {isCameraName ? ( + + ) : ( + + )} ("config"); + const allowedCameras = useAllowedCameras(); const allLabels = useMemo(() => { if (filterList?.labels) { @@ -83,7 +85,9 @@ export default function ReviewFilterGroup({ } const labels = new Set(); - const cameras = filter?.cameras || Object.keys(config.cameras); + const cameras = (filter?.cameras || allowedCameras).filter((camera) => + allowedCameras.includes(camera), + ); cameras.forEach((camera) => { if (camera == "birdseye") { @@ -106,7 +110,7 @@ export default function ReviewFilterGroup({ }); return [...labels].sort(); - }, [config, filterList, filter]); + }, [config, filterList, filter, allowedCameras]); const allZones = useMemo(() => { if (filterList?.zones) { @@ -118,7 +122,9 @@ export default function ReviewFilterGroup({ } const zones = new Set(); - const cameras = filter?.cameras || Object.keys(config.cameras); + const cameras = (filter?.cameras || allowedCameras).filter((camera) => + allowedCameras.includes(camera), + ); cameras.forEach((camera) => { if (camera == "birdseye") { @@ -134,11 +140,11 @@ export default function ReviewFilterGroup({ }); return [...zones].sort(); - }, [config, filterList, filter]); + }, [config, filterList, filter, allowedCameras]); const filterValues = useMemo( () => ({ - cameras: Object.keys(config?.cameras ?? {}).sort( + cameras: allowedCameras.sort( (a, b) => (config?.cameras[a]?.ui?.order ?? 0) - (config?.cameras[b]?.ui?.order ?? 0), @@ -146,7 +152,7 @@ export default function ReviewFilterGroup({ labels: Object.values(allLabels || {}), zones: Object.values(allZones || {}), }), - [config, allLabels, allZones], + [config, allLabels, allZones, allowedCameras], ); const groups = useMemo(() => { diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 1702fcc2ac..b96ed7dd7a 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -24,9 +24,9 @@ import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog"; import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; - import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; +import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; type SearchFilterGroupProps = { className: string; @@ -46,6 +46,7 @@ export default function SearchFilterGroup({ const { data: config } = useSWR("config", { revalidateOnFocus: false, }); + const allowedCameras = useAllowedCameras(); const allLabels = useMemo(() => { if (filterList?.labels) { @@ -57,7 +58,9 @@ export default function SearchFilterGroup({ } const labels = new Set(); - const cameras = filter?.cameras || Object.keys(config.cameras); + const cameras = (filter?.cameras || allowedCameras).filter((camera) => + allowedCameras.includes(camera), + ); cameras.forEach((camera) => { if (camera == "birdseye") { @@ -87,7 +90,7 @@ export default function SearchFilterGroup({ }); return [...labels].sort(); - }, [config, filterList, filter]); + }, [config, filterList, filter, allowedCameras]); const allZones = useMemo(() => { if (filterList?.zones) { @@ -99,7 +102,9 @@ export default function SearchFilterGroup({ } const zones = new Set(); - const cameras = filter?.cameras || Object.keys(config.cameras); + const cameras = (filter?.cameras || allowedCameras).filter((camera) => + allowedCameras.includes(camera), + ); cameras.forEach((camera) => { if (camera == "birdseye") { @@ -118,16 +123,16 @@ export default function SearchFilterGroup({ }); return [...zones].sort(); - }, [config, filterList, filter]); + }, [config, filterList, filter, allowedCameras]); const filterValues = useMemo( () => ({ - cameras: Object.keys(config?.cameras || {}), + cameras: allowedCameras, labels: Object.values(allLabels || {}), zones: Object.values(allZones || {}), search_type: ["thumbnail", "description"] as SearchSource[], }), - [config, allLabels, allZones], + [allLabels, allZones, allowedCameras], ); const availableSortTypes = useMemo(() => { diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx index 413dd64b17..1c65fe9bbc 100755 --- a/web/src/components/input/InputWithTags.tsx +++ b/web/src/components/input/InputWithTags.tsx @@ -53,6 +53,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { MdImageSearch } from "react-icons/md"; import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; +import { CameraNameLabel } from "../camera/CameraNameLabel"; type InputWithTagsProps = { inputFocused: boolean; @@ -826,9 +827,13 @@ export default function InputWithTags({ className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800 smart-capitalize" > {t("filter.label." + filterType)}:{" "} - {filterType === "labels" - ? getTranslatedLabel(value) - : value.replaceAll("_", " ")} + {filterType === "labels" ? ( + getTranslatedLabel(value) + ) : filterType === "cameras" ? ( + + ) : ( + value.replaceAll("_", " ") + )} + +
    +
    + + + + + + ); +} diff --git a/web/src/components/overlay/CreateTriggerDialog.tsx b/web/src/components/overlay/CreateTriggerDialog.tsx new file mode 100644 index 0000000000..f3a8a22bd6 --- /dev/null +++ b/web/src/components/overlay/CreateTriggerDialog.tsx @@ -0,0 +1,442 @@ +import { useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import useSWR from "swr"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { FrigateConfig } from "@/types/frigateConfig"; +import ImagePicker from "@/components/overlay/ImagePicker"; +import { Trigger, TriggerAction, TriggerType } from "@/types/trigger"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "../ui/textarea"; +import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; +import { isDesktop, isMobile } from "react-device-detect"; +import { cn } from "@/lib/utils"; +import { + MobilePage, + MobilePageContent, + MobilePageDescription, + MobilePageHeader, + MobilePageTitle, +} from "../mobile/MobilePage"; + +type CreateTriggerDialogProps = { + show: boolean; + trigger: Trigger | null; + selectedCamera: string; + isLoading: boolean; + onCreate: ( + enabled: boolean, + name: string, + type: TriggerType, + data: string, + threshold: number, + actions: TriggerAction[], + ) => void; + onEdit: (trigger: Trigger) => void; + onCancel: () => void; +}; + +export default function CreateTriggerDialog({ + show, + trigger, + selectedCamera, + isLoading, + onCreate, + onEdit, + onCancel, +}: CreateTriggerDialogProps) { + const { t } = useTranslation("views/settings"); + const { data: config } = useSWR("config"); + + const existingTriggerNames = useMemo(() => { + if ( + !config || + !selectedCamera || + !config.cameras[selectedCamera]?.semantic_search?.triggers + ) { + return []; + } + return Object.keys(config.cameras[selectedCamera].semantic_search.triggers); + }, [config, selectedCamera]); + + const formSchema = z.object({ + enabled: z.boolean(), + name: z + .string() + .min(2, t("triggers.dialog.form.name.error.minLength")) + .regex( + /^[a-zA-Z0-9_-]+$/, + t("triggers.dialog.form.name.error.invalidCharacters"), + ) + .refine( + (value) => + !existingTriggerNames.includes(value) || value === trigger?.name, + t("triggers.dialog.form.name.error.alreadyExists"), + ), + type: z.enum(["thumbnail", "description"]), + data: z.string().min(1, t("triggers.dialog.form.content.error.required")), + threshold: z + .number() + .min(0, t("triggers.dialog.form.threshold.error.min")) + .max(1, t("triggers.dialog.form.threshold.error.max")), + actions: z.array(z.enum(["notification"])), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + enabled: trigger?.enabled ?? true, + name: trigger?.name ?? "", + type: trigger?.type ?? "description", + data: trigger?.data ?? "", + threshold: trigger?.threshold ?? 0.5, + actions: trigger?.actions ?? [], + }, + }); + + const onSubmit = async (values: z.infer) => { + if (trigger) { + onEdit({ ...values }); + } else { + onCreate( + values.enabled, + values.name, + values.type, + values.data, + values.threshold, + values.actions, + ); + } + }; + + useEffect(() => { + if (!show) { + form.reset({ + enabled: true, + name: "", + type: "description", + data: "", + threshold: 0.5, + actions: [], + }); + } else if (trigger) { + form.reset( + { + enabled: trigger.enabled, + name: trigger.name, + type: trigger.type, + data: trigger.data, + threshold: trigger.threshold, + actions: trigger.actions, + }, + { keepDirty: false, keepTouched: false }, // Reset validation state + ); + // Trigger validation to ensure isValid updates + // form.trigger(); + } + }, [show, trigger, form]); + + const handleCancel = () => { + form.reset(); + onCancel(); + }; + + const cameraName = useCameraFriendlyName(selectedCamera); + + const Overlay = isDesktop ? Dialog : MobilePage; + const Content = isDesktop ? DialogContent : MobilePageContent; + const Header = isDesktop ? DialogHeader : MobilePageHeader; + const Description = isDesktop ? DialogDescription : MobilePageDescription; + const Title = isDesktop ? DialogTitle : MobilePageTitle; + + return ( + + +
    + + {t( + trigger + ? "triggers.dialog.editTrigger.title" + : "triggers.dialog.createTrigger.title", + )} + + + {t( + trigger + ? "triggers.dialog.editTrigger.desc" + : "triggers.dialog.createTrigger.desc", + { + camera: cameraName, + }, + )} + +
    + +
    + + ( + + {t("triggers.dialog.form.name.title")} + + + + + + )} + /> + + ( + +
    + + {t("enabled", { ns: "common" })} + +
    + {t("triggers.dialog.form.enabled.description")} +
    +
    + + + +
    + )} + /> + + ( + + {t("triggers.dialog.form.type.title")} + + + + )} + /> + + ( + + + {t("triggers.dialog.form.content.title")} + + {form.watch("type") === "thumbnail" ? ( + <> + + + + + {t("triggers.dialog.form.content.imageDesc")} + + + ) : ( + <> + +