diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 81d448f25f..482f9939ff 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -26,7 +26,7 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr - This PR fixes or closes issue: fixes # - This PR is related to issue: -- Link to discussion with maintainers (**required** for large/pinned features): +- Link to discussion with maintainers (**required** for any large or "planned" features): ## For new features diff --git a/.github/workflows/pr_template_check.yml b/.github/workflows/pr_template_check.yml index 57db79ecce..c82b202ef5 100644 --- a/.github/workflows/pr_template_check.yml +++ b/.github/workflows/pr_template_check.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check PR description against template - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]', 'weblate']; diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 6fff665a4a..516b55e89b 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -72,7 +72,7 @@ jobs: run: npm run e2e working-directory: ./web - name: Upload test artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: failure() with: name: playwright-report diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 011f70afd3..39512f24a3 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,9 +18,9 @@ jobs: close-issue-message: "" days-before-stale: 30 days-before-close: 3 - exempt-draft-pr: true - exempt-issue-labels: "pinned,security" - exempt-pr-labels: "pinned,security,dependencies" + exempt-draft-pr: false + exempt-issue-labels: "planned,security" + exempt-pr-labels: "planned,security,dependencies" operations-per-run: 120 - name: Print outputs env: diff --git a/.gitignore b/.gitignore index c9db2929f8..7c97a23a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ core !/web/**/*.ts .idea/* .ipynb_checkpoints + +# Auto-generated Docker Compose Generator config files +docs/src/components/DockerComposeGenerator/config/devices.ts +docs/src/components/DockerComposeGenerator/config/hardware.ts +docs/src/components/DockerComposeGenerator/config/ports.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cb575d37a..e25c20835d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,11 +10,14 @@ If you've found a bug and want to fix it, go for it. Link to the relevant issue ### New features -Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature: +A pull request is more than just code — it's a request for the maintainers to review, integrate, and support the change long-term. We're selective about what we take on, and prioritize changes that align with the project's direction and can be responsibly maintained in the long term. -1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Pinned feature requests are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one. +**Large or highly-requested features** raise the bar even higher. Popularity signals demand, but it doesn't pre-approve any particular implementation. The bigger the change, the higher the long-term cost, and the more important it is that we're aligned on scope and approach before any code is written. A large PR that lands without prior discussion is unlikely to be merged as-is, no matter how well it's implemented. + +Before writing code for a new feature: + +1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Feature requests tagged with "planned" are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one. 2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it. -3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider. ## AI usage policy @@ -39,6 +42,8 @@ We're not trying to gatekeep how you write code. Use whatever tools make you pro Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term — often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability. +One more thing worth saying directly: most maintainers already have access to the same AI tools you do. A PR that's entirely AI-generated — where the author can't explain the design, debug issues independently, or engage substantively in design discussions — doesn't offer something we couldn't produce ourselves. What makes a contribution genuinely valuable is the human judgment and domain understanding behind it, as well as the engagement during review that shapes it into something we can confidently take on long-term. + ## Pull request guidelines ### Before submitting diff --git a/docker/main/install_deps.sh b/docker/main/install_deps.sh index 11812e9edc..ad0fea91a8 100755 --- a/docker/main/install_deps.sh +++ b/docker/main/install_deps.sh @@ -87,43 +87,43 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then # intel packages use zst compression so we need to update dpkg apt-get install -y dpkg - # use intel apt intel packages + # use intel apt repo for libmfx1 (legacy QSV, pre-Gen12) wget -qO - https://repositories.intel.com/gpu/intel-graphics.key | gpg --yes --dearmor --output /usr/share/keyrings/intel-graphics.gpg 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 + # intel-media-va-driver-non-free is built from source in the # intel-media-driver Dockerfile stage for Battlemage (Xe2) support apt-get -qq install --no-install-recommends --no-install-suggests -y \ - libmfx1 libmfxgen1 libvpl2 + libmfx1 + rm -f /usr/share/keyrings/intel-graphics.gpg + rm -f /etc/apt/sources.list.d/intel-gpu-jammy.list + # upgrade libva2, oneVPL runtime, and libvpl2 from trixie for Battlemage support + echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list + apt-get -qq update + apt-get -qq install -y -t trixie libva2 libva-drm2 libzstd1 + apt-get -qq install -y -t trixie libmfx-gen1.2 libvpl2 + rm -f /etc/apt/sources.list.d/trixie.list + apt-get -qq update apt-get -qq install -y ocl-icd-libopencl1 # install libtbb12 for NPU support apt-get -qq install -y libtbb12 - 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 + # install legacy and standard intel compute packages # see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info - # newer intel packages (gmmlib 22.9+, igc 2.32+) require libstdc++ >= 13.1 and libzstd >= 1.5.5 - echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list - apt-get -qq update - apt-get -qq install -y -t trixie libstdc++6 libzstd1 - rm -f /etc/apt/sources.list.d/trixie.list - apt-get -qq update - # needed core package wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb dpkg -i libigdgmm12_22.9.0_amd64.deb rm libigdgmm12_22.9.0_amd64.deb - # legacy packages + # legacy compute-runtime 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 + # standard compute-runtime packages wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb @@ -137,6 +137,10 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then dpkg -i *.deb rm *.deb apt-get -qq install -f -y + + # Battlemage uses the xe kernel driver, but the VA-API driver is still iHD. + # The oneVPL runtime may look for a driver named after the kernel module. + ln -sf /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so /usr/lib/x86_64-linux-gnu/dri/xe_drv_video.so fi if [[ "${TARGETARCH}" == "arm64" ]]; then diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index f81fefea47..7bd098454d 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -11,7 +11,7 @@ joserfc == 1.2.* cryptography == 44.0.* pathvalidate == 3.3.* markupsafe == 3.0.* -python-multipart == 0.0.20 +python-multipart == 0.0.26 # Classification Model Training tensorflow == 2.19.* ; platform_machine == 'aarch64' tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64' @@ -42,7 +42,7 @@ opencv-python-headless == 4.11.0.* opencv-contrib-python == 4.11.0.* scipy == 1.16.* # OpenVino & ONNX -openvino == 2025.3.* +openvino == 2025.4.* onnxruntime == 1.22.* # Embeddings transformers == 4.45.* diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 71897be0a7..5796a58aad 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -3,7 +3,6 @@ import json import os import sys -from pathlib import Path from typing import Any from ruamel.yaml import YAML @@ -18,37 +17,12 @@ from frigate.const import ( ) from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.util.config import find_config_file +from frigate.util.services import is_restricted_go2rtc_source sys.path.remove("/opt/frigate") yaml = YAML() -# Check if arbitrary exec sources are allowed (defaults to False for security) -allow_arbitrary_exec = None -if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: - allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") -elif ( - os.path.isdir("/run/secrets") - and os.access("/run/secrets", os.R_OK) - and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets") -): - allow_arbitrary_exec = ( - Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC")) - .read_text() - .strip() - ) -# check for the add-on options file -elif os.path.isfile("/data/options.json"): - with open("/data/options.json") as f: - raw_options = f.read() - options = json.loads(raw_options) - allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec") - -ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str( - allow_arbitrary_exec -).lower() in ("true", "1", "yes") - - config_file = find_config_file() try: @@ -128,18 +102,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59: go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args -def is_restricted_source(stream_source: str) -> bool: - """Check if a stream source is restricted (echo, expr, or exec).""" - return stream_source.strip().startswith(("echo:", "expr:", "exec:")) - - for name in list(go2rtc_config.get("streams", {})): stream = go2rtc_config["streams"][name] if isinstance(stream, str): try: formatted_stream = substitute_frigate_vars(stream) - if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): + if is_restricted_go2rtc_source(formatted_stream): print( f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." @@ -158,7 +127,7 @@ for name in list(go2rtc_config.get("streams", {})): for i, stream_item in enumerate(stream): try: formatted_stream = substitute_frigate_vars(stream_item) - if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): + if is_restricted_go2rtc_source(formatted_stream): print( f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 13bd357491..653ed1e4ee 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -13,7 +13,7 @@ ARG ROCM RUN apt update -qq && \ apt install -y wget gpg && \ - wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2/ubuntu/jammy/amdgpu-install_7.2.70200-1_all.deb && \ + wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \ apt install -y ./rocm.deb && \ apt update && \ apt install -qq -y rocm @@ -32,11 +32,14 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf FROM deps AS deps-prelim COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources -RUN apt-get update && \ +# install_deps.sh upgraded libstdc++6 from trixie for Battlemage; the matching +# -dev package must also come from trixie or apt refuses to satisfy it. +RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list && \ + apt-get update && \ apt-get install -y libnuma1 && \ apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \ - # Install C++ standard library headers for HIPRTC kernel compilation fallback - apt-get install -qq -y libstdc++-12-dev && \ + apt-get install -qq -y -t trixie libstdc++-14-dev && \ + rm -f /etc/apt/sources.list.d/trixie.list && \ rm -rf /var/lib/apt/lists/* WORKDIR /opt/frigate @@ -75,6 +78,10 @@ ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1 ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1 ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1 ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1 +ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx +ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx +ENV AMD_COMGR_CACHE=1 +ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx COPY --from=rocm-dist / / diff --git a/docker/rocm/requirements-wheels-rocm.txt b/docker/rocm/requirements-wheels-rocm.txt index da22f2ff6d..f60b550c3f 100644 --- a/docker/rocm/requirements-wheels-rocm.txt +++ b/docker/rocm/requirements-wheels-rocm.txt @@ -1 +1 @@ -onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl \ No newline at end of file +onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-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 710bfe9956..224118818e 100644 --- a/docker/rocm/rocm.hcl +++ b/docker/rocm/rocm.hcl @@ -1,5 +1,5 @@ variable "ROCM" { - default = "7.2.0" + default = "7.2.3" } variable "HSA_OVERRIDE_GFX_VERSION" { default = "" diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index 035e4f4e80..e4999c6e8f 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -19,7 +19,7 @@ Face recognition requires a one-time internet connection to download detection a ### Face Detection -When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient. +When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/index.md#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient. When running a default COCO model or another model that does not include `face` as a detectable label, face detection will run via CV2 using a lightweight DNN model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track. @@ -171,7 +171,7 @@ When choosing images to include in the face training set it is recommended to al - If it is difficult to make out details in a persons face it will not be helpful in training. - Avoid images with extreme under/over-exposure. - Avoid blurry / pixelated images. -- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images. +- Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images. - Using images of people wearing hats / sunglasses may confuse the model. - Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting. diff --git a/docs/docs/configuration/genai/config.md b/docs/docs/configuration/genai/config.md index a02a313bab..a512943c90 100644 --- a/docs/docs/configuration/genai/config.md +++ b/docs/docs/configuration/genai/config.md @@ -201,7 +201,7 @@ Cloud Generative AI providers require an active internet connection to send imag ### Ollama Cloud -Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud). +Ollama also supports [cloud models](https://ollama.com/cloud), where model inference is performed in the cloud. You can connect directly to Ollama Cloud by setting `base_url` to `https://ollama.com` and providing an API key. Alternatively, you can run Ollama locally and use a cloud model name so your local instance forwards requests to the cloud. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud). #### Configuration @@ -210,7 +210,8 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where your local 1. Navigate to . - Set **Provider** to `ollama` - - Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`) + - Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`) or `https://ollama.com` for direct cloud inference + - Set **API key** if required by your endpoint (e.g., when using `https://ollama.com`) - Set **Model** to the cloud model name @@ -223,6 +224,16 @@ genai: model: cloud-model-name ``` +or when using Ollama Cloud directly + +```yaml +genai: + provider: ollama + base_url: https://ollama.com + model: cloud-model-name + api_key: your-api-key +``` + diff --git a/docs/docs/configuration/hardware_acceleration_video.md b/docs/docs/configuration/hardware_acceleration_video.md index 7aeecfda95..617d735395 100644 --- a/docs/docs/configuration/hardware_acceleration_video.md +++ b/docs/docs/configuration/hardware_acceleration_video.md @@ -136,90 +136,32 @@ ffmpeg: -### Configuring Intel GPU Stats in Docker +### Configuring Intel GPU Stats -Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. There are two options: +Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc//fdinfo/`. This requires: -1. Run the container as privileged. -2. Add the `CAP_PERFMON` capability (note: you might need to set the `perf_event_paranoid` low enough to allow access to the performance event system.) +- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver. +- Frigate running with permission to read other processes' fdinfo. Running as root inside the container (the default) satisfies this; non-root setups may need `CAP_SYS_PTRACE`. -#### Run as privileged +No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required. -This method works, but it gives more permissions to the container than are actually needed. +#### Stats for SR-IOV or specific devices -##### Docker Compose - Privileged - -```yaml -services: - frigate: - ... - image: ghcr.io/blakeblackshear/frigate:stable - # highlight-next-line - privileged: true -``` - -##### Docker Run CLI - Privileged - -```bash {4} -docker run -d \ - --name frigate \ - ... - --privileged \ - ghcr.io/blakeblackshear/frigate:stable -``` - -#### CAP_PERFMON - -Only recent versions of Docker support the `CAP_PERFMON` capability. You can test to see if yours supports it by running: `docker run --cap-add=CAP_PERFMON hello-world` - -##### Docker Compose - CAP_PERFMON - -```yaml {5,6} -services: - frigate: - ... - image: ghcr.io/blakeblackshear/frigate:stable - cap_add: - - CAP_PERFMON -``` - -##### Docker Run CLI - CAP_PERFMON - -```bash {4} -docker run -d \ - --name frigate \ - ... - --cap-add=CAP_PERFMON \ - ghcr.io/blakeblackshear/frigate:stable -``` - -#### perf_event_paranoid - -_Note: This setting must be changed for the entire system._ - -For more information on the various values across different distributions, see https://askubuntu.com/questions/1400874/what-does-perf-paranoia-level-four-do. - -Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=2 >> /etc/sysctl.d/local.conf'` - -#### Stats for SR-IOV or other devices - -When using virtualized GPUs via SR-IOV, you need to specify the device path to use to gather stats from `intel_gpu_top`. This example may work for some systems using SR-IOV: +If the host has more than one Intel GPU (e.g. an iGPU plus a discrete GPU, or SR-IOV virtual functions), pin stats collection to a specific device by setting `intel_gpu_device` to either its PCI bus address or a DRM card/render-node path: ```yaml telemetry: stats: - intel_gpu_device: "sriov" + intel_gpu_device: "0000:00:02.0" ``` -For other virtualized GPUs, try specifying the direct path to the device instead: - ```yaml telemetry: stats: - intel_gpu_device: "drm:/dev/dri/card0" + intel_gpu_device: "/dev/dri/card1" ``` -If you are passing in a device path, make sure you've passed the device through to the container. +When passing a device path, make sure the device is also passed through to the container. ## AMD-based CPUs diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 2821fb7a27..767f70ab9e 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -72,7 +72,7 @@ This does not affect using hardware for accelerating other tasks such as [semant # Officially Supported Detectors -Frigate provides a number of builtin detector types. 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 a number of builtin detector types. By default, Frigate will use a single OpenVINO detector running on the CPU. 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 @@ -494,7 +494,7 @@ detectors: | [YOLO-NAS](#yolo-nas) | ✅ | ✅ | | | [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models | | [YOLOX](#yolox) | ✅ | ? | | -| [D-FINE](#d-fine) | ❌ | ❌ | | +| [D-FINE / DEIMv2](#d-fine--deimv2) | ❌ | ❌ | | #### SSDLite MobileNet v2 @@ -710,13 +710,13 @@ model: -#### D-FINE +#### D-FINE / DEIMv2 -[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate. +[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate. :::warning -Currently D-FINE models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model +Currently D-FINE / DEIMv2 models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model ::: @@ -766,6 +766,31 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl +
+ DEIMv2 Setup & Config + +After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration: + +```yaml +detectors: + ov: + type: openvino + device: CPU + +model: + model_type: dfine + width: 640 + height: 640 + input_tensor: nchw + input_dtype: float + path: /config/model_cache/deimv2_hgnetv2_n.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. + +
+ ## 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`. @@ -947,7 +972,7 @@ The AMD GPU kernel is known problematic especially when converting models to mxr See [ONNX supported models](#supported-models) for supported models, there are some caveats: -- D-FINE models are not supported +- D-FINE / DEIMv2 models are not supported - YOLO-NAS models are known to not run well on integrated GPUs ## ONNX @@ -997,13 +1022,13 @@ detectors: ### ONNX Supported Models -| Model | Nvidia GPU | AMD GPU | Notes | -| ----------------------------- | ---------- | ------- | --------------------------------------------------- | -| [YOLOv9](#yolo-v3-v4-v7-v9-2) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance | -| [RF-DETR](#rf-detr) | ✅ | ❌ | Supports CUDA Graphs for optimal Nvidia performance | -| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs | -| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance | -| [D-FINE](#d-fine) | ⚠️ | ❌ | Not supported by CUDA Graphs | +| Model | Nvidia GPU | AMD GPU | Notes | +| ------------------------------------ | ---------- | ------- | --------------------------------------------------- | +| [YOLOv9](#yolo-v3-v4-v7-v9-2) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance | +| [RF-DETR](#rf-detr) | ✅ | ⚠️ | Supports CUDA Graphs for optimal Nvidia performance | +| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs | +| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance | +| [D-FINE / DEIMv2](#d-fine--deimv2-1) | ⚠️ | ❌ | Not supported by CUDA Graphs | There is no default model provided, the following formats are supported: @@ -1215,9 +1240,9 @@ model: -#### D-FINE +#### D-FINE / DEIMv2 -[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate. +[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
D-FINE Setup & Config @@ -1262,6 +1287,28 @@ model:
+
+ DEIMv2 Setup & Config + +After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration: + +```yaml +detectors: + onnx: + type: onnx + +model: + model_type: dfine + width: 640 + height: 640 + input_tensor: nchw + input_dtype: float + path: /config/model_cache/deimv2_hgnetv2_n.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. ## CPU Detector (not recommended) @@ -1405,7 +1452,7 @@ MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the #### 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). +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/2p1/tools/neural_compiler.html#usage). **Note:** The default model for the MemryX detector is YOLO-NAS 320x320. @@ -1459,7 +1506,7 @@ model: #### 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). +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/2p1/tools/neural_compiler.html#usage). ##### Configuration @@ -1601,19 +1648,39 @@ model: #### Using a Custom Model -To use your own model: +To use your own custom model, first compile it into a [.dfp](https://developer.memryx.com/2p1/specs/files.html#dataflow-program) file, which is the format used by MemryX. -1. Package your compiled model into a `.zip` file. +#### Compile the Model -2. The `.zip` must contain the compiled `.dfp` file. +Custom models must be compiled using **MemryX SDK 2.1**. -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`. +Before compiling your model, install the MemryX Neural Compiler tools from the +[Install Tools](https://developer.memryx.com/2p1/get_started/install_tools.html) page on the **host**. -4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config. +> **Note:** It is recommended to compile the model on the host machine, or on another separate machine, rather than inside the Frigate Docker container. Installing the compiler inside Docker may conflict with container packages. It is recommended to create a Python virtual environment and install the compiler there. -5. Update the `labelmap_path` to match your custom model's labels. +Once the SDK 2.1 environment is set up, follow the +[MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) documentation to compile your model. -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). +Example: + +```bash +mx_nc -m yolonas.onnx -c 4 --autocrop -v --dfp_fname yolonas.dfp +``` + +For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/2p1/tutorials/tutorials.html). + +#### Package the Compiled Model + +1. Package your compiled model into a `.zip` file. + +2. The `.zip` file 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 `labelmap_path` to match your custom model's labels. ```yaml # The detector automatically selects the default model if nothing is provided in the config. @@ -2274,6 +2341,49 @@ COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL EOF ``` +### Downloading DEIMv2 Model + +[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families: + +- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n` +- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x` + +Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`). + +```sh +docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF' +FROM python:3.11-slim AS build +RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* +COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/ +WORKDIR /deimv2 +RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git . +# Install CPU-only PyTorch first to avoid pulling CUDA variant +RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu +RUN uv pip install --no-cache --system -r requirements.txt +RUN uv pip install --no-cache --system onnx safetensors huggingface_hub +RUN mkdir -p output +ARG BACKBONE +ARG MODEL_SIZE +# Download from Hugging Face and convert safetensors to pth +RUN python3 -c "\ +from huggingface_hub import hf_hub_download; \ +from safetensors.torch import load_file; \ +import torch; \ +backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \ +size = '${MODEL_SIZE}'.upper(); \ +st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \ +torch.save({'model': st}, 'output/deimv2.pth')" +RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py +# HuggingFace safetensors omits frozen constants that the model constructor initializes +RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py +RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth +FROM scratch +ARG BACKBONE +ARG MODEL_SIZE +COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx +EOF +``` + ### Downloading RF-DETR Model RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size. diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index 614beafed7..3d5ef35ba1 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -195,7 +195,7 @@ Pre and post capture footage is included in the **recording timeline**, visible ## Will Frigate delete old recordings if my storage runs out? -As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted. +If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible. ## Configuring Recording Retention diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md index af4d635c6e..d488c54104 100644 --- a/docs/docs/configuration/restream.md +++ b/docs/docs/configuration/restream.md @@ -236,7 +236,7 @@ Enabling arbitrary exec sources allows execution of arbitrary commands through g ## Advanced Restream Configurations -The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#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.13#source-exec) source in go2rtc can be used for custom ffmpeg commands and other applications. An example is below: :::warning @@ -244,16 +244,11 @@ The `exec:`, `echo:`, and `expr:` sources are disabled by default for security. ::: -:::warning - -The `exec:`, `echo:`, and `expr:` sources are disabled by default for security. You must set `GO2RTC_ALLOW_ARBITRARY_EXEC=true` to use them. See [Security: Restricted Stream Sources](#security-restricted-stream-sources) for more information. - -::: - -NOTE: The output will need to be passed with two curly braces `{{output}}` +NOTE: RTSP output will need to be passed with two curly braces `{{output}}`, whereas pipe output must be passed without curly braces. ```yaml go2rtc: streams: stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}} + stream2: exec:rpicam-vid -t 0 --libav-format h264 -o - ``` diff --git a/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index 7df2ae0bb5..6e98d1b7bf 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -223,10 +223,11 @@ Apple Silicon can not run within a container, so a ZMQ proxy is utilized to comm 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 | t-320: ~ 14 ms s-320: 20 ms | 320: ~ 25 ms 640: ~ 50 ms | -| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms | +| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR 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 | | +| AMD 9060XT 16G | t-320: ~ 4 ms s-320: 5 ms | 320: ~ 6 ms | Nano-320: ~ 90 ms | ## Community Supported Detectors diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 5d228a609a..485a735d81 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -4,12 +4,15 @@ title: Installation --- import ShmCalculator from '@site/src/components/ShmCalculator' +import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator' +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App. :::tip -If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started#configuring-frigate) to configure Frigate. +If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started.md#configuring-frigate) to configure Frigate. ::: @@ -286,7 +289,7 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM #### 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). +To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/2p1/get_started/install_hardware.html). Then follow these steps for installing the correct driver/runtime configuration: @@ -295,6 +298,12 @@ Then follow these steps for installing the correct driver/runtime configuration: 3. Run the script with `./user_installation.sh` 4. **Restart your computer** to complete driver installation. +:::warning + +For manual setup, use **MemryX SDK 2.1** only. Other SDK versions are not supported for this setup. See the [SDK 2.1 documentation](https://developer.memryx.com/2p1/index.html) + +::: + #### Setup To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable` @@ -468,6 +477,16 @@ Finally, configure [hardware object detection](/configuration/object_detectors#a Running through Docker with Docker Compose is the recommended install method. + + + +Generate a Frigate Docker Compose configuration based on your hardware and requirements. + + + + + + ```yaml services: frigate: @@ -501,6 +520,10 @@ services: environment: FRIGATE_RTSP_PASSWORD: "password" ``` + + + +**Docker CLI** If you can't use Docker Compose, you can run the container with something similar to this: diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index cd456f2014..9619f5a318 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -192,7 +192,7 @@ cameras: ### Step 4: Configure detectors -By default, Frigate will use a single CPU detector. +By default, Frigate will use a single OpenVINO detector running on the CPU. In many cases, the integrated graphics on Intel CPUs provides sufficient performance for typical Frigate setups. If you have an Intel processor, you can follow the configuration below. diff --git a/docs/docs/integrations/third_party_extensions.md b/docs/docs/integrations/third_party_extensions.md index c30c7d966b..a7d5fc9e61 100644 --- a/docs/docs/integrations/third_party_extensions.md +++ b/docs/docs/integrations/third_party_extensions.md @@ -39,6 +39,10 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht [Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail. +## [kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) + +[kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) is a Raspberry Pi watchdog that runs Chromium fullscreen on a Frigate dashboard (optionally with VLC on a second monitor for an RTSP camera stream), auto-restarts on frozen screens or unreachable URLs, and ships a Birdseye-aware Chromium helper that auto-sizes the grid to the display. + ## [Periscope](https://github.com/maksz42/periscope) [Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS. diff --git a/docs/docs/troubleshooting/faqs.md b/docs/docs/troubleshooting/faqs.md index 6cd67ba889..e11ae63159 100644 --- a/docs/docs/troubleshooting/faqs.md +++ b/docs/docs/troubleshooting/faqs.md @@ -111,26 +111,16 @@ TCP ensures that all data packets arrive in the correct order. This is crucial f You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation. -### Frigate hangs on startup with a "probing detect stream" message in the logs +### Frigate is slow to start up with a "probing detect stream" message in the logs -On startup, Frigate probes each camera's detect stream with OpenCV to auto-detect its resolution. OpenCV's FFmpeg backend may attempt RTSP over UDP during this probe regardless of the `-rtsp_transport tcp` in your `input_args` or preset. For cameras that do not respond to UDP (common on some Reolink models and others behind firewalls that block UDP), the probe can hang indefinitely and block Frigate from finishing startup, or it can return zeroed-out dimensions that show up as width `0` and height `0` in Camera Probe Info under System Metrics. +When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under System Metrics. -There are two ways to avoid this: +To skip the probe entirely and make startup instant, set `detect.width` and `detect.height` explicitly in your camera config: -1. Set `detect.width` and `detect.height` explicitly in your camera config. When both are set, Frigate skips the auto-detect probe entirely: - - ```yaml - cameras: - my_camera: - detect: - width: 1280 - height: 720 - ``` - -2. Force OpenCV's FFmpeg backend to use TCP for RTSP by setting the environment variable on your Frigate container: - - ``` - OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp - ``` - - This is a process-wide setting and applies to all cameras. If you have any cameras that require `preset-rtsp-udp`, use option 1 instead. +```yaml +cameras: + my_camera: + detect: + width: 1280 + height: 720 +``` diff --git a/docs/package-lock.json b/docs/package-lock.json index 626d71dfda..2310274651 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -14,9 +14,11 @@ "@docusaurus/theme-mermaid": "^3.7.0", "@inkeep/docusaurus": "^2.0.16", "@mdx-js/react": "^3.1.0", + "@types/js-yaml": "^4.0.9", "clsx": "^2.1.1", "docusaurus-plugin-openapi-docs": "^4.5.1", "docusaurus-theme-openapi-docs": "^4.5.1", + "js-yaml": "^4.1.1", "prism-react-renderer": "^2.4.1", "raw-loader": "^4.0.2", "react": "^18.3.1", @@ -5747,6 +5749,11 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://mirrors.tencent.com/npm/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -10897,9 +10904,9 @@ "license": "MIT" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/express/node_modules/range-parser": { @@ -10964,9 +10971,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -12883,7 +12890,7 @@ }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { diff --git a/docs/package.json b/docs/package.json index 0ff76c4739..e57d7a1540 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,9 +3,10 @@ "version": "0.0.0", "private": true, "scripts": { + "build:config": "node scripts/build-config.mjs", "docusaurus": "docusaurus", - "start": "npm run regen-docs && docusaurus start --host 0.0.0.0", - "build": "npm run regen-docs && docusaurus build", + "start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0", + "build": "npm run build:config && npm run regen-docs && docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -23,9 +24,11 @@ "@docusaurus/theme-mermaid": "^3.7.0", "@inkeep/docusaurus": "^2.0.16", "@mdx-js/react": "^3.1.0", + "@types/js-yaml": "^4.0.9", "clsx": "^2.1.1", "docusaurus-plugin-openapi-docs": "^4.5.1", "docusaurus-theme-openapi-docs": "^4.5.1", + "js-yaml": "^4.1.1", "prism-react-renderer": "^2.4.1", "raw-loader": "^4.0.2", "react": "^18.3.1", diff --git a/docs/scripts/build-config.mjs b/docs/scripts/build-config.mjs new file mode 100644 index 0000000000..78926bed5b --- /dev/null +++ b/docs/scripts/build-config.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +/** + * Build script: reads config.yaml and generates TypeScript files + * for the Docker Compose Generator. + * + * Usage: node scripts/build-config.mjs + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_DIR = path.resolve(__dirname, "../src/components/DockerComposeGenerator/config"); +const YAML_PATH = path.join(CONFIG_DIR, "config.yaml"); + +// Read & parse YAML +const raw = fs.readFileSync(YAML_PATH, "utf8"); +const config = yaml.load(raw); + +if (!config.devices || !config.hardware || !config.ports) { + console.error("config.yaml must contain 'devices', 'hardware', and 'ports' sections."); + process.exit(1); +} + +/** + * Generate a .ts file from a section of the YAML config. + */ +function generateTsFile(sectionName, items, typeName, varName, mapVarName, yamlFilename) { + const jsonItems = JSON.stringify(items, null, 2); + // Indent JSON to fit inside the array literal + const indented = jsonItems + .split("\n") + .map((line, i) => (i === 0 ? line : " " + line)) + .join("\n"); + + const content = `/** + * AUTO-GENERATED FILE — do not edit directly. + * Source: ${yamlFilename} + * To update, edit the YAML file and run: npm run build:config + */ + +import type { ${typeName} } from "./types"; + +export const ${varName}: ${typeName}[] = ${indented}; + +/** Lookup map for quick access by ID */ +export const ${mapVarName}: Map = new Map(${varName}.map((item) => [item.id, item])); +`; + + const outPath = path.join(CONFIG_DIR, `${sectionName}.ts`); + fs.writeFileSync(outPath, content, "utf8"); + console.log(` ✓ Generated ${sectionName}.ts (${items.length} items)`); +} + +console.log("Building config from config.yaml..."); + +generateTsFile("devices", config.devices, "DeviceConfig", "devices", "deviceMap", "config.yaml"); +generateTsFile("hardware", config.hardware, "HardwareOption", "hardwareOptions", "hardwareMap", "config.yaml"); +generateTsFile("ports", config.ports, "PortConfig", "ports", "portMap", "config.yaml"); + +console.log("Done!"); diff --git a/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx b/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx new file mode 100644 index 0000000000..b8a8a8fc85 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/DockerComposeGenerator.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import Admonition from "@theme/Admonition"; +import DeviceSelector from "./components/DeviceSelector"; +import HardwareOptions from "./components/HardwareOptions"; +import PortConfigSection from "./components/PortConfig"; +import StoragePaths from "./components/StoragePaths"; +import NvidiaGpuConfig from "./components/NvidiaGpuConfig"; +import OtherOptions from "./components/OtherOptions"; +import GeneratedOutput from "./components/GeneratedOutput"; +import { useConfigGenerator } from "./hooks/useConfigGenerator"; +import styles from "./styles.module.css"; + +/** + * Simple markdown-link-to-React renderer for help text. + * Only supports [text](url) syntax — no nested brackets. + */ +function renderHelpText(text: string): React.ReactNode { + const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g); + return parts.map((part, i) => { + const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (match) { + return ( + + {match[1]} + + ); + } + return {part}; + }); +} + +export default function DockerComposeGenerator() { + const { + deviceId, device, hardwareEnabled, + portEnabled, + nvidiaGpuCount, nvidiaGpuDeviceId, + configPath, mediaPath, rtspPassword, timezone, shmSize, + shmSizeError, gpuDeviceIdError, configPathError, mediaPathError, + hasAnyHardware, generatedYaml, + selectDevice, toggleHardware, togglePort, + handleShmSizeChange, handleConfigPathChange, handleMediaPathChange, + handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange, + setRtspPassword, setTimezone, isHardwareDisabled, + } = useConfigGenerator(); + + return ( +
+
+ + + {device.helpText && ( + + {renderHelpText(device.helpText)} + + )} + + {device.needsNvidiaConfig && ( + + )} + + + + + + + + + + +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx b/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx new file mode 100644 index 0000000000..ddad160502 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/DeviceSelector.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { useColorMode } from "@docusaurus/theme-common"; +import { devices } from "../config"; +import type { DeviceConfig } from "../config"; +import styles from "../styles.module.css"; + +interface Props { + selectedId: string; + onSelect: (id: string) => void; +} + +/** + * Determine the icon type from the icon string: + * - Starts with " tag. + */ +function hasBackgroundProps(style: React.CSSProperties | undefined): boolean { + if (!style) return false; + return Object.keys(style).some((key) => { + const k = key.toLowerCase().replace(/-/g, ""); + return k === "backgroundsize" || k === "backgroundposition" || k === "backgroundrepeat" || k === "backgroundimage"; + }); +} + +/** + * Convert a style object to CSS custom properties (e.g. { width: "24px" } → { "--svg-width": "24px" }) + * so they can be consumed by CSS rules targeting child elements like . + */ +function toCssVars(style: React.CSSProperties | undefined, prefix: string): React.CSSProperties { + if (!style) return {}; + const vars: Record = {}; + for (const [key, value] of Object.entries(style)) { + const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase(); + vars[`--${prefix}-${cssKey}`] = value; + } + return vars as React.CSSProperties; +} + +function DeviceIcon({ device }: { device: DeviceConfig }) { + const { isDarkTheme } = useColorMode(); + const iconStr = isDarkTheme && device.iconDark ? device.iconDark : device.icon; + const iconStyle = (isDarkTheme && device.iconDarkStyle + ? device.iconDarkStyle + : device.iconStyle) as React.CSSProperties | undefined; + const svgStyle = (isDarkTheme && device.svgDarkStyle + ? device.svgDarkStyle + : device.svgStyle) as React.CSSProperties | undefined; + + const iconType = getIconType(iconStr); + + if (iconType === "svg") { + return ( +
+ ); + } + + if (iconType === "image") { + // When iconStyle contains background-* properties, render as background-image + // on the container div instead of an tag, enabling background-size/position control. + if (hasBackgroundProps(iconStyle)) { + return ( +
+ ); + } + return ( +
+ {device.name} +
+ ); + } + + return ( +
+ {iconStr} +
+ ); +} + +function DeviceCard({ + device, + active, + onClick, +}: { + device: DeviceConfig; + active: boolean; + onClick: () => void; +}) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") onClick(); + }} + > + +
{device.name}
+
{device.description}
+
+ ); +} + +export default function DeviceSelector({ selectedId, onSelect }: Props) { + return ( +
+

Device Type

+
+ {devices.map((d) => ( + onSelect(d.id)} + /> + ))} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx b/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx new file mode 100644 index 0000000000..f170637aa0 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/GeneratedOutput.tsx @@ -0,0 +1,60 @@ +import React, { useState, useCallback } from "react"; +import CodeBlock from "@theme/CodeBlock"; +import Admonition from "@theme/Admonition"; +import styles from "../styles.module.css"; + +interface Props { + yaml: string; + configPath: string; + mediaPath: string; + hasAnyHardware: boolean; + deviceId: string; +} + +export default function GeneratedOutput({ + yaml, + configPath, + mediaPath, + hasAnyHardware, + deviceId, +}: Props) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(yaml).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [yaml]); + + return ( +
+
+

Generated Configuration

+ +
+ + {!configPath && ( + +

You haven't specified a config file directory. You may want to modify the default path.

+
+ )} + {!mediaPath && ( + +

You haven't specified a recording storage directory. You may want to modify the default path.

+
+ )} + {deviceId === "stable" && !hasAnyHardware && ( + +

You haven't selected any hardware acceleration. Please check if you have supported hardware available.

+
+ )} + + + {yaml} + +
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx b/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx new file mode 100644 index 0000000000..9c261ed419 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/HardwareOptions.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { hardwareOptions } from "../config"; +import type { HardwareOption } from "../config"; +import styles from "../styles.module.css"; + +interface Props { + deviceId: string; + hardwareEnabled: Record; + onToggle: (hwId: string) => void; + isDisabled: (hwId: string) => boolean; +} + +function renderDescription(text: string): React.ReactNode { + const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g); + return parts.map((part, i) => { + const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (match) { + return {match[1]}; + } + return {part}; + }); +} + +function HardwareCheckbox({ + hw, disabled, checked, onToggle, +}: { + hw: HardwareOption; disabled: boolean; checked: boolean; onToggle: () => void; +}) { + return ( +
+ + {checked && hw.description && ( +
{renderDescription(hw.description)}
+ )} +
+ ); +} + +export default function HardwareOptions({ deviceId, hardwareEnabled, onToggle, isDisabled }: Props) { + return ( +
+

Generic Hardware Devices

+ {deviceId !== "stable" && ( +

+ Some options have been auto-configured based on your device type. +

+ )} +
+ {hardwareOptions.map((hw) => { + const disabled = isDisabled(hw.id); + const checked = disabled ? false : !!hardwareEnabled[hw.id]; + return ( + onToggle(hw.id)} /> + ); + })} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx b/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx new file mode 100644 index 0000000000..9c9be5e6a9 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/NvidiaGpuConfig.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import styles from "../styles.module.css"; + +interface Props { + gpuCount: string; + gpuDeviceId: string; + gpuDeviceIdError: boolean; + onGpuCountChange: (value: string) => void; + onGpuDeviceIdChange: (value: string) => void; +} + +export default function NvidiaGpuConfig({ + gpuCount, + gpuDeviceId, + gpuDeviceIdError, + onGpuCountChange, + onGpuDeviceIdChange, +}: Props) { + const showDeviceId = gpuCount !== ""; + + return ( +
+
+ + onGpuCountChange(e.target.value.replace(/\D/g, ""))} + /> +
+ {showDeviceId && ( +
+ + onGpuDeviceIdChange(e.target.value)} + /> + {gpuDeviceIdError ? ( +

+ ⚠️ GPU device IDs are required when GPU count is a number +

+ ) : ( +

+ Single GPU: 0  |  Multiple GPUs: 0,1,2 +

+ )} +
+ )} +
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx b/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx new file mode 100644 index 0000000000..8d1efef0fc --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/OtherOptions.tsx @@ -0,0 +1,122 @@ +import React, { useMemo } from "react"; +import CodeInline from "@theme/CodeInline"; +import styles from "../styles.module.css"; + +const AUTO_TIMEZONE_VALUE = "__auto__"; + +function getTimezoneList(): string[] { + if (typeof Intl !== "undefined") { + const intl = Intl as typeof Intl & { + supportedValuesOf?: (key: string) => string[]; + }; + const supported = intl.supportedValuesOf?.("timeZone"); + if (supported && supported.length > 0) { + return [...supported].sort(); + } + } + + const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone; + return fallback ? [fallback] : ["UTC"]; +} + +interface Props { + rtspPassword: string; + timezone: string; + shmSize: string; + shmSizeError: boolean; + onRtspPasswordChange: (value: string) => void; + onTimezoneChange: (value: string) => void; + onShmSizeChange: (value: string) => void; +} + +export default function OtherOptions({ + rtspPassword, + timezone, + shmSize, + shmSizeError, + onRtspPasswordChange, + onTimezoneChange, + onShmSizeChange, +}: Props) { + const timezones = useMemo(() => getTimezoneList(), []); + const systemTimezone = + Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"; + const selectedValue = timezone || AUTO_TIMEZONE_VALUE; + + return ( +
+

Other Options

+
+
+ + +
+
+ + onShmSizeChange(e.target.value)} + /> + {shmSizeError ? ( +

+ ⚠️ Invalid format. Use a number followed by a unit (e.g. 512mb, 1gb) +

+ ) : ( +

+ See{" "} + + calculating required SHM size + {" "} + for the correct value. +

+ )} +
+
+ + onRtspPasswordChange(e.target.value)} + /> +

+ Optional. You can specify{" "} + {"{FRIGATE_RTSP_PASSWORD}"}{" "} + in the config file to reference camera stream passwords. This is NOT + the Frigate login password. +

+
+
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx b/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx new file mode 100644 index 0000000000..c4e5acf713 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/PortConfig.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import Admonition from "@theme/Admonition"; +import { ports } from "../config"; +import styles from "../styles.module.css"; + +interface Props { + portEnabled: Record; + onTogglePort: (portId: string) => void; +} + +function PortItem({ + port, + enabled, + onToggle, +}: { + port: typeof ports[number]; + enabled: boolean; + onToggle: () => void; +}) { + const showWarning = port.warningContent && ( + port.warningWhen === "checked" ? enabled : + port.warningWhen === "unchecked" ? !enabled : enabled + ); + + return ( +
+ + {port.description && ( +
{port.description}
+ )} + {showWarning && ( + + {port.warningContent} + + )} +
+ ); +} + +export default function PortConfigSection({ + portEnabled, + onTogglePort, +}: Props) { + return ( +
+

Port Configuration

+
+ {ports.map((port) => ( + onTogglePort(port.id)} + /> + ))} +
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx b/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx new file mode 100644 index 0000000000..1e20189cea --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/components/StoragePaths.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import styles from "../styles.module.css"; + +interface Props { + configPath: string; + mediaPath: string; + configPathError: boolean; + mediaPathError: boolean; + onConfigPathChange: (value: string) => void; + onMediaPathChange: (value: string) => void; +} + +export default function StoragePaths({ + configPath, + mediaPath, + configPathError, + mediaPathError, + onConfigPathChange, + onMediaPathChange, +}: Props) { + return ( +
+

Storage Paths

+
+
+ + onConfigPathChange(e.target.value)} + /> + {configPathError && ( +

+ ⚠️ Path contains invalid characters. Only letters, numbers, + underscores, hyphens, slashes, and dots are allowed. +

+ )} +
+
+ + onMediaPathChange(e.target.value)} + /> + {mediaPathError && ( +

+ ⚠️ Path contains invalid characters. Only letters, numbers, + underscores, hyphens, slashes, and dots are allowed. +

+ )} +
+
+
+ ); +} diff --git a/docs/src/components/DockerComposeGenerator/config/config.yaml b/docs/src/components/DockerComposeGenerator/config/config.yaml new file mode 100644 index 0000000000..42199ffca1 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/config.yaml @@ -0,0 +1,297 @@ +# Unified configuration for Docker Compose Generator +# This file defines all devices, hardware options, and ports for Frigate Docker Compose generation + +devices: + - id: "stable" + name: "Standard x86_64" + description: "Generic PC / server" + icon: "💻" + imageTag: "stable" + autoHardware: [] + + - id: "intel" + name: "Intel Device" + description: "Intel GPU / NPU" + icon: '' + imageTag: "stable" + autoHardware: + - "gpu" + - "intelNpu" + helpText: "Intel Device automatically configures /dev/dri and /dev/accel device mappings." + helpType: "info" + + - id: "stable-tensorrt" + name: "NVIDIA GPU" + description: "NVIDIA acceleration" + icon: '' + svgStyle: + width: 50px + height: 50px + iconStyle: + padding-bottom: 15px + imageTag: "stable-tensorrt" + autoHardware: [] + helpText: "Requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker) to be installed. GPU deploy resources are configured automatically." + helpType: "warning" + needsNvidiaConfig: true + + - id: "stable-tensorrt-jp6" + name: "NVIDIA Jetson" + description: "Jetson development board" + icon: '' + svgStyle: + width: 50px + height: 50px + iconStyle: + padding-bottom: 15px + imageTag: "stable-tensorrt-jp6" + autoHardware: [] + helpText: "NVIDIA Jetson devices automatically configure runtime: nvidia." + helpType: "info" + runtime: "nvidia" + + - id: "stable-rocm" + name: "AMD GPU" + description: "ROCm acceleration" + icon: "https://www.amd.com/content/dam/code/images/header/amd-header-logo.svg" + iconStyle: + filter: invert(1) + background-repeat: no-repeat + background-position: right center + background-size: 338% 90% + iconDark: "https://www.amd.com/content/dam/code/images/header/amd-header-logo.svg" + iconDarkStyle: + filter: invert(0) + background-repeat: no-repeat + background-position: right center + background-size: 338% 90% + imageTag: "stable-rocm" + autoHardware: + - "gpu" + helpText: "AMD GPU automatically configures LIBVA_DRIVER_NAME environment variable and /dev/dri device mapping." + helpType: "info" + env: + LIBVA_DRIVER_NAME: "radeonsi" + + - id: "apple-silicon" + name: "Apple Silicon" + description: "Mac M-series processor" + icon: '' + svgStyle: + width: 90px + height: 90px + svgDarkStyle: + width: 90px + height: 90px + fill: white + imageTag: "stable" + imageTagSuffix: "-standard-arm64" + autoHardware: [] + helpText: "Apple Silicon (M-series) requires an [external detector](/configuration/object_detectors#apple-silicon-detector) running on the host." + helpType: "warning" + extraHosts: + - "host.docker.internal:host-gateway" + + - id: "raspberry-pi" + name: "Raspberry Pi" + description: "ARM device" + icon: '' + svgStyle: + width: 40px + height: 40px + transform: translateX(-3px) + imageTag: "stable" + imageTagSuffix: "-standard-arm64" + autoHardware: + - "video11" + helpText: "Raspberry Pi automatically configures the video11 device (RPi 4) and uses the arm64 image." + helpType: "info" + + - id: "stable-rk" + name: "Rockchip" + description: "Rockchip SoC board" + icon: "https://www.rock-chips.com/favicon.ico" + imageTag: "stable-rk" + autoHardware: + - "gpu" + helpText: "Rockchip devices automatically configure /dev/dri device mapping." + helpType: "info" + devices: + - host: "/dev/dma_heap" + comment: "Rockchip DMA heap" + - host: "/dev/rga" + comment: "Rockchip RGA" + - host: "/dev/mpp_service" + comment: "Rockchip MPP service" + volumes: + - host: "/sys/" + container: "/sys/" + readOnly: true + comment: "Rockchip system info" + securityOpt: + - "apparmor=unconfined" + - "systempaths=unconfined" + + - id: "stable-synaptics" + name: "Synaptics" + description: "Synaptics NPU" + icon: "🔷" + imageTag: "stable-synaptics" + autoHardware: [] + helpText: "Synaptics devices automatically configure /dev/synap and video devices." + helpType: "info" + devices: + - host: "/dev/synap" + comment: "Synaptics NPU" + - host: "/dev/video0" + comment: "Video device 0" + - host: "/dev/video1" + comment: "Video device 1" + +hardware: + - id: "usbCoral" + label: "USB Coral (TPU)" + description: "Enable this if you have a Google Coral USB TPU. Other Coral versions require different device paths." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/bus/usb" + container: "/dev/bus/usb" + comment: "USB Coral — modify for other versions" + + - id: "pcieCoral" + label: "PCIe Coral (TPU)" + description: "Enable this if you have a Google Coral PCIe/M.2 TPU. You also need to [install the driver](https://github.com/jnicolson/gasket-builder)." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/apex_0" + container: "/dev/apex_0" + comment: "PCIe Coral — follow driver instructions at https://github.com/jnicolson/gasket-builder" + + - id: "gpu" + label: "Intel/AMD GPU (/dev/dri)" + description: "Pass through /dev/dri for GPU hardware acceleration (Intel/AMD)." + disabledWhen: + - "stable-tensorrt-jp6" + - "apple-silicon" + devices: + - host: "/dev/dri" + container: "/dev/dri" + comment: "Intel/AMD GPU hardware acceleration" + + - id: "intelNpu" + label: "Intel NPU (/dev/accel)" + description: "Pass through /dev/accel for Intel NPU acceleration." + disabledWhen: + - "stable-tensorrt-jp6" + - "apple-silicon" + - "stable-rocm" + - "stable-rk" + - "stable-synaptics" + devices: + - host: "/dev/accel" + container: "/dev/accel" + comment: "Intel NPU" + + - id: "hailo" + label: "Hailo NPU (/dev/hailo0)" + description: "Pass through /dev/hailo0 for Hailo-8 / Hailo-8L NPU acceleration. You also need to [install the driver](#hailo-8)." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/hailo0" + comment: "Hailo NPU" + + - id: "memryx" + label: "MemryX MX3 (/dev/memx0)" + description: "Pass through /dev/memx0 for MemryX MX3 NPU acceleration. You also need to [install the driver](#memryx-mx3)." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/memx0" + comment: "MemryX MX3 NPU" + volumes: + - host: "/run/mxa_manager" + container: "/run/mxa_manager" + comment: "MemryX manager" + + - id: "axera" + label: "AXERA Accelerator" + description: "Pass through AXERA accelerator devices. Requires the [AXCL driver](#axera) to be installed first." + disabledWhen: + - "apple-silicon" + - "stable-synaptics" + devices: + - host: "/dev/axcl_host" + comment: "AXERA accelerator device" + - host: "/dev/ax_mmb_dev" + comment: "AXERA MMB device" + - host: "/dev/msg_userdev" + comment: "AXERA message device" + volumes: + - host: "/usr/bin/axcl" + container: "/usr/bin/axcl" + comment: "AXERA binaries" + - host: "/usr/lib/axcl" + container: "/usr/lib/axcl" + comment: "AXERA libraries" + + - id: "video11" + label: "Raspberry Pi (/dev/video11)" + description: "Pass through /dev/video11 for Raspberry Pi 4B hardware acceleration." + disabledWhen: + - "stable-tensorrt" + - "stable-tensorrt-jp6" + - "stable-rocm" + - "stable-rk" + - "stable-synaptics" + - "intel" + - "apple-silicon" + - "stable" + devices: + - host: "/dev/video11" + container: "/dev/video11" + comment: "Raspberry Pi 4B" + +ports: + - id: "8971" + host: 8971 + container: 8971 + protocol: "tcp" + description: "Authenticated UI and API access (default HTTPS)" + defaultEnabled: true + warningContent: "This is the access port for Frigate. Closing it means you will no longer be able to access the instance." + warningWhen: "unchecked" + + - id: "8554" + host: 8554 + container: 8554 + protocol: "tcp" + description: "Access RTSP feeds from go2rtc" + defaultEnabled: true + + - id: "8555-tcp" + host: 8555 + container: 8555 + protocol: "tcp" + description: "WebRTC over TCP" + defaultEnabled: true + + - id: "8555-udp" + host: 8555 + container: 8555 + protocol: "udp" + description: "WebRTC over UDP" + defaultEnabled: true + + - id: "1984" + host: 1984 + container: 1984 + protocol: "tcp" + description: "Go2RTC Web UI" + defaultEnabled: false diff --git a/docs/src/components/DockerComposeGenerator/config/index.ts b/docs/src/components/DockerComposeGenerator/config/index.ts new file mode 100644 index 0000000000..5acaba9f1b --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/index.ts @@ -0,0 +1,12 @@ +export { devices, deviceMap } from "./devices"; +export { hardwareOptions, hardwareMap } from "./hardware"; +export { ports, portMap } from "./ports"; + +export type { + DeviceConfig, + DeviceMapping, + VolumeMapping, + HardwareOption, + PortConfig, + NvidiaDeployConfig, +} from "./types"; diff --git a/docs/src/components/DockerComposeGenerator/config/types.ts b/docs/src/components/DockerComposeGenerator/config/types.ts new file mode 100644 index 0000000000..87bcb608dd --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/config/types.ts @@ -0,0 +1,154 @@ +/** + * Type definitions for the Docker Compose Generator configuration. + * All device, hardware, and port options are declaratively defined + * so that adding a new device only requires editing config files. + */ + +/** A single device mapping entry (e.g. /dev/dri:/dev/dri) */ +export interface DeviceMapping { + /** Host device path */ + host: string; + /** Container device path (defaults to host if omitted) */ + container?: string; + /** Inline comment for this device line */ + comment?: string; +} + +/** A single volume mapping entry */ +export interface VolumeMapping { + /** Host path */ + host: string; + /** Container path */ + container: string; + /** Whether the mount is read-only */ + readOnly?: boolean; + /** Inline comment */ + comment?: string; +} + +/** NVIDIA deploy configuration for docker-compose */ +export interface NvidiaDeployConfig { + /** "all" or a specific number */ + count: string; + /** Specific GPU device IDs (when count is a number) */ + deviceIds?: string[]; +} + +/** Full device type definition */ +export interface DeviceConfig { + /** Unique identifier, e.g. "intel" */ + id: string; + /** Display name, e.g. "Intel GPU" */ + name: string; + /** Short description */ + description: string; + /** + * Icon for the device card. Supports: + * - Emoji string (e.g. "🖥️") + * - Image URL or static path (e.g. "/img/intel.svg", "https://example.com/icon.png") + * - Inline SVG markup (e.g. "...") + */ + icon: string; + /** + * Additional CSS properties applied to the icon element. + * - For image-type icons: if any `background-*` property (e.g. `background-size`, + * `background-position`) is present, the image is rendered as a CSS `background-image` + * on the container div, enabling full background positioning control. + * Otherwise the image is rendered as an `` tag and styles apply to it. + * - For emoji/SVG icons: styles apply to the container div. + */ + iconStyle?: Record; + /** + * Additional CSS properties applied directly to the inner `` element + * when the icon is an inline SVG. Use this to override the default + * `width: 100%; height: 100%` or set `fill`, `transform`, etc. + * Ignored for emoji and image-type icons. + */ + svgStyle?: Record; + /** + * Icon for dark mode. Same format as `icon`. When provided, this icon + * replaces `icon` when the user is in dark mode. + */ + iconDark?: string; + /** Additional CSS properties for the dark mode icon container */ + iconDarkStyle?: Record; + /** + * SVG-specific styles for dark mode. Same as `svgStyle` but applied + * when dark mode is active. Merged over `svgStyle` in dark mode. + */ + svgDarkStyle?: Record; + /** Docker image tag, e.g. "stable" */ + imageTag: string; + /** + * Image tag suffix appended to the base tag. + * e.g. "-standard-arm64" produces "stable-standard-arm64" + */ + imageTagSuffix?: string; + /** Hardware option IDs to auto-enable when this device is selected */ + autoHardware: string[]; + /** Help text shown as an admonition when this device is selected */ + helpText?: string; + /** Admonition type for help text */ + helpType?: "info" | "warning" | "danger"; + /** Device mappings always added for this device type */ + devices?: DeviceMapping[]; + /** Volume mappings always added for this device type */ + volumes?: VolumeMapping[]; + /** Extra environment variables for this device type */ + env?: Record; + /** NVIDIA deploy config (only for tensorrt) */ + nvidiaDeploy?: NvidiaDeployConfig; + /** Runtime setting, e.g. "nvidia" for Jetson */ + runtime?: string; + /** Extra hosts entries, e.g. "host.docker.internal:host-gateway" */ + extraHosts?: string[]; + /** Security options, e.g. ["apparmor=unconfined"] */ + securityOpt?: string[]; + /** Whether this device type needs the NVIDIA GPU config UI */ + needsNvidiaConfig?: boolean; +} + +/** Generic hardware acceleration option definition */ +export interface HardwareOption { + /** Unique identifier, e.g. "usbCoral" */ + id: string; + /** Display label */ + label: string; + /** + * Description shown below the checkbox when this option is enabled. + * Supports markdown link syntax: [text](url) + */ + description?: string; + /** Device IDs that disable this option */ + disabledWhen?: string[]; + /** Device mappings added when this option is enabled */ + devices?: DeviceMapping[]; + /** Volume mappings added when this option is enabled */ + volumes?: VolumeMapping[]; + /** Extra environment variables */ + env?: Record; +} + +/** Port definition */ +export interface PortConfig { + /** Unique identifier (also the default host port as string) */ + id: string; + /** Host port number */ + host: number; + /** Container port number */ + container: number; + /** Protocol */ + protocol?: "tcp" | "udp"; + /** Description of the port's purpose */ + description: string; + /** Whether enabled by default */ + defaultEnabled: boolean; + /** Whether this port is locked (always enabled, cannot be toggled off) */ + locked?: boolean; + /** Admonition type for the warning */ + warningType?: "warning" | "danger"; + /** Warning content (markdown) */ + warningContent?: string; + /** When to show the warning: when the port is checked or unchecked */ + warningWhen?: "checked" | "unchecked"; +} diff --git a/docs/src/components/DockerComposeGenerator/generator/index.ts b/docs/src/components/DockerComposeGenerator/generator/index.ts new file mode 100644 index 0000000000..f6091f7c01 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/generator/index.ts @@ -0,0 +1,250 @@ +import type { + DeviceConfig, + DeviceMapping, + VolumeMapping, +} from "../config/types"; +import { hardwareMap } from "../config"; + +// --------------------------------------------------------------------------- +// Input type +// --------------------------------------------------------------------------- + +export interface GeneratorInput { + device: DeviceConfig; + selectedHardware: string[]; + enabledPorts: string[]; + configPath: string; + mediaPath: string; + rtspPassword?: string; + timezone: string; + shmSize: string; + nvidiaGpuCount?: string; + nvidiaGpuDeviceId?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function deviceLine(dm: DeviceMapping): string { + const host = dm.host; + const container = dm.container ?? dm.host; + const mapping = host === container ? host : `${host}:${container}`; + const comment = dm.comment ? ` # ${dm.comment}` : ""; + return ` - ${mapping}${comment}`; +} + +function volumeLine(vm: VolumeMapping): string { + const ro = vm.readOnly ? ":ro" : ""; + const comment = vm.comment ? ` # ${vm.comment}` : ""; + return ` - ${vm.host}:${vm.container}${ro}${comment}`; +} + +// --------------------------------------------------------------------------- +// YAML builder — each section returns an array of lines +// --------------------------------------------------------------------------- + +function buildImage(device: DeviceConfig): string[] { + const tag = device.imageTagSuffix + ? `${device.imageTag}${device.imageTagSuffix}` + : device.imageTag; + return [` image: ghcr.io/blakeblackshear/frigate:${tag}`]; +} + +function buildDevices( + device: DeviceConfig, + hwDevices: DeviceMapping[] +): string[] { + const all: DeviceMapping[] = [ + ...(device.devices ?? []), + ...hwDevices, + ]; + if (all.length === 0) return []; + return [ + " devices:", + ...all.map(deviceLine), + ]; +} + +function buildVolumes( + device: DeviceConfig, + hwVolumes: VolumeMapping[], + configPath: string, + mediaPath: string +): string[] { + const all: VolumeMapping[] = [ + ...(device.volumes ?? []), + ...hwVolumes, + ]; + return [ + " volumes:", + " - /etc/localtime:/etc/localtime:ro # Sync host time", + ` - ${configPath}:/config # Config file directory`, + ` - ${mediaPath}:/media/frigate # Recording storage directory`, + " - type: tmpfs # 1GB in-memory filesystem for recording segment storage", + " target: /tmp/cache", + " tmpfs:", + " size: 1000000000", + ...all.map(volumeLine), + ]; +} + +function buildPorts(enabledPorts: string[]): string[] { + return [ + " ports:", + ...enabledPorts, + ]; +} + +function buildEnvironment( + device: DeviceConfig, + hwEnv: Record, + rtspPassword: string | undefined, + timezone: string +): string[] { + const allEnv: Record = { + ...hwEnv, + ...(device.env ?? {}), + }; + + const lines: string[] = [" environment:"]; + + if (rtspPassword) { + lines.push( + ` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own` + ); + } + + lines.push(` TZ: "${timezone}" # Timezone`); + + for (const [key, value] of Object.entries(allEnv)) { + lines.push(` ${key}: "${value}"`); + } + + return lines; +} + +function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] { + if (device.id === "stable-tensorrt") { + const count = input.nvidiaGpuCount || "all"; + const isAll = count === "all"; + const deviceId = input.nvidiaGpuDeviceId?.trim(); + + if (isAll) { + return [ + " deploy:", + " resources:", + " reservations:", + " devices:", + " - driver: nvidia", + " count: all # Use all GPUs", + " capabilities: [gpu]", + ]; + } + + if (deviceId) { + const ids = deviceId + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => `'${s}'`) + .join(", "); + return [ + " deploy:", + " resources:", + " reservations:", + " devices:", + " - driver: nvidia", + ` device_ids: [${ids}] # GPU device IDs`, + ` count: ${count} # GPU count`, + " capabilities: [gpu]", + ]; + } + + return [ + " deploy:", + " resources:", + " reservations:", + " devices:", + " - driver: nvidia", + ` count: ${count} # GPU count`, + " capabilities: [gpu]", + ]; + } + + return []; +} + +function buildRuntime(device: DeviceConfig): string[] { + if (device.runtime) { + return [` runtime: ${device.runtime}`]; + } + return []; +} + +function buildExtraHosts(device: DeviceConfig): string[] { + if (!device.extraHosts?.length) return []; + return [ + " extra_hosts:", + ...device.extraHosts.map( + (h, i) => + ` - "${h}"${i === 0 ? " # Required to talk to the NPU detector" : ""}` + ), + ]; +} + +function buildSecurityOpt(device: DeviceConfig): string[] { + if (!device.securityOpt?.length) return []; + return [ + " security_opt:", + ...device.securityOpt.map((s) => ` - ${s}`), + ]; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Generate a docker-compose YAML string from the given input. + * The output is pure YAML with inline comments (no Shiki annotations). + */ +export function generateDockerCompose(input: GeneratorInput): string { + const { device } = input; + + // Collect hardware-level devices, volumes, and env + const hwDevices: DeviceMapping[] = []; + const hwVolumes: VolumeMapping[] = []; + const hwEnv: Record = {}; + + for (const hwId of input.selectedHardware) { + const hw = hardwareMap.get(hwId); + if (!hw) continue; + // Skip GPU device mapping for tensorrt images (it uses deploy instead) + if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue; + hwDevices.push(...(hw.devices ?? [])); + hwVolumes.push(...(hw.volumes ?? [])); + Object.assign(hwEnv, hw.env ?? {}); + } + + const lines: string[] = [ + "services:", + " frigate:", + " container_name: frigate", + " privileged: true # This may not be necessary for all setups", + " restart: unless-stopped", + " stop_grace_period: 30s # Allow enough time to shut down the various services", + ...buildImage(device), + ` shm_size: "${input.shmSize || "512mb"}" # Update for your cameras based on SHM calculation`, + ...buildRuntime(device), + ...buildDeploy(device, input), + ...buildExtraHosts(device), + ...buildSecurityOpt(device), + ...buildDevices(device, hwDevices), + ...buildVolumes(device, hwVolumes, input.configPath, input.mediaPath), + ...buildPorts(input.enabledPorts), + ...buildEnvironment(device, hwEnv, input.rtspPassword, input.timezone), + ]; + + return lines.join("\n"); +} diff --git a/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts b/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts new file mode 100644 index 0000000000..19c3976d8d --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/hooks/useConfigGenerator.ts @@ -0,0 +1,195 @@ +import { useState, useCallback, useMemo } from "react"; +import { deviceMap, hardwareMap, portMap } from "../config"; +import { generateDockerCompose } from "../generator"; +import type { GeneratorInput } from "../generator"; + +/** + * Main hook that holds all form state and generates the Docker Compose output. + * Configuration is loaded synchronously from build-time generated .ts files. + */ +export function useConfigGenerator() { + const [deviceId, setDeviceId] = useState("stable"); + + const [hardwareEnabled, setHardwareEnabled] = useState>(() => { + const defaultDevice = deviceMap.get("stable"); + const initial: Record = {}; + if (defaultDevice) { + for (const hwId of defaultDevice.autoHardware) { + initial[hwId] = true; + } + } + return initial; + }); + + const [portEnabled, setPortEnabled] = useState>(() => { + const initial: Record = {}; + for (const p of portMap.values()) { + initial[p.id] = p.defaultEnabled; + } + return initial; + }); + + const [nvidiaGpuCount, setNvidiaGpuCount] = useState(""); + const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState(""); + const [configPath, setConfigPath] = useState(""); + const [mediaPath, setMediaPath] = useState(""); + const [rtspPassword, setRtspPassword] = useState(""); + const [timezone, setTimezone] = useState(""); + const [shmSize, setShmSize] = useState("512mb"); + const [shmSizeError, setShmSizeError] = useState(false); + const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false); + const [configPathError, setConfigPathError] = useState(false); + const [mediaPathError, setMediaPathError] = useState(false); + + const device = useMemo(() => deviceMap.get(deviceId)!, [deviceId]); + + const selectDevice = useCallback((id: string) => { + const newDevice = deviceMap.get(id); + if (!newDevice) return; + setDeviceId(id); + setHardwareEnabled(() => { + const next: Record = {}; + for (const hwId of newDevice.autoHardware) { + next[hwId] = true; + } + return next; + }); + setNvidiaGpuCount(""); + setNvidiaGpuDeviceId(""); + setGpuDeviceIdError(false); + }, []); + + const toggleHardware = useCallback((hwId: string) => { + setHardwareEnabled((prev) => ({ ...prev, [hwId]: !prev[hwId] })); + }, []); + + const togglePort = useCallback((portId: string) => { + const port = portMap.get(portId); + if (port?.locked) return; + setPortEnabled((prev) => ({ ...prev, [portId]: !prev[portId] })); + }, []); + + const isHardwareDisabled = useCallback( + (hwId: string): boolean => { + const hw = hardwareMap.get(hwId); + if (!hw) return false; + return hw.disabledWhen?.includes(deviceId) ?? false; + }, + [deviceId] + ); + + const validateShmSize = useCallback((value: string): boolean => { + if (!value) return true; + return /^\d+(\.\d+)?[bkmgBKMG]{1,2}$/.test(value); + }, []); + + const validatePath = useCallback((value: string): boolean => { + if (!value) return true; + return /^[a-zA-Z0-9_\-/./]+$/.test(value); + }, []); + + const handleShmSizeChange = useCallback( + (value: string) => { + const filtered = value.replace(/[^0-9.bkmgBKMG]/g, ""); + const valid = validateShmSize(filtered); + setShmSize(filtered); + setShmSizeError(!valid && filtered !== ""); + }, + [validateShmSize] + ); + + const handleConfigPathChange = useCallback( + (value: string) => { + const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, ""); + const valid = validatePath(filtered); + setConfigPath(filtered); + setConfigPathError(!valid && filtered !== ""); + }, + [validatePath] + ); + + const handleMediaPathChange = useCallback( + (value: string) => { + const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, ""); + const valid = validatePath(filtered); + setMediaPath(filtered); + setMediaPathError(!valid && filtered !== ""); + }, + [validatePath] + ); + + const handleNvidiaGpuCountChange = useCallback((value: string) => { + // Only allow digits + setNvidiaGpuCount(value); + if (value === "") { + setNvidiaGpuDeviceId(""); + setGpuDeviceIdError(false); + } else { + setGpuDeviceIdError(false); + } + }, []); + + const handleNvidiaGpuDeviceIdChange = useCallback((value: string) => { + setNvidiaGpuDeviceId(value.trim()); + setGpuDeviceIdError(false); + }, []); + + const enabledPortLines = useMemo(() => { + const lines: string[] = []; + for (const [id, enabled] of Object.entries(portEnabled)) { + if (!enabled) continue; + const p = portMap.get(id); + if (!p) continue; + const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : ""; + const comment = p.description ? ` # ${p.description}` : ""; + lines.push(` - "${p.host}:${p.container}${proto}"${comment}`); + } + return lines; + }, [portEnabled]); + + const selectedHardwareIds = useMemo(() => { + return Object.entries(hardwareEnabled) + .filter(([id, enabled]) => { + if (!enabled) return false; + const hw = hardwareMap.get(id); + if (!hw) return false; + if (hw.disabledWhen?.includes(deviceId)) return false; + return true; + }) + .map(([id]) => id); + }, [hardwareEnabled, deviceId]); + + const generatedYaml = useMemo(() => { + const input: GeneratorInput = { + device, + selectedHardware: selectedHardwareIds, + enabledPorts: enabledPortLines, + configPath: configPath || "/path/to/your/config", + mediaPath: mediaPath || "/path/to/your/storage", + rtspPassword, + timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC", + shmSize: shmSize || "512mb", + nvidiaGpuCount, + nvidiaGpuDeviceId, + }; + return generateDockerCompose(input); + }, [ + device, selectedHardwareIds, enabledPortLines, + configPath, mediaPath, rtspPassword, timezone, shmSize, + nvidiaGpuCount, nvidiaGpuDeviceId, + ]); + + const hasAnyHardware = selectedHardwareIds.length > 0 || !!device?.devices?.length; + + return { + deviceId, device, hardwareEnabled, portEnabled, + nvidiaGpuCount, nvidiaGpuDeviceId, + configPath, mediaPath, rtspPassword, timezone, shmSize, + shmSizeError, gpuDeviceIdError, configPathError, mediaPathError, + hasAnyHardware, generatedYaml, + selectDevice, toggleHardware, togglePort, + handleShmSizeChange, handleConfigPathChange, handleMediaPathChange, + handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange, + setRtspPassword, setTimezone, isHardwareDisabled, + }; +} diff --git a/docs/src/components/DockerComposeGenerator/index.ts b/docs/src/components/DockerComposeGenerator/index.ts new file mode 100644 index 0000000000..76dd587560 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/index.ts @@ -0,0 +1 @@ +export { default } from "./DockerComposeGenerator"; diff --git a/docs/src/components/DockerComposeGenerator/styles.module.css b/docs/src/components/DockerComposeGenerator/styles.module.css new file mode 100644 index 0000000000..d2e1b62dd8 --- /dev/null +++ b/docs/src/components/DockerComposeGenerator/styles.module.css @@ -0,0 +1,381 @@ +/* =================================================================== + Docker Compose Generator — styles + Uses Docusaurus / Infima CSS variables for theme compatibility. + =================================================================== */ + +.generator { + margin: 2rem 0; +} + +.card { + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 12px; + padding: 2rem; + box-shadow: var(--ifm-global-shadow-lw); +} + +[data-theme="light"] .card { + background: var(--ifm-color-emphasis-100); + border: 1px solid var(--ifm-color-emphasis-300); +} + +/* --- Form sections --- */ + +.formSection { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--ifm-color-emphasis-400); +} + +.formSection:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.formSection h4 { + margin: 0 0 1rem 0; + color: var(--ifm-font-color-base); + font-size: 1.1rem; + font-weight: var(--ifm-font-weight-semibold); +} + +/* --- Form controls --- */ + +.formGroup { + margin-bottom: 1rem; +} + +.formGroup:last-child { + margin-bottom: 0; +} + +.label { + display: block; + margin-bottom: 0.25rem; + color: var(--ifm-font-color-base); + font-weight: var(--ifm-font-weight-semibold); + font-size: 0.9rem; +} + +.input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 6px; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font-size: 0.95rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +[data-theme="light"] .input { + background: #fff; + border: 1px solid #d0d7de; +} + +.input:focus { + outline: none; + border-color: var(--ifm-color-primary); + box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest); +} + +[data-theme="dark"] .input { + border-color: var(--ifm-color-emphasis-300); +} + +.inputError { + border-color: #e74c3c; + animation: shake 0.3s ease-in-out; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-5px); + } + 75% { + transform: translateX(5px); + } +} + +/* --- Select dropdown --- */ + +.select { + cursor: pointer; + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + background: var(--ifm-background-color) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E") + no-repeat right 0.75rem center / 12px 12px; + padding-right: 2rem; +} + +[data-theme="light"] .select { + background: #fff + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555' d='M6 8L1 3h10z'/%3E%3C/svg%3E") + no-repeat right 0.75rem center / 12px 12px; +} + +.helpText { + margin: 0.5rem 0 0 0; + font-size: 0.85rem; + color: var(--ifm-font-color-secondary); + line-height: 1.5; +} + +.helpText a { + color: var(--ifm-color-primary); +} + +/* --- Device grid --- */ + +.deviceGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.75rem; + margin-top: 0.5rem; +} + +.deviceCard { + padding: 0.75rem; + border: 2px solid var(--ifm-color-emphasis-400); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + text-align: center; + background: var(--ifm-background-color); + display: flex; + flex-direction: column; + align-items: center; +} + +[data-theme="light"] .deviceCard { + border: 2px solid #d0d7de; + background: #fff; +} + +.deviceCard:hover { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-emphasis-100); + transform: translateY(-2px); +} + +.deviceCardActive { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-primary-lightest); + box-shadow: 0 0 0 1px var(--ifm-color-primary); +} + +[data-theme="light"] .deviceCardActive { + background: color-mix(in srgb, var(--ifm-color-primary) 12%, #fff); +} + +[data-theme="dark"] .deviceCardActive { + background: color-mix(in srgb, var(--ifm-color-primary) 25%, #1b1b1b); +} + +[data-theme="dark"] .deviceCardActive .deviceName { + color: var(--ifm-color-primary-light); +} + +[data-theme="dark"] .deviceCardActive .deviceDesc { + color: var(--ifm-color-primary-light); + opacity: 0.85; +} + +.deviceIcon { + font-size: 2rem; + margin-bottom: 0.25rem; + height: 40px; + width: 50px; + display: flex; + align-items: center; + justify-content: center; +} + +.deviceIconSvg { + margin-bottom: 0.25rem; + height: 40px; + width: 50px; + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + /* Allow iconStyle width/height to override */ + flex-shrink: 0; +} + +.deviceIconSvg svg { + width: var(--svg-width, 100%); + height: var(--svg-height, 100%); + fill: var(--svg-fill, currentColor); + transform: var(--svg-transform, none); +} + +.deviceIconImage { + margin-bottom: 0.25rem; + height: 40px; + width: 50px; + display: flex; + align-items: center; + justify-content: center; +} + +.deviceIconImage img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.deviceName { + font-weight: var(--ifm-font-weight-semibold); + color: var(--ifm-font-color-base); + margin-bottom: 0.15rem; + font-size: 0.9rem; +} + +.deviceDesc { + font-size: 0.75rem; + color: var(--ifm-font-color-secondary); + line-height: 1.3; +} + +/* --- Checkbox grid --- */ + +.checkboxGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; +} + +@media (max-width: 576px) { + .checkboxGrid { + grid-template-columns: 1fr; + } +} + +.hardwareItem { + margin-bottom: 0; +} + +.hardwareDescription { + margin: 0.15rem 0 0.4rem 1.6rem; + font-size: 0.8rem; + color: var(--ifm-font-color-secondary); + line-height: 1.5; +} + +.hardwareDescription a { + color: var(--ifm-color-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.4rem 0.5rem; + border-radius: 6px; + transition: background-color 0.2s; + font-size: 0.9rem; +} + +.checkboxLabel:hover { + background: var(--ifm-color-emphasis-100); +} + +.checkboxLabel input[type="checkbox"] { + width: 1.1rem; + height: 1.1rem; + cursor: pointer; + flex-shrink: 0; +} + +.checkboxLabel span { + color: var(--ifm-font-color-base); +} + +.checkboxDisabled { + cursor: not-allowed; +} + +.checkboxDisabled:hover { + background: transparent; +} + +.checkboxDisabled input[type="checkbox"] { + cursor: not-allowed; + opacity: 0.5; +} + +/* --- Form grid (side-by-side) --- */ + +.formGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +@media (max-width: 576px) { + .formGrid { + grid-template-columns: 1fr; + } +} + +.formGrid .formGroup { + margin-bottom: 0; +} + +/* --- Port section --- */ + +.portSection { + margin-bottom: 0.75rem; +} + +.warningBadge { + margin-left: auto; + color: #e67e22; + font-size: 0.85rem; +} + +/* --- NVIDIA config --- */ + +.nvidiaConfig { + margin-top: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--ifm-background-color); + border-radius: 8px; + border-left: 3px solid var(--ifm-color-primary); +} + +[data-theme="light"] .nvidiaConfig { + background: #f6f8fa; + border-left: 3px solid var(--ifm-color-primary); +} + +/* --- Result section --- */ + +.resultSection { + margin-top: 2rem; +} + +.resultHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.resultHeader h4 { + margin: 0; + color: var(--ifm-font-color-base); +} diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 60621ff4e9..9c4e44051a 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -5997,7 +5997,10 @@ paths: tags: - App summary: Start debug replay - description: Start a debug replay session from camera recordings. + description: + Start a debug replay session from camera recordings. Returns + immediately while clip generation runs as a background job; subscribe + to the 'debug_replay' job_state WS topic to track progress. operationId: start_debug_replay_debug_replay_start_post requestBody: required: true @@ -6006,12 +6009,16 @@ paths: schema: $ref: "#/components/schemas/DebugReplayStartBody" responses: - "200": + "202": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/DebugReplayStartResponse" + "400": + description: Invalid camera, time range, or no recordings + "409": + description: A replay session is already active "422": description: Validation Error content: @@ -6272,10 +6279,14 @@ components: replay_camera: type: string title: Replay Camera + job_id: + type: string + title: Job Id type: object required: - success - replay_camera + - job_id title: DebugReplayStartResponse description: Response for starting a debug replay session. DebugReplayStatusResponse: diff --git a/frigate/api/app.py b/frigate/api/app.py index 57d1f0a799..9ff24ed7e8 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -96,11 +96,46 @@ def version(): @router.get("/stats", dependencies=[Depends(allow_any_authenticated())]) -def stats(request: Request): - return JSONResponse(content=request.app.stats_emitter.get_latest_stats()) +def stats( + request: Request, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): + stats_data = request.app.stats_emitter.get_latest_stats() + + # Admins see the full snapshot + if request.headers.get("remote-role") == "admin": + return JSONResponse(content=stats_data) + + allowed_set = set(allowed_cameras) + + # Shallow-copy so we don't mutate the cached stats history entry. + filtered = {**stats_data} + + cameras = stats_data.get("cameras") + if cameras is not None: + filtered["cameras"] = { + name: data for name, data in cameras.items() if name in allowed_set + } + + bandwidth = stats_data.get("bandwidth_usages") + if bandwidth is not None: + filtered["bandwidth_usages"] = { + name: data for name, data in bandwidth.items() if name in allowed_set + } + + # cmdline can leak camera URLs/paths; strip but keep cpu/mem so + # client-side problem heuristics still work. + cpu_usages = stats_data.get("cpu_usages") + if cpu_usages is not None: + filtered["cpu_usages"] = { + pid: {k: v for k, v in usage.items() if k != "cmdline"} + for pid, usage in cpu_usages.items() + } + + return JSONResponse(content=filtered) -@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())]) +@router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))]) def stats_history(request: Request, keys: str = None): if keys: keys = keys.split(",") @@ -146,8 +181,13 @@ def config(request: Request): for name, detector in config_obj.detectors.items() } - # remove the mqtt password + # remove environment_vars for non-admin users + if request.headers.get("remote-role") != "admin": + config.pop("environment_vars", None) + + # remove mqtt credentials config["mqtt"].pop("password", None) + config["mqtt"].pop("user", None) # remove the proxy secret config["proxy"].pop("auth_secret", None) @@ -494,6 +534,40 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) +def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None: + """Substitute incoming `*:*` masked credentials with the in-memory ones. + + The /config response masks ffmpeg input credentials, so the settings UI + sends the masked path back when sibling fields (e.g. hwaccel_args) are + edited. Without this we'd write `rtsp://*:*@host` into YAML and lose + the real credentials. Mutates `config_data` in place. + """ + cameras = config_data.get("cameras") + if not isinstance(cameras, dict): + return + + for camera_name, camera_data in cameras.items(): + if not isinstance(camera_data, dict): + continue + inputs = camera_data.get("ffmpeg", {}).get("inputs") + if not isinstance(inputs, list): + continue + existing = config.cameras.get(camera_name) + if existing is None: + continue + existing_paths = [inp.path for inp in existing.ffmpeg.inputs] + for index, input_obj in enumerate(inputs): + if not isinstance(input_obj, dict): + continue + path = input_obj.get("path") + if not isinstance(path, str): + continue + if ("://*:*@" in path or "user=*&password=*" in path) and index < len( + existing_paths + ): + input_obj["path"] = existing_paths[index] + + def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse: """Apply config changes in-memory only, without writing to YAML. @@ -504,6 +578,7 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo try: updates = {} if body.config_data: + _restore_masked_camera_paths(body.config_data, request.app.frigate_config) updates = flatten_config_data(body.config_data) updates = {k: ("" if v is None else v) for k, v in updates.items()} @@ -610,6 +685,9 @@ def config_set(request: Request, body: AppConfigSetBody): if query_string: updates = process_config_query_string(query_string) elif body.config_data: + _restore_masked_camera_paths( + body.config_data, request.app.frigate_config + ) updates = flatten_config_data(body.config_data) # Convert None values to empty strings for deletion (e.g., when deleting masks) updates = {k: ("" if v is None else v) for k, v in updates.items()} @@ -792,7 +870,7 @@ def nvinfo(): @router.get( "/logs/{service}", tags=[Tags.logs], - dependencies=[Depends(allow_any_authenticated())], + dependencies=[Depends(require_role(["admin"]))], ) async def logs( service: str = Path(enum=["frigate", "nginx", "go2rtc"]), @@ -997,12 +1075,27 @@ def get_media_sync_status(job_id: str): @router.get("/labels", dependencies=[Depends(allow_any_authenticated())]) -def get_labels(camera: str = ""): +def get_labels( + camera: str = "", + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): try: if camera: + if camera not in allowed_cameras: + return JSONResponse( + content={ + "success": False, + "message": f"Access denied to camera '{camera}'", + }, + status_code=403, + ) events = Event.select(Event.label).where(Event.camera == camera).distinct() else: - events = Event.select(Event.label).distinct() + events = ( + Event.select(Event.label) + .where(Event.camera << allowed_cameras) + .distinct() + ) except Exception as e: logger.error(e) return JSONResponse( @@ -1015,9 +1108,16 @@ def get_labels(camera: str = ""): @router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())]) -def get_sub_labels(split_joined: Optional[int] = None): +def get_sub_labels( + split_joined: Optional[int] = None, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): try: - events = Event.select(Event.sub_label).distinct() + events = ( + Event.select(Event.sub_label) + .where(Event.camera << allowed_cameras) + .distinct() + ) except Exception: return JSONResponse( content=({"success": False, "message": "Failed to get sub_labels"}), diff --git a/frigate/api/auth.py b/frigate/api/auth.py index d1c9688186..eca51df1a4 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -26,6 +26,7 @@ from frigate.api.defs.request.app_body import ( AppPutRoleBody, ) from frigate.api.defs.tags import Tags +from frigate.api.media_auth import check_camera_access, deny_response_for_media_uri from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM from frigate.models import User @@ -633,6 +634,9 @@ def auth(request: Request): logger.debug("X-Proxy-Secret header does not match configured secret value") return fail_response + original_url = request.headers.get("x-original-url") + frigate_config = request.app.frigate_config + # if auth is disabled, just apply the proxy header map and return success if not auth_config.enabled: # pass the user header value from the upstream proxy if a mapping is specified @@ -649,6 +653,11 @@ def auth(request: Request): role = resolve_role(request.headers, proxy_config, config_roles_set) success_response.headers["remote-role"] = role + + deny_status = deny_response_for_media_uri(original_url, role, frigate_config) + if deny_status is not None: + return Response("", status_code=deny_status) + return success_response # now apply authentication @@ -743,6 +752,11 @@ def auth(request: Request): success_response.headers["remote-user"] = user success_response.headers["remote-role"] = role + + deny_status = deny_response_for_media_uri(original_url, role, frigate_config) + if deny_status is not None: + return Response("", status_code=deny_status) + return success_response except Exception as e: logger.error(f"Error parsing jwt: {e}") @@ -812,6 +826,11 @@ limiter = Limiter(key_func=get_remote_addr) ) @limiter.limit(limit_value=rateLimiter.get_limit) def login(request: Request, body: AppPostLoginBody): + if not request.app.frigate_config.auth.enabled: + return JSONResponse( + content={"message": "Authentication is disabled"}, status_code=404 + ) + JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length @@ -1064,19 +1083,19 @@ async def require_camera_access( raise HTTPException(status_code=current_user.status_code, detail=detail) 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) + frigate_config = request.app.frigate_config - # Admin or full access bypasses - if role == "admin" or not roles_dict.get(role): + if check_camera_access(role, camera_name, frigate_config): return - if camera_name not in allowed_cameras: - raise HTTPException( - status_code=403, - detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}", - ) + all_camera_names = set(frigate_config.cameras.keys()) + allowed_cameras = User.get_allowed_cameras( + role, frigate_config.auth.roles, all_camera_names + ) + raise HTTPException( + status_code=403, + detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}", + ) def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]: diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 7a3b19439e..9eb4bec9e2 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -19,7 +19,9 @@ from zeep.exceptions import Fault, TransportError from zeep.transports import AsyncTransport from frigate.api.auth import ( + _get_stream_owner_cameras, allow_any_authenticated, + get_current_user, require_go2rtc_stream_access, require_role, ) @@ -31,11 +33,12 @@ from frigate.config.camera.updater import ( CameraConfigUpdateTopic, ) from frigate.config.env import substitute_frigate_vars +from frigate.models import User from frigate.util.builtin import clean_camera_user_pass from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file from frigate.util.image import run_ffmpeg_snapshot -from frigate.util.services import ffprobe_stream +from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source logger = logging.getLogger(__name__) @@ -66,7 +69,7 @@ def _is_valid_host(host: str) -> bool: @router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())]) -def go2rtc_streams(): +async def go2rtc_streams(request: Request): r = requests.get("http://127.0.0.1:1984/api/streams") if not r.ok: logger.error("Failed to fetch streams from go2rtc") @@ -75,6 +78,24 @@ def go2rtc_streams(): status_code=500, ) stream_data = r.json() + + # Roles with an explicit camera list see only streams owned by an allowed + # camera. Admin and full-access roles (no list / empty list) see all streams. + current_user = await get_current_user(request) + if not isinstance(current_user, JSONResponse): + role = current_user["role"] + roles_dict = request.app.frigate_config.auth.roles + if role != "admin" and roles_dict.get(role): + all_camera_names = set(request.app.frigate_config.cameras.keys()) + allowed_cameras = set( + User.get_allowed_cameras(role, roles_dict, all_camera_names) + ) + stream_data = { + name: data + for name, data in stream_data.items() + if _get_stream_owner_cameras(request, name) & allowed_cameras + } + for data in stream_data.values(): for producer in data.get("producers") or []: producer["url"] = clean_camera_user_pass(producer.get("url", "")) @@ -126,9 +147,24 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): params = {"name": stream_name} if src: try: - params["src"] = substitute_frigate_vars(src) + resolved_src = substitute_frigate_vars(src) except KeyError: - params["src"] = src + resolved_src = src + + if is_restricted_go2rtc_source(resolved_src): + logger.warning( + "Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)", + stream_name, + ) + return JSONResponse( + content={ + "success": False, + "message": "Restricted stream source type", + }, + status_code=400, + ) + + params["src"] = resolved_src r = requests.put( "http://127.0.0.1:1984/api/streams", @@ -966,7 +1002,6 @@ async def onvif_probe( probe = ffprobe_stream( request.app.frigate_config.ffmpeg, test_uri, detailed=False ) - print(probe) ok = probe is not None and getattr(probe, "returncode", 1) == 0 tested_candidates.append( { diff --git a/frigate/api/chat.py b/frigate/api/chat.py index 0543d5f8a6..8b2af8b265 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -10,7 +10,7 @@ from functools import reduce from typing import Any, Dict, List, Optional import cv2 -from fastapi import APIRouter, Body, Depends, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel @@ -36,6 +36,8 @@ from frigate.api.defs.response.chat_response import ( ) from frigate.api.defs.tags import Tags from frigate.api.event import events +from frigate.config import FrigateConfig +from frigate.config.ui import UnitSystemEnum from frigate.genai.utils import build_assistant_message_for_conversation from frigate.jobs.vlm_watch import ( get_vlm_watch_job, @@ -66,62 +68,123 @@ class VLMMonitorRequest(BaseModel): zones: List[str] = [] -def get_tool_definitions() -> List[Dict[str, Any]]: +def get_tool_definitions( + semantic_search_enabled: bool = False, +) -> List[Dict[str, Any]]: """ Get OpenAI-compatible tool definitions for Frigate. Returns a list of tool definitions that can be used with OpenAI-compatible - function calling APIs. + function calling APIs. When semantic search is enabled, the search_objects + tool exposes an additional `semantic_query` parameter for descriptive + queries (e.g. "person riding a lawn mower") and find_similar_objects is + included. """ + search_objects_properties: Dict[str, Any] = { + "camera": { + "type": "string", + "description": "Camera name to filter by (optional).", + }, + "label": { + "type": "string", + "description": ( + "Generic object class to filter by — one of the tracked detector " + "labels such as 'person', 'package', 'car', 'dog', 'bird'. Use " + "this for broad queries like 'show me all cars today'. Combine " + "with semantic_query when the user also describes appearance or " + "behavior (e.g. label='person', semantic_query='riding a lawn " + "mower')." + ), + }, + "sub_label": { + "type": "string", + "description": ( + "Filter by a DISCRETE NAMED entity recognized in the detection. " + "Use this for: a known person's name ('John'), a delivery " + "company ('Amazon', 'UPS'), a recognized animal species or " + "breed ('blue jay', 'cardinal', 'golden retriever'), or a " + "license plate string. When filtering by a specific name, set " + "only sub_label and leave label unset. Do NOT use sub_label " + "for descriptions of appearance, clothing, or actions — those " + "belong in semantic_query." + ), + }, + "after": { + "type": "string", + "description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').", + }, + "before": { + "type": "string", + "description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').", + }, + "zones": { + "type": "array", + "items": {"type": "string"}, + "description": "List of zone names to filter by.", + }, + "limit": { + "type": "integer", + "description": "Maximum number of objects to return (default: 25).", + "default": 25, + }, + } + + if semantic_search_enabled: + search_objects_properties["semantic_query"] = { + "type": "string", + "description": ( + "Optional natural-language description of a PHYSICAL " + "CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, " + "used to semantically narrow results. Only set this when the " + "user describes something beyond what label and sub_label can " + "express on their own.\n" + "USE for descriptive phrases like: 'riding a lawn mower', " + "'wearing a red jacket', 'carrying a package', 'walking a " + "dog', 'on a bicycle', 'holding an umbrella'.\n" + "DO NOT USE for:\n" + "- specific named people, pets, or delivery companies → use sub_label\n" + "- animal species or breed names like 'blue jay', 'cardinal', " + "'golden retriever' → use sub_label\n" + "- license plate strings → use sub_label\n" + "- generic object queries like 'all cars today' or 'every " + "person' → use label alone with no semantic_query\n" + "When set, combine with label/time/camera/zone filters as " + "usual (e.g. label='person', semantic_query='riding a lawn " + "mower', after='2024-05-01T00:00:00Z')." + ), + } + + search_objects_description = ( + "Search the historical record of detected objects in Frigate. " + "Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', " + "'when was the last car?', 'show me detections from yesterday'. " + "Do NOT use this for monitoring or alerting requests about future events — " + "use start_camera_watch instead for those. " + "An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n" + "Choose filters based on what the user is asking for:\n" + "- Generic class query ('show me all cars today'): set `label` only.\n" + "- Specific NAMED entity (known person, delivery company, animal " + "species/breed like 'blue jay' or 'golden retriever', license " + "plate): set `sub_label` only and leave `label` unset.\n" + ) + if semantic_search_enabled: + search_objects_description += ( + "- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a " + "discrete name ('person riding a lawn mower', 'someone in a red " + "jacket', 'person carrying a package'): set `semantic_query` with " + "the descriptive phrase, optionally alongside `label` for the " + "object class. Do NOT put descriptive phrases in sub_label." + ) + return [ { "type": "function", "function": { "name": "search_objects", - "description": ( - "Search the historical record of detected objects in Frigate. " - "Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', " - "'when was the last car?', 'show me detections from yesterday'. " - "Do NOT use this for monitoring or alerting requests about future events — " - "use start_camera_watch instead for those. " - "An 'object' in Frigate represents a tracked detection (e.g., a person, package, car). " - "When the user asks about a specific name (person, delivery company, animal, etc.), " - "filter by sub_label only and do not set label." - ), + "description": search_objects_description, "parameters": { "type": "object", - "properties": { - "camera": { - "type": "string", - "description": "Camera name to filter by (optional).", - }, - "label": { - "type": "string", - "description": "Object label to filter by (e.g., 'person', 'package', 'car').", - }, - "sub_label": { - "type": "string", - "description": "Name of a person, delivery company, animal, etc. When filtering by a specific name, use only sub_label; do not set label.", - }, - "after": { - "type": "string", - "description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').", - }, - "before": { - "type": "string", - "description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').", - }, - "zones": { - "type": "array", - "items": {"type": "string"}, - "description": "List of zone names to filter by.", - }, - "limit": { - "type": "integer", - "description": "Maximum number of objects to return (default: 25).", - "default": 25, - }, - }, + "properties": search_objects_properties, }, "required": [], }, @@ -395,22 +458,67 @@ def get_tool_definitions() -> List[Dict[str, Any]]: summary="Get available tools", description="Returns OpenAI-compatible tool definitions for function calling.", ) -def get_tools() -> JSONResponse: +def get_tools(request: Request) -> JSONResponse: """Get list of available tools for LLM function calling.""" - tools = get_tool_definitions() + semantic_search_enabled = bool( + getattr(request.app.frigate_config.semantic_search, "enabled", False) + ) + tools = get_tool_definitions(semantic_search_enabled=semantic_search_enabled) return JSONResponse(content={"tools": tools}) +def _resolve_zones( + zones: List[str], + config: FrigateConfig, + target_cameras: List[str], +) -> List[str]: + """Map zone names to their canonical config keys, case-insensitively. + + LLMs frequently echo a user's casing ("Front Yard") instead of the + configured key ("front_yard"). The downstream zone filter is a SQLite GLOB + over the JSON-encoded zones column, which is case-sensitive — so an + unnormalized name silently returns zero matches. Build a lookup over the + relevant cameras' configured zones and substitute when we find a match; + unknown names pass through so behavior matches what the model asked for. + """ + if not zones: + return zones + + lookup: Dict[str, str] = {} + for camera_id in target_cameras: + camera_config = config.cameras.get(camera_id) + if camera_config is None: + continue + for zone_name in camera_config.zones.keys(): + lookup.setdefault(zone_name.lower(), zone_name) + + return [lookup.get(z.lower(), z) for z in zones] + + async def _execute_search_objects( + request: Request, arguments: Dict[str, Any], allowed_cameras: List[str], ) -> JSONResponse: """ Execute the search_objects tool. - This searches for detected objects (events) in Frigate using the same - logic as the events API endpoint. + Routes to the semantic path when the LLM supplied a `semantic_query` + and semantic search is enabled; otherwise delegates to the standard + events API logic. """ + config = request.app.frigate_config + semantic_query = arguments.get("semantic_query") + if isinstance(semantic_query, str): + semantic_query = semantic_query.strip() or None + else: + semantic_query = None + + if semantic_query and getattr(config.semantic_search, "enabled", False): + return await _execute_search_objects_semantic( + request, arguments, allowed_cameras, semantic_query + ) + # Parse after/before as server local time; convert to Unix timestamp after = arguments.get("after") before = arguments.get("before") @@ -437,6 +545,11 @@ async def _execute_search_objects( # Convert zones array to comma-separated string if provided zones = arguments.get("zones") if isinstance(zones, list): + camera_arg = arguments.get("camera") + target_cameras = ( + [camera_arg] if camera_arg and camera_arg != "all" else allowed_cameras + ) + zones = _resolve_zones(zones, config, target_cameras) zones = ",".join(zones) elif zones is None: zones = "all" @@ -472,6 +585,119 @@ async def _execute_search_objects( ) +async def _execute_search_objects_semantic( + request: Request, + arguments: Dict[str, Any], + allowed_cameras: List[str], + semantic_query: str, +) -> JSONResponse: + """Search objects via fused thumbnail + description embeddings. + + Runs both visual and description vec searches against `semantic_query`, + intersects the candidates with the structured filters (camera, label, + sub_label, zones, time window) the LLM supplied, and ranks the survivors + by fused similarity. Mirrors the candidate-then-filter pattern used by + find_similar_objects since sqlite-vec's IN filter is unreliable. + """ + from peewee import fn + + config = request.app.frigate_config + context = request.app.embeddings + if context is None: + logger.warning( + "semantic_query supplied but embeddings context is unavailable; " + "returning empty results." + ) + return JSONResponse(content=[]) + + after = parse_iso_to_timestamp(arguments.get("after")) + before = parse_iso_to_timestamp(arguments.get("before")) + + camera_arg = arguments.get("camera") + if camera_arg and camera_arg != "all": + if camera_arg not in allowed_cameras: + return JSONResponse(content=[]) + cameras = [camera_arg] + else: + cameras = list(allowed_cameras) if allowed_cameras else [] + + if not cameras: + return JSONResponse(content=[]) + + label = arguments.get("label") + sub_label = arguments.get("sub_label") + + zones = arguments.get("zones") + if isinstance(zones, list) and zones: + zones = _resolve_zones(zones, config, cameras) + else: + zones = None + + limit = int(arguments.get("limit", 25)) + limit = max(1, min(limit, 100)) + + visual_distances: Dict[str, float] = {} + description_distances: Dict[str, float] = {} + try: + rows = context.search_thumbnail(semantic_query) + visual_distances = {row[0]: row[1] for row in rows} + except Exception: + logger.exception( + "search_thumbnail failed for semantic_query: %s", semantic_query + ) + + try: + rows = context.search_description(semantic_query) + description_distances = {row[0]: row[1] for row in rows} + except Exception: + logger.exception( + "search_description failed for semantic_query: %s", semantic_query + ) + + vec_ids = set(visual_distances) | set(description_distances) + if not vec_ids: + return JSONResponse(content=[]) + + clauses = [Event.id.in_(list(vec_ids)), Event.camera.in_(cameras)] + if after is not None: + clauses.append(Event.start_time >= after) + if before is not None: + clauses.append(Event.start_time <= before) + if label: + clauses.append(Event.label == label) + if sub_label: + # case-insensitive match to mirror events() behavior + clauses.append(fn.LOWER(Event.sub_label.cast("text")) == sub_label.lower()) + if zones: + zone_clauses = [Event.zones.cast("text") % f'*"{zone}"*' for zone in zones] + clauses.append(reduce(operator.or_, zone_clauses)) + + eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))} + + scored: List[tuple[str, float]] = [] + for eid in eligible: + v_score = ( + distance_to_score(visual_distances[eid], context.thumb_stats) + if eid in visual_distances + else None + ) + d_score = ( + distance_to_score(description_distances[eid], context.desc_stats) + if eid in description_distances + else None + ) + fused = fuse_scores(v_score, d_score) + if fused is None: + continue + scored.append((eid, fused)) + + scored.sort(key=lambda pair: pair[1], reverse=True) + scored = scored[:limit] + + results = [hydrate_event(eligible[eid], score=score) for eid, score in scored] + return JSONResponse(content=results) + + async def _execute_find_similar_objects( request: Request, arguments: Dict[str, Any], @@ -528,6 +754,11 @@ async def _execute_find_similar_objects( sub_labels = arguments.get("sub_labels") zones = arguments.get("zones") + if zones: + zones = _resolve_zones( + zones, request.app.frigate_config, cameras or list(allowed_cameras) + ) + similarity_mode = arguments.get("similarity_mode", "fused") if similarity_mode not in ("visual", "semantic", "fused"): similarity_mode = "fused" @@ -655,7 +886,7 @@ async def execute_tool( logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}") if tool_name == "search_objects": - return await _execute_search_objects(arguments, allowed_cameras) + return await _execute_search_objects(request, arguments, allowed_cameras) if tool_name == "find_similar_objects": result = await _execute_find_similar_objects( @@ -835,7 +1066,7 @@ async def _execute_tool_internal( This is used by the chat completion endpoint to execute tools. """ if tool_name == "search_objects": - response = await _execute_search_objects(arguments, allowed_cameras) + response = await _execute_search_objects(request, arguments, allowed_cameras) try: if hasattr(response, "body"): body_str = response.body.decode("utf-8") @@ -899,6 +1130,9 @@ async def _execute_start_camera_watch( await require_camera_access(camera, request=request) + if zones: + zones = _resolve_zones(zones, config, [camera]) + genai_manager = request.app.genai_manager chat_client = genai_manager.chat_client if chat_client is None or not chat_client.supports_vision: @@ -1245,7 +1479,9 @@ async def chat_completion( status_code=400, ) - tools = get_tool_definitions() + config = request.app.frigate_config + semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False)) + tools = get_tool_definitions(semantic_search_enabled=semantic_search_enabled) conversation = [] current_datetime = datetime.now() @@ -1253,7 +1489,7 @@ async def chat_completion( current_time_str = current_datetime.strftime("%I:%M:%S %p") cameras_info = [] - config = request.app.frigate_config + has_speed_zone = False for camera_id in allowed_cameras: if camera_id not in config.cameras: continue @@ -1264,6 +1500,10 @@ async def chat_completion( else camera_id.replace("_", " ").title() ) zone_names = list(camera_config.zones.keys()) + if not has_speed_zone: + has_speed_zone = any( + zone.distances for zone in camera_config.zones.values() + ) if zone_names: cameras_info.append( f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})" @@ -1279,6 +1519,22 @@ async def chat_completion( + "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls." ) + speed_units_section = "" + if has_speed_zone: + speed_unit = ( + "mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h" + ) + speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}." + + semantic_search_section = "" + if semantic_search_enabled: + semantic_search_section = ( + "\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n" + "- Generic class ('show me all cars today'): set `label` only.\n" + "- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n" + "- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`." + ) + system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events. Current server local date and time: {current_date_str} at {current_time_str} @@ -1290,7 +1546,7 @@ When users ask about "today", "yesterday", "this week", etc., use the current da When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today). Always be accurate with time calculations based on the current date provided. -When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{cameras_section}""" +When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{cameras_section}{speed_units_section}""" conversation.append( { @@ -1351,6 +1607,11 @@ When a user refers to a specific object they have seen or describe with identify ) + b"\n" ) + elif kind == "stats": + yield ( + json.dumps({"type": "stats", **value}).encode("utf-8") + + b"\n" + ) elif kind == "message": msg = value if msg.get("finish_reason") == "error": @@ -1581,6 +1842,7 @@ async def start_vlm_monitor( dispatcher=request.app.dispatcher, labels=body.labels, zones=body.zones, + username=request.headers.get("remote-user", ""), ) except RuntimeError as e: logger.error("Failed to start VLM watch job: %s", e, exc_info=True) @@ -1601,10 +1863,22 @@ async def start_vlm_monitor( summary="Get current VLM watch job", description="Returns the current (or most recently completed) VLM watch job.", ) -async def get_vlm_monitor() -> JSONResponse: +async def get_vlm_monitor(request: Request) -> JSONResponse: job = get_vlm_watch_job() if job is None: return JSONResponse(content={"active": False}, status_code=200) + + role = request.headers.get("remote-role", "viewer") + username = request.headers.get("remote-user", "") + + # Admin and the job's creator always see the job. Other users only see it + # if they have access to the camera being watched; otherwise hide it. + if role != "admin" and username != job.username: + try: + await require_camera_access(job.camera, request=request) + except HTTPException: + return JSONResponse(content={"active": False}, status_code=200) + return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200) @@ -1614,7 +1888,27 @@ async def get_vlm_monitor() -> JSONResponse: summary="Cancel the current VLM watch job", description="Cancels the running watch job if one exists.", ) -async def cancel_vlm_monitor() -> JSONResponse: +async def cancel_vlm_monitor(request: Request) -> JSONResponse: + job = get_vlm_watch_job() + if job is None: + return JSONResponse( + content={"success": False, "message": "No active watch job to cancel."}, + status_code=404, + ) + + role = request.headers.get("remote-role", "viewer") + username = request.headers.get("remote-user", "") + + # Admin can cancel any job; other users can only cancel jobs they started. + if role != "admin" and username != job.username: + return JSONResponse( + content={ + "success": False, + "message": "Not authorized to cancel this watch job.", + }, + status_code=403, + ) + cancelled = stop_vlm_watch_job() if not cancelled: return JSONResponse( diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py index 027d4e50c7..171bf1b98a 100644 --- a/frigate/api/debug_replay.py +++ b/frigate/api/debug_replay.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from frigate.api.auth import require_role from frigate.api.defs.tags import Tags +from frigate.jobs.debug_replay import start_debug_replay_job logger = logging.getLogger(__name__) @@ -29,10 +30,17 @@ class DebugReplayStartResponse(BaseModel): success: bool replay_camera: str + job_id: str class DebugReplayStatusResponse(BaseModel): - """Response for debug replay status.""" + """Response for debug replay status. + + Returns only session-presence fields. Startup progress and error + details flow through the job_state WebSocket topic via the + debug_replay job (see frigate.jobs.debug_replay); the + Replay page subscribes there with useJobStatus("debug_replay"). + """ active: bool replay_camera: str | None = None @@ -51,15 +59,32 @@ class DebugReplayStopResponse(BaseModel): @router.post( "/debug_replay/start", response_model=DebugReplayStartResponse, + status_code=202, + responses={ + 400: {"description": "Invalid camera, time range, or no recordings"}, + 409: {"description": "A replay session is already active"}, + }, dependencies=[Depends(require_role(["admin"]))], summary="Start debug replay", - description="Start a debug replay session from camera recordings.", + description="Start a debug replay session from camera recordings. Returns " + "immediately while clip generation runs as a background job; subscribe " + "to the 'debug_replay' job_state WS topic to track progress.", ) async def start_debug_replay(request: Request, body: DebugReplayStartBody): - """Start a debug replay session.""" + """Start a debug replay session asynchronously.""" replay_manager = request.app.replay_manager - if replay_manager.active: + try: + job_id = await asyncio.to_thread( + start_debug_replay_job, + source_camera=body.camera, + start_ts=body.start_time, + end_ts=body.end_time, + frigate_config=request.app.frigate_config, + config_publisher=request.app.config_publisher, + replay_manager=replay_manager, + ) + except RuntimeError: return JSONResponse( content={ "success": False, @@ -67,38 +92,23 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody): }, status_code=409, ) - - try: - replay_camera = await asyncio.to_thread( - replay_manager.start, - source_camera=body.camera, - start_ts=body.start_time, - end_ts=body.end_time, - frigate_config=request.app.frigate_config, - config_publisher=request.app.config_publisher, - ) except ValueError: - logger.exception("Invalid parameters for debug replay start request") + logger.exception("Rejected debug replay start request") return JSONResponse( content={ "success": False, - "message": "Invalid debug replay request parameters", + "message": "Invalid debug replay parameters", }, status_code=400, ) - except RuntimeError: - logger.exception("Error while starting debug replay session") - return JSONResponse( - content={ - "success": False, - "message": "An internal error occurred while starting debug replay", - }, - status_code=500, - ) - return DebugReplayStartResponse( - success=True, - replay_camera=replay_camera, + return JSONResponse( + content={ + "success": True, + "replay_camera": replay_manager.replay_camera_name, + "job_id": job_id, + }, + status_code=202, ) @@ -118,12 +128,16 @@ def get_debug_replay_status(request: Request): if replay_manager.active and replay_camera: frame_processor = request.app.detected_frames_processor - frame = frame_processor.get_current_frame(replay_camera) + frame = ( + frame_processor.get_current_frame(replay_camera) + if frame_processor is not None + else None + ) if frame is not None: frame_time = frame_processor.get_current_frame_time(replay_camera) camera_config = request.app.frigate_config.cameras.get(replay_camera) - retry_interval = 10 + retry_interval = 10.0 if camera_config is not None: retry_interval = float(camera_config.ffmpeg.retry_interval or 10) diff --git a/frigate/api/event.py b/frigate/api/event.py index a7d1cffc87..fc7c58c375 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -754,6 +754,15 @@ def events_search( status_code=404, ) + if search_event.camera not in allowed_cameras: + return JSONResponse( + content={ + "success": False, + "message": "Event not found", + }, + status_code=404, + ) + thumb_result = context.search_thumbnail(search_event) thumb_ids = {result[0]: result[1] for result in thumb_result} search_results = { diff --git a/frigate/api/export.py b/frigate/api/export.py index 714420903b..2f4ca78da0 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -5,13 +5,15 @@ import logging import random import string import time +import zipfile +from collections import deque from pathlib import Path -from typing import List, Optional +from typing import Iterator, List, Optional import psutil from fastapi import APIRouter, Depends, Query, Request -from fastapi.responses import JSONResponse -from pathvalidate import sanitize_filepath +from fastapi.responses import JSONResponse, StreamingResponse +from pathvalidate import sanitize_filename, sanitize_filepath from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict @@ -361,6 +363,136 @@ def get_export_case(case_id: str): ) +_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB + + +class _StreamingZipBuffer: + """File-like sink for ZipFile that exposes written bytes via drain(). + + ZipFile writes synchronously into this buffer; the generator drains the + queue between writes so StreamingResponse can yield bytes without + materializing the whole archive in memory. + """ + + def __init__(self) -> None: + self._queue: deque[bytes] = deque() + self._offset = 0 + + def write(self, data: bytes) -> int: + if data: + self._queue.append(bytes(data)) + self._offset += len(data) + return len(data) + + def tell(self) -> int: + return self._offset + + def flush(self) -> None: + pass + + def drain(self) -> Iterator[bytes]: + while self._queue: + yield self._queue.popleft() + + +def _unique_archive_name(export: Export, used: set[str]) -> str: + base = sanitize_filename(export.name) if export.name else None + if not base: + base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}" + + candidate = f"{base}.mp4" + counter = 1 + while candidate in used: + candidate = f"{base}_{counter}.mp4" + counter += 1 + + used.add(candidate) + return candidate + + +def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]: + """Yield bytes of a zip archive built from the given exports' mp4 files.""" + buffer = _StreamingZipBuffer() + used_names: set[str] = set() + + # ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win. + with zipfile.ZipFile( + buffer, + mode="w", + compression=zipfile.ZIP_STORED, + allowZip64=True, + ) as archive: + for export in exports: + source = Path(export.video_path) + if not source.exists(): + continue + + arcname = _unique_archive_name(export, used_names) + + with ( + archive.open(arcname, mode="w", force_zip64=True) as entry, + source.open("rb") as src, + ): + while True: + chunk = src.read(_ZIP_STREAM_CHUNK_SIZE) + if not chunk: + break + + entry.write(chunk) + yield from buffer.drain() + + yield from buffer.drain() + + yield from buffer.drain() + + +@router.get( + "/cases/{case_id}/download", + dependencies=[Depends(allow_any_authenticated())], + summary="Download export case as zip", + description="Streams a zip archive containing every completed export's mp4 for the given case.", +) +def download_export_case( + case_id: str, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): + try: + case = ExportCase.get(ExportCase.id == case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + exports = list( + Export.select() + .where( + Export.export_case == case_id, + ~Export.in_progress, + Export.camera << allowed_cameras, + ) + .order_by(Export.date.asc()) + ) + + if not exports: + return JSONResponse( + content={"success": False, "message": "No exports available to download."}, + status_code=404, + ) + + archive_base = sanitize_filename(case.name) if case.name else "" + if not archive_base: + archive_base = case_id + + return StreamingResponse( + _stream_case_archive(exports), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{archive_base}.zip"', + }, + ) + + @router.patch( "/cases/{case_id}", response_model=GenericResponse, diff --git a/frigate/api/media.py b/frigate/api/media.py index 489c008b41..c8285eda16 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -174,12 +174,10 @@ async def latest_frame( } quality_params = get_image_quality_params(extension.value, params.quality) - if camera_name in request.app.frigate_config.cameras: + camera_config = request.app.frigate_config.cameras.get(camera_name) + if camera_config is not None: frame = frame_processor.get_current_frame(camera_name, draw_options) - retry_interval = float( - request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval - or 10 - ) + retry_interval = float(camera_config.ffmpeg.retry_interval or 10) is_offline = False if frame is None or datetime.now().timestamp() > ( @@ -1368,12 +1366,17 @@ def preview_gif( file_start = f"preview_{camera_name}-" start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + selected_previews = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: continue @@ -1550,12 +1553,17 @@ def preview_mp4( file_start = f"preview_{camera_name}-" start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + selected_previews = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: continue diff --git a/frigate/api/media_auth.py b/frigate/api/media_auth.py new file mode 100644 index 0000000000..cc06eb75cb --- /dev/null +++ b/frigate/api/media_auth.py @@ -0,0 +1,291 @@ +"""URI-aware authorization for nginx-served static media. + +The `/auth` endpoint (used as nginx `auth_request` target) calls into this +module to classify the requested URI from the `X-Original-URL` header and, for +camera-scoped resources, decide whether the current role may access them. + +Without this, `auth_request` only verifies the JWT — every authenticated user +could read clips, recordings, and exports for *any* camera, bypassing the +per-camera authorization the regular API enforces via `require_camera_access`. +""" + +from __future__ import annotations + +import logging +import os +from enum import Enum +from typing import Optional +from urllib.parse import unquote, urlparse + +from peewee import DoesNotExist + +from frigate.config import FrigateConfig +from frigate.const import EXPORT_DIR +from frigate.models import Export, User + +logger = logging.getLogger(__name__) + + +class MediaAuthResolution(str, Enum): + """Classification of an `X-Original-URL` path for media-auth purposes.""" + + CAMERA = "camera" + ADMIN_ONLY = "admin_only" + LISTING_MULTI_CAMERA = "listing_multi_camera" + LISTING_NEUTRAL = "listing_neutral" + # Under a recognized media root (/clips, /recordings, /exports) but + # unclassifiable (unknown subtree, no matching DB row, DB error). + # Restricted users are denied; admins/full-access roles are allowed + # (nginx will likely return 404 if the file genuinely doesn't exist). + UNRESOLVED_MEDIA = "unresolved_media" + # Not a media URI at all (e.g. /api/events, /login). + UNKNOWN = "unknown" + + +def extract_path(original_url: Optional[str]) -> Optional[str]: + """Return the decoded path component of nginx's `X-Original-URL` header. + + nginx forwards the *raw* request URI (with `..` segments intact) via + `$request_uri`. nginx normalizes the path before serving the file, so a + request like `/recordings/.../allowed_cam/../forbidden_cam/file.mp4` + would (1) parse as the allowed camera in our auth check, (2) be served + as the forbidden camera by nginx. To close the bypass we reject any URI + whose path contains `.` or `..` segments outright. + """ + if not original_url: + return None + + parsed = urlparse(original_url) + raw_path = parsed.path or original_url + decoded = unquote(raw_path) + if not decoded: + return None + + if not decoded.startswith("/"): + decoded = "/" + decoded + + segments = decoded.split("/") + if ".." in segments or "." in segments: + return None + + return decoded + + +def resolve_media_uri( + uri: str, frigate_config: Optional[FrigateConfig] = None +) -> tuple[MediaAuthResolution, Optional[str]]: + """Classify a URI and return the owning camera if applicable. + + `frigate_config` is used to disambiguate clip/review filenames whose + camera name contains hyphens by matching against the longest configured + camera-name prefix. + """ + if not uri: + return MediaAuthResolution.UNKNOWN, None + + parts = [p for p in uri.split("/") if p] + if not parts: + return MediaAuthResolution.UNKNOWN, None + + root = parts[0] + if root == "recordings": + return _resolve_recording(parts) + if root == "clips": + return _resolve_clip(parts, frigate_config) + if root == "exports": + return _resolve_export(parts) + + return MediaAuthResolution.UNKNOWN, None + + +def _resolve_recording( + parts: list[str], +) -> tuple[MediaAuthResolution, Optional[str]]: + # /recordings → neutral + # /recordings/{date} → neutral + # /recordings/{date}/{hour} → multi-camera listing + # /recordings/{date}/{hour}/{cam}/... → camera + if len(parts) <= 2: + return MediaAuthResolution.LISTING_NEUTRAL, None + if len(parts) == 3: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + return MediaAuthResolution.CAMERA, parts[3] + + +def _resolve_clip( + parts: list[str], frigate_config: Optional[FrigateConfig] +) -> tuple[MediaAuthResolution, Optional[str]]: + # /clips → multi-camera listing + # /clips/thumbs/{cam}/... → camera + # /clips/previews/{cam}/... → camera + # /clips/review/thumb-{cam}-{review_id}.webp → camera (parsed) + # /clips/faces/... → admin-only + # /clips/genai-requests/... → admin-only + # /clips/preview_restart_cache/... → admin-only + # /clips/{model}/train|dataset/... → admin-only + # /clips/{cam}-{event_id}[-clean].{ext} → camera (parsed) + # other /clips/{subdir}/... → unresolved (deny restricted) + if len(parts) == 1: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + + second = parts[1] + + if second in ("thumbs", "previews"): + if len(parts) == 2: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + return MediaAuthResolution.CAMERA, parts[2] + + if second == "review": + if len(parts) == 2: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + camera = _camera_from_thumb_filename(parts[2], frigate_config) + if camera: + return MediaAuthResolution.CAMERA, camera + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + if second in ("faces", "genai-requests", "preview_restart_cache"): + return MediaAuthResolution.ADMIN_ONLY, None + + if len(parts) >= 3 and parts[2] in ("train", "dataset"): + return MediaAuthResolution.ADMIN_ONLY, None + + if len(parts) == 2: + camera = _camera_from_clip_filename(second, frigate_config) + if camera: + return MediaAuthResolution.CAMERA, camera + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + +def _longest_prefix_camera( + stem: str, frigate_config: Optional[FrigateConfig] +) -> Optional[str]: + if frigate_config is None: + return None + for cam in sorted(frigate_config.cameras.keys(), key=len, reverse=True): + if stem.startswith(cam + "-"): + return cam + return None + + +def _camera_from_clip_filename( + filename: str, frigate_config: Optional[FrigateConfig] +) -> Optional[str]: + """Match a flat clip filename `{camera}-{event_id}[-clean].{ext}` against + configured camera names. Longest-prefix wins so camera names containing + hyphens (e.g. `front-door`) resolve correctly. + """ + dot = filename.rfind(".") + stem = filename[:dot] if dot > 0 else filename + return _longest_prefix_camera(stem, frigate_config) + + +def _camera_from_thumb_filename( + filename: str, frigate_config: Optional[FrigateConfig] +) -> Optional[str]: + """Match a review thumbnail filename `thumb-{camera}-{review_id}.webp`.""" + if not filename.startswith("thumb-"): + return None + dot = filename.rfind(".") + stem = filename[len("thumb-") : dot] if dot > 0 else filename[len("thumb-") :] + return _longest_prefix_camera(stem, frigate_config) + + +def _resolve_export( + parts: list[str], +) -> tuple[MediaAuthResolution, Optional[str]]: + # /exports → multi-camera listing + # /exports/{filename}.mp4 → camera (DB lookup by exact path) + if len(parts) == 1: + return MediaAuthResolution.LISTING_MULTI_CAMERA, None + if len(parts) != 2: + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + filename = parts[1] + full_path = os.path.join(EXPORT_DIR, filename) + try: + export = Export.get(Export.video_path == full_path) + return MediaAuthResolution.CAMERA, export.camera + except DoesNotExist: + return MediaAuthResolution.UNRESOLVED_MEDIA, None + except Exception as e: + logger.warning("Export DB lookup failed for %s: %s", filename, e) + return MediaAuthResolution.UNRESOLVED_MEDIA, None + + +def check_camera_access(role: str, camera: str, frigate_config: FrigateConfig) -> bool: + """Return True iff `role` may access `camera`. + + Mirrors the gating logic in `require_camera_access`: admin and any role + without a non-empty allow-list bypass the check. + """ + if role == "admin": + return True + + roles_dict = frigate_config.auth.roles + if not roles_dict.get(role): + return True + + all_camera_names = set(frigate_config.cameras.keys()) + allowed = User.get_allowed_cameras(role, roles_dict, all_camera_names) + return camera in allowed + + +def is_role_restricted(role: str, frigate_config: FrigateConfig) -> bool: + """True if `role` has a non-empty allow-list (i.e. not full-access).""" + if role == "admin": + return False + return bool(frigate_config.auth.roles.get(role)) + + +def deny_response_for_media_uri( + original_url: Optional[str], role: Optional[str], frigate_config: FrigateConfig +) -> Optional[int]: + """Decide whether the current role should be blocked from `original_url`. + + Returns an HTTP status code (403) when access should be denied, or `None` + when the request is allowed. + """ + if not original_url: + return None + + path = extract_path(original_url) + + # `extract_path` returns None for URIs containing `.` or `..` segments. + # For media-root URIs that's a traversal attempt — deny outright. For + # non-media URIs, pass through (nginx / the backend handle them). + if path is None: + raw = urlparse(original_url).path or original_url + decoded = unquote(raw) + first = decoded.lstrip("/").split("/", 1)[0] if decoded else "" + if first in ("clips", "recordings", "exports"): + return 403 + return None + + resolution, camera = resolve_media_uri(path, frigate_config) + if resolution == MediaAuthResolution.UNKNOWN: + return None + + if not role or role == "admin": + return None + + if not is_role_restricted(role, frigate_config): + return None + + if resolution == MediaAuthResolution.LISTING_NEUTRAL: + return None + + if resolution in ( + MediaAuthResolution.LISTING_MULTI_CAMERA, + MediaAuthResolution.ADMIN_ONLY, + MediaAuthResolution.UNRESOLVED_MEDIA, + ): + return 403 + + if resolution == MediaAuthResolution.CAMERA: + if camera and check_camera_access(role, camera, frigate_config): + return None + return 403 + + return 403 diff --git a/frigate/api/preview.py b/frigate/api/preview.py index a5e30764de..a307b5abce 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -148,12 +148,17 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo file_start = f"preview_{camera_name}-" start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + selected_previews = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: continue diff --git a/frigate/api/record.py b/frigate/api/record.py index 4ab4b0af16..f6366813b6 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -35,7 +35,7 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.recordings]) -@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())]) +@router.get("/recordings/storage", dependencies=[Depends(require_role(["admin"]))]) def get_recordings_storage_usage(request: Request): recording_stats = request.app.stats_emitter.get_latest_stats()["service"][ "storage" diff --git a/frigate/app.py b/frigate/app.py index 0ead742685..8b5766148b 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -144,7 +144,7 @@ class FrigateApp: for d in dirs: if not os.path.exists(d) and not os.path.islink(d): logger.info(f"Creating directory: {d}") - os.makedirs(d) + os.makedirs(d, exist_ok=True) else: logger.debug(f"Skipping directory: {d}") @@ -189,17 +189,6 @@ class FrigateApp: except PermissionError: logger.error("Unable to write to /config to save DB state") - def cleanup_timeline_db(db: SqliteExtDatabase) -> None: - db.execute_sql( - "DELETE FROM timeline WHERE source_id NOT IN (SELECT id FROM event);" - ) - - try: - with open(f"{CONFIG_DIR}/.timeline", "w") as f: - f.write(str(datetime.datetime.now().timestamp())) - except PermissionError: - logger.error("Unable to write to /config to save DB state") - # Migrate DB schema migrate_db = SqliteExtDatabase(self.config.database.path) @@ -216,11 +205,6 @@ class FrigateApp: router.run() - # this is a temporary check to clean up user DB from beta - # will be removed before final release - if not os.path.exists(f"{CONFIG_DIR}/.timeline"): - cleanup_timeline_db(migrate_db) - # check if vacuum needs to be run if os.path.exists(f"{CONFIG_DIR}/.vacuum"): with open(f"{CONFIG_DIR}/.vacuum") as f: @@ -444,18 +428,11 @@ class FrigateApp: self.camera_maintainer.start() def start_audio_processor(self) -> None: - audio_cameras = [ - c - for c in self.config.cameras.values() - if c.enabled and c.audio.enabled_in_config - ] - - if audio_cameras: - 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 + self.audio_process = AudioProcessor( + self.config, self.camera_metrics, self.stop_event + ) + self.audio_process.start() + self.processes["audio_detector"] = self.audio_process.pid or 0 def start_timeline_processor(self) -> None: self.timeline_processor = TimelineProcessor( diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index e4ed832682..e35b64762c 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -429,7 +429,10 @@ class WebPushClient(Communicator): else: title = base_title - message = payload["after"]["data"]["metadata"]["shortSummary"] + if payload["after"]["data"]["metadata"].get("shortSummary"): + message = payload["after"]["data"]["metadata"]["shortSummary"] + else: + message = f"Detected on {camera_name}" else: zone_names = payload["after"]["data"]["zones"] formatted_zone_names = [] @@ -549,6 +552,14 @@ class WebPushClient(Communicator): logger.debug(f"Sending camera monitoring push notification for {camera_name}") for user in self.web_pushers: + if not self._user_has_camera_access(user, camera): + logger.debug( + "Skipping notification for user %s - no access to camera %s", + user, + camera, + ) + continue + self.send_push_notification( user=user, payload=payload, diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index 6cfe4ecc0b..2f16ab7141 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -17,9 +17,90 @@ from ws4py.websocket import WebSocket as WebSocket_ from frigate.comms.base_communicator import Communicator from frigate.config import FrigateConfig +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_AUDIO_TRANSCRIPTION_STATE, + UPDATE_BIRDSEYE_LAYOUT, + UPDATE_CAMERA_ACTIVITY, + UPDATE_EMBEDDINGS_REINDEX_PROGRESS, + UPDATE_EVENT_DESCRIPTION, + UPDATE_MODEL_STATE, + UPDATE_REVIEW_DESCRIPTION, + UPSERT_REVIEW_SEGMENT, +) logger = logging.getLogger(__name__) +# Internal IPC topics — NEVER allowed from WebSocket, regardless of role +_WS_BLOCKED_TOPICS = frozenset( + { + INSERT_MANY_RECORDINGS, + INSERT_PREVIEW, + REQUEST_REGION_GRID, + UPSERT_REVIEW_SEGMENT, + CLEAR_ONGOING_REVIEW_SEGMENTS, + UPDATE_CAMERA_ACTIVITY, + UPDATE_AUDIO_ACTIVITY, + EXPIRE_AUDIO_ACTIVITY, + UPDATE_EVENT_DESCRIPTION, + UPDATE_REVIEW_DESCRIPTION, + UPDATE_MODEL_STATE, + UPDATE_EMBEDDINGS_REINDEX_PROGRESS, + UPDATE_BIRDSEYE_LAYOUT, + UPDATE_AUDIO_TRANSCRIPTION_STATE, + NOTIFICATION_TEST, + } +) + +# Read-only topics any authenticated user (including viewer) can send +_WS_VIEWER_TOPICS = frozenset( + { + "onConnect", + "modelState", + "audioTranscriptionState", + "birdseyeLayout", + "embeddingsReindexProgress", + } +) + + +def _check_ws_authorization( + topic: str, + role_header: str | None, + separator: str, +) -> bool: + """Check if a WebSocket message is authorized. + + Args: + topic: The message topic. + role_header: The HTTP_REMOTE_ROLE header value, or None. + separator: The role separator character from proxy config. + + Returns: + True if authorized, False if blocked. + """ + # Block IPC-only topics unconditionally + if topic in _WS_BLOCKED_TOPICS: + return False + + # No role header: default to viewer (fail-closed) + if role_header is None: + return topic in _WS_VIEWER_TOPICS + + # Check if any role is admin + roles = [r.strip() for r in role_header.split(separator)] + if "admin" in roles: + return True + + # Non-admin: only viewer topics allowed + return topic in _WS_VIEWER_TOPICS + class WebSocket(WebSocket_): # type: ignore[misc] def unhandled_error(self, error: Any) -> None: @@ -49,6 +130,7 @@ class WebSocketClient(Communicator): class _WebSocketHandler(WebSocket): receiver = self._dispatcher + role_separator = self.config.proxy.separator or "," def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined] try: @@ -63,11 +145,25 @@ class WebSocketClient(Communicator): ) return - logger.debug( - f"Publishing mqtt message from websockets at {json_message['topic']}." + topic = json_message["topic"] + + # Authorization check (skip when environ is None — direct internal connection) + role_header = ( + self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None ) + if self.environ is not None and not _check_ws_authorization( + topic, role_header, self.role_separator + ): + logger.warning( + "Blocked unauthorized WebSocket message: topic=%s, role=%s", + topic, + role_header, + ) + return + + logger.debug(f"Publishing mqtt message from websockets at {topic}.") self.receiver( - json_message["topic"], + topic, json_message["payload"], ) diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 529b8e45cf..fe2bee647f 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -76,7 +76,7 @@ class CameraConfig(FrigateBaseModel): # Options with global fallback audio: AudioConfig = Field( default_factory=AudioConfig, - title="Audio events", + title="Audio detection", description="Settings for audio-based event detection for this camera.", ) audio_transcription: CameraAudioTranscriptionConfig = Field( diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index 721eeb60d8..902c94c42b 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -41,8 +41,7 @@ class GenAIConfig(FrigateBaseModel): title="Model", description="The model to use from the provider for generating descriptions or summaries.", ) - provider: GenAIProviderEnum | None = Field( - default=None, + provider: GenAIProviderEnum = Field( title="Provider", description="The GenAI provider to use (for example: ollama, gemini, openai).", ) diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 1965f38137..95092da08b 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -20,6 +20,7 @@ class CameraConfigUpdateEnum(str, Enum): ffmpeg = "ffmpeg" live = "live" motion = "motion" # includes motion and motion masks + mqtt = "mqtt" notifications = "notifications" objects = "objects" object_genai = "object_genai" @@ -33,6 +34,7 @@ class CameraConfigUpdateEnum(str, Enum): lpr = "lpr" snapshots = "snapshots" timestamp_style = "timestamp_style" + ui = "ui" zones = "zones" @@ -119,7 +121,10 @@ class CameraConfigUpdateSubscriber: elif update_type == CameraConfigUpdateEnum.objects: config.objects = updated_config elif update_type == CameraConfigUpdateEnum.record: + old_enabled_in_config = config.record.enabled_in_config config.record = updated_config + if old_enabled_in_config != updated_config.enabled_in_config: + config.recreate_ffmpeg_cmds() elif update_type == CameraConfigUpdateEnum.review: config.review = updated_config elif update_type == CameraConfigUpdateEnum.review_genai: diff --git a/frigate/config/classification.py b/frigate/config/classification.py index 05d6edc762..708f854e3a 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -26,6 +26,11 @@ class EnrichmentsDeviceEnum(str, Enum): CPU = "CPU" +class ModelSizeEnum(str, Enum): + small = "small" + large = "large" + + class TriggerType(str, Enum): THUMBNAIL = "thumbnail" DESCRIPTION = "description" @@ -53,13 +58,13 @@ class AudioTranscriptionConfig(FrigateBaseModel): title="Transcription language", description="Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", ) - device: Optional[EnrichmentsDeviceEnum] = Field( + device: EnrichmentsDeviceEnum = Field( default=EnrichmentsDeviceEnum.CPU, title="Transcription device", description="Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", ) - model_size: str = Field( - default="small", + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, title="Model size", description="Model size to use for offline audio event transcription.", ) @@ -189,8 +194,8 @@ class SemanticSearchConfig(FrigateBaseModel): return v return v - model_size: str = Field( - default="small", + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, title="Model size", description="Select model size; 'small' runs on CPU and 'large' typically requires GPU.", ) @@ -253,8 +258,8 @@ class FaceRecognitionConfig(FrigateBaseModel): title="Enable face recognition", description="Enable or disable face recognition for all cameras; can be overridden per-camera.", ) - model_size: str = Field( - default="small", + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, title="Model size", description="Model size to use for face embeddings (small/large); larger may require GPU.", ) @@ -335,8 +340,8 @@ class LicensePlateRecognitionConfig(FrigateBaseModel): title="Enable LPR", description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.", ) - model_size: str = Field( - default="small", + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, title="Model size", description="Model size used for text detection/recognition. Most users should use 'small'.", ) diff --git a/frigate/config/config.py b/frigate/config/config.py index de3438cd01..6873e6b880 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import json import logging import os @@ -25,7 +26,6 @@ from frigate.plus import PlusApi from frigate.util.builtin import ( deep_merge, get_ffmpeg_arg_list, - load_labels, ) from frigate.util.config import ( CURRENT_CONFIG_VERSION, @@ -80,17 +80,40 @@ logger = logging.getLogger(__name__) yaml = YAML() +DEFAULT_DETECTORS = { + "ov": { + "type": "openvino", + "device": "CPU", + } +} +DEFAULT_MODEL = { + "width": 300, + "height": 300, + "input_tensor": "nhwc", + "input_pixel_format": "bgr", + "path": "/openvino-model/ssdlite_mobilenet_v2.xml", + "labelmap_path": "/openvino-model/coco_91cl_bkgr.txt", +} +DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720} + + +def _render_default_yaml(data: dict) -> str: + buf = io.StringIO() + _yaml_writer = YAML() + _yaml_writer.indent(mapping=2, sequence=4, offset=2) + _yaml_writer.dump(data, buf) + return buf.getvalue() + + DEFAULT_CONFIG = f""" mqtt: enabled: False +{_render_default_yaml({"detectors": DEFAULT_DETECTORS, "model": DEFAULT_MODEL})} cameras: {{}} # No cameras defined, UI wizard should be used version: {CURRENT_CONFIG_VERSION} """ -DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}} -DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720} - # stream info handler stream_info_retriever = StreamInfoRetriever() @@ -453,7 +476,7 @@ class FrigateConfig(FrigateBaseModel): cameras: Dict[str, CameraConfig] = Field(title="Cameras", description="Cameras") audio: AudioConfig = Field( default_factory=AudioConfig, - title="Audio events", + title="Audio detection", description="Settings for audio-based event detection for all cameras; can be overridden per-camera.", ) birdseye: BirdseyeConfig = Field( @@ -614,17 +637,12 @@ class FrigateConfig(FrigateBaseModel): if self.ffmpeg.hwaccel_args == "auto": self.ffmpeg.hwaccel_args = auto_detect_hwaccel() - # Populate global audio filters for all audio labels - all_audio_labels = { - label - for label in load_labels("/audio-labelmap.txt", prefill=521).values() - if label - } - + # Populate global audio filters from listen. Existing user-defined + # entries for labels not in listen are preserved but unused at runtime. if self.audio.filters is None: self.audio.filters = {} - for key in sorted(all_audio_labels - self.audio.filters.keys()): + for key in sorted(set(self.audio.listen) - self.audio.filters.keys()): self.audio.filters[key] = AudioFilterConfig() self.audio.filters = dict(sorted(self.audio.filters.items())) @@ -679,6 +697,9 @@ class FrigateConfig(FrigateBaseModel): model_config["path"] = "/cpu_model.tflite" elif detector_config.type == "edgetpu": model_config["path"] = "/edgetpu_model.tflite" + elif detector_config.type == "openvino": + for default_key, default_value in DEFAULT_MODEL.items(): + model_config.setdefault(default_key, default_value) model = ModelConfig.model_validate(model_config) model.check_and_load_plus_model(self.plus_api, detector_config.type) @@ -813,7 +834,9 @@ class FrigateConfig(FrigateBaseModel): if camera_config.audio.filters is None: camera_config.audio.filters = {} - for key in sorted(all_audio_labels - camera_config.audio.filters.keys()): + for key in sorted( + set(camera_config.audio.listen) - camera_config.audio.filters.keys() + ): camera_config.audio.filters[key] = AudioFilterConfig() camera_config.audio.filters = dict( @@ -835,7 +858,9 @@ class FrigateConfig(FrigateBaseModel): if mask_config: coords = mask_config.coordinates relative_coords = get_relative_coordinates( - coords, camera_config.frame_shape + coords, + camera_config.frame_shape, + camera_name=camera_config.name, ) # Create a new ObjectMaskConfig with raw_coordinates set processed_global_masks[mask_id] = ObjectMaskConfig( diff --git a/frigate/config/telemetry.py b/frigate/config/telemetry.py index 41c3f7bbc2..f85ff343f3 100644 --- a/frigate/config/telemetry.py +++ b/frigate/config/telemetry.py @@ -25,8 +25,8 @@ class StatsConfig(FrigateBaseModel): ) intel_gpu_device: Optional[str] = Field( default=None, - title="SR-IOV device", - description="Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats.", + title="Intel GPU device", + description="PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.", ) diff --git a/frigate/const.py b/frigate/const.py index 51e06e4ad8..07537ea5f7 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -15,7 +15,7 @@ TRIGGER_DIR = f"{CLIPS_DIR}/triggers" BIRDSEYE_PIPE = "/tmp/cache/birdseye" CACHE_DIR = "/tmp/cache" REPLAY_CAMERA_PREFIX = "_replay_" -REPLAY_DIR = os.path.join(CACHE_DIR, "replay") +REPLAY_DIR = os.path.join(CLIPS_DIR, "replay") PLUS_ENV_VAR = "PLUS_API_KEY" PLUS_API_HOST = "https://api.frigate.video" diff --git a/frigate/data_processing/common/face/model.py b/frigate/data_processing/common/face/model.py index 45e8b8939e..87293f7f02 100644 --- a/frigate/data_processing/common/face/model.py +++ b/frigate/data_processing/common/face/model.py @@ -133,6 +133,61 @@ class FaceRecognizer(ABC): return 0.0 +def build_class_mean( + embs: list[np.ndarray], + trim: float = 0.15, + outlier_threshold: float = 0.30, + min_keep_frac: float = 0.7, + max_iters: int = 3, +) -> np.ndarray: + """Build a class-mean embedding with two-layer outlier protection. + + Layer 1 (iterative, vector-wise): drop whole embeddings whose cosine + similarity to the current class mean is below ``outlier_threshold``. + Catches mislabeled or corrupted training samples (wrong face in the + folder, full-frame screenshots, extreme crops) that per-dimension + trimming cannot detect. + + Layer 2 (per-dimension): ``scipy.stats.trim_mean`` on the retained set + to smooth per-component noise (lighting, expression, alignment jitter). + + Collections with fewer than 5 images bypass outlier rejection — too few + samples to establish a reliable class center. + """ + arr = np.stack(embs, axis=0) + + if len(arr) < 5: + return np.asarray(stats.trim_mean(arr, trim, axis=0)) + + keep = np.ones(len(arr), dtype=bool) + floor = max(5, int(np.ceil(min_keep_frac * len(arr)))) + + for _ in range(max_iters): + mean = stats.trim_mean(arr[keep], trim, axis=0) + m_norm = mean / (np.linalg.norm(mean) + 1e-9) + e_norms = arr / (np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9) + cos = e_norms @ m_norm + new_keep = cos >= outlier_threshold + + if new_keep.sum() < floor: + top = np.argsort(-cos)[:floor] + new_keep = np.zeros(len(arr), dtype=bool) + new_keep[top] = True + + if np.array_equal(new_keep, keep): + break + keep = new_keep + + dropped = int((~keep).sum()) + + if dropped: + logger.debug( + f"Vector-wise outlier filter dropped {dropped}/{len(arr)} embeddings" + ) + + return np.asarray(stats.trim_mean(arr[keep], trim, axis=0)) + + def similarity_to_confidence( cosine_similarity: float, median: float = 0.3, @@ -229,7 +284,7 @@ class FaceNetRecognizer(FaceRecognizer): for name, embs in face_embeddings_map.items(): if embs: - self.mean_embs[name] = stats.trim_mean(embs, 0.15) + self.mean_embs[name] = build_class_mean(embs) logger.debug("Finished building ArcFace model") @@ -340,7 +395,7 @@ class ArcFaceRecognizer(FaceRecognizer): for name, embs in face_embeddings_map.items(): if embs: - self.mean_embs[name] = stats.trim_mean(embs, 0.15) + self.mean_embs[name] = build_class_mean(embs) logger.debug("Finished building ArcFace model") diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index f767a5c2f4..7ebb464242 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -1073,10 +1073,6 @@ class LicensePlateProcessingMixin: top_score = score top_box = bbox - if score > top_score: - top_score = score - top_box = bbox - # Return the top scoring bounding box if found if top_box is not None: # expand box by 5% to help with OCR @@ -1092,9 +1088,6 @@ class LicensePlateProcessingMixin: ] ).clip(0, [input.shape[1], input.shape[0]] * 2) - logger.debug( - f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}" - ) return tuple(int(x) for x in expanded_box) # type: ignore[return-value] else: return None # No detection above the threshold @@ -1360,8 +1353,8 @@ class LicensePlateProcessingMixin: ) # check that license plate is valid - # double the value because we've doubled the size of the car - if license_plate_area < self.config.cameras[camera].lpr.min_area * 2: + # quadruple the value because we've doubled both dimensions of the car + if license_plate_area < self.config.cameras[camera].lpr.min_area * 4: logger.debug(f"{camera}: License plate is less than min_area") return @@ -1465,6 +1458,7 @@ class LicensePlateProcessingMixin: license_plate_frame, ) + logger.debug(f"{camera}: Found license plate. Bounding box: {list(plate_box)}") logger.debug(f"{camera}: Running plate recognition for id: {id}.") # run detection, returns results sorted by confidence, best first diff --git a/frigate/data_processing/post/object_descriptions.py b/frigate/data_processing/post/object_descriptions.py index babdb72521..6404b38516 100644 --- a/frigate/data_processing/post/object_descriptions.py +++ b/frigate/data_processing/post/object_descriptions.py @@ -269,7 +269,9 @@ class ObjectDescriptionProcessor(PostProcessorApi): if event.has_snapshot and camera_config.objects.genai.use_snapshot: snapshot_image = self._read_and_crop_snapshot(event) + if not snapshot_image: + self.cleanup_event(event_id) return num_thumbnails = len(self.tracked_events.get(event_id, [])) diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index 536b57f3c5..3740ac25f7 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -39,6 +39,8 @@ logger = logging.getLogger(__name__) RECORDING_BUFFER_EXTENSION_PERCENT = 0.10 MIN_RECORDING_DURATION = 10 +MAX_IMAGE_TOKENS = 24000 +MAX_FRAMES_PER_SECOND = 1 class ReviewDescriptionProcessor(PostProcessorApi): @@ -60,14 +62,22 @@ class ReviewDescriptionProcessor(PostProcessorApi): def calculate_frame_count( self, camera: str, + duration: float, image_source: ImageSourceEnum = ImageSourceEnum.preview, height: int = 480, ) -> int: - """Calculate optimal number of frames based on context size, image source, and resolution. + """Calculate optimal number of frames based on event duration, context size, + image source, and resolution. - Token usage varies by resolution: larger images (ultra-wide aspect ratios) use more tokens. - Estimates ~1 token per 1250 pixels. Targets 98% context utilization with safety margin. - Capped at 20 frames. + Per-image token cost is asked of the GenAI provider so providers that know + their model's true cost (e.g. llama.cpp can probe the loaded mmproj) can + diverge from the default ~1-token-per-1250-pixels heuristic. The frame + budget is bounded by: + - remaining context window after prompt + response reservations + - a fixed MAX_IMAGE_TOKENS ceiling + - MAX_FRAMES_PER_SECOND x duration, to avoid drowning short events in + near-duplicate frames where the model latches onto the redundant middle + and skips the start/end action """ client = self.genai_manager.description_client @@ -105,14 +115,15 @@ class ReviewDescriptionProcessor(PostProcessorApi): width = target_width height = int(target_width / aspect_ratio) - pixels_per_image = width * height - tokens_per_image = pixels_per_image / 1250 + tokens_per_image = client.estimate_image_tokens(width, height) prompt_tokens = 3800 response_tokens = 300 - available_tokens = context_size - prompt_tokens - response_tokens - max_frames = int(available_tokens / tokens_per_image) - - return min(max(max_frames, 3), 20) + context_budget = context_size - prompt_tokens - response_tokens + image_token_budget = min(context_budget, MAX_IMAGE_TOKENS) + max_frames_by_tokens = int(image_token_budget / tokens_per_image) + max_frames_by_duration = int(duration * MAX_FRAMES_PER_SECOND) + max_frames = min(max_frames_by_tokens, max_frames_by_duration) + return max(max_frames, 3) def process_data( self, data: dict[str, Any], data_type: PostProcessDataEnum @@ -355,12 +366,17 @@ class ReviewDescriptionProcessor(PostProcessorApi): file_start = f"preview_{camera}-" start_file = f"{file_start}{start_time}.webp" end_file = f"{file_start}{end_time}.webp" + + camera_files = [ + entry.name + for entry in os.scandir(preview_dir) + if entry.name.startswith(file_start) + ] + camera_files.sort() + all_frames: list[str] = [] - for file in sorted(os.listdir(preview_dir)): - if not file.startswith(file_start): - continue - + for file in camera_files: if file < start_file: if len(all_frames): all_frames[0] = os.path.join(preview_dir, file) @@ -376,7 +392,9 @@ class ReviewDescriptionProcessor(PostProcessorApi): all_frames.append(os.path.join(preview_dir, file)) frame_count = len(all_frames) - desired_frame_count = self.calculate_frame_count(camera) + desired_frame_count = self.calculate_frame_count( + camera, duration=end_time - start_time + ) if frame_count <= desired_frame_count: return all_frames @@ -400,7 +418,7 @@ class ReviewDescriptionProcessor(PostProcessorApi): """Get frames from recordings at specified timestamps.""" duration = end_time - start_time desired_frame_count = self.calculate_frame_count( - camera, ImageSourceEnum.recordings, height + camera, duration, ImageSourceEnum.recordings, height ) # Calculate evenly spaced timestamps throughout the duration diff --git a/frigate/data_processing/post/types.py b/frigate/data_processing/post/types.py index 6906f4a4ee..3698e99a85 100644 --- a/frigate/data_processing/post/types.py +++ b/frigate/data_processing/post/types.py @@ -1,21 +1,37 @@ -from pydantic import BaseModel, ConfigDict, Field +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + +ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=200)] class ReviewMetadata(BaseModel): model_config = ConfigDict(extra="ignore", protected_namespaces=()) - title: str = Field( - description="A short title characterizing what took place and where, under 10 words." + observations: list[ObservationItem] = Field( + ..., + min_length=3, + max_length=8, + description="Enumerate the significant observations across all frames, in chronological order.", ) scene: str = Field( - description="A chronological narrative of what happens from start to finish." + min_length=150, + max_length=600, + description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.", + ) + title: str = Field( + max_length=80, + description="Title for the activity.", ) shortSummary: str = Field( - description="A brief 2-sentence summary of the scene, suitable for notifications." + min_length=70, + max_length=140, + description="A brief summary for the activity.", ) confidence: float = Field( ge=0.0, - description="Confidence in the analysis, from 0 to 1.", + le=1.0, + description="Confidence in the analysis as a decimal between 0.0 and 1.0, where 0.0 means no confidence and 1.0 means complete confidence. Express ONLY as a decimal.", ) potential_threat_level: int = Field( ge=0, diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index c6b6346b54..c5c4ec56f0 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -229,9 +229,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): logger.debug(f"No person box available for {id}") return - rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) + # YuNet (cv2.FaceDetectorYN) is trained on BGR + bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) left, top, right, bottom = person_box - person = rgb[top:bottom, left:right] + person = bgr[top:bottom, left:right] face_box = self.__detect_face(person, self.face_config.detection_threshold) if not face_box: @@ -250,11 +251,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): ) return - try: - face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR) - except Exception as e: - logger.debug(f"Failed to convert face frame color for {id}: {e}") - return else: # don't run for object without attributes if not obj_data.get("current_attributes"): diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py index 15ca3777ac..ac04090e47 100644 --- a/frigate/debug_replay.py +++ b/frigate/debug_replay.py @@ -1,9 +1,13 @@ -"""Debug replay camera management for replaying recordings with detection overlays.""" +"""Debug replay camera management for replaying recordings with detection overlays. + +The startup work (ffmpeg concat + camera config publish) lives in +frigate.jobs.debug_replay. This module owns only session presence +(active), session metadata, and post-session cleanup. +""" import logging import os import shutil -import subprocess as sp import threading from ruamel.yaml import YAML @@ -21,7 +25,7 @@ from frigate.const import ( REPLAY_DIR, THUMB_DIR, ) -from frigate.models import Recordings +from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file @@ -29,7 +33,14 @@ logger = logging.getLogger(__name__) class DebugReplayManager: - """Manages a single debug replay session.""" + """Owns the lifecycle pointers for a single debug replay session. + + A session exists from the moment mark_starting is called (synchronously, + inside the API handler) until clear_session runs (on success cleanup, + failure, or stop). The active property is the source of truth that the + status bar consumes — broader than the startup job, which only covers the + preparing_clip / starting_camera window. + """ def __init__(self) -> None: self._lock = threading.Lock() @@ -41,144 +52,66 @@ class DebugReplayManager: @property def active(self) -> bool: - """Whether a replay session is currently active.""" + """True from mark_starting until clear_session.""" return self.replay_camera_name is not None - def start( + def mark_starting( self, source_camera: str, + replay_camera_name: str, start_ts: float, end_ts: float, - frigate_config: FrigateConfig, - config_publisher: CameraConfigUpdatePublisher, - ) -> str: - """Start a debug replay session. + ) -> None: + """Synchronously claim the session before the job runner starts. - Args: - source_camera: Name of the source camera to replay - start_ts: Start timestamp - end_ts: End timestamp - frigate_config: Current Frigate configuration - config_publisher: Publisher for camera config updates - - Returns: - The replay camera name - - Raises: - ValueError: If a session is already active or parameters are invalid - RuntimeError: If clip generation fails + Called inside the API handler so the status bar sees active=True + immediately, before the worker thread does any ffmpeg work. """ with self._lock: - return self._start_locked( - source_camera, start_ts, end_ts, frigate_config, config_publisher - ) + self.replay_camera_name = replay_camera_name + self.source_camera = source_camera + self.start_ts = start_ts + self.end_ts = end_ts + self.clip_path = None - def _start_locked( + def mark_session_ready(self, clip_path: str) -> None: + """Record the on-disk clip path after the camera has been published.""" + with self._lock: + self.clip_path = clip_path + + def clear_session(self) -> None: + """Reset session pointers without publishing camera removal. + + Used by the job runner on failure paths. stop() does the camera + teardown plus this clear in one step. + """ + with self._lock: + self._clear_locked() + + def _clear_locked(self) -> None: + self.replay_camera_name = None + self.source_camera = None + self.clip_path = None + self.start_ts = None + self.end_ts = None + + def publish_camera( self, source_camera: str, - start_ts: float, - end_ts: float, + replay_name: str, + clip_path: str, frigate_config: FrigateConfig, config_publisher: CameraConfigUpdatePublisher, - ) -> str: - if self.active: - raise ValueError("A replay session is already active") + ) -> None: + """Build the in-memory replay camera config and publish the add event. - if source_camera not in frigate_config.cameras: - raise ValueError(f"Camera '{source_camera}' not found") - - if end_ts <= start_ts: - raise ValueError("End time must be after start time") - - # Query recordings for the source camera in the time range - 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 == source_camera) - .order_by(Recordings.start_time.asc()) - ) - - if not recordings.count(): - raise ValueError( - f"No recordings found for camera '{source_camera}' in the specified time range" - ) - - # Create replay directory - os.makedirs(REPLAY_DIR, exist_ok=True) - - # Generate replay camera name - replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}" - - # Build concat file for ffmpeg - concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt") - clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4") - - with open(concat_file, "w") as f: - for recording in recordings: - f.write(f"file '{recording.path}'\n") - - # Concatenate recordings into a single clip with -c copy (fast) - ffmpeg_cmd = [ - frigate_config.ffmpeg.ffmpeg_path, - "-hide_banner", - "-y", - "-f", - "concat", - "-safe", - "0", - "-i", - concat_file, - "-c", - "copy", - "-movflags", - "+faststart", - clip_path, - ] - - logger.info( - "Generating replay clip for %s (%.1f - %.1f)", - source_camera, - start_ts, - end_ts, - ) - - try: - result = sp.run( - ffmpeg_cmd, - capture_output=True, - text=True, - timeout=120, - ) - if result.returncode != 0: - logger.error("FFmpeg error: %s", result.stderr) - raise RuntimeError( - f"Failed to generate replay clip: {result.stderr[-500:]}" - ) - except sp.TimeoutExpired: - raise RuntimeError("Clip generation timed out") - finally: - # Clean up concat file - if os.path.exists(concat_file): - os.remove(concat_file) - - if not os.path.exists(clip_path): - raise RuntimeError("Clip file was not created") - - # Build camera config dict for the replay camera + Called by the job runner during the starting_camera phase. + """ source_config = frigate_config.cameras[source_camera] camera_dict = self._build_camera_config_dict( source_config, replay_name, clip_path ) - # Build an in-memory config with the replay camera added config_file = find_config_file() yaml_parser = YAML() with open(config_file, "r") as f: @@ -191,75 +124,48 @@ class DebugReplayManager: try: new_config = FrigateConfig.parse_object(config_data) except Exception as e: - raise RuntimeError(f"Failed to validate replay camera config: {e}") - - # Update the running config + raise RuntimeError(f"Failed to validate replay camera config: {e}") from e frigate_config.cameras[replay_name] = new_config.cameras[replay_name] - # Publish the add event config_publisher.publish_update( CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name), new_config.cameras[replay_name], ) - # Store session state - self.replay_camera_name = replay_name - self.source_camera = source_camera - self.clip_path = clip_path - self.start_ts = start_ts - self.end_ts = end_ts - - logger.info("Debug replay started: %s -> %s", source_camera, replay_name) - return replay_name - def stop( self, frigate_config: FrigateConfig, config_publisher: CameraConfigUpdatePublisher, ) -> None: - """Stop the active replay session and clean up all artifacts. + """Cancel any in-flight startup job and tear down the active session. - Args: - frigate_config: Current Frigate configuration - config_publisher: Publisher for camera config updates + Safe to call when no session is active (no-op with a warning). """ + cancel_debug_replay_job() + wait_for_runner(timeout=2.0) + with self._lock: - self._stop_locked(frigate_config, config_publisher) + if not self.active: + logger.warning("No active replay session to stop") + return - def _stop_locked( - self, - frigate_config: FrigateConfig, - config_publisher: CameraConfigUpdatePublisher, - ) -> None: - if not self.active: - logger.warning("No active replay session to stop") - return + replay_name = self.replay_camera_name - replay_name = self.replay_camera_name + # Only publish remove if the camera was actually added to the live + # config (i.e. the runner reached the starting_camera phase). + if replay_name is not None and replay_name in frigate_config.cameras: + config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name), + frigate_config.cameras[replay_name], + ) - # Publish remove event so subscribers stop and remove from their config - if replay_name in frigate_config.cameras: - config_publisher.publish_update( - CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name), - frigate_config.cameras[replay_name], - ) - # Do NOT pop here — let subscribers handle removal from the shared - # config dict when they process the ZMQ message to avoid race conditions + if replay_name is not None: + self._cleanup_db(replay_name) + self._cleanup_files(replay_name) - # Defensive DB cleanup - self._cleanup_db(replay_name) + self._clear_locked() - # Remove filesystem artifacts - self._cleanup_files(replay_name) - - # Reset state - self.replay_camera_name = None - self.source_camera = None - self.clip_path = None - self.start_ts = None - self.end_ts = None - - logger.info("Debug replay stopped and cleaned up: %s", replay_name) + logger.info("Debug replay stopped and cleaned up: %s", replay_name) def _build_camera_config_dict( self, @@ -267,16 +173,7 @@ class DebugReplayManager: replay_name: str, clip_path: str, ) -> dict: - """Build a camera config dictionary for the replay camera. - - Args: - source_config: Source camera's CameraConfig - replay_name: Name for the replay camera - clip_path: Path to the replay clip file - - Returns: - Camera config as a dictionary - """ + """Build a camera config dictionary for the replay camera.""" # Extract detect config (exclude computed fields) detect_dict = source_config.detect.model_dump( exclude={"min_initialized", "max_disappeared", "enabled_in_config"} @@ -311,7 +208,6 @@ class DebugReplayManager: zone_dump = zone_config.model_dump( exclude={"contour", "color"}, exclude_defaults=True ) - # Always include required fields zone_dump.setdefault("coordinates", zone_config.coordinates) zones_dict[zone_name] = zone_dump diff --git a/frigate/detectors/detection_runners.py b/frigate/detectors/detection_runners.py index d12c8b733d..39ebbf5783 100644 --- a/frigate/detectors/detection_runners.py +++ b/frigate/detectors/detection_runners.py @@ -79,7 +79,11 @@ def is_openvino_gpu_npu_available() -> bool: 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) + return any( + avail_dev == accel_dev or avail_dev.startswith(accel_dev + ".") + for avail_dev in available_devices + for accel_dev in acceleration_devices + ) class BaseModelRunner(ABC): @@ -132,7 +136,6 @@ class ONNXModelRunner(BaseModelRunner): return model_type in [ EnrichmentModelTypeEnum.paddleocr.value, EnrichmentModelTypeEnum.jina_v2.value, - EnrichmentModelTypeEnum.arcface.value, ModelTypeEnum.rfdetr.value, ModelTypeEnum.dfine.value, ] diff --git a/frigate/detectors/plugins/openvino.py b/frigate/detectors/plugins/openvino.py index f73b7cb0cc..1e9fb1ab10 100644 --- a/frigate/detectors/plugins/openvino.py +++ b/frigate/detectors/plugins/openvino.py @@ -52,6 +52,12 @@ class OvDetector(DetectionApi): self.h = detector_config.model.height self.w = detector_config.model.width + logger.info( + "Loading OpenVINO model %s on device %s", + detector_config.model.path, + detector_config.device, + ) + self.runner = OpenVINOModelRunner( model_path=detector_config.model.path, device=detector_config.device, diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index 5e14d0d8c3..7e54d97036 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -4,6 +4,7 @@ import base64 import json import logging import os +import sys import threading from json.decoder import JSONDecodeError from multiprocessing.synchronize import Event as MpEvent @@ -52,6 +53,14 @@ class EmbeddingProcess(FrigateProcess): self.stop_event, ) maintainer.start() + maintainer.join() + + # If the maintainer thread exited but no shutdown was requested, it + # crashed. Surface as a non-zero exit so the watchdog restarts us + # instead of treating the silent thread death as a clean shutdown. + if not self.stop_event.is_set(): + logger.error("Embeddings maintainer thread exited unexpectedly") + sys.exit(1) class EmbeddingsContext: diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index ea1c9a1186..96a44a8c6c 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -60,7 +60,11 @@ from frigate.data_processing.real_time.license_plate import ( ) from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum from frigate.db.sqlitevecq import SqliteVecQueueDatabase -from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum +from frigate.events.types import ( + EventStateEnum, + EventTypeEnum, + RegenerateDescriptionEnum, +) from frigate.genai import GenAIClientManager from frigate.models import Event, Recordings, ReviewSegment, Trigger from frigate.types import TrackedObjectUpdateTypesEnum @@ -310,6 +314,10 @@ class EmbeddingMaintainer(threading.Thread): self._handle_custom_classification_update(topic, payload) return + if topic == "config/genai": + self.config.genai = payload + self.genai_manager.update_config(self.config) + # Broadcast to all processors — each decides if the topic is relevant for processor in self.realtime_processors: processor.update_config(topic, payload) @@ -431,7 +439,7 @@ class EmbeddingMaintainer(threading.Thread): if update is None: return - source_type, _, camera, frame_name, data = update + source_type, event_type, camera, frame_name, data = update logger.debug( f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}" @@ -481,6 +489,12 @@ class EmbeddingMaintainer(threading.Thread): for processor in self.post_processors: if isinstance(processor, ObjectDescriptionProcessor): + # skip end events — _process_finalized handles them via event_end_subscriber. + # processing them here can re-create tracked_events entries after cleanup + # when the event_subscriber queue is backlogged behind event_end_subscriber. + if event_type == EventStateEnum.end: + continue + processor.process_data( { "camera": camera, @@ -513,10 +527,16 @@ class EmbeddingMaintainer(threading.Thread): try: event: Event = Event.get(Event.id == event_id) except DoesNotExist: + for processor in self.post_processors: + if isinstance(processor, ObjectDescriptionProcessor): + processor.cleanup_event(event_id) continue # Skip the event if not an object if event.data.get("type") != "object": + for processor in self.post_processors: + if isinstance(processor, ObjectDescriptionProcessor): + processor.cleanup_event(event_id) continue # Extract valid thumbnail diff --git a/frigate/events/audio.py b/frigate/events/audio.py index f6c41fa30b..c5e35a8d52 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -84,7 +84,6 @@ class AudioProcessor(FrigateProcess): def __init__( self, config: FrigateConfig, - cameras: list[CameraConfig], camera_metrics: DictProxy, stop_event: MpEvent, ): @@ -93,12 +92,11 @@ class AudioProcessor(FrigateProcess): ) self.camera_metrics = camera_metrics - self.cameras = cameras self.config = config def run(self) -> None: self.pre_run_setup(self.config.logger) - audio_threads: list[AudioEventMaintainer] = [] + audio_threads: dict[str, AudioEventMaintainer] = {} threading.current_thread().name = "process:audio_manager" @@ -112,32 +110,56 @@ class AudioProcessor(FrigateProcess): else: self.transcription_model_runner = None - if len(self.cameras) == 0: - return + config_subscriber = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.audio, + CameraConfigUpdateEnum.ffmpeg, + ], + ) - for camera in self.cameras: - audio_thread = AudioEventMaintainer( + def spawn_if_needed(camera: CameraConfig) -> None: + name = camera.name + if name is None or name in audio_threads: + return + if not camera.enabled or not camera.audio.enabled: + return + # ffmpeg update may not have arrived yet; wait for next poll + if not any("audio" in i.roles for i in camera.ffmpeg.inputs): + return + thread = AudioEventMaintainer( camera, self.config, self.camera_metrics, self.transcription_model_runner, self.stop_event, # type: ignore[arg-type] ) - audio_threads.append(audio_thread) - audio_thread.start() + audio_threads[name] = thread + thread.start() + self.logger.info(f"Audio maintainer started for {name}") + + for camera in self.config.cameras.values(): + spawn_if_needed(camera) self.logger.info(f"Audio processor started (pid: {self.pid})") - while not self.stop_event.wait(): - pass + # poll for newly added cameras or cameras flipped to audio.enabled at runtime + while not self.stop_event.wait(timeout=1.0): + config_subscriber.check_for_updates() + for camera in self.config.cameras.values(): + spawn_if_needed(camera) - for thread in audio_threads: + config_subscriber.stop() + + for thread in audio_threads.values(): thread.join(1) if thread.is_alive(): self.logger.info(f"Waiting for thread {thread.name:s} to exit") thread.join(10) - for thread in audio_threads: + for thread in audio_threads.values(): if thread.is_alive(): self.logger.warning(f"Thread {thread.name} is still alive") @@ -205,6 +227,7 @@ class AudioEventMaintainer(threading.Thread): self.transcription_thread.start() self.was_enabled = camera.enabled + self.was_audio_enabled = camera.audio.enabled def detect_audio(self, audio: np.ndarray) -> None: if not self.camera_config.audio.enabled or self.stop_event.is_set(): @@ -363,6 +386,17 @@ class AudioEventMaintainer(threading.Thread): time.sleep(0.1) continue + audio_enabled = self.camera_config.audio.enabled + if audio_enabled != self.was_audio_enabled: + if not audio_enabled: + 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.was_audio_enabled = audio_enabled + self.read_audio() if self.audio_listener: diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index d95dd2cae2..ce0034670d 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -2,6 +2,7 @@ import datetime import importlib +import json import logging import os import re @@ -9,6 +10,7 @@ from typing import Any, Callable, Optional import numpy as np from playhouse.shortcuts import model_to_dict +from pydantic import ValidationError from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum from frigate.const import CLIPS_DIR @@ -106,10 +108,11 @@ When forming your description: ## Response Field Guidelines Respond with a JSON object matching the provided schema. Field-specific guidance: +- `observations`: Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence. - `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign. -- `title`: Characterize **what took place and where** — interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. No editorial qualifiers like "routine" or "suspicious." +- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. +- `shortSummary`: Briefly summarize the primary activity across the observations. - `potential_threat_level`: Must be consistent with your scene description and the activity patterns above. -{get_concern_prompt()} ## Sequence Details @@ -151,9 +154,6 @@ Each line represents a detection state, not necessarily unique individuals. The if "other_concerns" in schema.get("required", []): schema["required"].remove("other_concerns") - # OpenAI strict mode requires additionalProperties: false on all objects - schema["additionalProperties"] = False - response_format = { "type": "json_schema", "json_schema": { @@ -181,7 +181,36 @@ Each line represents a detection state, not necessarily unique individuals. The try: metadata = ReviewMetadata.model_validate_json(clean_json) + except ValidationError as ve: + # Constraint violations (length, item count, ranges) are logged + # at debug and the response is kept anyway — a slightly + # off-spec answer is still usable, and dropping the whole + # response loses the narrative content the model produced. + for err in ve.errors(): + loc = ".".join(str(p) for p in err["loc"]) or "" + logger.debug( + "Review metadata soft validation: %s — %s (input: %r)", + loc, + err["msg"], + err.get("input"), + ) + try: + raw = json.loads(clean_json) + except json.JSONDecodeError as je: + logger.error("Failed to parse review description JSON: %s", je) + return None + # observations and confidence are required on the model; fill an empty default + # if the response omitted it so attribute access stays safe. + raw.setdefault("observations", []) + raw.setdefault("confidence", 0.0) + metadata = ReviewMetadata.model_construct(**raw) + except Exception as e: + logger.error( + f"Failed to parse review description as the response did not match expected format. {e}" + ) + return None + try: # Normalize confidence if model returned a percentage (e.g. 85 instead of 0.85) if metadata.confidence > 1.0: metadata.confidence = min(metadata.confidence / 100.0, 1.0) @@ -194,10 +223,7 @@ Each line represents a detection state, not necessarily unique individuals. The 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}" - ) + logger.error(f"Failed to post-process review metadata: {e}") return None else: logger.debug( @@ -344,6 +370,14 @@ Guidelines: """Get the context window size for this provider in tokens.""" return 4096 + def estimate_image_tokens(self, width: int, height: int) -> float: + """Estimate prompt tokens consumed by a single image of the given dimensions. + + Default heuristic: ~1 token per 1250 pixels. Providers that can measure or + know their model's exact image-token cost should override. + """ + return (width * height) / 1250 + def embed( self, texts: list[str] | None = None, diff --git a/frigate/genai/azure-openai.py b/frigate/genai/azure-openai.py index 66d7d1568e..04a2b8d556 100644 --- a/frigate/genai/azure-openai.py +++ b/frigate/genai/azure-openai.py @@ -10,6 +10,7 @@ from openai import AzureOpenAI from frigate.config import GenAIProviderEnum from frigate.genai import GenAIClient, register_genai_provider +from frigate.genai.openai import _stats_from_openai_usage logger = logging.getLogger(__name__) @@ -210,6 +211,7 @@ class OpenAIClient(GenAIClient): "messages": messages, "timeout": self.timeout, "stream": True, + "stream_options": {"include_usage": True}, } if tools: @@ -221,10 +223,15 @@ class OpenAIClient(GenAIClient): content_parts: list[str] = [] tool_calls_by_index: dict[int, dict[str, Any]] = {} finish_reason = "stop" + usage_stats: Optional[dict[str, Any]] = None stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload] for chunk in stream: + chunk_usage = getattr(chunk, "usage", None) + if chunk_usage is not None: + usage_stats = _stats_from_openai_usage(chunk_usage) + if not chunk or not chunk.choices: continue @@ -284,6 +291,9 @@ class OpenAIClient(GenAIClient): ) finish_reason = "tool_calls" + if usage_stats is not None: + yield ("stats", usage_stats) + yield ( "message", { diff --git a/frigate/genai/gemini.py b/frigate/genai/gemini.py index cfa9cb8029..c1046428e6 100644 --- a/frigate/genai/gemini.py +++ b/frigate/genai/gemini.py @@ -14,6 +14,20 @@ from frigate.genai import GenAIClient, register_genai_provider logger = logging.getLogger(__name__) +def _stats_from_gemini_usage(usage: Any) -> Optional[dict[str, Any]]: + """Build a stats dict from a Gemini usage_metadata object.""" + prompt_tokens = getattr(usage, "prompt_token_count", None) + completion_tokens = getattr(usage, "candidates_token_count", None) + if prompt_tokens is None and completion_tokens is None: + return None + stats: dict[str, Any] = {} + if isinstance(prompt_tokens, int): + stats["prompt_tokens"] = prompt_tokens + if isinstance(completion_tokens, int): + stats["completion_tokens"] = completion_tokens + return stats or None + + @register_genai_provider(GenAIProviderEnum.gemini) class GeminiClient(GenAIClient): """Generative AI client for Frigate using Gemini.""" @@ -136,22 +150,44 @@ class GeminiClient(GenAIClient): ) ) elif role == "assistant": - gemini_messages.append( - types.Content( - role="model", parts=[types.Part.from_text(text=content)] - ) - ) + parts: list[types.Part] = [] + if content: + parts.append(types.Part.from_text(text=content)) + for tc in msg.get("tool_calls") or []: + func = tc.get("function") or {} + tc_name = func.get("name") or "" + tc_args: Any = func.get("arguments") + if isinstance(tc_args, str): + try: + tc_args = json.loads(tc_args) + except (json.JSONDecodeError, TypeError): + tc_args = {} + if not isinstance(tc_args, dict): + tc_args = {} + if tc_name: + parts.append( + types.Part.from_function_call( + name=tc_name, args=tc_args + ) + ) + if not parts: + parts.append(types.Part.from_text(text=" ")) + gemini_messages.append(types.Content(role="model", parts=parts)) elif role == "tool": # Handle tool response - function_response = { - "name": msg.get("name", ""), - "response": content, - } + response_payload = ( + content if isinstance(content, dict) else {"result": content} + ) gemini_messages.append( types.Content( role="function", parts=[ - types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type] + types.Part.from_function_response( + name=msg.get("name") + or msg.get("tool_call_id") + or "", + response=response_payload, + ) ], ) ) @@ -343,22 +379,44 @@ class GeminiClient(GenAIClient): ) ) elif role == "assistant": - gemini_messages.append( - types.Content( - role="model", parts=[types.Part.from_text(text=content)] - ) - ) + parts: list[types.Part] = [] + if content: + parts.append(types.Part.from_text(text=content)) + for tc in msg.get("tool_calls") or []: + func = tc.get("function") or {} + tc_name = func.get("name") or "" + tc_args: Any = func.get("arguments") + if isinstance(tc_args, str): + try: + tc_args = json.loads(tc_args) + except (json.JSONDecodeError, TypeError): + tc_args = {} + if not isinstance(tc_args, dict): + tc_args = {} + if tc_name: + parts.append( + types.Part.from_function_call( + name=tc_name, args=tc_args + ) + ) + if not parts: + parts.append(types.Part.from_text(text=" ")) + gemini_messages.append(types.Content(role="model", parts=parts)) elif role == "tool": # Handle tool response - function_response = { - "name": msg.get("name", ""), - "response": content, - } + response_payload = ( + content if isinstance(content, dict) else {"result": content} + ) gemini_messages.append( types.Content( role="function", parts=[ - types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type] + types.Part.from_function_response( + name=msg.get("name") + or msg.get("tool_call_id") + or "", + response=response_payload, + ) ], ) ) @@ -427,6 +485,7 @@ class GeminiClient(GenAIClient): content_parts: list[str] = [] tool_calls_by_index: dict[int, dict[str, Any]] = {} finish_reason = "stop" + usage_stats: Optional[dict[str, Any]] = None stream = await self.provider.aio.models.generate_content_stream( model=self.genai_config.model, @@ -435,6 +494,12 @@ class GeminiClient(GenAIClient): ) async for chunk in stream: + chunk_usage = getattr(chunk, "usage_metadata", None) + if chunk_usage is not None: + maybe_stats = _stats_from_gemini_usage(chunk_usage) + if maybe_stats is not None: + usage_stats = maybe_stats + if not chunk or not chunk.candidates: continue @@ -521,6 +586,9 @@ class GeminiClient(GenAIClient): ) finish_reason = "tool_calls" + if usage_stats is not None: + yield ("stats", usage_stats) + yield ( "message", { diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py index e5e9883b8f..c935207bfe 100644 --- a/frigate/genai/llama_cpp.py +++ b/frigate/genai/llama_cpp.py @@ -18,6 +18,63 @@ from frigate.genai.utils import parse_tool_calls_from_message logger = logging.getLogger(__name__) +def _stats_from_llama_cpp_chunk(data: dict[str, Any]) -> Optional[dict[str, Any]]: + """Build a stats dict from a llama.cpp streaming chunk. + + Final-chunk `usage` carries authoritative token counts. Per-chunk + `timings` (enabled via timings_per_token) carries the running token + counts (prompt_n, predicted_n) and generation rate, so live updates + work mid-stream. + """ + usage = data.get("usage") or {} + timings = data.get("timings") or {} + prompt_tokens = usage.get("prompt_tokens") + completion_tokens = usage.get("completion_tokens") + predicted_ms = timings.get("predicted_ms") + tps = timings.get("predicted_per_second") + stats: dict[str, Any] = {} + + if not isinstance(prompt_tokens, int): + prompt_n = timings.get("prompt_n") + + if isinstance(prompt_n, int): + prompt_tokens = prompt_n + + if not isinstance(completion_tokens, int): + predicted_n = timings.get("predicted_n") + + if isinstance(predicted_n, int): + completion_tokens = predicted_n + + if not isinstance(prompt_tokens, int) and not isinstance(completion_tokens, int): + return None + + if isinstance(prompt_tokens, int): + stats["prompt_tokens"] = prompt_tokens + + if isinstance(completion_tokens, int): + stats["completion_tokens"] = completion_tokens + + if isinstance(predicted_ms, (int, float)) and predicted_ms > 0: + stats["completion_duration_ms"] = float(predicted_ms) + + if isinstance(tps, (int, float)) and tps > 0: + stats["tokens_per_second"] = float(tps) + + return stats or None + + +def _parse_launch_arg(args: list[str], flag: str) -> str | None: + """Return the value following `flag` in a positional argv list, or None.""" + try: + idx = args.index(flag) + except ValueError: + return None + if idx + 1 >= len(args): + return None + return args[idx + 1] + + def _to_jpeg(img_bytes: bytes) -> bytes | None: """Convert image bytes to JPEG. llama.cpp/STB does not support WebP.""" try: @@ -42,6 +99,9 @@ class LlamaCppClient(GenAIClient): _supports_vision: bool _supports_audio: bool _supports_tools: bool + _image_token_cache: dict[tuple[int, int], int] + _text_baseline_tokens: int | None + _media_marker: str def _init_provider(self) -> str | None: """Initialize the client and query model metadata from the server.""" @@ -52,6 +112,9 @@ class LlamaCppClient(GenAIClient): self._supports_vision = False self._supports_audio = False self._supports_tools = False + self._image_token_cache = {} + self._text_baseline_tokens = None + self._media_marker = "<__media__>" base_url = ( self.genai_config.base_url.rstrip("/") @@ -61,28 +124,73 @@ class LlamaCppClient(GenAIClient): if base_url is None: return None + else: + base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url configured_model = self.genai_config.model + info = self._get_model_info(base_url, configured_model) - # Query /v1/models to validate the configured model exists + if info is None: + return None + + self._context_size = info["context_size"] + self._supports_vision = info["supports_vision"] + self._supports_audio = info["supports_audio"] + self._supports_tools = info["supports_tools"] + self._media_marker = info["media_marker"] + + logger.info( + "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s", + configured_model, + self._context_size or "unknown", + self._supports_vision, + self._supports_audio, + self._supports_tools, + ) + + return base_url + + def _get_model_info( + self, base_url: str, configured_model: str + ) -> dict[str, Any] | None: + """Resolve model metadata from /v1/models with /props fallback. + + Returns a dict of capability fields, or None if the server's model + registry was reachable and reported the configured model as missing. + A reachable-but-unparseable /v1/models is treated as soft-pass and + falls through to /props, matching prior behavior. + + After ggml-org/llama.cpp#22952, /v1/models exposes per-model + `architecture.input_modalities` (text/image/audio) — the primary + source. When proxied through llama-swap, the same entry carries + `status.args` (server launch argv) and, for the loaded model, + `meta.n_ctx`. /props remains the only source for `media_marker`, + which the server randomizes per startup unless LLAMA_MEDIA_MARKER + is set. + """ + info: dict[str, Any] = { + "context_size": None, + "supports_vision": False, + "supports_audio": False, + "supports_tools": False, + "media_marker": "<__media__>", + } + + model_entry: dict[str, Any] | None = None try: - response = requests.get( - f"{base_url}/v1/models", - timeout=10, - ) + response = requests.get(f"{base_url}/v1/models", timeout=10) response.raise_for_status() models_data = response.json() - model_found = False for model in models_data.get("data", []): model_ids = {model.get("id")} for alias in model.get("aliases", []): model_ids.add(alias) if configured_model in model_ids: - model_found = True + model_entry = model break - if not model_found: + if model_entry is None: available = [] for m in models_data.get("data", []): available.append(m.get("id", "unknown")) @@ -101,10 +209,35 @@ class LlamaCppClient(GenAIClient): e, ) - # Query /props for context size, modalities, and tool support. - # The standard /props?model= endpoint works with llama-server. - # If it fails, try the llama-swap per-model passthrough endpoint which - # returns props for a specific model without requiring it to be loaded. + if model_entry is not None: + architecture = model_entry.get("architecture") or {} + input_modalities = architecture.get("input_modalities") or [] + + if isinstance(input_modalities, list): + info["supports_vision"] = "image" in input_modalities + info["supports_audio"] = "audio" in input_modalities + + status = model_entry.get("status") or {} + launch_args = status.get("args") if isinstance(status, dict) else None + if not isinstance(launch_args, list): + launch_args = [] + + meta = model_entry.get("meta") if isinstance(model_entry, dict) else None + n_ctx = meta.get("n_ctx") if isinstance(meta, dict) else None + + if not n_ctx: + n_ctx = _parse_launch_arg(launch_args, "--ctx-size") + + if n_ctx: + try: + info["context_size"] = int(n_ctx) + except (TypeError, ValueError): + pass + + # Tool calling on llama-server requires --jinja. + if "--jinja" in launch_args: + info["supports_tools"] = True + try: try: response = requests.get( @@ -122,37 +255,32 @@ class LlamaCppClient(GenAIClient): response.raise_for_status() props = response.json() - # Context size from server runtime config - default_settings = props.get("default_generation_settings", {}) - n_ctx = default_settings.get("n_ctx") - if n_ctx: - self._context_size = int(n_ctx) + if info["context_size"] is None: + default_settings = props.get("default_generation_settings", {}) + n_ctx = default_settings.get("n_ctx") + if n_ctx: + info["context_size"] = int(n_ctx) - # Modalities (vision, audio) - modalities = props.get("modalities", {}) - self._supports_vision = modalities.get("vision", False) - self._supports_audio = modalities.get("audio", False) + if not (info["supports_vision"] or info["supports_audio"]): + modalities = props.get("modalities", {}) + info["supports_vision"] = bool(modalities.get("vision", False)) + info["supports_audio"] = bool(modalities.get("audio", False)) - # Tool support from chat template capabilities - chat_caps = props.get("chat_template_caps", {}) - self._supports_tools = chat_caps.get("supports_tools", False) + if not info["supports_tools"]: + chat_caps = props.get("chat_template_caps", {}) + info["supports_tools"] = bool(chat_caps.get("supports_tools", False)) - logger.info( - "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s", - configured_model, - self._context_size or "unknown", - self._supports_vision, - self._supports_audio, - self._supports_tools, - ) + media_marker = props.get("media_marker") + if isinstance(media_marker, str) and media_marker: + info["media_marker"] = media_marker except Exception as e: logger.warning( "Failed to query llama.cpp /props endpoint: %s. " - "Using defaults for context size and capabilities.", + "Image embeddings may fail if the server randomized its media marker.", e, ) - return base_url + return info def _send( self, @@ -272,6 +400,91 @@ class LlamaCppClient(GenAIClient): return self._context_size return 4096 + def estimate_image_tokens(self, width: int, height: int) -> float: + """Probe the llama.cpp server to learn the model's image-token cost at the + requested dimensions. + + llama.cpp's image tokenization is a deterministic function of dimensions and + the loaded mmproj, so the result is cached per (width, height) for the + lifetime of the process. Falls back to the base pixel heuristic if the + server is unreachable or the response is malformed. + """ + if self.provider is None: + return super().estimate_image_tokens(width, height) + + cached = self._image_token_cache.get((width, height)) + + if cached is not None: + return cached + + try: + baseline = self._probe_baseline_tokens() + with_image = self._probe_image_prompt_tokens(width, height) + tokens = max(1, with_image - baseline) + except Exception as e: + logger.debug( + "llama.cpp image-token probe failed for %dx%d (%s); using heuristic", + width, + height, + e, + ) + return super().estimate_image_tokens(width, height) + + self._image_token_cache[(width, height)] = tokens + logger.debug( + "llama.cpp model '%s' uses ~%d tokens for %dx%d images", + self.genai_config.model, + tokens, + width, + height, + ) + return tokens + + def _probe_baseline_tokens(self) -> int: + """Return prompt_tokens for a minimal text-only request. Cached after first call.""" + if self._text_baseline_tokens is not None: + return self._text_baseline_tokens + + self._text_baseline_tokens = self._probe_prompt_tokens( + [{"type": "text", "text": "."}] + ) + return self._text_baseline_tokens + + def _probe_image_prompt_tokens(self, width: int, height: int) -> int: + """Return prompt_tokens for a single synthetic image plus minimal text.""" + img = Image.new("RGB", (width, height), (128, 128, 128)) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=60) + encoded = base64.b64encode(buf.getvalue()).decode("utf-8") + return self._probe_prompt_tokens( + [ + {"type": "text", "text": "."}, + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{encoded}"}, + }, + ] + ) + + def _probe_prompt_tokens(self, content: list[dict[str, Any]]) -> int: + """POST a 1-token chat completion and return reported prompt_tokens. + + Uses a generous timeout to absorb a cold model load on the first probe + when the server lazily loads models on demand (e.g. llama-swap). + """ + payload = { + "model": self.genai_config.model, + "messages": [{"role": "user", "content": content}], + "max_tokens": 1, + } + response = requests.post( + f"{self.provider}/v1/chat/completions", + json=payload, + timeout=60, + ) + response.raise_for_status() + return int(response.json()["usage"]["prompt_tokens"]) + def _build_payload( self, messages: list[dict[str, Any]], @@ -295,6 +508,8 @@ class LlamaCppClient(GenAIClient): } if stream: payload["stream"] = True + payload["stream_options"] = {"include_usage": True} + payload["timings_per_token"] = True if tools: payload["tools"] = tools if openai_tool_choice is not None: @@ -376,10 +591,11 @@ class LlamaCppClient(GenAIClient): jpeg_bytes = _to_jpeg(img) to_encode = jpeg_bytes if jpeg_bytes is not None else img encoded = base64.b64encode(to_encode).decode("utf-8") - # prompt_string must contain <__media__> placeholder for image tokenization + # prompt_string must contain the server's media marker placeholder. + # The marker is randomized per server startup (read from /props). content.append( { - "prompt_string": "<__media__>\n", + "prompt_string": f"{self._media_marker}\n", "multimodal_data": [encoded], # type: ignore[dict-item] } ) @@ -556,6 +772,9 @@ class LlamaCppClient(GenAIClient): data = json.loads(data_str) except json.JSONDecodeError: continue + maybe_stats = _stats_from_llama_cpp_chunk(data) + if maybe_stats is not None: + yield ("stats", maybe_stats) choices = data.get("choices") or [] if not choices: continue diff --git a/frigate/genai/ollama.py b/frigate/genai/ollama.py index 7524d54e38..fe286f64de 100644 --- a/frigate/genai/ollama.py +++ b/frigate/genai/ollama.py @@ -1,5 +1,7 @@ """Ollama Provider for Frigate AI.""" +import base64 +import binascii import json import logging from typing import Any, AsyncGenerator, Optional @@ -16,6 +18,72 @@ from frigate.genai.utils import parse_tool_calls_from_message logger = logging.getLogger(__name__) +def _extract_ollama_stats(response: Any) -> Optional[dict[str, Any]]: + """Build a stats dict from Ollama's response metadata. + + Ollama reports eval_count/eval_duration (generation) and + prompt_eval_count (context size). Durations are nanoseconds. + """ + if not response: + return None + if hasattr(response, "get"): + getter = response.get + else: + getter = lambda key: getattr(response, key, None) # noqa: E731 + + eval_count = getter("eval_count") + eval_duration_ns = getter("eval_duration") + prompt_eval_count = getter("prompt_eval_count") + if eval_count is None and prompt_eval_count is None: + return None + + stats: dict[str, Any] = {} + if isinstance(prompt_eval_count, int): + stats["prompt_tokens"] = prompt_eval_count + if isinstance(eval_count, int): + stats["completion_tokens"] = eval_count + if isinstance(eval_duration_ns, int) and eval_duration_ns > 0: + stats["completion_duration_ms"] = eval_duration_ns / 1_000_000 + if isinstance(eval_count, int) and eval_count > 0: + stats["tokens_per_second"] = eval_count / (eval_duration_ns / 1_000_000_000) + return stats or None + + +def _normalize_multimodal_content( + content: Any, +) -> tuple[Optional[str], Optional[list[bytes]]]: + """Convert OpenAI-style multimodal content to Ollama's (text, images) shape. + + The chat API constructs user messages with content as a list of + ``{"type": "text"}`` and ``{"type": "image_url"}`` parts when a tool + returns a live frame. Ollama's SDK requires content to be a string and + images to be passed in a separate field, so we extract each. + """ + if not isinstance(content, list): + return content, None + + text_parts: list[str] = [] + images: list[bytes] = [] + for part in content: + if not isinstance(part, dict): + continue + part_type = part.get("type") + if part_type == "text": + text = part.get("text") + if text: + text_parts.append(str(text)) + elif part_type == "image_url": + url = (part.get("image_url") or {}).get("url", "") + if isinstance(url, str) and url.startswith("data:"): + try: + encoded = url.split(",", 1)[1] + images.append(base64.b64decode(encoded, validate=True)) + except (ValueError, IndexError, binascii.Error) as e: + logger.debug("Failed to decode multimodal image url: %s", e) + + return ("\n".join(text_parts) if text_parts else None), (images or None) + + @register_genai_provider(GenAIProviderEnum.ollama) class OllamaClient(GenAIClient): """Generative AI client for Frigate using Ollama.""" @@ -31,6 +99,12 @@ class OllamaClient(GenAIClient): provider: ApiClient | None provider_options: dict[str, Any] + def _auth_headers(self) -> dict | None: + if self.genai_config.api_key: + return {"Authorization": "Bearer " + self.genai_config.api_key} + + return None + def _init_provider(self) -> ApiClient | None: """Initialize the client.""" self.provider_options = { @@ -39,7 +113,11 @@ class OllamaClient(GenAIClient): } try: - client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout) + client = ApiClient( + host=self.genai_config.base_url, + timeout=self.timeout, + headers=self._auth_headers(), + ) # ensure the model is available locally response = client.show(self.genai_config.model) if response.get("error"): @@ -113,6 +191,15 @@ class OllamaClient(GenAIClient): schema = response_format.get("json_schema", {}).get("schema") if schema: ollama_options["format"] = self._clean_schema_for_ollama(schema) + logger.debug( + "Ollama generate request: model=%s, prompt_len=%s, image_count=%s, " + "has_format=%s, options=%s", + self.genai_config.model, + len(prompt), + len(images) if images else 0, + "format" in ollama_options, + {k: v for k, v in ollama_options.items() if k != "format"}, + ) result = self.provider.generate( self.genai_config.model, prompt, @@ -120,9 +207,24 @@ class OllamaClient(GenAIClient): **ollama_options, ) logger.debug( - f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}" + "Ollama generate response: done=%s, done_reason=%s, eval_count=%s, " + "prompt_eval_count=%s, response_len=%s", + result.get("done"), + result.get("done_reason"), + result.get("eval_count"), + result.get("prompt_eval_count"), + len(result.get("response", "") or ""), ) - return str(result["response"]).strip() + response_text = str(result["response"]).strip() + if not response_text: + logger.warning( + "Ollama returned a blank response for model %s (done_reason=%s, " + "eval_count=%s). Check model output, ensure thinking is disabled.", + self.genai_config.model, + result.get("done_reason"), + result.get("eval_count"), + ) + return response_text except ( TimeoutException, ResponseError, @@ -142,7 +244,9 @@ class OllamaClient(GenAIClient): return [] try: client = ApiClient( - host=self.genai_config.base_url, timeout=self.timeout + host=self.genai_config.base_url, + timeout=self.timeout, + headers=self._auth_headers(), ) except Exception: return [] @@ -171,10 +275,13 @@ class OllamaClient(GenAIClient): """Build request_messages and params for chat (sync or stream).""" request_messages = [] for msg in messages: - msg_dict = { + content, images = _normalize_multimodal_content(msg.get("content", "")) + msg_dict: dict[str, Any] = { "role": msg.get("role"), - "content": msg.get("content", ""), + "content": content if content is not None else "", } + if images: + msg_dict["images"] = images if msg.get("tool_call_id"): msg_dict["tool_call_id"] = msg["tool_call_id"] if msg.get("name"): @@ -320,12 +427,16 @@ class OllamaClient(GenAIClient): async_client = OllamaAsyncClient( host=self.genai_config.base_url, timeout=self.timeout, + headers=self._auth_headers(), ) response = await async_client.chat(**request_params) result = self._message_from_response(response) content = result.get("content") if content: yield ("content_delta", content) + stats = _extract_ollama_stats(response) + if stats is not None: + yield ("stats", stats) yield ("message", result) return @@ -335,9 +446,11 @@ class OllamaClient(GenAIClient): async_client = OllamaAsyncClient( host=self.genai_config.base_url, timeout=self.timeout, + headers=self._auth_headers(), ) content_parts: list[str] = [] final_message: dict[str, Any] | None = None + final_chunk: Any = None stream = await async_client.chat(**request_params) async for chunk in stream: if not chunk or "message" not in chunk: @@ -348,6 +461,7 @@ class OllamaClient(GenAIClient): content_parts.append(delta) yield ("content_delta", delta) if chunk.get("done"): + final_chunk = chunk full_content = "".join(content_parts).strip() or None final_message = { "content": full_content, @@ -356,6 +470,10 @@ class OllamaClient(GenAIClient): } break + stats = _extract_ollama_stats(final_chunk) + if stats is not None: + yield ("stats", stats) + if final_message is not None: yield ("message", final_message) else: diff --git a/frigate/genai/openai.py b/frigate/genai/openai.py index 88108e730d..09e0cf5381 100644 --- a/frigate/genai/openai.py +++ b/frigate/genai/openai.py @@ -14,6 +14,22 @@ from frigate.genai import GenAIClient, register_genai_provider logger = logging.getLogger(__name__) +def _stats_from_openai_usage(usage: Any) -> Optional[dict[str, Any]]: + """Build a stats dict from an OpenAI-compatible usage object.""" + if usage is None: + return None + prompt_tokens = getattr(usage, "prompt_tokens", None) + completion_tokens = getattr(usage, "completion_tokens", None) + if prompt_tokens is None and completion_tokens is None: + return None + stats: dict[str, Any] = {} + if isinstance(prompt_tokens, int): + stats["prompt_tokens"] = prompt_tokens + if isinstance(completion_tokens, int): + stats["completion_tokens"] = completion_tokens + return stats or None + + @register_genai_provider(GenAIProviderEnum.openai) class OpenAIClient(GenAIClient): """Generative AI client for Frigate using OpenAI.""" @@ -73,14 +89,39 @@ class OpenAIClient(GenAIClient): **self.genai_config.runtime_options, } if response_format: + # OpenAI strict mode requires additionalProperties: false on the schema + if response_format.get("type") == "json_schema" and response_format.get( + "json_schema", {} + ).get("strict"): + schema = response_format.get("json_schema", {}).get("schema") + if isinstance(schema, dict): + schema["additionalProperties"] = False request_params["response_format"] = response_format + result = self.provider.chat.completions.create(**request_params) + if ( result is not None and hasattr(result, "choices") and len(result.choices) > 0 ): - return str(result.choices[0].message.content.strip()) + message = result.choices[0].message + content = message.content + + if not content: + # When reasoning is enabled for some OpenAI backends the actual response + # is incorrectly placed in reasoning_content instead of content. + # This is buggy/incorrect behavior — reasoning should not be + # enabled for these models. + reasoning_content = getattr(message, "reasoning_content", None) + if reasoning_content: + logger.warning( + "Response content was empty but reasoning_content was provided; " + "reasoning appears to be enabled and should be disabled for this model." + ) + content = reasoning_content + + return str(content.strip()) if content else None return None except (TimeoutException, Exception) as e: logger.warning("OpenAI returned an error: %s", str(e)) @@ -273,6 +314,7 @@ class OpenAIClient(GenAIClient): "messages": messages, "timeout": self.timeout, "stream": True, + "stream_options": {"include_usage": True}, } if tools: @@ -293,10 +335,15 @@ class OpenAIClient(GenAIClient): content_parts: list[str] = [] tool_calls_by_index: dict[int, dict[str, Any]] = {} finish_reason = "stop" + usage_stats: Optional[dict[str, Any]] = None stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload] for chunk in stream: + chunk_usage = getattr(chunk, "usage", None) + if chunk_usage is not None: + usage_stats = _stats_from_openai_usage(chunk_usage) + if not chunk or not chunk.choices: continue @@ -356,6 +403,9 @@ class OpenAIClient(GenAIClient): ) finish_reason = "tool_calls" + if usage_stats is not None: + yield ("stats", usage_stats) + yield ( "message", { diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py new file mode 100644 index 0000000000..0616c46290 --- /dev/null +++ b/frigate/jobs/debug_replay.py @@ -0,0 +1,386 @@ +"""Debug replay startup job: ffmpeg concat + camera config publish. + +The runner orchestrates the async portion of starting a debug replay +session. The DebugReplayManager (in frigate.debug_replay) owns session +presence so the status bar can keep reading a single `active` flag from +/debug_replay/status for the entire session window — which is broader +than this job's lifetime. +""" + +import logging +import os +import subprocess as sp +import threading +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional, cast + +from peewee import ModelSelect + +from frigate.config import FrigateConfig +from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR +from frigate.jobs.export import JobStatePublisher +from frigate.jobs.job import Job +from frigate.jobs.manager import job_is_running, set_current_job +from frigate.models import Recordings +from frigate.types import JobStatusTypesEnum +from frigate.util.ffmpeg import run_ffmpeg_with_progress + +if TYPE_CHECKING: + from frigate.debug_replay import DebugReplayManager + +logger = logging.getLogger(__name__) + +# Coalesce frequent ffmpeg progress callbacks so the WS isn't flooded. +PROGRESS_BROADCAST_MIN_INTERVAL = 1.0 + +JOB_TYPE = "debug_replay" + +STEP_PREPARING_CLIP = "preparing_clip" +STEP_STARTING_CAMERA = "starting_camera" + + +_active_runner: Optional["DebugReplayJobRunner"] = None +_runner_lock = threading.Lock() + + +def _set_active_runner(runner: Optional["DebugReplayJobRunner"]) -> None: + global _active_runner + with _runner_lock: + _active_runner = runner + + +def get_active_runner() -> Optional["DebugReplayJobRunner"]: + with _runner_lock: + return _active_runner + + +@dataclass +class DebugReplayJob(Job): + """Job state for a debug replay startup.""" + + job_type: str = JOB_TYPE + source_camera: str = "" + replay_camera_name: str = "" + start_ts: float = 0.0 + end_ts: float = 0.0 + current_step: Optional[str] = None + progress_percent: float = 0.0 + + def to_dict(self) -> dict[str, Any]: + """Whitelisted payload for the job_state WS topic. + + Replay-specific fields land in results so the frontend's + generic Job type can be parameterised cleanly. + """ + return { + "id": self.id, + "job_type": self.job_type, + "status": self.status, + "start_time": self.start_time, + "end_time": self.end_time, + "error_message": self.error_message, + "results": { + "current_step": self.current_step, + "progress_percent": self.progress_percent, + "source_camera": self.source_camera, + "replay_camera_name": self.replay_camera_name, + "start_ts": self.start_ts, + "end_ts": self.end_ts, + }, + } + + +def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect: + """Return the Recordings query for the time range. + + Module-level so tests can patch it without instantiating a runner. + """ + query = ( + 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 == source_camera) + .order_by(Recordings.start_time.asc()) + ) + return cast(ModelSelect, query) + + +class DebugReplayJobRunner(threading.Thread): + """Worker thread that drives the startup job to completion. + + Owns the live ffmpeg Popen reference for cancellation. Cancellation + is two-step (threading.Event + proc.terminate()) so the runner + both knows it should stop and is unblocked from its blocking subprocess + wait. + """ + + def __init__( + self, + job: DebugReplayJob, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + replay_manager: "DebugReplayManager", + publisher: Optional[JobStatePublisher] = None, + ) -> None: + super().__init__(daemon=True, name=f"debug_replay_{job.id}") + self.job = job + self.frigate_config = frigate_config + self.config_publisher = config_publisher + self.replay_manager = replay_manager + self.publisher = publisher if publisher is not None else JobStatePublisher() + self._cancel_event = threading.Event() + self._active_process: sp.Popen | None = None + self._proc_lock = threading.Lock() + self._last_broadcast_monotonic: float = 0.0 + + def cancel(self) -> None: + """Request cancellation. Idempotent.""" + self._cancel_event.set() + with self._proc_lock: + proc = self._active_process + if proc is not None: + try: + proc.terminate() + except Exception as exc: + logger.warning("Failed to terminate ffmpeg subprocess: %s", exc) + + def is_cancelled(self) -> bool: + return self._cancel_event.is_set() + + def _record_proc(self, proc: sp.Popen) -> None: + with self._proc_lock: + self._active_process = proc + # Race: cancel arrived between Popen and _record_proc. + if self._cancel_event.is_set(): + try: + proc.terminate() + except Exception: + pass + + def _broadcast(self, force: bool = False) -> None: + now = time.monotonic() + if ( + not force + and now - self._last_broadcast_monotonic < PROGRESS_BROADCAST_MIN_INTERVAL + ): + return + self._last_broadcast_monotonic = now + + try: + self.publisher.publish(self.job.to_dict()) + except Exception as err: + logger.warning("Publisher raised during job state broadcast: %s", err) + + def run(self) -> None: + replay_name = self.job.replay_camera_name + os.makedirs(REPLAY_DIR, exist_ok=True) + concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt") + clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4") + + self.job.status = JobStatusTypesEnum.running + self.job.start_time = time.time() + self.job.current_step = STEP_PREPARING_CLIP + self._broadcast(force=True) + + try: + recordings = query_recordings( + self.job.source_camera, self.job.start_ts, self.job.end_ts + ) + with open(concat_file, "w") as f: + for recording in recordings: + f.write(f"file '{recording.path}'\n") + + ffmpeg_cmd = [ + self.frigate_config.ffmpeg.ffmpeg_path, + "-hide_banner", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + concat_file, + "-c", + "copy", + "-movflags", + "+faststart", + clip_path, + ] + + logger.info( + "Generating replay clip for %s (%.1f - %.1f)", + self.job.source_camera, + self.job.start_ts, + self.job.end_ts, + ) + + def _on_progress(percent: float) -> None: + self.job.progress_percent = percent + self._broadcast() + + try: + returncode, stderr = run_ffmpeg_with_progress( + ffmpeg_cmd, + expected_duration_seconds=max( + 0.0, self.job.end_ts - self.job.start_ts + ), + on_progress=_on_progress, + process_started=self._record_proc, + use_low_priority=True, + ) + finally: + with self._proc_lock: + self._active_process = None + + if self._cancel_event.is_set(): + self._finalize_cancelled(clip_path) + return + + if returncode != 0: + raise RuntimeError(f"FFmpeg failed: {stderr[-500:]}") + + if not os.path.exists(clip_path): + raise RuntimeError("Clip file was not created") + + self.job.current_step = STEP_STARTING_CAMERA + self.job.progress_percent = 100.0 + self._broadcast(force=True) + + if self._cancel_event.is_set(): + self._finalize_cancelled(clip_path) + return + + self.replay_manager.publish_camera( + source_camera=self.job.source_camera, + replay_name=replay_name, + clip_path=clip_path, + frigate_config=self.frigate_config, + config_publisher=self.config_publisher, + ) + self.replay_manager.mark_session_ready(clip_path) + + self.job.status = JobStatusTypesEnum.success + self.job.end_time = time.time() + self._broadcast(force=True) + logger.info( + "Debug replay started: %s -> %s", + self.job.source_camera, + replay_name, + ) + except Exception as exc: + logger.exception("Debug replay startup failed") + self.job.status = JobStatusTypesEnum.failed + self.job.error_message = str(exc) + self.job.end_time = time.time() + self._broadcast(force=True) + self.replay_manager.clear_session() + _remove_silent(clip_path) + finally: + _remove_silent(concat_file) + _set_active_runner(None) + + def _finalize_cancelled(self, clip_path: str) -> None: + logger.info("Debug replay startup cancelled") + self.job.status = JobStatusTypesEnum.cancelled + self.job.end_time = time.time() + self._broadcast(force=True) + # The caller of cancel_debug_replay_job (DebugReplayManager.stop) owns + # session cleanup — db rows, filesystem artifacts, clear_session. We + # only clean up the partial concat output we created. + _remove_silent(clip_path) + + +def _remove_silent(path: str) -> None: + try: + if os.path.exists(path): + os.remove(path) + except OSError: + pass + + +def start_debug_replay_job( + *, + source_camera: str, + start_ts: float, + end_ts: float, + frigate_config: FrigateConfig, + config_publisher: CameraConfigUpdatePublisher, + replay_manager: "DebugReplayManager", +) -> str: + """Validate, create job, start runner. Returns the job id. + + Raises ValueError for bad params (camera missing, time range + invalid, no recordings) and RuntimeError if a session is already + active. + """ + if job_is_running(JOB_TYPE) or replay_manager.active: + raise RuntimeError("A replay session is already active") + + if source_camera not in frigate_config.cameras: + raise ValueError(f"Camera '{source_camera}' not found") + + if end_ts <= start_ts: + raise ValueError("End time must be after start time") + + recordings = query_recordings(source_camera, start_ts, end_ts) + if not recordings.count(): + raise ValueError( + f"No recordings found for camera '{source_camera}' in the specified time range" + ) + + replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}" + replay_manager.mark_starting( + source_camera=source_camera, + replay_camera_name=replay_name, + start_ts=start_ts, + end_ts=end_ts, + ) + + job = DebugReplayJob( + source_camera=source_camera, + replay_camera_name=replay_name, + start_ts=start_ts, + end_ts=end_ts, + ) + set_current_job(job) + + runner = DebugReplayJobRunner( + job=job, + frigate_config=frigate_config, + config_publisher=config_publisher, + replay_manager=replay_manager, + ) + _set_active_runner(runner) + runner.start() + + return job.id + + +def cancel_debug_replay_job() -> bool: + """Signal the active runner to cancel. + + Returns True if a runner was signalled, False if no job was active. + """ + runner = get_active_runner() + if runner is None: + return False + runner.cancel() + return True + + +def wait_for_runner(timeout: float = 2.0) -> bool: + """Join the active runner. Returns True if the runner ended in time.""" + runner = get_active_runner() + if runner is None: + return True + runner.join(timeout=timeout) + return not runner.is_alive() diff --git a/frigate/jobs/vlm_watch.py b/frigate/jobs/vlm_watch.py index cd64325d0d..41ed830f14 100644 --- a/frigate/jobs/vlm_watch.py +++ b/frigate/jobs/vlm_watch.py @@ -45,6 +45,7 @@ class VLMWatchJob(Job): last_reasoning: str = "" notification_message: str = "" iteration_count: int = 0 + username: str = "" def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -374,6 +375,7 @@ def start_vlm_watch_job( dispatcher: Any, labels: list[str] | None = None, zones: list[str] | None = None, + username: str = "", ) -> str: """Start a new VLM watch job. Returns the job ID. @@ -397,6 +399,7 @@ def start_vlm_watch_job( max_duration_minutes=max_duration_minutes, labels=labels or [], zones=zones or [], + username=username, ) cancel_ev = threading.Event() _current_job = job diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 8b0fea6d7b..477e9a0db7 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -8,7 +8,6 @@ import os import queue import subprocess as sp import threading -import time import traceback from multiprocessing.synchronize import Event as MpEvent from typing import Any, Optional @@ -19,6 +18,7 @@ import numpy as np from frigate.comms.inter_process import InterProcessRequestor from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT +from frigate.output.ws_auth import ws_has_camera_access from frigate.util.image import ( SharedMemoryFrameManager, copy_yuv_to_position, @@ -62,8 +62,10 @@ def get_canvas_shape(width: int, height: int) -> tuple[int, int]: if round(a_w / a_h, 2) != round(width / height, 2): canvas_width = int(width // 4 * 4) canvas_height = int((canvas_width / a_w * a_h) // 4 * 4) - logger.warning( - f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}" + logger.error( + f"Birdseye resolution {width}x{height} is not a supported aspect ratio " + f"and may cause visual distortion; falling back to {canvas_width}x{canvas_height}. " + f"Set width and height to a supported aspect ratio (16:9, 20:10, 16:6, 32:9, 12:9, 22:15, 9:16, 9:12, 16:3, or 1:1)" ) return (canvas_width, canvas_height) @@ -236,12 +238,14 @@ class BroadcastThread(threading.Thread): converter: FFMpegConverter, websocket_server: Any, stop_event: MpEvent, + config: FrigateConfig, ): super().__init__() self.camera = camera self.converter = converter self.websocket_server = websocket_server self.stop_event = stop_event + self.config = config def run(self) -> None: while not self.stop_event.is_set(): @@ -256,6 +260,7 @@ class BroadcastThread(threading.Thread): if ( not ws.terminated and ws.environ["PATH_INFO"] == f"/{self.camera}" + and ws_has_camera_access(ws, self.camera, self.config) ): try: ws.send(buf, binary=True) @@ -793,20 +798,27 @@ class Birdseye: websocket_server: Any, ) -> None: self.config = config + canvas_width, canvas_height = get_canvas_shape( + config.birdseye.width, config.birdseye.height + ) self.input: queue.Queue[bytes] = queue.Queue(maxsize=10) self.converter = FFMpegConverter( config.ffmpeg, self.input, stop_event, - config.birdseye.width, - config.birdseye.height, - config.birdseye.width, - config.birdseye.height, + canvas_width, + canvas_height, + canvas_width, + canvas_height, config.birdseye.quality, config.birdseye.restream, ) self.broadcaster = BroadcastThread( - "birdseye", self.converter, websocket_server, stop_event + "birdseye", + self.converter, + websocket_server, + stop_event, + config, ) self.birdseye_manager = BirdsEyeFrameManager(self.config, stop_event) self.frame_manager = SharedMemoryFrameManager() @@ -874,7 +886,7 @@ class Birdseye: coordinates = self.birdseye_manager.get_camera_coordinates() self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates) if self._idle_interval: - now = time.monotonic() + now = datetime.datetime.now().timestamp() is_idle = len(self.birdseye_manager.camera_layout) == 0 if ( is_idle diff --git a/frigate/output/camera.py b/frigate/output/camera.py index 917e38dd1d..88d16ed4b4 100644 --- a/frigate/output/camera.py +++ b/frigate/output/camera.py @@ -7,7 +7,8 @@ import threading from multiprocessing.synchronize import Event as MpEvent from typing import Any -from frigate.config import CameraConfig, FfmpegConfig +from frigate.config import CameraConfig, FfmpegConfig, FrigateConfig +from frigate.output.ws_auth import ws_has_camera_access logger = logging.getLogger(__name__) @@ -102,12 +103,14 @@ class BroadcastThread(threading.Thread): converter: FFMpegConverter, websocket_server: Any, stop_event: MpEvent, + config: FrigateConfig, ): super().__init__() self.camera = camera self.converter = converter self.websocket_server = websocket_server self.stop_event = stop_event + self.config = config def run(self) -> None: while not self.stop_event.is_set(): @@ -122,6 +125,7 @@ class BroadcastThread(threading.Thread): if ( not ws.terminated and ws.environ["PATH_INFO"] == f"/{self.camera}" + and ws_has_camera_access(ws, self.camera, self.config) ): try: ws.send(buf, binary=True) @@ -135,7 +139,11 @@ class BroadcastThread(threading.Thread): class JsmpegCamera: def __init__( - self, config: CameraConfig, stop_event: MpEvent, websocket_server: Any + self, + config: CameraConfig, + frigate_config: FrigateConfig, + stop_event: MpEvent, + websocket_server: Any, ) -> None: self.config = config self.input: queue.Queue[bytes] = queue.Queue(maxsize=config.detect.fps) @@ -154,7 +162,11 @@ class JsmpegCamera: config.live.quality, ) self.broadcaster = BroadcastThread( - config.name or "", self.converter, websocket_server, stop_event + config.name or "", + self.converter, + websocket_server, + stop_event, + frigate_config, ) self.converter.start() diff --git a/frigate/output/output.py b/frigate/output/output.py index 22bcbb31ff..79ef349c8f 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -32,6 +32,7 @@ from frigate.const import ( from frigate.output.birdseye import Birdseye from frigate.output.camera import JsmpegCamera from frigate.output.preview import PreviewRecorder +from frigate.output.ws_auth import ws_has_camera_access from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame from frigate.util.process import FrigateProcess @@ -102,7 +103,7 @@ class OutputProcess(FrigateProcess): ) -> None: camera_config = self.config.cameras[camera] jsmpeg_cameras[camera] = JsmpegCamera( - camera_config, self.stop_event, websocket_server + camera_config, self.config, self.stop_event, websocket_server ) preview_recorders[camera] = PreviewRecorder(camera_config) preview_write_times[camera] = 0 @@ -262,6 +263,7 @@ class OutputProcess(FrigateProcess): # send camera frame to ffmpeg process if websockets are connected if any( ws.environ["PATH_INFO"].endswith(camera) + and ws_has_camera_access(ws, camera, self.config) for ws in websocket_server.manager ): # write to the converter for the camera if clients are listening to the specific camera @@ -275,6 +277,7 @@ class OutputProcess(FrigateProcess): self.config.birdseye.restream or any( ws.environ["PATH_INFO"].endswith("birdseye") + and ws_has_camera_access(ws, "birdseye", self.config) for ws in websocket_server.manager ) ) @@ -346,6 +349,13 @@ def move_preview_frames(loc: str) -> None: if not os.path.exists(preview_holdover): return + if not os.access(preview_holdover, os.R_OK | os.W_OK): + logger.error( + "Insufficient permissions on preview restart cache at %s", + preview_holdover, + ) + return + shutil.move(preview_holdover, preview_cache) except shutil.Error: logger.error("Failed to restore preview cache.") diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 389a3c2078..bf3c4bc7ef 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -361,14 +361,17 @@ class PreviewRecorder: small_frame, cv2.COLOR_YUV2BGR_I420, ) - cv2.imwrite( - get_cache_image_name(self.camera_name, frame_time), + cache_path = get_cache_image_name(self.camera_name, frame_time) + + if not cv2.imwrite( + cache_path, small_frame, [ int(cv2.IMWRITE_WEBP_QUALITY), PREVIEW_QUALITY_WEBP[self.config.record.preview.quality], ], - ) + ): + logger.error("Failed to write preview frame to %s", cache_path) def write_data( self, diff --git a/frigate/output/ws_auth.py b/frigate/output/ws_auth.py new file mode 100644 index 0000000000..33ec4e4980 --- /dev/null +++ b/frigate/output/ws_auth.py @@ -0,0 +1,43 @@ +"""Authorization helpers for JSMPEG websocket clients.""" + +from typing import Any + +from frigate.config import FrigateConfig +from frigate.models import User + + +def _get_valid_ws_roles(ws: Any, config: FrigateConfig) -> list[str]: + role_header = ws.environ.get("HTTP_REMOTE_ROLE", "") + roles = [ + role.strip() + for role in role_header.split(config.proxy.separator) + if role.strip() + ] + return [role for role in roles if role in config.auth.roles] + + +def ws_has_camera_access(ws: Any, camera_name: str, config: FrigateConfig) -> bool: + """Return True when a websocket client is authorized for the camera path.""" + roles = _get_valid_ws_roles(ws, config) + + if not roles: + return False + + roles_dict = config.auth.roles + + # Birdseye is a composite stream, so only users with unrestricted access + # should receive it. + if camera_name == "birdseye": + return any(role == "admin" or not roles_dict.get(role) for role in roles) + + all_camera_names = set(config.cameras.keys()) + + for role in roles: + if role == "admin" or not roles_dict.get(role): + return True + + allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + if camera_name in allowed_cameras: + return True + + return False diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index e41a5bf393..71097f1d95 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -351,9 +351,11 @@ class RecordingCleanup(threading.Thread): ) .where( ReviewSegment.camera == camera, - # need to ensure segments for all reviews starting - # before the expire date are included - ReviewSegment.start_time < motion_expire_date, + # candidate recordings can extend up to continuous_expire_date + # (the no-motion no-audio branch of the recordings query), + # so reviews must cover that full range to avoid deleting + # segments that overlap recent alerts/detections. + ReviewSegment.start_time < continuous_expire_date, ) .order_by(ReviewSegment.start_time) .namedtuples() diff --git a/frigate/record/export.py b/frigate/record/export.py index 9d7a9eb0c9..9f571a5a5c 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -13,6 +13,7 @@ from enum import Enum from pathlib import Path from typing import Callable, Optional +import pytz # type: ignore[import-untyped] from peewee import DoesNotExist from frigate.config import FfmpegConfig, FrigateConfig @@ -22,13 +23,13 @@ from frigate.const import ( EXPORT_DIR, MAX_PLAYLIST_SECONDS, PREVIEW_FRAME_TYPE, - PROCESS_PRIORITY_LOW, ) from frigate.ffmpeg_presets import ( EncodeTypeEnum, parse_preset_hardware_acceleration_encode, ) -from frigate.models import Export, Previews, Recordings +from frigate.models import Export, Previews, Recordings, ReviewSegment +from frigate.util.ffmpeg import run_ffmpeg_with_progress from frigate.util.time import is_current_hour logger = logging.getLogger(__name__) @@ -242,111 +243,177 @@ class RecordingExporter(threading.Thread): return total - def _inject_progress_flags(self, ffmpeg_cmd: list[str]) -> list[str]: - """Insert FFmpeg progress reporting flags before the output path. - - ``-progress pipe:2`` writes structured key=value lines to stderr, - ``-nostats`` suppresses the noisy default stats output. - """ - if not ffmpeg_cmd: - return ffmpeg_cmd - return ffmpeg_cmd[:-1] + ["-progress", "pipe:2", "-nostats", ffmpeg_cmd[-1]] - def _run_ffmpeg_with_progress( self, ffmpeg_cmd: list[str], playlist_lines: str | list[str], step: str = "encoding", ) -> tuple[int, str]: - """Run an FFmpeg export command, parsing progress events from stderr. + """Delegate to the shared helper, mapping percent → (step, percent). - Returns ``(returncode, captured_stderr)``. Stdout is left attached to - the parent process so we don't have to drain it (and risk a deadlock - if the buffer fills). Progress percent is computed against the - expected output duration; values are clamped to [0, 100] inside - :py:meth:`_emit_progress`. + Returns ``(returncode, captured_stderr)``. """ - cmd = ["nice", "-n", str(PROCESS_PRIORITY_LOW)] + self._inject_progress_flags( - ffmpeg_cmd - ) - if isinstance(playlist_lines, list): stdin_payload = "\n".join(playlist_lines) else: stdin_payload = playlist_lines - expected_duration = self._expected_output_duration_seconds() - - self._emit_progress(step, 0.0) - - proc = sp.Popen( - cmd, - stdin=sp.PIPE, - stderr=sp.PIPE, - text=True, - encoding="ascii", - errors="replace", + return run_ffmpeg_with_progress( + ffmpeg_cmd, + expected_duration_seconds=self._expected_output_duration_seconds(), + on_progress=lambda percent: self._emit_progress(step, percent), + stdin_payload=stdin_payload, + use_low_priority=True, ) - assert proc.stdin is not None - assert proc.stderr is not None - - try: - proc.stdin.write(stdin_payload) - except (BrokenPipeError, OSError): - # FFmpeg may have rejected the input early; still wait for it - # to terminate so the returncode is meaningful. - pass - finally: - try: - proc.stdin.close() - except (BrokenPipeError, OSError): - pass - - captured: list[str] = [] - - try: - for raw_line in proc.stderr: - captured.append(raw_line) - line = raw_line.strip() - - if not line: - continue - - if line.startswith("out_time_us="): - if expected_duration <= 0: - continue - try: - out_time_us = int(line.split("=", 1)[1]) - except (ValueError, IndexError): - continue - if out_time_us < 0: - continue - out_seconds = out_time_us / 1_000_000.0 - percent = (out_seconds / expected_duration) * 100.0 - self._emit_progress(step, percent) - elif line == "progress=end": - self._emit_progress(step, 100.0) - break - except Exception: - logger.exception("Failed reading FFmpeg progress for %s", self.export_id) - - proc.wait() - - # Drain any remaining stderr so callers can log it on failure. - try: - remaining = proc.stderr.read() - if remaining: - captured.append(remaining) - except Exception: - pass - - return proc.returncode, "".join(captured) - def get_datetime_from_timestamp(self, timestamp: int) -> str: - # return in iso format + # return in iso format using the configured ui.timezone when set, + # so the auto-generated export name reflects local time rather + # than the container's UTC clock + tz_name = self.config.ui.timezone + if tz_name: + try: + tz = pytz.timezone(tz_name) + except pytz.UnknownTimeZoneError: + tz = None + if tz is not None: + return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime( + "%Y-%m-%d %H:%M:%S" + ) return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + def _chapter_metadata_path(self) -> str: + return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt") + + def _build_chapter_metadata_file(self, recordings: list) -> Optional[str]: + """Write an FFmpeg metadata file with chapters for review items in range. + + Chapter offsets are computed in *output time*: the VOD endpoint + concatenates recording clips back-to-back, so wall-clock gaps + between recordings collapse in the produced video. We walk the + same recording rows that feed the playlist and convert each + review item's wall-clock boundaries into output-time offsets. + Returns ``None`` when there are no recordings, no review items, + or any chapter would have zero output duration. + """ + if not recordings: + return None + + windows: list[tuple[float, float, float]] = [] + output_offset = 0.0 + for rec in recordings: + clipped_start = max(float(rec.start_time), float(self.start_time)) + clipped_end = min(float(rec.end_time), float(self.end_time)) + if clipped_end <= clipped_start: + continue + windows.append((clipped_start, clipped_end, output_offset)) + output_offset += clipped_end - clipped_start + + if not windows: + return None + + try: + review_rows = list( + ReviewSegment.select( + ReviewSegment.start_time, + ReviewSegment.end_time, + ReviewSegment.severity, + ReviewSegment.data, + ) + .where( + ReviewSegment.start_time.between(self.start_time, self.end_time) + | ReviewSegment.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > ReviewSegment.start_time) + & (self.end_time < ReviewSegment.end_time) + ) + ) + .where(ReviewSegment.camera == self.camera) + .order_by(ReviewSegment.start_time.asc()) + .iterator() + ) + except Exception: + logger.exception( + "Failed to query review segments for export %s", self.export_id + ) + return None + + if not review_rows: + return None + + total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0]) + last_recorded_end = windows[-1][1] + + def wall_to_output(t: float) -> float: + t = max(float(self.start_time), min(float(self.end_time), t)) + for w_start, w_end, w_offset in windows: + if t < w_start: + return w_offset + if t <= w_end: + return w_offset + (t - w_start) + return total_output + + chapter_blocks: list[str] = [] + for review in review_rows: + if review.start_time is None: + continue + # In-progress segments have a NULL end_time until the activity + # closes; clamp to the last recorded second so the chapter never + # extends past the actual video. + review_end = ( + float(review.end_time) + if review.end_time is not None + else last_recorded_end + ) + start_out = wall_to_output(float(review.start_time)) + end_out = wall_to_output(review_end) + + # Drop chapters that fall entirely in a recording gap, or are + # too short to be navigable in a player. + if end_out - start_out < 1.0: + continue + + data = review.data or {} + labels: list[str] = [] + for obj in data.get("objects") or []: + label = str(obj).split("-")[0] + if label and label not in labels: + labels.append(label) + + metadata = data.get("metadata") or {} + title = metadata.get("title") + + if not title: + title = str(review.severity).capitalize() + + if labels: + title = f"{title}: {', '.join(labels)}" + + chapter_blocks.append( + "[CHAPTER]\n" + "TIMEBASE=1/1000\n" + f"START={int(start_out * 1000)}\n" + f"END={int(end_out * 1000)}\n" + f"title={title}" + ) + + if not chapter_blocks: + return None + + meta_path = self._chapter_metadata_path() + try: + with open(meta_path, "w", encoding="utf-8") as f: + f.write(";FFMETADATA1\n") + f.write("\n".join(chapter_blocks)) + f.write("\n") + except OSError: + logger.exception( + "Failed to write chapter metadata file for export %s", self.export_id + ) + return None + + return meta_path + def save_thumbnail(self, id: str) -> str: thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp") @@ -387,16 +454,14 @@ class RecordingExporter(threading.Thread): except DoesNotExist: return "" - diff = self.start_time - preview.start_time - minutes = int(diff / 60) - seconds = int(diff % 60) + diff = max(0.0, float(self.start_time) - float(preview.start_time)) ffmpeg_cmd = [ "/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support "-hide_banner", "-loglevel", "warning", "-ss", - f"00:{minutes}:{seconds}", + f"{diff:.3f}", "-i", preview.path, "-frames", @@ -422,12 +487,18 @@ class RecordingExporter(threading.Thread): start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}" selected_preview = None + # Preview frames are written at most 1-2 fps during activity + # and as little as one every 30s during quiet periods, so a + # short export window can contain zero frames. Track the most + # recent frame before the window as a fallback. + fallback_preview = None for file in sorted(os.listdir(preview_dir)): if not file.startswith(file_start): continue if file < start_file: + fallback_preview = os.path.join(preview_dir, file) continue if file > end_file: @@ -436,6 +507,9 @@ class RecordingExporter(threading.Thread): selected_preview = os.path.join(preview_dir, file) break + if not selected_preview: + selected_preview = fallback_preview + if not selected_preview: return "" @@ -451,6 +525,24 @@ class RecordingExporter(threading.Thread): if type(internal_port) is str: internal_port = int(internal_port.split(":")[-1]) + recordings = list( + Recordings.select( + Recordings.start_time, + Recordings.end_time, + ) + .where( + Recordings.start_time.between(self.start_time, self.end_time) + | Recordings.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > Recordings.start_time) + & (self.end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == self.camera) + .order_by(Recordings.start_time.asc()) + .iterator() + ) + playlist_lines: list[str] = [] if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" @@ -458,32 +550,13 @@ class RecordingExporter(threading.Thread): f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}" ) else: - # get full set of recordings - export_recordings = ( - Recordings.select( - Recordings.start_time, - Recordings.end_time, - ) - .where( - Recordings.start_time.between(self.start_time, self.end_time) - | Recordings.end_time.between(self.start_time, self.end_time) - | ( - (self.start_time > Recordings.start_time) - & (self.end_time < Recordings.end_time) - ) - ) - .where(Recordings.camera == self.camera) - .order_by(Recordings.start_time.asc()) - ) - - # Use pagination to process records in chunks + # Chunk the recording rows into pages so each playlist line + # references a bounded sub-range rather than the full export. page_size = 1000 - num_pages = (export_recordings.count() + page_size - 1) // page_size - - for page in range(1, num_pages + 1): - playlist = export_recordings.paginate(page, page_size) + for i in range(0, len(recordings), page_size): + chunk = recordings[i : i + page_size] playlist_lines.append( - f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'" + f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(chunk[0].start_time)}/end/{float(chunk[-1].end_time)}/index.m3u8'" ) ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin" @@ -504,8 +577,12 @@ class RecordingExporter(threading.Thread): ) ).split(" ") else: + chapters_path = self._build_chapter_metadata_file(recordings) + chapter_args = ( + f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else "" + ) ffmpeg_cmd = ( - f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart" + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart" ).split(" ") # add metadata @@ -691,6 +768,8 @@ class RecordingExporter(threading.Thread): ffmpeg_cmd, playlist_lines, step="encoding_retry" ) + Path(self._chapter_metadata_path()).unlink(missing_ok=True) + if returncode != 0: logger.error( f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}" diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 6d25622f49..62d4ad8cb8 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -610,8 +610,7 @@ class RecordingMaintainer(threading.Thread): camera, ) - if not os.path.exists(directory): - os.makedirs(directory) + os.makedirs(directory, exist_ok=True) # file will be in utc due to start_time being in utc file_name = f"{start_time.strftime('%M.%S.mp4')}" diff --git a/frigate/stats/intel_gpu_info.py b/frigate/stats/intel_gpu_info.py new file mode 100644 index 0000000000..5ca3066fbd --- /dev/null +++ b/frigate/stats/intel_gpu_info.py @@ -0,0 +1,109 @@ +"""Resolve human-readable names for Intel GPUs via OpenVINO.""" + +import logging +import re +from typing import Optional + +logger = logging.getLogger(__name__) + + +class IntelGpuNameResolver: + """Build a pdev -> normalized device name map by enumerating OpenVINO GPUs. + + The lookup is performed once on first access and cached for the process + lifetime. OpenVINO exposes DEVICE_PCI_INFO (domain/bus/device/function) and + FULL_DEVICE_NAME for each GPU it can see, which is enough to associate the + name with the pdev string used by DRM fdinfo. + """ + + _names: Optional[dict[str, str]] = None + + def get_names(self) -> dict[str, str]: + if self._names is not None: + return self._names + + names: dict[str, str] = {} + + try: + from openvino import Core + except ImportError: + logger.debug("OpenVINO unavailable; cannot resolve Intel GPU names") + self._names = names + return names + + try: + core = Core() + devices = core.available_devices + except Exception as exc: + logger.debug(f"OpenVINO Core initialization failed: {exc}") + self._names = names + return names + + cpu_name: Optional[str] = None + if "CPU" in devices: + try: + cpu_name = self._strip_trademarks( + core.get_property("CPU", "FULL_DEVICE_NAME") + ) + except Exception as exc: + logger.debug(f"Failed to read CPU FULL_DEVICE_NAME: {exc}") + + for device in devices: + if not device.startswith("GPU"): + continue + + try: + pci = core.get_property(device, "DEVICE_PCI_INFO") + raw_name = core.get_property(device, "FULL_DEVICE_NAME") + device_type = core.get_property(device, "DEVICE_TYPE") + except Exception as exc: + logger.debug(f"Failed to read properties for {device}: {exc}") + continue + + pdev = self._format_pdev(pci) + if not pdev: + continue + + names[pdev] = self._resolve_name(raw_name, device_type, cpu_name) + + self._names = names + return names + + @staticmethod + def _format_pdev(pci) -> Optional[str]: + try: + return f"{pci.domain:04x}:{pci.bus:02x}:{pci.device:02x}.{pci.function:x}" + except AttributeError: + return None + + @classmethod + def _resolve_name(cls, raw_name: str, device_type, cpu_name: Optional[str]) -> str: + """Build a display name for a GPU. + + Modern integrated Intel GPUs are reported by OpenVINO with a generic + FULL_DEVICE_NAME like "Intel(R) Graphics (iGPU)" that gives no model + information. Since the iGPU is part of the CPU on these platforms, fall + back to the CPU name (which OpenVINO does report specifically) and + suffix it with "iGPU" so it's clear what the entry is. + """ + is_integrated = "INTEGRATED" in str(device_type).upper() + + if is_integrated and cpu_name: + short_cpu = re.sub(r"^Intel\s+", "", cpu_name) + return f"{short_cpu} iGPU" + + return cls._normalize_name(raw_name) + + @classmethod + def _normalize_name(cls, name: str) -> str: + cleaned = cls._strip_trademarks(name) + cleaned = re.sub(r"\s*\((?:i|d)GPU\)\s*$", "", cleaned, flags=re.IGNORECASE) + return " ".join(cleaned.split()) + + @staticmethod + def _strip_trademarks(name: str) -> str: + cleaned = re.sub(r"\(R\)|\(TM\)", "", name) + return " ".join(cleaned.split()) + + +intel_gpu_name_resolver = IntelGpuNameResolver() diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 07b410ad21..a0141d1305 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -230,6 +230,7 @@ async def set_gpu_stats( hwaccel_args.append(args) stats: dict[str, dict] = {} + intel_gpu_collected = False for args in hwaccel_args: if args in hwaccel_errors: @@ -242,6 +243,7 @@ async def set_gpu_stats( if nvidia_usage: for i in range(len(nvidia_usage)): stats[nvidia_usage[i]["name"]] = { + "vendor": "nvidia", "gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%", "mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%", "enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%", @@ -250,31 +252,34 @@ async def set_gpu_stats( } else: - stats["nvidia-gpu"] = {"gpu": "", "mem": ""} + stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""} hwaccel_errors.append(args) elif "nvmpi" in args or "jetson" in args: # nvidia Jetson jetson_usage = get_jetson_stats() if jetson_usage: - stats["jetson-gpu"] = jetson_usage + stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage} else: - stats["jetson-gpu"] = {"gpu": "", "mem": ""} + stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""} hwaccel_errors.append(args) elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()): if not config.telemetry.stats.intel_gpu_stats: continue - if "intel-gpu" not in stats: + if not intel_gpu_collected: # intel GPU (QSV or VAAPI both use the same physical GPU) + intel_gpu_collected = True intel_usage = get_intel_gpu_stats( config.telemetry.stats.intel_gpu_device ) - if intel_usage is not None: - stats["intel-gpu"] = intel_usage or {"gpu": "", "mem": ""} + if intel_usage: + for entry in intel_usage.values(): + name = entry.pop("name") + stats[name] = entry else: - stats["intel-gpu"] = {"gpu": "", "mem": ""} + stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""} hwaccel_errors.append(args) elif "vaapi" in args: if not config.telemetry.stats.amd_gpu_stats: @@ -284,18 +289,18 @@ async def set_gpu_stats( amd_usage = get_amd_gpu_stats() if amd_usage: - stats["amd-vaapi"] = amd_usage + stats["amd-vaapi"] = {"vendor": "amd", **amd_usage} else: - stats["amd-vaapi"] = {"gpu": "", "mem": ""} + stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""} hwaccel_errors.append(args) elif "preset-rk" in args: rga_usage = get_rockchip_gpu_stats() if rga_usage: - stats["rockchip"] = rga_usage + stats["rockchip"] = {"vendor": "rockchip", **rga_usage} elif "v4l2m2m" in args or "rpi" in args: # RPi v4l2m2m is currently not able to get usage stats - stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""} + stats["rpi-v4l2m2m"] = {"vendor": "rpi", "gpu": "", "mem": ""} if stats: all_stats["gpu_usages"] = stats diff --git a/frigate/test/http_api/test_debug_replay_api.py b/frigate/test/http_api/test_debug_replay_api.py new file mode 100644 index 0000000000..45c2c5478f --- /dev/null +++ b/frigate/test/http_api/test_debug_replay_api.py @@ -0,0 +1,123 @@ +"""Tests for /debug_replay API endpoints.""" + +from unittest.mock import patch + +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestDebugReplayAPI(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + self.app = self.create_app() + + def test_start_returns_202_with_job_id(self): + # Stub the factory to skip validation/threading and just record the + # name on the manager the way the real factory's mark_starting would. + def fake_start(**kwargs): + kwargs["replay_manager"].mark_starting( + source_camera=kwargs["source_camera"], + replay_camera_name="_replay_front", + start_ts=kwargs["start_ts"], + end_ts=kwargs["end_ts"], + ) + return "job-1234" + + with patch( + "frigate.api.debug_replay.start_debug_replay_job", + side_effect=fake_start, + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "front", + "start_time": 100, + "end_time": 200, + }, + ) + + self.assertEqual(resp.status_code, 202) + body = resp.json() + self.assertTrue(body["success"]) + self.assertEqual(body["job_id"], "job-1234") + self.assertEqual(body["replay_camera"], "_replay_front") + + def test_start_returns_400_on_validation_error(self): + with patch( + "frigate.api.debug_replay.start_debug_replay_job", + side_effect=ValueError("Camera 'missing' not found"), + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "missing", + "start_time": 100, + "end_time": 200, + }, + ) + + self.assertEqual(resp.status_code, 400) + body = resp.json() + self.assertFalse(body["success"]) + # Message is hard-coded so we don't echo exception text back to clients + # (CodeQL: information exposure through an exception). + self.assertEqual(body["message"], "Invalid debug replay parameters") + + def test_start_returns_409_when_session_already_active(self): + with patch( + "frigate.api.debug_replay.start_debug_replay_job", + side_effect=RuntimeError("A replay session is already active"), + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "front", + "start_time": 100, + "end_time": 200, + }, + ) + + self.assertEqual(resp.status_code, 409) + body = resp.json() + self.assertFalse(body["success"]) + + def test_status_inactive_when_no_session(self): + with AuthTestClient(self.app) as client: + resp = client.get("/debug_replay/status") + + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertFalse(body["active"]) + self.assertIsNone(body["replay_camera"]) + self.assertIsNone(body["source_camera"]) + self.assertIsNone(body["start_time"]) + self.assertIsNone(body["end_time"]) + self.assertFalse(body["live_ready"]) + # Make sure deprecated fields are gone + self.assertNotIn("state", body) + self.assertNotIn("progress_percent", body) + self.assertNotIn("error_message", body) + + def test_status_active_after_mark_starting(self): + manager = self.app.replay_manager + manager.mark_starting( + source_camera="front", + replay_camera_name="_replay_front", + start_ts=100.0, + end_ts=200.0, + ) + + with AuthTestClient(self.app) as client: + resp = client.get("/debug_replay/status") + + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertTrue(body["active"]) + self.assertEqual(body["replay_camera"], "_replay_front") + self.assertEqual(body["source_camera"], "front") + self.assertEqual(body["start_time"], 100.0) + self.assertEqual(body["end_time"], 200.0) + self.assertFalse(body["live_ready"]) diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index bf8e9c72a9..2be0e65da8 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -23,6 +23,26 @@ class TestHttpApp(BaseTestHttp): response_json = response.json() assert response_json == self.test_stats + def test_recordings_storage_requires_admin(self): + stats = Mock(spec=StatsEmitter) + stats.get_latest_stats.return_value = self.test_stats + app = super().create_app(stats) + app.storage_maintainer = Mock() + app.storage_maintainer.calculate_camera_usages.return_value = { + "front_door": {"usage": 2.0}, + } + + with AuthTestClient(app) as client: + response = client.get( + "/recordings/storage", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + ) + assert response.status_code == 403 + + response = client.get("/recordings/storage") + assert response.status_code == 200 + assert response.json()["front_door"]["usage_percent"] == 25.0 + def test_config_set_in_memory_replaces_objects_track_list(self): self.minimal_config["cameras"]["front_door"]["objects"] = { "track": ["person", "car"], diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py index 211c84bb4f..44520d79f5 100644 --- a/frigate/test/http_api/test_http_camera_access.py +++ b/frigate/test/http_api/test_http_camera_access.py @@ -1,3 +1,4 @@ +import os from unittest.mock import patch from fastapi import HTTPException, Request @@ -357,6 +358,51 @@ class TestGo2rtcStreamAccess(BaseTestHttp): f"got {resp.status_code}" ) + def test_add_stream_rejects_restricted_source(self): + """PUT /go2rtc/streams must reject exec:/echo:/expr: sources even for + admins""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with AuthTestClient(app) as client: + for src in ( + "exec:/tmp/rev.sh", + "echo:foo", + "expr:bar", + " exec:/tmp/rev.sh", + ): + resp = client.put(f"/go2rtc/streams/revshell?src={src}") + assert resp.status_code == 400, ( + f"Expected 400 for restricted src {src!r}; got {resp.status_code}" + ) + assert resp.json().get("success") is False + + def test_add_stream_allows_non_restricted_source(self): + """A normal stream URL should pass the restricted-source check and reach + the (unavailable in tests) go2rtc proxy — so we expect 500, not 400.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with AuthTestClient(app) as client: + resp = client.put("/go2rtc/streams/legit?src=rtsp://10.0.0.1:554/video") + assert resp.status_code != 400, ( + f"Non-restricted source should not be rejected with 400; got {resp.status_code}" + ) + + def test_add_stream_allows_restricted_source_when_override_set(self): + """When GO2RTC_ALLOW_ARBITRARY_EXEC is set, the API must defer to operator + intent and forward the request to go2rtc instead of short-circuiting with 400.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})() + with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}): + with patch( + "frigate.api.camera.requests.put", return_value=mock_response + ) as mock_put: + with AuthTestClient(app) as client: + resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something") + assert resp.status_code == 200, ( + f"Restricted src should be forwarded when override set; got {resp.status_code}" + ) + mock_put.assert_called_once() + forwarded_src = mock_put.call_args.kwargs["params"]["src"] + assert forwarded_src == "exec:/tmp/something" + def test_stream_alias_blocked_when_owning_camera_disallowed(self): """limited_user cannot access a stream alias that belongs to a camera they are not allowed to see.""" diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index bc7f388e15..8aca6577d9 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -219,6 +219,25 @@ class TestHttpApp(BaseTestHttp): assert len(events) == 1 assert events[0]["id"] == event_id + def test_similarity_search_hides_unauthorized_anchor_event(self): + mock_embeddings = Mock() + self.app.frigate_config.semantic_search.enabled = True + self.app.embeddings = mock_embeddings + + with AuthTestClient(self.app) as client: + super().insert_mock_event("hidden.anchor", camera="back_door") + response = client.get( + "/events/search", + params={ + "search_type": "similarity", + "event_id": "hidden.anchor", + }, + ) + + assert response.status_code == 404 + assert response.json()["message"] == "Event not found" + mock_embeddings.search_thumbnail.assert_not_called() + def test_get_good_event(self): id = "123456.random" diff --git a/frigate/test/test_chat_find_similar_objects.py b/frigate/test/test_chat_find_similar_objects.py index 38055658e1..73fd3b27db 100644 --- a/frigate/test/test_chat_find_similar_objects.py +++ b/frigate/test/test_chat_find_similar_objects.py @@ -145,9 +145,12 @@ class TestExecuteFindSimilarObjects(unittest.TestCase): embeddings=embeddings, frigate_config=SimpleNamespace( semantic_search=SimpleNamespace(enabled=semantic_enabled), + cameras={"driveway": object()}, + auth=SimpleNamespace(roles={"admin": [], "viewer": ["driveway"]}), + proxy=SimpleNamespace(separator=","), ), ) - return SimpleNamespace(app=app) + return SimpleNamespace(app=app, headers={}) def test_semantic_search_disabled_returns_error(self): req = self._make_request(semantic_enabled=False) @@ -180,7 +183,7 @@ class TestExecuteFindSimilarObjects(unittest.TestCase): _execute_find_similar_objects( req, {"event_id": "anchor", "cameras": ["nonexistent_cam"]}, - allowed_cameras=["nonexistent_cam"], + allowed_cameras=["driveway"], ) ) self.assertEqual(result["results"], []) diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index e82b688c62..c63f27430a 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -10,7 +10,7 @@ from ruamel.yaml.constructor import DuplicateKeyError from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.const import MODEL_CACHE_DIR from frigate.detectors import DetectorTypeEnum -from frigate.util.builtin import deep_merge, load_labels +from frigate.util.builtin import deep_merge class TestConfig(unittest.TestCase): @@ -64,9 +64,9 @@ class TestConfig(unittest.TestCase): def test_config_class(self): frigate_config = FrigateConfig(**self.minimal) - assert "cpu" in frigate_config.detectors.keys() - assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu - assert frigate_config.detectors["cpu"].model.width == 320 + assert "ov" in frigate_config.detectors.keys() + assert frigate_config.detectors["ov"].type == DetectorTypeEnum.openvino + assert frigate_config.detectors["ov"].model.width == 300 @patch("frigate.detectors.detector_config.load_labels") def test_detector_custom_model_path(self, mock_labels): @@ -309,16 +309,11 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - all_audio_labels = { - label - for label in load_labels("/audio-labelmap.txt", prefill=521).values() - if label + assert set(frigate_config.cameras["back"].audio.filters.keys()) == { + "speech", + "yell", } - assert all_audio_labels.issubset( - set(frigate_config.cameras["back"].audio.filters.keys()) - ) - def test_override_audio_filters(self): config = { "mqtt": {"host": "mqtt"}, @@ -345,7 +340,8 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) assert "speech" in frigate_config.cameras["back"].audio.filters assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9 - assert "babbling" in frigate_config.cameras["back"].audio.filters + assert "yell" in frigate_config.cameras["back"].audio.filters + assert "babbling" not in frigate_config.cameras["back"].audio.filters def test_inherit_object_filters(self): config = { @@ -1005,6 +1001,7 @@ class TestConfig(unittest.TestCase): config = { "mqtt": {"host": "mqtt"}, + "detectors": {"cpu": {"type": "cpu"}}, "model": {"path": "plus://test"}, "cameras": { "back": { diff --git a/frigate/test/test_debug_replay.py b/frigate/test/test_debug_replay.py new file mode 100644 index 0000000000..e7f9df42db --- /dev/null +++ b/frigate/test/test_debug_replay.py @@ -0,0 +1,242 @@ +"""Tests for the simplified DebugReplayManager. + +Startup orchestration lives in ``frigate.jobs.debug_replay`` (covered by +``test_debug_replay_job``). The manager owns only session presence and +cleanup. +""" + +import unittest +import unittest.mock +from unittest.mock import MagicMock, patch + + +class TestDebugReplayManagerSession(unittest.TestCase): + def test_inactive_by_default(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + + self.assertFalse(manager.active) + self.assertIsNone(manager.replay_camera_name) + self.assertIsNone(manager.source_camera) + self.assertIsNone(manager.clip_path) + self.assertIsNone(manager.start_ts) + self.assertIsNone(manager.end_ts) + + def test_mark_starting_sets_session_pointers_and_active(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + + manager.mark_starting( + source_camera="front", + replay_camera_name="_replay_front", + start_ts=100.0, + end_ts=200.0, + ) + + self.assertTrue(manager.active) + self.assertEqual(manager.replay_camera_name, "_replay_front") + self.assertEqual(manager.source_camera, "front") + self.assertEqual(manager.start_ts, 100.0) + self.assertEqual(manager.end_ts, 200.0) + self.assertIsNone(manager.clip_path) + + def test_mark_session_ready_sets_clip_path(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + + manager.mark_session_ready(clip_path="/tmp/replay/_replay_front.mp4") + + self.assertEqual(manager.clip_path, "/tmp/replay/_replay_front.mp4") + self.assertTrue(manager.active) + + def test_clear_session_resets_all_pointers(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + manager.mark_session_ready("/tmp/replay/clip.mp4") + + manager.clear_session() + + self.assertFalse(manager.active) + self.assertIsNone(manager.replay_camera_name) + self.assertIsNone(manager.source_camera) + self.assertIsNone(manager.clip_path) + self.assertIsNone(manager.start_ts) + self.assertIsNone(manager.end_ts) + + +class TestDebugReplayManagerStop(unittest.TestCase): + def test_stop_when_inactive_is_a_noop(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + frigate_config = MagicMock() + frigate_config.cameras = {} + publisher = MagicMock() + + # Should not raise; should not publish any events. + manager.stop(frigate_config=frigate_config, config_publisher=publisher) + + publisher.publish_update.assert_not_called() + + def test_stop_publishes_remove_when_camera_was_published(self) -> None: + from frigate.config.camera.updater import CameraConfigUpdateEnum + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + manager.mark_session_ready("/tmp/replay/_replay_front.mp4") + + camera_config = MagicMock() + frigate_config = MagicMock() + frigate_config.cameras = {"_replay_front": camera_config} + publisher = MagicMock() + + with ( + patch.object(manager, "_cleanup_db"), + patch.object(manager, "_cleanup_files"), + patch("frigate.debug_replay.cancel_debug_replay_job", return_value=False), + ): + manager.stop(frigate_config=frigate_config, config_publisher=publisher) + + # One publish_update call with a remove topic. + self.assertEqual(publisher.publish_update.call_count, 1) + topic_arg = publisher.publish_update.call_args.args[0] + self.assertEqual(topic_arg.update_type, CameraConfigUpdateEnum.remove) + self.assertFalse(manager.active) + + def test_stop_skips_remove_publish_when_camera_not_in_config(self) -> None: + """Cancellation during preparing_clip: no camera was published yet.""" + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + # clip_path stays None because we cancelled before camera publish. + + frigate_config = MagicMock() + frigate_config.cameras = {} # _replay_front not present + publisher = MagicMock() + + with ( + patch.object(manager, "_cleanup_db"), + patch.object(manager, "_cleanup_files"), + patch("frigate.debug_replay.cancel_debug_replay_job", return_value=True), + ): + manager.stop(frigate_config=frigate_config, config_publisher=publisher) + + publisher.publish_update.assert_not_called() + self.assertFalse(manager.active) + + def test_stop_calls_cancel_debug_replay_job(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + manager.mark_starting("front", "_replay_front", 100.0, 200.0) + + frigate_config = MagicMock() + frigate_config.cameras = {} + publisher = MagicMock() + + with ( + patch.object(manager, "_cleanup_db"), + patch.object(manager, "_cleanup_files"), + patch( + "frigate.debug_replay.cancel_debug_replay_job", + return_value=True, + ) as mock_cancel, + ): + manager.stop(frigate_config=frigate_config, config_publisher=publisher) + + mock_cancel.assert_called_once() + + +class TestDebugReplayManagerPublishCamera(unittest.TestCase): + def test_publish_camera_invokes_publisher_with_add_topic(self) -> None: + from frigate.config.camera.updater import CameraConfigUpdateEnum + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + + source_config = MagicMock() + new_camera_config = MagicMock() + frigate_config = MagicMock() + frigate_config.cameras = {"front": source_config} + publisher = MagicMock() + + with ( + patch.object( + manager, + "_build_camera_config_dict", + return_value={"enabled": True}, + ), + patch("frigate.debug_replay.find_config_file", return_value="/cfg.yml"), + patch("frigate.debug_replay.YAML") as yaml_cls, + patch("frigate.debug_replay.FrigateConfig.parse_object") as parse_object, + patch("builtins.open", unittest.mock.mock_open(read_data="cameras:\n")), + ): + yaml_instance = yaml_cls.return_value + yaml_instance.load.return_value = {"cameras": {}} + parsed = MagicMock() + parsed.cameras = {"_replay_front": new_camera_config} + parse_object.return_value = parsed + + manager.publish_camera( + source_camera="front", + replay_name="_replay_front", + clip_path="/tmp/clip.mp4", + frigate_config=frigate_config, + config_publisher=publisher, + ) + + # Camera registered into the live config dict + self.assertIn("_replay_front", frigate_config.cameras) + # Publisher invoked with an add topic + self.assertEqual(publisher.publish_update.call_count, 1) + topic_arg = publisher.publish_update.call_args.args[0] + self.assertEqual(topic_arg.update_type, CameraConfigUpdateEnum.add) + + def test_publish_camera_wraps_parse_failure_in_runtime_error(self) -> None: + from frigate.debug_replay import DebugReplayManager + + manager = DebugReplayManager() + frigate_config = MagicMock() + frigate_config.cameras = {"front": MagicMock()} + publisher = MagicMock() + + with ( + patch.object( + manager, + "_build_camera_config_dict", + return_value={"enabled": True}, + ), + patch("frigate.debug_replay.find_config_file", return_value="/cfg.yml"), + patch("frigate.debug_replay.YAML") as yaml_cls, + patch( + "frigate.debug_replay.FrigateConfig.parse_object", + side_effect=ValueError("zone foo has invalid coordinates"), + ), + patch("builtins.open", unittest.mock.mock_open(read_data="cameras:\n")), + ): + yaml_cls.return_value.load.return_value = {"cameras": {}} + + with self.assertRaises(RuntimeError) as ctx: + manager.publish_camera( + source_camera="front", + replay_name="_replay_front", + clip_path="/tmp/clip.mp4", + frigate_config=frigate_config, + config_publisher=publisher, + ) + + self.assertIn("replay camera config", str(ctx.exception)) + self.assertIn("invalid coordinates", str(ctx.exception)) + publisher.publish_update.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_debug_replay_job.py b/frigate/test/test_debug_replay_job.py new file mode 100644 index 0000000000..60997564f4 --- /dev/null +++ b/frigate/test/test_debug_replay_job.py @@ -0,0 +1,460 @@ +"""Tests for the debug replay job runner and factory.""" + +import threading +import time +import unittest +import unittest.mock +from unittest.mock import MagicMock, patch + +from frigate.debug_replay import DebugReplayManager +from frigate.jobs.debug_replay import ( + DebugReplayJob, + cancel_debug_replay_job, + get_active_runner, + start_debug_replay_job, +) +from frigate.jobs.export import JobStatePublisher +from frigate.jobs.manager import _completed_jobs, _current_jobs +from frigate.types import JobStatusTypesEnum + + +def _reset_job_manager() -> None: + """Clear the global job manager state between tests.""" + _current_jobs.clear() + _completed_jobs.clear() + + +def _patch_publisher(test_case: unittest.TestCase) -> None: + """Replace JobStatePublisher.publish with a no-op to avoid hanging on IPC.""" + publisher_patch = patch.object( + JobStatePublisher, "publish", lambda self, payload: None + ) + publisher_patch.start() + test_case.addCleanup(publisher_patch.stop) + + +class TestDebugReplayJob(unittest.TestCase): + def test_default_fields(self) -> None: + job = DebugReplayJob() + + self.assertEqual(job.job_type, "debug_replay") + self.assertEqual(job.status, JobStatusTypesEnum.queued) + self.assertIsNone(job.current_step) + self.assertEqual(job.progress_percent, 0.0) + + def test_to_dict_whitelist(self) -> None: + job = DebugReplayJob( + source_camera="front", + replay_camera_name="_replay_front", + start_ts=100.0, + end_ts=200.0, + ) + job.current_step = "preparing_clip" + job.progress_percent = 42.5 + + payload = job.to_dict() + + # Top-level matches the standard Job shape. + for key in ( + "id", + "job_type", + "status", + "start_time", + "end_time", + "error_message", + "results", + ): + self.assertIn(key, payload, f"missing top-level field: {key}") + + results = payload["results"] + self.assertEqual(results["source_camera"], "front") + self.assertEqual(results["replay_camera_name"], "_replay_front") + self.assertEqual(results["current_step"], "preparing_clip") + self.assertEqual(results["progress_percent"], 42.5) + self.assertEqual(results["start_ts"], 100.0) + self.assertEqual(results["end_ts"], 200.0) + + +class TestStartDebugReplayJob(unittest.TestCase): + def setUp(self) -> None: + _reset_job_manager() + _patch_publisher(self) + self.manager = DebugReplayManager() + self.frigate_config = MagicMock() + self.frigate_config.cameras = {"front": MagicMock()} + self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" + self.publisher = MagicMock() + + self.recordings_qs = MagicMock() + self.recordings_qs.count.return_value = 1 + self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + + def tearDown(self) -> None: + runner = get_active_runner() + if runner is not None: + runner.cancel() + runner.join(timeout=2.0) + _reset_job_manager() + + def test_rejects_unknown_camera(self) -> None: + with self.assertRaises(ValueError): + start_debug_replay_job( + source_camera="missing", + start_ts=100.0, + end_ts=200.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + def test_rejects_invalid_time_range(self) -> None: + with self.assertRaises(ValueError): + start_debug_replay_job( + source_camera="front", + start_ts=200.0, + end_ts=100.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + def test_rejects_when_no_recordings(self) -> None: + empty_qs = MagicMock() + empty_qs.count.return_value = 0 + with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs): + with self.assertRaises(ValueError): + start_debug_replay_job( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + def test_returns_job_id_and_marks_session_starting(self) -> None: + block = threading.Event() + + def slow_helper(cmd, **kwargs): + block.wait(timeout=5) + return 0, "" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=slow_helper, + ), + patch.object(self.manager, "publish_camera"), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("builtins.open", unittest.mock.mock_open()), + ): + job_id = start_debug_replay_job( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + self.assertIsInstance(job_id, str) + self.assertTrue(self.manager.active) + self.assertEqual(self.manager.replay_camera_name, "_replay_front") + self.assertEqual(self.manager.source_camera, "front") + + block.set() + + def test_rejects_concurrent_calls(self) -> None: + block = threading.Event() + + def slow_helper(cmd, **kwargs): + block.wait(timeout=5) + return 0, "" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=slow_helper, + ), + patch.object(self.manager, "publish_camera"), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("builtins.open", unittest.mock.mock_open()), + ): + start_debug_replay_job( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + with self.assertRaises(RuntimeError): + start_debug_replay_job( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + block.set() + + +class TestRunnerHappyPath(unittest.TestCase): + def setUp(self) -> None: + _reset_job_manager() + _patch_publisher(self) + self.manager = DebugReplayManager() + self.frigate_config = MagicMock() + self.frigate_config.cameras = {"front": MagicMock()} + self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" + self.publisher = MagicMock() + + self.recordings_qs = MagicMock() + self.recordings_qs.count.return_value = 1 + self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + + def tearDown(self) -> None: + runner = get_active_runner() + if runner is not None: + runner.cancel() + runner.join(timeout=2.0) + _reset_job_manager() + + def _wait_for(self, predicate, timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + def test_progress_callback_updates_job_percent(self) -> None: + captured: list[float] = [] + + def fake_helper(cmd, *, on_progress=None, **kwargs): + on_progress(0.0) + on_progress(50.0) + on_progress(100.0) + return 0, "" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=fake_helper, + ), + patch.object( + self.manager, + "publish_camera", + side_effect=lambda *a, **kw: captured.append("published"), + ), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("builtins.open", unittest.mock.mock_open()), + ): + start_debug_replay_job( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + self.assertTrue( + self._wait_for(lambda: get_active_runner() is None), + "runner did not finish", + ) + + from frigate.jobs.manager import get_current_job + + job = get_current_job("debug_replay") + self.assertIsNotNone(job) + self.assertEqual(job.status, JobStatusTypesEnum.success) + self.assertEqual(job.progress_percent, 100.0) + self.assertEqual(captured, ["published"]) + # Manager should have been told the session is ready with the clip path. + self.assertIsNotNone(self.manager.clip_path) + + +class TestRunnerFailurePath(unittest.TestCase): + def setUp(self) -> None: + _reset_job_manager() + _patch_publisher(self) + self.manager = DebugReplayManager() + self.frigate_config = MagicMock() + self.frigate_config.cameras = {"front": MagicMock()} + self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" + self.publisher = MagicMock() + self.recordings_qs = MagicMock() + self.recordings_qs.count.return_value = 1 + self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + + def tearDown(self) -> None: + runner = get_active_runner() + if runner is not None: + runner.cancel() + runner.join(timeout=2.0) + _reset_job_manager() + + def _wait_for(self, predicate, timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + def test_ffmpeg_failure_marks_job_failed_and_clears_session(self) -> None: + def failing_helper(cmd, **kwargs): + return 1, "ffmpeg exploded" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=failing_helper, + ), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("os.remove"), + patch("builtins.open", unittest.mock.mock_open()), + ): + start_debug_replay_job( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + self.assertTrue( + self._wait_for(lambda: get_active_runner() is None), + "runner did not finish", + ) + + from frigate.jobs.manager import get_current_job + + job = get_current_job("debug_replay") + self.assertIsNotNone(job) + self.assertEqual(job.status, JobStatusTypesEnum.failed) + self.assertIsNotNone(job.error_message) + self.assertIn("ffmpeg", job.error_message.lower()) + # Session cleared so a new /start is allowed + self.assertFalse(self.manager.active) + + +class TestRunnerCancellation(unittest.TestCase): + def setUp(self) -> None: + _reset_job_manager() + _patch_publisher(self) + self.manager = DebugReplayManager() + self.frigate_config = MagicMock() + self.frigate_config.cameras = {"front": MagicMock()} + self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true" + self.publisher = MagicMock() + self.recordings_qs = MagicMock() + self.recordings_qs.count.return_value = 1 + self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")]) + + def tearDown(self) -> None: + runner = get_active_runner() + if runner is not None: + runner.cancel() + runner.join(timeout=2.0) + _reset_job_manager() + + def _wait_for(self, predicate, timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + def test_cancel_terminates_ffmpeg_and_marks_cancelled(self) -> None: + terminated = threading.Event() + fake_proc = MagicMock() + fake_proc.terminate = MagicMock(side_effect=lambda: terminated.set()) + + def fake_helper(cmd, *, process_started=None, **kwargs): + if process_started is not None: + process_started(fake_proc) + terminated.wait(timeout=5) + return -15, "killed" + + with ( + patch( + "frigate.jobs.debug_replay.query_recordings", + return_value=self.recordings_qs, + ), + patch( + "frigate.jobs.debug_replay.run_ffmpeg_with_progress", + side_effect=fake_helper, + ), + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("os.remove"), + patch("builtins.open", unittest.mock.mock_open()), + ): + start_debug_replay_job( + source_camera="front", + start_ts=100.0, + end_ts=200.0, + frigate_config=self.frigate_config, + config_publisher=self.publisher, + replay_manager=self.manager, + ) + + # Wait for the runner to register the active process. + self.assertTrue( + self._wait_for( + lambda: ( + get_active_runner() is not None + and get_active_runner()._active_process is fake_proc + ) + ) + ) + + cancelled = cancel_debug_replay_job() + self.assertTrue(cancelled) + self.assertTrue(fake_proc.terminate.called) + + self.assertTrue( + self._wait_for(lambda: get_active_runner() is None), + "runner did not finish", + ) + + from frigate.jobs.manager import get_current_job + + job = get_current_job("debug_replay") + self.assertEqual(job.status, JobStatusTypesEnum.cancelled) + # Runner must not clear the manager session on cancellation — + # that belongs to the caller of cancel_debug_replay_job (stop()). + # If the runner cleared it, stop() would log "no active session" + # and skip its cleanup_db / cleanup_files calls. + self.assertTrue(self.manager.active) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_export_progress.py b/frigate/test/test_export_progress.py index 616a63503b..835cf91b99 100644 --- a/frigate/test/test_export_progress.py +++ b/frigate/test/test_export_progress.py @@ -1,6 +1,9 @@ """Tests for export progress tracking, broadcast, and FFmpeg parsing.""" import io +import os +import shutil +import tempfile import unittest from unittest.mock import MagicMock, patch @@ -11,6 +14,7 @@ from frigate.jobs.export import ( ) from frigate.record.export import PlaybackSourceEnum, RecordingExporter from frigate.types import JobStatusTypesEnum +from frigate.util.ffmpeg import inject_progress_flags def _make_exporter( @@ -115,10 +119,9 @@ class TestExpectedOutputDuration(unittest.TestCase): class TestProgressFlagInjection(unittest.TestCase): def test_inserts_before_output_path(self) -> None: - exporter = _make_exporter() cmd = ["ffmpeg", "-i", "input.m3u8", "-c", "copy", "/tmp/output.mp4"] - result = exporter._inject_progress_flags(cmd) + result = inject_progress_flags(cmd) assert result == [ "ffmpeg", @@ -133,8 +136,7 @@ class TestProgressFlagInjection(unittest.TestCase): ] def test_handles_empty_cmd(self) -> None: - exporter = _make_exporter() - assert exporter._inject_progress_flags([]) == [] + assert inject_progress_flags([]) == [] class TestFfmpegProgressParsing(unittest.TestCase): @@ -164,7 +166,7 @@ class TestFfmpegProgressParsing(unittest.TestCase): fake_proc.returncode = 0 fake_proc.wait = MagicMock(return_value=0) - with patch("frigate.record.export.sp.Popen", return_value=fake_proc): + with patch("frigate.util.ffmpeg.sp.Popen", return_value=fake_proc): returncode, _stderr = exporter._run_ffmpeg_with_progress( ["ffmpeg", "-i", "x.m3u8", "/tmp/out.mp4"], "playlist", step="encoding" ) @@ -363,6 +365,121 @@ class TestBroadcastAggregation(unittest.TestCase): assert job.progress_percent == 33.0 +class TestGetDatetimeFromTimestamp(unittest.TestCase): + """Auto-generated export name should honor config.ui.timezone, not + fall back to the container's UTC clock when a timezone is configured. + """ + + def test_uses_configured_ui_timezone(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = "America/New_York" + # 2025-01-15 12:00:00 UTC is 07:00:00 EST + assert exporter.get_datetime_from_timestamp(1736942400) == "2025-01-15 07:00:00" + + def test_falls_back_to_local_when_timezone_unset(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = None + # No assertion on the exact wall-clock value — just confirm no + # exception and that pytz isn't required when the field is unset. + assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str) + + def test_invalid_timezone_falls_back_to_local(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = "Not/A_Real_Zone" + assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str) + + +class TestSaveThumbnailFromPreviewFrames(unittest.TestCase): + """Short exports in the current hour can fall between preview frame + writes (1-2 fps during activity, every 30s otherwise). When no frame + falls inside the export window, save_thumbnail should fall back to + the most recent prior frame instead of returning no thumbnail.""" + + def setUp(self) -> None: + self.tmp_root = tempfile.mkdtemp(prefix="frigate_thumb_test_") + self.preview_dir = os.path.join(self.tmp_root, "cache", "preview_frames") + self.export_clips = os.path.join(self.tmp_root, "clips", "export") + os.makedirs(self.preview_dir, exist_ok=True) + os.makedirs(self.export_clips, exist_ok=True) + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_root, ignore_errors=True) + + def _write_frame(self, camera: str, frame_time: float) -> str: + path = os.path.join(self.preview_dir, f"preview_{camera}-{frame_time}.webp") + with open(path, "wb") as f: + f.write(b"fake-webp-bytes") + return path + + def _make_short_current_hour_exporter(self) -> RecordingExporter: + # Use a "now-ish" timestamp so save_thumbnail's start-of-hour + # comparison takes the current-hour branch (preview frames). + import datetime + + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + exporter = _make_exporter() + exporter.export_id = "thumb_short" + exporter.start_time = now + exporter.end_time = now + 3 + return exporter + + def test_short_export_falls_back_to_prior_preview_frame(self) -> None: + exporter = self._make_short_current_hour_exporter() + # Most recent preview frame is 10s before the export window + prior = self._write_frame(exporter.camera, exporter.start_time - 10.0) + thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp") + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == thumb_target + assert os.path.isfile(thumb_target) + with open(thumb_target, "rb") as f, open(prior, "rb") as src: + assert f.read() == src.read() + + def test_returns_empty_when_no_preview_frames_exist(self) -> None: + exporter = self._make_short_current_hour_exporter() + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == "" + + def test_prefers_in_window_frame_over_prior_frame(self) -> None: + exporter = self._make_short_current_hour_exporter() + self._write_frame(exporter.camera, exporter.start_time - 10.0) + in_window = self._write_frame(exporter.camera, exporter.start_time + 1.0) + thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp") + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == thumb_target + with open(thumb_target, "rb") as f, open(in_window, "rb") as src: + assert f.read() == src.read() + + class TestSchedulesCleanup(unittest.TestCase): def test_schedule_job_cleanup_removes_after_delay(self) -> None: config = MagicMock() @@ -381,5 +498,56 @@ class TestSchedulesCleanup(unittest.TestCase): assert job.id not in manager.jobs +class TestChapterMetadataInProgressReview(unittest.TestCase): + """Regression: in-progress review segments have end_time=NULL until the + activity closes. The chapter builder must clamp the chapter end to the + last recorded second instead of crashing on float(None).""" + + def _fake_select_returning(self, rows: list) -> MagicMock: + mock_query = MagicMock() + mock_query.where.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.iterator.return_value = iter(rows) + return mock_query + + def test_in_progress_review_does_not_crash_and_clamps_to_last_recording( + self, + ) -> None: + exporter = _make_exporter(end_minus_start=200) + # Recordings cover [1000, 1150]; export window is [1000, 1200] so + # the last recorded second is 1150 (a 50s gap at the tail). + recordings = [ + MagicMock(start_time=1000.0, end_time=1150.0), + ] + in_progress = MagicMock( + start_time=1100.0, + end_time=None, + severity="alert", + data={"objects": ["person"]}, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + chapter_path = os.path.join(tmpdir, "chapters.txt") + exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign] + + with patch( + "frigate.record.export.ReviewSegment.select", + return_value=self._fake_select_returning([in_progress]), + ): + result = exporter._build_chapter_metadata_file(recordings) + + assert result == chapter_path + with open(chapter_path) as f: + content = f.read() + + # Output time is windows[-1][1] - windows[-1][0] = 150s. + # Review starts at wall=1100, output offset = 100s -> 100000ms. + # Clamped end = last_recorded_end (1150) -> output offset = 150s -> 150000ms. + assert "[CHAPTER]" in content + assert "START=100000" in content + assert "END=150000" in content + assert "title=Alert: person" in content + + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_ffmpeg_progress.py b/frigate/test/test_ffmpeg_progress.py new file mode 100644 index 0000000000..5210511168 --- /dev/null +++ b/frigate/test/test_ffmpeg_progress.py @@ -0,0 +1,111 @@ +"""Tests for the shared ffmpeg progress helper.""" + +import unittest +from unittest.mock import MagicMock, patch + +from frigate.util.ffmpeg import inject_progress_flags, run_ffmpeg_with_progress + + +class TestInjectProgressFlags(unittest.TestCase): + def test_inserts_flags_before_output_path(self): + cmd = ["ffmpeg", "-i", "in.mp4", "-c", "copy", "out.mp4"] + result = inject_progress_flags(cmd) + self.assertEqual( + result, + [ + "ffmpeg", + "-i", + "in.mp4", + "-c", + "copy", + "-progress", + "pipe:2", + "-nostats", + "out.mp4", + ], + ) + + def test_empty_cmd_returns_empty(self): + self.assertEqual(inject_progress_flags([]), []) + + +class TestRunFfmpegWithProgress(unittest.TestCase): + def _make_fake_proc(self, stderr_lines, returncode=0): + proc = MagicMock() + proc.stderr = iter(stderr_lines) + proc.stdin = MagicMock() + proc.returncode = returncode + proc.wait = MagicMock() + return proc + + def test_emits_percent_from_out_time_us_lines(self): + captured: list[float] = [] + + def on_progress(percent: float) -> None: + captured.append(percent) + + stderr_lines = [ + "out_time_us=1000000\n", + "out_time_us=5000000\n", + "progress=end\n", + ] + proc = self._make_fake_proc(stderr_lines) + proc.stderr = MagicMock() + proc.stderr.__iter__ = lambda self: iter(stderr_lines) + proc.stderr.read = MagicMock(return_value="") + + with patch("subprocess.Popen", return_value=proc): + returncode, _stderr = run_ffmpeg_with_progress( + ["ffmpeg", "-i", "in", "out"], + expected_duration_seconds=10.0, + on_progress=on_progress, + use_low_priority=False, + ) + + self.assertEqual(returncode, 0) + self.assertEqual(len(captured), 4) # initial 0.0 + two parsed + final 100.0 + self.assertAlmostEqual(captured[0], 0.0) + self.assertAlmostEqual(captured[1], 10.0) + self.assertAlmostEqual(captured[2], 50.0) + self.assertAlmostEqual(captured[3], 100.0) + + def test_passes_started_process_to_callback(self): + proc = self._make_fake_proc([]) + proc.stderr = MagicMock() + proc.stderr.__iter__ = lambda self: iter([]) + proc.stderr.read = MagicMock(return_value="") + + seen: list = [] + + with patch("subprocess.Popen", return_value=proc): + run_ffmpeg_with_progress( + ["ffmpeg", "out"], + expected_duration_seconds=1.0, + process_started=lambda p: seen.append(p), + use_low_priority=False, + ) + + self.assertEqual(seen, [proc]) + + def test_clamps_percent_to_0_100(self): + captured: list[float] = [] + + def on_progress(percent: float) -> None: + captured.append(percent) + + stderr_lines = ["out_time_us=999999999999\n"] + proc = self._make_fake_proc(stderr_lines) + proc.stderr = MagicMock() + proc.stderr.__iter__ = lambda self: iter(stderr_lines) + proc.stderr.read = MagicMock(return_value="") + + with patch("subprocess.Popen", return_value=proc): + run_ffmpeg_with_progress( + ["ffmpeg", "out"], + expected_duration_seconds=10.0, + on_progress=on_progress, + use_low_priority=False, + ) + + # initial 0.0 then a clamped reading + self.assertEqual(captured[-1], 100.0) diff --git a/frigate/test/test_gpu_stats.py b/frigate/test/test_gpu_stats.py index 2604c4002c..f6986912f7 100644 --- a/frigate/test/test_gpu_stats.py +++ b/frigate/test/test_gpu_stats.py @@ -7,8 +7,6 @@ from frigate.util.services import get_amd_gpu_stats, get_intel_gpu_stats class TestGpuStats(unittest.TestCase): def setUp(self): self.amd_results = "Unknown Radeon card. <= R500 won't work, new cards might.\nDumping to -, line limit 1.\n1664070990.607556: bus 10, gpu 4.17%, ee 0.00%, vgt 0.00%, ta 0.00%, tc 0.00%, sx 0.00%, sh 0.00%, spi 0.83%, smx 0.00%, cr 0.00%, sc 0.00%, pa 0.00%, db 0.00%, cb 0.00%, vram 60.37% 294.04mb, gtt 0.33% 52.21mb, mclk 100.00% 1.800ghz, sclk 26.65% 0.533ghz\n" - self.intel_results = """{"period":{"duration":1.194033,"unit":"ms"},"frequency":{"requested":0.000000,"actual":0.000000,"unit":"MHz"},"interrupts":{"count":3349.991164,"unit":"irq/s"},"rc6":{"value":47.844741,"unit":"%"},"engines":{"Render/3D/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Blitter/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/0":{"busy":4.533124,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/1":{"busy":6.194385,"sema":0.000000,"wait":0.000000,"unit":"%"},"VideoEnhance/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"}}},{"period":{"duration":1.189291,"unit":"ms"},"frequency":{"requested":0.000000,"actual":0.000000,"unit":"MHz"},"interrupts":{"count":0.000000,"unit":"irq/s"},"rc6":{"value":100.000000,"unit":"%"},"engines":{"Render/3D/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Blitter/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/1":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"VideoEnhance/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"}}}""" - self.nvidia_results = "name, utilization.gpu [%], memory.used [MiB], memory.total [MiB]\nNVIDIA GeForce RTX 3050, 42 %, 5036 MiB, 8192 MiB\n" @patch("subprocess.run") def test_amd_gpu_stats(self, sp): @@ -19,32 +17,82 @@ class TestGpuStats(unittest.TestCase): amd_stats = get_amd_gpu_stats() assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"} - # @patch("subprocess.run") - # def test_nvidia_gpu_stats(self, sp): - # process = MagicMock() - # process.returncode = 0 - # process.stdout = self.nvidia_results - # sp.return_value = process - # nvidia_stats = get_nvidia_gpu_stats() - # assert nvidia_stats == { - # "name": "NVIDIA GeForce RTX 3050", - # "gpu": "42 %", - # "mem": "61.5 %", - # } + @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names") + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services.time.monotonic") + @patch("frigate.util.services._read_intel_drm_fdinfo") + def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep, get_names): + # 1 second of wall clock between snapshots + monotonic.side_effect = [0.0, 1.0] + get_names.return_value = {"0000:00:02.0": "Intel Graphics"} - @patch("subprocess.run") - def test_intel_gpu_stats(self, sp): - process = MagicMock() - process.returncode = 124 - process.stdout = self.intel_results - sp.return_value = process - intel_stats = get_intel_gpu_stats(False) - # rc6 values: 47.844741 and 100.0 → avg 73.92 → gpu = 100 - 73.92 = 26.08% - # Render/3D/0: 0.0 and 0.0 → enc = 0.0% - # Video/0: 4.533124 and 0.0 → dec = 2.27% - assert intel_stats == { - "gpu": "26.08%", - "mem": "-%", - "compute": "0.0%", - "dec": "2.27%", + # Two i915 clients on the same iGPU. Engine values are cumulative ns. + # Deltas over the 1s window: + # client A (pid 100): render +200_000_000 (20%), video +500_000_000 (50%), + # video-enhance +100_000_000 (10%) + # client B (pid 200): compute +100_000_000 (10%) + # Engine totals → render 20, video 50, video-enhance 10, compute 10 + # → compute = render + compute = 30 + # → dec = video + video-enhance = 60 + # → gpu = compute + dec = 90 + snapshot_a = { + ("0000:00:02.0", "1", "100"): { + "driver": "i915", + "pid": "100", + "engines": { + "render": (1_000_000_000, 0), + "video": (5_000_000_000, 0), + "video-enhance": (200_000_000, 0), + "compute": (0, 0), + }, + }, + ("0000:00:02.0", "2", "200"): { + "driver": "i915", + "pid": "200", + "engines": { + "render": (0, 0), + "compute": (2_000_000_000, 0), + }, + }, } + snapshot_b = { + ("0000:00:02.0", "1", "100"): { + "driver": "i915", + "pid": "100", + "engines": { + "render": (1_200_000_000, 0), + "video": (5_500_000_000, 0), + "video-enhance": (300_000_000, 0), + "compute": (0, 0), + }, + }, + ("0000:00:02.0", "2", "200"): { + "driver": "i915", + "pid": "200", + "engines": { + "render": (0, 0), + "compute": (2_100_000_000, 0), + }, + }, + } + read_fdinfo.side_effect = [snapshot_a, snapshot_b] + + intel_stats = get_intel_gpu_stats(None) + + sleep.assert_called_once() + assert intel_stats == { + "0000:00:02.0": { + "name": "Intel Graphics", + "vendor": "intel", + "gpu": "90.0%", + "mem": "-%", + "compute": "30.0%", + "dec": "60.0%", + "clients": {"100": "80.0%", "200": "10.0%"}, + }, + } + + @patch("frigate.util.services._read_intel_drm_fdinfo") + def test_intel_gpu_stats_no_clients(self, read_fdinfo): + read_fdinfo.return_value = {} + assert get_intel_gpu_stats(None) is None diff --git a/frigate/test/test_media_auth.py b/frigate/test/test_media_auth.py new file mode 100644 index 0000000000..80dc6fb8b0 --- /dev/null +++ b/frigate/test/test_media_auth.py @@ -0,0 +1,381 @@ +"""Unit tests for `frigate.api.media_auth`. + +Covers URI classification, the role-vs-camera decision matrix, and the export +DB-lookup path. These are pure functions/DB lookups — no HTTP stack involved. +""" + +import datetime +import logging +import os +import unittest + +from peewee_migrate import Router +from playhouse.sqlite_ext import SqliteExtDatabase +from playhouse.sqliteq import SqliteQueueDatabase + +from frigate.api.media_auth import ( + MediaAuthResolution, + deny_response_for_media_uri, + extract_path, + resolve_media_uri, +) +from frigate.config import FrigateConfig +from frigate.models import Event, Export, Recordings, ReviewSegment +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS + +_CONFIG = { + "mqtt": {"host": "mqtt"}, + "auth": {"roles": {"limited_user": ["front_door"]}}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + "back_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + # Camera name with a hyphen — exercises longest-prefix match. + "back-yard": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, +} + + +class TestExtractPath(unittest.TestCase): + def test_full_url(self): + self.assertEqual( + extract_path("http://host:8971/clips/front_door-1.jpg"), + "/clips/front_door-1.jpg", + ) + + def test_strips_query_string(self): + self.assertEqual( + extract_path("http://h/recordings/2026-05-11/14/front_door/00.00.mp4?t=1"), + "/recordings/2026-05-11/14/front_door/00.00.mp4", + ) + + def test_path_only(self): + self.assertEqual(extract_path("/exports/x.mp4"), "/exports/x.mp4") + + def test_percent_decoded(self): + self.assertEqual( + extract_path("http://h/clips/front%20door-1.jpg"), + "/clips/front door-1.jpg", + ) + + def test_empty(self): + self.assertIsNone(extract_path(None)) + self.assertIsNone(extract_path("")) + + +class TestResolveMediaUri(unittest.TestCase): + def setUp(self): + self.config = FrigateConfig(**_CONFIG) + + def _assert(self, uri, resolution, camera=None): + got_resolution, got_camera = resolve_media_uri(uri, self.config) + self.assertEqual(got_resolution, resolution, uri) + self.assertEqual(got_camera, camera, uri) + + def test_unknown_paths(self): + self._assert("/api/events", MediaAuthResolution.UNKNOWN) + self._assert("/", MediaAuthResolution.UNKNOWN) + self._assert("", MediaAuthResolution.UNKNOWN) + + def test_recordings(self): + self._assert("/recordings/", MediaAuthResolution.LISTING_NEUTRAL) + self._assert("/recordings/2026-05-11/", MediaAuthResolution.LISTING_NEUTRAL) + self._assert( + "/recordings/2026-05-11/14/", MediaAuthResolution.LISTING_MULTI_CAMERA + ) + self._assert( + "/recordings/2026-05-11/14/front_door/", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + self._assert( + "/recordings/2026-05-11/14/back_door/00.00.mp4", + MediaAuthResolution.CAMERA, + camera="back_door", + ) + + def test_clip_flat_filename_resolves_camera(self): + self._assert( + "/clips/front_door-1234.jpg", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + self._assert( + "/clips/back_door-1234-clean.webp", + MediaAuthResolution.CAMERA, + camera="back_door", + ) + + def test_clip_filename_with_hyphenated_camera_name(self): + # Camera name "back-yard" itself contains a hyphen; longest-prefix + # match must pick `back-yard`, not the bogus `back` prefix. + self._assert( + "/clips/back-yard-1234.jpg", + MediaAuthResolution.CAMERA, + camera="back-yard", + ) + + def test_clip_filename_no_matching_camera(self): + # Looks like a media path but couldn't classify — fail closed for + # restricted users (UNRESOLVED_MEDIA), not pass-through. + self._assert( + "/clips/nonexistent-1234.jpg", MediaAuthResolution.UNRESOLVED_MEDIA + ) + + def test_clip_thumbs(self): + self._assert("/clips/thumbs/", MediaAuthResolution.LISTING_MULTI_CAMERA) + self._assert( + "/clips/thumbs/front_door/", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + self._assert( + "/clips/thumbs/back_door/abc.webp", + MediaAuthResolution.CAMERA, + camera="back_door", + ) + + def test_clip_previews(self): + self._assert("/clips/previews/", MediaAuthResolution.LISTING_MULTI_CAMERA) + self._assert( + "/clips/previews/front_door/", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + self._assert( + "/clips/previews/back_door/segment.mp4", + MediaAuthResolution.CAMERA, + camera="back_door", + ) + + def test_clip_review_thumbs(self): + # Format: /clips/review/thumb-{camera}-{review_id}.webp (frigate/review/maintainer.py). + self._assert( + "/clips/review/thumb-front_door-abc123.webp", + MediaAuthResolution.CAMERA, + camera="front_door", + ) + # Hyphenated camera name — longest-prefix match. + self._assert( + "/clips/review/thumb-back-yard-abc123.webp", + MediaAuthResolution.CAMERA, + camera="back-yard", + ) + # Unknown camera prefix → unresolved, not allowed for restricted users. + self._assert( + "/clips/review/thumb-unknown-cam-abc123.webp", + MediaAuthResolution.UNRESOLVED_MEDIA, + ) + + def test_clip_admin_only_subtrees(self): + self._assert("/clips/faces/train/foo.webp", MediaAuthResolution.ADMIN_ONLY) + self._assert("/clips/faces/", MediaAuthResolution.ADMIN_ONLY) + self._assert("/clips/genai-requests/x/0.webp", MediaAuthResolution.ADMIN_ONLY) + self._assert( + "/clips/preview_restart_cache/x.mp4", MediaAuthResolution.ADMIN_ONLY + ) + self._assert("/clips/some_model/train/x.jpg", MediaAuthResolution.ADMIN_ONLY) + self._assert("/clips/some_model/dataset/x.jpg", MediaAuthResolution.ADMIN_ONLY) + + def test_clip_unknown_subtree_is_unresolved(self): + # Unknown /clips/{x}/{y}/... subtree falls through as unresolved (not + # admin-only) so restricted users get 403 without admins being denied + # access to legitimate but unrecognized resources. + self._assert("/clips/random_dir/foo.jpg", MediaAuthResolution.UNRESOLVED_MEDIA) + + def test_clip_top_level_listing(self): + self._assert("/clips/", MediaAuthResolution.LISTING_MULTI_CAMERA) + + def test_exports_listing(self): + self._assert("/exports/", MediaAuthResolution.LISTING_MULTI_CAMERA) + + +class TestExportResolution(unittest.TestCase): + """Export resolution requires a DB lookup.""" + + def setUp(self): + migrate_db = SqliteExtDatabase("test.db") + del logging.getLogger("peewee_migrate").handlers[:] + Router(migrate_db).run() + migrate_db.close() + self.db = SqliteQueueDatabase(TEST_DB) + self.db.bind([Event, ReviewSegment, Recordings, Export]) + self.config = FrigateConfig(**_CONFIG) + + def tearDown(self): + if not self.db.is_closed(): + self.db.close() + for f in TEST_DB_CLEANUPS: + try: + os.remove(f) + except OSError: + pass + + def _insert_export(self, export_id, camera, filename): + Export.insert( + id=export_id, + camera=camera, + name=f"export-{export_id}", + date=datetime.datetime.now(), + video_path=f"/media/frigate/exports/{filename}", + thumb_path=f"/media/frigate/exports/{filename}.jpg", + in_progress=False, + ).execute() + + def test_export_resolves_camera(self): + self._insert_export( + "exp1", "back_door", "back_door_20260511_140000-20260511_150000_abc123.mp4" + ) + resolution, camera = resolve_media_uri( + "/exports/back_door_20260511_140000-20260511_150000_abc123.mp4", + self.config, + ) + self.assertEqual(resolution, MediaAuthResolution.CAMERA) + self.assertEqual(camera, "back_door") + + def test_unknown_export_is_unresolved(self): + # No matching row → UNRESOLVED_MEDIA (fail closed for restricted users), + # not UNKNOWN (which would pass-through). + resolution, camera = resolve_media_uri( + "/exports/does_not_exist.mp4", self.config + ) + self.assertEqual(resolution, MediaAuthResolution.UNRESOLVED_MEDIA) + self.assertIsNone(camera) + + def test_export_anchored_match_not_endswith(self): + # Anchored exact-path equality must NOT match by filename suffix. + # A request like /exports/clip.mp4 must not authorize against a row at + # /media/frigate/exports/back_door_clip.mp4 just because the suffix matches. + self._insert_export("exp_bd", "back_door", "back_door_clip.mp4") + self._insert_export("exp_fd", "front_door", "front_door_clip.mp4") + resolution, _ = resolve_media_uri("/exports/clip.mp4", self.config) + self.assertEqual(resolution, MediaAuthResolution.UNRESOLVED_MEDIA) + + +class TestDenyResponseForMediaUri(unittest.TestCase): + """End-to-end decision check used by /auth.""" + + def setUp(self): + self.config = FrigateConfig(**_CONFIG) + + def _deny(self, url, role): + return deny_response_for_media_uri(url, role, self.config) + + def test_admin_always_allowed(self): + self.assertIsNone(self._deny("/clips/back_door-1.jpg", "admin")) + self.assertIsNone(self._deny("/clips/", "admin")) + self.assertIsNone(self._deny("/clips/faces/x.webp", "admin")) + self.assertIsNone( + self._deny("/recordings/2026-05-11/14/back_door/00.00.mp4", "admin") + ) + + def test_unrestricted_role_allowed(self): + # "viewer" role has no entry in roles_dict → full access (matches the + # behavior of require_camera_access). + self.assertIsNone(self._deny("/clips/back_door-1.jpg", "viewer")) + self.assertIsNone(self._deny("/clips/", "viewer")) + + def test_restricted_role_allowed_camera(self): + self.assertIsNone(self._deny("/clips/front_door-1.jpg", "limited_user")) + self.assertIsNone( + self._deny("/recordings/2026-05-11/14/front_door/00.00.mp4", "limited_user") + ) + self.assertIsNone( + self._deny("/clips/thumbs/front_door/abc.webp", "limited_user") + ) + + def test_restricted_role_blocked_other_camera(self): + self.assertEqual(self._deny("/clips/back_door-1.jpg", "limited_user"), 403) + self.assertEqual( + self._deny("/recordings/2026-05-11/14/back_door/00.00.mp4", "limited_user"), + 403, + ) + self.assertEqual( + self._deny("/clips/thumbs/back_door/abc.webp", "limited_user"), 403 + ) + + def test_restricted_role_blocked_admin_only(self): + self.assertEqual(self._deny("/clips/faces/train/foo.webp", "limited_user"), 403) + + def test_restricted_role_blocked_multi_camera_listing(self): + self.assertEqual(self._deny("/clips/", "limited_user"), 403) + self.assertEqual(self._deny("/exports/", "limited_user"), 403) + self.assertEqual(self._deny("/recordings/2026-05-11/14/", "limited_user"), 403) + + def test_restricted_role_allowed_neutral_listing(self): + self.assertIsNone(self._deny("/recordings/", "limited_user")) + self.assertIsNone(self._deny("/recordings/2026-05-11/", "limited_user")) + + def test_non_media_uri_passes_through(self): + self.assertIsNone(self._deny("/api/events", "limited_user")) + self.assertIsNone(self._deny("http://h/login", "limited_user")) + + def test_missing_header(self): + self.assertIsNone(self._deny(None, "limited_user")) + self.assertIsNone(self._deny("", "limited_user")) + + def test_traversal_in_media_uri_denied_for_all_roles(self): + # Bypass attempt: parts[3] looks like an allowed camera, but the + # normalized path nginx would serve points at a forbidden camera. + # Both restricted and admin should be denied — the URI is malformed + # and we refuse to make an auth decision against it. + traversal_uris = [ + "/recordings/2026-05-11/14/front_door/../back_door/00.00.mp4", + "/clips/front_door-1.jpg/../back_door-1.jpg", + "/exports/../recordings/2026-05-11/14/back_door/00.00.mp4", + "/clips/./back_door-1.jpg", + ] + for uri in traversal_uris: + self.assertEqual(self._deny(uri, "limited_user"), 403, uri) + self.assertEqual(self._deny(uri, "admin"), 403, uri) + self.assertEqual(self._deny(uri, "viewer"), 403, uri) + + def test_traversal_outside_media_passes_through(self): + # `..` in non-media URIs is not our problem; the backend handles it. + self.assertIsNone(self._deny("/api/foo/../bar", "limited_user")) + + def test_percent_encoded_traversal_denied(self): + # nginx may decode percent-encoded `%2E%2E` to `..` before serving; + # we must apply the same denial after percent-decoding. + self.assertEqual( + self._deny( + "/recordings/2026-05-11/14/front_door/%2E%2E/back_door/00.mp4", + "limited_user", + ), + 403, + ) + + def test_unresolved_media_fails_closed_for_restricted(self): + # Restricted user requesting a media URI we can't classify (no DB row, + # unknown clip prefix, unknown clip subtree) must be denied. + self.assertEqual(self._deny("/clips/nonexistent-1.jpg", "limited_user"), 403) + self.assertEqual(self._deny("/clips/random_dir/foo.jpg", "limited_user"), 403) + self.assertEqual( + self._deny("/clips/review/thumb-unknown_cam-1.webp", "limited_user"), + 403, + ) + + def test_unresolved_media_allowed_for_admin(self): + # Admin and full-access roles are *not* denied on UNRESOLVED_MEDIA — + # nginx returns 404 if the file doesn't exist on disk anyway, and we + # don't want a stale DB to lock out admins. + self.assertIsNone(self._deny("/clips/nonexistent-1.jpg", "admin")) + self.assertIsNone(self._deny("/clips/nonexistent-1.jpg", "viewer")) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_output_ws_auth.py b/frigate/test/test_output_ws_auth.py new file mode 100644 index 0000000000..ea4834ef13 --- /dev/null +++ b/frigate/test/test_output_ws_auth.py @@ -0,0 +1,57 @@ +"""Tests for JSMPEG websocket authorization.""" + +import unittest +from types import SimpleNamespace + +from frigate.config import FrigateConfig +from frigate.output.ws_auth import ws_has_camera_access + + +class TestWsHasCameraAccess(unittest.TestCase): + def setUp(self): + self.config = FrigateConfig( + mqtt={"host": "mqtt"}, + auth={"roles": {"limited_user": ["front_door"]}}, + cameras={ + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + "back_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, + ) + + def _make_ws(self, role: str): + return SimpleNamespace(environ={"HTTP_REMOTE_ROLE": role}) + + def test_restricted_role_only_gets_allowed_camera(self): + ws = self._make_ws("limited_user") + self.assertTrue(ws_has_camera_access(ws, "front_door", self.config)) + self.assertFalse(ws_has_camera_access(ws, "back_door", self.config)) + + def test_unrestricted_role_can_access_any_camera(self): + ws = self._make_ws("viewer") + self.assertTrue(ws_has_camera_access(ws, "front_door", self.config)) + self.assertTrue(ws_has_camera_access(ws, "back_door", self.config)) + + def test_birdseye_requires_unrestricted_access(self): + self.assertTrue( + ws_has_camera_access(self._make_ws("admin"), "birdseye", self.config) + ) + self.assertTrue( + ws_has_camera_access(self._make_ws("viewer"), "birdseye", self.config) + ) + self.assertFalse( + ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config) + ) diff --git a/frigate/test/test_webpush_camera_monitoring.py b/frigate/test/test_webpush_camera_monitoring.py new file mode 100644 index 0000000000..fa9172ad20 --- /dev/null +++ b/frigate/test/test_webpush_camera_monitoring.py @@ -0,0 +1,29 @@ +"""Tests for camera monitoring notification authorization.""" + +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock + +from frigate.comms.webpush import WebPushClient + + +class TestCameraMonitoringNotifications(unittest.TestCase): + def test_send_camera_monitoring_filters_by_camera_access(self): + client = WebPushClient.__new__(WebPushClient) + client.config = SimpleNamespace( + cameras={"front_door": SimpleNamespace(friendly_name=None)} + ) + client.web_pushers = {"allowed": [], "denied": []} + client.user_cameras = {"allowed": {"front_door"}, "denied": set()} + client.check_registrations = MagicMock() + client.cleanup_registrations = MagicMock() + client.send_push_notification = MagicMock() + + client.send_camera_monitoring( + {"camera": "front_door", "message": "Monitoring condition met"} + ) + + self.assertEqual(client.send_push_notification.call_count, 1) + self.assertEqual( + client.send_push_notification.call_args.kwargs["user"], "allowed" + ) diff --git a/frigate/test/test_ws_auth.py b/frigate/test/test_ws_auth.py new file mode 100644 index 0000000000..b762f4384c --- /dev/null +++ b/frigate/test/test_ws_auth.py @@ -0,0 +1,166 @@ +"""Tests for WebSocket authorization checks.""" + +import unittest + +from frigate.comms.ws import _check_ws_authorization +from frigate.const import INSERT_MANY_RECORDINGS, UPDATE_CAMERA_ACTIVITY + + +class TestCheckWsAuthorization(unittest.TestCase): + """Tests for the _check_ws_authorization pure function.""" + + DEFAULT_SEPARATOR = "," + + # --- IPC topic blocking (unconditional, regardless of role) --- + + def test_ipc_topic_blocked_for_admin(self): + self.assertFalse( + _check_ws_authorization( + INSERT_MANY_RECORDINGS, "admin", self.DEFAULT_SEPARATOR + ) + ) + + def test_ipc_topic_blocked_for_viewer(self): + self.assertFalse( + _check_ws_authorization( + UPDATE_CAMERA_ACTIVITY, "viewer", self.DEFAULT_SEPARATOR + ) + ) + + def test_ipc_topic_blocked_when_no_role(self): + self.assertFalse( + _check_ws_authorization( + INSERT_MANY_RECORDINGS, None, self.DEFAULT_SEPARATOR + ) + ) + + # --- Viewer allowed topics --- + + def test_viewer_can_send_on_connect(self): + self.assertTrue( + _check_ws_authorization("onConnect", "viewer", self.DEFAULT_SEPARATOR) + ) + + def test_viewer_can_send_model_state(self): + self.assertTrue( + _check_ws_authorization("modelState", "viewer", self.DEFAULT_SEPARATOR) + ) + + def test_viewer_can_send_audio_transcription_state(self): + self.assertTrue( + _check_ws_authorization( + "audioTranscriptionState", "viewer", self.DEFAULT_SEPARATOR + ) + ) + + def test_viewer_can_send_birdseye_layout(self): + self.assertTrue( + _check_ws_authorization("birdseyeLayout", "viewer", self.DEFAULT_SEPARATOR) + ) + + def test_viewer_can_send_embeddings_reindex_progress(self): + self.assertTrue( + _check_ws_authorization( + "embeddingsReindexProgress", "viewer", self.DEFAULT_SEPARATOR + ) + ) + + # --- Viewer blocked from admin topics --- + + def test_viewer_blocked_from_restart(self): + self.assertFalse( + _check_ws_authorization("restart", "viewer", self.DEFAULT_SEPARATOR) + ) + + def test_viewer_blocked_from_camera_detect_set(self): + self.assertFalse( + _check_ws_authorization( + "front_door/detect/set", "viewer", self.DEFAULT_SEPARATOR + ) + ) + + def test_viewer_blocked_from_camera_ptz(self): + self.assertFalse( + _check_ws_authorization("front_door/ptz", "viewer", self.DEFAULT_SEPARATOR) + ) + + def test_viewer_blocked_from_global_notifications_set(self): + self.assertFalse( + _check_ws_authorization( + "notifications/set", "viewer", self.DEFAULT_SEPARATOR + ) + ) + + def test_viewer_blocked_from_camera_notifications_suspend(self): + self.assertFalse( + _check_ws_authorization( + "front_door/notifications/suspend", "viewer", self.DEFAULT_SEPARATOR + ) + ) + + def test_viewer_blocked_from_arbitrary_unknown_topic(self): + self.assertFalse( + _check_ws_authorization( + "some_random_topic", "viewer", self.DEFAULT_SEPARATOR + ) + ) + + # --- Admin access --- + + def test_admin_can_send_restart(self): + self.assertTrue( + _check_ws_authorization("restart", "admin", self.DEFAULT_SEPARATOR) + ) + + def test_admin_can_send_camera_detect_set(self): + self.assertTrue( + _check_ws_authorization( + "front_door/detect/set", "admin", self.DEFAULT_SEPARATOR + ) + ) + + def test_admin_can_send_camera_ptz(self): + self.assertTrue( + _check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR) + ) + + # --- Comma-separated roles --- + + def test_comma_separated_admin_viewer_grants_admin(self): + self.assertTrue( + _check_ws_authorization("restart", "admin,viewer", self.DEFAULT_SEPARATOR) + ) + + def test_comma_separated_viewer_admin_grants_admin(self): + self.assertTrue( + _check_ws_authorization("restart", "viewer,admin", self.DEFAULT_SEPARATOR) + ) + + def test_comma_separated_with_spaces(self): + self.assertTrue( + _check_ws_authorization("restart", "viewer, admin", self.DEFAULT_SEPARATOR) + ) + + # --- Custom separator --- + + def test_pipe_separator(self): + self.assertTrue(_check_ws_authorization("restart", "viewer|admin", "|")) + + def test_pipe_separator_no_admin(self): + self.assertFalse(_check_ws_authorization("restart", "viewer|editor", "|")) + + # --- No role header (fail-closed) --- + + def test_no_role_header_blocks_admin_topics(self): + self.assertFalse( + _check_ws_authorization("restart", None, self.DEFAULT_SEPARATOR) + ) + + def test_no_role_header_allows_viewer_topics(self): + self.assertTrue( + _check_ws_authorization("onConnect", None, self.DEFAULT_SEPARATOR) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 3fae8da6f4..3f8b5e626d 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -773,7 +773,9 @@ class TrackedObjectProcessor(threading.Thread): logger.debug(f"Camera {camera} disabled, skipping update") continue - camera_state = self.camera_states[camera] + camera_state = self.camera_states.get(camera) + if camera_state is None: + continue camera_state.update( frame_name, frame_time, current_tracked_objects, motion_boxes, regions diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 4fda92afd0..03117df692 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -330,7 +330,12 @@ class TrackedObject: if self.obj_data["position_changes"] != obj_data["position_changes"]: significant_change = True - if self.obj_data["attributes"] != obj_data["attributes"]: + # disappearance of a per-frame attribute can be caused by detection + # skipping the object on a frame (stationary objects on non-interval + # frames), so only flag when a new attribute label appears + prev_labels = {a["label"] for a in self.obj_data["attributes"]} + curr_labels = {a["label"] for a in obj_data["attributes"]} + if curr_labels - prev_labels: significant_change = True # if the state changed between stationary and active @@ -526,8 +531,7 @@ class TrackedObject: directory = os.path.join(THUMB_DIR, self.camera_config.name) - if not os.path.exists(directory): - os.makedirs(directory) + os.makedirs(directory, exist_ok=True) thumb_bytes = self.get_thumbnail("webp") diff --git a/frigate/util/classification.py b/frigate/util/classification.py index ada3ee1f71..66bacdeb04 100644 --- a/frigate/util/classification.py +++ b/frigate/util/classification.py @@ -24,8 +24,12 @@ from frigate.log import redirect_output_to_logger, suppress_stderr_during from frigate.models import Event, Recordings, ReviewSegment from frigate.types import ModelStatusTypesEnum from frigate.util.downloader import ModelDownloader -from frigate.util.file import get_event_thumbnail_bytes -from frigate.util.image import get_image_from_recording +from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image +from frigate.util.image import ( + calculate_region, + get_image_from_recording, + relative_box_to_absolute, +) from frigate.util.process import FrigateProcess BATCH_SIZE = 16 @@ -713,7 +717,7 @@ def collect_object_classification_examples( This function: 1. Queries events for the specified label 2. Selects 100 balanced events across different cameras and times - 3. Retrieves thumbnails for selected events (with 33% center crop applied) + 3. Crops each event's clean snapshot around the object bounding box 4. Selects 24 most visually distinct thumbnails 5. Saves to dataset directory @@ -832,66 +836,106 @@ def _select_balanced_events( def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]: """ - Extract thumbnails from events and save to disk. + Extract a training image for each event. + + Preferred path: load the full-frame clean snapshot and crop around the + stored bounding box with the same calculate_region(..., max(w, h), 1.0) + call the live ObjectClassificationProcessor uses, so wizard examples + are framed like inference-time inputs. + + Fallback: if no clean snapshot exists (snapshots disabled, or only a + legacy annotated JPG is on disk), center-crop the stored thumbnail + using a step ladder sized from the box/region area ratio. Args: events: List of Event objects - output_dir: Directory to save thumbnails + output_dir: Directory to save crops Returns: - List of paths to successfully extracted thumbnail images + List of paths to successfully extracted images """ - thumbnail_paths = [] + image_paths = [] for idx, event in enumerate(events): try: - thumbnail_bytes = get_event_thumbnail_bytes(event) + img = _load_event_classification_crop(event) + if img is None: + continue - if thumbnail_bytes: - nparr = np.frombuffer(thumbnail_bytes, np.uint8) - img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - - if img is not None: - height, width = img.shape[:2] - - crop_size = 1.0 - if event.data and "box" in event.data and "region" in event.data: - box = event.data["box"] - region = event.data["region"] - - if len(box) == 4 and len(region) == 4: - box_w, box_h = box[2], box[3] - region_w, region_h = region[2], region[3] - - box_area = (box_w * box_h) / (region_w * region_h) - - if box_area < 0.05: - crop_size = 0.4 - elif box_area < 0.10: - crop_size = 0.5 - elif box_area < 0.20: - crop_size = 0.65 - elif box_area < 0.35: - crop_size = 0.80 - else: - crop_size = 0.95 - - crop_width = int(width * crop_size) - crop_height = int(height * crop_size) - - x1 = (width - crop_width) // 2 - y1 = (height - crop_height) // 2 - x2 = x1 + crop_width - y2 = y1 + crop_height - - cropped = img[y1:y2, x1:x2] - resized = cv2.resize(cropped, (224, 224)) - output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg") - cv2.imwrite(output_path, resized) - thumbnail_paths.append(output_path) + resized = cv2.resize(img, (224, 224)) + output_path = os.path.join(output_dir, f"thumbnail_{idx:04d}.jpg") + cv2.imwrite(output_path, resized) + image_paths.append(output_path) except Exception as e: - logger.debug(f"Failed to extract thumbnail for event {event.id}: {e}") + logger.debug(f"Failed to extract image for event {event.id}: {e}") continue - return thumbnail_paths + return image_paths + + +def _load_event_classification_crop(event: Event) -> np.ndarray | None: + """Prefer a snapshot-based object crop; fall back to a center-cropped thumbnail.""" + if event.data and "box" in event.data: + snapshot, _ = load_event_snapshot_image(event, clean_only=True) + if snapshot is not None: + abs_box = relative_box_to_absolute(snapshot.shape, event.data["box"]) + if abs_box is not None: + xmin, ymin, xmax, ymax = abs_box + box_w = xmax - xmin + box_h = ymax - ymin + if box_w > 0 and box_h > 0: + x1, y1, x2, y2 = calculate_region( + snapshot.shape, + xmin, + ymin, + xmax, + ymax, + max(box_w, box_h), + 1.0, + ) + cropped = snapshot[y1:y2, x1:x2] + if cropped.size > 0: + return cropped + + thumbnail_bytes = get_event_thumbnail_bytes(event) + if not thumbnail_bytes: + return None + + nparr = np.frombuffer(thumbnail_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None or img.size == 0: + return None + + height, width = img.shape[:2] + crop_size = 1.0 + + if event.data and "box" in event.data and "region" in event.data: + box = event.data["box"] + region = event.data["region"] + + if len(box) == 4 and len(region) == 4: + box_w, box_h = box[2], box[3] + region_w, region_h = region[2], region[3] + box_area = (box_w * box_h) / (region_w * region_h) + + if box_area < 0.05: + crop_size = 0.4 + elif box_area < 0.10: + crop_size = 0.5 + elif box_area < 0.20: + crop_size = 0.65 + elif box_area < 0.35: + crop_size = 0.80 + else: + crop_size = 0.95 + + crop_width = int(width * crop_size) + crop_height = int(height * crop_size) + x1 = (width - crop_width) // 2 + y1 = (height - crop_height) // 2 + cropped = img[y1 : y1 + crop_height, x1 : x1 + crop_width] + if cropped.size == 0: + return None + + return cropped diff --git a/frigate/util/config.py b/frigate/util/config.py index 578ec18527..71e2af809d 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -492,7 +492,7 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] genai = new_config.get("genai") if genai and genai.get("provider"): - genai["roles"] = ["embeddings", "vision", "tools"] + genai["roles"] = ["embeddings", "descriptions", "chat"] new_config["genai"] = {"default": genai} # Remove deprecated sync_recordings from global record config @@ -608,11 +608,14 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] def get_relative_coordinates( - mask: Optional[Union[str, list]], frame_shape: tuple[int, int] + mask: Optional[Union[str, list]], + frame_shape: tuple[int, int], + camera_name: str = "", ) -> Union[str, list]: # masks and zones are saved as relative coordinates # we know if any points are > 1 then it is using the # old native resolution coordinates + where = f" for camera {camera_name}" if camera_name else "" if mask: if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")): relative_masks = [] @@ -627,7 +630,7 @@ def get_relative_coordinates( if x > frame_shape[1] or y > frame_shape[0]: logger.error( - f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask." + f"Not applying mask due to invalid coordinates{where}. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask." ) continue @@ -650,7 +653,7 @@ def get_relative_coordinates( if x > frame_shape[1] or y > frame_shape[0]: logger.error( - f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask." + f"Not applying mask due to invalid coordinates{where}. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask." ) return [] diff --git a/frigate/util/ffmpeg.py b/frigate/util/ffmpeg.py index 9abacd4ed6..9f4c5569ab 100644 --- a/frigate/util/ffmpeg.py +++ b/frigate/util/ffmpeg.py @@ -2,8 +2,9 @@ import logging import subprocess as sp -from typing import Any +from typing import Any, Callable, Optional +from frigate.const import PROCESS_PRIORITY_LOW from frigate.log import LogPipe @@ -46,3 +47,124 @@ def start_or_restart_ffmpeg( start_new_session=True, ) return process + + +logger = logging.getLogger(__name__) + + +def inject_progress_flags(cmd: list[str]) -> list[str]: + """Insert `-progress pipe:2 -nostats` immediately before the output path. + + `-progress pipe:2` writes structured key=value lines to stderr; + `-nostats` suppresses the noisy default stats output. The output path + is conventionally the last token in an FFmpeg argv. + """ + if not cmd: + return cmd + return cmd[:-1] + ["-progress", "pipe:2", "-nostats", cmd[-1]] + + +def run_ffmpeg_with_progress( + cmd: list[str], + *, + expected_duration_seconds: float, + on_progress: Optional[Callable[[float], None]] = None, + stdin_payload: Optional[str] = None, + process_started: Optional[Callable[[sp.Popen], None]] = None, + use_low_priority: bool = True, +) -> tuple[int, str]: + """Run an ffmpeg command, streaming progress via `-progress pipe:2`. + + Args: + cmd: ffmpeg argv. Output path must be the last token. + expected_duration_seconds: Duration of the expected output clip in + seconds. Used to convert ffmpeg's `out_time_us` into a percent. + on_progress: Optional callback invoked with a percent in [0, 100]. + Called once with 0.0 at start, again on each `out_time_us=` + stderr line, and once with 100.0 on `progress=end`. + stdin_payload: Optional string written to ffmpeg stdin (used by + export for concat playlists). + process_started: Optional callback invoked with the live `Popen` + once spawned — lets callers store the ref for cancellation. + use_low_priority: When True, prepend `nice -n PROCESS_PRIORITY_LOW` + so concat doesn't starve detection. + + Returns: + Tuple of `(returncode, captured_stderr)`. Stdout is left attached + to the parent process to avoid buffer-full deadlocks. + """ + full_cmd = inject_progress_flags(cmd) + if use_low_priority: + full_cmd = ["nice", "-n", str(PROCESS_PRIORITY_LOW)] + full_cmd + + def emit(percent: float) -> None: + if on_progress is None: + return + try: + on_progress(max(0.0, min(100.0, percent))) + except Exception: + logger.exception("FFmpeg progress callback failed") + + emit(0.0) + + proc = sp.Popen( + full_cmd, + stdin=sp.PIPE if stdin_payload is not None else None, + stderr=sp.PIPE, + text=True, + encoding="ascii", + errors="replace", + ) + if process_started is not None: + try: + process_started(proc) + except Exception: + logger.exception("FFmpeg process_started callback failed") + + if stdin_payload is not None and proc.stdin is not None: + try: + proc.stdin.write(stdin_payload) + except (BrokenPipeError, OSError): + pass + finally: + try: + proc.stdin.close() + except (BrokenPipeError, OSError): + pass + + captured: list[str] = [] + if proc.stderr is not None: + try: + for raw_line in proc.stderr: + captured.append(raw_line) + line = raw_line.strip() + if not line: + continue + if line.startswith("out_time_us="): + if expected_duration_seconds <= 0: + continue + try: + out_time_us = int(line.split("=", 1)[1]) + except (ValueError, IndexError): + continue + if out_time_us < 0: + continue + out_seconds = out_time_us / 1_000_000.0 + emit((out_seconds / expected_duration_seconds) * 100.0) + elif line == "progress=end": + emit(100.0) + break + except Exception: + logger.exception("Failed reading FFmpeg progress stream") + + proc.wait() + + if proc.stderr is not None: + try: + remaining = proc.stderr.read() + if remaining: + captured.append(remaining) + except Exception: + pass + + return proc.returncode or 0, "".join(captured) diff --git a/frigate/util/media.py b/frigate/util/media.py index 38f5698067..31374c5960 100644 --- a/frigate/util/media.py +++ b/frigate/util/media.py @@ -94,7 +94,7 @@ def remove_empty_directories(root: Path, paths: Iterable[Path]) -> None: paths = parents - logger.debug("Removed {count} empty directories") + logger.debug(f"Removed {count} empty directories") def sync_recordings( diff --git a/frigate/util/services.py b/frigate/util/services.py index f0bf2de1ed..4a715608e2 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -264,156 +264,237 @@ def get_amd_gpu_stats() -> Optional[dict[str, str]]: return results -def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, str]]: - """Get stats using intel_gpu_top. +_INTEL_FDINFO_SAMPLE_SECONDS = 1.0 - Returns overall GPU usage derived from rc6 residency (idle time), - plus individual engine breakdowns: - - enc: Render/3D engine (compute/shader encoder, used by QSV) - - dec: Video engines (fixed-function codec, used by VAAPI) +# Engines we track. Render/3D and Compute are pooled into "compute"; Video and +# VideoEnhance into "dec" (VideoEnhance is the post-process engine that handles +# VAAPI scaling/deinterlace/CSC, e.g. ffmpeg `-vf scale_vaapi=...`). The Copy +# (DMA blitter) engine is intentionally ignored — it represents transparent +# memory transfers, not user-visible GPU work. +# i915 fdinfo keys (cumulative ns) → logical engine name. +_I915_ENGINE_KEYS = { + "drm-engine-render": "render", + "drm-engine-video": "video", + "drm-engine-video-enhance": "video-enhance", + "drm-engine-compute": "compute", +} +# Xe fdinfo suffixes (cumulative cycles, paired with drm-total-cycles-*). +_XE_ENGINE_KEYS = { + "rcs": "render", + "vcs": "video", + "vecs": "video-enhance", + "ccs": "compute", +} + + +def _resolve_intel_gpu_pdev(device: Optional[str]) -> Optional[str]: + """Map a configured GPU hint (/dev/dri/card1, renderD128, or a PCI bus + address) to its drm-pdev string so we can filter fdinfo entries to that + device. Returns None when no hint is supplied or it cannot be resolved.""" + if not device: + return None + + if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$", device): + return device + + name = os.path.basename(device.rstrip("/")) + try: + return os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device")) + except OSError: + return None + + +def _read_intel_drm_fdinfo(target_pdev: Optional[str]) -> dict: + """Snapshot DRM fdinfo for every Intel client visible in /proc. + + Returns a dict keyed by (pdev, drm-client-id, pid) so the same context + seen via multiple file descriptors on a single process collapses to one + entry. """ - - def get_stats_manually(output: str) -> dict[str, str]: - """Find global stats via regex when json fails to parse.""" - reading = "".join(output) - results: dict[str, str] = {} - - # rc6 residency for overall GPU usage - rc6_match = re.search(r'"rc6":\{"value":([\d.]+)', reading) - if rc6_match: - rc6_value = float(rc6_match.group(1)) - results["gpu"] = f"{round(100.0 - rc6_value, 2)}%" - else: - results["gpu"] = "-%" - - results["mem"] = "-%" - - # Render/3D is the compute/encode engine - render = [] - for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading): - packet = json.loads(result[14:]) - single = packet.get("busy", 0.0) - render.append(float(single)) - - if render: - results["compute"] = f"{round(sum(render) / len(render), 2)}%" - - # Video engines are the fixed-function decode engines - video = [] - for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading): - packet = json.loads(result[10:]) - single = packet.get("busy", 0.0) - video.append(float(single)) - - if video: - results["dec"] = f"{round(sum(video) / len(video), 2)}%" - - return results - - intel_gpu_top_command = [ - "timeout", - "0.5s", - "intel_gpu_top", - "-J", - "-o", - "-", - "-s", - "1000", # Intel changed this from seconds to milliseconds in 2024+ versions - ] - - if intel_gpu_device: - intel_gpu_top_command += ["-d", intel_gpu_device] + snapshot: dict = {} try: - p = sp.run( - intel_gpu_top_command, - encoding="ascii", - capture_output=True, - ) - except UnicodeDecodeError: - return None + proc_entries = os.listdir("/proc") + except OSError: + return snapshot - # timeout has a non-zero returncode when timeout is reached - if p.returncode != 124: - logger.error(f"Unable to poll intel GPU stats: {p.stderr}") - return None - else: - output = "".join(p.stdout.split()) + for entry in proc_entries: + if not entry.isdigit(): + continue + fdinfo_dir = f"/proc/{entry}/fdinfo" try: - data = json.loads(f"[{output}]") - except json.JSONDecodeError: - return get_stats_manually(output) + fds = os.listdir(fdinfo_dir) + except (FileNotFoundError, PermissionError, NotADirectoryError, OSError): + continue - results: dict[str, str] = {} - rc6_values = [] - render_global = [] - video_global = [] - # per-client: {pid: [total_busy_per_sample, ...]} - client_usages: dict[str, list[float]] = {} + for fd in fds: + try: + with open(f"{fdinfo_dir}/{fd}") as f: + content = f.read() + except (FileNotFoundError, PermissionError, OSError): + continue - for block in data: - # rc6 residency: percentage of time GPU is idle - rc6 = block.get("rc6", {}).get("value") - if rc6 is not None: - rc6_values.append(float(rc6)) + if "drm-driver" not in content: + continue - global_engine = block.get("engines") + fields: dict[str, str] = {} + for line in content.splitlines(): + key, sep, value = line.partition(":") + if sep: + fields[key.strip()] = value.strip() - if global_engine: - render_frame = global_engine.get("Render/3D/0", {}).get("busy") - video_frame = global_engine.get("Video/0", {}).get("busy") + driver = fields.get("drm-driver") + if driver not in ("i915", "xe"): + continue - if render_frame is not None: - render_global.append(float(render_frame)) + pdev = fields.get("drm-pdev", "") + if target_pdev and pdev != target_pdev: + continue - if video_frame is not None: - video_global.append(float(video_frame)) + client_id = fields.get("drm-client-id") + if not client_id: + continue - clients = block.get("clients", {}) + key = (pdev, client_id, entry) + if key in snapshot: + continue - if clients: - for client_block in clients.values(): - pid = client_block["pid"] + engines: dict[str, tuple[int, int]] = {} - if pid not in client_usages: - client_usages[pid] = [] + if driver == "i915": + for fkey, engine in _I915_ENGINE_KEYS.items(): + raw = fields.get(fkey) + if not raw: + continue + try: + engines[engine] = (int(raw.split()[0]), 0) + except (ValueError, IndexError): + continue + else: + for suffix, engine in _XE_ENGINE_KEYS.items(): + busy_raw = fields.get(f"drm-cycles-{suffix}") + total_raw = fields.get(f"drm-total-cycles-{suffix}") + if not (busy_raw and total_raw): + continue + try: + engines[engine] = ( + int(busy_raw.split()[0]), + int(total_raw.split()[0]), + ) + except (ValueError, IndexError): + continue - # Sum all engine-class busy values for this client - total_busy = 0.0 - for engine in client_block.get("engine-classes", {}).values(): - busy = engine.get("busy") - if busy is not None: - total_busy += float(busy) + if not engines: + continue - client_usages[pid].append(total_busy) + snapshot[key] = {"driver": driver, "pid": entry, "engines": engines} - # Overall GPU usage from rc6 (idle) residency - if rc6_values: - rc6_avg = sum(rc6_values) / len(rc6_values) - results["gpu"] = f"{round(100.0 - rc6_avg, 2)}%" + return snapshot - results["mem"] = "-%" - # Compute: Render/3D engine (compute/shader workloads and QSV encode) - if render_global: - results["compute"] = f"{round(sum(render_global) / len(render_global), 2)}%" +def get_intel_gpu_stats( + intel_gpu_device: Optional[str], +) -> Optional[dict[str, dict[str, Any]]]: + """Get stats by reading DRM fdinfo files, bucketed per-pdev. - # Decoder: Video engine (fixed-function codec) - if video_global: - results["dec"] = f"{round(sum(video_global) / len(video_global), 2)}%" + Each DRM client FD exposes monotonic per-engine busy counters via + /proc//fdinfo/ (i915 since kernel 5.19, Xe since first release). + We sample twice and divide busy-time deltas by wall-clock to derive + utilization. Render/3D and Compute are pooled into "compute"; Video and + VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped + to 100%). - # Per-client GPU usage (sum of all engines per process) - if client_usages: - results["clients"] = {} + The return value is keyed by the GPU's drm-pdev string so multiple Intel + GPUs in the same system are reported separately. Each entry carries a + "name" populated from OpenVINO (falling back to the pdev) so callers can + surface a real device name in the UI. + """ + from frigate.stats.intel_gpu_info import intel_gpu_name_resolver - for pid, samples in client_usages.items(): - if samples: - results["clients"][pid] = ( - f"{round(sum(samples) / len(samples), 2)}%" - ) + target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device) - return results + snapshot_a = _read_intel_drm_fdinfo(target_pdev) + if not snapshot_a: + return None + + start = time.monotonic() + time.sleep(_INTEL_FDINFO_SAMPLE_SECONDS) + elapsed_ns = (time.monotonic() - start) * 1e9 + + snapshot_b = _read_intel_drm_fdinfo(target_pdev) + if not snapshot_b or elapsed_ns <= 0: + return None + + def _new_engine_pct() -> dict[str, float]: + return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0} + + per_pdev_engine_pct: dict[str, dict[str, float]] = {} + per_pdev_pid_pct: dict[str, dict[str, float]] = {} + + for key, data_b in snapshot_b.items(): + data_a = snapshot_a.get(key) + if not data_a or data_a["driver"] != data_b["driver"]: + continue + + pdev = key[0] + engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct()) + pid_pct = per_pdev_pid_pct.setdefault(pdev, {}) + + client_total = 0.0 + for engine, (busy_b, total_b) in data_b["engines"].items(): + if engine not in engine_pct: + continue + + busy_a, total_a = data_a["engines"].get(engine, (busy_b, total_b)) + + if data_b["driver"] == "i915": + delta = max(0, busy_b - busy_a) + pct = min(100.0, delta / elapsed_ns * 100.0) + else: + delta_busy = max(0, busy_b - busy_a) + delta_total = total_b - total_a + if delta_total <= 0: + continue + pct = min(100.0, delta_busy / delta_total * 100.0) + + engine_pct[engine] += pct + client_total += pct + + pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total + + if not per_pdev_engine_pct: + return None + + names = intel_gpu_name_resolver.get_names() + results: dict[str, dict[str, Any]] = {} + + for pdev, engine_pct in per_pdev_engine_pct.items(): + for engine in engine_pct: + engine_pct[engine] = min(100.0, engine_pct[engine]) + + compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"]) + dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"]) + overall_pct = min(100.0, compute_pct + dec_pct) + + entry: dict[str, Any] = { + "name": names.get(pdev) or f"Intel GPU {pdev}", + "vendor": "intel", + "gpu": f"{round(overall_pct, 2)}%", + "mem": "-%", + "compute": f"{round(compute_pct, 2)}%", + "dec": f"{round(dec_pct, 2)}%", + } + + pid_pct = per_pdev_pid_pct.get(pdev) + if pid_pct: + entry["clients"] = { + pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items() + } + + results[pdev] = entry + + return results def get_openvino_npu_stats() -> Optional[dict[str, str]]: @@ -697,6 +778,41 @@ def get_hailo_temps() -> dict[str, float]: return temps +def _go2rtc_arbitrary_exec_allowed() -> bool: + """Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker + secrets, or the Home Assistant add-on options file.""" + raw: Optional[str] = None + if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: + raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") + elif ( + os.path.isdir("/run/secrets") + and os.access("/run/secrets", os.R_OK) + and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets") + ): + try: + with open("/run/secrets/GO2RTC_ALLOW_ARBITRARY_EXEC") as f: + raw = f.read().strip() + except OSError: + raw = None + elif os.path.isfile("/data/options.json"): + try: + with open("/data/options.json") as f: + options = json.loads(f.read()) + raw = options.get("go2rtc_allow_arbitrary_exec") + except (OSError, json.JSONDecodeError): + raw = None + + return raw is not None and str(raw).lower() in ("true", "1", "yes") + + +def is_restricted_go2rtc_source(stream_source: str) -> bool: + """Check if a stream source is a restricted type (echo, expr, or exec) + and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.""" + if not stream_source.strip().startswith(("echo:", "expr:", "exec:")): + return False + return not _go2rtc_arbitrary_exec_allowed() + + def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: """Run ffprobe on stream.""" clean_path = escape_special_characters(path) @@ -711,23 +827,44 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro else: format_entries = None - ffprobe_cmd = [ - ffmpeg.ffprobe_path, - "-timeout", - "1000000", - "-print_format", - "json", - "-show_entries", - f"stream={stream_entries}", - ] + def run(rtsp_transport: Optional[str] = None) -> sp.CompletedProcess: + cmd = [ffmpeg.ffprobe_path] + if rtsp_transport: + cmd += ["-rtsp_transport", rtsp_transport] + cmd += [ + "-timeout", + "1000000", + "-print_format", + "json", + "-show_entries", + f"stream={stream_entries}", + ] + if detailed and format_entries: + cmd.extend(["-show_entries", f"format={format_entries}"]) + cmd.extend(["-loglevel", "error", clean_path]) + try: + return sp.run(cmd, capture_output=True, timeout=6) + except sp.TimeoutExpired as e: + logger.info( + "ffprobe timed out while probing %s (transport=%s)", + clean_camera_user_pass(path), + rtsp_transport or "default", + ) + return sp.CompletedProcess( + args=cmd, + returncode=1, + stdout=e.stdout or b"", + stderr=(e.stderr or b"") + b"\nffprobe timed out", + ) - # Add format entries for detailed mode - if detailed and format_entries: - ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"]) + result = run() - ffprobe_cmd.extend(["-loglevel", "error", clean_path]) + # For RTSP: retry with explicit TCP transport if the first attempt failed + # (default UDP may be blocked) + if result.returncode != 0 and clean_path.startswith("rtsp://"): + result = run(rtsp_transport="tcp") - return sp.run(ffprobe_cmd, capture_output=True) + return result def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess: @@ -807,10 +944,15 @@ async def get_video_properties( ) -> dict[str, Any]: async def probe_with_ffprobe( url: str, + rtsp_transport: Optional[str] = None, ) -> tuple[bool, int, int, Optional[str], float]: """Fallback using ffprobe: returns (valid, width, height, codec, duration).""" - cmd = [ - ffmpeg.ffprobe_path, + cmd = [ffmpeg.ffprobe_path] + if rtsp_transport: + cmd += ["-rtsp_transport", rtsp_transport] + cmd += [ + "-rw_timeout", + "5000000", "-v", "quiet", "-print_format", @@ -819,11 +961,23 @@ async def get_video_properties( "-show_streams", url, ] + proc = None try: proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - stdout, _ = await proc.communicate() + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=6) + except asyncio.TimeoutError: + logger.info( + "ffprobe timed out while probing %s (transport=%s)", + clean_camera_user_pass(url), + rtsp_transport or "default", + ) + proc.kill() + await proc.wait() + return False, 0, 0, None, -1 + if proc.returncode != 0: return False, 0, 0, None, -1 @@ -872,12 +1026,26 @@ async def get_video_properties( cap.release() return valid, width, height, fourcc, duration - # try cv2 first - has_video, width, height, fourcc, duration = probe_with_cv2(url) + is_rtsp = url.startswith("rtsp://") - # fallback to ffprobe if needed - if not has_video or (get_duration and duration < 0): + if is_rtsp: + # skip cv2 for RTSP: its FFmpeg backend has a hardcoded ~30s internal + # timeout that cannot be shortened per-call, and ffprobe bounded by + # -rw_timeout handles RTSP probing reliably has_video, width, height, fourcc, duration = await probe_with_ffprobe(url) + else: + # try cv2 first for local files, HTTP, RTMP + has_video, width, height, fourcc, duration = probe_with_cv2(url) + + # fallback to ffprobe if needed + if not has_video or (get_duration and duration < 0): + has_video, width, height, fourcc, duration = await probe_with_ffprobe(url) + + # last resort for RTSP: try TCP transport, since default UDP may be blocked + if (not has_video or (get_duration and duration < 0)) and is_rtsp: + has_video, width, height, fourcc, duration = await probe_with_ffprobe( + url, rtsp_transport="tcp" + ) result: dict[str, Any] = {"has_valid_video": has_video} if has_video: diff --git a/frigate/video/detect.py b/frigate/video/detect.py index 339b11e534..89124a75de 100644 --- a/frigate/video/detect.py +++ b/frigate/video/detect.py @@ -438,34 +438,32 @@ def process_frames( else: object_tracker.update_frame_times(frame_name, frame_time) - # group the attribute detections based on what label they apply to - attribute_detections: dict[str, list[TrackedObjectAttribute]] = {} - for label, attribute_labels in attributes_map.items(): - attribute_detections[label] = [ - TrackedObjectAttribute(d) - for d in consolidated_detections - if d[0] in attribute_labels - ] - # build detections detections = {} for obj in object_tracker.tracked_objects.values(): detections[obj["id"]] = {**obj, "attributes": []} - # find the best object for each attribute to be assigned to + # assign each detected attribute to the best matching object. + # iterate consolidated_detections once so attributes that appear under + # multiple parent labels in attributes_map (e.g. license_plate is in + # both "car" and "motorcycle") are not appended more than once all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values() - for attributes in attribute_detections.values(): - for attribute in attributes: - filtered_objects = filter( - lambda o: attribute.label in attributes_map.get(o["label"], []), - all_objects, - ) - selected_object_id = attribute.find_best_object(filtered_objects) + detected_attributes = [ + TrackedObjectAttribute(d) + for d in consolidated_detections + if d[0] in all_attributes + ] + for attribute in detected_attributes: + filtered_objects = filter( + lambda o: attribute.label in attributes_map.get(o["label"], []), + all_objects, + ) + selected_object_id = attribute.find_best_object(filtered_objects) - if selected_object_id is not None: - detections[selected_object_id]["attributes"].append( - attribute.get_tracking_data() - ) + if selected_object_id is not None: + detections[selected_object_id]["attributes"].append( + attribute.get_tracking_data() + ) # debug object tracking if False: diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py index d30dc3b188..e77c03b5e5 100644 --- a/frigate/video/ffmpeg.py +++ b/frigate/video/ffmpeg.py @@ -24,7 +24,7 @@ from frigate.config.camera.updater import ( ) from frigate.const import PROCESS_PRIORITY_HIGH from frigate.log import LogPipe -from frigate.util.builtin import EventsPerSecond +from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg from frigate.util.image import ( FrameManager, @@ -34,6 +34,23 @@ from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) +# all built-in record presets use this segment_time +DEFAULT_RECORD_SEGMENT_TIME = 10 + + +def _get_record_segment_time(config: CameraConfig) -> int: + """Extract -segment_time from the camera's record output args.""" + record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record) + + if record_args and record_args[0].startswith("preset"): + return DEFAULT_RECORD_SEGMENT_TIME + + try: + idx = record_args.index("-segment_time") + return int(record_args[idx + 1]) + except (ValueError, IndexError): + return DEFAULT_RECORD_SEGMENT_TIME + def capture_frames( ffmpeg_process: sp.Popen[Any], @@ -157,6 +174,7 @@ class CameraWatchdog(threading.Thread): ) self.requestor = InterProcessRequestor() self.was_enabled = self.config.enabled + self.was_record_enabled_in_config = self.config.record.enabled_in_config self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all) self.latest_valid_segment_time: float = 0 @@ -164,6 +182,12 @@ class CameraWatchdog(threading.Thread): self.latest_cache_segment_time: float = 0 self.record_enable_time: datetime | None = None + # `valid` segments are published with the segment's start time, so the + # gap between consecutive publishes can reach 2 * segment_time. Pad the + # staleness threshold so it's never tighter than that worst case. + segment_time = _get_record_segment_time(self.config) + self.record_stale_threshold = max(120, 2 * segment_time + 30) + # Stall tracking (based on last processed frame) self._stall_timestamps: deque[float] = deque() self._stall_active: bool = False @@ -300,6 +324,22 @@ class CameraWatchdog(threading.Thread): self.was_enabled = enabled continue + record_enabled_in_config = self.config.record.enabled_in_config + if record_enabled_in_config != self.was_record_enabled_in_config: + if record_enabled_in_config and enabled: + self.logger.debug( + f"Record enabled in config for {self.config.name}, restarting ffmpeg" + ) + self.stop_all_ffmpeg() + self.start_all_ffmpeg() + self.latest_valid_segment_time = 0 + self.latest_invalid_segment_time = 0 + self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(timezone.utc) + last_restart_time = datetime.now().timestamp() + self.was_record_enabled_in_config = record_enabled_in_config + continue + if not enabled: continue @@ -317,16 +357,16 @@ class CameraWatchdog(threading.Thread): if camera != self.config.name: continue - if topic.endswith(RecordingsDataTypeEnum.valid.value): - self.logger.debug( - f"Latest valid recording segment time on {camera}: {segment_time}" - ) - self.latest_valid_segment_time = segment_time - elif topic.endswith(RecordingsDataTypeEnum.invalid.value): + if topic.endswith(RecordingsDataTypeEnum.invalid.value): self.logger.warning( f"Invalid recording segment detected for {camera} at {segment_time}" ) self.latest_invalid_segment_time = segment_time + elif topic.endswith(RecordingsDataTypeEnum.valid.value): + self.logger.debug( + f"Latest valid recording segment time on {camera}: {segment_time}" + ) + self.latest_valid_segment_time = segment_time elif topic.endswith(RecordingsDataTypeEnum.latest.value): if segment_time is not None: self.latest_cache_segment_time = segment_time @@ -413,16 +453,17 @@ class CameraWatchdog(threading.Thread): # ensure segments are still being created and that they have valid video data # Skip checks during grace period to allow segments to start being created + stale_window = timedelta(seconds=self.record_stale_threshold) cache_stale = not in_grace_period and now_utc > ( - latest_cache_dt + timedelta(seconds=120) + latest_cache_dt + stale_window ) valid_stale = not in_grace_period and now_utc > ( - latest_valid_dt + timedelta(seconds=120) + latest_valid_dt + stale_window ) invalid_stale_condition = ( self.latest_invalid_segment_time > 0 and not in_grace_period - and now_utc > (latest_invalid_dt + timedelta(seconds=120)) + and now_utc > (latest_invalid_dt + stale_window) and self.latest_valid_segment_time <= self.latest_invalid_segment_time ) @@ -439,7 +480,7 @@ class CameraWatchdog(threading.Thread): ) self.logger.error( - f"{reason} for {self.config.name} in the last 120s. Restarting the ffmpeg record process..." + f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..." ) p["process"] = start_or_restart_ffmpeg( p["cmd"], diff --git a/frigate/watchdog.py b/frigate/watchdog.py index 63fd166298..7ae42d9883 100644 --- a/frigate/watchdog.py +++ b/frigate/watchdog.py @@ -28,6 +28,7 @@ class MonitoredProcess: restart_timestamps: deque[float] = field( default_factory=lambda: deque(maxlen=MAX_RESTARTS) ) + clean_exit_logged: bool = False def is_restarting_too_fast(self, now: float) -> bool: while ( @@ -72,7 +73,9 @@ class FrigateWatchdog(threading.Thread): exitcode = entry.process.exitcode if exitcode == 0: - logger.info("Process %s exited cleanly, not restarting", entry.name) + if not entry.clean_exit_logged: + logger.info("Process %s exited cleanly, not restarting", entry.name) + entry.clean_exit_logged = True return logger.warning( diff --git a/generate_config_translations.py b/generate_config_translations.py index 032edb2327..9bc830855d 100644 --- a/generate_config_translations.py +++ b/generate_config_translations.py @@ -150,29 +150,51 @@ def extract_translations_from_schema( # Handle anyOf cases elif "anyOf" in field_schema: for item in field_schema["anyOf"]: + nested = None + if item.get("type") == "null": + continue if "properties" in item: nested = extract_translations_from_schema(item, defs=defs) + elif "$ref" in item: + ref_path = item["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested = extract_translations_from_schema( + defs[ref_name], defs=defs + ) + elif ( + "additionalProperties" in item + and isinstance(item["additionalProperties"], dict) + and "$ref" in item["additionalProperties"] + ): + ref_path = item["additionalProperties"]["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested = extract_translations_from_schema( + defs[ref_name], defs=defs + ) + elif ( + "items" in item + and isinstance(item["items"], dict) + and ("$ref" in item["items"]) + ): + ref_path = item["items"]["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + nested = extract_translations_from_schema( + defs[ref_name], defs=defs + ) + + if nested: nested_without_root = { k: v for k, v in nested.items() if k not in ("label", "description") } field_translations.update(nested_without_root) - elif "$ref" in item: - ref_path = item["$ref"] - if ref_path.startswith("#/$defs/"): - ref_name = ref_path.split("/")[-1] - if ref_name in defs: - ref_schema = defs[ref_name] - nested = extract_translations_from_schema( - ref_schema, defs=defs - ) - nested_without_root = { - k: v - for k, v in nested.items() - if k not in ("label", "description") - } - field_translations.update(nested_without_root) if field_translations: translations[field_name] = field_translations diff --git a/notebooks/YOLO_NAS_Pretrained_Export.ipynb b/notebooks/YOLO_NAS_Pretrained_Export.ipynb index e9ee223149..23c55d1dcb 100644 --- a/notebooks/YOLO_NAS_Pretrained_Export.ipynb +++ b/notebooks/YOLO_NAS_Pretrained_Export.ipynb @@ -1,88 +1,95 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "rmuF9iKWTbdk" - }, - "outputs": [], - "source": [ - "! pip install -q git+https://github.com/Deci-AI/super-gradients.git" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "NiRCt917KKcL" - }, - "outputs": [], - "source": [ - "! 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" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "dTB0jy_NNSFz" - }, - "outputs": [], - "source": [ - "from super_gradients.common.object_names import Models\n", - "from super_gradients.conversion import DetectionOutputFormatMode\n", - "from super_gradients.training import models\n", - "\n", - "model = models.get(Models.YOLO_NAS_S, pretrained_weights=\"coco\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "GymUghyCNXem" - }, - "outputs": [], - "source": [ - "# export the model for compatibility with Frigate\n", - "\n", - "model.export(\"yolo_nas_s.onnx\",\n", - " output_predictions_format=DetectionOutputFormatMode.FLAT_FORMAT,\n", - " max_predictions_per_image=20,\n", - " num_pre_nms_predictions=300,\n", - " confidence_threshold=0.4,\n", - " input_image_shape=(320,320),\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "uBhXV5g4Nh42" - }, - "outputs": [], - "source": [ - "from google.colab import files\n", - "\n", - "files.download('yolo_nas_s.onnx')" - ] - } - ], - "metadata": { - "colab": { - "provenance": [] - }, - "kernelspec": { - "display_name": "Python 3", - "name": "python3" - }, - "language_info": { - "name": "python" - } + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "runtime-notice" + }, + "source": [ + "**Before running:** go to **Runtime → Change runtime type → Fallback runtime version: 2025.07** (Python 3.11). The current Colab default (Python 3.12+) is incompatible with `super-gradients`." + ] }, - "nbformat": 4, - "nbformat_minor": 0 -} + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "rmuF9iKWTbdk" + }, + "outputs": [], + "source": [ + "! pip install -q \"jedi>=0.16\"\n", + "! pip install -q git+https://github.com/Deci-AI/super-gradients.git" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "NiRCt917KKcL" + }, + "outputs": [], + "source": "! sed -i 's/sghub\\.deci\\.ai/d2gjn4b69gu75n.cloudfront.net/g; s/sg-hub-nv\\.s3\\.amazonaws\\.com/d2gjn4b69gu75n.cloudfront.net/g' /usr/local/lib/python*/dist-packages/super_gradients/training/pretrained_models.py\n! sed -i 's/sghub\\.deci\\.ai/d2gjn4b69gu75n.cloudfront.net/g; s/sg-hub-nv\\.s3\\.amazonaws\\.com/d2gjn4b69gu75n.cloudfront.net/g' /usr/local/lib/python*/dist-packages/super_gradients/training/utils/checkpoint_utils.py" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "dTB0jy_NNSFz" + }, + "outputs": [], + "source": [ + "from super_gradients.common.object_names import Models\n", + "from super_gradients.conversion import DetectionOutputFormatMode\n", + "from super_gradients.training import models\n", + "\n", + "model = models.get(Models.YOLO_NAS_S, pretrained_weights=\"coco\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "GymUghyCNXem" + }, + "outputs": [], + "source": [ + "# export the model for compatibility with Frigate\n", + "\n", + "model.export(\"yolo_nas_s.onnx\",\n", + " output_predictions_format=DetectionOutputFormatMode.FLAT_FORMAT,\n", + " max_predictions_per_image=20,\n", + " num_pre_nms_predictions=300,\n", + " confidence_threshold=0.4,\n", + " input_image_shape=(320,320),\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "uBhXV5g4Nh42" + }, + "outputs": [], + "source": [ + "from google.colab import files\n", + "\n", + "files.download('yolo_nas_s.onnx')" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/testing-scripts/analyze_recording_keyframes.py b/testing-scripts/analyze_recording_keyframes.py new file mode 100644 index 0000000000..982cac82f3 --- /dev/null +++ b/testing-scripts/analyze_recording_keyframes.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Analyze keyframe and timestamp structure of Frigate recording segments. + +This is a diagnostic tool for investigating seek precision / GOP behavior on +recorded segments. It does not modify anything. + +ffprobe is only available inside the Frigate container, at + /usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe +This script auto-resolves that path from the DEFAULT_FFMPEG_VERSION env var +(or falls back to scanning /usr/lib/ffmpeg/*/bin/ffprobe). Pass --ffprobe to +override if needed. + +All recording segments on the filesystem are in UTC. The --timestamp flag +expects a UTC Unix timestamp. + +Typical use: + # Inside the Frigate container (or wherever recordings are mounted) + python3 analyze_recording_keyframes.py + + # Analyze 10 most recent segments + python3 analyze_recording_keyframes.py --count 10 + + # Locate the segment that contains a specific UTC Unix timestamp and + # show it plus surrounding segments + python3 analyze_recording_keyframes.py --timestamp 1713471234.567 + + # Custom recordings directory + python3 analyze_recording_keyframes.py --recordings-dir /media/frigate/recordings + + # Override the ffprobe path explicitly + python3 analyze_recording_keyframes.py --ffprobe /usr/lib/ffmpeg/7.0/bin/ffprobe +""" + +import argparse +import datetime +import json +import os +import subprocess +import sys +from pathlib import Path +from statistics import mean, median, stdev + + +def resolve_ffprobe_path(override: str | None) -> str: + """Resolve the ffprobe binary path. + + Inside the Frigate container, ffprobe lives at + /usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe — the exact version + depends on the image build and is exposed as an env var. + """ + if override: + return override + version = os.environ.get("DEFAULT_FFMPEG_VERSION", "") + if version: + path = f"/usr/lib/ffmpeg/{version}/bin/ffprobe" + if Path(path).is_file(): + return path + # Fall back to scanning the Frigate ffmpeg install root. + for candidate in sorted(Path("/usr/lib/ffmpeg").glob("*/bin/ffprobe")): + if candidate.is_file(): + return str(candidate) + print( + "Could not locate ffprobe. Pass --ffprobe or set " + "DEFAULT_FFMPEG_VERSION.", + file=sys.stderr, + ) + sys.exit(1) + + +def find_recent_segments(recordings_dir: Path, camera: str, count: int) -> list[Path]: + """Return the N most recent .mp4 segments for the given camera. + + Expected layout: ////..mp4 + """ + pattern = f"*/*/{camera}/*.mp4" + segments = sorted(recordings_dir.glob(pattern)) + return segments[-count:] + + +def find_segments_near_timestamp( + recordings_dir: Path, camera: str, target_ts: float, count: int +) -> tuple[list[Path], Path | None]: + """Return `count` segments centered on the one containing `target_ts`. + + Also returns the specific segment that should contain the timestamp, so + callers can highlight it in output. + """ + pattern = f"*/*/{camera}/*.mp4" + with_ts: list[tuple[float, Path]] = [] + for seg in sorted(recordings_dir.glob(pattern)): + ts = filename_to_timestamp(seg) + if ts is not None: + with_ts.append((ts, seg)) + + if not with_ts: + return [], None + + # Largest filename_ts that is <= target_ts — that's the segment that + # should contain the timestamp (Frigate catalogs segments by filename). + target_idx = -1 + for i, (ts, _) in enumerate(with_ts): + if ts <= target_ts: + target_idx = i + else: + break + + if target_idx < 0: + # target_ts is before the earliest segment we have — just return the + # first `count` segments so the user can see what's available. + window = with_ts[:count] + return [seg for _, seg in window], None + + half = count // 2 + start = max(0, target_idx - half) + end = min(len(with_ts), start + count) + start = max(0, end - count) + + window = with_ts[start:end] + return [seg for _, seg in window], with_ts[target_idx][1] + + +def filename_to_timestamp(segment: Path) -> float | None: + """Parse the wall-clock time from Frigate's segment path layout.""" + try: + date = segment.parent.parent.parent.name # YYYY-MM-DD + hour = segment.parent.parent.name # HH + mm_ss = segment.stem # MM.SS + minute, second = mm_ss.split(".") + dt = datetime.datetime.strptime( + f"{date} {hour}:{minute}:{second}", + "%Y-%m-%d %H:%M:%S", + ).replace(tzinfo=datetime.timezone.utc) + return dt.timestamp() + except (ValueError, IndexError): + return None + + +def run_ffprobe(ffprobe: str, args: list[str]) -> dict: + """Run ffprobe and return parsed JSON, or empty dict on failure.""" + result = subprocess.run( + [ffprobe, "-v", "error", *args, "-of", "json"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print(f" ffprobe error: {result.stderr.strip()}", file=sys.stderr) + return {} + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return {} + + +def get_format_info(ffprobe: str, segment: Path) -> tuple[dict, dict]: + """Return (format_dict, stream_dict) for the first video stream.""" + data = run_ffprobe( + ffprobe, + [ + "-show_entries", + "format=duration,start_time", + "-show_entries", + "stream=codec_name,profile,r_frame_rate,width,height", + "-select_streams", + "v:0", + str(segment), + ], + ) + fmt = data.get("format", {}) + streams = data.get("streams") or [{}] + return fmt, streams[0] + + +def get_video_packets(ffprobe: str, segment: Path) -> list[dict]: + """Return video packets with pts_time and flags.""" + data = run_ffprobe( + ffprobe, + [ + "-select_streams", + "v", + "-show_entries", + "packet=pts_time,dts_time,flags", + str(segment), + ], + ) + return data.get("packets", []) + + +def analyze(ffprobe: str, segment: Path, highlight: bool = False) -> None: + marker = " <-- contains target timestamp" if highlight else "" + print(f"\n=== {segment} ==={marker}") + + fmt, stream = get_format_info(ffprobe, segment) + duration = float(fmt.get("duration", 0) or 0) + start_time = float(fmt.get("start_time", 0) or 0) + codec = stream.get("codec_name", "?") + profile = stream.get("profile", "?") + width = stream.get("width", "?") + height = stream.get("height", "?") + fps = stream.get("r_frame_rate", "?/1") + + filename_ts = filename_to_timestamp(segment) + filename_iso = ( + datetime.datetime.fromtimestamp( + filename_ts, tz=datetime.timezone.utc + ).isoformat() + if filename_ts is not None + else "?" + ) + + print(f" Codec: {codec} ({profile}) {width}x{height} {fps}") + print(f" Filename time: {filename_ts} ({filename_iso})") + print(f" Format duration: {duration:.3f}s") + print(f" Format start: {start_time:.3f}s (PTS offset of first packet)") + + packets = get_video_packets(ffprobe, segment) + if not packets: + print(" (no video packets)") + return + + keyframe_times: list[float] = [] + first_pts: float | None = None + last_pts: float | None = None + + for pkt in packets: + pts_str = pkt.get("pts_time") + if pts_str is None or pts_str == "N/A": + continue + pts = float(pts_str) + if first_pts is None: + first_pts = pts + last_pts = pts + if "K" in pkt.get("flags", ""): + keyframe_times.append(pts) + + total_packets = len(packets) + kf_count = len(keyframe_times) + + print(f" Video packets: {total_packets}") + print(f" Keyframes: {kf_count}") + if first_pts is not None and last_pts is not None: + print( + f" Packet PTS: first={first_pts:.3f}s last={last_pts:.3f}s " + f"span={last_pts - first_pts:.3f}s" + ) + + if keyframe_times: + print( + f" Keyframe PTS: first={keyframe_times[0]:.3f}s " + f"last={keyframe_times[-1]:.3f}s" + ) + formatted = ", ".join(f"{t:.3f}" for t in keyframe_times) + print(f" Keyframe times: [{formatted}]") + + if len(keyframe_times) >= 2: + gaps = [b - a for a, b in zip(keyframe_times, keyframe_times[1:])] + avg_fps_estimate = ( + total_packets / (last_pts - first_pts) + if last_pts and first_pts is not None and last_pts > first_pts + else 0 + ) + print( + f" GOP gaps (s): min={min(gaps):.3f} max={max(gaps):.3f} " + f"mean={mean(gaps):.3f} median={median(gaps):.3f}" + ) + if len(gaps) > 1: + print(f" stdev={stdev(gaps):.3f}") + print( + f" Est. mean GOP: ~{mean(gaps) * avg_fps_estimate:.1f} frames" + if avg_fps_estimate + else "" + ) + if max(gaps) > 5: + print( + " !! Max GOP > 5s — consistent with adaptive/smart codec " + "(even if 'Smart Codec' is off in the UI, some cameras still " + "produce irregular GOPs under specific encoder profiles)" + ) + elif kf_count == 1: + print(" !! Only one keyframe in segment — very long GOP") + + # Report how well filename time aligns with first-packet PTS. + # (Filename time is what Frigate uses as recording.start_time in the DB.) + if filename_ts is not None and first_pts is not None: + print( + f" Notes: first packet PTS is {first_pts:.3f}s into the file; " + f"Frigate treats filename time as PTS=0 for seek math." + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("camera", help="Camera name (matches the recordings subfolder)") + parser.add_argument( + "--count", + type=int, + default=5, + help="Number of most recent segments to analyze (default: 5)", + ) + parser.add_argument( + "--recordings-dir", + default="/media/frigate/recordings", + help="Path to the recordings directory (default: /media/frigate/recordings)", + ) + parser.add_argument( + "--ffprobe", + default=None, + help=( + "Full path to the ffprobe binary. Defaults to the Frigate-bundled " + "binary at /usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe." + ), + ) + parser.add_argument( + "--timestamp", + type=float, + default=None, + help=( + "Unix timestamp (UTC seconds, decimals allowed) to locate. The " + "script finds the segment that should contain this time and " + "analyzes it plus surrounding segments (count controls the " + "window). All on-disk segments are stored in UTC, so pass a UTC " + "Unix timestamp." + ), + ) + args = parser.parse_args() + + ffprobe = resolve_ffprobe_path(args.ffprobe) + + recordings_dir = Path(args.recordings_dir) + if not recordings_dir.is_dir(): + print( + f"Recordings directory not found: {recordings_dir}", + file=sys.stderr, + ) + sys.exit(1) + + target_segment: Path | None = None + if args.timestamp is not None: + segments, target_segment = find_segments_near_timestamp( + recordings_dir, args.camera, args.timestamp, args.count + ) + target_iso = datetime.datetime.fromtimestamp( + args.timestamp, tz=datetime.timezone.utc + ).isoformat() + mode = f"around timestamp {args.timestamp} ({target_iso})" + else: + segments = find_recent_segments(recordings_dir, args.camera, args.count) + mode = "most recent" + + if not segments: + print( + f"No segments found for camera '{args.camera}' under {recordings_dir}", + file=sys.stderr, + ) + sys.exit(1) + + if args.timestamp is not None and target_segment is None: + print( + f"!! Target timestamp {args.timestamp} is before the earliest " + f"segment on disk; showing the earliest available segments instead.", + file=sys.stderr, + ) + + print( + f"Analyzing {len(segments)} {mode} segment(s) for camera " + f"'{args.camera}' under {recordings_dir} (ffprobe: {ffprobe})" + ) + for segment in segments: + analyze(ffprobe, segment, highlight=(segment == target_segment)) + + +if __name__ == "__main__": + main() diff --git a/benchmark.py b/testing-scripts/benchmark.py similarity index 100% rename from benchmark.py rename to testing-scripts/benchmark.py diff --git a/benchmark_motion.py b/testing-scripts/benchmark_motion.py similarity index 100% rename from benchmark_motion.py rename to testing-scripts/benchmark_motion.py diff --git a/testing-scripts/face_dataset.py b/testing-scripts/face_dataset.py new file mode 100644 index 0000000000..0c9e451d1b --- /dev/null +++ b/testing-scripts/face_dataset.py @@ -0,0 +1,783 @@ +""" +Face recognition investigation script. + +Standalone replica of Frigate's ArcFace pipeline (see +frigate/data_processing/common/face/model.py and +frigate/embeddings/onnx/face_embedding.py) for analyzing a face collection +outside the running service. Useful for: + + - Diagnosing why a person's collection produces false positives + - Finding outlier/contaminating training images + - Inspecting the effect of the shipped vector-wise outlier filter + +Layout: + - Core pipeline: LandmarkAligner, ArcFaceEmbedder, arcface_preprocess, + similarity_to_confidence, blur_reduction — all mirroring the production + code exactly + - Default run: summarize positive and negative sets against a baseline + trim_mean class representation + - Optional diagnostics (flags): vector-outlier filter behavior, degenerate + "tiny crop" embedding clustering, and multi-identity contamination + +Usage: + python3 face_investigate.py \\ + --positive \\ + --negative \\ + [--model-cache /path/to/model_cache] \\ + [--vector-outlier] [--degenerate] [--contamination] + +The positive folder should contain training images for a single identity +(same layout as FACE_DIR//*.webp). The negative folder should contain +runtime crops to test against — a mix of true matches and misfires. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from dataclasses import dataclass +from typing import Iterable + +import cv2 +import numpy as np +import onnxruntime as ort +from PIL import Image +from scipy import stats + +ARCFACE_INPUT_SIZE = 112 + + +# --------------------------------------------------------------------------- +# Replicated Frigate pipeline +# --------------------------------------------------------------------------- + + +def _process_image_frigate(image: np.ndarray) -> Image.Image: + """Mirror BaseEmbedding._process_image for an ndarray input. + + NOTE: Frigate passes the output of `cv2.imread` (BGR) directly in. PIL's + `Image.fromarray` does NOT reorder channels, so the embedder effectively + receives a BGR-ordered tensor. We replicate that faithfully here. (Tested + — swapping to RGB produces near-identical embeddings; this model is + robust to channel order.) + """ + return Image.fromarray(image) + + +def arcface_preprocess(image_bgr: np.ndarray) -> np.ndarray: + """Mirror ArcfaceEmbedding._preprocess_inputs.""" + pil = _process_image_frigate(image_bgr) + + width, height = pil.size + if width != ARCFACE_INPUT_SIZE or height != ARCFACE_INPUT_SIZE: + if width > height: + new_height = int(((height / width) * ARCFACE_INPUT_SIZE) // 4 * 4) + pil = pil.resize((ARCFACE_INPUT_SIZE, new_height)) + else: + new_width = int(((width / height) * ARCFACE_INPUT_SIZE) // 4 * 4) + pil = pil.resize((new_width, ARCFACE_INPUT_SIZE)) + + og = np.array(pil).astype(np.float32) + og_h, og_w, channels = og.shape + + frame = np.zeros( + (ARCFACE_INPUT_SIZE, ARCFACE_INPUT_SIZE, channels), dtype=np.float32 + ) + x_center = (ARCFACE_INPUT_SIZE - og_w) // 2 + y_center = (ARCFACE_INPUT_SIZE - og_h) // 2 + frame[y_center : y_center + og_h, x_center : x_center + og_w] = og + + frame = (frame / 127.5) - 1.0 + frame = np.transpose(frame, (2, 0, 1)) + frame = np.expand_dims(frame, axis=0) + return frame + + +class LandmarkAligner: + """Mirror FaceRecognizer.align_face.""" + + def __init__(self, landmark_model_path: str): + if not os.path.exists(landmark_model_path): + raise FileNotFoundError(landmark_model_path) + self.detector = cv2.face.createFacemarkLBF() + self.detector.loadModel(landmark_model_path) + + def align( + self, image: np.ndarray, out_w: int, out_h: int + ) -> tuple[np.ndarray, dict]: + land_image = ( + cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image + ) + _, lands = self.detector.fit( + land_image, np.array([(0, 0, land_image.shape[1], land_image.shape[0])]) + ) + landmarks = lands[0][0] + + leftEyePts = landmarks[42:48] + rightEyePts = landmarks[36:42] + leftEyeCenter = leftEyePts.mean(axis=0).astype("int") + rightEyeCenter = rightEyePts.mean(axis=0).astype("int") + + dY = rightEyeCenter[1] - leftEyeCenter[1] + dX = rightEyeCenter[0] - leftEyeCenter[0] + angle = np.degrees(np.arctan2(dY, dX)) - 180 + dist = float(np.sqrt((dX**2) + (dY**2))) + + desiredRightEyeX = 1.0 - 0.35 + desiredDist = (desiredRightEyeX - 0.35) * out_w + scale = desiredDist / dist if dist > 0 else 1.0 + + eyesCenter = ( + int((leftEyeCenter[0] + rightEyeCenter[0]) // 2), + int((leftEyeCenter[1] + rightEyeCenter[1]) // 2), + ) + M = cv2.getRotationMatrix2D(eyesCenter, angle, scale) + tX = out_w * 0.5 + tY = out_h * 0.35 + M[0, 2] += tX - eyesCenter[0] + M[1, 2] += tY - eyesCenter[1] + + aligned = cv2.warpAffine( + image, M, (out_w, out_h), flags=cv2.INTER_CUBIC + ) + info = dict( + angle=float(angle), + eye_dist_px=dist, + scale=float(scale), + landmarks=landmarks, + ) + return aligned, info + + +class ArcFaceEmbedder: + def __init__(self, model_path: str): + self.session = ort.InferenceSession( + model_path, providers=["CPUExecutionProvider"] + ) + self.input_name = self.session.get_inputs()[0].name + + def embed(self, image_bgr: np.ndarray) -> np.ndarray: + tensor = arcface_preprocess(image_bgr) + out = self.session.run(None, {self.input_name: tensor})[0] + return out.squeeze() + + +def similarity_to_confidence( + cos_sim: float, + median: float = 0.3, + range_width: float = 0.6, + slope_factor: float = 12, +) -> float: + slope = slope_factor / range_width + return float(1.0 / (1.0 + np.exp(-slope * (cos_sim - median)))) + + +def laplacian_variance(image: np.ndarray) -> float: + return float(cv2.Laplacian(image, cv2.CV_64F).var()) + + +def blur_reduction(variance: float) -> float: + if variance < 120: + return 0.06 + elif variance < 160: + return 0.04 + elif variance < 200: + return 0.02 + elif variance < 250: + return 0.01 + return 0.0 + + +def cosine(a: np.ndarray, b: np.ndarray) -> float: + denom = np.linalg.norm(a) * np.linalg.norm(b) + if denom == 0: + return 0.0 + return float(np.dot(a, b) / denom) + + +def l2(v: np.ndarray) -> np.ndarray: + return v / (np.linalg.norm(v) + 1e-9) + + +# --------------------------------------------------------------------------- +# Sample loading +# --------------------------------------------------------------------------- + + +@dataclass +class FaceSample: + path: str + shape: tuple[int, int] + embedding: np.ndarray + blur_var: float + align_info: dict + + +def load_folder( + folder: str, aligner: LandmarkAligner, embedder: ArcFaceEmbedder +) -> list[FaceSample]: + samples: list[FaceSample] = [] + names = sorted(os.listdir(folder)) + for name in names: + if name.startswith("."): + continue + path = os.path.join(folder, name) + if not os.path.isfile(path): + continue + img = cv2.imread(path) + if img is None: + print(f" [skip unreadable] {name}") + continue + aligned, info = aligner.align(img, img.shape[1], img.shape[0]) + emb = embedder.embed(aligned) + samples.append( + FaceSample( + path=path, + shape=(img.shape[1], img.shape[0]), + embedding=emb, + blur_var=laplacian_variance(img), + align_info=info, + ) + ) + return samples + + +def trimmed_mean(embs: Iterable[np.ndarray], trim: float = 0.15) -> np.ndarray: + arr = np.stack(list(embs), axis=0) + return stats.trim_mean(arr, trim, axis=0) + + +# --------------------------------------------------------------------------- +# Baseline analyses (always run) +# --------------------------------------------------------------------------- + + +def summarize_positive(samples: list[FaceSample], mean_emb: np.ndarray) -> None: + """Summary of training set: per-sample cos to class mean, intra-class stats. + + Outliers with cos far below the rest are likely degrading the mean — + they'd be the first candidates the shipped vector-outlier filter drops. + """ + print("\n" + "=" * 78) + print(f"POSITIVE SET ANALYSIS ({len(samples)} images)") + print("=" * 78) + + rows = [] + for s in samples: + cs = cosine(s.embedding, mean_emb) + conf = similarity_to_confidence(cs) + red = blur_reduction(s.blur_var) + rows.append( + dict( + name=os.path.basename(s.path), + shape=f"{s.shape[0]}x{s.shape[1]}", + eye_px=s.align_info["eye_dist_px"], + angle=s.align_info["angle"] + 180, + blur=s.blur_var, + cos=cs, + conf=conf, + red=red, + adj_conf=max(0.0, conf - red), + ) + ) + + rows.sort(key=lambda r: r["cos"]) + sims = np.array([r["cos"] for r in rows]) + print( + f"\nCosine-to-trimmed-mean: mean={sims.mean():.3f} std={sims.std():.3f} " + f"min={sims.min():.3f} max={sims.max():.3f}" + ) + + print("\n-- Worst matches (bottom 10, most likely hurting the mean) --") + print( + f"{'cos':>6} {'conf':>6} {'blur':>7} {'eyes':>6} " + f"{'angle':>6} {'shape':>9} name" + ) + for r in rows[:10]: + print( + f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} " + f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}" + ) + + print("\n-- Best matches (top 5) --") + for r in rows[-5:][::-1]: + print( + f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} " + f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}" + ) + + # Pairwise analysis — flags embeddings poorly correlated with the rest + print("\n-- Pairwise intra-class similarity (mean cos vs. other positives) --") + embs = np.stack([s.embedding for s in samples], axis=0) + norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9) + sim_matrix = norms @ norms.T + np.fill_diagonal(sim_matrix, np.nan) + mean_pairwise = np.nanmean(sim_matrix, axis=1) + names = [os.path.basename(s.path) for s in samples] + ordered = sorted(zip(names, mean_pairwise), key=lambda t: t[1]) + print(f"{'mean_cos':>9} name") + for nm, mp in ordered[:10]: + print(f"{mp:9.3f} {nm}") + print(f"\n overall mean pairwise cos: {np.nanmean(sim_matrix):.3f}") + print(f" median pairwise cos: {np.nanmedian(sim_matrix):.3f}") + + +def summarize_negative( + neg_samples: list[FaceSample], + mean_emb: np.ndarray, + pos_samples: list[FaceSample], +) -> None: + """Score each negative against the class mean, then show its top-3 + nearest positives. High-scoring negatives that match specific outlier + positives hint at training-set contamination. + """ + print("\n" + "=" * 78) + print(f"NEGATIVE SET ANALYSIS ({len(neg_samples)} images)") + print("=" * 78) + print( + f"\n{'cos':>6} {'conf':>6} {'red':>5} {'adj':>5} " + f"{'blur':>7} {'eyes':>6} {'shape':>9} name" + ) + for s in neg_samples: + cs = cosine(s.embedding, mean_emb) + conf = similarity_to_confidence(cs) + red = blur_reduction(s.blur_var) + print( + f"{cs:6.3f} {conf:6.3f} {red:5.2f} {max(0, conf - red):5.2f} " + f"{s.blur_var:7.1f} {s.align_info['eye_dist_px']:6.1f} " + f"{s.shape[0]}x{s.shape[1]:<5} {os.path.basename(s.path)}" + ) + + print("\n-- For each negative, top-3 most similar positives --") + pos_embs = np.stack([p.embedding for p in pos_samples]) + pos_norm = pos_embs / (np.linalg.norm(pos_embs, axis=1, keepdims=True) + 1e-9) + for s in neg_samples: + v = s.embedding / (np.linalg.norm(s.embedding) + 1e-9) + sims = pos_norm @ v + idx = np.argsort(-sims)[:3] + print(f"\n {os.path.basename(s.path)}:") + for i in idx: + print( + f" {sims[i]:6.3f} {os.path.basename(pos_samples[i].path)} " + f"blur={pos_samples[i].blur_var:.1f} " + f"eyes={pos_samples[i].align_info['eye_dist_px']:.1f}" + ) + + +# --------------------------------------------------------------------------- +# Optional diagnostics +# --------------------------------------------------------------------------- + + +def vector_outlier_test( + pos: list[FaceSample], neg: list[FaceSample], base_trim: float = 0.15 +) -> None: + """Measure the shipped vector-wise outlier filter at various thresholds. + + The production filter at `build_class_mean` in + frigate/data_processing/common/face/model.py uses T=0.30. This test + sweeps T so you can see which images would be dropped on a new collection + and how that affects the negative scores. + + Algorithm: iteratively recompute trim_mean on the kept set, drop any + embedding with cos < T to that mean, repeat until converged. Floor at + 50% of the collection to avoid collapse. + """ + print("\n" + "=" * 78) + print("VECTOR-WISE OUTLIER PRE-FILTER — layered on trim_mean(0.15)") + print("=" * 78) + + all_embs = np.stack([s.embedding for s in pos]) + + def iterative_mean( + embs: np.ndarray, + threshold: float, + iters: int = 3, + min_keep_frac: float = 0.5, + ) -> tuple[np.ndarray, np.ndarray]: + keep = np.ones(len(embs), dtype=bool) + floor = max(5, int(np.ceil(min_keep_frac * len(embs)))) + for _ in range(iters): + m = stats.trim_mean(embs[keep], base_trim, axis=0) + m_norm = m / (np.linalg.norm(m) + 1e-9) + e_norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9) + cos_to_mean = e_norms @ m_norm + new_keep = cos_to_mean >= threshold + if new_keep.sum() < floor: + top_idx = np.argsort(-cos_to_mean)[:floor] + new_keep = np.zeros_like(new_keep) + new_keep[top_idx] = True + if np.array_equal(new_keep, keep): + break + keep = new_keep + final = stats.trim_mean(embs[keep], base_trim, axis=0) + return final, keep + + provisional = stats.trim_mean(all_embs, base_trim, axis=0) + p_norm = provisional / (np.linalg.norm(provisional) + 1e-9) + e_norms_all = all_embs / (np.linalg.norm(all_embs, axis=1, keepdims=True) + 1e-9) + cos_to_prov = e_norms_all @ p_norm + print("\nDistribution of cos(positive, provisional trim_mean):") + print( + f" min={cos_to_prov.min():.3f} p10={np.percentile(cos_to_prov, 10):.3f} " + f"p25={np.percentile(cos_to_prov, 25):.3f} " + f"median={np.median(cos_to_prov):.3f} " + f"p75={np.percentile(cos_to_prov, 75):.3f} max={cos_to_prov.max():.3f}" + ) + + baseline_mean = stats.trim_mean(all_embs, base_trim, axis=0) + baseline_pos = np.array([cosine(p.embedding, baseline_mean) for p in pos]) + baseline_neg = ( + np.array([cosine(n.embedding, baseline_mean) for n in neg]) + if neg + else np.array([]) + ) + baseline_conf_neg = np.array( + [similarity_to_confidence(c) for c in baseline_neg] + ) + + print( + f"\nBaseline (trim_mean only, {len(pos)} images):" + f"\n pos cos min={baseline_pos.min():.3f} " + f"mean={baseline_pos.mean():.3f} max={baseline_pos.max():.3f}" + ) + if len(neg): + print( + f" neg cos min={baseline_neg.min():.3f} " + f"mean={baseline_neg.mean():.3f} max={baseline_neg.max():.3f}" + ) + print( + f" neg conf min={baseline_conf_neg.min():.3f} " + f"mean={baseline_conf_neg.mean():.3f} max={baseline_conf_neg.max():.3f}" + ) + print( + f" margin (pos.min - neg.max): " + f"{baseline_pos.min() - baseline_neg.max():+.3f}" + ) + + print("\nIterative (refine mean → drop vectors with cos5} {'kept':>6} {'pos min':>7} {'pos mean':>8} " + f"{'neg max':>7} {'neg mean':>8} {'neg conf.max':>12} {'margin':>7}" + ) + for T in [0.15, 0.20, 0.25, 0.28, 0.30, 0.33, 0.36, 0.40]: + mean, keep = iterative_mean(all_embs, T) + pos_sims = np.array([cosine(p.embedding, mean) for p in pos]) + neg_sims = ( + np.array([cosine(n.embedding, mean) for n in neg]) + if neg + else np.array([]) + ) + neg_conf = np.array([similarity_to_confidence(c) for c in neg_sims]) + margin = pos_sims.min() - (neg_sims.max() if len(neg_sims) else 0) + print( + f"{T:5.2f} {int(keep.sum()):>3}/{len(pos):<2} " + f"{pos_sims.min():7.3f} {pos_sims.mean():8.3f} " + f"{neg_sims.max() if len(neg_sims) else float('nan'):7.3f} " + f"{neg_sims.mean() if len(neg_sims) else float('nan'):8.3f} " + f"{neg_conf.max() if len(neg_conf) else float('nan'):12.3f} " + f"{margin:+7.3f}" + ) + + # Show which images get dropped at the shipped threshold + neighbors + for T_show in (0.25, 0.30, 0.33): + _, keep = iterative_mean(all_embs, T_show) + print( + f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:" + ) + final_mean = stats.trim_mean(all_embs[keep], base_trim, axis=0) + m_n = final_mean / (np.linalg.norm(final_mean) + 1e-9) + for i, (p, k) in enumerate(zip(pos, keep)): + if not k: + e_n = p.embedding / (np.linalg.norm(p.embedding) + 1e-9) + cos_final = float(e_n @ m_n) + print( + f" cos_to_clean_mean={cos_final:6.3f} " + f"shape={p.shape[0]}x{p.shape[1]} " + f"eyes={p.align_info['eye_dist_px']:6.1f} " + f"blur={p.blur_var:7.1f} " + f"{os.path.basename(p.path)}" + ) + + +def degenerate_embedding_test( + pos: list[FaceSample], neg: list[FaceSample] +) -> None: + """Detect whether negatives and low-quality positives share a degenerate + 'tiny/noisy face' region of the embedding space. + + Signal: if neg-to-neg cos is higher than pos-to-pos cos, the negatives + aren't really per-identity embeddings — they're dominated by upsample / + low-resolution artifacts that all map to a similar corner of embedding + space regardless of who the face belongs to. + + Also rebuilds the mean using only high-intra-similarity positives to + show whether a cleaner training set separates the negatives. + """ + print("\n" + "=" * 78) + print("DEGENERATE-EMBEDDING TEST") + print("=" * 78) + + pos_embs = np.stack([l2(s.embedding) for s in pos]) + neg_embs = np.stack([l2(s.embedding) for s in neg]) + + nn = neg_embs @ neg_embs.T + np.fill_diagonal(nn, np.nan) + pp = pos_embs @ pos_embs.T + np.fill_diagonal(pp, np.nan) + pn = pos_embs @ neg_embs.T + + print( + f"\n neg<->neg mean cos : {np.nanmean(nn):.3f} " + f"(how tightly negatives cluster together)" + ) + print( + f" pos<->pos mean cos : {np.nanmean(pp):.3f} " + f"(how tightly positives cluster)" + ) + print( + f" pos<->neg mean cos : {pn.mean():.3f} " + f"(cross-class — should be low for a clean class)" + ) + if np.nanmean(nn) > np.nanmean(pp): + print( + "\n >> neg<->neg > pos<->pos: negatives cluster more tightly than\n" + " positives. This is the degenerate-embedding signature —\n" + " upsampled tiny crops share a common 'face-like blob' region\n" + " regardless of identity." + ) + + mean_intra = np.nanmean(pp, axis=1) + for thresh in (0.30, 0.33, 0.36): + keep = mean_intra >= thresh + if keep.sum() < 5: + continue + clean_embs = [pos[i].embedding for i in range(len(pos)) if keep[i]] + clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0) + neg_scores = np.array([cosine(n.embedding, clean_mean) for n in neg]) + neg_confs = np.array([similarity_to_confidence(c) for c in neg_scores]) + pos_scores = np.array( + [ + cosine(pos[i].embedding, clean_mean) + for i in range(len(pos)) + if keep[i] + ] + ) + print( + f"\n mean_intra >= {thresh}: keeping {int(keep.sum())}/{len(pos)} positives" + ) + print( + f" pos cos vs mean : min={pos_scores.min():.3f} " + f"mean={pos_scores.mean():.3f} max={pos_scores.max():.3f}" + ) + print( + f" neg cos vs mean : min={neg_scores.min():.3f} " + f"mean={neg_scores.mean():.3f} max={neg_scores.max():.3f}" + ) + print( + f" neg conf : min={neg_confs.min():.3f} " + f"mean={neg_confs.mean():.3f} max={neg_confs.max():.3f}" + ) + print( + f" margin (pos.min - neg.max): " + f"{pos_scores.min() - neg_scores.max():+.3f}" + ) + + +def contamination_analysis( + pos: list[FaceSample], neg: list[FaceSample] +) -> None: + """Check whether the positive collection contains a second identity. + + Two signals: + (a) Per-positive: if an image is closer to at least one negative than + to the rest of the positive class, it's likely a mislabeled face. + (b) 2-means split of the positive embeddings: if one cluster center + lands close to the negative mean, that cluster is a contaminating + sub-identity that's pulling the class mean toward the negatives. + """ + print("\n" + "=" * 78) + print("CONTAMINATION ANALYSIS") + print("=" * 78) + + pos_embs = np.stack([l2(s.embedding) for s in pos]) + neg_embs = np.stack([l2(s.embedding) for s in neg]) + pos_names = [os.path.basename(s.path) for s in pos] + + pos_pos = pos_embs @ pos_embs.T + np.fill_diagonal(pos_pos, np.nan) + pos_neg = pos_embs @ neg_embs.T + + mean_intra = np.nanmean(pos_pos, axis=1) + max_to_neg = pos_neg.max(axis=1) + mean_to_neg = pos_neg.mean(axis=1) + + print( + "\nPositives closer to a negative than to their own class avg" + "\n(these are candidates for mislabeled images):" + ) + print( + f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} " + f"{'delta':>6} name" + ) + rows = list(zip(pos_names, max_to_neg, mean_to_neg, mean_intra)) + rows.sort(key=lambda r: -(r[1] - r[3])) + for nm, mxn, mnn, mi in rows[:15]: + delta = mxn - mi + marker = " <<" if delta > 0 else "" + print(f"{mxn:7.3f} {mnn:8.3f} {mi:10.3f} {delta:6.3f} {nm}{marker}") + + # 2-means in cosine space (no sklearn dependency). + print("\n2-means split of positive embeddings (cosine space):") + rng = np.random.default_rng(0) + best = None + for _ in range(5): + idx = rng.choice(len(pos_embs), 2, replace=False) + centers = pos_embs[idx].copy() + for _ in range(50): + sims = pos_embs @ centers.T + labels = np.argmax(sims, axis=1) + new_centers = np.stack( + [ + l2(pos_embs[labels == k].mean(axis=0)) + if np.any(labels == k) + else centers[k] + for k in range(2) + ] + ) + if np.allclose(new_centers, centers): + break + centers = new_centers + tight = float(np.mean([sims[i, labels[i]] for i in range(len(labels))])) + if best is None or tight > best[0]: + best = (tight, labels.copy(), centers.copy()) + + _, labels, centers = best + sizes = [int((labels == k).sum()) for k in range(2)] + neg_mean = l2(neg_embs.mean(axis=0)) + print( + f" cluster 0: size={sizes[0]:>2} " + f"center<->other_center_cos={float(centers[0] @ centers[1]):.3f} " + f"center<->neg_mean_cos={float(centers[0] @ neg_mean):.3f}" + ) + print( + f" cluster 1: size={sizes[1]:>2} " + f"center<->neg_mean_cos={float(centers[1] @ neg_mean):.3f}" + ) + + neg_aligned = 0 if centers[0] @ neg_mean > centers[1] @ neg_mean else 1 + print( + f"\n cluster {neg_aligned} is more similar to the negatives — " + f"its members are the contamination candidates:" + ) + for i, lbl in enumerate(labels): + if lbl == neg_aligned: + print( + f" max_to_neg={max_to_neg[i]:.3f} " + f"mean_intra={mean_intra[i]:.3f} {pos_names[i]}" + ) + + keep_mask = labels != neg_aligned + if keep_mask.sum() >= 3: + clean_embs = [pos[i].embedding for i in range(len(pos)) if keep_mask[i]] + clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0) + print( + f"\n Rebuilding class mean from the OTHER cluster " + f"({keep_mask.sum()} images):" + ) + print(f" {'cos':>6} {'conf':>6} name") + for n in neg: + cs = cosine(n.embedding, clean_mean) + cf = similarity_to_confidence(cs) + print(f" {cs:6.3f} {cf:6.3f} {os.path.basename(n.path)}") + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Analyze a face recognition collection outside Frigate.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + ap.add_argument("--positive", required=True, help="Training folder for one identity") + ap.add_argument( + "--negative", + default=None, + help="Runtime-crop folder to score against (optional)", + ) + ap.add_argument( + "--model-cache", + default="/config/model_cache", + help="Directory containing facedet/arcface.onnx and facedet/landmarkdet.yaml", + ) + ap.add_argument( + "--trim", + type=float, + default=0.15, + help="trim_mean proportion (Frigate uses 0.15)", + ) + ap.add_argument( + "--vector-outlier", + action="store_true", + help="Sweep the vector-wise outlier filter threshold", + ) + ap.add_argument( + "--degenerate", + action="store_true", + help="Test whether negatives share a degenerate embedding region", + ) + ap.add_argument( + "--contamination", + action="store_true", + help="Check whether the positive folder contains a second identity", + ) + args = ap.parse_args() + + arcface_path = os.path.join(args.model_cache, "facedet", "arcface.onnx") + landmark_path = os.path.join(args.model_cache, "facedet", "landmarkdet.yaml") + for p in (arcface_path, landmark_path): + if not os.path.exists(p): + print(f"ERROR: model file not found: {p}") + return 1 + + print(f"Loading ArcFace from {arcface_path}") + embedder = ArcFaceEmbedder(arcface_path) + print(f"Loading landmark model from {landmark_path}") + aligner = LandmarkAligner(landmark_path) + + print(f"\nLoading positives from {args.positive} ...") + pos = load_folder(args.positive, aligner, embedder) + print(f" {len(pos)} positives loaded") + + neg: list[FaceSample] = [] + if args.negative: + print(f"\nLoading negatives from {args.negative} ...") + neg = load_folder(args.negative, aligner, embedder) + print(f" {len(neg)} negatives loaded") + + if not pos: + print("no positive samples — aborting") + return 1 + + mean_emb = trimmed_mean([s.embedding for s in pos], trim=args.trim) + summarize_positive(pos, mean_emb) + if neg: + summarize_negative(neg, mean_emb, pos) + + if args.vector_outlier: + vector_outlier_test(pos, neg, args.trim) + if args.degenerate and neg: + degenerate_embedding_test(pos, neg) + if args.contamination and neg: + contamination_analysis(pos, neg) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/process_clip.py b/testing-scripts/process_clip.py similarity index 100% rename from process_clip.py rename to testing-scripts/process_clip.py diff --git a/web/e2e/fixtures/mock-data/debug-replay.ts b/web/e2e/fixtures/mock-data/debug-replay.ts new file mode 100644 index 0000000000..9b9d2c650d --- /dev/null +++ b/web/e2e/fixtures/mock-data/debug-replay.ts @@ -0,0 +1,54 @@ +/** + * Debug replay status factory. + * + * The Replay page polls /api/debug_replay/status every 1s via SWR. + * The no-session state shows an empty state; the active state + * renders the live camera image + debug toggles + objects/messages + * tabs. Used by replay.spec.ts. + */ + +export type DebugReplayStatus = { + active: boolean; + replay_camera: string | null; + source_camera: string | null; + start_time: number | null; + end_time: number | null; + live_ready: boolean; +}; + +export function noSessionStatus(): DebugReplayStatus { + return { + active: false, + replay_camera: null, + source_camera: null, + start_time: null, + end_time: null, + live_ready: false, + }; +} + +export function activeSessionStatus( + opts: { + camera?: string; + sourceCamera?: string; + startTime?: number; + endTime?: number; + liveReady?: boolean; + } = {}, +): DebugReplayStatus { + const { + camera = "front_door", + sourceCamera = "front_door", + startTime = Date.now() / 1000 - 3600, + endTime = Date.now() / 1000 - 1800, + liveReady = true, + } = opts; + return { + active: true, + replay_camera: camera, + source_camera: sourceCamera, + start_time: startTime, + end_time: endTime, + live_ready: liveReady, + }; +} diff --git a/web/e2e/fixtures/mock-data/faces.ts b/web/e2e/fixtures/mock-data/faces.ts new file mode 100644 index 0000000000..ed2f944cb1 --- /dev/null +++ b/web/e2e/fixtures/mock-data/faces.ts @@ -0,0 +1,45 @@ +/** + * Face library factories. + * + * The /api/faces endpoint returns a record keyed by collection name + * with the list of face image filenames. Grouped training attempts + * live under the "train" key with filenames of the form + * `${event_id}-${timestamp}-${label}-${score}.webp`. + * + * Used by face-library.spec.ts and chat.spec.ts (attachment chip). + */ + +export type FacesMock = Record; + +export function basicFacesMock(): FacesMock { + return { + alice: ["alice-1.webp", "alice-2.webp"], + bob: ["bob-1.webp"], + charlie: ["charlie-1.webp"], + }; +} + +export function emptyFacesMock(): FacesMock { + return {}; +} + +/** + * Adds a grouped recent-recognition training attempt to an existing + * faces mock. The grouping key on the backend is the event id — so + * images with the same event-id prefix render as one dialog-able card. + */ +export function withGroupedTrainingAttempt( + base: FacesMock, + opts: { + eventId: string; + attempts: Array<{ timestamp: number; label: string; score: number }>; + }, +): FacesMock { + const trainImages = opts.attempts.map( + (a) => `${opts.eventId}-${a.timestamp}-${a.label}-${a.score}.webp`, + ); + return { + ...base, + train: [...(base.train ?? []), ...trainImages], + }; +} diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index 52f10d64b4..e1a191fe0b 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -113,11 +113,12 @@ export class ApiMocker { route.fulfill({ json: [] }), ); - // Sub-labels and attributes (for explore filters) - await this.page.route("**/api/sub_labels", (route) => + // Sub-labels and attributes (for explore filters). + // Use trailing ** so query-string variants (e.g. ?split_joined=1) match. + await this.page.route("**/api/sub_labels**", (route) => route.fulfill({ json: [] }), ); - await this.page.route("**/api/labels", (route) => + await this.page.route("**/api/labels**", (route) => route.fulfill({ json: ["person", "car"] }), ); await this.page.route("**/api/*/attributes", (route) => diff --git a/web/e2e/helpers/clipboard.ts b/web/e2e/helpers/clipboard.ts new file mode 100644 index 0000000000..9099073b32 --- /dev/null +++ b/web/e2e/helpers/clipboard.ts @@ -0,0 +1,25 @@ +/** + * Clipboard read helper for e2e tests. + * + * Clipboard API requires a browser permission in headless mode. + * grantClipboardPermissions() must be called before any readClipboard() + * attempt. Used by logs.spec.ts (Copy button) and config-editor.spec.ts + * (Copy button). + */ + +import type { BrowserContext, Page } from "@playwright/test"; + +/** + * Grant clipboard-read + clipboard-write permissions on the context. + * Call in beforeEach or at the top of a test before the Copy action. + */ +export async function grantClipboardPermissions( + context: BrowserContext, +): Promise { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); +} + +/** Read the current clipboard contents via the page's navigator.clipboard. */ +export async function readClipboard(page: Page): Promise { + return page.evaluate(async () => await navigator.clipboard.readText()); +} diff --git a/web/e2e/helpers/monaco.ts b/web/e2e/helpers/monaco.ts new file mode 100644 index 0000000000..6e9bcd871a --- /dev/null +++ b/web/e2e/helpers/monaco.ts @@ -0,0 +1,58 @@ +/** + * Monaco editor DOM helpers for e2e tests. + * + * Monaco is imported as a module-local object in the app and is NOT + * exposed on window; we drive + read through the rendered DOM and + * keyboard instead. Used by config-editor.spec.ts only. + */ + +import { expect, type Page } from "@playwright/test"; + +/** + * Returns the current visible text of the first Monaco editor on the + * page. Monaco virtualizes long files — this reads only the rendered + * lines. For short configs (our mocks) that's the full content. + */ +export async function getMonacoVisibleText(page: Page): Promise { + return page.locator(".monaco-editor .view-lines").first().innerText(); +} + +/** + * Focus the editor and replace its full content with `value` via + * keyboard. Uses Ctrl+A (Cmd+A on macOS Playwright is equivalent) + * + Delete + type. Works cross-platform because Playwright normalizes. + */ +export async function replaceMonacoValue( + page: Page, + value: string, +): Promise { + const editor = page.locator(".monaco-editor").first(); + await editor.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("Delete"); + // Use `type` with zero delay — Monaco handles each key. + await page.keyboard.type(value, { delay: 0 }); +} + +/** + * Returns true when the editor shows at least one error-severity + * marker. Monaco renders error underlines as `.squiggly-error` in + * the `.view-overlays` layer. + */ +export async function hasErrorMarkers(page: Page): Promise { + const count = await page.locator(".monaco-editor .squiggly-error").count(); + return count > 0; +} + +/** + * Poll until an error marker appears. Monaco schedules marker updates + * asynchronously after content changes (debounce + schema validation). + */ +export async function waitForErrorMarker( + page: Page, + timeoutMs: number = 10_000, +): Promise { + await expect + .poll(() => hasErrorMarkers(page), { timeout: timeoutMs }) + .toBe(true); +} diff --git a/web/e2e/helpers/overlay-interaction.ts b/web/e2e/helpers/overlay-interaction.ts new file mode 100644 index 0000000000..81a01d8415 --- /dev/null +++ b/web/e2e/helpers/overlay-interaction.ts @@ -0,0 +1,41 @@ +/** + * Overlay interaction helpers for Radix-based UI tests. + * + * These helpers exist to guard the class of bugs fixed by de-duping + * `@radix-ui/react-dismissable-layer` across the tree: body pointer-events + * getting stuck, dropdown typeahead breaking, tooltips re-popping after a + * dropdown closes, and related nested-overlay regressions. + */ + +import { expect, type Page } from "@playwright/test"; + +/** + * Assert that `` is interactive (no stuck `pointer-events: none`). + * + * Call after closing any overlay. This is the fast secondary assertion — + * test specs should also assert a user-visible behavior like "a button + * responded to a click" so the test fails on meaningful breakage rather + * than just a CSS invariant. + */ +export async function expectBodyInteractive(page: Page) { + const stuck = await page.evaluate( + () => document.body.style.pointerEvents === "none", + ); + expect(stuck, "body.style.pointer-events stuck after overlay close").toBe( + false, + ); +} + +/** + * Wait until the `` is no longer marked with `pointer-events: none`. + * + * Useful right after closing an overlay when Radix's cleanup runs in the + * next frame. Throws if the style does not clear within `timeoutMs`. + */ +export async function waitForBodyInteractive(page: Page, timeoutMs = 2000) { + await page.waitForFunction( + () => document.body.style.pointerEvents !== "none", + null, + { timeout: timeoutMs }, + ); +} diff --git a/web/e2e/helpers/ws-frames.ts b/web/e2e/helpers/ws-frames.ts new file mode 100644 index 0000000000..d46376d871 --- /dev/null +++ b/web/e2e/helpers/ws-frames.ts @@ -0,0 +1,65 @@ +/** + * WebSocket frame capture helper. + * + * The ws-mocker intercepts the /ws route, so Playwright's page-level + * `websocket` event never fires. This helper patches client-side + * WebSocket.prototype.send before any app code runs and mirrors every + * sent frame into a window-level array the test can read back. + * + * Used by live.spec.ts (feature toggles, PTZ preset commands) and + * config-editor.spec.ts (restart command via useRestart). + */ + +import { expect, type Page } from "@playwright/test"; + +export type CapturedFrame = string; + +declare global { + interface Window { + __sentWsFrames: CapturedFrame[]; + } +} + +/** + * Patch WebSocket.prototype.send to capture every outbound frame into + * window.__sentWsFrames. Must be called BEFORE page.goto(). + */ +export async function installWsFrameCapture(page: Page): Promise { + await page.addInitScript(() => { + window.__sentWsFrames = []; + const origSend = WebSocket.prototype.send; + WebSocket.prototype.send = function (data) { + try { + window.__sentWsFrames.push( + typeof data === "string" ? data : "(binary)", + ); + } catch { + // ignore — best-effort tracing + } + return origSend.call(this, data); + }; + }); +} + +/** Read all captured frames at call time. */ +export async function readWsFrames(page: Page): Promise { + return page.evaluate(() => window.__sentWsFrames ?? []); +} + +/** + * Poll until at least one captured frame matches the predicate. + * Throws via expect if the frame never arrives within timeout. + */ +export async function waitForWsFrame( + page: Page, + matcher: (frame: CapturedFrame) => boolean, + opts: { timeout?: number; message?: string } = {}, +): Promise { + const { timeout = 2_000, message } = opts; + await expect + .poll(async () => (await readWsFrames(page)).some(matcher), { + timeout, + message, + }) + .toBe(true); +} diff --git a/web/e2e/helpers/ws-mocker.ts b/web/e2e/helpers/ws-mocker.ts index 6b29b76396..03db9f7410 100644 --- a/web/e2e/helpers/ws-mocker.ts +++ b/web/e2e/helpers/ws-mocker.ts @@ -79,7 +79,20 @@ export class WsMocker { this.send("model_state", JSON.stringify({})); } if (data.topic === "embeddingsReindexProgress") { - this.send("embeddings_reindex_progress", JSON.stringify(null)); + // Send a completed reindex state so Explore renders when + // semantic_search.enabled is true. A null payload leaves the page + // in a permanent loading spinner because !reindexState is truthy. + this.send( + "embeddings_reindex_progress", + JSON.stringify({ + status: "completed", + processed_objects: 0, + total_objects: 0, + thumbnails: 0, + descriptions: 0, + time_remaining: null, + }), + ); } if (data.topic === "birdseyeLayout") { this.send("birdseye_layout", JSON.stringify(null)); diff --git a/web/e2e/pages/live.page.ts b/web/e2e/pages/live.page.ts new file mode 100644 index 0000000000..814064944b --- /dev/null +++ b/web/e2e/pages/live.page.ts @@ -0,0 +1,55 @@ +/** + * Live dashboard + single-camera page object. + * + * Encapsulates selectors and viewport-conditional openers for the + * Live route. Does NOT own assertions — specs call expect on the + * locators returned from these getters. + */ + +import type { Locator, Page } from "@playwright/test"; +import { BasePage } from "./base.page"; + +export class LivePage extends BasePage { + constructor(page: Page, isDesktop: boolean) { + super(page, isDesktop); + } + + /** The camera card wrapper on the dashboard, keyed by camera name. */ + cameraCard(name: string): Locator { + return this.page.locator(`[data-camera='${name}']`); + } + + /** Back button on the single-camera view header (desktop text). */ + get backButton(): Locator { + return this.page.getByText("Back", { exact: true }); + } + + /** History button on the single-camera view header (desktop text). */ + get historyButton(): Locator { + return this.page.getByText("History", { exact: true }); + } + + /** All CameraFeatureToggle elements (active + inactive). */ + get featureToggles(): Locator { + // Use div selector to exclude NavItem anchor elements that share the same classes. + return this.page.locator( + "div.flex.flex-col.items-center.justify-center.bg-selected, div.flex.flex-col.items-center.justify-center.bg-secondary", + ); + } + + /** Only the active (bg-selected) feature toggles. */ + get activeFeatureToggles(): Locator { + // Use div selector to exclude NavItem anchor elements that share the same classes. + return this.page.locator( + "div.flex.flex-col.items-center.justify-center.bg-selected", + ); + } + + /** Open the right-click context menu on a camera card (desktop only). */ + async openContextMenuOn(cameraName: string): Promise { + await this.cameraCard(cameraName).first().click({ button: "right" }); + return this.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + } +} diff --git a/web/e2e/pages/review.page.ts b/web/e2e/pages/review.page.ts new file mode 100644 index 0000000000..6d9cfb7c24 --- /dev/null +++ b/web/e2e/pages/review.page.ts @@ -0,0 +1,52 @@ +/** + * Review/events page object. + * + * Encapsulates severity tab, filter bar, calendar, and mobile filter + * drawer selectors. Does NOT own assertions. + */ + +import type { Locator, Page } from "@playwright/test"; +import { BasePage } from "./base.page"; + +export class ReviewPage extends BasePage { + constructor(page: Page, isDesktop: boolean) { + super(page, isDesktop); + } + + get alertsTab(): Locator { + return this.page.getByLabel("Alerts"); + } + + get detectionsTab(): Locator { + return this.page.getByLabel("Detections"); + } + + get motionTab(): Locator { + return this.page.getByRole("radio", { name: "Motion" }); + } + + get camerasFilterTrigger(): Locator { + return this.page.getByRole("button", { name: /cameras/i }).first(); + } + + get calendarTrigger(): Locator { + return this.page.getByRole("button", { name: /24 hours|calendar|date/i }); + } + + get showReviewedToggle(): Locator { + return this.page.getByRole("button", { name: /reviewed/i }); + } + + get reviewItems(): Locator { + return this.page.locator(".review-item"); + } + + /** The filter popover content (desktop) or drawer (mobile). */ + get filterOverlay(): Locator { + return this.page + .locator( + '[data-radix-popper-content-wrapper], [role="dialog"], [data-vaul-drawer]', + ) + .first(); + } +} diff --git a/web/e2e/scripts/lint-specs.mjs b/web/e2e/scripts/lint-specs.mjs index 4724e99bb9..e4046869df 100644 --- a/web/e2e/scripts/lint-specs.mjs +++ b/web/e2e/scripts/lint-specs.mjs @@ -14,10 +14,6 @@ * * @mobile rule: every .spec.ts under specs/ (not specs/_meta/) must * contain at least one test title or describe with the substring "@mobile". - * - * Specs in PENDING_REWRITE are exempt from all rules until they are - * rewritten with proper assertions and mobile coverage. Remove each - * entry when its spec is updated. */ import { readFileSync, readdirSync, statSync } from "node:fs"; @@ -28,24 +24,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const SPECS_DIR = resolve(__dirname, "..", "specs"); const META_PREFIX = resolve(SPECS_DIR, "_meta"); -// Specs exempt from lint rules until they are rewritten with proper -// assertions and mobile coverage. Remove each entry when its spec is updated. -const PENDING_REWRITE = new Set([ - "auth.spec.ts", - "chat.spec.ts", - "classification.spec.ts", - "config-editor.spec.ts", - "explore.spec.ts", - "export.spec.ts", - "face-library.spec.ts", - "live.spec.ts", - "logs.spec.ts", - "navigation.spec.ts", - "replay.spec.ts", - "review.spec.ts", - "system.spec.ts", -]); - const BANNED_PATTERNS = [ { name: "page.waitForTimeout", @@ -62,14 +40,12 @@ const BANNED_PATTERNS = [ { name: "conditional count() assertion", regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/, - advice: - "Assertions must be unconditional. Use expect(...).toHaveCount(n).", + advice: "Assertions must be unconditional. Use expect(...).toHaveCount(n).", }, { name: "vacuous textContent length assertion", regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/, - advice: - "Assert specific content, not that some text exists.", + advice: "Assert specific content, not that some text exists.", }, ]; @@ -89,8 +65,6 @@ function walk(dir) { } function lintFile(file) { - const basename = file.split("/").pop(); - if (PENDING_REWRITE.has(basename)) return []; if (file.includes("/specs/settings/")) return []; const errors = []; diff --git a/web/e2e/specs/auth.spec.ts b/web/e2e/specs/auth.spec.ts index 5f58375185..f0326a5d02 100644 --- a/web/e2e/specs/auth.spec.ts +++ b/web/e2e/specs/auth.spec.ts @@ -1,147 +1,110 @@ /** - * Auth and cross-cutting tests -- HIGH tier. + * Auth and role tests -- HIGH tier. * - * Tests protected route access for admin/viewer roles, - * access denied page rendering, viewer nav restrictions, - * and all routes smoke test. + * Admin access to /system, /config, /logs; viewer access denied + * markers (via i18n heading, not a data-testid we don't own); + * viewer nav restrictions; all-routes smoke. */ import { test, expect } from "../fixtures/frigate-test"; import { viewerProfile } from "../fixtures/mock-data/profile"; -test.describe("Auth - Admin Access @high", () => { - test("admin can access /system and sees system tabs", async ({ - frigateApp, - }) => { +test.describe("Auth — admin access @high", () => { + test("admin /system renders general tab", async ({ frigateApp }) => { await frigateApp.goto("/system"); - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - await frigateApp.page.waitForTimeout(3000); - // System page should have named tab buttons await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({ - timeout: 5_000, + timeout: 15_000, }); }); - test("admin can access /config and Monaco editor loads", async ({ - frigateApp, - }) => { + test("admin /config renders Monaco editor", async ({ frigateApp }) => { await frigateApp.goto("/config"); - await frigateApp.page.waitForTimeout(5000); - const editor = frigateApp.page.locator( - ".monaco-editor, [data-keybinding-context]", - ); - await expect(editor.first()).toBeVisible({ timeout: 10_000 }); + await expect( + frigateApp.page + .locator(".monaco-editor, [data-keybinding-context]") + .first(), + ).toBeVisible({ timeout: 15_000 }); }); - test("admin can access /logs and sees service tabs", async ({ - frigateApp, - }) => { + test("admin /logs renders frigate tab", async ({ frigateApp }) => { await frigateApp.goto("/logs"); - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({ timeout: 5_000, }); }); - - test("admin sees Classification nav on desktop", async ({ frigateApp }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/"); - await expect( - frigateApp.page.locator('a[href="/classification"]'), - ).toBeVisible(); - }); }); -test.describe("Auth - Viewer Restrictions @high", () => { - test("viewer sees Access Denied on /system", async ({ frigateApp, page }) => { - await frigateApp.installDefaults({ profile: viewerProfile() }); - await page.goto("/system"); - await page.waitForTimeout(2000); - // Should show "Access Denied" text - await expect(page.getByText("Access Denied")).toBeVisible({ - timeout: 5_000, +test.describe("Auth — viewer restrictions @high", () => { + for (const path of ["/system", "/config", "/logs"]) { + test(`viewer on ${path} sees AccessDenied`, async ({ frigateApp }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto(path); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /access denied/i, + }), + ).toBeVisible({ timeout: 10_000 }); }); + } + + test("viewer sees cameras on /", async ({ frigateApp }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto("/"); + await expect( + frigateApp.page.locator("[data-camera='front_door']"), + ).toBeVisible({ timeout: 10_000 }); }); - test("viewer sees Access Denied on /config", async ({ frigateApp, page }) => { + test("viewer sees severity tabs on /review", async ({ frigateApp }) => { await frigateApp.installDefaults({ profile: viewerProfile() }); - await page.goto("/config"); - await page.waitForTimeout(2000); - await expect(page.getByText("Access Denied")).toBeVisible({ - timeout: 5_000, - }); - }); - - test("viewer sees Access Denied on /logs", async ({ frigateApp, page }) => { - await frigateApp.installDefaults({ profile: viewerProfile() }); - await page.goto("/logs"); - await page.waitForTimeout(2000); - await expect(page.getByText("Access Denied")).toBeVisible({ - timeout: 5_000, - }); - }); - - test("viewer can access Live page and sees cameras", async ({ - frigateApp, - page, - }) => { - await frigateApp.installDefaults({ profile: viewerProfile() }); - await page.goto("/"); - await page.waitForSelector("#pageRoot", { timeout: 10_000 }); - await expect(page.locator("[data-camera='front_door']")).toBeVisible({ + await frigateApp.page.goto("/review"); + await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({ timeout: 10_000, }); }); - test("viewer can access Review page and sees severity tabs", async ({ + test("viewer can access all non-admin routes without AccessDenied", async ({ frigateApp, - page, - }) => { - await frigateApp.installDefaults({ profile: viewerProfile() }); - await page.goto("/review"); - await page.waitForSelector("#pageRoot", { timeout: 10_000 }); - await expect(page.getByLabel("Alerts")).toBeVisible({ timeout: 5_000 }); - }); - - test("viewer can access all main user routes without crash", async ({ - frigateApp, - page, }) => { await frigateApp.installDefaults({ profile: viewerProfile() }); const routes = ["/", "/review", "/explore", "/export", "/settings"]; for (const route of routes) { - await page.goto(route); - await page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await frigateApp.page.goto(route); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /access denied/i, + }), + ).toHaveCount(0); } }); }); -test.describe("Auth - All Routes Smoke @high", () => { - test("all user routes render without crash", async ({ frigateApp }) => { - const routes = ["/", "/review", "/explore", "/export", "/settings"]; - for (const route of routes) { +test.describe("Auth — viewer nav restrictions (desktop) @high", () => { + test.skip(({ frigateApp }) => frigateApp.isMobile, "Sidebar only on desktop"); + + test("viewer sidebar hides admin routes", async ({ frigateApp }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto("/"); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + for (const href of ["/system", "/config", "/logs"]) { + await expect( + frigateApp.page.locator(`aside a[href='${href}']`), + ).toHaveCount(0); + } + }); +}); + +test.describe("Auth — all routes smoke @high @mobile", () => { + test("every common route renders #pageRoot", async ({ frigateApp }) => { + for (const route of ["/", "/review", "/explore", "/export", "/settings"]) { await frigateApp.goto(route); await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ timeout: 10_000, }); } }); - - test("admin routes render with specific content", async ({ frigateApp }) => { - // System page should have tab controls - await frigateApp.goto("/system"); - await frigateApp.page.waitForTimeout(3000); - await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({ - timeout: 5_000, - }); - - // Logs page should have service tabs - await frigateApp.goto("/logs"); - await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({ - timeout: 5_000, - }); - }); }); diff --git a/web/e2e/specs/chat.spec.ts b/web/e2e/specs/chat.spec.ts index ba4a4e6589..fcd10782c1 100644 --- a/web/e2e/specs/chat.spec.ts +++ b/web/e2e/specs/chat.spec.ts @@ -1,34 +1,311 @@ /** * Chat page tests -- MEDIUM tier. * - * Tests chat interface rendering, input area, and example prompt buttons. + * Starting state, NDJSON streaming contract (not SSE), assistant + * bubble grows as chunks arrive, error path, and mobile viewport. */ -import { test, expect } from "../fixtures/frigate-test"; +import { test, expect, type FrigateApp } from "../fixtures/frigate-test"; -test.describe("Chat Page @medium", () => { - test("chat page renders without crash", async ({ frigateApp }) => { - await frigateApp.goto("/chat"); - await frigateApp.page.waitForTimeout(2000); - await expect(frigateApp.page.locator("body")).toBeVisible(); - }); +/** + * Install a window.fetch override on the page so that POSTs to + * chat/completion resolve with a real ReadableStream that emits the + * given chunks over time. This is the only way to validate + * chunk-by-chunk rendering through Playwright — page.route() does not + * support streaming responses. + * + * Must be called BEFORE frigateApp.goto(). The override also exposes + * `__chatRequests` on window so tests can assert the outgoing body. + */ +async function installChatStreamOverride( + app: FrigateApp, + chunks: Array>, + opts: { chunkDelayMs?: number; status?: number } = {}, +) { + const { chunkDelayMs = 40, status = 200 } = opts; + await app.page.addInitScript( + ({ chunks, chunkDelayMs, status }) => { + (window as unknown as { __chatRequests: unknown[] }).__chatRequests = []; + const origFetch = window.fetch; + window.fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (url.includes("chat/completion")) { + const body = + init?.body instanceof String || typeof init?.body === "string" + ? JSON.parse(init!.body as string) + : null; + ( + window as unknown as { __chatRequests: unknown[] } + ).__chatRequests.push({ url, body }); + if (status !== 200) { + return new Response(JSON.stringify({ error: "boom" }), { + status, + }); + } + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + await new Promise((r) => setTimeout(r, chunkDelayMs)); + controller.enqueue( + encoder.encode(JSON.stringify(chunk) + "\n"), + ); + } + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + } + return origFetch.call(window, input as RequestInfo, init); + }; + }, + { chunks, chunkDelayMs, status }, + ); +} - test("chat page has interactive input or buttons", async ({ frigateApp }) => { +test.describe("Chat — starting state @medium", () => { + test("empty message list renders ChatStartingState with title and input", async ({ + frigateApp, + }) => { await frigateApp.goto("/chat"); - await frigateApp.page.waitForTimeout(2000); - const interactive = frigateApp.page.locator("input, textarea, button"); - const count = await interactive.count(); - expect(count).toBeGreaterThan(0); - }); - - test("chat input accepts text", async ({ frigateApp }) => { - await frigateApp.goto("/chat"); - await frigateApp.page.waitForTimeout(2000); - const input = frigateApp.page.locator("input, textarea").first(); - if (await input.isVisible().catch(() => false)) { - await input.fill("What cameras detected a person today?"); - const value = await input.inputValue(); - expect(value.length).toBeGreaterThan(0); - } + await expect( + frigateApp.page.getByRole("heading", { level: 1 }), + ).toBeVisible({ timeout: 10_000 }); + await expect(frigateApp.page.getByPlaceholder(/ask/i)).toBeVisible(); + // Four quick-reply buttons from starting_requests.* + const quickReplies = frigateApp.page.locator( + "button:has-text('Show recent events'), button:has-text('Show camera status'), button:has-text('What happened'), button:has-text('Watch')", + ); + await expect(quickReplies.first()).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Chat — streaming @medium", () => { + test("submission POSTs to chat/completion with stream: true", async ({ + frigateApp, + }) => { + await installChatStreamOverride(frigateApp, [ + { type: "content", delta: "Hel" }, + { type: "content", delta: "lo" }, + ]); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("hello chat"); + await input.press("Enter"); + + await expect + .poll( + async () => + frigateApp.page.evaluate( + () => + (window as unknown as { __chatRequests: unknown[] }) + .__chatRequests?.length ?? 0, + ), + { timeout: 5_000 }, + ) + .toBeGreaterThan(0); + + const request = await frigateApp.page.evaluate( + () => + ( + window as unknown as { + __chatRequests: Array<{ + url: string; + body: { stream: boolean; messages: Array<{ content: string }> }; + }>; + } + ).__chatRequests[0], + ); + expect(request.body.stream).toBe(true); + expect( + request.body.messages[request.body.messages.length - 1].content, + ).toBe("hello chat"); + }); + + test("NDJSON content chunks accumulate in the assistant bubble", async ({ + frigateApp, + }) => { + await installChatStreamOverride( + frigateApp, + [ + { type: "content", delta: "Hel" }, + { type: "content", delta: "lo, " }, + { type: "content", delta: "world!" }, + ], + { chunkDelayMs: 50 }, + ); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("greet me"); + await input.press("Enter"); + + await expect(frigateApp.page.getByText(/Hello, world!/i)).toBeVisible({ + timeout: 10_000, + }); + }); + + test("tool_calls chunks render a ToolCallsGroup", async ({ frigateApp }) => { + await installChatStreamOverride(frigateApp, [ + { + type: "tool_calls", + tool_calls: [ + { + id: "call_1", + name: "search_objects", + arguments: { label: "person" }, + }, + ], + }, + { type: "content", delta: "Searching for people." }, + ]); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("find people"); + await input.press("Enter"); + + // ToolCallsGroup normalizes "search_objects" → "Search Objects" via + // normalizeName(). Match the rendered display label instead. + await expect(frigateApp.page.getByText(/search objects/i)).toBeVisible({ + timeout: 10_000, + }); + await expect( + frigateApp.page.getByText(/searching for people/i), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Chat — stop @medium", () => { + test("Stop button aborts an in-flight stream and freezes the partial message", async ({ + frigateApp, + }) => { + // A long chunk sequence with big delays gives us time to hit Stop. + await installChatStreamOverride( + frigateApp, + [ + { type: "content", delta: "First chunk. " }, + { type: "content", delta: "Second chunk. " }, + { type: "content", delta: "Third chunk. " }, + ], + { chunkDelayMs: 300 }, + ); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("slow response please"); + await input.press("Enter"); + + // Wait for the first chunk to render + await expect(frigateApp.page.getByText(/First chunk\./)).toBeVisible({ + timeout: 10_000, + }); + + // The Stop button is a destructive rounded button shown while isLoading. + // It contains only an FaStop SVG icon (no visible text). Find it by the + // destructive variant class or fall back to aria-label. + const stopBtn = frigateApp.page + .locator("button.bg-destructive, button[class*='destructive']") + .first(); + await stopBtn.click({ timeout: 3_000 }).catch(async () => { + await frigateApp.page + .getByRole("button", { name: /stop|cancel/i }) + .first() + .click(); + }); + + // Third chunk should never appear. + await expect(frigateApp.page.getByText(/Third chunk\./)).toHaveCount(0); + }); +}); + +test.describe("Chat — error @medium", () => { + test("non-OK response renders an error banner", async ({ frigateApp }) => { + await installChatStreamOverride(frigateApp, [], { status: 500 }); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("trigger error"); + await input.press("Enter"); + // The error banner is a role="alert" paragraph; target by role so we + // don't collide with the user-message bubble that contains "trigger + // error" (which would match /error/ in strict mode). + await expect( + frigateApp.page.getByRole("alert").filter({ + hasText: /boom|something went wrong/i, + }), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Chat — attachment chip @medium", () => { + test("attaching an event renders a ChatAttachmentChip", async ({ + frigateApp, + }) => { + // The chat starts with an empty message list (ChatStartingState). + // After sending a message, ChatEntry with the paperclip button appears. + // We use the stream override so the first message completes quickly. + await installChatStreamOverride(frigateApp, [ + { type: "content", delta: "Done." }, + ]); + await frigateApp.goto("/chat"); + + // Send a first message to transition out of ChatStartingState so the + // full ChatEntry (with the paperclip) is visible. + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("hello"); + await input.press("Enter"); + // Wait for the assistant response to complete so isLoading becomes false + // and the paperclip button is re-enabled. + await expect(frigateApp.page.getByText(/Done\./i)).toBeVisible({ + timeout: 10_000, + }); + + // The paperclip button has aria-label from t("attachment_picker_placeholder") + // = "Attach an event". + const paperclip = frigateApp.page + .getByRole("button", { name: /attach an event/i }) + .first(); + await expect(paperclip).toBeVisible({ timeout: 5_000 }); + await paperclip.click(); + + // The popover shows a paste input with placeholder "Or paste event ID". + const idInput = frigateApp.page + .locator('input[placeholder*="event" i], input[aria-label*="attach" i]') + .first(); + await expect(idInput).toBeVisible({ timeout: 3_000 }); + await idInput.fill("test-event-1"); + await frigateApp.page + .getByRole("button", { name: /^attach$/i }) + .first() + .click(); + + // The ChatAttachmentChip renders in the composer area. It shows an + // activity indicator while loading event data (event_ids API not mocked), + // so assert on the chip container being present in the composer. + await expect( + frigateApp.page.locator( + "[class*='inline-flex'][class*='rounded-lg'][class*='border']", + ), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Chat — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("chat input is focusable at mobile viewport", async ({ frigateApp }) => { + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.focus(); + await expect(input).toBeFocused(); }); }); diff --git a/web/e2e/specs/classification.spec.ts b/web/e2e/specs/classification.spec.ts index 9dd0815c60..83a33a815d 100644 --- a/web/e2e/specs/classification.spec.ts +++ b/web/e2e/specs/classification.spec.ts @@ -1,33 +1,228 @@ /** * Classification page tests -- MEDIUM tier. * - * Tests model selection view rendering and interactive elements. + * Model list driven by config.classification.custom + per-model + * dataset fetches. Admin-only access. */ import { test, expect } from "../fixtures/frigate-test"; +import { viewerProfile } from "../fixtures/mock-data/profile"; -test.describe("Classification @medium", () => { - test("classification page renders without crash", async ({ frigateApp }) => { +const CUSTOM_MODELS = { + object_classifier: { + name: "object_classifier", + object_config: { objects: ["person"], classification_type: "sub_label" }, + }, + state_classifier: { + name: "state_classifier", + state_config: { cameras: { front_door: { crop: [0, 0, 1, 1] } } }, + }, +}; + +async function installDatasetRoute( + app: { page: import("@playwright/test").Page }, + name: string, + body: Record = { categories: {} }, +) { + await app.page.route( + new RegExp(`/api/classification/${name}/dataset`), + (route) => route.fulfill({ json: body }), + ); +} + +async function installTrainRoute( + app: { page: import("@playwright/test").Page }, + name: string, +) { + await app.page.route( + new RegExp(`/api/classification/${name}/train`), + (route) => route.fulfill({ json: [] }), + ); +} + +test.describe("Classification — model list @medium", () => { + test("custom models render by name", async ({ frigateApp }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); await frigateApp.goto("/classification"); await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); + await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({ + timeout: 10_000, + }); }); - test("classification page shows content and controls", async ({ - frigateApp, - }) => { + test("empty custom map renders without crash", async ({ frigateApp }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: {} } }, + }); await frigateApp.goto("/classification"); - await frigateApp.page.waitForTimeout(2000); - const text = await frigateApp.page.textContent("#pageRoot"); - expect(text?.length).toBeGreaterThan(0); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); }); - test("classification page has interactive elements", async ({ + test("toggling to states view switches the rendered card set", async ({ frigateApp, }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); await frigateApp.goto("/classification"); - await frigateApp.page.waitForTimeout(2000); - const buttons = frigateApp.page.locator("#pageRoot button"); - const count = await buttons.count(); - expect(count).toBeGreaterThanOrEqual(0); + // Objects is default — object_classifier visible, state_classifier hidden. + await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({ + timeout: 10_000, + }); + await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0); + + // Click the "states" toggle. Radix ToggleGroup type="single" uses role="radio". + const statesToggle = frigateApp.page + .getByRole("radio", { name: /state/i }) + .first(); + await expect(statesToggle).toBeVisible({ timeout: 5_000 }); + await statesToggle.click(); + + await expect(frigateApp.page.getByText("state_classifier")).toBeVisible({ + timeout: 5_000, + }); + await expect(frigateApp.page.getByText("object_classifier")).toHaveCount(0); + }); +}); + +test.describe("Classification — model detail navigation @medium", () => { + test("clicking a model card opens ModelTrainingView", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); + await installTrainRoute(frigateApp, "object_classifier"); + await frigateApp.goto("/classification"); + + const objectCard = frigateApp.page.getByText("object_classifier").first(); + await expect(objectCard).toBeVisible({ timeout: 10_000 }); + await objectCard.click(); + + // ModelTrainingView renders a Back button (aria-label "Back"). + // useOverlayState stores the selected model in window.history.state + // (not the URL), so we verify the state transition via the DOM. + await expect( + frigateApp.page.getByRole("button", { name: /back/i }), + ).toBeVisible({ timeout: 5_000 }); + + // The model grid is no longer shown; state_classifier card is gone. + await expect(frigateApp.page.getByText("state_classifier")).toHaveCount(0); + }); +}); + +test.describe("Classification — delete model (desktop) @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Delete action menu is desktop-focused", + ); + + test("deleting a model fires DELETE + PUT /config/set", async ({ + frigateApp, + }) => { + let deleteCalled = false; + let configSetCalled = false; + + // installDefaults must run first because Playwright matches routes in + // LIFO order — routes registered after installDefaults take precedence + // over the generic catch-all registered inside it. + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); + + // Register spy routes after installDefaults so they win over the catch-all. + await frigateApp.page.route( + /\/api\/classification\/object_classifier$/, + async (route) => { + if (route.request().method() === "DELETE") { + deleteCalled = true; + await route.fulfill({ json: { success: true } }); + return; + } + return route.fallback(); + }, + ); + await frigateApp.page.route("**/api/config/set", async (route) => { + if (route.request().method() === "PUT") configSetCalled = true; + await route.fulfill({ json: { success: true, require_restart: false } }); + }); + await frigateApp.goto("/classification"); + await expect(frigateApp.page.getByText("object_classifier")).toBeVisible({ + timeout: 10_000, + }); + + // The card-level actions menu (FiMoreVertical three-dot icon) is a + // DropdownMenuTrigger with asChild on a BlurredIconButton div. + // Radix forwards aria-haspopup="menu" to the child element. + // Scope the selector to the model card grid to avoid hitting the + // settings sidebar trigger. + const cardGrid = frigateApp.page.locator(".grid.auto-rows-max"); + await expect(cardGrid).toBeVisible({ timeout: 5_000 }); + const trigger = cardGrid.locator('[aria-haspopup="menu"]').first(); + await expect(trigger).toBeVisible({ timeout: 5_000 }); + await trigger.click(); + const deleteItem = frigateApp.page + .getByRole("menuitem", { name: /delete/i }) + .first(); + await expect(deleteItem).toBeVisible({ timeout: 5_000 }); + await deleteItem.click(); + + // Confirm the AlertDialog. + const alert = frigateApp.page.getByRole("alertdialog"); + await expect(alert).toBeVisible({ timeout: 5_000 }); + await alert + .getByRole("button", { name: /delete|confirm/i }) + .first() + .click(); + + await expect.poll(() => deleteCalled, { timeout: 5_000 }).toBe(true); + await expect.poll(() => configSetCalled, { timeout: 5_000 }).toBe(true); + }); +}); + +test.describe("Classification — admin only @medium", () => { + test("viewer navigating to /classification is redirected to access-denied", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ profile: viewerProfile() }); + await frigateApp.page.goto("/classification"); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect(frigateApp.page).toHaveURL(/\/unauthorized/, { + timeout: 10_000, + }); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /access denied/i, + }), + ).toBeVisible({ timeout: 10_000 }); + }); +}); + +test.describe("Classification — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("page renders at mobile viewport", async ({ frigateApp }) => { + await frigateApp.installDefaults({ + config: { classification: { custom: CUSTOM_MODELS } }, + }); + await installDatasetRoute(frigateApp, "object_classifier"); + await installDatasetRoute(frigateApp, "state_classifier"); + await frigateApp.goto("/classification"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); }); }); diff --git a/web/e2e/specs/config-editor.spec.ts b/web/e2e/specs/config-editor.spec.ts index 1de6fc52b7..2b51a93636 100644 --- a/web/e2e/specs/config-editor.spec.ts +++ b/web/e2e/specs/config-editor.spec.ts @@ -1,44 +1,276 @@ /** - * Config Editor page tests -- MEDIUM tier. + * Config Editor tests -- MEDIUM tier. * - * Tests Monaco editor loading, YAML content rendering, - * save button presence, and copy button interaction. + * Monaco load + value, Save (config/save?save_option=saveonly), + * Save error path, Save and Restart (WS frame via useRestart), + * Copy (clipboard), schema markers. */ import { test, expect } from "../fixtures/frigate-test"; +import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames"; +import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard"; +import { + getMonacoVisibleText, + replaceMonacoValue, + waitForErrorMarker, +} from "../helpers/monaco"; -test.describe("Config Editor @medium", () => { - test("config editor loads Monaco editor with content", async ({ - frigateApp, - }) => { +const SAMPLE_CONFIG = + "mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n"; + +async function installSaveRoute( + app: { page: import("@playwright/test").Page }, + status: number, + body: Record, +): Promise<{ + capturedUrl: () => string | null; + capturedBody: () => string | null; +}> { + let lastUrl: string | null = null; + let lastBody: string | null = null; + await app.page.route("**/api/config/save**", async (route) => { + lastUrl = route.request().url(); + lastBody = route.request().postData(); + await route.fulfill({ status, json: body }); + }); + return { + capturedUrl: () => lastUrl, + capturedBody: () => lastBody, + }; +} + +test.describe("Config Editor — Monaco @medium", () => { + test("editor loads with mocked configRaw content", async ({ frigateApp }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); await frigateApp.goto("/config"); - await frigateApp.page.waitForTimeout(5000); - // Monaco editor should render with a specific class - const editor = frigateApp.page.locator( - ".monaco-editor, [data-keybinding-context]", + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + // Assert via DOM-rendered visible text (Monaco virtualizes — works + // for short configs which covers our mocked content). + await expect + .poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 }) + .toContain("front_door"); + }); +}); + +test.describe("Config Editor — Save @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Save button copy is desktop-visible (hidden md:block)", + ); + + test("clicking Save Only POSTs config/save?save_option=saveonly", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + const capture = await installSaveRoute(frigateApp, 200, { + message: "Config saved", + }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + await frigateApp.page.getByLabel("Save Only").click(); + await expect + .poll(() => capture.capturedUrl(), { timeout: 5_000 }) + .toMatch(/config\/save\?save_option=saveonly/); + // Body is the raw YAML as text/plain + await expect + .poll(() => capture.capturedBody(), { timeout: 5_000 }) + .toContain("front_door"); + }); + + test("Save error shows the server message in the error area", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await installSaveRoute(frigateApp, 400, { + message: "Invalid field `cameras.front_door`", + }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + await frigateApp.page.getByLabel("Save Only").click(); + await expect(frigateApp.page.getByText(/Invalid field/i)).toBeVisible({ + timeout: 5_000, + }); + }); +}); + +test.describe("Config Editor — Save and Restart @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Save and Restart button copy is desktop-visible", + ); + + test("Save and Restart opens dialog; confirm sends WS restart frame", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await installSaveRoute(frigateApp, 200, { message: "Saved" }); + await installWsFrameCapture(frigateApp.page); + + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + await frigateApp.page.getByLabel("Save & Restart").click(); + const dialog = frigateApp.page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + await dialog.getByRole("button", { name: /restart/i }).click(); + await waitForWsFrame( + frigateApp.page, + (frame) => frame.includes('"restart"') || frame.includes("restart"), + { message: "useRestart should send a WS frame on the restart topic" }, + ); + }); + + test("cancelling the restart dialog leaves body interactive", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await installSaveRoute(frigateApp, 200, { message: "Saved" }); + + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + await frigateApp.page.getByLabel("Save & Restart").click(); + const dialog = frigateApp.page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await dialog.getByRole("button", { name: /cancel/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 3_000 }); + await expect( + frigateApp.page.locator(".monaco-editor").first(), + ).toBeVisible(); + }); +}); + +test.describe("Config Editor — Copy @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Copy button copy is desktop-visible", + ); + + test("Copy places the editor value in the clipboard", async ({ + frigateApp, + context, + }) => { + await grantClipboardPermissions(context); + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + await frigateApp.page.getByLabel("Copy Config").click(); + await expect + .poll(() => readClipboard(frigateApp.page), { timeout: 5_000 }) + .toContain("front_door"); + }); +}); + +test.describe("Config Editor — schema markers @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Schema validation assumes focused desktop editing", + ); + + test("invalid YAML renders at least one error marker in the DOM", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + // Replace editor contents with clearly invalid YAML via keyboard. + await replaceMonacoValue( + frigateApp.page, + "this is not: [yaml: and has {unbalanced", + ); + // Monaco debounces marker evaluation; the .squiggly-error decoration + // appears asynchronously in the .view-overlays layer. + await waitForErrorMarker(frigateApp.page); + }); +}); + +test.describe("Config Editor — Cmd+S keyboard shortcut @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Keyboard save shortcut is desktop-only", + ); + + test("Cmd/Ctrl+S fires the same config/save POST as the Save button", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + const capture = await installSaveRoute(frigateApp, 200, { + message: "Saved", + }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + + // Focus the editor so Monaco's keybinding receives the shortcut. + await frigateApp.page.locator(".monaco-editor").first().click(); + await frigateApp.page.keyboard.press("ControlOrMeta+s"); + + await expect + .poll(() => capture.capturedUrl(), { timeout: 5_000 }) + .toMatch(/config\/save\?save_option=saveonly/); + }); +}); + +test.describe("Config Editor — Safe Mode auto-validation @medium", () => { + test("safe-mode config auto-posts on mount and shows the inline error", async ({ + frigateApp, + }) => { + // Thread safe_mode: true through the config override, then stub + // config/save to return a validation error. The page's + // initialValidationRef effect runs on mount and POSTs + // config/save?save_option=saveonly with the raw config; the 400 + // surfaces through setError. + // installDefaults must come first so our specific route wins (LIFO). + await frigateApp.installDefaults({ + config: { safe_mode: true } as unknown as Record, + configRaw: "cameras:\n front_door:\n ffmpeg: {}\n", + }); + let autoSaveCalled = false; + await frigateApp.page.route("**/api/config/save**", async (route) => { + autoSaveCalled = true; + await route.fulfill({ + status: 400, + json: { message: "safe-mode validation failure" }, + }); + }); + + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, + ); + await expect.poll(() => autoSaveCalled, { timeout: 10_000 }).toBe(true); + await expect( + frigateApp.page.getByText(/safe-mode validation failure/i), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +test.describe("Config Editor — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("editor renders at narrow viewport", async ({ frigateApp }) => { + await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG }); + await frigateApp.goto("/config"); + await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( + { timeout: 15_000 }, ); - await expect(editor.first()).toBeVisible({ timeout: 10_000 }); - }); - - test("config editor has action buttons", async ({ frigateApp }) => { - await frigateApp.goto("/config"); - await frigateApp.page.waitForTimeout(5000); - const buttons = frigateApp.page.locator("button"); - const count = await buttons.count(); - expect(count).toBeGreaterThan(0); - }); - - test("config editor button clicks do not crash", async ({ frigateApp }) => { - await frigateApp.goto("/config"); - await frigateApp.page.waitForTimeout(5000); - // Find buttons with SVG icons (copy, save, etc.) - const iconButtons = frigateApp.page.locator("button:has(svg)"); - const count = await iconButtons.count(); - if (count > 0) { - // Click the first icon button (likely copy) - await iconButtons.first().click(); - await frigateApp.page.waitForTimeout(500); - } - await expect(frigateApp.page.locator("body")).toBeVisible(); }); }); diff --git a/web/e2e/specs/explore.spec.ts b/web/e2e/specs/explore.spec.ts index 1811338a4c..9d239a3f91 100644 --- a/web/e2e/specs/explore.spec.ts +++ b/web/e2e/specs/explore.spec.ts @@ -1,97 +1,265 @@ /** * Explore page tests -- HIGH tier. * - * Tests search input with text entry and clearing, camera filter popover - * opening with camera names, and content rendering with mock events. + * Search input, Enter submission, camera filter popover (desktop), + * event grid rendering with mocked events, mobile filter drawer. + * + * DEVIATION NOTES (from original plan): + * + * 1. Search input: InputWithTags is only rendered when + * config.semantic_search.enabled is true. Tests that exercise the search + * input override the config accordingly, using model:"genai" (not in the + * JINA_EMBEDDING_MODELS list) so the page skips local model-state checks + * and renders without waiting for model-download WS messages. + * + * 2. Filter buttons (Cameras, Labels, More Filters): SearchFilterGroup is + * only rendered when hasExistingSearch is true. Tests navigate with a URL + * param (?labels=person) to surface the filter bar. + * + * 3. Cameras button: accessible name is "Cameras Filter" (aria-label), not + * "All Cameras" (inner text). Use getByLabel("Cameras Filter"). + * + * 4. Labels: button accessible name is "Labels" (aria-label). With + * ?labels=person, the text shows "Person" rather than "All Labels". + * Use getByLabel("Labels"). + * + * 5. Sub-labels / Zones: These live inside the "More Filters" dialog + * (SearchFilterDialog), not as standalone top-level buttons. The Zones + * test opens "More Filters" and asserts zone content from config. + * + * 6. similarity_search_id URL param: This param does not exist in the app. + * The correct entrypoint for similarity search is + * ?search_type=similarity&event_id=. The test uses this URL and + * polls for the resulting API request. */ import { test, expect } from "../fixtures/frigate-test"; -test.describe("Explore Page - Search @high", () => { - test("explore page renders with filter buttons", async ({ frigateApp }) => { +// Semantic search config override used by multiple tests. Using model: +// "genai" (not in JINA_EMBEDDING_MODELS) sets isGenaiEmbeddings=true, which +// skips local model-state checks and lets the page render without waiting for +// individual model download WS messages. The WS mocker returns a completed +// reindexState so !reindexState is false and the loading gate clears. +const SEMANTIC_SEARCH_CONFIG = { + semantic_search: { enabled: true, model: "genai" }, +} as const; + +// --------------------------------------------------------------------------- +// Search input (semantic_search must be enabled) +// --------------------------------------------------------------------------- + +test.describe("Explore — search @high", () => { + test("search input accepts text and clears", async ({ frigateApp }) => { + // Enable semantic search so InputWithTags renders. + await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG }); await frigateApp.goto("/explore"); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.fill("person"); + await expect(searchInput).toHaveValue("person"); + await searchInput.fill(""); + await expect(searchInput).toHaveValue(""); + }); + + test("Enter submission does not crash the page", async ({ frigateApp }) => { + await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG }); + await frigateApp.goto("/explore"); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.fill("car in driveway"); + await searchInput.press("Enter"); await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - const buttons = frigateApp.page.locator("#pageRoot button"); - await expect(buttons.first()).toBeVisible({ timeout: 10_000 }); - }); - - test("search input accepts text and can be cleared", async ({ - frigateApp, - }) => { - await frigateApp.goto("/explore"); - await frigateApp.page.waitForTimeout(1000); - const searchInput = frigateApp.page.locator("input").first(); - if (await searchInput.isVisible()) { - await searchInput.fill("person"); - await expect(searchInput).toHaveValue("person"); - await searchInput.fill(""); - await expect(searchInput).toHaveValue(""); - } - }); - - test("search input submits on Enter", async ({ frigateApp }) => { - await frigateApp.goto("/explore"); - await frigateApp.page.waitForTimeout(1000); - const searchInput = frigateApp.page.locator("input").first(); - if (await searchInput.isVisible()) { - await searchInput.fill("car in driveway"); - await searchInput.press("Enter"); - await frigateApp.page.waitForTimeout(1000); - // Page should not crash after search submit - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - } }); }); -test.describe("Explore Page - Filters @high", () => { - test("camera filter button opens popover with camera names (desktop)", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/explore"); - await frigateApp.page.waitForTimeout(1000); - const camerasBtn = frigateApp.page.getByRole("button", { - name: /cameras/i, - }); - if (await camerasBtn.isVisible().catch(() => false)) { - await camerasBtn.click(); - await frigateApp.page.waitForTimeout(500); - const popover = frigateApp.page.locator( - "[data-radix-popper-content-wrapper]", - ); - await expect(popover.first()).toBeVisible({ timeout: 3_000 }); - // Camera names from config should be in the popover - await expect(frigateApp.page.getByText("Front Door")).toBeVisible(); - await frigateApp.page.keyboard.press("Escape"); - } +// --------------------------------------------------------------------------- +// Filter bar — desktop only +// Filter buttons appear once hasExistingSearch is true (URL params present). +// --------------------------------------------------------------------------- + +test.describe("Explore — filters (desktop) @high", () => { + test.skip(({ frigateApp }) => frigateApp.isMobile, "Desktop popovers"); + + test("Cameras popover lists configured cameras", async ({ frigateApp }) => { + // Navigate with a labels filter param so the filter bar renders. + await frigateApp.goto("/explore?labels=person"); + // CamerasFilterButton has aria-label="Cameras Filter". Use getByLabel to + // match against the accessible name (not the inner "All Cameras" text). + const camerasBtn = frigateApp.page.getByLabel("Cameras Filter").first(); + await expect(camerasBtn).toBeVisible({ timeout: 10_000 }); + await camerasBtn.click(); + // DropdownMenu on desktop wraps content in data-radix-popper-content-wrapper. + const popover = frigateApp.page.locator( + "[data-radix-popper-content-wrapper]", + ); + await expect(popover.first()).toBeVisible({ timeout: 3_000 }); + await expect(frigateApp.page.getByText("Front Door")).toBeVisible(); }); - test("filter button opens and closes overlay cleanly", async ({ - frigateApp, - }) => { - await frigateApp.goto("/explore"); - await frigateApp.page.waitForTimeout(1000); - const firstButton = frigateApp.page.locator("#pageRoot button").first(); - await expect(firstButton).toBeVisible({ timeout: 5_000 }); - await firstButton.click(); - await frigateApp.page.waitForTimeout(500); + test("Labels filter lists labels from config", async ({ frigateApp }) => { + // Navigate with an existing search so the filter bar renders. + await frigateApp.goto("/explore?labels=person"); + // GeneralFilterButton has aria-label="Labels". With ?labels=person the + // button text shows "Person" (the selected label), but the aria-label + // remains "Labels". + const labelsBtn = frigateApp.page.getByLabel("Labels").first(); + await expect(labelsBtn).toBeVisible({ timeout: 10_000 }); + await labelsBtn.click(); + // PlatformAwareDialog renders on desktop as a dropdown/popover overlay. + const overlay = frigateApp.page.locator( + "[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']", + ); + await expect(overlay.first()).toBeVisible({ timeout: 3_000 }); + // "person" is already selected (it's in the URL); assert it appears in + // the overlay content. + await expect(overlay.first().getByText(/person/i)).toBeVisible(); + await frigateApp.page.keyboard.press("Escape"); + }); + + test("Sub-labels filter renders inside More Filters dialog", async ({ + frigateApp, + }) => { + // Sub-labels live inside SearchFilterDialog ("More Filters" button). + // With sub_labels mocked as [], the section still renders its heading. + await frigateApp.page.route("**/api/sub_labels**", (route) => + route.fulfill({ json: [] }), + ); + await frigateApp.goto("/explore?labels=person"); + const moreBtn = frigateApp.page.getByLabel("More Filters").first(); + await expect(moreBtn).toBeVisible({ timeout: 10_000 }); + await moreBtn.click(); + const overlay = frigateApp.page.locator( + "[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']", + ); + await expect(overlay.first()).toBeVisible({ timeout: 3_000 }); + // "Sub Labels" section heading always renders inside the dialog. + await expect( + frigateApp.page.getByText(/sub.?label/i).first(), + ).toBeVisible(); + await frigateApp.page.keyboard.press("Escape"); + }); + + test("Zones filter lists configured zones inside More Filters dialog", async ({ + frigateApp, + }) => { + // Override config to guarantee a known zone on front_door. + await frigateApp.installDefaults({ + config: { + cameras: { + front_door: { + zones: { + front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" }, + }, + }, + }, + }, + }); + await frigateApp.goto("/explore?labels=person"); + const moreBtn = frigateApp.page.getByLabel("More Filters").first(); + await expect(moreBtn).toBeVisible({ timeout: 10_000 }); + await moreBtn.click(); + const overlay = frigateApp.page.locator( + "[data-radix-popper-content-wrapper], [role='dialog'], [data-state='open']", + ); + await expect(overlay.first()).toBeVisible({ timeout: 3_000 }); + await expect(frigateApp.page.getByText(/front.?yard/i)).toBeVisible(); await frigateApp.page.keyboard.press("Escape"); - await frigateApp.page.waitForTimeout(300); - // Page is still functional after open/close cycle - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); }); }); -test.describe("Explore Page - Content @high", () => { - test("explore page shows content with mock events", async ({ +// --------------------------------------------------------------------------- +// Content +// --------------------------------------------------------------------------- + +test.describe("Explore — content @high", () => { + test("page renders with mock events", async ({ frigateApp }) => { + await frigateApp.goto("/explore"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + await expect( + frigateApp.page.locator("#pageRoot button").first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("empty events renders without crash", async ({ frigateApp }) => { + await frigateApp.installDefaults({ events: [] }); + await frigateApp.goto("/explore"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("search fires a /api/events request with the query", async ({ frigateApp, }) => { + await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG }); + const eventsRequests: string[] = []; + frigateApp.page.on("request", (req) => { + const url = req.url(); + if (/\/api\/events/.test(url)) eventsRequests.push(url); + }); await frigateApp.goto("/explore"); - await frigateApp.page.waitForTimeout(3000); - const pageText = await frigateApp.page.textContent("#pageRoot"); - expect(pageText?.length).toBeGreaterThan(0); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + + const before = eventsRequests.length; + await searchInput.fill("person in driveway"); + await searchInput.press("Enter"); + await expect + .poll(() => eventsRequests.length > before, { timeout: 5_000 }) + .toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Similarity search URL param +// --------------------------------------------------------------------------- + +test.describe("Explore — similarity search (desktop) @high", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Similarity trigger is hover-based; desktop-focused", + ); + + test("URL similarity search params fetch events", async ({ frigateApp }) => { + const eventsRequests: string[] = []; + frigateApp.page.on("request", (req) => { + const url = req.url(); + if (/\/api\/events/.test(url)) eventsRequests.push(url); + }); + // The app uses search_type=similarity&event_id= (not + // similarity_search_id). This exercises the same similarity search code + // path as clicking "Find Similar" on a thumbnail. + // Use a valid event-id format (timestamp.fractional-alphanumeric). + await frigateApp.goto( + "/explore?search_type=similarity&event_id=1712412000.000000-abc123", + ); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + // Poll to allow any pending SWR fetch to complete and be captured. + await expect + .poll(() => eventsRequests.length, { timeout: 5_000 }) + .toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Mobile +// --------------------------------------------------------------------------- + +test.describe("Explore — mobile @high @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("search input is focusable at mobile viewport", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ config: SEMANTIC_SEARCH_CONFIG }); + await frigateApp.goto("/explore"); + const searchInput = frigateApp.page.locator("input").first(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.focus(); + await expect(searchInput).toBeFocused(); }); }); diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 4db98d5e95..5b7e9f0b39 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -1,4 +1,114 @@ import { test, expect } from "../fixtures/frigate-test"; +import { + expectBodyInteractive, + waitForBodyInteractive, +} from "../helpers/overlay-interaction"; + +test.describe("Export Page - Delete race @high", () => { + // Empirical guard for radix-ui/primitives#3445: when a modal DropdownMenu + // opens an AlertDialog and the AlertDialog's confirm action causes the + // parent's optimistic cache update to unmount the card, we want to know + // whether the deduped react-dismissable-layer (1.1.11) handles the + // pointer-events stack cleanup or whether `modal={false}` is still + // required on the DropdownMenu. The classic "canonical" pattern, distinct + // from the FaceSelectionDialog auto-unmount race already covered by + // face-library.spec.ts. + test("deleting an export via dropdown→alert→confirm leaves body interactive", async ({ + frigateApp, + }) => { + if (frigateApp.isMobile) { + test.skip(); + return; + } + + const initialExports = [ + { + id: "export-race-001", + camera: "front_door", + name: "Race - Test Export", + date: 1775490731.3863528, + video_path: "/exports/export-race-001.mp4", + thumb_path: "/exports/export-race-001-thumb.jpg", + in_progress: false, + export_case_id: null, + }, + ]; + let deleted = false; + + await frigateApp.installDefaults({ + exports: initialExports, + }); + + // Flip /api/export to empty after the delete POST is observed so the + // page's SWR mutate sees the export gone. + await frigateApp.page.route("**/api/export**", async (route) => { + const payload = deleted ? [] : initialExports; + await route.fulfill({ json: payload }); + }); + await frigateApp.page.route("**/api/exports/delete", async (route) => { + deleted = true; + const delayMs = Number( + (globalThis as { process?: { env?: Record } }).process + ?.env?.DELETE_DELAY_MS ?? "100", + ); + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + await route.fulfill({ json: { success: true } }); + }); + + await frigateApp.goto("/export"); + await expect(frigateApp.page.getByText("Race - Test Export")).toBeVisible({ + timeout: 5_000, + }); + + // Open the kebab menu on the export card. The kebab uses the + // (misleading) aria-label "Edit name" from ExportCard's source — it + // wraps the FiMoreVertical icon. There is exactly one such button on + // the page once we have a single export rendered. + const kebab = frigateApp.page + .getByRole("button", { name: /edit name/i }) + .first(); + await expect(kebab).toBeVisible({ timeout: 5_000 }); + await kebab.click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + + // Delete Export + await menu + .getByRole("menuitem", { name: /delete export/i }) + .first() + .click(); + + // AlertDialog at page level. The confirm button's accessible name is + // "Delete Export" (its aria-label), the visible text is just "Delete". + const confirm = frigateApp.page.getByRole("alertdialog"); + await expect(confirm).toBeVisible({ timeout: 3_000 }); + await confirm + .getByRole("button", { name: /^delete export$/i }) + .first() + .click(); + + // The card optimistically disappears, the dialog closes, and body + // pointer-events must come unstuck. + await expect( + frigateApp.page.getByText("Race - Test Export"), + ).not.toBeVisible({ timeout: 5_000 }); + await waitForBodyInteractive(frigateApp.page, 5_000); + await expectBodyInteractive(frigateApp.page); + + // Sanity: another page-level button still responds. + const newCase = frigateApp.page.getByRole("button", { name: /new case/i }); + await expect(newCase).toBeVisible({ timeout: 3_000 }); + await newCase.click(); + await expect( + frigateApp.page.getByRole("dialog").filter({ hasText: /create case/i }), + ).toBeVisible({ timeout: 3_000 }); + }); +}); test.describe("Export Page - Overview @high", () => { test("renders uncategorized exports and case cards from mock data", async ({ diff --git a/web/e2e/specs/face-library.spec.ts b/web/e2e/specs/face-library.spec.ts index d68b8f8a53..e499178b7b 100644 --- a/web/e2e/specs/face-library.spec.ts +++ b/web/e2e/specs/face-library.spec.ts @@ -1,32 +1,542 @@ /** - * Face Library page tests -- MEDIUM tier. + * Face Library page tests -- HIGH tier. * - * Tests face grid rendering, empty state, and interactive controls. + * Collection selector, face tiles, grouped recent-recognition dialog + * (migrated from radix-overlay-regressions.spec.ts), and mobile + * library selector. */ -import { test, expect } from "../fixtures/frigate-test"; +import { type Locator } from "@playwright/test"; +import { test, expect, type FrigateApp } from "../fixtures/frigate-test"; +import { + basicFacesMock, + emptyFacesMock, + withGroupedTrainingAttempt, +} from "../fixtures/mock-data/faces"; +import { + expectBodyInteractive, + waitForBodyInteractive, +} from "../helpers/overlay-interaction"; -test.describe("Face Library @medium", () => { - test("face library page renders without crash", async ({ frigateApp }) => { +const GROUPED_EVENT_ID = "1775487131.3863528-abc123"; + +function groupedFacesMock() { + return withGroupedTrainingAttempt(basicFacesMock(), { + eventId: GROUPED_EVENT_ID, + attempts: [ + { timestamp: 1775487131.3863528, label: "unknown", score: 0.95 }, + { timestamp: 1775487132.3863528, label: "unknown", score: 0.91 }, + ], + }); +} + +async function installGroupedFaces(app: FrigateApp) { + await app.api.install({ + events: [ + { + id: GROUPED_EVENT_ID, + label: "person", + sub_label: null, + camera: "front_door", + start_time: 1775487131.3863528, + end_time: 1775487161.3863528, + false_positive: false, + zones: ["front_yard"], + thumbnail: null, + has_clip: true, + has_snapshot: true, + retain_indefinitely: false, + plus_id: null, + model_hash: "abc123", + detector_type: "cpu", + model_type: "ssd", + data: { + top_score: 0.92, + score: 0.92, + region: [0.1, 0.1, 0.5, 0.8], + box: [0.2, 0.15, 0.45, 0.75], + area: 0.18, + ratio: 0.6, + type: "object", + path_data: [], + }, + }, + ], + faces: groupedFacesMock(), + }); +} + +async function openGroupedFaceDialog(app: FrigateApp): Promise { + await installGroupedFaces(app); + await app.goto("/faces"); + const groupedImage = app.page + .locator('img[src*="clips/faces/train/"]') + .first(); + const groupedCard = groupedImage.locator("xpath=.."); + await expect(groupedImage).toBeVisible({ timeout: 5_000 }); + await groupedCard.click(); + const dialog = app.page + .getByRole("dialog") + .filter({ has: app.page.locator('img[src*="clips/faces/train/"]') }) + .first(); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog.locator('img[src*="clips/faces/train/"]')).toHaveCount(2); + return dialog; +} + +/** + * Opens the LibrarySelector dropdown (the single button at the top-left of + * the Face Library page) and returns the dropdown menu locator. + * + * The LibrarySelector is a single DropdownMenu whose trigger shows the + * current tab name + count (e.g. "Recent Recognitions (0)"). Named face + * collections (alice, bob, charlie) are items inside this dropdown. + */ +async function openLibraryDropdown(app: FrigateApp): Promise { + // The trigger is the first button on the page with a parenthesised count. + const trigger = app.page + .getByRole("button") + .filter({ hasText: /\(\d+\)/ }) + .first(); + await expect(trigger).toBeVisible({ timeout: 10_000 }); + await trigger.click(); + const menu = app.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 5_000 }); + return menu; +} + +test.describe("Face Library — collection selector @high", () => { + test("selector shows named face collections", async ({ frigateApp }) => { + await frigateApp.installDefaults({ faces: basicFacesMock() }); + await frigateApp.goto("/faces"); + // Named collections appear in the LibrarySelector dropdown. + const menu = await openLibraryDropdown(frigateApp); + await expect(menu.getByText(/alice/i).first()).toBeVisible({ + timeout: 5_000, + }); + }); + + test("empty state renders when no faces exist", async ({ frigateApp }) => { + await frigateApp.installDefaults({ faces: emptyFacesMock() }); await frigateApp.goto("/faces"); await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); + await expect( + frigateApp.page.locator('img[src*="/clips/faces/"]'), + ).toHaveCount(0); }); - test("face library shows empty state with no faces", async ({ - frigateApp, - }) => { + test("tiles render for each named collection", async ({ frigateApp }) => { + await frigateApp.installDefaults({ faces: basicFacesMock() }); await frigateApp.goto("/faces"); - await frigateApp.page.waitForTimeout(2000); - // With empty faces mock, should show empty state or content - const text = await frigateApp.page.textContent("#pageRoot"); - expect(text?.length).toBeGreaterThan(0); - }); - - test("face library has interactive buttons", async ({ frigateApp }) => { - await frigateApp.goto("/faces"); - await frigateApp.page.waitForTimeout(2000); - const buttons = frigateApp.page.locator("#pageRoot button"); - const count = await buttons.count(); - expect(count).toBeGreaterThanOrEqual(0); + // Open the dropdown — collections list shows "alice (2)" and "bob (1)". + const menu = await openLibraryDropdown(frigateApp); + await expect( + menu.locator('[role="menuitem"]').filter({ hasText: /alice/i }).first(), + ).toBeVisible({ timeout: 5_000 }); + await expect( + menu.locator('[role="menuitem"]').filter({ hasText: /bob/i }).first(), + ).toBeVisible(); + }); +}); + +test.describe("Face Library — delete flow (desktop) @high", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Delete action menu is desktop-focused", + ); + + test("deleting a collection fires POST /faces//delete", async ({ + frigateApp, + }) => { + let deleteUrl: string | null = null; + let deleteBody: unknown = null; + // Install base mocks first, then register our more-specific route AFTER + // so it takes priority over the ApiMocker catch-all (Playwright LIFO order). + await frigateApp.installDefaults({ faces: basicFacesMock() }); + await frigateApp.page.route( + /\/api\/faces\/[^/]+\/delete/, + async (route) => { + deleteUrl = route.request().url(); + deleteBody = route.request().postDataJSON(); + await route.fulfill({ json: { success: true } }); + }, + ); + await frigateApp.goto("/faces"); + + // Open the LibrarySelector dropdown and click the trash icon next + // to the alice row. The trash icon is a ghost-variant Button inside + // the DropdownMenuItem — it becomes visible on hover/focus. + const menu = await openLibraryDropdown(frigateApp); + const aliceRow = menu + .locator('[role="menuitem"]') + .filter({ hasText: /alice/i }) + .first(); + await expect(aliceRow).toBeVisible({ timeout: 5_000 }); + // Hover first to make hover-only opacity-0 buttons visible. + await aliceRow.hover(); + // The icon buttons have no aria-label or title. The row renders exactly + // two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2 + // (delete). This order is determined by FaceLibrary.tsx and is stable. + const trashBtn = aliceRow.locator("button").nth(1); + await trashBtn.click(); + + // The delete confirmation is a Dialog (not AlertDialog) in this flow. + const dialog = frigateApp.page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await dialog + .getByRole("button", { name: /delete/i }) + .first() + .click(); + + await expect + .poll(() => deleteUrl, { timeout: 5_000 }) + .toMatch(/\/faces\/alice\/delete/); + expect(deleteBody).toMatchObject({ ids: expect.any(Array) }); + }); +}); + +test.describe("Face Library — rename flow (desktop) @high", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Rename action menu is desktop-focused", + ); + + test("renaming a collection fires PUT /faces//rename", async ({ + frigateApp, + }) => { + let renameUrl: string | null = null; + let renameBody: unknown = null; + // Install base mocks first, then register our more-specific route AFTER + // so it takes priority over the ApiMocker catch-all (Playwright LIFO order). + await frigateApp.installDefaults({ faces: basicFacesMock() }); + await frigateApp.page.route( + /\/api\/faces\/[^/]+\/rename/, + async (route) => { + renameUrl = route.request().url(); + renameBody = route.request().postDataJSON(); + await route.fulfill({ json: { success: true } }); + }, + ); + await frigateApp.goto("/faces"); + + // Open the LibrarySelector dropdown and click the pencil (rename) icon + // next to alice. The icon is a ghost Button inside the DropdownMenuItem. + const menu = await openLibraryDropdown(frigateApp); + const aliceRow = menu + .locator('[role="menuitem"]') + .filter({ hasText: /alice/i }) + .first(); + await expect(aliceRow).toBeVisible({ timeout: 5_000 }); + await aliceRow.hover(); + // The icon buttons have no aria-label or title. The row renders exactly + // two buttons in fixed source order: [0] LuPencil (rename), [1] LuTrash2 + // (delete). This order is determined by FaceLibrary.tsx and is stable. + const pencilBtn = aliceRow.locator("button").nth(0); + await pencilBtn.click(); + + // TextEntryDialog — fill the input and confirm. + const dialog = frigateApp.page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await dialog.locator("input").first().fill("alice_renamed"); + await dialog + .getByRole("button", { name: /save|rename|confirm/i }) + .first() + .click(); + + await expect + .poll(() => renameUrl, { timeout: 5_000 }) + .toMatch(/\/faces\/alice\/rename/); + expect(renameBody).toEqual({ new_name: "alice_renamed" }); + }); +}); + +test.describe("Face Library — upload flow @high", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Upload button has no accessible text on mobile — icon-only on narrow viewports", + ); + + test("Upload button opens the upload dialog", async ({ frigateApp }) => { + await frigateApp.installDefaults({ faces: basicFacesMock() }); + await frigateApp.goto("/faces"); + + // Navigate to the alice tab by opening the dropdown and clicking alice. + const menu = await openLibraryDropdown(frigateApp); + await menu + .locator('[role="menuitem"]') + .filter({ hasText: /alice/i }) + .first() + .click(); + + // After switching to alice, the Upload Image button appears in the toolbar. + const uploadBtn = frigateApp.page + .getByRole("button") + .filter({ hasText: /upload/i }) + .first(); + await expect(uploadBtn).toBeVisible({ timeout: 5_000 }); + await uploadBtn.click(); + + // UploadImageDialog renders a file input + confirm button. + const dialog = frigateApp.page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog.locator('input[type="file"]')).toHaveCount(1); + }); +}); + +test.describe("FaceSelectionDialog @high", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Grouped dropdown flow is desktop-only", + ); + + test("reclassify dropdown selects a name and closes cleanly", async ({ + frigateApp, + }) => { + // Migrated from radix-overlay-regressions.spec.ts. + const dialog = await openGroupedFaceDialog(frigateApp); + const triggers = dialog.locator('[aria-haspopup="menu"]'); + await expect(triggers).toHaveCount(2); + + await triggers.first().click(); + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 5_000 }); + + await menu.getByRole("menuitem", { name: /^bob$/i }).click(); + + await expect(menu).not.toBeVisible({ timeout: 3_000 }); + await expect(dialog).toBeVisible(); + + const tooltipVisible = await frigateApp.page + .locator('[role="tooltip"]') + .filter({ hasText: /train face/i }) + .isVisible() + .catch(() => false); + expect( + tooltipVisible, + "Train Face tooltip popped after dropdown closed — focus-restore regression", + ).toBe(false); + }); + + test("second dropdown open accepts typeahead keyboard input", async ({ + frigateApp, + }) => { + // Migrated from radix-overlay-regressions.spec.ts. + const dialog = await openGroupedFaceDialog(frigateApp); + const triggers = dialog.locator('[aria-haspopup="menu"]'); + await expect(triggers).toHaveCount(2); + + await triggers.first().click(); + let menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 5_000 }); + await menu.getByRole("menuitem", { name: /^bob$/i }).click(); + await expect(menu).not.toBeVisible({ timeout: 3_000 }); + + await triggers.nth(1).click(); + menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 5_000 }); + + await frigateApp.page.keyboard.press("c"); + await expect + .poll( + async () => + frigateApp.page.evaluate( + () => + document.activeElement?.textContent?.trim().toLowerCase() ?? "", + ), + { timeout: 2_000 }, + ) + .toMatch(/^charlie/); + + await frigateApp.page.keyboard.press("Escape"); + await expect(menu).not.toBeVisible({ timeout: 3_000 }); + }); + + test("classifying the last image in a group leaves body interactive", async ({ + frigateApp, + }) => { + // Regression guard for the stuck body pointer-events bug when the + // last image in a grouped-recognition detail Dialog is classified. + // Tracked upstream at radix-ui/primitives#3445. + // + // Root cause: when the user clicks a FaceSelectionDialog menu item, + // the modal DropdownMenu enters its exit animation (Radix's Presence + // keeps it in the DOM with data-state="closed" until animationend). + // While that is in flight the classify axios resolves, SWR removes + // the image from /api/faces, the parent's map no longer renders the + // grouped card, and React unmounts the subtree — including the still- + // animating DropdownMenu's Presence container. DismissableLayer's + // shared modal-layer stack can't reconcile the interrupted exit, so + // the `body { pointer-events: none }` entry it put on mount is never + // popped and the rest of the UI becomes unclickable. + // + // The fix is `modal={false}` on the FaceSelectionDialog's + // DropdownMenu (desktop path only). With modal=false the DropdownMenu + // never puts an entry on DismissableLayer's body-pointer-events stack + // in the first place, so there's nothing to leak when its Presence is + // torn down mid-animation. The Radix-community-documented workaround + // for #3445. + // + // The bug only reproduces when the mock resolves fast enough that + // the parent unmounts before the dropdown's exit animation finishes. + // Measured window via a 3x sweep on the pre-fix build: 0–200 ms + // triggers it; 300 ms+ no longer reproduces. Production LAN networks + // sit comfortably inside the bad window, while `npm run dev` seems + // to mask it via React StrictMode's double-effect scheduling. + const EVENT_ID = "1775487131.3863528-race"; + const initialFaces = withGroupedTrainingAttempt(basicFacesMock(), { + eventId: EVENT_ID, + attempts: [ + { timestamp: 1775487131.3863528, label: "unknown", score: 0.95 }, + ], + }); + + let classified = false; + + await frigateApp.installDefaults({ + faces: initialFaces, + events: [ + { + id: EVENT_ID, + label: "person", + sub_label: null, + camera: "front_door", + start_time: 1775487131.3863528, + end_time: 1775487161.3863528, + false_positive: false, + zones: ["front_yard"], + thumbnail: null, + has_clip: true, + has_snapshot: true, + retain_indefinitely: false, + plus_id: null, + model_hash: "abc123", + detector_type: "cpu", + model_type: "ssd", + data: { + top_score: 0.92, + score: 0.92, + region: [0.1, 0.1, 0.5, 0.8], + box: [0.2, 0.15, 0.45, 0.75], + area: 0.18, + ratio: 0.6, + type: "object", + path_data: [], + }, + }, + ], + }); + + // Re-route /api/faces to flip to the "train empty" payload once the + // classify POST has been received. Registered AFTER installDefaults so + // Playwright's LIFO route matching hits this handler first. + await frigateApp.page.route("**/api/faces", async (route) => { + const payload = classified ? basicFacesMock() : initialFaces; + await route.fulfill({ json: payload }); + }); + + // Hold the classify POST briefly. The race opens when the parent + // unmounts before the dropdown's exit animation finishes (~200ms + // in Radix). 100ms keeps us comfortably inside that window and + // reliably triggered the bug in a 3x sweep across 0/50/100/200ms + // on the pre-fix build. CLASSIFY_DELAY_MS overrides for local sweeps. + const delayMs = Number( + (globalThis as { process?: { env?: Record } }).process + ?.env?.CLASSIFY_DELAY_MS ?? "100", + ); + await frigateApp.page.route( + "**/api/faces/train/*/classify", + async (route) => { + classified = true; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + await route.fulfill({ json: { success: true } }); + }, + ); + + await frigateApp.goto("/faces"); + + // Open the grouped detail Dialog. + const groupedImage = frigateApp.page + .locator('img[src*="clips/faces/train/"]') + .first(); + await expect(groupedImage).toBeVisible({ timeout: 5_000 }); + await groupedImage.locator("xpath=..").click(); + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ + has: frigateApp.page.locator('img[src*="clips/faces/train/"]'), + }) + .first(); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Single attempt → single `+` trigger. + const triggers = dialog.locator('[aria-haspopup="menu"]'); + await expect(triggers).toHaveCount(1); + await triggers.first().click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 5_000 }); + await menu.getByRole("menuitem", { name: /^alice$/i }).click(); + + // The Dialog must leave the tree cleanly, and body must recover. + await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + + // Give Radix's exit animation + cleanup a comfortable margin on top of + // the ~300ms simulated network delay. + await waitForBodyInteractive(frigateApp.page, 5_000); + await expectBodyInteractive(frigateApp.page); + + // User-visible confirmation: click something outside the dialog + // and assert it actually responds. + const librarySelector = frigateApp.page + .getByRole("button") + .filter({ hasText: /\(\d+\)/ }) + .first(); + await librarySelector.click(); + await expect( + frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(), + ).toBeVisible({ timeout: 3_000 }); + }); +}); + +test.describe("Face Library — mobile @high @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("mobile library selector dropdown closes cleanly on Escape", async ({ + frigateApp, + }) => { + // Migrated from radix-overlay-regressions.spec.ts. + await installGroupedFaces(frigateApp); + await frigateApp.goto("/faces"); + + const selector = frigateApp.page + .getByRole("button") + .filter({ hasText: /\(\d+\)/ }) + .first(); + await expect(selector).toBeVisible({ timeout: 5_000 }); + await selector.click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + + await frigateApp.page.keyboard.press("Escape"); + await expect(menu).not.toBeVisible({ timeout: 3_000 }); + await waitForBodyInteractive(frigateApp.page); + await expectBodyInteractive(frigateApp.page); }); }); diff --git a/web/e2e/specs/live.spec.ts b/web/e2e/specs/live.spec.ts index e355984b33..af28f00307 100644 --- a/web/e2e/specs/live.spec.ts +++ b/web/e2e/specs/live.spec.ts @@ -1,59 +1,47 @@ /** * Live page tests -- CRITICAL tier. * - * Tests camera dashboard rendering, camera card clicks, single camera view - * with named controls, feature toggle behavior, context menu, and mobile layout. + * Dashboard grid, single-camera controls, feature toggles (with WS + * frame assertions), context menu, birdseye, and mobile layout. + * Also absorbs the PTZ preset-dropdown regression tests from the + * now-deleted ptz-overlay.spec.ts. */ import { test, expect } from "../fixtures/frigate-test"; +import { LivePage } from "../pages/live.page"; +import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames"; +import { + expectBodyInteractive, + waitForBodyInteractive, +} from "../helpers/overlay-interaction"; + +const PTZ_CAMERA = "front_door"; +const PRESET_NAMES = ["home", "driveway", "front_porch"]; test.describe("Live Dashboard @critical", () => { - test("dashboard renders all configured cameras by name", async ({ + test("every configured camera renders on the dashboard", async ({ frigateApp, }) => { await frigateApp.goto("/"); + const live = new LivePage(frigateApp.page, !frigateApp.isMobile); for (const cam of ["front_door", "backyard", "garage"]) { - await expect( - frigateApp.page.locator(`[data-camera='${cam}']`), - ).toBeVisible({ timeout: 10_000 }); + await expect(live.cameraCard(cam)).toBeVisible({ timeout: 10_000 }); } }); - test("clicking camera card opens single camera view via hash", async ({ + test("clicking a camera card opens the single-camera view via hash", async ({ frigateApp, }) => { await frigateApp.goto("/"); - const card = frigateApp.page.locator("[data-camera='front_door']").first(); - await card.click({ timeout: 10_000 }); + const live = new LivePage(frigateApp.page, !frigateApp.isMobile); + await live.cameraCard("front_door").first().click({ timeout: 10_000 }); await expect(frigateApp.page).toHaveURL(/#front_door/); }); - test("back button returns from single camera to dashboard", async ({ - frigateApp, - }) => { - // First navigate to dashboard so there's history to go back to - await frigateApp.goto("/"); - await frigateApp.page.waitForTimeout(1000); - // Click a camera to enter single view - const card = frigateApp.page.locator("[data-camera='front_door']").first(); - await card.click({ timeout: 10_000 }); - await frigateApp.page.waitForTimeout(2000); - // Now click Back to return to dashboard - const backBtn = frigateApp.page.getByText("Back", { exact: true }); - if (await backBtn.isVisible().catch(() => false)) { - await backBtn.click(); - await frigateApp.page.waitForTimeout(1000); - } - // Should be back on the dashboard with cameras visible - await expect( - frigateApp.page.locator("[data-camera='front_door']"), - ).toBeVisible({ timeout: 10_000 }); - }); - - test("birdseye view loads without crash", async ({ frigateApp }) => { + test("birdseye route renders without crash", async ({ frigateApp }) => { await frigateApp.goto("/#birdseye"); - await frigateApp.page.waitForTimeout(2000); await expect(frigateApp.page.locator("body")).toBeVisible(); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); }); test("empty group shows fallback content", async ({ frigateApp }) => { @@ -63,191 +51,239 @@ test.describe("Live Dashboard @critical", () => { }); }); -test.describe("Live Single Camera - Controls @critical", () => { - test("single camera view shows Back and History buttons (desktop)", async ({ +test.describe("Live Single Camera — desktop controls @critical", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Desktop-only header controls", + ); + + test("single-camera view shows Back and History buttons", async ({ frigateApp, }) => { - if (frigateApp.isMobile) { - test.skip(); // On mobile, buttons may show icons only - return; - } await frigateApp.goto("/#front_door"); - await frigateApp.page.waitForTimeout(2000); - // Back and History are visible text buttons in the header - await expect( - frigateApp.page.getByText("Back", { exact: true }), - ).toBeVisible({ timeout: 5_000 }); - await expect( - frigateApp.page.getByText("History", { exact: true }), - ).toBeVisible(); + const live = new LivePage(frigateApp.page, true); + await expect(live.backButton).toBeVisible({ timeout: 5_000 }); + await expect(live.historyButton).toBeVisible(); }); - test("single camera view shows feature toggle icons (desktop)", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + test("feature toggles render (at least 3)", async ({ frigateApp }) => { await frigateApp.goto("/#front_door"); - await frigateApp.page.waitForTimeout(2000); - // Feature toggles are CameraFeatureToggle components rendered as divs - // with bg-selected (active) or bg-secondary (inactive) classes - // Count the toggles - should have at least detect, recording, snapshots - const toggles = frigateApp.page.locator( - ".flex.flex-col.items-center.justify-center.bg-selected, .flex.flex-col.items-center.justify-center.bg-secondary", - ); - const count = await toggles.count(); + const live = new LivePage(frigateApp.page, true); + // Wait for the single-camera header to render before counting toggles. + await expect(live.backButton).toBeVisible({ timeout: 5_000 }); + await expect(live.featureToggles.first()).toBeVisible({ timeout: 5_000 }); + const count = await live.featureToggles.count(); expect(count).toBeGreaterThanOrEqual(3); }); - test("clicking a feature toggle changes its visual state (desktop)", async ({ + test("clicking a feature toggle sends the matching WS frame", async ({ frigateApp, }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + await installWsFrameCapture(frigateApp.page); await frigateApp.goto("/#front_door"); - await frigateApp.page.waitForTimeout(2000); - // Find active toggles (bg-selected class = feature is ON) - const activeToggles = frigateApp.page.locator( - ".flex.flex-col.items-center.justify-center.bg-selected", + const live = new LivePage(frigateApp.page, true); + // Wait for feature toggles to render (WS camera_activity must arrive first). + await expect(live.activeFeatureToggles.first()).toBeVisible({ + timeout: 5_000, + }); + const activeBefore = await live.activeFeatureToggles.count(); + expect(activeBefore).toBeGreaterThan(0); + + await live.activeFeatureToggles.first().click(); + + // The toggle dispatches a frame on //set — match on + // front_door/ prefix + /set suffix (any feature). + await waitForWsFrame( + frigateApp.page, + (frame) => frame.includes("front_door/") && frame.includes("/set"), + { + message: + "feature toggle should dispatch a //set frame", + }, ); - const initialCount = await activeToggles.count(); - if (initialCount > 0) { - // Click the first active toggle to disable it - await activeToggles.first().click(); - await frigateApp.page.waitForTimeout(1000); - // After WS mock echoes back new state, count should decrease - const newCount = await activeToggles.count(); - expect(newCount).toBeLessThan(initialCount); - } }); - test("settings gear button opens dropdown (desktop)", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/#front_door"); - await frigateApp.page.waitForTimeout(2000); - // Find the gear icon button (last button-like element in header) - // The settings gear opens a dropdown with Stream, Play in background, etc. - const gearButtons = frigateApp.page.locator("button:has(svg)"); - const count = await gearButtons.count(); - // Click the last one (gear icon is typically last in the header) - if (count > 0) { - await gearButtons.last().click(); - await frigateApp.page.waitForTimeout(500); - // A dropdown or drawer should appear - const overlay = frigateApp.page.locator( - '[role="menu"], [data-radix-menu-content], [role="dialog"]', - ); - const visible = await overlay - .first() - .isVisible() - .catch(() => false); - if (visible) { - await frigateApp.page.keyboard.press("Escape"); - } - } - }); - - test("keyboard shortcut f does not crash on desktop", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + test("keyboard shortcut f does not crash", async ({ frigateApp }) => { await frigateApp.goto("/"); await frigateApp.page.keyboard.press("f"); - await frigateApp.page.waitForTimeout(500); + await expect(frigateApp.page.locator("body")).toBeVisible(); + // Note: headless Chromium rejects fullscreen requests without a user + // gesture, so document.fullscreenElement cannot be asserted reliably + // in e2e. We assert the keypress doesn't crash the app; real + // fullscreen behavior is covered by manual testing. + }); + + test("settings gear opens a dropdown with Stream/Play menu items", async ({ + frigateApp, + }) => { + await frigateApp.goto("/#front_door"); + // Wait for the single-camera view to render — use the Back button + // as a deterministic marker. + const live = new LivePage(frigateApp.page, true); + await expect(live.backButton).toBeVisible({ timeout: 10_000 }); + + // The gear icon button is the last button-like element in the + // single-camera header. Clicking it opens a Radix dropdown. + const gearButtons = frigateApp.page.locator("button:has(svg)"); + const count = await gearButtons.count(); + expect(count).toBeGreaterThan(0); + await gearButtons.last().click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + await frigateApp.page.keyboard.press("Escape"); + await expect(menu).not.toBeVisible({ timeout: 3_000 }); + }); +}); + +test.describe("Live Context Menu (desktop) @critical", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Right-click is desktop-only", + ); + + test("right-click opens the context menu", async ({ frigateApp }) => { + await frigateApp.goto("/"); + const live = new LivePage(frigateApp.page, true); + const menu = await live.openContextMenuOn("front_door"); + await expect(menu).toBeVisible({ timeout: 5_000 }); + }); + + test("context menu closes on Escape and leaves body interactive", async ({ + frigateApp, + }) => { + await frigateApp.goto("/"); + const live = new LivePage(frigateApp.page, true); + const menu = await live.openContextMenuOn("front_door"); + await expect(menu).toBeVisible({ timeout: 5_000 }); + await frigateApp.page.keyboard.press("Escape"); + await expect(menu).not.toBeVisible(); + await waitForBodyInteractive(frigateApp.page); + await expectBodyInteractive(frigateApp.page); + }); +}); + +test.describe("Live PTZ preset dropdown @critical", () => { + // Migrated from ptz-overlay.spec.ts. Guards: + // 1. After selecting a preset, the "Presets" tooltip must not re-pop. + // 2. Keyboard shortcuts after close should not re-open the dropdown. + + test("selecting a preset closes menu cleanly and does not re-open on keyboard", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "PTZ preset dropdown is desktop-only"); + + await frigateApp.api.install({ + config: { + cameras: { + [PTZ_CAMERA]: { onvif: { host: "10.0.0.50" } }, + }, + }, + }); + await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) => + route.fulfill({ + json: { + name: PTZ_CAMERA, + features: ["pt", "zoom"], + presets: PRESET_NAMES, + profiles: [], + }, + }), + ); + + await installWsFrameCapture(frigateApp.page); + await frigateApp.goto(`/#${PTZ_CAMERA}`); + + const presetTrigger = frigateApp.page.getByRole("button", { + name: /presets/i, + }); + await expect(presetTrigger.first()).toBeVisible({ timeout: 5_000 }); + await presetTrigger.first().click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + + await menu.getByRole("menuitem", { name: PRESET_NAMES[0] }).first().click(); + await expect(menu).not.toBeVisible({ timeout: 3_000 }); + + await waitForWsFrame( + frigateApp.page, + (frame) => + frame.includes(`"${PTZ_CAMERA}/ptz"`) && + frame.includes(`preset_${PRESET_NAMES[0]}`), + ); + + await waitForBodyInteractive(frigateApp.page); + await expectBodyInteractive(frigateApp.page); + + await expect + .poll( + async () => + frigateApp.page + .locator('[role="tooltip"]') + .filter({ hasText: /presets/i }) + .isVisible() + .catch(() => false), + { timeout: 1_000 }, + ) + .toBe(false); + + await frigateApp.page.keyboard.press("ArrowUp"); + await frigateApp.page.keyboard.press("Space"); + await frigateApp.page.keyboard.press("Enter"); + await expect + .poll(() => menu.isVisible().catch(() => false), { timeout: 1_000 }) + .toBe(false); + }); +}); + +test.describe("Live mobile layout @critical @mobile", () => { + test("mobile dashboard has no sidebar and renders cameras", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile-only"); + await frigateApp.goto("/"); + await expect(frigateApp.page.locator("aside")).toHaveCount(0); + const live = new LivePage(frigateApp.page, false); + await expect(live.cameraCard("front_door")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("mobile camera tap opens single view", async ({ frigateApp }) => { + test.skip(!frigateApp.isMobile, "Mobile-only"); + await frigateApp.goto("/"); + const live = new LivePage(frigateApp.page, false); + await live.cameraCard("front_door").first().click({ timeout: 10_000 }); + await expect(frigateApp.page).toHaveURL(/#front_door/); + }); + + test("mobile onvif single-camera view loads without freezing body", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile-only"); + // Migrated from ptz-overlay.spec.ts — dismissable-layer dedupe smoke test. + await frigateApp.api.install({ + config: { + cameras: { [PTZ_CAMERA]: { onvif: { host: "10.0.0.50" } } }, + }, + }); + await frigateApp.page.route(`**/api/${PTZ_CAMERA}/ptz/info`, (route) => + route.fulfill({ + json: { + name: PTZ_CAMERA, + features: ["pt", "zoom"], + presets: PRESET_NAMES, + profiles: [], + }, + }), + ); + await frigateApp.goto(`/#${PTZ_CAMERA}`); + await expectBodyInteractive(frigateApp.page); await expect(frigateApp.page.locator("body")).toBeVisible(); }); }); - -test.describe("Live Single Camera - Mobile Controls @critical", () => { - test("mobile camera view has settings drawer trigger", async ({ - frigateApp, - }) => { - if (!frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/#front_door"); - await frigateApp.page.waitForTimeout(2000); - // On mobile, settings gear opens a drawer - // The button has aria-label with the camera name like "front_door Settings" - const buttons = frigateApp.page.locator("button:has(svg)"); - const count = await buttons.count(); - expect(count).toBeGreaterThan(0); - }); -}); - -test.describe("Live Context Menu @critical", () => { - test("right-click on camera opens context menu on desktop", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/"); - const card = frigateApp.page.locator("[data-camera='front_door']").first(); - await card.waitFor({ state: "visible", timeout: 10_000 }); - await card.click({ button: "right" }); - const contextMenu = frigateApp.page.locator( - '[role="menu"], [data-radix-menu-content]', - ); - await expect(contextMenu.first()).toBeVisible({ timeout: 5_000 }); - }); - - test("context menu closes on escape", async ({ frigateApp }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/"); - const card = frigateApp.page.locator("[data-camera='front_door']").first(); - await card.waitFor({ state: "visible", timeout: 10_000 }); - await card.click({ button: "right" }); - await frigateApp.page.waitForTimeout(500); - await frigateApp.page.keyboard.press("Escape"); - await frigateApp.page.waitForTimeout(300); - const contextMenu = frigateApp.page.locator( - '[role="menu"], [data-radix-menu-content]', - ); - await expect(contextMenu).not.toBeVisible(); - }); -}); - -test.describe("Live Mobile Layout @critical", () => { - test("mobile renders cameras without sidebar", async ({ frigateApp }) => { - if (!frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/"); - await expect(frigateApp.page.locator("aside")).not.toBeVisible(); - await expect( - frigateApp.page.locator("[data-camera='front_door']"), - ).toBeVisible({ timeout: 10_000 }); - }); - - test("mobile camera click opens single camera view", async ({ - frigateApp, - }) => { - if (!frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/"); - const card = frigateApp.page.locator("[data-camera='front_door']").first(); - await card.click({ timeout: 10_000 }); - await expect(frigateApp.page).toHaveURL(/#front_door/); - }); -}); diff --git a/web/e2e/specs/logs.spec.ts b/web/e2e/specs/logs.spec.ts index 1f6af36aeb..0b74acfa11 100644 --- a/web/e2e/specs/logs.spec.ts +++ b/web/e2e/specs/logs.spec.ts @@ -1,75 +1,222 @@ /** * Logs page tests -- MEDIUM tier. * - * Tests service tab switching by name, copy/download buttons, - * and websocket message feed tab. + * Service tabs (with real /logs/ JSON contract), + * log content render, Copy (clipboard), Download (assert + * ?download=true request fired), mobile tab selector. */ import { test, expect } from "../fixtures/frigate-test"; +import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard"; -test.describe("Logs Page - Service Tabs @medium", () => { - test("logs page renders with named service tabs", async ({ frigateApp }) => { +function logsJsonBody(lines: string[]) { + return { lines, totalLines: lines.length }; +} + +test.describe("Logs — service tabs @medium", () => { + test("frigate tab renders by default with mocked log lines", async ({ + frigateApp, + }) => { + await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => + route.fulfill({ + json: logsJsonBody([ + "[2026-04-06 10:00:00] INFO: Frigate started", + "[2026-04-06 10:00:01] INFO: Cameras loaded", + ]), + }), + ); + // Silence the streaming fetch so it doesn't hang the test. + await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) => + route.fulfill({ status: 200, body: "" }), + ); + await frigateApp.goto("/logs"); + await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({ + timeout: 5_000, + }); + await expect(frigateApp.page.getByText(/Frigate started/)).toBeVisible({ + timeout: 10_000, + }); + }); + + test("switching to go2rtc fires a GET to /logs/go2rtc", async ({ + frigateApp, + }) => { + let go2rtcCalled = false; + await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => + route.fulfill({ json: logsJsonBody(["frigate line"]) }), + ); + await frigateApp.page.route(/\/api\/logs\/go2rtc(\?|$)/, (route) => { + if (!route.request().url().includes("stream=true")) { + go2rtcCalled = true; + } + return route.fulfill({ json: logsJsonBody(["go2rtc line"]) }); + }); + await frigateApp.page.route(/\/api\/logs\/.*\?stream=true/, (route) => + route.fulfill({ status: 200, body: "" }), + ); + + await frigateApp.goto("/logs"); + await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({ + timeout: 5_000, + }); + const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc"); + await expect(go2rtcTab).toBeVisible(); + await go2rtcTab.click(); + await expect.poll(() => go2rtcCalled, { timeout: 5_000 }).toBe(true); + await expect(go2rtcTab).toHaveAttribute("data-state", "on"); + }); +}); + +test.describe("Logs — actions @medium", () => { + test("Copy button writes current logs to clipboard", async ({ + frigateApp, + context, + }) => { + await grantClipboardPermissions(context); + await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => + route.fulfill({ + json: logsJsonBody([ + "[2026-04-06 10:00:00] INFO: Frigate started", + "[2026-04-06 10:00:01] INFO: Cameras loaded", + ]), + }), + ); + await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) => + route.fulfill({ status: 200, body: "" }), + ); + await frigateApp.goto("/logs"); + await expect(frigateApp.page.getByText(/Frigate started/)).toBeVisible({ + timeout: 10_000, + }); + + const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard"); + await expect(copyBtn).toBeVisible({ timeout: 5_000 }); + await copyBtn.click(); + await expect + .poll(() => readClipboard(frigateApp.page), { timeout: 5_000 }) + .toContain("Frigate started"); + }); + + test("Download button fires GET /logs/?download=true", async ({ + frigateApp, + }) => { + let downloadCalled = false; + await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => { + if (route.request().url().includes("download=true")) { + downloadCalled = true; + } + return route.fulfill({ json: logsJsonBody(["frigate line"]) }); + }); + await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) => + route.fulfill({ status: 200, body: "" }), + ); + + await frigateApp.goto("/logs"); + const downloadBtn = frigateApp.page.getByLabel("Download Logs"); + await expect(downloadBtn).toBeVisible({ timeout: 5_000 }); + await downloadBtn.click(); + await expect.poll(() => downloadCalled, { timeout: 5_000 }).toBe(true); + }); +}); + +test.describe("Logs — websocket tab @medium", () => { + test("switching to websocket tab renders WsMessageFeed container", async ({ + frigateApp, + }) => { + await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => + route.fulfill({ json: logsJsonBody(["frigate line"]) }), + ); + await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) => + route.fulfill({ status: 200, body: "" }), + ); + await frigateApp.goto("/logs"); + const wsTab = frigateApp.page.getByLabel("Select websocket"); + await expect(wsTab).toBeVisible({ timeout: 5_000 }); + await wsTab.click(); + await expect(wsTab).toHaveAttribute("data-state", "on", { timeout: 5_000 }); + }); +}); + +test.describe("Logs — streaming @medium", () => { + test("streamed log lines appear in the viewport", async ({ frigateApp }) => { + await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => { + if (route.request().url().includes("stream=true")) { + // Intercepted below via addInitScript fetch override. + return route.fallback(); + } + return route.fulfill({ + json: logsJsonBody(["[2026-04-06 10:00:00] INFO: initial batch line"]), + }); + }); + + // Override window.fetch so the /api/logs/frigate?stream=true request + // resolves with a real ReadableStream that emits chunks over time. + // This is the only way to validate streaming-append behavior through + // Playwright — route.fulfill() cannot return a stream. + // NOTE: The app calls fetch('api/logs/...') with a relative URL (no + // leading slash), so we match both relative and absolute forms. + await frigateApp.page.addInitScript(() => { + const origFetch = window.fetch; + window.fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (url.includes("api/logs/frigate") && url.includes("stream=true")) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + await new Promise((r) => setTimeout(r, 30)); + controller.enqueue( + encoder.encode( + "[2026-04-06 10:00:02] INFO: streamed line one\n", + ), + ); + await new Promise((r) => setTimeout(r, 30)); + controller.enqueue( + encoder.encode( + "[2026-04-06 10:00:03] INFO: streamed line two\n", + ), + ); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + } + return origFetch.call(window, input as RequestInfo, init); + }; + }); + + await frigateApp.goto("/logs"); + // The initial batch line is parsed by LogLineData and its content is + // rendered in a .log-content cell — assert against that element. + await expect(frigateApp.page.getByText("initial batch line")).toBeVisible({ + timeout: 10_000, + }); + await expect(frigateApp.page.getByText(/streamed line one/)).toBeVisible({ + timeout: 10_000, + }); + await expect(frigateApp.page.getByText(/streamed line two/)).toBeVisible({ + timeout: 10_000, + }); + }); +}); + +test.describe("Logs — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("service tabs render at mobile viewport", async ({ frigateApp }) => { + await frigateApp.page.route(/\/api\/logs\/frigate(\?|$)/, (route) => + route.fulfill({ json: logsJsonBody(["frigate line"]) }), + ); + await frigateApp.page.route(/\/api\/logs\/frigate\?stream=true/, (route) => + route.fulfill({ status: 200, body: "" }), + ); await frigateApp.goto("/logs"); - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - // Service tabs have aria-label="Select {service}" await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({ timeout: 5_000, }); }); - - test("switching to go2rtc tab changes active tab", async ({ frigateApp }) => { - await frigateApp.goto("/logs"); - await frigateApp.page.waitForTimeout(1000); - const go2rtcTab = frigateApp.page.getByLabel("Select go2rtc"); - if (await go2rtcTab.isVisible().catch(() => false)) { - await go2rtcTab.click(); - await frigateApp.page.waitForTimeout(1000); - await expect(go2rtcTab).toHaveAttribute("data-state", "on"); - } - }); - - test("switching to websocket tab shows message feed", async ({ - frigateApp, - }) => { - await frigateApp.goto("/logs"); - await frigateApp.page.waitForTimeout(1000); - const wsTab = frigateApp.page.getByLabel("Select websocket"); - if (await wsTab.isVisible().catch(() => false)) { - await wsTab.click(); - await frigateApp.page.waitForTimeout(1000); - await expect(wsTab).toHaveAttribute("data-state", "on"); - } - }); -}); - -test.describe("Logs Page - Actions @medium", () => { - test("copy to clipboard button is present and clickable", async ({ - frigateApp, - }) => { - await frigateApp.goto("/logs"); - await frigateApp.page.waitForTimeout(1000); - const copyBtn = frigateApp.page.getByLabel("Copy to Clipboard"); - if (await copyBtn.isVisible().catch(() => false)) { - await copyBtn.click(); - await frigateApp.page.waitForTimeout(500); - // Should trigger clipboard copy (toast may appear) - } - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - }); - - test("download logs button is present", async ({ frigateApp }) => { - await frigateApp.goto("/logs"); - await frigateApp.page.waitForTimeout(1000); - const downloadBtn = frigateApp.page.getByLabel("Download Logs"); - if (await downloadBtn.isVisible().catch(() => false)) { - await expect(downloadBtn).toBeVisible(); - } - }); - - test("logs page displays log content text", async ({ frigateApp }) => { - await frigateApp.goto("/logs"); - await frigateApp.page.waitForTimeout(2000); - const text = await frigateApp.page.textContent("#pageRoot"); - expect(text?.length).toBeGreaterThan(0); - }); }); diff --git a/web/e2e/specs/navigation.spec.ts b/web/e2e/specs/navigation.spec.ts index e049b6f7e5..e14887c157 100644 --- a/web/e2e/specs/navigation.spec.ts +++ b/web/e2e/specs/navigation.spec.ts @@ -1,227 +1,175 @@ /** * Navigation tests -- CRITICAL tier. * - * Tests sidebar (desktop) and bottombar (mobile) navigation, - * conditional nav items, settings menus, and their actual behaviors. + * Covers sidebar (desktop) / bottombar (mobile) link set, conditional + * nav items (faces, chat, classification), settings menu navigation, + * unknown-route redirect to /, and mobile-specific nav behaviors. */ import { test, expect } from "../fixtures/frigate-test"; import { BasePage } from "../pages/base.page"; -test.describe("Navigation @critical", () => { - test("app loads and renders page root", async ({ frigateApp }) => { - await frigateApp.goto("/"); - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); - }); +const PRIMARY_ROUTES = ["/review", "/explore", "/export"] as const; - test("logo is visible and links to home", async ({ frigateApp }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/"); - const base = new BasePage(frigateApp.page, true); - const logo = base.sidebar.locator('a[href="/"]').first(); - await expect(logo).toBeVisible(); - }); - - test("all primary nav links are present and navigate", async ({ +test.describe("Navigation — primary links @critical", () => { + test("every primary link is visible and navigates", async ({ frigateApp, }) => { await frigateApp.goto("/"); - const routes = ["/review", "/explore", "/export"]; - for (const route of routes) { + for (const route of PRIMARY_ROUTES) { await expect( frigateApp.page.locator(`a[href="${route}"]`).first(), ).toBeVisible(); } - // Verify clicking each one actually navigates const base = new BasePage(frigateApp.page, !frigateApp.isMobile); - for (const route of routes) { + for (const route of PRIMARY_ROUTES) { await base.navigateTo(route); await expect(frigateApp.page).toHaveURL(new RegExp(route)); await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); } }); - test("desktop sidebar is visible, mobile bottombar is visible", async ({ - frigateApp, - }) => { - await frigateApp.goto("/"); - const base = new BasePage(frigateApp.page, !frigateApp.isMobile); - if (!frigateApp.isMobile) { - await expect(base.sidebar).toBeVisible(); - } else { - await expect(base.sidebar).not.toBeVisible(); - } + test("logo links home on desktop", async ({ frigateApp }) => { + test.skip(frigateApp.isMobile, "Sidebar logo is desktop-only"); + await frigateApp.goto("/review"); + await frigateApp.page.locator("aside a[href='/']").first().click(); + await expect(frigateApp.page).toHaveURL(/\/$/); }); - test("navigate between all main pages without crash", async ({ - frigateApp, - }) => { - await frigateApp.goto("/"); - const base = new BasePage(frigateApp.page, !frigateApp.isMobile); - const pageRoot = frigateApp.page.locator("#pageRoot"); - - await base.navigateTo("/review"); - await expect(pageRoot).toBeVisible({ timeout: 10_000 }); - await base.navigateTo("/explore"); - await expect(pageRoot).toBeVisible({ timeout: 10_000 }); - await base.navigateTo("/export"); - await expect(pageRoot).toBeVisible({ timeout: 10_000 }); - await base.navigateTo("/review"); - await expect(pageRoot).toBeVisible({ timeout: 10_000 }); - }); - - test("unknown route redirects to home", async ({ frigateApp }) => { + test("unknown route redirects to /", async ({ frigateApp }) => { await frigateApp.page.goto("/nonexistent-route"); - await frigateApp.page.waitForTimeout(2000); - const url = frigateApp.page.url(); - const hasPageRoot = await frigateApp.page - .locator("#pageRoot") - .isVisible() - .catch(() => false); - expect(url.endsWith("/") || hasPageRoot).toBeTruthy(); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect(frigateApp.page).toHaveURL(/\/$/); + await expect( + frigateApp.page.locator("[data-camera='front_door']"), + ).toBeVisible({ timeout: 10_000 }); }); }); -test.describe("Navigation - Conditional Items @critical", () => { - test("Faces nav hidden when face_recognition disabled", async ({ +test.describe("Navigation — conditional items @critical", () => { + test("/faces is hidden when face_recognition.enabled is false", async ({ frigateApp, }) => { await frigateApp.goto("/"); - await expect(frigateApp.page.locator('a[href="/faces"]')).not.toBeVisible(); + await expect( + frigateApp.page.locator('a[href="/faces"]').first(), + ).toHaveCount(0); }); - test("Chat nav hidden when genai model is none", async ({ frigateApp }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.installDefaults({ - config: { - genai: { - enabled: false, - provider: "ollama", - model: "none", - base_url: "", - }, - }, - }); - await frigateApp.goto("/"); - await expect(frigateApp.page.locator('a[href="/chat"]')).not.toBeVisible(); - }); - - test("Faces nav visible when face_recognition enabled on desktop", async ({ + test("/faces is visible when face_recognition.enabled is true (desktop)", async ({ frigateApp, - page, }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + test.skip(frigateApp.isMobile, "Desktop sidebar"); await frigateApp.installDefaults({ config: { face_recognition: { enabled: true } }, }); await frigateApp.goto("/"); - await expect(page.locator('a[href="/faces"]')).toBeVisible(); + await expect( + frigateApp.page.locator('a[href="/faces"]').first(), + ).toBeVisible(); }); - test("Chat nav visible when genai model set on desktop", async ({ + test("/chat is hidden when no agent has the chat role (desktop)", async ({ frigateApp, - page, }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + test.skip(frigateApp.isMobile, "Desktop sidebar"); await frigateApp.installDefaults({ - config: { genai: { enabled: true, model: "llava" } }, + config: { + genai: { + descriptions_only: { + provider: "ollama", + model: "llava", + roles: ["descriptions"], + }, + }, + }, }); await frigateApp.goto("/"); - await expect(page.locator('a[href="/chat"]')).toBeVisible(); + await expect( + frigateApp.page.locator('a[href="/chat"]').first(), + ).toHaveCount(0); }); - test("Classification nav visible for admin on desktop", async ({ + test("/chat is visible when an agent has the chat role (desktop)", async ({ frigateApp, - page, }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + test.skip(frigateApp.isMobile, "Desktop sidebar"); + await frigateApp.installDefaults({ + config: { + genai: { + chat_agent: { + provider: "ollama", + model: "llava", + roles: ["chat"], + }, + }, + }, + }); await frigateApp.goto("/"); - await expect(page.locator('a[href="/classification"]')).toBeVisible(); + await expect( + frigateApp.page.locator('a[href="/chat"]').first(), + ).toBeVisible(); + }); + + test("/classification is visible for admin on desktop", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop sidebar"); + await frigateApp.goto("/"); + await expect( + frigateApp.page.locator('a[href="/classification"]').first(), + ).toBeVisible(); }); }); -test.describe("Navigation - Settings Menu @critical", () => { - test("settings gear opens menu with navigation items (desktop)", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - await frigateApp.goto("/"); - // Settings gear is in the sidebar bottom section, a div with cursor-pointer - const sidebarBottom = frigateApp.page.locator("aside .mb-8"); - const gearIcon = sidebarBottom - .locator("div[class*='cursor-pointer']") - .first(); - await expect(gearIcon).toBeVisible({ timeout: 5_000 }); - await gearIcon.click(); - // Menu should open - look for the "Settings" menu item by aria-label - await expect(frigateApp.page.getByLabel("Settings")).toBeVisible({ - timeout: 3_000, - }); - }); +test.describe("Navigation — settings menu (desktop) @critical", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Sidebar settings menu is desktop-only", + ); - test("settings menu items navigate to correct routes (desktop)", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } - const targets = [ - { label: "Settings", url: "/settings" }, - { label: "System metrics", url: "/system" }, - { label: "System logs", url: "/logs" }, - { label: "Configuration Editor", url: "/config" }, - ]; - for (const target of targets) { + const TARGETS = [ + { label: "Settings", url: /\/settings/ }, + { label: "System metrics", url: /\/system/ }, + { label: "System logs", url: /\/logs/ }, + { label: "Configuration Editor", url: /\/config/ }, + ]; + + for (const target of TARGETS) { + test(`menu → ${target.label} navigates`, async ({ frigateApp }) => { await frigateApp.goto("/"); - const gearIcon = frigateApp.page + const gear = frigateApp.page .locator("aside .mb-8 div[class*='cursor-pointer']") .first(); - await gearIcon.click(); - await frigateApp.page.waitForTimeout(300); - const menuItem = frigateApp.page.getByLabel(target.label); - if (await menuItem.isVisible().catch(() => false)) { - await menuItem.click(); - await expect(frigateApp.page).toHaveURL( - new RegExp(target.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), - ); - } + await gear.click(); + await frigateApp.page.getByLabel(target.label).click(); + await expect(frigateApp.page).toHaveURL(target.url); + }); + } +}); + +test.describe("Navigation — mobile @critical @mobile", () => { + test("mobile bottombar visible, sidebar not rendered", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile-only"); + await frigateApp.goto("/"); + await expect(frigateApp.page.locator("aside")).toHaveCount(0); + for (const route of PRIMARY_ROUTES) { + await expect( + frigateApp.page.locator(`a[href="${route}"]`).first(), + ).toBeVisible(); } }); - test("account button in sidebar is clickable (desktop)", async ({ - frigateApp, - }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + test("mobile nav survives route change", async ({ frigateApp }) => { + test.skip(!frigateApp.isMobile, "Mobile-only"); await frigateApp.goto("/"); - const sidebarBottom = frigateApp.page.locator("aside .mb-8"); - const items = sidebarBottom.locator("div[class*='cursor-pointer']"); - const count = await items.count(); - if (count >= 2) { - await items.nth(1).click(); - await frigateApp.page.waitForTimeout(500); - } - await expect(frigateApp.page.locator("body")).toBeVisible(); + const reviewLink = frigateApp.page.locator('a[href="/review"]').first(); + await reviewLink.click(); + await expect(frigateApp.page).toHaveURL(/\/review/); + await expect( + frigateApp.page.locator('a[href="/review"]').first(), + ).toBeVisible(); }); }); diff --git a/web/e2e/specs/replay.spec.ts b/web/e2e/specs/replay.spec.ts index c506fec5ad..c09abf10b2 100644 --- a/web/e2e/specs/replay.spec.ts +++ b/web/e2e/specs/replay.spec.ts @@ -1,23 +1,304 @@ /** - * Replay page tests -- LOW tier. + * Replay page tests -- MEDIUM tier. * - * Tests replay page rendering and basic interactivity. + * /replay is the admin debug replay page (not a recordings player). + * Polls /api/debug_replay/status, renders a no-session state when + * inactive, and a live camera image + debug toggles + Stop controls + * when active. */ import { test, expect } from "../fixtures/frigate-test"; +import { + activeSessionStatus, + noSessionStatus, +} from "../fixtures/mock-data/debug-replay"; -test.describe("Replay Page @low", () => { - test("replay page renders without crash", async ({ frigateApp }) => { +async function installStatusRoute( + app: { page: import("@playwright/test").Page }, + body: unknown, +) { + await app.page.route("**/api/debug_replay/status", (route) => + route.fulfill({ json: body }), + ); +} + +test.describe("Replay — no active session @medium", () => { + test("empty state renders heading + Go to History button", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, noSessionStatus()); await frigateApp.goto("/replay"); - await frigateApp.page.waitForTimeout(2000); - await expect(frigateApp.page.locator("body")).toBeVisible(); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /No Active Debug Replay Session/i, + }), + ).toBeVisible({ timeout: 10_000 }); + const goButton = frigateApp.page.getByRole("button", { + name: /Go to History|Go to Recordings/i, + }); + await expect(goButton).toBeVisible(); }); - test("replay page has interactive controls", async ({ frigateApp }) => { + test("clicking Go to History navigates to /review", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, noSessionStatus()); await frigateApp.goto("/replay"); - await frigateApp.page.waitForTimeout(2000); - const buttons = frigateApp.page.locator("button"); - const count = await buttons.count(); - expect(count).toBeGreaterThan(0); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /No Active Debug Replay Session/i, + }), + ).toBeVisible({ timeout: 10_000 }); + await frigateApp.page + .getByRole("button", { name: /Go to History|Go to Recordings/i }) + .click(); + await expect(frigateApp.page).toHaveURL(/\/review/); + }); +}); + +test.describe("Replay — active session @medium", () => { + test("active status renders the Debug Replay side panel", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + await expect( + frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }), + ).toBeVisible({ timeout: 10_000 }); + // Three tabs (Debug / Objects / Messages) in TabsList + await expect(frigateApp.page.locator('[role="tab"]')).toHaveCount(3); + }); + + test("debug toggles render with bbox ON by default", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + const bbox = frigateApp.page.locator("#debug-bbox"); + await expect(bbox).toBeVisible({ timeout: 10_000 }); + await expect(bbox).toHaveAttribute("aria-checked", "true"); + }); + + test("clicking bbox toggle flips aria-checked", async ({ frigateApp }) => { + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + const bbox = frigateApp.page.locator("#debug-bbox"); + await expect(bbox).toBeVisible({ timeout: 10_000 }); + await expect(bbox).toHaveAttribute("aria-checked", "true"); + await bbox.click(); + await expect(bbox).toHaveAttribute("aria-checked", "false"); + }); + + test("Configuration button opens the configuration dialog (desktop)", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop: button has visible text label"); + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + await expect( + frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }), + ).toBeVisible({ timeout: 10_000 }); + + // On desktop the span is visible and gives the button an accessible name. + await frigateApp.page + .getByRole("button", { name: /configuration/i }) + .first() + .click(); + + const dialog = frigateApp.page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + }); + + test("Configuration button opens the configuration dialog (mobile)", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile: button is icon-only"); + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + await expect( + frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }), + ).toBeVisible({ timeout: 10_000 }); + + // On mobile the Configuration button text span is hidden (md:inline). + // It is the first button inside the right-side action group div + // (the flex container that holds Config + Stop, sibling of the Back button). + const actionGroup = frigateApp.page.locator( + ".flex.items-center.gap-2 button", + ); + await actionGroup.first().click(); + + const dialog = frigateApp.page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + }); + + test("Objects tab renders with the camera_activity objects list", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + + await expect( + frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }), + ).toBeVisible({ timeout: 10_000 }); + + // Send an activity payload with a person object on front_door. + // Must be called after goto() so the WS connection is established. + await frigateApp.ws.sendCameraActivity({ + front_door: { + objects: [ + { + label: "person", + score: 0.95, + box: [0.1, 0.1, 0.5, 0.8], + area: 0.2, + ratio: 0.6, + region: [0.05, 0.05, 0.6, 0.85], + current_zones: [], + id: "obj-person-1", + }, + ], + }, + }); + + // Switch to Objects tab (labelled "Object List" in i18n). + const objectsTab = frigateApp.page.getByRole("tab", { + name: /object/i, + }); + await objectsTab.click(); + await expect(objectsTab).toHaveAttribute("data-state", "active", { + timeout: 3_000, + }); + + // The object row renders the label. + await expect(frigateApp.page.getByText(/person/i).first()).toBeVisible({ + timeout: 5_000, + }); + }); + + test("Messages tab renders WsMessageFeed container", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + await expect( + frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }), + ).toBeVisible({ timeout: 10_000 }); + + const messagesTab = frigateApp.page.getByRole("tab", { + name: /messages/i, + }); + await messagesTab.click(); + await expect(messagesTab).toHaveAttribute("data-state", "active", { + timeout: 3_000, + }); + }); + + test("bbox info popover opens and closes cleanly", async ({ frigateApp }) => { + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + // The bbox row has an info icon popover trigger next to its label. + // The trigger is a div (not button) wrapping LuInfo with an sr-only + // "Info" span. Target it by the sr-only text content. + const infoTrigger = frigateApp.page + .locator("span.sr-only", { hasText: /info/i }) + .first(); + await expect(infoTrigger).toBeVisible({ timeout: 10_000 }); + // Click the parent div (the actual trigger) + await infoTrigger.locator("..").click(); + + const popover = frigateApp.page.locator( + "[data-radix-popper-content-wrapper]", + ); + await expect(popover.first()).toBeVisible({ timeout: 3_000 }); + await frigateApp.page.keyboard.press("Escape"); + await expect(popover.first()).not.toBeVisible({ timeout: 3_000 }); + }); +}); + +test.describe("Replay — stop flow (desktop) @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Desktop button has accessible 'Stop Replay' name", + ); + + test("Stop Replay opens confirm dialog; confirm POSTs debug_replay/stop", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, activeSessionStatus()); + let stopCalled = false; + await frigateApp.page.route("**/api/debug_replay/stop", async (route) => { + if (route.request().method() === "POST") stopCalled = true; + await route.fulfill({ json: { success: true } }); + }); + + await frigateApp.goto("/replay"); + await expect( + frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }), + ).toBeVisible({ timeout: 10_000 }); + + await frigateApp.page + .getByRole("button", { name: /stop replay/i }) + .first() + .click(); + + const dialog = frigateApp.page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 3_000 }); + await dialog + .getByRole("button", { name: /stop|confirm/i }) + .first() + .click(); + await expect.poll(() => stopCalled, { timeout: 5_000 }).toBe(true); + }); +}); + +test.describe("Replay — stop button (mobile) @medium @mobile", () => { + test.skip( + ({ frigateApp }) => !frigateApp.isMobile, + "Mobile-only icon-button variant", + ); + + test("tapping the icon-only stop button opens the confirm dialog", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, activeSessionStatus()); + await frigateApp.goto("/replay"); + await expect( + frigateApp.page.getByRole("heading", { level: 3, name: /Debug Replay/i }), + ).toBeVisible({ timeout: 10_000 }); + + // On mobile the Stop button is an icon (LuSquare) inside an + // AlertDialogTrigger. It's the last button in the top bar's + // right-side action group (Back is on the left). Target by + // position within the top-bar flex container. + const topRightButtons = frigateApp.page + .locator(".min-h-12 button, .md\\:min-h-16 button") + .filter({ hasNot: frigateApp.page.getByLabel("Back") }); + const lastButton = topRightButtons.last(); + await expect(lastButton).toBeVisible({ timeout: 10_000 }); + await lastButton.click(); + + const dialog = frigateApp.page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 3_000 }); + await dialog.getByRole("button", { name: /cancel/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 3_000 }); + }); +}); + +test.describe("Replay — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("no-session state renders at mobile viewport", async ({ + frigateApp, + }) => { + await installStatusRoute(frigateApp, noSessionStatus()); + await frigateApp.goto("/replay"); + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /No Active Debug Replay Session/i, + }), + ).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/web/e2e/specs/review.spec.ts b/web/e2e/specs/review.spec.ts index 166f32c44b..8d52e083a8 100644 --- a/web/e2e/specs/review.spec.ts +++ b/web/e2e/specs/review.spec.ts @@ -1,200 +1,230 @@ /** * Review/Events page tests -- CRITICAL tier. * - * Tests severity tab switching by name (Alerts/Detections/Motion), - * filter popover opening with camera names, show reviewed toggle, - * calendar button, and filter button interactions. + * Severity tabs, filter popovers, calendar, show-reviewed toggle, + * timeline, and the nested-overlay regression migrated from + * radix-overlay-regressions.spec.ts. */ import { test, expect } from "../fixtures/frigate-test"; import { BasePage } from "../pages/base.page"; +import { ReviewPage } from "../pages/review.page"; +import { + expectBodyInteractive, + waitForBodyInteractive, +} from "../helpers/overlay-interaction"; -test.describe("Review Page - Severity Tabs @critical", () => { - test("severity tabs render with Alerts, Detections, Motion", async ({ +test.describe("Review — severity tabs @critical", () => { + test("tabs render with Alerts default-on", async ({ frigateApp }) => { + await frigateApp.goto("/review"); + const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile); + await expect(review.alertsTab).toBeVisible({ timeout: 10_000 }); + await expect(review.detectionsTab).toBeVisible(); + await expect(review.motionTab).toBeVisible(); + await expect(review.alertsTab).toHaveAttribute("data-state", "on"); + }); + + test("clicking Detections flips data-state", async ({ frigateApp }) => { + await frigateApp.goto("/review"); + const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile); + await expect(review.alertsTab).toBeVisible({ timeout: 10_000 }); + await review.detectionsTab.click(); + await expect(review.detectionsTab).toHaveAttribute("data-state", "on"); + await expect(review.alertsTab).toHaveAttribute("data-state", "off"); + }); + + test("clicking Motion flips data-state", async ({ frigateApp }) => { + await frigateApp.goto("/review"); + const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile); + await expect(review.alertsTab).toBeVisible({ timeout: 10_000 }); + await review.motionTab.click(); + await expect(review.motionTab).toHaveAttribute("data-state", "on"); + }); + + test("switching back to Alerts works", async ({ frigateApp }) => { + await frigateApp.goto("/review"); + const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile); + await review.detectionsTab.click(); + await expect(review.detectionsTab).toHaveAttribute("data-state", "on"); + await review.alertsTab.click(); + await expect(review.alertsTab).toHaveAttribute("data-state", "on"); + }); + + test("switching tabs updates active data-state (client-side filter)", async ({ frigateApp, }) => { + // The severity tabs filter the already-fetched review data client-side; + // they do not trigger a new /api/review network request. This test + // verifies the state-change assertion that the tab switch takes effect. await frigateApp.goto("/review"); - await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({ - timeout: 10_000, - }); - await expect(frigateApp.page.getByLabel("Detections")).toBeVisible(); - // Motion uses role="radio" to distinguish from other Motion elements - await expect( - frigateApp.page.getByRole("radio", { name: "Motion" }), - ).toBeVisible(); - }); - - test("Alerts tab is active by default", async ({ frigateApp }) => { - await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); - const alertsTab = frigateApp.page.getByLabel("Alerts"); - await expect(alertsTab).toHaveAttribute("data-state", "on"); - }); - - test("clicking Detections tab makes it active and deactivates Alerts", async ({ - frigateApp, - }) => { - await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); - const alertsTab = frigateApp.page.getByLabel("Alerts"); - const detectionsTab = frigateApp.page.getByLabel("Detections"); - - await detectionsTab.click(); - await frigateApp.page.waitForTimeout(500); - - await expect(detectionsTab).toHaveAttribute("data-state", "on"); - await expect(alertsTab).toHaveAttribute("data-state", "off"); - }); - - test("clicking Motion tab makes it active", async ({ frigateApp }) => { - await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); - const motionTab = frigateApp.page.getByRole("radio", { name: "Motion" }); - await motionTab.click(); - await frigateApp.page.waitForTimeout(500); - await expect(motionTab).toHaveAttribute("data-state", "on"); - }); - - test("switching back to Alerts from Detections works", async ({ - frigateApp, - }) => { - await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); - - await frigateApp.page.getByLabel("Detections").click(); - await frigateApp.page.waitForTimeout(300); - await frigateApp.page.getByLabel("Alerts").click(); - await frigateApp.page.waitForTimeout(300); - - await expect(frigateApp.page.getByLabel("Alerts")).toHaveAttribute( - "data-state", - "on", - ); + const review = new ReviewPage(frigateApp.page, !frigateApp.isMobile); + await expect(review.alertsTab).toBeVisible({ timeout: 10_000 }); + await expect(review.alertsTab).toHaveAttribute("data-state", "on"); + await review.detectionsTab.click(); + await expect(review.detectionsTab).toHaveAttribute("data-state", "on"); + await expect(review.alertsTab).toHaveAttribute("data-state", "off"); }); }); -test.describe("Review Page - Filters @critical", () => { - test("All Cameras filter button opens popover with camera names", async ({ +test.describe("Review — filters (desktop) @critical", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Filter bar differs on mobile", + ); + + test("Cameras popover lists configured camera names", async ({ frigateApp, }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); - - const camerasBtn = frigateApp.page.getByRole("button", { - name: /cameras/i, - }); - await expect(camerasBtn).toBeVisible({ timeout: 5_000 }); - await camerasBtn.click(); - await frigateApp.page.waitForTimeout(500); - - // Popover should open with camera names from config - const popover = frigateApp.page.locator( - "[data-radix-popper-content-wrapper]", - ); - await expect(popover.first()).toBeVisible({ timeout: 3_000 }); - // Camera names should be present + const review = new ReviewPage(frigateApp.page, true); + await expect(review.camerasFilterTrigger).toBeVisible({ timeout: 5_000 }); + await review.camerasFilterTrigger.click(); + await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 }); await expect(frigateApp.page.getByText("Front Door")).toBeVisible(); + }); + test("closing the Cameras popover with Escape leaves body interactive", async ({ + frigateApp, + }) => { + // Migrated from radix-overlay-regressions.spec.ts. + await frigateApp.goto("/review"); + const review = new ReviewPage(frigateApp.page, true); + await review.camerasFilterTrigger.click(); + await expect(review.filterOverlay).toBeVisible({ timeout: 3_000 }); + await frigateApp.page.keyboard.press("Escape"); + await expect(review.filterOverlay).not.toBeVisible({ timeout: 3_000 }); + await waitForBodyInteractive(frigateApp.page); + await expectBodyInteractive(frigateApp.page); + }); + + test("Labels are shown inside the General Filter dialog", async ({ + frigateApp, + }) => { + // Labels are surfaced inside the "Filter" button's GeneralFilterContent + // dialog, not as a standalone top-level button. We open that dialog and + // confirm labels from the camera config are listed there. + await frigateApp.goto("/review"); + const filterBtn = frigateApp.page + .getByRole("button", { name: /^filter$/i }) + .first(); + await expect(filterBtn).toBeVisible({ timeout: 5_000 }); + await filterBtn.click(); + + const overlay = frigateApp.page.locator( + "[data-radix-popper-content-wrapper], [role='dialog']", + ); + await expect(overlay.first()).toBeVisible({ timeout: 3_000 }); + // The default mock config for front_door tracks "person" + await expect(overlay.first().getByText(/person/i)).toBeVisible(); await frigateApp.page.keyboard.press("Escape"); }); - test("Show Reviewed toggle is clickable", async ({ frigateApp }) => { - await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); - - const showReviewed = frigateApp.page.getByRole("button", { - name: /reviewed/i, + test("Zones popover lists configured zones inside the General Filter dialog", async ({ + frigateApp, + }) => { + // Override config to guarantee a known zone on front_door. + await frigateApp.installDefaults({ + config: { + cameras: { + front_door: { + zones: { + front_yard: { coordinates: "0.1,0.1,0.9,0.1,0.9,0.9,0.1,0.9" }, + }, + }, + }, + }, }); - if (await showReviewed.isVisible().catch(() => false)) { - await showReviewed.click(); - await frigateApp.page.waitForTimeout(500); - // Toggle should change state - await expect(frigateApp.page.locator("body")).toBeVisible(); - } + await frigateApp.goto("/review"); + const filterBtn = frigateApp.page + .getByRole("button", { name: /^filter$/i }) + .first(); + await expect(filterBtn).toBeVisible({ timeout: 5_000 }); + await filterBtn.click(); + + const overlay = frigateApp.page.locator( + "[data-radix-popper-content-wrapper], [role='dialog']", + ); + await expect(overlay.first()).toBeVisible({ timeout: 3_000 }); + await expect(overlay.first().getByText(/front.?yard/i)).toBeVisible(); + await frigateApp.page.keyboard.press("Escape"); }); - test("Last 24 Hours calendar button opens date picker", async ({ + test("Calendar trigger opens a date picker popover", async ({ frigateApp, }) => { await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); + const review = new ReviewPage(frigateApp.page, true); + await expect(review.calendarTrigger).toBeVisible({ timeout: 5_000 }); + await review.calendarTrigger.click(); - const calendarBtn = frigateApp.page.getByRole("button", { - name: /24 hours|calendar|date/i, - }); - if (await calendarBtn.isVisible().catch(() => false)) { - await calendarBtn.click(); - await frigateApp.page.waitForTimeout(500); - // Popover should open - const popover = frigateApp.page.locator( - "[data-radix-popper-content-wrapper]", - ); - if ( - await popover - .first() - .isVisible() - .catch(() => false) - ) { - await frigateApp.page.keyboard.press("Escape"); - } - } + // react-day-picker v9 renders a role="grid" calendar with day cells + // as buttons inside gridcells (e.g. "Wednesday, April 1st, 2026"). + // The calendar is placed directly in the DOM (not always inside a + // Radix popper wrapper), so scope by the grid role instead. + const calendarGrid = frigateApp.page.locator('[role="grid"]').first(); + await expect(calendarGrid).toBeVisible({ timeout: 3_000 }); + const dayButton = calendarGrid.locator('[role="gridcell"] button').first(); + await expect(dayButton).toBeVisible({ timeout: 3_000 }); + await frigateApp.page.keyboard.press("Escape"); }); - test("Filter button opens filter popover", async ({ frigateApp }) => { - await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(1000); - - const filterBtn = frigateApp.page.getByRole("button", { - name: /^filter$/i, - }); - if (await filterBtn.isVisible().catch(() => false)) { - await filterBtn.click(); - await frigateApp.page.waitForTimeout(500); - // Popover or dialog should open - const popover = frigateApp.page.locator( - "[data-radix-popper-content-wrapper], [role='dialog']", - ); - if ( - await popover - .first() - .isVisible() - .catch(() => false) - ) { - await frigateApp.page.keyboard.press("Escape"); - } - } - }); -}); - -test.describe("Review Page - Timeline @critical", () => { - test("review page has timeline with time markers (desktop)", async ({ + test("Show Reviewed switch flips its checked state", async ({ frigateApp, }) => { - if (frigateApp.isMobile) { - test.skip(); - return; - } + // "Show Reviewed" is a Radix Switch (role=switch), not a button. + // It filters review data client-side; it does not trigger a new + // /api/review network request. Verify the switch state toggles. await frigateApp.goto("/review"); - await frigateApp.page.waitForTimeout(2000); - // Timeline renders time labels like "4:30 PM" - const pageText = await frigateApp.page.textContent("#pageRoot"); - expect(pageText).toMatch(/[AP]M/); + const showReviewedSwitch = frigateApp.page.getByRole("switch", { + name: /show reviewed/i, + }); + await expect(showReviewedSwitch).toBeVisible({ timeout: 5_000 }); + + // Record initial checked state and click to toggle + const initialChecked = + await showReviewedSwitch.getAttribute("aria-checked"); + await showReviewedSwitch.click(); + const flippedChecked = initialChecked === "true" ? "false" : "true"; + await expect(showReviewedSwitch).toHaveAttribute( + "aria-checked", + flippedChecked, + ); }); }); -test.describe("Review Page - Navigation @critical", () => { - test("navigate to review from live page works", async ({ frigateApp }) => { +test.describe("Review — timeline (desktop) @critical", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Timeline not shown on mobile", + ); + + test("timeline renders time markers", async ({ frigateApp }) => { + await frigateApp.goto("/review"); + await expect + .poll( + async () => (await frigateApp.page.textContent("#pageRoot")) ?? "", + { timeout: 10_000 }, + ) + .toMatch(/[AP]M|\d+:\d+/); + }); +}); + +test.describe("Review — mobile @critical @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("severity tabs render on mobile", async ({ frigateApp }) => { + await frigateApp.goto("/review"); + const review = new ReviewPage(frigateApp.page, false); + await expect(review.alertsTab).toBeVisible({ timeout: 10_000 }); + await expect(review.detectionsTab).toBeVisible(); + }); + + test("back navigation returns to Live", async ({ frigateApp }) => { await frigateApp.goto("/"); - const base = new BasePage(frigateApp.page, !frigateApp.isMobile); + const base = new BasePage(frigateApp.page, false); await base.navigateTo("/review"); await expect(frigateApp.page).toHaveURL(/\/review/); - // Severity tabs should be visible - await expect(frigateApp.page.getByLabel("Alerts")).toBeVisible({ - timeout: 10_000, - }); + await base.navigateTo("/"); + await expect(frigateApp.page).toHaveURL(/\/$/); }); }); diff --git a/web/e2e/specs/system.spec.ts b/web/e2e/specs/system.spec.ts index a3aa512e5b..87e4db51ea 100644 --- a/web/e2e/specs/system.spec.ts +++ b/web/e2e/specs/system.spec.ts @@ -1,15 +1,21 @@ /** - * System page tests -- MEDIUM tier. + * System page tests -- MEDIUM tier (promoted to cover migrated + * RestartDialog test from radix-overlay-regressions.spec.ts). * - * Tests system page rendering with tabs and tab switching. - * Navigates to /system#general explicitly so useHashState resolves - * the tab state deterministically. + * Tab switching, version + last-refreshed display, and the + * RestartDialog cancel flow. */ import { test, expect } from "../fixtures/frigate-test"; +import { + expectBodyInteractive, + waitForBodyInteractive, +} from "../helpers/overlay-interaction"; -test.describe("System Page @medium", () => { - test("system page renders with tab buttons", async ({ frigateApp }) => { +test.describe("System — tabs @medium", () => { + test("general tab is active by default via #general hash", async ({ + frigateApp, + }) => { await frigateApp.goto("/system#general"); await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( "data-state", @@ -20,7 +26,7 @@ test.describe("System Page @medium", () => { await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible(); }); - test("general tab is active when navigated via hash", async ({ + test("Storage tab activates and deactivates General", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); @@ -29,18 +35,6 @@ test.describe("System Page @medium", () => { "on", { timeout: 15_000 }, ); - }); - - test("clicking Storage tab activates it and deactivates General", async ({ - frigateApp, - }) => { - await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( - "data-state", - "on", - { timeout: 15_000 }, - ); - await frigateApp.page.getByLabel("Select storage").click(); await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( "data-state", @@ -53,29 +47,22 @@ test.describe("System Page @medium", () => { ); }); - test("clicking Cameras tab activates it and deactivates General", async ({ - frigateApp, - }) => { + test("Cameras tab activates", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select cameras").click(); await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute( "data-state", "on", { timeout: 5_000 }, ); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( - "data-state", - "off", - ); }); - test("system page shows version and last refreshed", async ({ + test("general tab shows version and last-refreshed", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); @@ -87,4 +74,164 @@ test.describe("System Page @medium", () => { await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible(); await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible(); }); + + test("storage tab renders content after switching", async ({ + frigateApp, + }) => { + await frigateApp.goto("/system#general"); + await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + "data-state", + "on", + { timeout: 15_000 }, + ); + await frigateApp.page.getByLabel("Select storage").click(); + await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( + "data-state", + "on", + { timeout: 5_000 }, + ); + // On desktop, tab buttons render text labels so the word "storage" + // always appears in #pageRoot after switching. On mobile, tabs are + // icon-only, so we verify the general-tab content disappears instead + // (the storage tab's metrics section is hidden but general is gone). + if (!frigateApp.isMobile) { + await expect + .poll( + async () => (await frigateApp.page.textContent("#pageRoot")) ?? "", + { timeout: 10_000 }, + ) + .toMatch(/storage|mount|disk|used|free/i); + } else { + // Mobile: tab activation (data-state "on") already asserted above. + // Additionally confirm general tab is no longer the active tab. + await expect( + frigateApp.page.getByLabel("Select general"), + ).toHaveAttribute("data-state", "off", { timeout: 5_000 }); + } + }); + + test("cameras tab renders each configured camera", async ({ frigateApp }) => { + await frigateApp.goto("/system#general"); + await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + "data-state", + "on", + { timeout: 15_000 }, + ); + await frigateApp.page.getByLabel("Select cameras").click(); + await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute( + "data-state", + "on", + { timeout: 5_000 }, + ); + // Cameras tab lists every camera from config/stats. The default + // mock has front_door, backyard, garage. + for (const cam of ["front_door", "backyard", "garage"]) { + await expect( + frigateApp.page + .getByText(new RegExp(cam.replace("_", ".?"), "i")) + .first(), + ).toBeVisible({ timeout: 10_000 }); + } + }); + + test("enrichments tab renders when semantic search is enabled", async ({ + frigateApp, + }) => { + // Override config to guarantee the enrichments tab is present. + // System.tsx shows the tab when semantic_search.enabled === true. + await frigateApp.installDefaults({ + config: { semantic_search: { enabled: true } }, + }); + await frigateApp.goto("/system#general"); + await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + "data-state", + "on", + { timeout: 15_000 }, + ); + const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first(); + await expect(enrichTab).toBeVisible({ timeout: 5_000 }); + await enrichTab.click(); + await expect(enrichTab).toHaveAttribute("data-state", "on", { + timeout: 5_000, + }); + }); +}); + +test.describe("System — RestartDialog @medium", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Sidebar menu is desktop-only", + ); + + test("cancelling restart leaves body interactive", async ({ frigateApp }) => { + // Migrated from radix-overlay-regressions.spec.ts. + await frigateApp.goto("/"); + + const sidebarTriggers = frigateApp.page + .locator('[role="complementary"] [aria-haspopup="menu"]') + .or(frigateApp.page.locator('aside [aria-haspopup="menu"]')); + const triggerCount = await sidebarTriggers.count(); + expect(triggerCount).toBeGreaterThan(0); + + let opened = false; + for (let i = 0; i < triggerCount; i++) { + const trigger = sidebarTriggers.nth(i); + await trigger.click().catch(() => {}); + const restartItem = frigateApp.page + .getByRole("menuitem", { name: /restart/i }) + .first(); + const visible = await expect(restartItem) + .toBeVisible({ timeout: 300 }) + .then(() => true) + .catch(() => false); + if (visible) { + await restartItem.click(); + opened = true; + break; + } + await frigateApp.page.keyboard.press("Escape").catch(() => {}); + } + expect(opened).toBe(true); + + const cancel = frigateApp.page.getByRole("button", { name: /cancel/i }); + await expect(cancel).toBeVisible({ timeout: 3_000 }); + await cancel.click(); + + await waitForBodyInteractive(frigateApp.page); + await expectBodyInteractive(frigateApp.page); + + const postCancelTrigger = sidebarTriggers.first(); + await postCancelTrigger.click(); + await expect( + frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(), + ).toBeVisible({ timeout: 3_000 }); + }); +}); + +test.describe("System — mobile @medium @mobile", () => { + test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only"); + + test("tabs render at mobile viewport", async ({ frigateApp }) => { + await frigateApp.goto("/system#general"); + await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({ + timeout: 15_000, + }); + }); + + test("switching tabs works at mobile viewport", async ({ frigateApp }) => { + await frigateApp.goto("/system#general"); + await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + "data-state", + "on", + { timeout: 15_000 }, + ); + await frigateApp.page.getByLabel("Select storage").click(); + await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( + "data-state", + "on", + { timeout: 5_000 }, + ); + }); }); diff --git a/web/package-lock.json b/web/package-lock.json index fc115fc3cc..cd09b79811 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,14 +12,14 @@ "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^3.10.0", "@melloware/react-logviewer": "^6.1.2", - "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.6", - "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-progress": "^1.1.8", @@ -54,7 +54,7 @@ "immer": "^10.1.1", "js-yaml": "^4.1.1", "konva": "^10.2.3", - "lodash": "^4.17.23", + "lodash": "^4.18.1", "lucide-react": "^0.577.0", "monaco-yaml": "^5.4.1", "next-themes": "^0.4.6", @@ -1515,17 +1515,17 @@ "license": "MIT" }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", - "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dialog": "1.1.6", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-slot": "1.1.2" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1542,126 +1542,17 @@ } } }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", - "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.5", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.2", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.4", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-slot": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", - "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-focus-scope": { + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", - "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-portal": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", - "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1672,6 +1563,29 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -2113,17 +2027,17 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz", - "integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==", + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-menu": "2.1.6", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -2140,6 +2054,99 @@ } } }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -2197,21 +2204,6 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -2398,18 +2390,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", - "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-menu": "2.1.6", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-controllable-state": "1.1.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -2426,10 +2418,106 @@ } } }, - "node_modules/@radix-ui/react-focus-guards": { + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", - "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2505,20 +2593,20 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz", - "integrity": "sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.5", - "@radix-ui/react-popper": "1.2.2", - "@radix-ui/react-portal": "1.1.4", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-controllable-state": "1.1.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -2535,17 +2623,35 @@ } } }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": { + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-presence": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", - "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -2562,14 +2668,13 @@ } } }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", - "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2586,13 +2691,14 @@ } } }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -2604,6 +2710,21 @@ } } }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-icons": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", @@ -2678,27 +2799,27 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", - "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-collection": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.5", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.2", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.2", - "@radix-ui/react-portal": "1.1.4", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-roving-focus": "1.1.2", - "@radix-ui/react-slot": "1.1.2", - "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -2717,17 +2838,22 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", - "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2744,15 +2870,62 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": { + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", - "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-callback-ref": "1.1.0" + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -2769,14 +2942,13 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", - "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-use-layout-effect": "1.1.0" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2793,14 +2965,76 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2869,21 +3103,6 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -3717,21 +3936,6 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -7771,9 +7975,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -9432,15 +9636,15 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.merge": { diff --git a/web/package.json b/web/package.json index 0ece2d6fef..7d22dc7183 100644 --- a/web/package.json +++ b/web/package.json @@ -26,14 +26,14 @@ "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^3.10.0", "@melloware/react-logviewer": "^6.1.2", - "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.6", - "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-progress": "^1.1.8", @@ -68,7 +68,7 @@ "immer": "^10.1.1", "js-yaml": "^4.1.1", "konva": "^10.2.3", - "lodash": "^4.17.23", + "lodash": "^4.18.1", "lucide-react": "^0.577.0", "monaco-yaml": "^5.4.1", "next-themes": "^0.4.6", diff --git a/web/public/locales/ar/components/dialog.json b/web/public/locales/ar/components/dialog.json index 42918739f9..acc4c8b1a1 100644 --- a/web/public/locales/ar/components/dialog.json +++ b/web/public/locales/ar/components/dialog.json @@ -6,7 +6,8 @@ "title": "يتم إعادة تشغيل فرايجيت", "content": "العد التنازلي", "button": "فرض إعادة التحميل الآن" - } + }, + "description": "هذا سيؤدي لإيقاف Frigate مؤقتا أثناء إعادة تشغيلها" }, "explore": { "plus": { diff --git a/web/public/locales/ar/config/cameras.json b/web/public/locales/ar/config/cameras.json index a5ec98238e..8cf9fb07b6 100644 --- a/web/public/locales/ar/config/cameras.json +++ b/web/public/locales/ar/config/cameras.json @@ -1,3 +1,6 @@ { - "label": "اعدادات الكاميرا" + "label": "اعدادات الكاميرا", + "name": { + "label": "إسم الكاميرا" + } } diff --git a/web/public/locales/ar/config/global.json b/web/public/locales/ar/config/global.json index 0967ef424b..a663ff699b 100644 --- a/web/public/locales/ar/config/global.json +++ b/web/public/locales/ar/config/global.json @@ -1 +1,6 @@ -{} +{ + "version": { + "label": "إصدار الإعدادات الحالية", + "description": "نسحة عددية أو نصية من الإعدادات الحالية الفعالة للمساعدة على اكتشاف الانتقال أو التغير في الصِّيَغ" + } +} diff --git a/web/public/locales/ar/config/groups.json b/web/public/locales/ar/config/groups.json index 2254e03084..ff73d6a171 100644 --- a/web/public/locales/ar/config/groups.json +++ b/web/public/locales/ar/config/groups.json @@ -1,7 +1,8 @@ { "audio": { "global": { - "detection": "التحري العام" + "detection": "التحري العام", + "sensitivity": "الحساسية العامة" } } } diff --git a/web/public/locales/ar/config/validation.json b/web/public/locales/ar/config/validation.json index 0967ef424b..fef7af713e 100644 --- a/web/public/locales/ar/config/validation.json +++ b/web/public/locales/ar/config/validation.json @@ -1 +1,4 @@ -{} +{ + "minimum": "يجب أن تكون {{limit}} على الأقل", + "maximum": "يجب أن تكون {{limit}} كحد أقصى" +} diff --git a/web/public/locales/ar/views/chat.json b/web/public/locales/ar/views/chat.json new file mode 100644 index 0000000000..961cd84043 --- /dev/null +++ b/web/public/locales/ar/views/chat.json @@ -0,0 +1,3 @@ +{ + "documentTitle": "المحادثات - Frigate" +} diff --git a/web/public/locales/ar/views/motionSearch.json b/web/public/locales/ar/views/motionSearch.json new file mode 100644 index 0000000000..119c06ea2d --- /dev/null +++ b/web/public/locales/ar/views/motionSearch.json @@ -0,0 +1,3 @@ +{ + "documentTitle": "البحث عن الحركة - Frigate" +} diff --git a/web/public/locales/ar/views/replay.json b/web/public/locales/ar/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ar/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/bg/config/global.json b/web/public/locales/bg/config/global.json index 0967ef424b..ad191cd667 100644 --- a/web/public/locales/bg/config/global.json +++ b/web/public/locales/bg/config/global.json @@ -1 +1,8 @@ -{} +{ + "auth": { + "label": "Автентикация", + "session_length": { + "label": "Продължителност на сесията" + } + } +} diff --git a/web/public/locales/bg/views/chat.json b/web/public/locales/bg/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/bg/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/bg/views/motionSearch.json b/web/public/locales/bg/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/bg/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/bg/views/replay.json b/web/public/locales/bg/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/bg/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/bs/audio.json b/web/public/locales/bs/audio.json new file mode 100644 index 0000000000..4ee2ca993d --- /dev/null +++ b/web/public/locales/bs/audio.json @@ -0,0 +1,503 @@ +{ + "speech": "Govor", + "babbling": "Babavljanje", + "bicycle": "Kolo", + "yell": "Vik", + "bellow": "Bubanj", + "whoop": "Vrisak", + "whispering": "Šaputanje", + "laughter": "Smijeh", + "snicker": "Prijem", + "crying": "Plač", + "sigh": "Usklik", + "singing": "Pjevanje", + "choir": "Hors", + "yodeling": "Jodelanje", + "chant": "Pjevanje", + "mantra": "Mantra", + "child_singing": "Dječje pjevanje", + "synthetic_singing": "Sintetičko pjevanje", + "rapping": "Rap", + "humming": "Hum", + "groan": "Grokot", + "grunt": "Groktanje", + "whistling": "Pucanje", + "breathing": "Disanje", + "wheeze": "Pijuckanje", + "snoring": "Kicanje", + "gasp": "Udah", + "pant": "Pantanje", + "snort": "Snortanje", + "cough": "Kašljanje", + "throat_clearing": "Očišćavanje grla", + "sneeze": "Prašanje", + "sniff": "Njuhanje", + "run": "Trčanje", + "shuffle": "Prelazak", + "footsteps": "Koraci", + "chewing": "Zubljanje", + "biting": "Gubitak", + "gargling": "Peranje grla", + "stomach_rumble": "Grušenje", + "burping": "Puknutje", + "hiccup": "Kikot", + "fart": "Pucanje", + "hands": "Ruke", + "finger_snapping": "Prašanje prstiju", + "clapping": "Ključanje", + "heartbeat": "Taktilno", + "heart_murmur": "Šum srca", + "cheering": "Pozdrav", + "applause": "Pozdravljati", + "chatter": "Šaputanje", + "crowd": "Gomila", + "children_playing": "Dječja igra", + "animal": "Životinja", + "pets": "Hrana", + "dog": "Pas", + "bark": "Glavu", + "yip": "Jauk", + "howl": "Vijuk", + "bow_wow": "Vau vau", + "growling": "Gručenje", + "whimper_dog": "Pijuckanje psa", + "cat": "Mačka", + "purr": "Mrmor", + "meow": "Mjau", + "hiss": "Zujanje", + "caterwaul": "Krik", + "livestock": "Stoke", + "horse": "Konj", + "clip_clop": "Klik klok", + "neigh": "Kijanje", + "cattle": "Stoke", + "moo": "Muu", + "cowbell": "Kovčeg", + "pig": "Svinja", + "oink": "Roktanje", + "goat": "Koza", + "bleat": "Blejkanje", + "sheep": "Ovca", + "fowl": "Ptica", + "chicken": "Pilica", + "cluck": "Kukanje", + "cock_a_doodle_doo": "Kukavica", + "turkey": "Gusa", + "gobble": "Gubljanje", + "duck": "Kuja", + "quack": "Kvaka", + "goose": "Guska", + "honk": "Trubljenje", + "wild_animals": "Divlja životinja", + "roaring_cats": "Vrišćeći mački", + "roar": "Vrištanje", + "bird": "Ptica", + "chirp": "Pijuckanje", + "squawk": "Krik", + "pigeon": "Papiga", + "coo": "Kukanje", + "crow": "Vran", + "caw": "Vranje", + "owl": "Kukavica", + "hoot": "Kukavica", + "flapping_wings": "Mahanje krilima", + "dogs": "Psi", + "rats": "Štakori", + "mouse": "Miš", + "patter": "Topotanje", + "insect": "Insekt", + "cricket": "Cvrčak", + "mosquito": "Komarac", + "fly": "Muha", + "buzz": "Zujanje", + "frog": "Žaba", + "croak": "Kreketanje", + "snake": "Zmija", + "rattle": "Zveckanje", + "whale_vocalization": "Glasanje kita", + "music": "Muzika", + "musical_instrument": "Muzički instrument", + "plucked_string_instrument": "Plucked String Instrument", + "guitar": "Gitara", + "electric_guitar": "Električna gitara", + "bass_guitar": "Bas gitara", + "acoustic_guitar": "Akustična gitara", + "steel_guitar": "Steel gitara", + "tapping": "Tapping", + "strum": "Strum", + "banjo": "Bendžo", + "sitar": "Sitar", + "mandolin": "Mandolina", + "zither": "Citra", + "ukulele": "Ukulele", + "keyboard": "Klaviatura", + "piano": "Klavir", + "electric_piano": "Električni piano", + "organ": "Orgulje", + "electronic_organ": "Elektronski organ", + "hammond_organ": "Hammond organ", + "synthesizer": "Sintetizator", + "sampler": "Sampler", + "harpsichord": "Harfura", + "percussion": "Percuzija", + "drum_kit": "Set bubnjeva", + "drum_machine": "Mašina za bubnjeve", + "drum": "Bubanj", + "snare_drum": "Bubanj sa zavojima", + "rimshot": "Rimshot", + "drum_roll": "Bubanj za roliranje", + "bass_drum": "Bubanj za bas", + "timpani": "Timpani", + "tabla": "Tabla", + "cymbal": "Cimbale", + "hi_hat": "Hi-Hat", + "wood_block": "Drveni blok", + "tambourine": "Tamburina", + "maraca": "Maraka", + "gong": "Gong", + "tubular_bells": "Cijevasti zvoni", + "mallet_percussion": "Percusija s mljevima", + "marimba": "Marimba", + "glockenspiel": "Glockenspiel", + "vibraphone": "Vibrafon", + "steelpan": "Stelpan", + "orchestra": "Orkestar", + "brass_instrument": "Bronski instrument", + "french_horn": "Francuski rog", + "trumpet": "Truba", + "trombone": "Trombon", + "bowed_string_instrument": "Užadno strunski instrument", + "string_section": "Strunski sekcija", + "violin": "Violina", + "pizzicato": "Pizzicato", + "cello": "Celula", + "double_bass": "Dvostruki bas", + "wind_instrument": "Vjetreni instrument", + "flute": "Flauta", + "saxophone": "Saksafon", + "clarinet": "Klarinet", + "harp": "Harfa", + "bell": "Zvono", + "church_bell": "Crkveno zvono", + "jingle_bell": "Zvono za igračke", + "bicycle_bell": "Zvono za bicikl", + "tuning_fork": "Zvučnik", + "chime": "Zvono", + "wind_chime": "Vjetrenjac", + "harmonica": "Harmonika", + "accordion": "Akkordon", + "bagpipes": "Bogovina", + "didgeridoo": "Didgeridoo", + "theremin": "Teremin", + "singing_bowl": "Pjevni čaša", + "scratching": "Skrečing", + "pop_music": "Pop muzika", + "hip_hop_music": "Hip-Hop muzika", + "beatboxing": "Bitboksing", + "rock_music": "Rock muzika", + "heavy_metal": "Heavy metal", + "punk_rock": "Punk rock", + "grunge": "Grandž", + "progressive_rock": "Progressivni rock", + "rock_and_roll": "Rock and roll", + "psychedelic_rock": "Psihederički rock", + "rhythm_and_blues": "Ritam i blues", + "soul_music": "Soul glazba", + "reggae": "Rege", + "country": "Kantri", + "swing_music": "Swing glazba", + "bluegrass": "Bluegrass", + "funk": "Fank", + "folk_music": "Folklorno glazba", + "middle_eastern_music": "Glazba Bliskog istoka", + "jazz": "Džez", + "disco": "Disko", + "classical_music": "Klasična glazba", + "opera": "Opera", + "electronic_music": "Elektronska glazba", + "house_music": "House glazba", + "techno": "Tehno", + "dubstep": "Dubstep", + "drum_and_bass": "Drum i bass", + "electronica": "Elektronika", + "electronic_dance_music": "Elektronska plesna glazba", + "ambient_music": "Ambient glazba", + "trance_music": "Trance glazba", + "music_of_latin_america": "Glazba Latinske Amerike", + "salsa_music": "Salsa glazba", + "flamenco": "Flamenko", + "blues": "Bluz", + "music_for_children": "Muzika za djecu", + "new-age_music": "Muzika novog doba", + "vocal_music": "Vokalna muzika", + "a_capella": "A Capella", + "music_of_africa": "Afrička muzika", + "afrobeat": "Afrobeat", + "christian_music": "Kršćanska muzika", + "gospel_music": "Gospel muzika", + "music_of_asia": "Azijatska muzika", + "carnatic_music": "Karnatička muzika", + "music_of_bollywood": "Bollywood muzika", + "ska": "Ska", + "traditional_music": "Tradicionalna muzika", + "independent_music": "Nezavisna muzika", + "song": "Pjesma", + "background_music": "Pozadinska muzika", + "theme_music": "Tema muzika", + "jingle": "Jingle", + "soundtrack_music": "Soundtrack muzika", + "lullaby": "Pjesma za uspavanje", + "video_game_music": "Muzika za video igre", + "christmas_music": "Božićna muzika", + "dance_music": "Dance muzika", + "wedding_music": "Venčanska glazba", + "happy_music": "Sretna glazba", + "sad_music": "Tužna glazba", + "tender_music": "Tenderna glazba", + "exciting_music": "Uzbudljiva glazba", + "angry_music": "Zlobna glazba", + "scary_music": "Strašna glazba", + "wind": "Vjetar", + "rustling_leaves": "Šum listova", + "wind_noise": "Šum vjetra", + "thunderstorm": "Grmljavina", + "thunder": "Grmljavac", + "water": "Voda", + "rain": "Kisa", + "raindrop": "Kap kise", + "rain_on_surface": "Kisa na površini", + "stream": "Tok", + "waterfall": "Padina", + "ocean": "Okean", + "waves": "Valovi", + "steam": "Par", + "gurgling": "Gurkanje", + "fire": "Vatra", + "crackle": "Krik", + "vehicle": "Vozilo", + "boat": "Brod", + "sailboat": "Jedrilica", + "rowboat": "Čamac", + "motorboat": "Motorni čamac", + "ship": "Brod", + "motor_vehicle": "Motorno vozilo", + "car": "Automobil", + "toot": "Zvuk klaksona", + "car_alarm": "Automobilski alarm", + "power_windows": "Električna prozora", + "skidding": "Klizanje", + "tire_squeal": "Krik kotača", + "car_passing_by": "Automobil prolazi", + "race_car": "Racing automobil", + "truck": "Kamion", + "air_brake": "Vazdušni kočnici", + "air_horn": "Vazdušni signal", + "reversing_beeps": "Zvukovi za odlazak unazad", + "ice_cream_truck": "Kamion za sladoled", + "bus": "Autobus", + "emergency_vehicle": "Hitni vozilo", + "police_car": "Policijski automobil", + "ambulance": "Ambulansa", + "fire_engine": "Pogonski automobil", + "motorcycle": "Motocikl", + "traffic_noise": "Prometni šum", + "rail_transport": "Željeznički transport", + "train": "Vlak", + "train_whistle": "Vlakovni svirac", + "train_horn": "Vlakovni rohorn", + "railroad_car": "Željeznički vagon", + "train_wheels_squealing": "Vlakove točkove koje zavijaju", + "subway": "Metropolitena", + "aircraft": "Avion", + "aircraft_engine": "Avionski motor", + "jet_engine": "Reaktivni motor", + "propeller": "Vijak", + "helicopter": "Heličopter", + "fixed-wing_aircraft": "Avion s krilima", + "skateboard": "Skejtbord", + "engine": "Motor", + "light_engine": "Lagani motor", + "dental_drill's_drill": "Stomatološki bušilica", + "lawn_mower": "Kosilica", + "chainsaw": "Pilica", + "medium_engine": "Srednji motor", + "heavy_engine": "Teški motor", + "engine_knocking": "Kloping motora", + "engine_starting": "Pokretanje motora", + "idling": "Miris", + "accelerating": "Ubrzavanje", + "door": "Vrata", + "doorbell": "Zvonce", + "ding-dong": "Ding-dong", + "sliding_door": "Klizna vrata", + "slam": "Zatvaranje", + "knock": "Kucanje", + "tap": "Kucanje", + "squeak": "Krik", + "cupboard_open_or_close": "Otvorenje ili zatvaranje police", + "drawer_open_or_close": "Otvorenje ili zatvaranje vunca", + "dishes": "Posuđe", + "cutlery": "Posuđe za jelo", + "chopping": "Rezanje", + "frying": "Praženje", + "microwave_oven": "Mikrotalasna pećnica", + "blender": "Miksere", + "water_tap": "Kran", + "sink": "Lavabo", + "bathtub": "Kupatilo", + "hair_dryer": "Sušilac za kosu", + "toilet_flush": "Očišćavanje toaleta", + "toothbrush": "Šetka za zube", + "electric_toothbrush": "Električna šetka za zube", + "vacuum_cleaner": "Praškoljac", + "zipper": "Zatvarac", + "keys_jangling": "Ključevi koji se škripi", + "coin": "Novčanik", + "scissors": "Škare", + "electric_shaver": "Električni šavac", + "shuffling_cards": "Premještanje karata", + "typing": "Kucanje", + "typewriter": "Tipkovnica", + "computer_keyboard": "Računalna tipkovnica", + "writing": "Pisanje", + "alarm": "Alarm", + "telephone": "Telefon", + "telephone_bell_ringing": "Zvono telefona", + "ringtone": "Ton za poziv", + "telephone_dialing": "Pozivanje telefona", + "dial_tone": "Ton za poziv", + "busy_signal": "Signal zauzetosti", + "alarm_clock": "Budilica", + "siren": "Sirena", + "civil_defense_siren": "Sirena za civilnu zaštitu", + "buzzer": "Buzer", + "smoke_detector": "Detektor dima", + "fire_alarm": "Pozar alarm", + "foghorn": "Mlazni svirac", + "whistle": "Štiklja", + "steam_whistle": "Parni zvono", + "mechanisms": "Mehanizmi", + "ratchet": "Ratchet", + "clock": "Sat", + "tick": "Tik", + "tick-tock": "Tik-tak", + "gears": "Zupčanici", + "pulleys": "Koturači", + "sewing_machine": "Šitna mašina", + "mechanical_fan": "Mehanički ventilator", + "air_conditioning": "Klima uređaj", + "cash_register": "Gotovinska kasica", + "printer": "Štampač", + "camera": "Kamera", + "single-lens_reflex_camera": "Kamera s jednim objektivom", + "tools": "Alati", + "hammer": "Klubica", + "jackhammer": "Betonomijak", + "sawing": "Sečenje", + "filing": "Flešanje", + "sanding": "Šljokanje", + "power_tool": "Električni alat", + "drill": "Bušilica", + "explosion": "Eksplozija", + "gunshot": "Pucanj", + "machine_gun": "Automatska puška", + "fusillade": "Fusiladža", + "artillery_fire": "Pucanj topovima", + "cap_gun": "Pistolj za pucanje", + "fireworks": "Pucanje svjetiljki", + "firecracker": "Svjetiljka", + "burst": "Izbič", + "eruption": "Eruptija", + "boom": "Tutnjava", + "wood": "Drvo", + "chop": "Rezanje", + "splinter": "Razlomak", + "crack": "Klackanje", + "glass": "Staklo", + "chink": "Prozor", + "shatter": "Razbijanje", + "silence": "Tišina", + "sound_effect": "Zvučni efekt", + "environmental_noise": "Okolišni šum", + "static": "Statički šum", + "white_noise": "Bijeli šum", + "pink_noise": "Rumeni šum", + "television": "Televizija", + "radio": "Radio", + "field_recording": "Snimka na terenu", + "scream": "Vrisak", + "sodeling": "Sodeling", + "chird": "Chird", + "change_ringing": "Promjena zvona", + "shofar": "Šofar", + "liquid": "Tekućina", + "splash": "Pljuskanje", + "slosh": "Sloš", + "squish": "Škripanje", + "drip": "Kapanje", + "pour": "Prelivanje", + "trickle": "Tijek", + "gush": "Gusenje", + "fill": "Popunjavanje", + "spray": "Sprajanje", + "pump": "Pumpa", + "stir": "Miješanje", + "boiling": "Vrećenje", + "sonar": "Sonar", + "arrow": "Strela", + "whoosh": "Šum", + "thump": "Tupanje", + "thunk": "Tunk", + "electronic_tuner": "Elektronski tuner", + "effects_unit": "Jedinica efekata", + "chorus_effect": "Efekt korusa", + "basketball_bounce": "Košarkaški skok", + "bang": "Bum", + "slap": "Pljeska", + "whack": "Perc", + "smash": "Sprem", + "breaking": "Raskidanje", + "bouncing": "Skakanje", + "whip": "Škripanje", + "flap": "Klizanje", + "scratch": "Oštećenje", + "scrape": "Prašenje", + "rub": "Trenje", + "roll": "Kotrljanje", + "crushing": "Stiskanje", + "crumpling": "Sklapanje", + "tearing": "Raskidanje", + "beep": "Bip", + "ping": "Poziv", + "ding": "Ding", + "clang": "Zveket", + "squeal": "Cika", + "creak": "Škripa", + "rustle": "Šuškanje", + "whir": "Brujanje", + "clatter": "Tropot", + "sizzle": "Šištanje", + "clicking": "Klikanje", + "clickety_clack": "Klik-tak", + "rumble": "Rumbljanje", + "plop": "Pljus", + "hum": "Pjevušenje", + "zing": "Zing", + "boing": "Boing", + "crunch": "Crunch", + "sine_wave": "Sinusna valna", + "harmonic": "Harmonični", + "chirp_tone": "Tanjirasti ton", + "pulse": "Impuls", + "inside": "Unutra", + "outside": "Van", + "reverberation": "Reverberacija", + "echo": "Odjek", + "noise": "Šum", + "mains_hum": "Glavni šum", + "distortion": "Distorzija", + "sidetone": "Sidetone", + "cacophony": "Kacofonija", + "throbbing": "Tremor", + "vibration": "Vibracija" +} diff --git a/web/public/locales/bs/common.json b/web/public/locales/bs/common.json new file mode 100644 index 0000000000..dba59d2091 --- /dev/null +++ b/web/public/locales/bs/common.json @@ -0,0 +1,326 @@ +{ + "time": { + "untilForTime": "Do {{time}}", + "untilForRestart": "Do ponovnog pokretanja Frigate.", + "untilRestart": "Do ponovnog pokretanja", + "never": "Nikad", + "ago": "{{timeAgo}} prije", + "justNow": "Sada", + "today": "Danas", + "yesterday": "Jučer", + "last7": "Prošlih 7 dana", + "last14": "Prošlih 14 dana", + "last30": "Prošlih 30 dana", + "thisWeek": "Ova sedmica", + "lastWeek": "Prošla sedmica", + "thisMonth": "Ovaj mjesec", + "lastMonth": "Prošli mjesec", + "5minutes": "5 minuta", + "10minutes": "10 minuta", + "30minutes": "30 minuta", + "1hour": "1 sat", + "12hours": "12 sati", + "24hours": "24 sata", + "pm": "posle podne", + "am": "pre podne", + "yr": "{{time}} god", + "year_one": "{{time}} godina", + "year_few": "{{time}} godine", + "year_other": "{{time}} godina", + "mo": "{{time}} mjes", + "month_one": "{{time}} mjesec", + "month_few": "{{time}} mjeseca", + "month_other": "{{time}} mjeseci", + "d": "{{time}}d", + "day_one": "{{time}} dan", + "day_few": "{{time}} dana", + "day_other": "{{time}} dana", + "h": "{{time}}h", + "hour_one": "{{time}} sat", + "hour_few": "{{time}} sata", + "hour_other": "{{time}} sati", + "m": "{{time}}m", + "minute_one": "{{time}} minuta", + "minute_few": "{{time}} minute", + "minute_other": "{{time}} minuta", + "s": "{{time}}s", + "second_one": "{{time}} sekunda", + "second_few": "{{time}} sekunde", + "second_other": "{{time}} sekundi", + "formattedTimestamp": { + "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" + }, + "inProgress": "U toku", + "invalidStartTime": "Neispravno početno vrijeme", + "invalidEndTime": "Neispravno krajnje vrijeme" + }, + "unit": { + "speed": { + "mph": "mph", + "kph": "kph" + }, + "length": { + "feet": "fut", + "meters": "metar" + }, + "data": { + "kbps": "kB/s", + "mbps": "MB/s", + "gbps": "GB/s", + "kbph": "kB/hour", + "mbph": "MB/hour", + "gbph": "GB/hour" + } + }, + "label": { + "back": "Povratak", + "hide": "Sakrij {{item}}", + "show": "Prikaži {{item}}", + "ID": "ID", + "none": "Nijedan", + "all": "Sve", + "other": "Ostalo" + }, + "list": { + "two": "{{0}} i {{1}}", + "many": "{{items}}, i {{last}}", + "separatorWithSpace": ", " + }, + "field": { + "optional": "Opcionalno", + "internalID": "Unutarnji ID koji Frigate koristi u konfiguraciji i bazi podataka" + }, + "button": { + "add": "Dodaj", + "apply": "Primijeni", + "applying": "Primjenjuje se…", + "reset": "Resetuj", + "undo": "Poništi", + "done": "Gotovo", + "enabled": "Omogućeno", + "enable": "Omogući", + "disabled": "Onemogućeno", + "disable": "Onemogući", + "save": "Sačuvaj", + "saving": "Sačuvanje…", + "cancel": "Otkaži", + "close": "Zatvori", + "copy": "Kopiraj", + "copiedToClipboard": "Kopirano u međuspremnik", + "back": "Nazad", + "history": "Historija", + "fullscreen": "Pun ekran", + "exitFullscreen": "Napusti pun ekran", + "pictureInPicture": "Slika u slici", + "twoWayTalk": "Dvostrani razgovor", + "cameraAudio": "Zvuk kamere", + "on": "Uključeno", + "off": "Isključeno", + "edit": "Uredi", + "copyCoordinates": "Kopiraj koordinate", + "delete": "Obriši", + "yes": "Da", + "no": "Ne", + "download": "Preuzmi", + "info": "Informacija", + "suspended": "Otkazano", + "unsuspended": "Ponovi", + "play": "Reproduciraj", + "unselect": "Odznači", + "export": "Izvoz", + "deleteNow": "Obriši sada", + "next": "Sljedeće", + "continue": "Nastavi", + "modified": "Izmijenjeno", + "overridden": "Preklopljeno", + "resetToGlobal": "Vrati na globalno", + "resetToDefault": "Vrati na podrazumijevano", + "saveAll": "Sačuvaj sve", + "savingAll": "Sačuvanje svih…", + "undoAll": "Poništi sve", + "retry": "Pokušaj ponovno" + }, + "menu": { + "system": "Sistem", + "systemMetrics": "Sistem metrike", + "configuration": "Konfiguracija", + "systemLogs": "Sistemski zapisi", + "profiles": "Profili", + "settings": "Postavke", + "configurationEditor": "Uređivač konfiguracije", + "languages": "Jezici", + "language": { + "en": "Engleski (English)", + "es": "Španjolski (Spanish)", + "zhCN": "Jednostavni kineski (Simplified Chinese)", + "hi": "Hindi (Hindi)", + "fr": "Francuski (French)", + "ar": "Arapski (Arabic)", + "pt": "Portugalski (Portuguese)", + "ptBR": "Portugalski brazilski (Brazilian Portuguese)", + "ru": "Ruski (Russian)", + "de": "Nemački (German)", + "ja": "Japanski (Japanese)", + "tr": "Turski (Turkish)", + "it": "Talijanski (Italian)", + "nl": "Nizozemski (Dutch)", + "sv": "Švedski (Swedish)", + "cs": "Češki (Czech)", + "nb": "Norveški bokmål (Norwegian Bokmål)", + "ko": "Koreanski (Korean)", + "vi": "Vietnamski (Vietnamese)", + "fa": "Perzijski (Persian)", + "pl": "Polski (Poljski)", + "uk": "Українська (Ukrajinski)", + "he": "עברית (Hebrejski)", + "el": "Ελληνικά (Grčki)", + "ro": "Română (Romunski)", + "hu": "Magyar (Mađarski)", + "fi": "Suomi (Finski)", + "da": "Dansk (Danski)", + "sk": "Slovenčina (Slovački)", + "yue": "粵語 (Kantonski)", + "th": "ไทย (Tajski)", + "ca": "Català (Katalonski)", + "hr": "Hrvatski (Hrvatski)", + "sr": "Српски (Srpski)", + "sl": "Slovenščina (Slovenski)", + "lt": "Lietuvių (Lietuvių)", + "bg": "Български (Bugarinski)", + "gl": "Galego (Galicijski)", + "id": "Bahasa Indonesia (Indoneziski)", + "ur": "اردو (Urdu)", + "withSystem": { + "label": "Koristite postavke sistema za jezik" + } + }, + "appearance": "Izgled", + "darkMode": { + "label": "Tamni režim", + "light": "Svijetla", + "dark": "Tamna", + "withSystem": { + "label": "Koristite postavke sistema za svjetlosni ili tamni režim" + } + }, + "withSystem": "Sistem", + "theme": { + "label": "Tema", + "blue": "Plava", + "green": "Zelena", + "nord": "Nord", + "red": "Crvena", + "highcontrast": "Visok kontrast", + "default": "Zadano" + }, + "help": "Pomoć", + "documentation": { + "title": "Dokumentacija", + "label": "Dokumentacija za Frigate" + }, + "restart": "Ponovno pokreni Frigate", + "live": { + "title": "Uživo", + "allCameras": "Sve Kamere", + "cameras": { + "title": "Kamere", + "count_one": "{{count}} Kamera", + "count_few": "{{count}} Kamere", + "count_other": "{{count}} Kamere" + } + }, + "review": "Pregled", + "explore": "Istraži", + "export": "Izvoz", + "actions": "Akcije", + "uiPlayground": "UI Playground", + "features": "Funkcije", + "faceLibrary": "Biblioteka lica", + "classification": "Klasifikacija", + "chat": "Razgovor", + "user": { + "title": "Korisnik", + "account": "Račun", + "current": "Trenutni korisnik: {{user}}", + "anonymous": "anons", + "logout": "Odjava", + "setPassword": "Postavi lozinku" + } + }, + "toast": { + "copyUrlToClipboard": "URL kopiran u međuspremnik.", + "save": { + "title": "Sačuvaj", + "error": { + "title": "Nije uspješno sačuvana promjena konfiguracije: {{errorMessage}}", + "noMessage": "Nije uspješno sačuvana promjena konfiguracije" + }, + "success": "Uspješno sačuvana promjena konfiguracije." + } + }, + "role": { + "title": "Uloga", + "admin": "Administrator", + "viewer": "Pregledač", + "desc": "Admini imaju pun pristup svim funkcijama u korisničkom sučelju Frigate. Pregledači su ograničeni na pregled kamere, pregled stavki i povijesne snimke u korisničkom sučelju." + }, + "pagination": { + "label": "paginacija", + "previous": { + "title": "Prethodno", + "label": "Idi na prethodnu stranicu" + }, + "next": { + "title": "Sljedeće", + "label": "Idi na sljedeću stranicu" + }, + "more": "Više stranica" + }, + "accessDenied": { + "documentTitle": "Pristup odbijen - Frigate", + "title": "Pristup odbijen", + "desc": "Nemate dozvolu za pregled ove stranice." + }, + "notFound": { + "documentTitle": "Nije pronađeno - Frigate", + "title": "404", + "desc": "Stranica nije pronađena" + }, + "selectItem": "Odaberite {{item}}", + "readTheDocumentation": "Pročitajte dokumentaciju", + "information": { + "pixels": "{{area}}px" + }, + "no_items": "Nema stavki", + "validation_errors": "Greške validacije" +} diff --git a/web/public/locales/bs/components/auth.json b/web/public/locales/bs/components/auth.json new file mode 100644 index 0000000000..42bac9b61d --- /dev/null +++ b/web/public/locales/bs/components/auth.json @@ -0,0 +1,16 @@ +{ + "form": { + "user": "Korisničko ime", + "password": "Lozinka", + "login": "Prijava", + "firstTimeLogin": "Pokušavate se prijaviti prvi put? Vjerodajnice su ispisane u logovima Frigate.", + "errors": { + "usernameRequired": "Korisničko ime je obavezno", + "passwordRequired": "Lozinka je obavezna", + "rateLimit": "Premašen je limit brzine. Pokušajte kasnije.", + "loginFailed": "Prijava nije uspješna", + "unknownError": "Nepoznata greška. Provjerite zapise.", + "webUnknownError": "Nepoznata greška. Provjerite konzolne zapise." + } + } +} diff --git a/web/public/locales/bs/components/camera.json b/web/public/locales/bs/components/camera.json new file mode 100644 index 0000000000..68c8fdfa78 --- /dev/null +++ b/web/public/locales/bs/components/camera.json @@ -0,0 +1,87 @@ +{ + "group": { + "label": "Grupe kamere", + "add": "Dodaj grupu kamere", + "edit": "Uredi grupu kamera", + "delete": { + "label": "Obriši grupu kamere", + "confirm": { + "title": "Potvrdi brisanje", + "desc": "Sigurno li želite da obrišete grupu kamere {{name}}?" + } + }, + "name": { + "label": "Ime", + "placeholder": "Unesite ime…", + "errorMessage": { + "mustLeastCharacters": "Ime grupe kamere mora imati najmanje 2 karaktera.", + "exists": "Ime grupe kamere već postoji.", + "nameMustNotPeriod": "Ime grupe kamere ne smije sadržavati tačku.", + "invalid": "Neispravno ime grupe kamere." + } + }, + "cameras": { + "label": "Kamere", + "desc": "Odaberite kamere za ovu grupu." + }, + "icon": "Ikona", + "success": "Grupa kamere ({{name}}) je sačuvana.", + "camera": { + "birdseye": "Birdseye", + "setting": { + "label": "Postavke prenošenja kamere", + "title": "Postavke prenošenja {{cameraName}}", + "desc": "Promijenite opcije uživo prenošenja za tablicu upravljanja ove grupe kamere. Ove postavke su specifične za uređaj/pretvarač.", + "audioIsAvailable": "Audio je dostupan za ovaj stream", + "audioIsUnavailable": "Zvuk nije dostupan za ovaj tok", + "audio": { + "tips": { + "title": "Audio mora biti izlaz iz vaše kamere i konfiguriran u go2rtc za ovaj stream." + } + }, + "stream": "Tok", + "placeholder": "Odaberite tok", + "streamMethod": { + "label": "Način prenošenja", + "placeholder": "Odaberite način prenošenja", + "method": { + "noStreaming": { + "label": "Bez prenošenja", + "desc": "Slike kamere će se ažurirati samo jednom na minut i neće se dogoditi uživo prenošenje." + }, + "smartStreaming": { + "label": "Pametno prenošenje (preporučeno)", + "desc": "Pametno prenošenje će ažurirati sliku kamere jednom na minut kada se ne događa detektovana aktivnost kako bi se uštedjelo na širovini i resursima. Kada se detektuje aktivnost, slika se glatko prebacuje u uživo prenošenje." + }, + "continuousStreaming": { + "label": "Neprekidno prenošenje", + "desc": { + "title": "Slika kamere uvijek će biti živo prenošenje kada je vidljiva na ploči, čak i ako se ne detektira aktivnost.", + "warning": "Neprekidno prenošenje može uzrokovati visoku upotrebu širine pojasa i probleme s performansama. Koristite s oprezom." + } + } + } + }, + "compatibilityMode": { + "label": "Režim kompatibilnosti", + "desc": "Omogućite ovu opciju samo ako se živo prenošenje vaše kamere prikazuje s bojnim artefaktima i dijagonalnom linijom na desnoj strani slike." + } + } + } + }, + "debug": { + "options": { + "label": "Postavke", + "title": "Opcije", + "showOptions": "Prikaži opcije", + "hideOptions": "Sakrij opcije" + }, + "boundingBox": "Okvir", + "timestamp": "Vremenski pečat", + "zones": "Zone", + "mask": "Maska", + "motion": "Kretanje", + "regions": "Regije", + "paths": "Putanje" + } +} diff --git a/web/public/locales/bs/components/dialog.json b/web/public/locales/bs/components/dialog.json new file mode 100644 index 0000000000..95f4adad24 --- /dev/null +++ b/web/public/locales/bs/components/dialog.json @@ -0,0 +1,197 @@ +{ + "restart": { + "title": "Sigurni li ste da želite ponovno pokrenuti Frigate?", + "description": "Ovo privremeno zaustavi Frigate dok se ponovno pokreće.", + "button": "Ponovno pokretanje", + "restarting": { + "title": "Frigate se ponovo pokreće", + "content": "Ova stranica će se ponovno učitati za {{countdown}} sekundi.", + "button": "Silovito ponovno učitavanje sada" + } + }, + "explore": { + "plus": { + "submitToPlus": { + "label": "Pošalji na Frigate+", + "desc": "Predmeti u lokacijama koje želite izbjeći nisu lažni pozitivi. Pošiljanje ih kao lažne pozitive zbunjuje model." + }, + "review": { + "question": { + "label": "Potvrdite ovu oznaku za Frigate Plus", + "ask_a": "Je li ovaj objekt {{label}}?", + "ask_an": "Je li ovaj objekt {{label}}?", + "ask_full": "Je li ovaj objekt {{untranslatedLabel}} ({{translatedLabel}})?" + }, + "state": { + "submitted": "Pošlato" + } + } + }, + "video": { + "viewInHistory": "Pregledajte u povijesti" + } + }, + "export": { + "time": { + "fromTimeline": "Odaberite iz vremenske linije", + "lastHour_one": "Prošli sat", + "lastHour_few": "Prošla {{count}} sata", + "lastHour_other": "Prošlih {{count}} sati", + "custom": "Prilagođeno", + "start": { + "title": "Vrijeme početka", + "label": "Odaberite vrijeme početka" + }, + "end": { + "title": "Vrijeme kraja", + "label": "Odaberite vrijeme kraja" + } + }, + "name": { + "placeholder": "Nazovite izvoz" + }, + "case": { + "newCaseOption": "Napravite novi slučaj", + "newCaseNamePlaceholder": "Novo ime slučaja", + "newCaseDescriptionPlaceholder": "Opis slučaja", + "label": "Slučaj", + "nonAdminHelp": "Za ove izvoze će se stvoriti novi slučaj.", + "placeholder": "Odaberite slučaj" + }, + "select": "Odaberite", + "export": "Izvoz", + "queueing": "Stavljanje izvoza u red...", + "selectOrExport": "Odaberite ili izvozite", + "tabs": { + "export": "Jedna kamera", + "multiCamera": "Više kamera" + }, + "multiCamera": { + "timeRange": "Vremenski opseg", + "selectFromTimeline": "Odaberite iz vremenske linije", + "cameraSelection": "Kamere", + "cameraSelectionHelp": "Kamere s praćenim objektima u ovom vremenskom opsegu su preselektirane", + "checkingActivity": "Provjeravamo aktivnost kamere...", + "noCameras": "Nema dostupnih kamera", + "detectionCount_one": "1 praćen objekt", + "detectionCount_few": "{{count}} praćena objekta", + "detectionCount_other": "{{count}} praćenih objekata", + "nameLabel": "Ime izvoza", + "namePlaceholder": "Nepovlačenje baznog imena za ove izvoze", + "queueingButton": "Stavljanje izvoza u red...", + "exportButton_one": "Izvoz 1 kamere", + "exportButton_few": "Izvoz {{count}} kamere", + "exportButton_other": "Izvoz {{count}} kamera" + }, + "multi": { + "title_one": "Izvoz 1 pregleda", + "title_few": "Izvoz {{count}} pregleda", + "title_other": "Izvoz {{count}} pregleda", + "description": "Izvoz svakog odabranih pregleda. Svi izvozi bit će grupirani pod jedan slučaj.", + "descriptionNoCase": "Izvoz svakog odabranih pregleda.", + "caseNamePlaceholder": "Pregled izvoza - {{date}}", + "exportButton_one": "Izvoz 1 pregleda", + "exportButton_few": "Izvoz {{count}} pregleda", + "exportButton_other": "Izvoz {{count}} pregleda", + "exportingButton": "Izvoz...", + "toast": { + "started_one": "Pokrenut 1 izvoz. Otvaranje slučaja sada.", + "started_few": "Pokrenuta {{count}} izvoza. Otvaranje slučaja sada.", + "started_other": "Pokrenuto {{count}} izvoza. Otvaranje slučaja sada.", + "startedNoCase_one": "Pokrenut 1 izvoz.", + "startedNoCase_few": "Pokrenuta {{count}} izvoza.", + "startedNoCase_other": "Pokrenuto {{count}} izvoza.", + "partial": "Pokrenuto {{successful}} od {{total}} izvoza. Neuspješno: {{failedItems}}", + "failed": "Neuspješno pokretanje {{total}} izvoza. Neuspješno: {{failedItems}}" + } + }, + "toast": { + "success": "Uspješno pokrenut izvoz. Pregledajte datoteku na stranici izvoza.", + "queued": "Izvoz u redu. Pregledajte napredak na stranici izvoza.", + "view": "Pregled", + "batchSuccess_one": "Pokrenut 1 izvoz. Otvaranje slučaja sada.", + "batchSuccess_few": "Pokrenuta {{count}} izvoza. Otvaranje slučaja sada.", + "batchSuccess_other": "Pokrenuto {{count}} izvoza. Otvaranje slučaja sada.", + "batchPartial": "Pokrenuto {{successful}} od {{total}} izvoza. Neuspješne kamere: {{failedCameras}}", + "batchFailed": "Neuspješno pokretanje {{total}} izvoza. Neuspješne kamere: {{failedCameras}}", + "batchQueuedSuccess_one": "U red stavljen 1 izvoz. Otvaranje slučaja sada.", + "batchQueuedSuccess_few": "U red stavljena {{count}} izvoza. Otvaranje slučaja sada.", + "batchQueuedSuccess_other": "U red stavljeno {{count}} izvoza. Otvaranje slučaja sada.", + "batchQueuedPartial": "U redu {{successful}} od {{total}} izvoza. Neuspješne kamere: {{failedCameras}}", + "batchQueueFailed": "Neuspješno dodavanje {{total}} izvoza. Neuspješne kamere: {{failedCameras}}", + "error": { + "failed": "Neuspješno dodavanje izvoza: {{error}}", + "endTimeMustAfterStartTime": "Krajnje vrijeme mora biti nakon početnog vremena", + "noVaildTimeSelected": "Nije odabran valjan vremenski opseg" + } + }, + "fromTimeline": { + "saveExport": "Sačuvaj izvoz", + "queueingExport": "Kopiranje izvoza...", + "previewExport": "Pregled izvoza", + "useThisRange": "Koristi ovaj opseg" + } + }, + "streaming": { + "label": "Tok", + "restreaming": { + "disabled": "Restreaming nije omogućeno za ovu kameru.", + "desc": { + "title": "Postavite go2rtc za dodatne opcije uživog pregleda i zvuk za ovu kameru." + } + }, + "showStats": { + "label": "Prikaži statistiku strima", + "desc": "Omogući ovu opciju da prikaže statistiku prijenosa kao preklapanje na toku kamere." + }, + "debugView": "Pregled za otklanjanje grešaka" + }, + "search": { + "saveSearch": { + "label": "Sačuvaj pretragu", + "desc": "Navedite ime za ovu sačuvanu pretragu.", + "placeholder": "Unesite ime za svoju pretragu", + "overwrite": "{{searchName}} već postoji. Sačuvavanje će prebrisati postojet će vrijednost.", + "success": "Pretraga ({{searchName}}) je sačuvana.", + "button": { + "save": { + "label": "Sačuvaj ovu pretragu" + } + } + } + }, + "recording": { + "shareTimestamp": { + "label": "Dijeli vremensku oznaku", + "title": "Dijeli vremensku oznaku", + "description": "Dijelite URL označen vremenom trenutne pozicije igrača ili odaberite prilagođenu vremensku oznaku. Napomena: ovo nije javni URL za dijeljenje i dostupan je samo korisnicima koji imaju pristup Frigate i ovoj kameri.", + "custom": "Prilagođena vremenska oznaka", + "button": "URL za dijeljenje vremenske oznake", + "shareTitle": "Vremenska oznaka pregleda Frigate: {{camera}}" + }, + "confirmDelete": { + "title": "Potvrdi brisanje", + "desc": { + "selected": "Sigurni li ste da želite izbrisati sve snimljeno video povezano s ovim preglednim stavkom?

Zadržite tipku Shift da biste preskočili ovaj dijalog u budućnosti." + }, + "toast": { + "success": "Video snimke povezane s odabranim preglednim stavcima uspješno su izbrisane.", + "error": "Neuspješno brisanje: {{error}}" + } + }, + "button": { + "export": "Izvoz", + "markAsReviewed": "Označi kao pregledano", + "markAsUnreviewed": "Označi kao nepregledano", + "deleteNow": "Obriši sada" + } + }, + "imagePicker": { + "selectImage": "Odaberite minijaturu praćenog objekta", + "unknownLabel": "Sačuvana slika izazivača", + "search": { + "placeholder": "Pretraga po oznaci ili podoznaci..." + }, + "noImages": "Nema mini prikaza za ovu kameru" + } +} diff --git a/web/public/locales/bs/components/filter.json b/web/public/locales/bs/components/filter.json new file mode 100644 index 0000000000..7578235133 --- /dev/null +++ b/web/public/locales/bs/components/filter.json @@ -0,0 +1,140 @@ +{ + "filter": "Filtar", + "classes": { + "label": "Klase", + "all": { + "title": "Sve klase" + }, + "count_one": "{{count}} Klasa", + "count_other": "{{count}} Klase" + }, + "labels": { + "label": "Oznake", + "all": { + "title": "Sve oznake", + "short": "Oznake" + }, + "count_one": "{{count}} Oznaka", + "count_other": "{{count}} Oznake" + }, + "zones": { + "label": "Zone", + "all": { + "title": "Sve zone", + "short": "Zone" + } + }, + "dates": { + "selectPreset": "Odaberite predpostavku…", + "all": { + "title": "Svi datumi", + "short": "Datumi" + } + }, + "more": "Više filtera", + "reset": { + "label": "Poništi filtere na zadane vrijednosti" + }, + "timeRange": "Vremenski opseg", + "subLabels": { + "label": "Podoznake", + "all": "Sve podoznake" + }, + "attributes": { + "label": "Atributi klasifikacije", + "all": "Svi atributi" + }, + "score": "Rezultat", + "estimatedSpeed": "Procijenjena brzina ({{unit}})", + "features": { + "label": "Funkcije", + "hasSnapshot": "Ima snimak", + "hasVideoClip": "Ima video zapis", + "submittedToFrigatePlus": { + "label": "Predano Frigate+", + "tips": "Prvo morate filtrirati prateće objekte koji imaju snimak.

Prateći objekti bez snimka ne mogu se poslati na Frigate+." + } + }, + "sort": { + "label": "Sortiraj", + "dateAsc": "Datum (Uzlazno)", + "dateDesc": "Datum (Silazno)", + "scoreAsc": "Ocjena objekta (Uzlazno)", + "scoreDesc": "Ocjena objekta (Silazno)", + "speedAsc": "Procijenjena brzina (Uzlazno)", + "speedDesc": "Procijenjena brzina (Silazno)", + "relevance": "Relevantnost" + }, + "cameras": { + "label": "Filter kamere", + "all": { + "title": "Sve Kamere", + "short": "Kamere" + } + }, + "review": { + "showReviewed": "Prikaži pregledane" + }, + "motion": { + "showMotionOnly": "Prikaži samo pokret" + }, + "explore": { + "settings": { + "title": "Postavke", + "defaultView": { + "title": "Zadani prikaz", + "desc": "Kada nisu odabrani filteri, prikazuje se sažetak najnovijih pratećih objekata po oznaci, ili prikazuje se mreža bez filtriranja.", + "summary": "Sažetak", + "unfilteredGrid": "Mreža bez filtriranja" + }, + "gridColumns": { + "title": "Kolone mreže", + "desc": "Odaberite broj kolona u prikazu mreže." + }, + "searchSource": { + "label": "Izvor pretrage", + "desc": "Odaberite da li ćete pretraživati miniaturne slike ili opise vaših praćenih objekata.", + "options": { + "thumbnailImage": "Miniaturna slika", + "description": "Opis" + } + } + }, + "date": { + "selectDateBy": { + "label": "Odaberite datum za filtriranje" + } + } + }, + "logSettings": { + "label": "Filtrirajte nivo zapisa", + "filterBySeverity": "Filtrirajte zapise prema ozbiljnosti", + "loading": { + "title": "Učitavanje", + "desc": "Kada se panel zapisa pomakne do dna, novi zapisi automatski se prikazuju kada se dodaju." + }, + "disableLogStreaming": "Onemogući praćenje zapisa", + "allLogs": "Svi zapisi" + }, + "trackedObjectDelete": { + "title": "Potvrdi brisanje", + "desc": "Brisanje ovih {{objectLength}} praćenih objekata uklanja snimku, bilo koje sačuvane ugradnje, i sve povezane uloge objekata. Snimljeni materijal ovih praćenih objekata u pogledu Historija NEĆE biti obrisan.

Sigurni ste da želite nastaviti?

Zadržite tipku Shift da biste preskočili ovaj dijalog u budućnosti.", + "toast": { + "success": "Praćeni objekti uspješno obrisani.", + "error": "Neuspješno brisanje praćenih objekata: {{errorMessage}}" + } + }, + "zoneMask": { + "filterBy": "Filtriraj po maski zone" + }, + "recognizedLicensePlates": { + "title": "Prepoznate tablice", + "loadFailed": "Neuspješno učitavanje prepoznatih tablica.", + "loading": "Učitavanje prepoznatih tablica…", + "placeholder": "Unesite za pretragu tablica…", + "noLicensePlatesFound": "Nema pronađenih tablica.", + "selectPlatesFromList": "Odaberite jednu ili više tablica iz liste.", + "selectAll": "Odaberite sve", + "clearAll": "Očistite sve" + } +} diff --git a/web/public/locales/bs/components/icons.json b/web/public/locales/bs/components/icons.json new file mode 100644 index 0000000000..807c1d29f6 --- /dev/null +++ b/web/public/locales/bs/components/icons.json @@ -0,0 +1,8 @@ +{ + "iconPicker": { + "selectIcon": "Odaberite ikonu", + "search": { + "placeholder": "Pretražite ikonu…" + } + } +} diff --git a/web/public/locales/bs/components/input.json b/web/public/locales/bs/components/input.json new file mode 100644 index 0000000000..03e33fb6d5 --- /dev/null +++ b/web/public/locales/bs/components/input.json @@ -0,0 +1,10 @@ +{ + "button": { + "downloadVideo": { + "label": "Preuzimanje videa", + "toast": { + "success": "Vaš video stavke pregleda je započelo preuzimanje." + } + } + } +} diff --git a/web/public/locales/bs/components/player.json b/web/public/locales/bs/components/player.json new file mode 100644 index 0000000000..3056608f08 --- /dev/null +++ b/web/public/locales/bs/components/player.json @@ -0,0 +1,52 @@ +{ + "noRecordingsFoundForThisTime": "Nisu pronađeni snimci za ovo vrijeme", + "noPreviewFound": "Nije pronađen pregled", + "noPreviewFoundFor": "Nije pronađen pregled za {{cameraName}}", + "submitFrigatePlus": { + "title": "Pošalji ovaj okvir Frigate+?", + "submit": "Pošalji", + "previewError": "Nije moguće učitati prikaz snimke. Snimka možda trenutno nije dostupna." + }, + "livePlayerRequiredIOSVersion": "Za ovaj tip uživo prijenosa potreban je iOS 17.1 ili noviji.", + "streamOffline": { + "title": "Prijenos je offline", + "desc": "Nisu primljeni okviri na {{cameraName}} detect prijenos, provjerite zapise o greškama" + }, + "cameraDisabled": "Kamera je onemogućena", + "stats": { + "streamType": { + "title": "Tip prijenosa:", + "short": "Tip" + }, + "bandwidth": { + "title": "Širina pojasa:", + "short": "Širina pojasa" + }, + "latency": { + "title": "Kasnjenje:", + "value": "{{seconds}} sekundi", + "short": { + "title": "Kasnjenje", + "value": "{{seconds}} sek" + } + }, + "totalFrames": "Ukupno okvira:", + "droppedFrames": { + "title": "Izgubljeni okviri:", + "short": { + "title": "Izgubljeni", + "value": "{{droppedFrames}} okvira" + } + }, + "decodedFrames": "Dekodirani okviri:", + "droppedFrameRate": "Stopa izgubljenih okvira:" + }, + "toast": { + "success": { + "submittedFrigatePlus": "Uspješno je poslano okvir Frigate+" + }, + "error": { + "submitFrigatePlusFailed": "Neuspješno slanje okvira Frigate+" + } + } +} diff --git a/web/public/locales/bs/config/cameras.json b/web/public/locales/bs/config/cameras.json new file mode 100644 index 0000000000..97da5a3793 --- /dev/null +++ b/web/public/locales/bs/config/cameras.json @@ -0,0 +1,949 @@ +{ + "label": "KameraKonfig", + "zones": { + "label": "Zone", + "description": "Zona omogućava da definirate specifičnu područje okvira da biste odredili je li objekt unutar određenog područja.", + "friendly_name": { + "label": "Ime zone", + "description": "Korisničko ime za zonu, prikazano u UI Frigate. Ako nije postavljeno, koristi se oblikovana verzija imena zone." + }, + "enabled": { + "label": "Omogućeno", + "description": "Omogući ili onemogući ovu zonu. Onemogućene zone zanemaruju se tijekom izvršavanja." + }, + "enabled_in_config": { + "label": "Zapamti originalno stanje zone." + }, + "filters": { + "label": "Filtri zone", + "description": "Filtri za primjenu na objekte unutar ove zone. Koriste se za smanjenje lažnih pozitiva ili ograničavanje kojih objekata se smatraju prisutnim u zoni.", + "min_area": { + "label": "Minimalna površina objekta", + "description": "Minimalna površina okvira (pikseli ili postotak) potrebna za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)." + }, + "max_area": { + "label": "Maksimalna površina objekta", + "description": "Maksimalna površina okvira (pikseli ili postotak) dozvoljena za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)." + }, + "min_ratio": { + "label": "Minimalni omjer visine/širine", + "description": "Minimalni omjer širine/visine potreban da bi okvir bio prihvaćen." + }, + "max_ratio": { + "label": "Maksimalni omjer visine/širine", + "description": "Maksimalni omjer širine/visine dozvoljen da bi okvir bio prihvaćen." + }, + "threshold": { + "label": "Prag pouzdanosti", + "description": "Prosjek pragova pouzdanosti detekcije potreban da bi objekt bio smatravan pravim pozitivom." + }, + "min_score": { + "label": "Minimalna pouzdanost", + "description": "Minimalna pouzdanost detekcije po okviru potrebna da bi objekt bio brojan." + }, + "mask": { + "label": "Maska filtriranja", + "description": "Koordinate poligona koje definiraju područje na kojem se ovaj filter primjenjuje unutar okvira." + }, + "raw_mask": { + "label": "Ručna maska" + } + }, + "coordinates": { + "label": "Koordinate", + "description": "Koordinate poligona koje definiraju područje zone. Može biti niz razdvojen zarezom ili lista nizova koordinata. Koordinate trebaju biti relativne (0-1) ili apsolutne (stariji format)." + }, + "distances": { + "label": "Stvarne udaljenosti", + "description": "Nepovlačni stvarne udaljenosti za svaku stranu kvadrilateralne zone, koristi se za izračun brzine ili udaljenosti. Moraju imati tačno 4 vrijednosti ako su postavljene." + }, + "inertia": { + "label": "Okviri inertnosti", + "description": "Broj uzastopnih okvira u kojima mora biti detektovan objekt u zoni da bi bio smatravan prisutnim. Pomaže u filtriranju privremenih detekcija." + }, + "loitering_time": { + "label": "Sekunde loiteranja", + "description": "Broj sekundi koje objekt mora ostati u zoni da bi bio smatravan loiteranjem. Postaviti na 0 za onemogućavanje detekcije loiteranja." + }, + "speed_threshold": { + "label": "Minimalna brzina", + "description": "Minimalna brzina (u stvarnim jedinicama ako su udaljenosti postavljene) potrebna da bi objekt bio smatravan prisutnim u zoni. Koristi se za zone koje se aktiviraju na osnovu brzine." + }, + "objects": { + "label": "Objekti koji izazivaju", + "description": "Lista tipova objekata (iz labelmapa) koji mogu izazvati ovu zonu. Može biti niz ili lista nizova. Ako je prazna, svi objekti se uzimaju u obzir." + } + }, + "name": { + "label": "Ime kamere", + "description": "Ime kamere je obavezno" + }, + "friendly_name": { + "label": "Prijateljsko ime", + "description": "Prijateljsko ime kamere korišteno u korisničkom sučelju Frigate" + }, + "enabled": { + "label": "Omogućeno", + "description": "Omogućeno" + }, + "audio": { + "label": "Audio događaji", + "description": "Postavke za detekciju događaja temeljene na audio.", + "enabled": { + "label": "Omogući detekciju zvuka", + "description": "Omogući ili onemogući detekciju događaja temeljenu na audio za ovu kameru." + }, + "max_not_heard": { + "label": "Vrijeme trajanja do kraja", + "description": "Količina sekundi bez konfiguriranog tipa zvuka prije nego što se audio događaj završi." + }, + "min_volume": { + "label": "Minimalna zapremina", + "description": "Minimalni prag RMS zapremine potreban za pokretanje detekcije zvuka; niže vrijednosti povećavaju osjetljivost (npr. 200 visoko, 500 srednje, 1000 nisko)." + }, + "listen": { + "label": "Tipovi slušanja", + "description": "Popis tipova audio događaja za detekciju (npr. zavijanje, požarne zvona, vrisak, govorenje, vikanje)." + }, + "filters": { + "label": "Audio filteri", + "description": "Postavke filtera po tipu zvuka kao što su pragovi pouzdanosti za smanjenje lažnih pozitiva." + }, + "enabled_in_config": { + "label": "Originalno stanje zvuka", + "description": "Indikuje je li detekcija zvuka izvorno omogućena u statičkoj konfiguracijskoj datoteci." + }, + "num_threads": { + "label": "Dretve detekcije", + "description": "Broj dretvi za korištenje za obradu detekcije zvuka." + } + }, + "audio_transcription": { + "label": "Transkripcija zvuka", + "description": "Postavke za transkripciju živog i govornog zvuka korištenih za događaje i žive podnaslove.", + "enabled": { + "label": "Omogući transkripciju", + "description": "Omogući ili onemogući transkripciju audio događaja pokrenutu ručno." + }, + "enabled_in_config": { + "label": "Originalni stanje transkripcije" + }, + "live_enabled": { + "label": "Uživo transkripcija", + "description": "Omogući streaming uživo transkripcije za audio dok se prima." + } + }, + "birdseye": { + "label": "Birdseye", + "description": "Postavke za sastavni prikaz Birdseye koji kombinuje više snimke kamere u jedinstveni raspored.", + "enabled": { + "label": "Omogući Birdseye", + "description": "Omogući ili onemogući funkciju prikaza Birdseye." + }, + "mode": { + "label": "Način praćenja", + "description": "Način uključivanja kamera u Birdseye: 'objekti', 'kretanje' ili 'kontinuirano'." + }, + "order": { + "label": "Pozicija", + "description": "Numerička pozicija koja kontroliše redoslijed kamera u rasporedu Birdseye." + } + }, + "detect": { + "label": "Detekcija objekata", + "description": "Postavke za ulogu detekcije/detekcija koja se koristi za pokretanje detekcije objekata i inicijalizaciju praćenja.", + "enabled": { + "label": "Omogući detekciju objekata", + "description": "Omogući ili onemogući detekciju objekata za ovu kameru." + }, + "height": { + "label": "Visina detekcije", + "description": "Visina (pikseli) okvira korištenih za detekciju stream-a; ostavite prazno za korištenje originalne rezolucije stream-a." + }, + "width": { + "label": "Širina detekcije", + "description": "Širina (pikseli) okvira korištenih za detekciju stream-a; ostavite prazno za korištenje originalne rezolucije stream-a." + }, + "fps": { + "label": "Detekcija FPS", + "description": "Željeni broj okvira po sekundi za pokretanje detekcije; niže vrijednosti smanjuju upotrebu CPU-a (preporučena vrijednost je 5, postavite više - najviše 10 - samo ako praćite vrlo brze objekte)." + }, + "min_initialized": { + "label": "Minimalni broj okvira inicijalizacije", + "description": "Broj uzastopnih detekcija potreban prije stvaranja praćenog objekta. Povećajte da biste smanjili lažne inicijalizacije. Zadana vrijednost je fps podijeljeno sa 2." + }, + "max_disappeared": { + "label": "Maksimalni broj okvira koji su nestali", + "description": "Broj okvira bez detekcije prije nego što se praćeni objekt smatra izgubljenim." + }, + "stationary": { + "label": "Konfiguracija stacionarnih objekata", + "description": "Postavke za detekciju i upravljanje objektima koji ostaju stacionarni tokom određenog vremena.", + "interval": { + "label": "Stacionarni interval", + "description": "Kako često (u snimcima) pokretati provjeru detekcije da biste potvrdili stacionarni objekt." + }, + "threshold": { + "label": "Stacionarni prag", + "description": "Broj snimaka bez promjene pozicije potreban da bi objekt bio označen kao stacionarni." + }, + "max_frames": { + "label": "Maksimalni snimci", + "description": "Ograničava koliko dugo se stacionarni objekti praćaju prije nego što se odbacuju.", + "default": { + "label": "Zadani maksimalni snimci", + "description": "Zadani maksimalni broj snimaka za praćenje stacionarnog objekta prije prestanka." + }, + "objects": { + "label": "Maksimalni snimci po objektu", + "description": "Podešavanja po objektu za maksimalni broj snimaka za praćenje stacionarnih objekata." + } + }, + "classifier": { + "label": "Omogući vizualni klasifikator", + "description": "Koristi vizualni klasifikator za detekciju pravozadanih stacionarnih objekata čak i kada se okviri tresu." + } + }, + "annotation_offset": { + "label": "Pomak oznake", + "description": "Milisekunde za pomak detektiranih oznaka kako bi se bolje poravnali vremenski okviri s snimcima; može biti pozitivan ili negativan." + } + }, + "face_recognition": { + "label": "Prepoznavanje lica", + "description": "Postavke za detekciju i prepoznavanje lica za ovu kameru.", + "enabled": { + "label": "Omogući prepoznavanje lica", + "description": "Omogući ili onemogući prepoznavanje lica." + }, + "min_area": { + "label": "Minimalna površina lica", + "description": "Minimalna površina (pikseli) detektiranog okvira lica potrebna za pokušaj prepoznavanja." + } + }, + "ffmpeg": { + "label": "FFmpeg", + "description": "Postavke FFmpeg uključuju putanju binarne datoteke, argumente, opcije hwaccel i izlazne argumente po ulozi.", + "path": { + "label": "Putanja do FFmpeg binarne datoteke", + "description": "Putanja do FFmpeg binarne datoteke ili verzija alias (\"5.0\" ili \"7.0\")." + }, + "global_args": { + "label": "Globalni argumenti FFmpeg-a", + "description": "Globalni argumenti prebačeni na procese FFmpeg." + }, + "hwaccel_args": { + "label": "Argumenti za ubrzanje hardvera", + "description": "Argumenti za ubrzanje hardvera za FFmpeg. Preporučuju se predložci specifični za dobavljača." + }, + "input_args": { + "label": "Unos argumenata", + "description": "Ulazni argumenti primjenjeni na ulazne snimke FFmpeg." + }, + "output_args": { + "label": "Izlazni argumenti", + "description": "Zadani izlazni argumenti korišteni za različite uloge FFmpeg-a poput detekcije i snimanja.", + "detect": { + "label": "Izlazni argumenti za detekciju", + "description": "Zadani izlazni argumenti za snimke uloga detekcije." + }, + "record": { + "label": "Izlazni argumenti za snimanje", + "description": "Zadani izlazni argumenti za snimke uloga snimanja." + } + }, + "retry_interval": { + "label": "Vrijeme ponovnog pokušaja FFmpeg-a", + "description": "Sekunde koje treba čekati prije nego što se pokuša ponovno uspostaviti veza s tokom kamere nakon neuspjeha. Zadano je 10." + }, + "apple_compatibility": { + "label": "Kompatibilnost s Apple-om", + "description": "Omogući označavanje HEVC za bolju kompatibilnost s igračima Apple-a prilikom snimanja H.265." + }, + "gpu": { + "label": "Indeks GPU-a", + "description": "Zadani indeks GPU-a korišten za ubrzanje hardvera ako je dostupan." + }, + "inputs": { + "label": "Ulazni podaci kamere", + "description": "Popis definicija ulaznih tokova (putanje i uloge) za ovu kameru.", + "path": { + "label": "Putanja ulaza", + "description": "URL ili putanja ulaznog toka kamere." + }, + "roles": { + "label": "Uloge ulaza", + "description": "Uloge za ovaj ulazni tok." + }, + "global_args": { + "label": "Globalni argumenti FFmpeg-a", + "description": "Globalni argumenti FFmpeg-a za ovaj ulazni tok." + }, + "hwaccel_args": { + "label": "Argumenti za ubrzanje hardvera", + "description": "Argumenti za ubrzanje hardvera za ovaj ulazni stream." + }, + "input_args": { + "label": "Unos argumenata", + "description": "Argumeti unosa specifični za ovaj stream." + } + } + }, + "live": { + "label": "Uživo prikaz", + "description": "Postavke korištenje Web UI za kontrolu izbora živog streama, rezolucije i kvalitete.", + "streams": { + "label": "Imena živih streamova", + "description": "Mapiranje konfiguriranih imena streamova na imena restream/go2rtc korишtena za uživo prikaz." + }, + "height": { + "label": "Visina uživo", + "description": "Visina (piksela) za prikaz jsmpeg živog streama u Web UI; mora biti <= visina detektiranog streama." + }, + "quality": { + "label": "Kvalitet uživo", + "description": "Kvalitet kodiranja za jsmpeg stream (1 najviši, 31 najniži)." + } + }, + "lpr": { + "label": "Prepoznavanje tablice vozila", + "description": "Postavke prepoznavanja tablice vozila uključujući pragovi detekcije, formatiranje i poznate tablice.", + "enabled": { + "label": "Omogući LPR", + "description": "Omogući ili onemogući LPR na ovoj kameri." + }, + "expire_time": { + "label": "Sekunde isteka", + "description": "Vrijeme u sekundama nakon kojeg nevidljiva tablica istječe iz praćenja (samo za dedikovane LPR kamere)." + }, + "min_area": { + "label": "Minimalna površina tablice", + "description": "Minimalna površina tablice (piksela) potrebna za pokušaj prepoznavanja." + }, + "enhancement": { + "label": "Nivo poboljšanja", + "description": "Nivo poboljšanja (0-10) za primjenu na isječke tablice prije OCR-a; veće vrijednosti ne moraju uvijek poboljšati rezultate, nivoi iznad 5 mogu raditi samo s tablicama u noćnom vremenu i trebaju se koristiti s oprezom." + } + }, + "motion": { + "label": "Detekcija pokreta", + "description": "Zadane postavke detekcije pokreta za ovu kameru.", + "enabled": { + "label": "Omogući detekciju pokreta", + "description": "Omogući ili onemogući detekciju pokreta za ovu kameru." + }, + "threshold": { + "label": "Prag pokreta", + "description": "Prag razlike piksela korišten za detektor pokreta; veće vrijednosti smanjuju osjetljivost (opseg 1-255)." + }, + "lightning_threshold": { + "label": "Prag munje", + "description": "Prag za detekciju i zanemarivanje kratkih iskri svjetlosti (niže vrijednosti povećavaju osjetljivost, vrijednosti između 0.3 i 1.0). Ovo ne spriječava detekciju pokreta u potpunosti; jednostavno zaustavlja detektor da analizira dodatne okvire nakon što se prag premaši. Snimci temeljeni na pokretima i dalje se stvaraju tijekom ovih događaja." + }, + "skip_motion_threshold": { + "label": "Preskoči prag pokreta", + "description": "Ako se postavi na vrijednost između 0.0 i 1.0, i ako se više od ovog udjela slike promijeni u jednom okviru, detektor neće vratiti kutije pokreta i odmah će se ponovno kalibrirati. Ovo može uštedjeti CPU i smanjiti lažne pozitive tijekom munje, oluje itd., ali može propustiti stvarne događaje kao što je automatsko praćenje objekta PTZ kamerom. Tržište je između izgube nekoliko megabajta snimaka i pregleda nekoliko kratkih zapisnika. Ostavite nepostavljeno (Nijedno) za onemogućavanje ove funkcije." + }, + "improve_contrast": { + "label": "Poboljšaj kontrast", + "description": "Primijeni poboljšanje kontrasta na okvire prije analize pokreta kako bi pomoću detekcije." + }, + "contour_area": { + "label": "Površina kontura", + "description": "Minimalna površina kontura u pikselima potrebna za brojanje kontura pokreta." + }, + "delta_alpha": { + "label": "Delta alfa", + "description": "Faktor alfa spajanja korišten za razliku okvira za izračun pokreta." + }, + "frame_alpha": { + "label": "Alfa okvira", + "description": "Vrijednost alfa korištena prilikom spajanja okvira za predobradbu pokreta." + }, + "frame_height": { + "label": "Visina okvira", + "description": "Visina u pikselima na koju se skaliraju okviri prilikom izračuna pokreta." + }, + "mask": { + "label": "Koordinate maska", + "description": "Uredno x,y koordinate koje definiraju poligon maska pokreta za uključivanje/isključivanje područja." + }, + "mqtt_off_delay": { + "label": "MQTT zakasnjenje isključivanja", + "description": "Sekunde koje se čekaju nakon posljednjeg pokreta prije objave MQTT 'isključeno' stanje." + }, + "enabled_in_config": { + "label": "Originalno stanje pokreta", + "description": "Indikira je li detekcija pokreta bila omogućena u originalnoj statičkoj konfiguraciji." + }, + "raw_mask": { + "label": "Ručna maska" + } + }, + "objects": { + "label": "Objekti", + "description": "Zadani parametri praćenja objekata uključujući koje oznake praćenja i filtre po objektu.", + "track": { + "label": "Objekti za praćenje", + "description": "Popis oznaka objekata za praćenje za ovu kameru." + }, + "filters": { + "label": "Filtar objekata", + "description": "Filtar primijenjen na detektirane objekte kako bi se smanjila broj lažnih pozitiva (površina, omjer, pouzdanost).", + "min_area": { + "label": "Minimalna površina objekta", + "description": "Minimalna površina okvira (pikseli ili postotak) potrebna za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)." + }, + "max_area": { + "label": "Maksimalna površina objekta", + "description": "Maksimalna površina okvira (pikseli ili postotak) dozvoljena za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)." + }, + "min_ratio": { + "label": "Minimalni omjer visine/širine", + "description": "Minimalni omjer širine/visine potreban da bi okvir bio prihvaćen." + }, + "max_ratio": { + "label": "Maksimalni omjer visine/širine", + "description": "Maksimalni omjer širine/visine dozvoljen da bi okvir bio prihvaćen." + }, + "threshold": { + "label": "Prag pouzdanosti", + "description": "Prosjek pragova pouzdanosti detekcije potreban da bi objekt bio smatravan pravim pozitivom." + }, + "min_score": { + "label": "Minimalna pouzdanost", + "description": "Minimalna pouzdanost detekcije po okviru potrebna da bi objekt bio brojan." + }, + "mask": { + "label": "Maska filtriranja", + "description": "Koordinate poligona koje definiraju područje na kojem se ovaj filter primjenjuje unutar okvira." + }, + "raw_mask": { + "label": "Ručna maska" + } + }, + "mask": { + "label": "Maska objekta", + "description": "Poligonalna maska korištena za spriječavanje detekcije objekta u određenim područjima." + }, + "raw_mask": { + "label": "Ručna maska" + }, + "genai": { + "label": "Konfiguracija GenAI objekta", + "description": "Opcije GenAI za opisivanje praćenih objekata i slanje okvira za generisanje.", + "enabled": { + "label": "Omogući GenAI", + "description": "Omogući generisanje opisa za praćene objekte po zadanim postavkama." + }, + "use_snapshot": { + "label": "Koristi snimke", + "description": "Koristi snimke objekata umjesto miniaturnih slika za generisanje opisa GenAI." + }, + "prompt": { + "label": "Naslovni prompt", + "description": "Zadani šablon upita korišten za generisanje opisa pomoću GenAI." + }, + "object_prompts": { + "label": "Prompti za objekte", + "description": "Prompti po objektu za prilagođavanje izlaza GenAI za specifične oznake." + }, + "objects": { + "label": "GenAI objekti", + "description": "Popis oznaka objekata koje se po defaultu šalju GenAI." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje moraju biti unesene za objekte da bi se kvalifikovali za generisanje opisa GenAI." + }, + "debug_save_thumbnails": { + "label": "Sačuvajte miniaturne slike", + "description": "Sačuvaj miniaturne slike koje se šalju GenAI za ispravljanje i pregled." + }, + "send_triggers": { + "label": "GenAI izazivači", + "description": "Definiše kada bi se trebale slati okvir za GenAI (na kraju, nakon ažuriranja, itd.).", + "tracked_object_end": { + "label": "Pošalji na kraju", + "description": "Pošalji zahtjev GenAI kada praćeni objekt završi." + }, + "after_significant_updates": { + "label": "Raniji GenAI izazivač", + "description": "Pošalji zahtjev GenAI nakon određenog broja značajnih ažuriranja za praćeni objekt." + } + }, + "enabled_in_config": { + "label": "Originalno stanje GenAI", + "description": "Pokazuje je li GenAI bio omogućen u originalnoj statičkoj konfiguraciji." + } + } + }, + "record": { + "label": "Snimanje", + "description": "Postavke snimanja i zadržavanja za ovu kameru.", + "enabled": { + "label": "Omogući snimanje", + "description": "Omogući ili onemogući snimanje za ovu kameru." + }, + "expire_interval": { + "label": "Interval čišćenja snimanja", + "description": "Minute između čišćenja koja uklanjaju istekle segmente snimaka." + }, + "continuous": { + "label": "Neprekidna retencija", + "description": "Broj dana za čuvanje snimaka bez obzira na praćene objekte ili pokret. Postavite na 0 ako želite da čuvate samo snimke upozorenja i detekcije.", + "days": { + "label": "Dane zadržavanja", + "description": "Dana za čuvanje snimaka." + } + }, + "motion": { + "label": "Retencija pokreta", + "description": "Broj dana za čuvanje snimaka izazvanih pokretom bez obzira na praćene objekte. Postavite na 0 ako želite da čuvate samo snimke upozorenja i detekcije.", + "days": { + "label": "Dane zadržavanja", + "description": "Dana za čuvanje snimaka." + } + }, + "detections": { + "label": "Retencija detekcije", + "description": "Postavke retencije snimaka za događaje detekcije uključujući trajanje pre/post snimanja.", + "pre_capture": { + "label": "Sekundi pre snimanja", + "description": "Broj sekundi prije događaja detekcije koje treba uključiti u snimak." + }, + "post_capture": { + "label": "Sekunde nakon snimanja", + "description": "Broj sekundi nakon događaja detekcije koje se uključuju u snimanje." + }, + "retain": { + "label": "Zadržavanje događaja", + "description": "Postavke zadržavanja za snimke događaja detekcije.", + "days": { + "label": "Dane zadržavanja", + "description": "Broj dana za koje se zadržavaju snimke događaja detekcije." + }, + "mode": { + "label": "Način zadržavanja", + "description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)." + } + } + }, + "alerts": { + "label": "Retencija upozorenja", + "description": "Postavke retencije snimaka za događaje upozorenja uključujući trajanje pre/post snimanja.", + "pre_capture": { + "label": "Sekundi pre snimanja", + "description": "Broj sekundi prije događaja detekcije koje treba uključiti u snimak." + }, + "post_capture": { + "label": "Sekunde nakon snimanja", + "description": "Broj sekundi nakon događaja detekcije koje se uključuju u snimanje." + }, + "retain": { + "label": "Zadržavanje događaja", + "description": "Postavke zadržavanja za snimke događaja detekcije.", + "days": { + "label": "Dane zadržavanja", + "description": "Broj dana za koje se zadržavaju snimke događaja detekcije." + }, + "mode": { + "label": "Način zadržavanja", + "description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)." + } + } + }, + "export": { + "label": "Konfiguracija izvoza", + "description": "Postavke koje se koriste prilikom izvoza snimaka kao što su timelapse i ubrzavanje dretve.", + "hwaccel_args": { + "label": "Argumeti ubrzavanja dretve za izvoz", + "description": "Argumeti ubrzavanja dretve za operacije izvoza/prenosa." + }, + "max_concurrent": { + "label": "Maksimalan broj istovremenih izvoza", + "description": "Maksimalan broj poslova izvoza koji se obrađuju istovremeno." + } + }, + "preview": { + "label": "Konfiguracija pregleda", + "description": "Postavke koje kontrolišu kvalitet pregleda snimanja prikazanih u UI.", + "quality": { + "label": "Kvaliteta pregleda", + "description": "Nivo kvalitete pregleda (vrlo_nizak, nizak, srednji, visok, vrlo_visok)." + } + }, + "enabled_in_config": { + "label": "Originalno stanje snimanja", + "description": "Pokazuje je li snimanje bilo omogućeno u originalnoj statičkoj konfiguraciji." + } + }, + "review": { + "label": "Pregled", + "description": "Postavke koje kontrolišu upozorenja, detekcije i sažetke pregleda GenAI korišteni od strane UI i skladišta za ovu kameru.", + "alerts": { + "label": "Konfiguracija upozorenja", + "description": "Postavke za koje objekti praćeni generišu upozorenja i kako se upozorenja zadržavaju.", + "enabled": { + "label": "Omogući upozorenja", + "description": "Omogući ili onemogući generisanje upozorenja za ovu kameru." + }, + "labels": { + "label": "Oznake upozorenja", + "description": "Lista oznaka objekata koje se smatraju upozorenjima (npr. automobil, osoba)." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje objekt mora ući da bi se smatrao upozorenjem; ostavite prazno da omogućite bilo koju zonu." + }, + "enabled_in_config": { + "label": "Originalno stanje upozorenja", + "description": "Pratiti je li upozorenja izvorno omogućena u statičkoj konfiguraciji." + }, + "cutoff_time": { + "label": "Vrijeme prekida upozorenja", + "description": "Sekunde koje treba čekati nakon što nema aktivnosti koja uzrokuje upozorenje prije nego se prekine upozorenje." + } + }, + "detections": { + "label": "Konfiguracija detekcija", + "description": "Postavke koje objekti koje se praćenje generišu detekcije (nepozornja) i kako se detekcije čuvaju.", + "enabled": { + "label": "Omogući detekcije", + "description": "Omogući ili onemogući događaje detekcije za ovu kameru." + }, + "labels": { + "label": "Oznake detekcije", + "description": "Popis oznaka objekata koje kvalifikuju kao događaji detekcije." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje objekt mora ući da bi se smatrao detekcijom; ostavite prazno da omogućite bilo koju zonu." + }, + "cutoff_time": { + "label": "Vrijeme prekida detekcija", + "description": "Sekunde koje treba čekati nakon što nema aktivnosti koja uzrokuje detekciju prije nego se prekine detekcija." + }, + "enabled_in_config": { + "label": "Originalno stanje detekcija", + "description": "Pratiti je li detekcije izvorno omogućene u statičkoj konfiguraciji." + } + }, + "genai": { + "label": "Konfiguracija GenAI", + "description": "Kontrolira korištenje generativne AI za proizvodnju opisa i sažetaka stavki za pregled.", + "enabled": { + "label": "Omogući opise GenAI", + "description": "Omogući ili onemogući opise i sažetke generirane GenAI za stavke za pregled." + }, + "alerts": { + "label": "Omogući GenAI za upozorenja", + "description": "Koristi GenAI za generiranje opisa stavki upozorenja." + }, + "detections": { + "label": "Omogući GenAI za detekcije", + "description": "Koristite GenAI za generiranje opisa predmeta detekcije." + }, + "image_source": { + "label": "Pregledajte izvor slike", + "description": "Izvor slika poslatih GenAIJ-u ('preview' ili 'recordings'); 'recordings' koristi kvalitetnije okvire, ali više tokena." + }, + "additional_concerns": { + "label": "Dodatne brige", + "description": "Popis dodatnih briga ili napomena koje GenAI treba uzeti u obzir prilikom procjene aktivnosti na ovoj kameri." + }, + "debug_save_thumbnails": { + "label": "Sačuvajte miniaturne slike", + "description": "Sačuvajte miniaturne slike koje se šalju GenAI provajderu za ispravljanje grešaka i pregled." + }, + "enabled_in_config": { + "label": "Originalno stanje GenAI", + "description": "Pratiti je li pregled GenAI izvorno omogućen u statičkoj konfiguraciji." + }, + "preferred_language": { + "label": "Preferirani jezik", + "description": "Preferirani jezik za zahtijevanje od GenAI provajdera za generirane odgovore." + }, + "activity_context_prompt": { + "label": "Prompt konteksta aktivnosti", + "description": "Prilagođeni prompt koji opisuje što je i što nije sumnjivo ponašanje kako bi pružio kontekst za sažetke GenAI." + } + } + }, + "semantic_search": { + "label": "Semantička pretraga", + "description": "Postavke za semantičku pretragu koja konstruira i upita uključivanje objekata kako bi pronašla slične stavke.", + "triggers": { + "label": "Pokretači", + "description": "Akcije i kriteriji za usklađivanje za pokretače semantičke pretrage specifične za kameru.", + "friendly_name": { + "label": "Prijateljsko ime", + "description": "Nepovlačno prijateljsko ime prikazano u korisničkom sučelju za ovaj pokretač." + }, + "enabled": { + "label": "Omogući ovaj pokretač", + "description": "Omogući ili onemogući ovaj pokretač semantičke pretrage." + }, + "type": { + "label": "Tip pokretača", + "description": "Tip pokretača: 'thumbnail' (uspoređivanje slikom) ili 'description' (uspoređivanje teksta)." + }, + "data": { + "label": "Sadržaj pokretača", + "description": "Tekstualni izraz ili ID miniaturne slike za uspoređivanje s praćenim objektima." + }, + "threshold": { + "label": "Prag aktivacije", + "description": "Minimalna ocjena sličnosti (0-1) potrebna za aktivaciju ovog izazivača." + }, + "actions": { + "label": "Akcije izazivača", + "description": "Popis akcija koje se izvršavaju kada izazivač odgovara (obavijest, pod_naziv, atribute)." + } + } + }, + "snapshots": { + "label": "Snimci", + "description": "Postavke za snimke generirane preko API-ja za praćene objekte za ovu kameru.", + "enabled": { + "label": "Omogući snimke", + "description": "Omogući ili onemogući snimanje snimaka za ovu kameru." + }, + "timestamp": { + "label": "Preklapanje vremenske oznake", + "description": "Preklopiti vremensku oznaku na snimke iz API-ja." + }, + "bounding_box": { + "label": "Preklapanje okvira", + "description": "Crtanje okvira za praćene objekte na snimke iz API-ja." + }, + "crop": { + "label": "Izrezivanje snimke", + "description": "Izrezivanje snimki iz API-ja do okvira detektiranog objekta." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje objekt mora ući da bi snimka bila sačuvana." + }, + "height": { + "label": "Visina snimke", + "description": "Visina (pikseli) za promjenu veličine snimki iz API-ja; ostavite prazno da biste sačuvali originalnu veličinu." + }, + "retain": { + "label": "Zadržavanje snimki", + "description": "Postavke zadržavanja snimki uključujući zadane dane i prekriženja po objektu.", + "default": { + "label": "Zadano zadržavanje", + "description": "Zadani broj dana za zadržavanje snimki." + }, + "mode": { + "label": "Način zadržavanja", + "description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)." + }, + "objects": { + "label": "Zadržavanje objekata", + "description": "Prekriženja po objektu za dane zadržavanja snimki." + } + }, + "quality": { + "label": "Kvaliteta snimka", + "description": "Kvaliteta kodiranja za sačuvane snimke (0-100)." + } + }, + "timestamp_style": { + "label": "Stil vremenske oznake", + "description": "Opcije stilizacije za vremenske oznake u snimcima i snimcima.", + "position": { + "label": "Pozicija vremenske oznake", + "description": "Pozicija vremenske oznake na slici (tl/tr/bl/br)." + }, + "format": { + "label": "Format vremenske oznake", + "description": "String formata datuma i vremena korišten za vremenske oznake (Python format koda za datum i vrijeme)." + }, + "color": { + "label": "Boja vremenske oznake", + "description": "RGB vrijednosti boja za tekst vremenske oznake (sve vrijednosti 0-255).", + "red": { + "label": "Crvena", + "description": "Crveni komponent (0-255) za boju vremenske oznake." + }, + "green": { + "label": "Zelena", + "description": "Zeleni komponent (0-255) za boju vremenske oznake." + }, + "blue": { + "label": "Plava", + "description": "Plavi komponent (0-255) za boju vremenske oznake." + } + }, + "thickness": { + "label": "Debljina vremenske oznake", + "description": "Debljina linije teksta vremenske oznake." + }, + "effect": { + "label": "Efekt vremenske oznake", + "description": "Vizualni efekt za tekst vremenske oznake (none, solid, shadow)." + } + }, + "best_image_timeout": { + "label": "Vrijeme čekanja za najbolju sliku", + "description": "Koliko dugo čekati na sliku s najvišim stupnjem pouzdanosti." + }, + "mqtt": { + "label": "MQTT", + "description": "Postavke objave slika preko MQTT.", + "enabled": { + "label": "Pošalji sliku", + "description": "Omogući objavljivanje snimaka slika za objekte na MQTT teme za ovu kameru." + }, + "timestamp": { + "label": "Dodaj vremensku oznaku", + "description": "Preklopiti vremensku oznaku na slike objavljene preko MQTT." + }, + "bounding_box": { + "label": "Dodaj okvir", + "description": "Crtaj okvire na slikama objavljenim preko MQTT." + }, + "crop": { + "label": "Iscijepi sliku", + "description": "Iscijepi slike objavljene preko MQTT na okvir detektiranog objekta." + }, + "height": { + "label": "Visina slike", + "description": "Visina (piksela) za promjenu veličine slika objavljenih preko MQTT." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje objekt mora ući da bi se slika preko MQTT objavila." + }, + "quality": { + "label": "Kvaliteta JPEG", + "description": "Kvaliteta JPEG za slike objavljene preko MQTT (0-100)." + } + }, + "notifications": { + "label": "Obavještenja", + "description": "Postavke za omogućavanje i kontrolu obavijesti za ovu kameru.", + "enabled": { + "label": "Omogući obavijesti", + "description": "Omogući ili onemogući obavijesti za ovu kameru." + }, + "email": { + "label": "E-mail za obavijesti", + "description": "Adresa e-maila koja se koristi za obavijesti putem push-a ili je potrebna određenim dobavljačima obavijesti." + }, + "cooldown": { + "label": "Period hlađenja", + "description": "Period hlađenja (sekunde) između obavijesti kako bi se izbjeglo spaming primateljima." + }, + "enabled_in_config": { + "label": "Originalno stanje obavijesti", + "description": "Pokazuje je li obavijesti bile omogućene u originalnoj statičkoj konfiguraciji." + } + }, + "onvif": { + "label": "ONVIF", + "description": "Postavke povezivanja preko ONVIF i automatskog praćenja PTZ za ovu kameru.", + "host": { + "label": "Gost ONVIF", + "description": "Gost (i opcionalni shema) za uslugu ONVIF za ovu kameru." + }, + "port": { + "label": "Port ONVIF", + "description": "Broj porta za uslugu ONVIF." + }, + "user": { + "label": "Korisničko ime za ONVIF", + "description": "Korisničko ime za autentifikaciju ONVIF; neki uređaji zahtijevaju korisnika admin za ONVIF." + }, + "password": { + "label": "Lozinka za ONVIF", + "description": "Lozinka za autentifikaciju ONVIF." + }, + "tls_insecure": { + "label": "Onemogući provjeru TLS", + "description": "Preskoči provjeru TLS i onemogući digest autentifikaciju za ONVIF (nebezbedno; koristiti samo u sigurnim mrežama)." + }, + "profile": { + "label": "ONVIF profil", + "description": "Specifičan ONVIF medij profil za korištenje za kontrolu PTZ, prilagođen tokenom ili imenom. Ako nije postavljen, prvi profil s važećom konfiguracijom PTZ automatski se odabire." + }, + "autotracking": { + "label": "Autotračenje", + "description": "Automatski praćenje pokretanja objekata i držanje ih u sredini okvira korištenjem pokreta kamere PTZ.", + "enabled": { + "label": "Omogući automatsko praćenje", + "description": "Omogući ili onemogući automatsko praćenje kamere PTZ detektiranih objekata." + }, + "calibrate_on_startup": { + "label": "Kalibriraj na početku", + "description": "Mjeri brzine motora PTZ pri pokretanju kako bi poboljšao preciznost praćenja. Frigate će ažurirati konfiguraciju s težinama pokreta nakon kalibracije." + }, + "zooming": { + "label": "Režim zumiranja", + "description": "Kontrola ponašanja zumiranja: onemogućeno (samo pan/tilt), apsolutno (najkompatibilnije) ili relativno (konkurentno pan/tilt/zum)." + }, + "zoom_factor": { + "label": "Faktor zumiranja", + "description": "Kontrola razine zumiranja na praćenim objektima. Niže vrijednosti drže više scene u pogledu; više vrijednosti zumiraju bliže, ali mogu izgubiti praćenje. Vrijednosti između 0.1 i 0.75." + }, + "track": { + "label": "Praćeni objekti", + "description": "Popis vrsta objekata koji trebaju pokrenuti automatsko praćenje." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Objekti moraju ući u jednu od ovih zona prije nego što započne automatsko praćenje." + }, + "return_preset": { + "label": "Povratak na predpostavku", + "description": "Ime predpostavke konfigurirano u firmware kamere za povratak nakon završetka praćenja." + }, + "timeout": { + "label": "Vrijeme čekanja povratka", + "description": "Čekajte ovaj broj sekundi nakon gubitka praćenja prije povratka kamere na predpostavljeno mjesto." + }, + "movement_weights": { + "label": "Težine pokreta", + "description": "Vrijednosti kalibracije automatski generirane kroz kalibraciju kamere. Ne mijenjajte ručno." + }, + "enabled_in_config": { + "label": "Originalni stanje autotračenja", + "description": "Unutarnje polje za praćenje je li autotračenje bilo omogućeno u konfiguraciji." + } + }, + "ignore_time_mismatch": { + "label": "Zanemari razliku u vremenu", + "description": "Zanemari razlike u sinhronizaciji vremena između kamere i Frigate servera za komunikaciju ONVIF." + } + }, + "type": { + "label": "Tip kamere", + "description": "Tip kamere" + }, + "ui": { + "label": "Korisnički interfejs kamere", + "description": "Prikaz redoslijeda i vidljivosti za ovu kameru u UI. Redoslijed utječe na zadani nadzorno pločo. Za detaljniju kontrolu koristite grupe kamere.", + "order": { + "label": "Redoslijed UI", + "description": "Numerički redoslijed koristi se za sortiranje kamere u UI (zadani nadzorno pločo i popisi); veći brojevi pojavljuju se kasnije." + }, + "dashboard": { + "label": "Prikaži u UI", + "description": "Prekidač je li ova kamera vidljiva svuda u UI Frigate. Onemogućavanje ovoga zahtijeva ručno uređivanje konfiguracije za ponovno prikazivanje ove kamere u UI." + } + }, + "webui_url": { + "label": "URL kamere", + "description": "URL za pristup kamere izravno iz stranice sustava" + }, + "profiles": { + "label": "Profili", + "description": "Imenovane konfiguracijske profile s parcijalnim preklopima koji se mogu aktivirati tijekom izvršavanja." + }, + "enabled_in_config": { + "label": "Originalno stanje kamere", + "description": "Pratite originalno stanje kamere." + } +} diff --git a/web/public/locales/bs/config/global.json b/web/public/locales/bs/config/global.json new file mode 100644 index 0000000000..84e370a6cc --- /dev/null +++ b/web/public/locales/bs/config/global.json @@ -0,0 +1,1596 @@ +{ + "version": { + "label": "Trenutna verzija konfiguracije", + "description": "Numerička ili string verzija aktivne konfiguracije za pomoć pri otkrivanju migracija ili promjena formata." + }, + "audio": { + "label": "Audio događaji", + "enabled": { + "label": "Omogući detekciju zvuka", + "description": "Omogući ili onemogući detekciju zvučnih događaja za sve kamere; mogu se prekrivati po kameri." + }, + "max_not_heard": { + "label": "Vrijeme trajanja do kraja", + "description": "Količina sekundi bez konfiguriranog tipa zvuka prije nego što se audio događaj završi." + }, + "min_volume": { + "label": "Minimalna zapremina", + "description": "Minimalni prag RMS zapremine potreban za pokretanje detekcije zvuka; niže vrijednosti povećavaju osjetljivost (npr. 200 visoko, 500 srednje, 1000 nisko)." + }, + "listen": { + "label": "Tipovi slušanja", + "description": "Popis tipova audio događaja za detekciju (npr. zavijanje, požarne zvona, vrisak, govorenje, vikanje)." + }, + "filters": { + "label": "Audio filteri", + "description": "Postavke filtera po tipu zvuka kao što su pragovi pouzdanosti za smanjenje lažnih pozitiva." + }, + "enabled_in_config": { + "label": "Originalno stanje zvuka", + "description": "Indikuje je li detekcija zvuka izvorno omogućena u statičkoj konfiguracijskoj datoteci." + }, + "num_threads": { + "label": "Dretve detekcije", + "description": "Broj dretvi za korištenje za obradu detekcije zvuka." + }, + "description": "Postavke za detekciju događaja na osnovu zvuka za sve kamere; mogu se prekrivati po kameri." + }, + "audio_transcription": { + "label": "Transkripcija zvuka", + "description": "Postavke za transkripciju živog i govornog zvuka korištenih za događaje i žive podnaslove.", + "live_enabled": { + "label": "Uživo transkripcija", + "description": "Omogući streaming uživo transkripcije za audio dok se prima." + }, + "enabled": { + "label": "Omogući transkripciju zvuka", + "description": "Omogući ili onemogući automatsku transkripciju zvuka za sve kamere; može se prekrimiti po kamere." + }, + "language": { + "label": "Jezik za transkripciju", + "description": "Kod jezika korišten za transkripciju/prevod (npr. 'en' za engleski). Pogledajte https://whisper-api.com/docs/languages/ za podržane kodove jezika." + }, + "device": { + "label": "Uređaj za transkripciju", + "description": "Ključ uređaja (CPU/GPU) za izvršavanje modela transkripcije. Trenutno se podržavaju samo NVIDIA CUDA GPU-ovi za transkripciju." + }, + "model_size": { + "label": "Veličina modela", + "description": "Veličina modela za korištenje za offline transkripciju zvučnih događaja." + } + }, + "birdseye": { + "label": "Birdseye", + "description": "Postavke za sastavni prikaz Birdseye koji kombinuje više snimke kamere u jedinstveni raspored.", + "enabled": { + "label": "Omogući Birdseye", + "description": "Omogući ili onemogući funkciju prikaza Birdseye." + }, + "mode": { + "label": "Način praćenja", + "description": "Način uključivanja kamera u Birdseye: 'objekti', 'kretanje' ili 'kontinuirano'." + }, + "order": { + "label": "Pozicija", + "description": "Numerička pozicija koja kontroliše redoslijed kamera u rasporedu Birdseye." + }, + "restream": { + "label": "Ponovno prenos RTSP", + "description": "Ponovno prenos izlaza Birdseye kao RTSP tok; uključivanje ovoga će održavati Birdseye u neprekidnom radu." + }, + "width": { + "label": "Širina", + "description": "Širina izlaza (piksela) sastavljenog okvira Birdseye." + }, + "height": { + "label": "Visina", + "description": "Visina izlaza (piksela) sastavljenog okvira Birdseye." + }, + "quality": { + "label": "Kvalitet kodiranja", + "description": "Kvalitet kodiranja za Birdseye mpeg1 tok (1 najviši kvalitet, 31 najniži)." + }, + "inactivity_threshold": { + "label": "Prag neaktivnosti", + "description": "Sekunde neaktivnosti nakon kojih će kamera prestati da se prikazuje u Birdseye." + }, + "layout": { + "label": "Razmještaj", + "description": "Opcije razmještaja za sastavljanje Birdseye.", + "scaling_factor": { + "label": "Faktor skaliranja", + "description": "Faktor skaliranja korišten od strane računala za razmještaj (opseg 1.0 do 5.0)." + }, + "max_cameras": { + "label": "Maksimalan broj kamera", + "description": "Maksimalan broj kamera koje se mogu prikazati istovremeno u Birdseye; prikazuje najnovije kamere." + } + }, + "idle_heartbeat_fps": { + "label": "Neaktivno srčanog udaraca FPS", + "description": "Broj okvira po sekundi za ponovno slanje posljednjeg sastavljenog Birdseye okvira kada je neaktivno; postavite na 0 za onemogućavanje." + } + }, + "detect": { + "label": "Detekcija objekata", + "description": "Postavke za ulogu detekcije/detekcija koja se koristi za pokretanje detekcije objekata i inicijalizaciju praćenja.", + "enabled": { + "label": "Omogući detekciju objekata", + "description": "Omogući ili onemogući detekciju objekata za sve kamere; može se prekrimiti po kamere." + }, + "height": { + "label": "Visina detekcije", + "description": "Visina (pikseli) okvira korištenih za detekciju stream-a; ostavite prazno za korištenje originalne rezolucije stream-a." + }, + "width": { + "label": "Širina detekcije", + "description": "Širina (pikseli) okvira korištenih za detekciju stream-a; ostavite prazno za korištenje originalne rezolucije stream-a." + }, + "fps": { + "label": "Detekcija FPS", + "description": "Željeni broj okvira po sekundi za pokretanje detekcije; niže vrijednosti smanjuju upotrebu CPU-a (preporučena vrijednost je 5, postavite više - najviše 10 - samo ako praćite vrlo brze objekte)." + }, + "min_initialized": { + "label": "Minimalni broj okvira inicijalizacije", + "description": "Broj uzastopnih detekcija potreban prije stvaranja praćenog objekta. Povećajte da biste smanjili lažne inicijalizacije. Zadana vrijednost je fps podijeljeno sa 2." + }, + "max_disappeared": { + "label": "Maksimalni broj okvira koji su nestali", + "description": "Broj okvira bez detekcije prije nego što se praćeni objekt smatra izgubljenim." + }, + "stationary": { + "label": "Konfiguracija stacionarnih objekata", + "description": "Postavke za detekciju i upravljanje objektima koji ostaju stacionarni tokom određenog vremena.", + "interval": { + "label": "Stacionarni interval", + "description": "Kako često (u snimcima) pokretati provjeru detekcije da biste potvrdili stacionarni objekt." + }, + "threshold": { + "label": "Stacionarni prag", + "description": "Broj snimaka bez promjene pozicije potreban da bi objekt bio označen kao stacionarni." + }, + "max_frames": { + "label": "Maksimalni snimci", + "description": "Ograničava koliko dugo se stacionarni objekti praćaju prije nego što se odbacuju.", + "default": { + "label": "Zadani maksimalni snimci", + "description": "Zadani maksimalni broj snimaka za praćenje stacionarnog objekta prije prestanka." + }, + "objects": { + "label": "Maksimalni snimci po objektu", + "description": "Podešavanja po objektu za maksimalni broj snimaka za praćenje stacionarnih objekata." + } + }, + "classifier": { + "label": "Omogući vizualni klasifikator", + "description": "Koristi vizualni klasifikator za detekciju pravozadanih stacionarnih objekata čak i kada se okviri tresu." + } + }, + "annotation_offset": { + "label": "Pomak oznake", + "description": "Milisekunde za pomak detektiranih oznaka kako bi se bolje poravnali vremenski okviri s snimcima; može biti pozitivan ili negativan." + } + }, + "face_recognition": { + "label": "Prepoznavanje lica", + "enabled": { + "label": "Omogući prepoznavanje lica", + "description": "Omogući ili onemogući prepoznavanje lica za sve kamere; mogu se preklopiti po kameri." + }, + "min_area": { + "label": "Minimalna površina lica", + "description": "Minimalna površina (pikseli) detektiranog okvira lica potrebna za pokušaj prepoznavanja." + }, + "description": "Postavke za detekciju i prepoznavanje lica za sve kamere; mogu se preklopiti po kameri.", + "model_size": { + "label": "Veličina modela", + "description": "Veličina modela za korištenje za ugradnje lica (small/large); veće može zahtijevati GPU." + }, + "unknown_score": { + "label": "Prag neznatnog rezultata", + "description": "Prag udaljenosti ispod kojeg se lice smatra potencijalnim odgovarajućim (viši = stroži)." + }, + "detection_threshold": { + "label": "Prag detekcije", + "description": "Minimalni prag pouzdanosti potreban za razmatranje detekcije lica kao važeće." + }, + "recognition_threshold": { + "label": "Prag prepoznavanja", + "description": "Prag udaljenosti ugradnje lica za razmatranje dva lica kao odgovarajuća." + }, + "min_faces": { + "label": "Minimalan broj lica", + "description": "Minimalan broj prepoznavanja lica potreban prije nego što se primijeni prepoznati podnaziv za osobu." + }, + "save_attempts": { + "label": "Pokušaji sačuvanja", + "description": "Broj pokušaja prepoznavanja lica koje se treba sačuvati za korisnički sučelje najnovijih prepoznavanja." + }, + "blur_confidence_filter": { + "label": "Filter pouzdanosti za zamagljenost", + "description": "Prilagodite ocjene pouzdanosti na temelju zamagljenosti slike kako biste smanjili lažne pozitive za loše kvalitete lica." + }, + "device": { + "label": "Uređaj", + "description": "Ovo je prekršaj, da biste ciljali specifičan uređaj. Pogledajte https://onnxruntime.ai/docs/execution-providers/ za više informacija" + } + }, + "ffmpeg": { + "label": "FFmpeg", + "description": "Postavke FFmpeg uključuju putanju binarne datoteke, argumente, opcije hwaccel i izlazne argumente po ulozi.", + "path": { + "label": "Putanja do FFmpeg binarne datoteke", + "description": "Putanja do FFmpeg binarne datoteke ili verzija alias (\"5.0\" ili \"7.0\")." + }, + "global_args": { + "label": "Globalni argumenti FFmpeg-a", + "description": "Globalni argumenti prebačeni na procese FFmpeg." + }, + "hwaccel_args": { + "label": "Argumenti za ubrzanje hardvera", + "description": "Argumenti za ubrzanje hardvera za FFmpeg. Preporučuju se predložci specifični za dobavljača." + }, + "input_args": { + "label": "Unos argumenata", + "description": "Ulazni argumenti primjenjeni na ulazne snimke FFmpeg." + }, + "output_args": { + "label": "Izlazni argumenti", + "description": "Zadani izlazni argumenti korišteni za različite uloge FFmpeg-a poput detekcije i snimanja.", + "detect": { + "label": "Izlazni argumenti za detekciju", + "description": "Zadani izlazni argumenti za snimke uloga detekcije." + }, + "record": { + "label": "Izlazni argumenti za snimanje", + "description": "Zadani izlazni argumenti za snimke uloga snimanja." + } + }, + "retry_interval": { + "label": "Vrijeme ponovnog pokušaja FFmpeg-a", + "description": "Sekunde koje treba čekati prije nego što se pokuša ponovno uspostaviti veza s tokom kamere nakon neuspjeha. Zadano je 10." + }, + "apple_compatibility": { + "label": "Kompatibilnost s Apple-om", + "description": "Omogući označavanje HEVC za bolju kompatibilnost s igračima Apple-a prilikom snimanja H.265." + }, + "gpu": { + "label": "Indeks GPU-a", + "description": "Zadani indeks GPU-a korišten za ubrzanje hardvera ako je dostupan." + }, + "inputs": { + "label": "Ulazni podaci kamere", + "description": "Popis definicija ulaznih tokova (putanje i uloge) za ovu kameru.", + "path": { + "label": "Putanja ulaza", + "description": "URL ili putanja ulaznog toka kamere." + }, + "roles": { + "label": "Uloge ulaza", + "description": "Uloge za ovaj ulazni tok." + }, + "global_args": { + "label": "Globalni argumenti FFmpeg-a", + "description": "Globalni argumenti FFmpeg-a za ovaj ulazni tok." + }, + "hwaccel_args": { + "label": "Argumenti za ubrzanje hardvera", + "description": "Argumenti za ubrzanje hardvera za ovaj ulazni stream." + }, + "input_args": { + "label": "Unos argumenata", + "description": "Argumeti unosa specifični za ovaj stream." + } + } + }, + "live": { + "label": "Uživo prikaz", + "streams": { + "label": "Imena živih streamova", + "description": "Mapiranje konfiguriranih imena streamova na imena restream/go2rtc korишtena za uživo prikaz." + }, + "height": { + "label": "Visina uživo", + "description": "Visina (piksela) za prikaz jsmpeg živog streama u Web UI; mora biti <= visina detektiranog streama." + }, + "quality": { + "label": "Kvalitet uživo", + "description": "Kvalitet kodiranja za jsmpeg stream (1 najviši, 31 najniži)." + }, + "description": "Postavke za kontrolu rezolucije i kvalitete žive struje jsmpeg. Ovo ne utiče na kamere koje koriste go2rtc za živi pregled." + }, + "lpr": { + "label": "Prepoznavanje tablice vozila", + "description": "Postavke prepoznavanja tablice vozila uključujući pragovi detekcije, formatiranje i poznate tablice.", + "enabled": { + "label": "Omogući LPR", + "description": "Omogući ili onemogući prepoznavanje tablice za sve kamere; može se prekršiti po kamere." + }, + "expire_time": { + "label": "Sekunde isteka", + "description": "Vrijeme u sekundama nakon kojeg nevidljiva tablica istječe iz praćenja (samo za dedikovane LPR kamere)." + }, + "min_area": { + "label": "Minimalna površina tablice", + "description": "Minimalna površina tablice (piksela) potrebna za pokušaj prepoznavanja." + }, + "enhancement": { + "label": "Nivo poboljšanja", + "description": "Nivo poboljšanja (0-10) za primjenu na isječke tablice prije OCR-a; veće vrijednosti ne moraju uvijek poboljšati rezultate, nivoi iznad 5 mogu raditi samo s tablicama u noćnom vremenu i trebaju se koristiti s oprezom." + }, + "model_size": { + "label": "Veličina modela", + "description": "Veličina modela korištena za detekciju/pretvorbu teksta. Većina korisnika treba koristiti 'small'." + }, + "detection_threshold": { + "label": "Prag detekcije", + "description": "Prag pouzdanosti detekcije za početak izvršavanja OCR na sumnjivim pločama." + }, + "recognition_threshold": { + "label": "Prag prepoznavanja", + "description": "Prag pouzdanosti potreban za prepoznati tekst ploče da bi se priložio kao podnaziv." + }, + "min_plate_length": { + "label": "Minimalna dužina ploče", + "description": "Minimalan broj znakova koje prepoznata ploča mora sadržavati da bi se smatrala važećom." + }, + "format": { + "label": "Regex formata ploče", + "description": "Nepovlačen regex za provjeru prepoznatih nizova ploča protiv očekivanog formata." + }, + "match_distance": { + "label": "Razlika u odgovaranju", + "description": "Broj nepravilnih znakova dopuštenih pri uspoređivanju detektiranih ploča s poznatim pločama." + }, + "known_plates": { + "label": "Poznate ploče", + "description": "Popis ploča ili regexa za posebno praćenje ili upozorenje." + }, + "debug_save_plates": { + "label": "Sačuvaj tablice za debagovanje", + "description": "Sačuvaj slike izrezaka tablica za debagovanje performansi LPR." + }, + "device": { + "label": "Uređaj", + "description": "Ovo je preklop za ciljanje specifičnog uređaja. Vidi https://onnxruntime.ai/docs/execution-providers/ za više informacija" + }, + "replace_rules": { + "label": "Pravila zamjene", + "description": "Pravila zamjene regex korишtena za normalizaciju detektiranih stringova ploča prije uspoređivanja.", + "pattern": { + "label": "Regex uzorak" + }, + "replacement": { + "label": "Zamjenski string" + } + } + }, + "motion": { + "label": "Detekcija pokreta", + "enabled": { + "label": "Omogući detekciju pokreta", + "description": "Omogući ili onemogući detekciju pokreta za sve kamere; može se prekrimiti po kamere." + }, + "threshold": { + "label": "Prag pokreta", + "description": "Prag razlike piksela korišten za detektor pokreta; veće vrijednosti smanjuju osjetljivost (opseg 1-255)." + }, + "lightning_threshold": { + "label": "Prag munje", + "description": "Prag za detekciju i zanemarivanje kratkih iskri svjetlosti (niže vrijednosti povećavaju osjetljivost, vrijednosti između 0.3 i 1.0). Ovo ne spriječava detekciju pokreta u potpunosti; jednostavno zaustavlja detektor da analizira dodatne okvire nakon što se prag premaši. Snimci temeljeni na pokretima i dalje se stvaraju tijekom ovih događaja." + }, + "skip_motion_threshold": { + "label": "Preskoči prag pokreta", + "description": "Ako se postavi na vrijednost između 0.0 i 1.0, i ako se više od ovog udjela slike promijeni u jednom okviru, detektor neće vratiti kutije pokreta i odmah će se ponovno kalibrirati. Ovo može uštedjeti CPU i smanjiti lažne pozitive tijekom munje, oluje itd., ali može propustiti stvarne događaje kao što je automatsko praćenje objekta PTZ kamerom. Tržište je između izgube nekoliko megabajta snimaka i pregleda nekoliko kratkih zapisnika. Ostavite nepostavljeno (Nijedno) za onemogućavanje ove funkcije." + }, + "improve_contrast": { + "label": "Poboljšaj kontrast", + "description": "Primijeni poboljšanje kontrasta na okvire prije analize pokreta kako bi pomoću detekcije." + }, + "contour_area": { + "label": "Površina kontura", + "description": "Minimalna površina kontura u pikselima potrebna za brojanje kontura pokreta." + }, + "delta_alpha": { + "label": "Delta alfa", + "description": "Faktor alfa spajanja korišten za razliku okvira za izračun pokreta." + }, + "frame_alpha": { + "label": "Alfa okvira", + "description": "Vrijednost alfa korištena prilikom spajanja okvira za predobradbu pokreta." + }, + "frame_height": { + "label": "Visina okvira", + "description": "Visina u pikselima na koju se skaliraju okviri prilikom izračuna pokreta." + }, + "mask": { + "label": "Koordinate maska", + "description": "Uredno x,y koordinate koje definiraju poligon maska pokreta za uključivanje/isključivanje područja." + }, + "mqtt_off_delay": { + "label": "MQTT zakasnjenje isključivanja", + "description": "Sekunde koje se čekaju nakon posljednjeg pokreta prije objave MQTT 'isključeno' stanje." + }, + "enabled_in_config": { + "label": "Originalno stanje pokreta", + "description": "Indikira je li detekcija pokreta bila omogućena u originalnoj statičkoj konfiguraciji." + }, + "raw_mask": { + "label": "Ručna maska" + }, + "description": "Zadane postavke detekcije pokreta primjenjene na kamere osim ako se prekrivaju po kamere." + }, + "objects": { + "label": "Objekti", + "description": "Zadani parametri praćenja objekata uključujući koje oznake praćenja i filtre po objektu.", + "track": { + "label": "Objekti za praćenje", + "description": "Popis oznaka objekata za praćenje za sve kamere; može se prekrimiti po kamere." + }, + "filters": { + "label": "Filtar objekata", + "description": "Filtar primijenjen na detektirane objekte kako bi se smanjila broj lažnih pozitiva (površina, omjer, pouzdanost).", + "min_area": { + "label": "Minimalna površina objekta", + "description": "Minimalna površina okvira (pikseli ili postotak) potrebna za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)." + }, + "max_area": { + "label": "Maksimalna površina objekta", + "description": "Maksimalna površina okvira (pikseli ili postotak) dozvoljena za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)." + }, + "min_ratio": { + "label": "Minimalni omjer visine/širine", + "description": "Minimalni omjer širine/visine potreban da bi okvir bio prihvaćen." + }, + "max_ratio": { + "label": "Maksimalni omjer visine/širine", + "description": "Maksimalni omjer širine/visine dozvoljen da bi okvir bio prihvaćen." + }, + "threshold": { + "label": "Prag pouzdanosti", + "description": "Prosjek pragova pouzdanosti detekcije potreban da bi objekt bio smatravan pravim pozitivom." + }, + "min_score": { + "label": "Minimalna pouzdanost", + "description": "Minimalna pouzdanost detekcije po okviru potrebna da bi objekt bio brojan." + }, + "mask": { + "label": "Maska filtriranja", + "description": "Koordinate poligona koje definiraju područje na kojem se ovaj filter primjenjuje unutar okvira." + }, + "raw_mask": { + "label": "Ručna maska" + } + }, + "mask": { + "label": "Maska objekta", + "description": "Poligonalna maska korištena za spriječavanje detekcije objekta u određenim područjima." + }, + "raw_mask": { + "label": "Ručna maska" + }, + "genai": { + "label": "Konfiguracija GenAI objekta", + "description": "Opcije GenAI za opisivanje praćenih objekata i slanje okvira za generisanje.", + "enabled": { + "label": "Omogući GenAI", + "description": "Omogući generisanje opisa za praćene objekte po zadanim postavkama." + }, + "use_snapshot": { + "label": "Koristi snimke", + "description": "Koristi snimke objekata umjesto miniaturnih slika za generisanje opisa GenAI." + }, + "prompt": { + "label": "Naslovni prompt", + "description": "Zadani šablon upita korišten za generisanje opisa pomoću GenAI." + }, + "object_prompts": { + "label": "Prompti za objekte", + "description": "Prompti po objektu za prilagođavanje izlaza GenAI za specifične oznake." + }, + "objects": { + "label": "GenAI objekti", + "description": "Popis oznaka objekata koje se po defaultu šalju GenAI." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje moraju biti unesene za objekte da bi se kvalifikovali za generisanje opisa GenAI." + }, + "debug_save_thumbnails": { + "label": "Sačuvajte miniaturne slike", + "description": "Sačuvaj miniaturne slike koje se šalju GenAI za ispravljanje i pregled." + }, + "send_triggers": { + "label": "GenAI izazivači", + "description": "Definiše kada bi se trebale slati okvir za GenAI (na kraju, nakon ažuriranja, itd.).", + "tracked_object_end": { + "label": "Pošalji na kraju", + "description": "Pošalji zahtjev GenAI kada praćeni objekt završi." + }, + "after_significant_updates": { + "label": "Raniji GenAI izazivač", + "description": "Pošalji zahtjev GenAI nakon određenog broja značajnih ažuriranja za praćeni objekt." + } + }, + "enabled_in_config": { + "label": "Originalno stanje GenAI", + "description": "Pokazuje je li GenAI bio omogućen u originalnoj statičkoj konfiguraciji." + } + } + }, + "record": { + "label": "Snimanje", + "enabled": { + "label": "Omogući snimanje", + "description": "Omogući ili onemogući snimanje za sve kamere; može se prekrimiti po kamere." + }, + "expire_interval": { + "label": "Interval čišćenja snimanja", + "description": "Minute između čišćenja koja uklanjaju istekle segmente snimaka." + }, + "continuous": { + "label": "Neprekidna retencija", + "description": "Broj dana za čuvanje snimaka bez obzira na praćene objekte ili pokret. Postavite na 0 ako želite da čuvate samo snimke upozorenja i detekcije.", + "days": { + "label": "Dane zadržavanja", + "description": "Dana za čuvanje snimaka." + } + }, + "motion": { + "label": "Retencija pokreta", + "description": "Broj dana za čuvanje snimaka izazvanih pokretom bez obzira na praćene objekte. Postavite na 0 ako želite da čuvate samo snimke upozorenja i detekcije.", + "days": { + "label": "Dane zadržavanja", + "description": "Dana za čuvanje snimaka." + } + }, + "detections": { + "label": "Retencija detekcije", + "description": "Postavke retencije snimaka za događaje detekcije uključujući trajanje pre/post snimanja.", + "pre_capture": { + "label": "Sekundi pre snimanja", + "description": "Broj sekundi prije događaja detekcije koje treba uključiti u snimak." + }, + "post_capture": { + "label": "Sekunde nakon snimanja", + "description": "Broj sekundi nakon događaja detekcije koje se uključuju u snimanje." + }, + "retain": { + "label": "Zadržavanje događaja", + "description": "Postavke zadržavanja za snimke događaja detekcije.", + "days": { + "label": "Dane zadržavanja", + "description": "Broj dana za koje se zadržavaju snimke događaja detekcije." + }, + "mode": { + "label": "Način zadržavanja", + "description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)." + } + } + }, + "alerts": { + "label": "Retencija upozorenja", + "description": "Postavke retencije snimaka za događaje upozorenja uključujući trajanje pre/post snimanja.", + "pre_capture": { + "label": "Sekundi pre snimanja", + "description": "Broj sekundi prije događaja detekcije koje treba uključiti u snimak." + }, + "post_capture": { + "label": "Sekunde nakon snimanja", + "description": "Broj sekundi nakon događaja detekcije koje se uključuju u snimanje." + }, + "retain": { + "label": "Zadržavanje događaja", + "description": "Postavke zadržavanja za snimke događaja detekcije.", + "days": { + "label": "Dane zadržavanja", + "description": "Broj dana za koje se zadržavaju snimke događaja detekcije." + }, + "mode": { + "label": "Način zadržavanja", + "description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)." + } + } + }, + "export": { + "label": "Konfiguracija izvoza", + "description": "Postavke koje se koriste prilikom izvoza snimaka kao što su timelapse i ubrzavanje dretve.", + "hwaccel_args": { + "label": "Argumeti ubrzavanja dretve za izvoz", + "description": "Argumeti ubrzavanja dretve za operacije izvoza/prenosa." + }, + "max_concurrent": { + "label": "Maksimalan broj istovremenih izvoza", + "description": "Maksimalan broj poslova izvoza koji se obrađuju istovremeno." + } + }, + "preview": { + "label": "Konfiguracija pregleda", + "description": "Postavke koje kontrolišu kvalitet pregleda snimanja prikazanih u UI.", + "quality": { + "label": "Kvaliteta pregleda", + "description": "Nivo kvalitete pregleda (vrlo_nizak, nizak, srednji, visok, vrlo_visok)." + } + }, + "enabled_in_config": { + "label": "Originalno stanje snimanja", + "description": "Pokazuje je li snimanje bilo omogućeno u originalnoj statičkoj konfiguraciji." + }, + "description": "Postavke za snimanje i zadržavanje primjenjene na kamere osim ako se prekrivaju po kamere." + }, + "review": { + "label": "Pregled", + "alerts": { + "label": "Konfiguracija upozorenja", + "description": "Postavke za koje objekti praćeni generišu upozorenja i kako se upozorenja zadržavaju.", + "enabled": { + "label": "Omogući upozorenja", + "description": "Omogući ili onemogući generisanje upozorenja za sve kamere; može se prekrimiti po kamere." + }, + "labels": { + "label": "Oznake upozorenja", + "description": "Lista oznaka objekata koje se smatraju upozorenjima (npr. automobil, osoba)." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje objekt mora ući da bi se smatrao upozorenjem; ostavite prazno da omogućite bilo koju zonu." + }, + "enabled_in_config": { + "label": "Originalno stanje upozorenja", + "description": "Pratiti je li upozorenja izvorno omogućena u statičkoj konfiguraciji." + }, + "cutoff_time": { + "label": "Vrijeme prekida upozorenja", + "description": "Sekunde koje treba čekati nakon što nema aktivnosti koja uzrokuje upozorenje prije nego se prekine upozorenje." + } + }, + "detections": { + "label": "Konfiguracija detekcija", + "description": "Postavke koje objekti koje se praćenje generišu detekcije (nepozornja) i kako se detekcije čuvaju.", + "enabled": { + "label": "Omogući detekcije", + "description": "Omogući ili onemogući događaje detekcije za sve kamere; može se prekrimiti po kamere." + }, + "labels": { + "label": "Oznake detekcije", + "description": "Popis oznaka objekata koje kvalifikuju kao događaji detekcije." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje objekt mora ući da bi se smatrao detekcijom; ostavite prazno da omogućite bilo koju zonu." + }, + "cutoff_time": { + "label": "Vrijeme prekida detekcija", + "description": "Sekunde koje treba čekati nakon što nema aktivnosti koja uzrokuje detekciju prije nego se prekine detekcija." + }, + "enabled_in_config": { + "label": "Originalno stanje detekcija", + "description": "Pratiti je li detekcije izvorno omogućene u statičkoj konfiguraciji." + } + }, + "genai": { + "label": "Konfiguracija GenAI", + "description": "Kontrolira korištenje generativne AI za proizvodnju opisa i sažetaka stavki za pregled.", + "enabled": { + "label": "Omogući opise GenAI", + "description": "Omogući ili onemogući opise i sažetke generirane GenAI za stavke za pregled." + }, + "alerts": { + "label": "Omogući GenAI za upozorenja", + "description": "Koristi GenAI za generiranje opisa stavki upozorenja." + }, + "detections": { + "label": "Omogući GenAI za detekcije", + "description": "Koristite GenAI za generiranje opisa predmeta detekcije." + }, + "image_source": { + "label": "Pregledajte izvor slike", + "description": "Izvor slika poslatih GenAIJ-u ('preview' ili 'recordings'); 'recordings' koristi kvalitetnije okvire, ali više tokena." + }, + "additional_concerns": { + "label": "Dodatne brige", + "description": "Popis dodatnih briga ili napomena koje GenAI treba uzeti u obzir prilikom procjene aktivnosti na ovoj kameri." + }, + "debug_save_thumbnails": { + "label": "Sačuvajte miniaturne slike", + "description": "Sačuvajte miniaturne slike koje se šalju GenAI provajderu za ispravljanje grešaka i pregled." + }, + "enabled_in_config": { + "label": "Originalno stanje GenAI", + "description": "Pratiti je li pregled GenAI izvorno omogućen u statičkoj konfiguraciji." + }, + "preferred_language": { + "label": "Preferirani jezik", + "description": "Preferirani jezik za zahtijevanje od GenAI provajdera za generirane odgovore." + }, + "activity_context_prompt": { + "label": "Prompt konteksta aktivnosti", + "description": "Prilagođeni prompt koji opisuje što je i što nije sumnjivo ponašanje kako bi pružio kontekst za sažetke GenAI." + } + }, + "description": "Postavke koje kontrolišu upozorenja, detekcije i GenAI pregledne sažetke korišteni od strane UI i skladišta." + }, + "semantic_search": { + "label": "Semantička pretraga", + "triggers": { + "label": "Pokretači", + "description": "Akcije i kriteriji za usklađivanje za pokretače semantičke pretrage specifične za kameru.", + "friendly_name": { + "label": "Prijateljsko ime", + "description": "Nepovlačno prijateljsko ime prikazano u korisničkom sučelju za ovaj pokretač." + }, + "enabled": { + "label": "Omogući ovaj pokretač", + "description": "Omogući ili onemogući ovaj pokretač semantičke pretrage." + }, + "type": { + "label": "Tip pokretača", + "description": "Tip pokretača: 'thumbnail' (uspoređivanje slikom) ili 'description' (uspoređivanje teksta)." + }, + "data": { + "label": "Sadržaj pokretača", + "description": "Tekstualni izraz ili ID miniaturne slike za uspoređivanje s praćenim objektima." + }, + "threshold": { + "label": "Prag aktivacije", + "description": "Minimalna ocjena sličnosti (0-1) potrebna za aktivaciju ovog izazivača." + }, + "actions": { + "label": "Akcije izazivača", + "description": "Popis akcija koje se izvršavaju kada izazivač odgovara (obavijest, pod_naziv, atribute)." + } + }, + "description": "Postavke za semantičku pretragu koja građi i upita objektne ugradnje da bi pronašla slične stavke.", + "enabled": { + "label": "Omogući semantičku pretragu", + "description": "Omogući ili onemogući funkciju semantičke pretrage." + }, + "reindex": { + "label": "Ponovno indeksiranje pri pokretanju", + "description": "Pokrenite puno ponovno indeksiranje povijesnih praćenih objekata u bazu ugradnji." + }, + "model": { + "label": "Ime modela za semantičku pretragu ili dobavljača GenAI", + "description": "Model ugradnje koji se koristi za semantičku pretragu (npr. 'jinav1'), ili ime dobavljača GenAI s ulogom ugradnje." + }, + "model_size": { + "label": "Veličina modela", + "description": "Izaberite veličinu modela; 'small' radi na CPU i 'large' obično zahtijeva GPU." + }, + "device": { + "label": "Uređaj", + "description": "Ovo je preklop za ciljanje specifičnog uređaja. Vidi https://onnxruntime.ai/docs/execution-providers/ za više informacija" + } + }, + "snapshots": { + "label": "Snimci", + "enabled": { + "label": "Omogući snimke", + "description": "Omogući ili onemogući sačuvanje snimaka za sve kamere; može se prekrimiti po kamere." + }, + "timestamp": { + "label": "Preklapanje vremenske oznake", + "description": "Preklopiti vremensku oznaku na snimke iz API-ja." + }, + "bounding_box": { + "label": "Preklapanje okvira", + "description": "Crtanje okvira za praćene objekte na snimke iz API-ja." + }, + "crop": { + "label": "Izrezivanje snimke", + "description": "Izrezivanje snimki iz API-ja do okvira detektiranog objekta." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje objekt mora ući da bi snimka bila sačuvana." + }, + "height": { + "label": "Visina snimke", + "description": "Visina (pikseli) za promjenu veličine snimki iz API-ja; ostavite prazno da biste sačuvali originalnu veličinu." + }, + "retain": { + "label": "Zadržavanje snimki", + "description": "Postavke zadržavanja snimki uključujući zadane dane i prekriženja po objektu.", + "default": { + "label": "Zadano zadržavanje", + "description": "Zadani broj dana za zadržavanje snimki." + }, + "mode": { + "label": "Način zadržavanja", + "description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)." + }, + "objects": { + "label": "Zadržavanje objekata", + "description": "Prekriženja po objektu za dane zadržavanja snimki." + } + }, + "quality": { + "label": "Kvaliteta snimka", + "description": "Kvaliteta kodiranja za sačuvane snimke (0-100)." + }, + "description": "Postavke za API generisane snimke praćenih objekata za sve kamere; može se prekrimiti po kamere." + }, + "timestamp_style": { + "label": "Stil vremenske oznake", + "position": { + "label": "Pozicija vremenske oznake", + "description": "Pozicija vremenske oznake na slici (tl/tr/bl/br)." + }, + "format": { + "label": "Format vremenske oznake", + "description": "String formata datuma i vremena korišten za vremenske oznake (Python format koda za datum i vrijeme)." + }, + "color": { + "label": "Boja vremenske oznake", + "description": "RGB vrijednosti boja za tekst vremenske oznake (sve vrijednosti 0-255).", + "red": { + "label": "Crvena", + "description": "Crveni komponent (0-255) za boju vremenske oznake." + }, + "green": { + "label": "Zelena", + "description": "Zeleni komponent (0-255) za boju vremenske oznake." + }, + "blue": { + "label": "Plava", + "description": "Plavi komponent (0-255) za boju vremenske oznake." + } + }, + "thickness": { + "label": "Debljina vremenske oznake", + "description": "Debljina linije teksta vremenske oznake." + }, + "effect": { + "label": "Efekt vremenske oznake", + "description": "Vizualni efekt za tekst vremenske oznake (none, solid, shadow)." + }, + "description": "Opcije stilizacije vremenskih oznaka u toku prikaza primjenjene na debug prikaz i snimke." + }, + "mqtt": { + "label": "MQTT", + "description": "Postavke za povezivanje i objavljivanje telemetrije, snimaka i detalja događaja na MQTT brokera.", + "enabled": { + "label": "Omogući MQTT", + "description": "Omogući ili onemogući integraciju MQTT za stanje, događaje i snimke." + }, + "host": { + "label": "Gospodar MQTT", + "description": "Ime domene ili IP adresa MQTT brokera." + }, + "port": { + "label": "Port MQTT", + "description": "Port MQTT brokera (obično 1883 za običan MQTT)." + }, + "topic_prefix": { + "label": "Predfiks teme", + "description": "Predložak teme MQTT za sve teme Frigate; mora biti jedinstven ako pokrećete više instanci." + }, + "client_id": { + "label": "ID klijenta", + "description": "Identifikator klijenta korišten pri povezivanju s MQTT brokerom; trebao bi biti jedinstven po instanci." + }, + "stats_interval": { + "label": "Interval statistika", + "description": "Interval u sekundama za objavljivanje sustavnih i kamera statistika na MQTT." + }, + "user": { + "label": "Korisničko ime MQTT", + "description": "Nepovlačno korisničko ime MQTT; može se pružiti putem varijabli okoline ili vjerodajnica." + }, + "password": { + "label": "Lozinka MQTT", + "description": "Nepovlačna lozinka MQTT; može se pružiti putem varijabli okoline ili vjerodajnica." + }, + "tls_ca_certs": { + "label": "TLS CA sertifikati", + "description": "Putanja do sertifikata CA za TLS povezivanje s brokerom (za samopotpisane sertifikate)." + }, + "tls_client_cert": { + "label": "Klijent sertifikat", + "description": "Putanja do sertifikata klijenta za TLS međusobnu autentifikaciju; ne postavljajte korisničko ime/lozinku kada koristite sertifikate klijenta." + }, + "tls_client_key": { + "label": "Klijent ključ", + "description": "Putanja do privatnog ključa za klijent sertifikat." + }, + "tls_insecure": { + "label": "TLS nebezbedan", + "description": "Dozvoli nebezbedne TLS povezivanja preskačući provjeru imena domene (nije preporučeno)." + }, + "qos": { + "label": "MQTT QoS", + "description": "Nivo kvaliteta usluge za MQTT objave/pretplate (0, 1 ili 2)." + } + }, + "notifications": { + "label": "Obavještenja", + "enabled": { + "label": "Omogući obavijesti", + "description": "Omogući ili onemogući obavijesti za sve kamere; mogu se prekrivati po kamere." + }, + "email": { + "label": "E-mail za obavijesti", + "description": "Adresa e-maila koja se koristi za obavijesti putem push-a ili je potrebna određenim dobavljačima obavijesti." + }, + "cooldown": { + "label": "Period hlađenja", + "description": "Period hlađenja (sekunde) između obavijesti kako bi se izbjeglo spaming primateljima." + }, + "enabled_in_config": { + "label": "Originalno stanje obavijesti", + "description": "Pokazuje je li obavijesti bile omogućene u originalnoj statičkoj konfiguraciji." + }, + "description": "Postavke za omogućavanje i kontrolu obavijesti za sve kamere; mogu se prekrivati po kamere." + }, + "onvif": { + "label": "ONVIF", + "description": "Postavke povezivanja preko ONVIF i automatskog praćenja PTZ za ovu kameru.", + "host": { + "label": "Gost ONVIF", + "description": "Gost (i opcionalni shema) za uslugu ONVIF za ovu kameru." + }, + "port": { + "label": "Port ONVIF", + "description": "Broj porta za uslugu ONVIF." + }, + "user": { + "label": "Korisničko ime za ONVIF", + "description": "Korisničko ime za autentifikaciju ONVIF; neki uređaji zahtijevaju korisnika admin za ONVIF." + }, + "password": { + "label": "Lozinka za ONVIF", + "description": "Lozinka za autentifikaciju ONVIF." + }, + "tls_insecure": { + "label": "Onemogući provjeru TLS", + "description": "Preskoči provjeru TLS i onemogući digest autentifikaciju za ONVIF (nebezbedno; koristiti samo u sigurnim mrežama)." + }, + "profile": { + "label": "ONVIF profil", + "description": "Specifičan ONVIF medij profil za korištenje za kontrolu PTZ, prilagođen tokenom ili imenom. Ako nije postavljen, prvi profil s važećom konfiguracijom PTZ automatski se odabire." + }, + "autotracking": { + "label": "Autotračenje", + "description": "Automatski praćenje pokretanja objekata i držanje ih u sredini okvira korištenjem pokreta kamere PTZ.", + "enabled": { + "label": "Omogući automatsko praćenje", + "description": "Omogući ili onemogući automatsko praćenje kamere PTZ detektiranih objekata." + }, + "calibrate_on_startup": { + "label": "Kalibriraj na početku", + "description": "Mjeri brzine motora PTZ pri pokretanju kako bi poboljšao preciznost praćenja. Frigate će ažurirati konfiguraciju s težinama pokreta nakon kalibracije." + }, + "zooming": { + "label": "Režim zumiranja", + "description": "Kontrola ponašanja zumiranja: onemogućeno (samo pan/tilt), apsolutno (najkompatibilnije) ili relativno (konkurentno pan/tilt/zum)." + }, + "zoom_factor": { + "label": "Faktor zumiranja", + "description": "Kontrola razine zumiranja na praćenim objektima. Niže vrijednosti drže više scene u pogledu; više vrijednosti zumiraju bliže, ali mogu izgubiti praćenje. Vrijednosti između 0.1 i 0.75." + }, + "track": { + "label": "Praćeni objekti", + "description": "Popis vrsta objekata koji trebaju pokrenuti automatsko praćenje." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Objekti moraju ući u jednu od ovih zona prije nego što započne automatsko praćenje." + }, + "return_preset": { + "label": "Povratak na predpostavku", + "description": "Ime predpostavke konfigurirano u firmware kamere za povratak nakon završetka praćenja." + }, + "timeout": { + "label": "Vrijeme čekanja povratka", + "description": "Čekajte ovaj broj sekundi nakon gubitka praćenja prije povratka kamere na predpostavljeno mjesto." + }, + "movement_weights": { + "label": "Težine pokreta", + "description": "Vrijednosti kalibracije automatski generirane kroz kalibraciju kamere. Ne mijenjajte ručno." + }, + "enabled_in_config": { + "label": "Originalni stanje autotračenja", + "description": "Unutarnje polje za praćenje je li autotračenje bilo omogućeno u konfiguraciji." + } + }, + "ignore_time_mismatch": { + "label": "Zanemari razliku u vremenu", + "description": "Zanemari razlike u sinhronizaciji vremena između kamere i Frigate servera za komunikaciju ONVIF." + } + }, + "profiles": { + "label": "Profili", + "description": "Imenovane definicije profila s prijateljivim imenima. Profili kamera moraju se referirati na imena definirana ovdje.", + "friendly_name": { + "label": "Prijateljsko ime", + "description": "Prikazano ime za ovaj profil prikazano u UI-u." + } + }, + "safe_mode": { + "label": "Sigurnosni režim", + "description": "Kada je omogućeno, pokrenite Frigate u sigurnosnom režimu s smanjenim funkcijama za uklanjanje problema." + }, + "environment_vars": { + "label": "Okolinski varijable", + "description": "Parovi ključ/vrijednost okolinskih varijabli za postavljanje za proces Frigate u Home Assistant OS. Korisnici koji nisu HAOS moraju koristiti konfiguraciju okolinskih varijabli Docker umjesto toga." + }, + "logger": { + "label": "Zapisi", + "description": "Kontrolira podrazumijevanu razinu detaljnosti zapisa i prekriženja razina detaljnosti po komponenti.", + "default": { + "label": "Razina zapisa", + "description": "Podrazumijevana globalna razina detaljnosti (debug, info, warning, error)." + }, + "logs": { + "label": "Razina zapisa po procesu", + "description": "Prekriženja razina detaljnosti po komponenti za povećanje ili smanjenje detaljnosti za određene module." + } + }, + "auth": { + "label": "Autentifikacija", + "description": "Postavke povezane s autentifikacijom i sesijama uključujući opcije kolačića i ograničenja brzine.", + "enabled": { + "label": "Omogući autentifikaciju", + "description": "Omogući nativnu autentifikaciju za korisnički sučelje Frigate." + }, + "reset_admin_password": { + "label": "Ponovno postavljanje lozinke administratora", + "description": "Ako je tačno, ponovno postavite lozinku korisnika administratora pri pokretanju i ispišite novu lozinku u zapisima." + }, + "cookie_name": { + "label": "Ime kolačića JWT", + "description": "Ime kolačića koji se koristi za pohranjivanje JWT tokena za nativnu autentifikaciju." + }, + "cookie_secure": { + "label": "Sigurnosni flag kolačića", + "description": "Postavite sigurnosni flag na kolačić autentifikacije; trebalo bi biti tačno kada se koristi TLS." + }, + "session_length": { + "label": "Trajanje sesije", + "description": "Trajanje sesije u sekundama za sesije temeljene na JWT." + }, + "refresh_time": { + "label": "Prozor osvežavanja sesije", + "description": "Kada se sesija nalazi unutar ovih sekundi do isteka, ponovo je ažurirati na punu dužinu." + }, + "failed_login_rate_limit": { + "label": "Ograničenja za neuspješne prijave", + "description": "Pravila ograničavanja brzine za neuspješne pokušaje prijave kako bi se smanjila napada silom." + }, + "trusted_proxies": { + "label": "Povereni proxy-ovi", + "description": "Lista IP adresa poverenih proxy-ova korištena prilikom određivanja IP adrese klijenta za ograničavanje brzine." + }, + "hash_iterations": { + "label": "Iteracije haširanja", + "description": "Broj iteracija PBKDF2-SHA256 koje se koriste za kriptiranje lozinki korisnika." + }, + "roles": { + "label": "Mapiranja uloga", + "description": "Pridružiti uloge listama kamera. Prazna lista omogućava pristup svim kamerama za ulogu." + }, + "admin_first_time_login": { + "label": "Zastavica za prvi put administrator", + "description": "Kada je istina, UI može prikazati poveznicu za pomoć na stranici prijave koja obavješćuje korisnike kako se prijaviti nakon ponovnog postavljanja lozinke administratora. " + } + }, + "database": { + "label": "Baza podataka", + "description": "Postavke SQLite baze podataka korištene od strane Frigate za pohranjivanje metapodataka praćenih objekata i metapodataka snimaka.", + "path": { + "label": "Putanja do baze podataka", + "description": "Putanja datotečnog sustava gdje će se datoteka SQLite baze podataka Frigate pohraniti." + } + }, + "go2rtc": { + "label": "go2rtc", + "description": "Postavke integrirane usluge go2rtc ponovnog prenošenja korištene za prenošenje živih streamova i prevodjenje." + }, + "networking": { + "label": "Mrežno", + "description": "Postavke povezane s mrežom, kao što je omogućavanje IPv6 za Frigate krajeve.", + "ipv6": { + "label": "Konfiguracija IPv6", + "description": "IPv6-specifične postavke za mrežne usluge Frigate.", + "enabled": { + "label": "Omogući IPv6", + "description": "Omogući podršku za IPv6 za usluge Frigate (API i UI) gdje je primjenjivo." + } + }, + "listen": { + "label": "Konfiguracija slušajućih porta", + "description": "Konfiguracija unutarnjih i vanjskih slušajućih porta. Ovo je za napredne korisnike. Za većinu slučajeva preporučuje se promijeniti sekciju porta u svojoj Docker compose datoteci.", + "internal": { + "label": "Unutarnji port", + "description": "Unutarnji slušajući port za Frigate (zadano 5000)." + }, + "external": { + "label": "Vanjski port", + "description": "Vanjski slušajući port za Frigate (zadano 8971)." + } + } + }, + "proxy": { + "label": "Proxy", + "description": "Postavke za integraciju Frigate iza obrnute proxy posrednike koji prenose zaglavlja autentificiranih korisnika.", + "header_map": { + "label": "Mapiranje zaglavlja", + "description": "Mapiraj dolazna zaglavlja proxy-a na polja korisnika i uloge Frigate za autentifikaciju baziranu na proxy-u.", + "user": { + "label": "Zaglavlje korisnika", + "description": "Zaglavlje koje sadrži autentificirano korisničko ime pruženo od strane nadolazećeg proxy-a." + }, + "role": { + "label": "Zaglavlje uloge", + "description": "Zaglavlje koje sadrži ulogu ili grupe autentificiranog korisnika od strane nadolazećeg proxy-a." + }, + "role_map": { + "label": "Mapiranje uloga", + "description": "Mapiraj vrijednosti grupe iznad na uloge Frigate (npr. mapiraj grupe administratora na ulogu administratora)." + } + }, + "logout_url": { + "label": "URL za odjavu", + "description": "URL na koji će korisnici biti preusmjereni kada se odjave putem proxy-a." + }, + "auth_secret": { + "label": "Tajna proxy", + "description": "Nepovlačena tajna provjeravana protiv zaglavlja X-Proxy-Secret za potvrdu pouzdanih proxy-a." + }, + "default_role": { + "label": "Zadana uloga", + "description": "Zadana uloga dodijeljena korisnicima autentificiranim putem proxy-a kada neka mapiranja uloga ne vrijede (administrator ili pregledač)." + }, + "separator": { + "label": "Znak separatora", + "description": "Karakter koristen za razdvajanje više vrijednosti navedenih u zaglavju proksi." + } + }, + "telemetry": { + "label": "Telemetrija", + "description": "Opcije sistem telemetrije i statistika uključujući praćenje širine pojasa mreže i GPU.", + "network_interfaces": { + "label": "Mrežni sučelja", + "description": "Popis prefiksa imena mrežnih sučelja za praćenje statistika širine pojasa." + }, + "stats": { + "label": "Sistem statistika", + "description": "Opcije za omogućavanje/onemogućavanje prikupljanja različitih sistem i GPU statistika.", + "amd_gpu_stats": { + "label": "AMD GPU statistika", + "description": "Omogući prikupljanje AMD GPU statistika ako je prisutan AMD GPU." + }, + "intel_gpu_stats": { + "label": "Intel GPU statistika", + "description": "Omogući prikupljanje Intel GPU statistika ako je prisutan Intel GPU." + }, + "network_bandwidth": { + "label": "Širina pojasa mreže", + "description": "Omogući praćenje širine pojasa mreže po procesu za procese kamere ffmpeg i detektore (zahtijeva mogućnosti)." + }, + "intel_gpu_device": { + "label": "Intel GPU uređaj", + "description": "PCI adresa magistrale ili DRM putanja uređaja (npr. /dev/dri/card1) koja se koristi za vezivanje Intel GPU statistika za određeni uređaj kada je prisutno više njih." + } + }, + "version_check": { + "label": "Provjera verzije", + "description": "Omogući ishodnu provjeru za otkrivanje ako je dostupnija verzija Frigate." + } + }, + "tls": { + "label": "TLS", + "description": "Postavke TLS za web krajnje točke Frigate (port 8971).", + "enabled": { + "label": "Omogući TLS", + "description": "Omogući TLS za web UI i API Frigate na konfiguriranom TLS portu." + } + }, + "ui": { + "label": "UI", + "description": "Postavke korisničkog sučelja poput vremenske zone, oblikovanja vremena/datuma i jedinica.", + "timezone": { + "label": "Vremenska zona", + "description": "Nepovlačena vremenska zona za prikaz kroz UI (podrazumijevano je lokalno vrijeme preglednika ako nije postavljeno)." + }, + "time_format": { + "label": "Oblik vremena", + "description": "Oblik vremena za korištenje u UI (browser, 12hour, ili 24hour)." + }, + "date_style": { + "label": "Oblik datuma", + "description": "Oblik datuma za korištenje u UI (full, long, medium, short)." + }, + "time_style": { + "label": "Oblik vremena", + "description": "Oblik vremena za korištenje u UI (full, long, medium, short)." + }, + "unit_system": { + "label": "Sustav jedinica", + "description": "Sustav jedinica za prikaz (metric ili imperial) korišten u UI i MQTT." + } + }, + "detectors": { + "label": "Hardver detektora", + "description": "Konfiguracija za detektore objekata (CPU, GPU, ONNX backends) i bilo koje postavke modela specifične za detektor.", + "type": { + "label": "Tip" + }, + "model": { + "label": "Konfiguracija modela specifične za detektor", + "description": "Opcije konfiguracije modela specifične za detektor (putanja, veličina ulaza, itd.).", + "path": { + "label": "Putanja za prilagođeni model detektora objekata", + "description": "Putanja do datoteke prilagođenog modela detekcije (ili plus:// za modele Frigate+)." + }, + "labelmap_path": { + "label": "Mapa oznaka za prilagođeni detektor objekata", + "description": "Putanja do datoteke mape oznaka koja mapira numeričke klase na string oznake za detektor." + }, + "width": { + "label": "Širina ulaznog tenzora modela detekcije objekata", + "description": "Širina ulaznog tenzora modela u pikselima." + }, + "height": { + "label": "Visina ulaznog tenzora modela detekcije objekata", + "description": "Visina ulaznog tenzora modela u pikselima." + }, + "labelmap": { + "label": "Prilagodba mape oznaka", + "description": "Preklop ili preslikavanje unosa za uključivanje u standardnu mapu oznaka." + }, + "attributes_map": { + "label": "Mapa oznaka objekata na njihove atribute", + "description": "Preslikavanje iz oznaka objekata na atribute oznaka koje se koriste za dodavanje metapodataka (npr. 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Oblik tenzora ulaza modela", + "description": "Format tenzora očekivan od strane modela: 'nhwc' ili 'nchw'." + }, + "input_pixel_format": { + "label": "Format boje piksela ulaza modela", + "description": "Boja piksela očekivana od strane modela: 'rgb', 'bgr' ili 'yuv'." + }, + "input_dtype": { + "label": "Tip D ulaza modela", + "description": "Tip podataka modela ulaznog tenzora (npr. 'float32')." + }, + "model_type": { + "label": "Tip modela detekcije objekata", + "description": "Tip arhitekture modela detektora (ssd, yolox, yolonas) korišten od strane nekih detektora za optimizaciju." + } + }, + "model_path": { + "label": "Putanja modela specifična za detektor", + "description": "Putanja datoteke do binarne datoteke modela detektora ako je potrebna odabranim detektorom." + }, + "axengine": { + "label": "AXEngine NPU", + "description": "Detektor AXERA AX650N/AX8850N NPU koji pokreće prevedene .axmodel datoteke putem AXEngine runtime-a." + }, + "cpu": { + "label": "CPU", + "description": "Detektor CPU TFLite koji pokreće modele TensorFlow Lite na domaćem CPU bez hardverske akceleracije. Nije preporučeno.", + "num_threads": { + "label": "Broj nitova detekcije", + "description": "Broj nitova korištenih za inferenciju na CPU." + } + }, + "deepstack": { + "label": "DeepStack", + "description": "Detektor DeepStack/CodeProject.AI koji šalje slike na udaljenu DeepStack HTTP API za inferenciju. Nije preporučeno.", + "api_url": { + "label": "URL API-ja DeepStack", + "description": "URL API-ja DeepStack." + }, + "api_timeout": { + "label": "Vrijeme čekanja API-ja DeepStack (u sekundama)", + "description": "Maksimalno dozvoljeno vrijeme za zahtjev API-ja DeepStack." + }, + "api_key": { + "label": "Ključ API-ja DeepStack (ako je potreban)", + "description": "Nepovlađeni ključ API-ja za autentificirane usluge DeepStack." + } + }, + "degirum": { + "label": "DeGirum", + "description": "Detektor DeGirum za pokretanje modela putem DeGirum oblaka ili lokalnih usluga inferencije.", + "location": { + "label": "Lokacija inferencije", + "description": "Lokacija DeGirim inferencije (npr. '@cloud', '127.0.0.1')." + }, + "zoo": { + "label": "Model Zoo", + "description": "Putanja ili URL do DeGirum model zoo." + }, + "token": { + "label": "Token za DeGirum Cloud", + "description": "Token za pristup DeGirum Cloud." + } + }, + "edgetpu": { + "label": "EdgeTPU", + "description": "Detektor EdgeTPU koji pokreće modele TensorFlow Lite kompilirane za Coral EdgeTPU pomoću EdgeTPU delegata.", + "device": { + "label": "Tip uređaja", + "description": "Uređaj za korištenje EdgeTPU inferencije (npr. 'usb', 'pci')." + } + }, + "hailo8l": { + "label": "Hailo-8/Hailo-8L", + "description": "Detektor Hailo-8/Hailo-8L koji koristi HEF modele i HailoRT SDK za inferenciju na Hailo uređaju.", + "device": { + "label": "Tip uređaja", + "description": "Uređaj za korištenje Hailo inferencije (npr. 'PCIe', 'M.2')." + } + }, + "memryx": { + "label": "MemryX", + "description": "Detektor MemryX MX3 koji pokreće kompilirane modele DFP na MemryX akceleratorima.", + "device": { + "label": "Putanja uređaja", + "description": "Uređaj za korištenje MemryX inferencije (npr. 'PCIe')." + } + }, + "onnx": { + "label": "ONNX", + "description": "Detektor ONNX za pokretanje ONNX modela; koristi dostupne akceleracijske backendove (CUDA/ROCm/OpenVINO) kada su dostupni.", + "device": { + "label": "Tip uređaja", + "description": "Uređaj za korištenje ONNX inferencije (npr. 'AUTO', 'CPU', 'GPU')." + } + }, + "openvino": { + "label": "OpenVINO", + "description": "Detektor OpenVINO za AMD i Intel CPU-e, Intel GPU-e i Intel VPU uređaje.", + "device": { + "label": "Tip uređaja", + "description": "Uređaj za korištenje za inferenciju OpenVINO (npr. 'CPU', 'GPU', 'NPU')." + } + }, + "rknn": { + "label": "RKNN", + "description": "Detektor RKNN za NPUs Rockchipa; izvršava preveđene modele RKNN na Rockchip uređaju.", + "num_cores": { + "label": "Broj jezgri NPU koje se koriste.", + "description": "Broj jezgri NPU koje se koriste (0 za automatsko)." + } + }, + "synaptics": { + "label": "Synaptics", + "description": "Detektor NPU Synaptics za modele u formatu .synap pomoću SDK-a Synap na uređaju Synaptics." + }, + "teflon_tfl": { + "label": "Teflon", + "description": "Detektor delegata Teflon za TFLite pomoću biblioteke Mesa Teflon delegata za ubrzanje inferencije na podržanim GPU-ima." + }, + "tensorrt": { + "label": "TensorRT", + "description": "Detektor TensorRT za uređaje Nvidia Jetson koji koristi serijalizirane TensorRT motore za ubrzanu inferenciju.", + "device": { + "label": "Indeks GPU uređaja", + "description": "Indeks GPU uređaja za korištenje." + } + }, + "zmq": { + "label": "ZMQ IPC", + "description": "Detektor ZMQ IPC koji prenosi inferenciju vanjskom procesu putem ZMQ IPC kraja.", + "endpoint": { + "label": "ZMQ IPC kraja", + "description": "Kraj ZMQ-a na koji se povezati." + }, + "request_timeout_ms": { + "label": "ZMQ zahtjev timeout u milisekundama", + "description": "Timeout za ZMQ zahtjeve u milisekundama." + }, + "linger_ms": { + "label": "ZMQ socket linger u milisekundama", + "description": "Period linger socketa u milisekundama." + } + } + }, + "model": { + "label": "Model detekcije", + "description": "Postavke za konfiguraciju prilagođenog modela detekcije objekata i njegove ulazne oblike.", + "path": { + "label": "Put do prilagođenog modela detekcije", + "description": "Put do datoteke prilagođenog modela detekcije (ili plus:// za modele Frigate+)." + }, + "labelmap_path": { + "label": "Mapa oznaka za prilagođeni detektor objekata", + "description": "Putanja do datoteke labelmap koja preslikava numeričke klase u string oznake za detektor." + }, + "width": { + "label": "Širina ulaznog tenzora modela detekcije objekata", + "description": "Širina ulaznog tenzora modela u pikselima." + }, + "height": { + "label": "Visina ulaznog tenzora modela detekcije objekata", + "description": "Visina ulaznog tenzora modela u pikselima." + }, + "labelmap": { + "label": "Prilagodba labelmap", + "description": "Preklop ili preslikavanje unosa za spajanje u standardnu labelmap." + }, + "attributes_map": { + "label": "Mapa oznaka objekata na njihove atribute oznake", + "description": "Preslikavanje iz oznaka objekata na atribute oznake koje se koriste za dodavanje metapodataka (npr. 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Oblik ulaznog tenzora modela", + "description": "Format tenzora očekivan od strane modela: 'nhwc' ili 'nchw'." + }, + "input_pixel_format": { + "label": "Format boje piksela ulaznog modela", + "description": "Boja prostor očekivan od strane modela: 'rgb', 'bgr' ili 'yuv'." + }, + "input_dtype": { + "label": "D tip ulaza modela", + "description": "Tip podataka ulaznog tenzora modela (npr. 'float32')." + }, + "model_type": { + "label": "Tip modela detekcije objekata", + "description": "Tip arhitekture modela detektora (ssd, yolox, yolonas) korišten od strane nekih detektora za optimizaciju." + } + }, + "genai": { + "label": "Konfiguracija generativne AI", + "description": "Postavke za integrirane generativne AI provajdere korišteni za generisanje opisa objekata i pregled sažetaka.", + "api_key": { + "label": "Ključ API", + "description": "Ključ API potreban nekim provajderima (takođe može biti postavljen putem okruženja)." + }, + "base_url": { + "label": "Osnovna URL", + "description": "Osnovna URL za samohostovane ili kompatibilne provajdere (npr. instanca Ollama)." + }, + "model": { + "label": "Model", + "description": "Model koji se koristi iz ponuđača za generisanje opisa ili sažetaka." + }, + "provider": { + "label": "Ponuđač", + "description": "GenAI ponuđač koji se koristi (npr. ollama, gemini, openai)." + }, + "roles": { + "label": "Uloge", + "description": "GenAI uloge (razgovor, opisi, ugradnje); jedan ponuđač po ulozi." + }, + "provider_options": { + "label": "Opcije ponuđača", + "description": "Dodatne opcije specifične za ponuđača koje se prosleđuju klijentu GenAI." + }, + "runtime_options": { + "label": "Opcije izvršavanja", + "description": "Opcije izvršavanja koje se prosleđuju ponuđaču za svaku poziv izvođenja." + } + }, + "classification": { + "label": "Klasifikacija objekata", + "description": "Postavke modela klasifikacije koji se koriste za poboljšanje oznaka objekata ili klasifikaciju stanja.", + "bird": { + "label": "Konfiguracija klasifikacije ptica", + "description": "Postavke specifične za modele klasifikacije ptica.", + "enabled": { + "label": "Klasifikacija ptica", + "description": "Omogući ili onemogući klasifikaciju ptica." + }, + "threshold": { + "label": "Minimalna ocjena", + "description": "Minimalna ocjena klasifikacije potrebna za prihvaćanje klasifikacije ptica." + } + }, + "custom": { + "label": "Prilagođeni modeli klasifikacije", + "description": "Konfiguracija prilagođenih modela klasifikacije korištenih za objekte ili detekciju stanja.", + "enabled": { + "label": "Omogući model", + "description": "Omogući ili onemogući prilagođeni model klasifikacije." + }, + "name": { + "label": "Ime modela", + "description": "Identifikator za prilagođeni model klasifikacije koji se koristi." + }, + "threshold": { + "label": "Prag ocjene", + "description": "Prag ocjene korišten za promjenu stanja klasifikacije." + }, + "save_attempts": { + "label": "Snimi pokušaje", + "description": "Koliko pokušaja klasifikacije sačuvati za korisnički sučelje nedavnih klasifikacija." + }, + "object_config": { + "objects": { + "label": "Klasificiraj objekte", + "description": "Popis vrsta objekata za koje se izvršava klasifikacija objekata." + }, + "classification_type": { + "label": "Vrsta klasifikacije", + "description": "Vrsta klasifikacije primijenjena: 'sub_label' (dodaje sub_label) ili druge podržane vrste." + } + }, + "state_config": { + "cameras": { + "label": "Kamere za klasifikaciju", + "description": "Postavke za rezanje i podešavanja po kameri za izvršavanje klasifikacije stanja.", + "crop": { + "label": "Rezanje za klasifikaciju", + "description": "Koordinate rezanja koje se koriste za izvršavanje klasifikacije na ovoj kameri." + } + }, + "motion": { + "label": "Pokreni na pokret", + "description": "Ako je tačno, pokrenite klasifikaciju kada se detektuje pokret unutar navedenog krova." + }, + "interval": { + "label": "Interval klasifikacije", + "description": "Interval (sekunde) između periodičnih pokretanja klasifikacije za klasifikaciju stanja." + } + } + } + }, + "camera_groups": { + "label": "Grupe kamera", + "description": "Konfiguracija imenovanih grupa kamera koje se koriste za organizaciju kamera u UI-u.", + "cameras": { + "label": "Popis kamera", + "description": "Niz imena kamera uključenih u ovu grupu." + }, + "icon": { + "label": "Ikona grupe", + "description": "Ikona korištena za prikaz grupe kamera u UI-u." + }, + "order": { + "label": "Redoslijed sortiranja", + "description": "Numerički redoslijed korišten za sortiranje grupa kamera u UI-u; veći brojevi se pojavljuju kasnije." + } + }, + "active_profile": { + "label": "Aktivni profil", + "description": "Trenutno aktivno ime profila. Samo za runtime, ne čuva se u YAML-u." + }, + "camera_mqtt": { + "label": "MQTT", + "description": "Postavke objave slika preko MQTT.", + "enabled": { + "label": "Pošalji sliku", + "description": "Omogući objavljivanje snimaka slika za objekte na MQTT teme za ovu kameru." + }, + "timestamp": { + "label": "Dodaj vremensku oznaku", + "description": "Preklopiti vremensku oznaku na slike objavljene preko MQTT." + }, + "bounding_box": { + "label": "Dodaj okvir", + "description": "Crtaj okvire na slikama objavljenim preko MQTT." + }, + "crop": { + "label": "Iscijepi sliku", + "description": "Iscijepi slike objavljene preko MQTT na okvir detektiranog objekta." + }, + "height": { + "label": "Visina slike", + "description": "Visina (piksela) za promjenu veličine slika objavljenih preko MQTT." + }, + "required_zones": { + "label": "Potrebne zone", + "description": "Zone koje objekt mora ući da bi se slika preko MQTT objavila." + }, + "quality": { + "label": "Kvaliteta JPEG", + "description": "Kvaliteta JPEG za slike objavljene preko MQTT (0-100)." + } + }, + "camera_ui": { + "label": "Kamera UI", + "description": "Prikaz redoslijeda i vidljivosti za ovu kameru u UI. Redoslijed utječe na zadani nadzorno pločo. Za detaljniju kontrolu koristite grupe kamere.", + "order": { + "label": "Redoslijed UI", + "description": "Numerički redoslijed koristi se za sortiranje kamere u UI (zadani nadzorno pločo i popisi); veći brojevi pojavljuju se kasnije." + }, + "dashboard": { + "label": "Prikaži u UI", + "description": "Prekidač je li ova kamera vidljiva svuda u UI Frigate. Onemogućavanje ovoga zahtijeva ručno uređivanje konfiguracije za ponovno prikazivanje ove kamere u UI." + } + } +} diff --git a/web/public/locales/bs/config/groups.json b/web/public/locales/bs/config/groups.json new file mode 100644 index 0000000000..44f91e7731 --- /dev/null +++ b/web/public/locales/bs/config/groups.json @@ -0,0 +1,73 @@ +{ + "audio": { + "global": { + "detection": "Globalna detekcija", + "sensitivity": "Globalna osjetljivost" + }, + "cameras": { + "detection": "Detekcija", + "sensitivity": "Osjetljivost" + } + }, + "timestamp_style": { + "global": { + "appearance": "Globalno izgled" + }, + "cameras": { + "appearance": "Izgled" + } + }, + "motion": { + "global": { + "sensitivity": "Globalna osjetljivost", + "algorithm": "Globalni algoritam" + }, + "cameras": { + "sensitivity": "Osjetljivost", + "algorithm": "Algoritam" + } + }, + "snapshots": { + "global": { + "display": "Globalno prikazivanje" + }, + "cameras": { + "display": "Prikazivanje" + } + }, + "detect": { + "global": { + "resolution": "Globalna rezolucija", + "tracking": "Globalno praćenje" + }, + "cameras": { + "resolution": "Rezolucija", + "tracking": "Praćenje" + } + }, + "objects": { + "global": { + "tracking": "Globalno praćenje", + "filtering": "Globalno filtriranje" + }, + "cameras": { + "tracking": "Praćenje", + "filtering": "Filtriranje" + } + }, + "record": { + "global": { + "retention": "Globalno zadržavanje", + "events": "Globalni događaji" + }, + "cameras": { + "retention": "Zadržavanje", + "events": "Događaji" + } + }, + "ffmpeg": { + "cameras": { + "cameraFfmpeg": "Argumeni FFmpeg specifični za kameru" + } + } +} diff --git a/web/public/locales/bs/config/validation.json b/web/public/locales/bs/config/validation.json new file mode 100644 index 0000000000..1ed1dd7a5a --- /dev/null +++ b/web/public/locales/bs/config/validation.json @@ -0,0 +1,32 @@ +{ + "minimum": "Bar {{limit}}", + "maximum": "Mora biti najviše {{limit}}", + "exclusiveMinimum": "Mora biti veći od {{limit}}", + "exclusiveMaximum": "Mora biti manje od {{limit}}", + "minLength": "Bar {{limit}} znak(ovi)", + "maxLength": "Mora biti najviše {{limit}} znak(ovi)", + "minItems": "Mora imati bar {{limit}} stavke", + "maxItems": "Mora imati najviše {{limit}} stavke", + "pattern": "Neispravan format", + "required": "Ovo polje je obavezno", + "type": "Neispravan tip vrijednosti", + "enum": "Mora biti jedan od dopuštenih vrijednosti", + "const": "Vrijednost se ne podudara s očekivanom konstantom", + "uniqueItems": "Sve stavke moraju biti jedinstvene", + "format": "Neispravan format", + "additionalProperties": "Nepoznato svojstvo nije dozvoljeno", + "oneOf": "Mora se podudarati s točno jednim od dopuštenih shema", + "anyOf": "Mora se podudarati s bar jednim od dopuštenih shema", + "proxy": { + "header_map": { + "roleHeaderRequired": "Zaglavlje uloge je obavezno kada su konfigurirane mapiranja uloga." + } + }, + "ffmpeg": { + "inputs": { + "rolesUnique": "Svaka uloga može biti dodijeljena samo jednom ulaznom toku.", + "detectRequired": "Bar jedan ulazni tok mora biti dodijeljen ulozi 'detektirati'.", + "hwaccelDetectOnly": "Samo ulazni tok s ulogom detektiranja može definirati argumente ubrzavanja hardvera." + } + } +} diff --git a/web/public/locales/bs/objects.json b/web/public/locales/bs/objects.json new file mode 100644 index 0000000000..cbeaacdb4e --- /dev/null +++ b/web/public/locales/bs/objects.json @@ -0,0 +1,125 @@ +{ + "person": "Ljudsko bit će", + "bicycle": "Kolo", + "animal": "Životinja", + "dog": "Pas", + "bark": "Glavu", + "cat": "Mačka", + "horse": "Konj", + "goat": "Koza", + "sheep": "Ovca", + "bird": "Ptica", + "mouse": "Miš", + "keyboard": "Klaviatura", + "vehicle": "Vozilo", + "boat": "Brod", + "car": "Automobil", + "bus": "Autobus", + "motorcycle": "Motocikl", + "train": "Vlak", + "skateboard": "Skejtbord", + "door": "Vrata", + "blender": "Miksere", + "sink": "Lavabo", + "hair_dryer": "Sušilac za kosu", + "toothbrush": "Šetka za zube", + "scissors": "Škare", + "clock": "Sat", + "airplane": "Avion", + "traffic_light": "Svetofor", + "fire_hydrant": "Vatrostaničar", + "street_sign": "Ulični znak", + "stop_sign": "Znak zaustavljanja", + "parking_meter": "Parkirni metar", + "bench": "Banko", + "cow": "Korova", + "elephant": "Slon", + "bear": "Medvjed", + "zebra": "Zebra", + "giraffe": "Žirafa", + "hat": "Kaputa", + "backpack": "Torba", + "umbrella": "Kreveta", + "shoe": "Cizma", + "eye_glasses": "Očna stakla", + "handbag": "Ručna torba", + "tie": "Kremplj", + "suitcase": "Kufer", + "frisbee": "Frizbi", + "skis": "Ski", + "snowboard": "Snjegobord", + "sports_ball": "Sportska lopta", + "kite": "Let", + "baseball_bat": "Batsa za baseball", + "baseball_glove": "Rukavica za baseball", + "surfboard": "Surfbord", + "tennis_racket": "Teniski raketa", + "bottle": "Bocica", + "plate": "Ploča", + "wine_glass": "Vinsko čaša", + "cup": "Kupa", + "fork": "Škarpe", + "knife": "Nož", + "spoon": "Lajna", + "bowl": "Tanjir", + "banana": "Banana", + "apple": "Jabuka", + "sandwich": "Sandučić", + "orange": "Portakal", + "broccoli": "Brobkoli", + "carrot": "Mahunika", + "hot_dog": "Hot dog", + "pizza": "Pica", + "donut": "Krofna", + "cake": "Torta", + "chair": "Stolica", + "couch": "Divan", + "potted_plant": "Ukrasna biljka", + "bed": "Krevet", + "mirror": "Zrcalo", + "dining_table": "Stol za ručak", + "window": "Prozor", + "desk": "Radni stol", + "toilet": "Toalet", + "tv": "TV", + "laptop": "Laptop", + "remote": "Udaljeno upravljanje", + "cell_phone": "Mobilni telefon", + "microwave": "Mikrotalasna pećnica", + "oven": "Pećnica", + "toaster": "Tostera", + "refrigerator": "Hladnjak", + "book": "Knjiga", + "vase": "Vaza", + "teddy_bear": "Biberon", + "hair_brush": "Kosmetička četka", + "squirrel": "Šumski pas", + "deer": "Jelen", + "fox": "Lisica", + "rabbit": "Zajac", + "raccoon": "Rakun", + "robot_lawnmower": "Robotska kosilica", + "waste_bin": "Kanta za otpad", + "on_demand": "Na zahtjev", + "face": "Lice", + "license_plate": "Tablica", + "package": "Paket", + "bbq_grill": "Grill za BBQ", + "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", + "canada_post": "Canada Post", + "royal_mail": "Royal Mail", + "school_bus": "Školski autobus", + "skunk": "Mrdka", + "kangaroo": "Kanguru" +} diff --git a/web/public/locales/bs/views/chat.json b/web/public/locales/bs/views/chat.json new file mode 100644 index 0000000000..643825a780 --- /dev/null +++ b/web/public/locales/bs/views/chat.json @@ -0,0 +1,46 @@ +{ + "documentTitle": "Razgovor - Frigate", + "title": "Frigate razgovor", + "subtitle": "Vaš AI asistent za upravljanje kamerama i insighti", + "placeholder": "Pitajte bilo što...", + "error": "Nešto je pošlo po zlu. Molimo pokušajte ponovo.", + "processing": "Obrađivanje...", + "toolsUsed": "Korišteno: {{tools}}", + "showTools": "Prikaži alate ({{count}})", + "hideTools": "Sakrij alate", + "call": "Poziv", + "result": "Rezultat", + "arguments": "Argumenti:", + "response": "Odgovor:", + "attachment_chip_label": "{{label}} na {{camera}}", + "attachment_chip_remove": "Ukloni privitak", + "open_in_explore": "Otvori u Explore", + "attach_event_aria": "Prikači događaj {{eventId}}", + "attachment_picker_paste_label": "Ili zalijepite ID događaja", + "attachment_picker_attach": "Prikači", + "attachment_picker_placeholder": "Prikači događaj", + "quick_reply_find_similar": "Pronađi slične susreti", + "quick_reply_tell_me_more": "Recite mi više o ovome", + "quick_reply_when_else": "Kada je još puta vidjeno?", + "quick_reply_find_similar_text": "Pronađi slične susreti za ovaj.", + "quick_reply_tell_me_more_text": "Recite mi više o ovom.", + "quick_reply_when_else_text": "Kada je to još puta vidjeno?", + "anchor": "Referenca", + "similarity_score": "Sličnost", + "no_similar_objects_found": "Nisu pronađeni slični objekti.", + "semantic_search_required": "Semantička pretraga mora biti omogućena da bi se pronašli slični objekti.", + "send": "Pošalji", + "suggested_requests": "Pokušaj pitati:", + "starting_requests": { + "show_recent_events": "Prikaži nedavne događaje", + "show_camera_status": "Prikaži status kamere", + "recap": "Što se desilo dok sam bio odsutan?", + "watch_camera": "Pratite kameru za aktivnost" + }, + "starting_requests_prompts": { + "show_recent_events": "Prikaži mi nedavne događaje iz posljednjeg sata", + "show_camera_status": "Koji je trenutni status mojih kamera?", + "recap": "Što se desilo dok sam bio odsutan?", + "watch_camera": "Pratite ulazna vrata i obavijestite me ako netko dođe" + } +} diff --git a/web/public/locales/bs/views/classificationModel.json b/web/public/locales/bs/views/classificationModel.json new file mode 100644 index 0000000000..aa18d5a07f --- /dev/null +++ b/web/public/locales/bs/views/classificationModel.json @@ -0,0 +1,205 @@ +{ + "documentTitle": "Modeli klasifikacije - Frigate", + "details": { + "scoreInfo": "Ocjena predstavlja prosjek pouzdanosti klasifikacije kroz sve detekcije ovog objekta.", + "none": "Nijedan", + "unknown": "Nepoznato" + }, + "button": { + "deleteClassificationAttempts": "Obriši slike klasifikacije", + "renameCategory": "Preimenuj klasu", + "deleteCategory": "Obriši klasu", + "deleteImages": "Obriši slike", + "trainModel": "Obuci model", + "addClassification": "Dodaj klasifikaciju", + "deleteModels": "Obriši modele", + "editModel": "Uredi model" + }, + "tooltip": { + "trainingInProgress": "Model trenutno obučava", + "noNewImages": "Nema novih slika za obuku. Prvo klasificirajte više slika u skupu podataka.", + "noChanges": "Nema promjena u skupu podataka od posljednje obuke.", + "modelNotReady": "Model nije spreman za obuku" + }, + "toast": { + "success": { + "deletedModel_one": "Uspješno obrisan {{count}} model", + "deletedModel_few": "Uspješno obrisani {{count}} modeli", + "deletedModel_other": "Uspješno obrisani {{count}} modeli", + "categorizedImage": "Uspješno klasificirana slika", + "reclassifiedImage": "Uspješno ponovno klasificirana slika", + "trainedModel": "Uspješno obučen model.", + "trainingModel": "Uspješno pokrenuta obuka modela.", + "updatedModel": "Uspješno ažurirana konfiguracija modela", + "renamedCategory": "Uspješno preimenovana klasa u {{name}}", + "deletedCategory_one": "Obrisana {{count}} klasa", + "deletedCategory_few": "Obrisane {{count}} klase", + "deletedCategory_other": "Obrisane {{count}} klase", + "deletedImage_one": "Izbrisana {{count}} slika", + "deletedImage_few": "Izbrisane {{count}} slike", + "deletedImage_other": "Izbrisane {{count}} slike" + }, + "error": { + "deleteImageFailed": "Neuspješno brisanje: {{errorMessage}}", + "deleteCategoryFailed": "Neuspješno brisanje klase: {{errorMessage}}", + "deleteModelFailed": "Neuspješno brisanje modela: {{errorMessage}}", + "categorizeFailed": "Neuspješno kategoriziranje slike: {{errorMessage}}", + "trainingFailed": "Obuka modela nije uspješna. Provjerite zapise Frigate za detalje.", + "trainingFailedToStart": "Neuspješno pokretanje obuke modela: {{errorMessage}}", + "updateModelFailed": "Neuspješno ažuriranje modela: {{errorMessage}}", + "renameCategoryFailed": "Neuspješno preimenovanje klase: {{errorMessage}}", + "reclassifyFailed": "Neuspješno ponovno klasifikovanje slike: {{errorMessage}}" + } + }, + "deleteCategory": { + "title": "Izbriši klasu", + "desc": "Sigurni li ste da želite izbrisati klasu {{name}}? Ovo će trajno izbrisati sve povezane slike i zahtijevati ponovnu obuku modela.", + "minClassesTitle": "Nemoguće izbrisati klasu", + "minClassesDesc": "Model klasifikacije mora imati najmanje 2 klase. Dodajte još jednu klasu prije brisanja ove." + }, + "deleteModel": { + "title": "Izbriši model klasifikacije", + "single": "Sigurni li ste da želite izbrisati {{name}}? Ovo će trajno izbrisati sve povezane podatke uključujući slike i podatke o obuci. Ova akcija ne može biti poništena.", + "desc_one": "Sigurni li ste da želite izbrisati {{count}} model? Ovo će trajno izbrisati sve povezane podatke uključujući slike i podatke o obuci. Ova akcija ne može biti poništena.", + "desc_few": "Sigurni li ste da želite izbrisati {{count}} modele? Ovo će trajno izbrisati sve povezane podatke uključujući slike i podatke o obuci. Ova akcija ne može biti poništena.", + "desc_other": "Sigurni li ste da želite izbrisati {{count}} modele? Ovo će trajno izbrisati sve povezane podatke uključujući slike i podatke o obuci. Ova akcija ne može biti poništena." + }, + "edit": { + "title": "Uredi model klasifikacije", + "descriptionState": "Uredi klase za ovaj model klasifikacije stanja. Promjene će zahtijevati ponovnu obuku modela.", + "descriptionObject": "Uredi vrstu objekta i vrstu klasifikacije za ovaj model klasifikacije objekta.", + "stateClassesInfo": "Napomena: Promjene klasa stanja zahtijevaju ponovnu obuku modela sa ažuriranim klasama." + }, + "deleteDatasetImages": { + "title": "Izbriši slike skupa podataka", + "desc_one": "Sigurni li ste da želite izbrisati {{count}} sliku iz {{dataset}}? Ova akcija ne može biti poništena i zahtijevati će ponovnu obuku modela.", + "desc_few": "Sigurni li ste da želite obrisati {{count}} slike iz {{dataset}}? Ova akcija ne može se poništiti i zahtijevat će ponovno treniranje modela.", + "desc_other": "Sigurni li ste da želite obrisati {{count}} slike iz {{dataset}}? Ova akcija ne može se poništiti i zahtijevat će ponovno treniranje modela." + }, + "deleteTrainImages": { + "title": "Obriši slike za treniranje", + "desc_one": "Sigurni li ste da želite obrisati {{count}} sliku? Ova akcija ne može se poništiti.", + "desc_few": "Sigurni li ste da želite obrisati {{count}} slike? Ova akcija ne može se poništiti.", + "desc_other": "Sigurni li ste da želite obrisati {{count}} slike? Ova akcija ne može se poništiti." + }, + "renameCategory": { + "title": "Preimenuj klasu", + "desc": "Unesite novo ime za {{name}}. Za promjenu imena će vam se zahtijevati ponovno treniranje modela." + }, + "description": { + "invalidName": "Neprihvatljivo ime. Imena mogu sadržavati samo slova, brojeve, razmake, aposrofe, donje crte i crte." + }, + "train": { + "title": "Nedavne klasifikacije", + "titleShort": "Nedavno", + "aria": "Odaberite nedavne klasifikacije" + }, + "categories": "Klase", + "createCategory": { + "new": "Stvori novu klasu" + }, + "categorizeImageAs": "Klasificiraj sliku kao:", + "categorizeImage": "Klasificiraj sliku", + "reclassifyImageAs": "Ponovno klasificiraj sliku kao:", + "reclassifyImage": "Ponovno klasificiraj sliku", + "menu": { + "objects": "Objekti", + "states": "Stanja" + }, + "noModels": { + "object": { + "title": "Nema modela za klasifikaciju objekata", + "description": "Stvorite prilagođeni model za klasifikaciju detektiranih objekata.", + "buttonText": "Stvori model objekta" + }, + "state": { + "title": "Nema modela za klasifikaciju stanja", + "description": "Stvorite prilagođeni model za praćenje i klasifikaciju promjena stanja u određenim područjima kamere.", + "buttonText": "Stvori model stanja" + } + }, + "wizard": { + "title": "Stvori novu klasifikaciju", + "steps": { + "nameAndDefine": "Ime i definicija", + "stateArea": "Područje stanja", + "chooseExamples": "Odaberite primjere" + }, + "step1": { + "description": "Modeli stanja nadziraju fiksne područja kamere za promjene (npr. vrata otvorena/zatvorena). Modeli objekata dodaju klasifikacije detektiranim objektima (npr. poznati životinje, dostavljači, itd.).", + "name": "Ime", + "namePlaceholder": "Unesite ime modela...", + "type": "Tip", + "typeState": "Stanje", + "typeObject": "Objekt", + "objectLabel": "Oznaka objekta", + "objectLabelPlaceholder": "Odaberite vrstu objekta...", + "classificationType": "Vrsta klasifikacije", + "classificationTypeTip": "Učite više o vrstama klasifikacije", + "classificationTypeDesc": "Podoznake dodaju dodatni tekst oznaci objekta (npr. 'Ljudsko bit će: UPS'). Atributi su pretraživi metapodaci pohranjeni zasebno u metapodacima objekta.", + "classificationSubLabel": "Podoznaka", + "classificationAttribute": "Atribut", + "classes": "Klase", + "states": "Stanja", + "classesTip": "Učite više o klasama", + "classesStateDesc": "Definirajte različita stanja koja može imati područje kamere. Na primjer: 'otvoreno' i 'zatvoreno' za vrata garaže.", + "classesObjectDesc": "Definirajte različite kategorije u koje ćete klasificirati detektirane objekte. Na primjer: 'dostavljac', 'stanovnik', 'stranac' za klasifikaciju ljudi.", + "classPlaceholder": "Unesite ime klase...", + "errors": { + "nameRequired": "Ime modela je obavezno", + "nameLength": "Ime modela mora imati 64 znaka ili manje", + "nameOnlyNumbers": "Ime modela ne može sadržavati samo brojeve", + "classRequired": "Potrebna je bar jedna klasa", + "classesUnique": "Imena klasa moraju biti jedinstvena", + "noneNotAllowed": "Klasa 'none' nije dozvoljena", + "stateRequiresTwoClasses": "Modeli stanja zahtijevaju bar dvije klase", + "objectLabelRequired": "Molimo odaberite oznaku objekta", + "objectTypeRequired": "Molimo odaberite vrstu klasifikacije" + } + }, + "step2": { + "description": "Odaberite kamere i definirajte područje koje ćete nadzirati za svaku kameru. Model će klasificirati stanje ovih područja.", + "cameras": "Kamere", + "selectCamera": "Odaberite Kameru", + "noCameras": "Kliknite + za dodavanje kamera", + "selectCameraPrompt": "Odaberite kameru iz popisa da biste definirali njezino područje nadzora" + }, + "step3": { + "selectImagesPrompt": "Odaberite sve slike s: {{className}}", + "selectImagesDescription": "Kliknite na slike da biste ih odabrali. Kliknite Nadalje kada završite s ovom klasifikacijom.", + "allImagesRequired_one": "Molimo klasificirajte sve slike. Preostala je {{count}} slika.", + "allImagesRequired_few": "Molimo klasificirajte sve slike. Preostale su {{count}} slike.", + "allImagesRequired_other": "Molimo klasificirajte sve slike. Preostale su {{count}} slike.", + "generating": { + "title": "Generisanje uzoraka slika", + "description": "Frigate učitava reprezentativne slike iz vaših snimaka. Ovo može trajati trenutak..." + }, + "training": { + "title": "Obučavanje modela", + "description": "Vaš model se trenutno obučava u pozadini. Zatvorite ovaj dijalog, a vaš model će započeti raditi odmah kada se obuka završi." + }, + "retryGenerate": "Ponovno generisanje", + "noImages": "Nema generisanih uzoraka slika", + "classifying": "Klasifikacija i obuka...", + "trainingStarted": "Obuka je uspješno pokrenuta", + "modelCreated": "Model je uspješno stvoren. Koristite pogled Najnovije klasifikacije da dodate slike za nedostajuće stanja, a zatim obučite model.", + "errors": { + "noCameras": "Nema konfigurisanih kamera", + "noObjectLabel": "Nije odabrana oznaka objekta", + "generateFailed": "Neuspješno generisanje primera: {{error}}", + "generationFailed": "Generisanje nije uspješno. Molimo pokušajte ponovo.", + "classifyFailed": "Neuspješna klasifikacija slika: {{error}}" + }, + "generateSuccess": "Uspješno generisane uzorak slike", + "refreshExamples": "Generiši nove primjere", + "refreshConfirm": { + "title": "Generiši nove primjere?", + "description": "Ovo će generisati novi skup slika i obrisati sve odabire, uključujući prethodne klase. Trebat će vam ponovno odabrati primjere za sve klase." + }, + "missingStatesWarning": { + "title": "Primjeri nedostajućih klasa", + "description": "Nisu sve klase imale primjere. Pokušajte generisanje novih primjera da biste pronašli nedostajuću klasu, ili nastavite i koristite pogled Najnovije klasifikacije da biste kasnije dodali slike." + } + } + } +} diff --git a/web/public/locales/bs/views/configEditor.json b/web/public/locales/bs/views/configEditor.json new file mode 100644 index 0000000000..7ce01001ff --- /dev/null +++ b/web/public/locales/bs/views/configEditor.json @@ -0,0 +1,18 @@ +{ + "documentTitle": "Uređivač konfiguracije - Frigate", + "configEditor": "Uređivač konfiguracije", + "safeConfigEditor": "Uređivač konfiguracije (Sigurnosni režim)", + "safeModeDescription": "Frigate je u sigurnosnom režimu zbog greške u validaciji konfiguracije.", + "copyConfig": "Kopiraj konfiguraciju", + "saveAndRestart": "Sačuvaj i ponovo pokreni", + "saveOnly": "Sačuvaj samo", + "confirm": "Napusti bez čuvanja?", + "toast": { + "success": { + "copyToClipboard": "Konfiguracija kopirana u međuspremnik." + }, + "error": { + "savingError": "Greška prilikom čuvanja konfiguracije" + } + } +} diff --git a/web/public/locales/bs/views/events.json b/web/public/locales/bs/views/events.json new file mode 100644 index 0000000000..19869a820c --- /dev/null +++ b/web/public/locales/bs/views/events.json @@ -0,0 +1,92 @@ +{ + "alerts": "Upozorenja", + "detections": "Detekcije", + "camera": "Kamera", + "motion": { + "label": "Kretanje", + "only": "Samo pokret" + }, + "allCameras": "Sve Kamere", + "empty": { + "alert": "Nema upozorenja za pregled", + "detection": "Nema detekcija za pregled", + "motion": "Nema podataka o pokretu", + "recordingsDisabled": { + "title": "Snimci moraju biti omogućeni", + "description": "Pregledni stavci mogu se stvarati samo za kameru kada su snimci omogućeni za tu kameru." + } + }, + "timeline": { + "label": "vremenska linija", + "aria": "Odaberite vremensku liniju" + }, + "zoomIn": "Uvećajte", + "zoomOut": "Umanjite", + "events": { + "label": "Događaji", + "aria": "Odaberite događaje", + "noFoundForTimePeriod": "Nema događaja za ovaj vremenski period." + }, + "detail": { + "label": "Detalj", + "noDataFound": "Nema detaljnih podataka za pregled", + "aria": "Prekidač pregleda detalja", + "trackedObject_one": "{{count}} objekt", + "trackedObject_other": "{{count}} objekta", + "noObjectDetailData": "Nema dostupnih detaljnih podataka o objektu.", + "settings": "Postavke pregleda detalja", + "alwaysExpandActive": { + "title": "Uvijek proširujte aktivno", + "desc": "Uvijek proširite detalje objekta aktivnog stavka pregleda kada su dostupni." + } + }, + "objectTrack": { + "trackedPoint": "Praćeni točka", + "clickToSeek": "Kliknite da biste prešli na ovo vrijeme" + }, + "documentTitle": "Pregled - Frigate", + "recordings": { + "documentTitle": "Snimci - Frigate", + "invalidSharedLink": "Nemoguće otvoriti vezu za snimak sa vremenskom oznakom zbog greške u parsiranju.", + "invalidSharedCamera": "Nemoguće otvoriti vezu za snimak sa vremenskom oznakom zbog nepoznate ili neovlašćene kamere." + }, + "calendarFilter": { + "last24Hours": "Poslednje 24 sata" + }, + "markAsReviewed": "Označi kao pregledano", + "markTheseItemsAsReviewed": "Označi ove stavke kao pregledane", + "newReviewItems": { + "label": "Pregledaj nove stavke za pregled", + "button": "Nove stavke za pregled" + }, + "selected_one": "{{count}} odabrano", + "selected_other": "{{count}} odabrano", + "select_all": "Sve", + "detected": "detektovano", + "normalActivity": "Normal", + "needsReview": "Treba pregledati", + "securityConcern": "Sigurnosna zabrinutost", + "motionSearch": { + "menuItem": "Pretraga kretanja", + "openMenu": "Opcije kamere" + }, + "motionPreviews": { + "menuItem": "Pregledaj preglednike kretanja", + "title": "Preglednici kretanja: {{camera}}", + "mobileSettingsTitle": "Postavke preglednika kretanja", + "mobileSettingsDesc": "Prilagodite brzinu reprodukcije i osvetljavanje, i odaberite datum za pregled snimaka samo sa kretanjem.", + "dim": "Osvetljavanje", + "dimAria": "Prilagodite intenzitet osvetljavanja", + "dimDesc": "Povećajte osvetljavanje da biste povećali vidljivost područja kretanja.", + "speed": "Brzina", + "speedAria": "Odaberite brzinu reprodukcije preglednika", + "speedDesc": "Odaberite koliko brzo će se pregledni snimci reproducirati.", + "back": "Nazad", + "empty": "Nema pregleda dostupnih", + "noPreview": "Pregled nije dostupan", + "seekAria": "Pretражuj {{camera}} igraču do {{time}}", + "filter": "Filtar", + "filterDesc": "Odaberite područja da biste prikazali samo klipove sa kretanjem u tim područjima.", + "filterClear": "Očisti" + } +} diff --git a/web/public/locales/bs/views/explore.json b/web/public/locales/bs/views/explore.json new file mode 100644 index 0000000000..8049fd879f --- /dev/null +++ b/web/public/locales/bs/views/explore.json @@ -0,0 +1,267 @@ +{ + "documentTitle": "Istraživanje - Frigate", + "generativeAI": "Generativna AI", + "exploreMore": "Istražite više {{label}} objekata", + "exploreIsUnavailable": { + "title": "Istraživanje nije dostupno", + "embeddingsReindexing": { + "context": "Istraživanje može se koristiti nakon što se reindeksiranje ugrađenih objekata završi.", + "startingUp": "Pokretanje…", + "estimatedTime": "Procijenjeno preostalo vrijeme:", + "finishingShortly": "Završetak uskoro", + "step": { + "thumbnailsEmbedded": "Ugrađene miniaturne slike: ", + "descriptionsEmbedded": "Ugrađene opise: ", + "trackedObjectsProcessed": "Obrađeni praćeni objekti: " + } + }, + "downloadingModels": { + "context": "Frigate preuzima potrebne modele ugrađenih objekata kako bi podržao funkciju Semantičke pretrage. Ovo može trajati nekoliko minuta ovisno o brzini vaše mreže.", + "setup": { + "visionModel": "Model vida", + "visionModelFeatureExtractor": "Izvođač značajki modela vida", + "textModel": "Model teksta", + "textTokenizer": "Tokenizator teksta" + }, + "tips": { + "context": "Moguće je da želite ponovno indeksirati ugrađene objekte koji se prate nakon što se modele preuzmu." + }, + "error": "Dogodila se greška. Provjerite zapise Frigate." + } + }, + "trackedObjectDetails": "Detalji praćenih objekata", + "type": { + "details": "Detalji", + "snapshot": "Snimak", + "thumbnail": "miniaturna slika", + "video": "Video", + "tracking_details": "detalji praćenja" + }, + "trackingDetails": { + "title": "Detalji praćenja", + "noImageFound": "Nije pronađena slika za ovaj vremenski moment.", + "createObjectMask": "Kreirajte masku objekta", + "adjustAnnotationSettings": "Prilagodite postavke oznaka", + "scrollViewTips": "Kliknite da biste vidjeli važne trenutke životnog ciklusa ovog objekta.", + "autoTrackingTips": "Pozicije okvirnih kutija neće biti tačne za autotracking kamere.", + "count": "{{first}} od {{second}}", + "trackedPoint": "Praćena tačka", + "lifecycleItemDesc": { + "visible": "{{label}} detektovan", + "entered_zone": "{{label}} ušao u {{zones}}", + "active": "{{label}} postao aktivno", + "stationary": "{{label}} postao stacionarno", + "attribute": { + "faceOrLicense_plate": "{{attribute}} detektovan za {{label}}", + "other": "{{label}} prepoznat kao {{attribute}}" + }, + "gone": "{{label}} otišao", + "heard": "{{label}} čujeo", + "external": "{{label}} detektovan", + "header": { + "zones": "Zone", + "ratio": "Omjer", + "area": "Površina", + "score": "Rezultat", + "computedScore": "Izračunata ocjena", + "topScore": "Najbolja ocjena", + "toggleAdvancedScores": "Prekidač naprednih ocjena" + } + }, + "annotationSettings": { + "title": "Postavke oznaka", + "showAllZones": { + "title": "Prikaži sve zone", + "desc": "Uvijek prikazujte zone na okvirima gdje su objekti ušli u zonu." + }, + "offset": { + "label": "Pomak oznaka", + "desc": "Ova podatka dolaze iz vaše kamere detektovane snimke, ali se preklapaju na slikama iz snimke snimke. Vjerojatno nije moguće da su dva toka savršeno sinhronizirana. Kao rezultat, okvirni kutiji i snimke neće se savršeno poklopiti. Možete koristiti ovu postavku da pomaknete oznake unaprijed ili unazad u vremenu da bi ih bolje uskladili s snimljenim snimkom.", + "millisecondsToOffset": "Milisekunde za pomak detektovanih oznaka. Podrazumevano: 0", + "tips": "Smanjite vrijednost ako je reprodukcija videa ispred kutija i tačaka putanje, a povećajte vrijednost ako je reprodukcija videa iza njih. Ova vrijednost može biti negativna.", + "toast": { + "success": "Pomak anotacije za {{camera}} je sačuvan u konfiguracionu datoteku." + } + } + }, + "carousel": { + "previous": "Prethodni slajd", + "next": "Sljedeći slajd" + } + }, + "details": { + "item": { + "title": "Pregled detalja stavke", + "desc": "Detalji stavke za pregled", + "button": { + "share": "Dijelite ovu stavku za pregled", + "viewInExplore": "Pregledajte u Explore" + }, + "tips": { + "mismatch_one": "{{count}} nedostupan objekat je detektovan i uključen u ovu stavku pregleda. Ti objekti se nisu kvalifikovali kao upozorenje ili detekcija, ili su već očišćeni/obrisani.", + "mismatch_few": "{{count}} nedostupnih objekata je detektovano i uključeno u ovu stavku pregleda. Ti objekti se nisu kvalifikovali kao upozorenje ili detekciju, ili su već očišćeni/obrisani.", + "mismatch_other": "{{count}} nedostupnih objekata je detektovano i uključeno u ovu stavku pregleda. Ti objekti se nisu kvalifikovali kao upozorenje ili detekciju, ili su već očišćeni/obrisani.", + "hasMissingObjects": "Prilagodite svoju konfiguraciju ako želite da Frigate sačuva pratiti objekte za sljedeće oznake: {{objects}}" + }, + "toast": { + "success": { + "regenerate": "Zahtjev za novi opis je poslat {{provider}}. Ovisno o brzini vašeg provajdera, novi opis može potrajati neko vrijeme da se ponovno generira.", + "updatedSublabel": "Uspješno ažurirana podjezika.", + "updatedLPR": "Uspješno ažurirana tablica.", + "updatedAttributes": "Uspješno ažurirana atribute.", + "audioTranscription": "Uspješno zahtjev za audio transkripciju. Ovisno o brzini vašeg Frigate servera, transkripcija može potrajati neko vrijeme da se završi." + }, + "error": { + "regenerate": "Neuspješno poziv {{provider}} za novi opis: {{errorMessage}}", + "updatedSublabelFailed": "Neuspješno ažuriranje podjezika: {{errorMessage}}", + "updatedLPRFailed": "Neuspješno ažuriranje tablice: {{errorMessage}}", + "updatedAttributesFailed": "Neuspješno ažuriranje atribute: {{errorMessage}}", + "audioTranscription": "Neuspješno zahtjev za audio transkripciju: {{errorMessage}}" + } + } + }, + "label": "Oznaka", + "editSubLabel": { + "title": "Uredi podjeziku", + "desc": "Unesite novu podjeziku za ovaj {{label}}", + "descNoLabel": "Unesite novu podjeziku za ovaj pratiti objekt" + }, + "editLPR": { + "title": "Uredi tablica", + "desc": "Unesite novu vrijednost tablice za ovaj {{label}}", + "descNoLabel": "Unesite novu vrijednost tablice za ovaj praćeni objekt" + }, + "editAttributes": { + "title": "Uredi atribute", + "desc": "Odaberite atribute klasifikacije za ovaj {{label}}" + }, + "snapshotScore": { + "label": "Snimak Rezultat" + }, + "topScore": { + "label": "Najbolji Rezultat", + "info": "Najbolji rezultat je najviši srednji rezultat za praćeni objekt, pa se može razlikovati od rezultata prikazanog na minijaturi rezultata pretrage." + }, + "score": { + "label": "Rezultat" + }, + "recognizedLicensePlate": "Prepoznata tablica", + "attributes": "Atributi klasifikacije", + "estimatedSpeed": "Procijenjena brzina", + "objects": "Objekti", + "camera": "Kamera", + "zones": "Zone", + "timestamp": "Vremenski pečat", + "button": { + "findSimilar": "Pronađi slične", + "regenerate": { + "title": "Regeneriraj", + "label": "Regeneriraj opis praćenog objekta" + } + }, + "description": { + "label": "Opis", + "placeholder": "Opis praćenog objekta", + "aiTips": "Frigate neće tražiti opis od vašeg generativnog AI provajdera dok se životni vijek praćenog objekta ne završi." + }, + "expandRegenerationMenu": "Proširi izbornik regeneracije", + "regenerateFromSnapshot": "Regeneriraj iz snimka", + "regenerateFromThumbnails": "Regeneriraj iz minijatura", + "tips": { + "descriptionSaved": "Uspješno sačuvan opis", + "saveDescriptionFailed": "Neuspješno ažuriranje opisa: {{errorMessage}}" + }, + "title": { + "label": "Naslov" + }, + "scoreInfo": "Informacije o rezultatu" + }, + "itemMenu": { + "downloadVideo": { + "label": "Preuzmi video", + "aria": "Preuzmi video" + }, + "downloadSnapshot": { + "label": "Preuzmi snimak", + "aria": "Preuzmi snimak" + }, + "downloadCleanSnapshot": { + "label": "Preuzmi čist snimak", + "aria": "Preuzmi čist snimak" + }, + "viewTrackingDetails": { + "label": "Pregledaj detalje praćenja", + "aria": "Prikaži detalje praćenja" + }, + "findSimilar": { + "label": "Pronađi slične", + "aria": "Pronađi slične praćene objekte" + }, + "addTrigger": { + "label": "Dodaj izazov", + "aria": "Dodaj izazov za ovaj praćeni objekt" + }, + "audioTranscription": { + "label": "Transkriptiraj", + "aria": "Zatraži transkripciju zvuka" + }, + "submitToPlus": { + "label": "Pošalji na Frigate+", + "aria": "Pošalji na Frigate Plus" + }, + "viewInHistory": { + "label": "Pregledajte u povijesti", + "aria": "Pregledajte u povijesti" + }, + "deleteTrackedObject": { + "label": "Obriši ovaj praćeni objekt" + }, + "showObjectDetails": { + "label": "Prikaži put objekta" + }, + "hideObjectDetails": { + "label": "Sakrij put objekta" + }, + "debugReplay": { + "label": "Debug ponovno snimanje", + "aria": "Pregledaj ovaj praćeni objekt u pogledu debug ponovnog snimanja" + }, + "more": { + "aria": "Više" + } + }, + "dialog": { + "confirmDelete": { + "title": "Potvrdi brisanje", + "desc": "Brisanje ovog praćenog objekta uklanja snimak, bilo kakve sačuvane ugradnje, i sve povezane unose detalja praćenja. Snimljeni materijal ovog praćenog objekta u pogledu povijesti NEĆE biti obrisan.

Sigurno li želite nastaviti?" + }, + "toast": { + "error": "Greška prilikom brisanja ovog praćenog objekta: {{errorMessage}}" + } + }, + "noTrackedObjects": "Nijedan praćeni objekt nije pronađen", + "fetchingTrackedObjectsFailed": "Greška prilikom dohvaćanja praćenih objekata: {{errorMessage}}", + "trackedObjectsCount_one": "{{count}} praćeni objekt ", + "trackedObjectsCount_few": "{{count}} praćena objekta ", + "trackedObjectsCount_other": "{{count}} praćena objekta ", + "searchResult": { + "tooltip": "Pronađeno {{type}} na {{confidence}}%", + "previousTrackedObject": "Prethodni praćeni objekt", + "nextTrackedObject": "Sljedeći praćeni objekt", + "deleteTrackedObject": { + "toast": { + "success": "Praćeni objekt je uspješno obrisan.", + "error": "Neuspješno brisanje praćenog objekta: {{errorMessage}}" + } + } + }, + "aiAnalysis": { + "title": "Analiza AI" + }, + "concerns": { + "label": "Pitanja" + }, + "objectLifecycle": { + "noImageFound": "Nije pronađena slika za ovaj praćeni objekt." + } +} diff --git a/web/public/locales/bs/views/exports.json b/web/public/locales/bs/views/exports.json new file mode 100644 index 0000000000..b04ece10b0 --- /dev/null +++ b/web/public/locales/bs/views/exports.json @@ -0,0 +1,128 @@ +{ + "search": "Pretraga", + "documentTitle": "Izvoz - Frigate", + "selected_one": "{{count}} odabrano", + "selected_other": "{{count}} odabrano", + "noExports": "Nijedan izvoz nije pronađen", + "headings": { + "cases": "Slučajevi", + "uncategorizedExports": "Nekategorizirani izvozi" + }, + "deleteExport": { + "label": "Obriši izvoz", + "desc": "Da li ste sigurni da želite da obrišete {{exportName}}?" + }, + "editExport": { + "title": "Preimenuj izvoz", + "desc": "Unesite novi naziv za ovaj izvoz.", + "saveExport": "Sačuvaj izvoz" + }, + "tooltip": { + "shareExport": "Dijeli izvoz", + "downloadVideo": "Preuzmi video", + "editName": "Uredi naziv", + "deleteExport": "Obriši izvoz", + "assignToCase": "Dodaj u slučaj", + "removeFromCase": "Ukloni iz slučaja" + }, + "toolbar": { + "newCase": "Novi slučaj", + "addExport": "Dodaj izvoz", + "editCase": "Uredi slučaj", + "deleteCase": "Obriši slučaj" + }, + "toast": { + "error": { + "renameExportFailed": "Neuspješno preimenovanje izvoza: {{errorMessage}}", + "assignCaseFailed": "Neuspješno ažuriranje dodjele slučaja: {{errorMessage}}", + "caseSaveFailed": "Neuspješno čuvanje slučaja: {{errorMessage}}", + "caseDeleteFailed": "Neuspješno brisanje slučaja: {{errorMessage}}" + } + }, + "deleteCase": { + "label": "Obriši slučaj", + "desc": "Da li ste sigurni da želite da obrišete {{caseName}}?", + "descKeepExports": "Izvozi će ostati dostupni kao nekategorizirani izvozi.", + "descDeleteExports": "Svi izvozi u ovom slučaju trajno će biti obrisani.", + "deleteExports": "Takođe izbriši izvoze" + }, + "caseDialog": { + "title": "Dodaj u slučaj", + "description": "Odaberite postojeći slučaj ili napravite novi.", + "selectLabel": "Slučaj", + "newCaseOption": "Napravite novi slučaj", + "nameLabel": "Ime slučaja", + "descriptionLabel": "Opis" + }, + "caseCard": { + "emptyCase": "Nema još izvoza" + }, + "jobCard": { + "defaultName": "{{camera}} izvoz", + "queued": "U redu", + "running": "Pokretanje", + "preparing": "Priprema", + "copying": "Kopiranje", + "encoding": "Kodiranje", + "encodingRetry": "Kodiranje (ponovi)", + "finalizing": "Završavanje" + }, + "caseView": { + "noDescription": "Nema opisa", + "createdAt": "Kreirano {{value}}", + "exportCount_one": "1 izvoz", + "exportCount_other": "{{count}} izvozi", + "cameraCount_one": "1 kamera", + "cameraCount_other": "{{count}} kamere", + "showMore": "Prikaži više", + "showLess": "Prikaži manje", + "emptyTitle": "Ovaj slučaj je prazan", + "emptyDescription": "Dodaj postojet će nekategorizirane izvoze kako bi slučaj ostao organizovan.", + "emptyDescriptionNoExports": "Nema dostupnih nekategoriziranih izvoza koje je moguće dodati još." + }, + "caseEditor": { + "createTitle": "Kreiraj slučaj", + "editTitle": "Uredi slučaj", + "namePlaceholder": "Ime slučaja", + "descriptionPlaceholder": "Dodaj napomene ili kontekst za ovaj slučaj" + }, + "addExportDialog": { + "title": "Dodaj izvoz u {{caseName}}", + "searchPlaceholder": "Pretraga nekategoriziranih izvoza", + "empty": "Nema nekategoriziranih izvoza koji odgovaraju ovoj pretrazi.", + "addButton_one": "Dodaj 1 izvoz", + "addButton_other": "Dodaj {{count}} izvoza", + "adding": "Dodavanje..." + }, + "bulkActions": { + "addToCase": "Dodaj u slučaj", + "moveToCase": "Premjesti u slučaj", + "removeFromCase": "Ukloni iz slučaja", + "delete": "Obriši", + "deleteNow": "Obriši sada" + }, + "bulkDelete": { + "title": "Obriši izvoze", + "desc_one": "Sigurni li ste da želite obrisati {{count}} izvoz?", + "desc_other": "Sigurni li ste da želite obrisati {{count}} izvoze?" + }, + "bulkRemoveFromCase": { + "title": "Ukloni iz slučaja", + "desc_one": "Ukloni {{count}} izvoz iz ovog slučaja?", + "desc_other": "Ukloni {{count}} izvoze iz ovog slučaja?", + "descKeepExports": "Izvozi će biti premješteni u nekategorizirane.", + "descDeleteExports": "Izvozi će biti trajno obrisani.", + "deleteExports": "Umjesto toga, obriši izvoze" + }, + "bulkToast": { + "success": { + "delete": "Uspješno obrisani izvozi", + "reassign": "Uspješno ažurirana dodjela slučaja", + "remove": "Uspješno uklonjeni izvozi iz slučaja" + }, + "error": { + "deleteFailed": "Neuspješno brisanje izvoza: {{errorMessage}}", + "reassignFailed": "Neuspješno ažuriranje dodjele slučaja: {{errorMessage}}" + } + } +} diff --git a/web/public/locales/bs/views/faceLibrary.json b/web/public/locales/bs/views/faceLibrary.json new file mode 100644 index 0000000000..db74fe1f56 --- /dev/null +++ b/web/public/locales/bs/views/faceLibrary.json @@ -0,0 +1,98 @@ +{ + "description": { + "addFace": "Dodajte novu kolekciju u Biblioteku lica prema učitavanju svoje prve slike.", + "placeholder": "Unesite ime za ovu kolekciju", + "invalidName": "Neprihvatljivo ime. Imena mogu sadržavati samo slova, brojeve, razmake, aposrofe, donje crte i crte.", + "nameCannotContainHash": "Ime ne može sadržavati #." + }, + "details": { + "unknown": "Nepoznato", + "timestamp": "Vremenski pečat", + "scoreInfo": "Ocjena je težinski prosjek svih ocjena lica, težinski određen prema veličini lica u svakoj slici." + }, + "train": { + "titleShort": "Nedavno", + "title": "Najnovije prepoznavanja", + "aria": "Odaberite nedavna prepoznavanja", + "empty": "Nema nedavnih pokušaja prepoznavanja lica" + }, + "documentTitle": "Biblioteka lica - Frigate", + "uploadFaceImage": { + "title": "Učitajte sliku lica", + "desc": "Učitajte sliku za skeniranje lica i uključite za {{pageToggle}}" + }, + "collections": "Kolekcije", + "createFaceLibrary": { + "new": "Stvori novo lice", + "nextSteps": "Da biste izgradili čvrstu osnovu:
  • Koristite karticu Najnovije prepoznavanja da biste odabrali i trenirali se na slikama za svaku detektiranu osobu.
  • Fokusirajte se na slike iz pravog ugla za najbolje rezultate; izbjegavajte slike za treniranje koje prikazuju lica pod uglom.
  • " + }, + "steps": { + "faceName": "Unesite ime lica", + "uploadFace": "Učitajte sliku lica", + "nextSteps": "Sljedeći koraci", + "description": { + "uploadFace": "Učitajte sliku od {{name}} koja prikazuje njihovo lice iz pravog ugla. Slika ne mora biti izrezana samo na njihovo lice." + } + }, + "deleteFaceLibrary": { + "title": "Izbrišite ime", + "desc": "Da li ste sigurni da želite izbrisati kolekciju {{name}}? Ovo će trajno izbrisati sva povezana lica." + }, + "deleteFaceAttempts": { + "title": "Izbrišite lica", + "desc_one": "Da li ste sigurni da želite izbrisati {{count}} lice? Ova akcija ne može se poništiti.", + "desc_few": "Da li ste sigurni da želite izbrisati {{count}} lica? Ova akcija ne može se poništiti.", + "desc_other": "Da li ste sigurni da želite izbrisati {{count}} lica? Ova akcija ne može se poništiti." + }, + "renameFace": { + "title": "Preimenujte lice", + "desc": "Unesite novo ime za {{name}}" + }, + "button": { + "deleteFaceAttempts": "Izbrišite lica", + "addFace": "Dodaj lice", + "renameFace": "Preimenuj lice", + "deleteFace": "Obriši lice", + "uploadImage": "Prenesi sliku", + "reprocessFace": "Ponovno obradi lice" + }, + "imageEntry": { + "validation": { + "selectImage": "Molimo izaberite datoteku slike." + }, + "dropActive": "Pustite sliku ovdje…", + "dropInstructions": "Povucite i ispišite, zalijepite sliku ovdje ili kliknite za odabir", + "maxSize": "Maksimalna veličina: {{size}}MB" + }, + "nofaces": "Nema dostupnih lica", + "trainFaceAs": "Obuči lice kao:", + "trainFace": "Obuči lice", + "reclassifyFaceAs": "Ponovno klasificiraj lice kao:", + "reclassifyFace": "Ponovno klasificiraj lice", + "toast": { + "success": { + "uploadedImage": "Uspješno prenesena slika.", + "addFaceLibrary": "{{name}} je uspješno dodan u biblioteku lica!", + "deletedFace_one": "Uspješno obrisano {{count}} lice.", + "deletedFace_few": "Uspješno obrisana {{count}} lica.", + "deletedFace_other": "Uspješno obrisana {{count}} lica.", + "deletedName_one": "{{count}} lice je uspješno obrisano.", + "deletedName_few": "{{count}} lica su uspješno obrisana.", + "deletedName_other": "{{count}} lica su uspješno obrisana.", + "renamedFace": "Uspješno preimenovan lice na {{name}}", + "trainedFace": "Uspješno obučeno lice.", + "reclassifiedFace": "Uspješno ponovno klasificirano lice.", + "updatedFaceScore": "Uspješno ažurirana ocjena lica na {{name}} ({{score}})." + }, + "error": { + "uploadingImageFailed": "Nije uspješno prenijeti sliku: {{errorMessage}}", + "addFaceLibraryFailed": "Nije uspješno postaviti ime lica: {{errorMessage}}", + "deleteFaceFailed": "Neuspješno brisanje: {{errorMessage}}", + "deleteNameFailed": "Nije uspješno obrisati ime: {{errorMessage}}", + "renameFaceFailed": "Nije uspješno preimenovati lice: {{errorMessage}}", + "trainFailed": "Nije uspješno trenirati: {{errorMessage}}", + "reclassifyFailed": "Nije uspješno ponovno klasifikovati lice: {{errorMessage}}", + "updateFaceScoreFailed": "Nije uspješno ažurirati bodove lica: {{errorMessage}}" + } + } +} diff --git a/web/public/locales/bs/views/live.json b/web/public/locales/bs/views/live.json new file mode 100644 index 0000000000..e4fa6735df --- /dev/null +++ b/web/public/locales/bs/views/live.json @@ -0,0 +1,199 @@ +{ + "documentTitle": { + "default": "Uživo - Frigate", + "withCamera": "{{camera}} - Uživo - Frigate" + }, + "lowBandwidthMode": "Nizopojasni režim", + "twoWayTalk": { + "enable": "Omogući dvostrani razgovor", + "disable": "Onemogući dvostrani razgovor" + }, + "cameraAudio": { + "enable": "Omogući zvuk kamere", + "disable": "Onemogući zvuk kamere" + }, + "ptz": { + "move": { + "clickMove": { + "label": "Kliknite unutar okvira da biste centrirali kameru", + "enable": "Omogući klik za pomak", + "enableWithZoom": "Omogući klik za pomak / povucite za uvećanje", + "disable": "Onemogući klik za pomak" + }, + "left": { + "label": "Pomaknite PTZ kameru ulevo" + }, + "up": { + "label": "Pomaknite PTZ kameru gore" + }, + "down": { + "label": "Pomaknite PTZ kameru dolje" + }, + "right": { + "label": "Pomaknite PTZ kameru udesno" + } + }, + "zoom": { + "in": { + "label": "Uvećajte PTZ kameru" + }, + "out": { + "label": "Umanjite PTZ kameru" + } + }, + "focus": { + "in": { + "label": "Fokusirajte PTZ kameru unapred" + }, + "out": { + "label": "Fokusirajte PTZ kameru unazad" + } + }, + "frame": { + "center": { + "label": "Kliknite unutar okvira da biste centrirali PTZ kameru" + } + }, + "presets": "Preseti PTZ kamere" + }, + "camera": { + "enable": "Omogući kameru", + "disable": "Onemogući kameru" + }, + "muteCameras": { + "enable": "Utišajte sve kamere", + "disable": "Ponovo uključite zvuk za sve kamere" + }, + "detect": { + "enable": "Omogući detekciju", + "disable": "Onemogući detekciju" + }, + "recording": { + "enable": "Omogući snimanje", + "disable": "Onemogući snimanje" + }, + "snapshots": { + "enable": "Omogući snimke", + "disable": "Onemogući snimke" + }, + "snapshot": { + "takeSnapshot": "Preuzmi trenutni snimak", + "noVideoSource": "Nema dostupnog video izvora za snimak.", + "captureFailed": "Neuspješno snimanje trenutnog snimka.", + "downloadStarted": "Preuzimanje trenutnog snimka započeto." + }, + "audioDetect": { + "enable": "Omogući detekciju zvuka", + "disable": "Onemogući detekciju zvuka" + }, + "transcription": { + "enable": "Omogući prepoznavanje zvuka uživo", + "disable": "Onemogući prepoznavanje zvuka uživo" + }, + "autotracking": { + "enable": "Omogući automatsko praćenje", + "disable": "Onemogući automatsko praćenje" + }, + "streamStats": { + "enable": "Prikaži statistiku prijenosa", + "disable": "Sakrij statistiku prijenosa" + }, + "manualRecording": { + "title": "Na zahtjev", + "tips": "Preuzmi trenutni snimak ili pokreni ručni događaj na temelju postavki trajanja snimanja ove kamere.", + "playInBackground": { + "label": "Ponovno postavi stream", + "desc": "Omogući ovu opciju da nastavi streamanje kada je pokazivač sakriven." + }, + "showStats": { + "label": "Prikaži statistiku", + "desc": "Omogući ovu opciju da prikaže statistiku prijenosa kao preklapanje na toku kamere." + }, + "debugView": "Pregled za otklanjanje grešaka", + "start": "Počni snimanje na zahtjev", + "started": "Pokrenuto ručno snimanje na zahtjev.", + "failedToStart": "Neuspješno pokretanje ručnog snimanja na zahtjev.", + "recordDisabledTips": "Kako je snimanje onemogućeno ili ograničeno u konfiguraciji za ovu kameru, spremat će se samo snimak.", + "end": "Završi snimanje na zahtjev", + "ended": "Završeno ručno snimanje na zahtjev.", + "failedToEnd": "Neuspješno završavanje ručnog snimanja na zahtjev." + }, + "streamingSettings": "Postavke streamanja", + "notifications": "Obavještenja", + "audio": "Audio", + "suspend": { + "forTime": "Pauziraj za: " + }, + "stream": { + "title": "Tok", + "audio": { + "tips": { + "title": "Audio mora biti izlaz iz vaše kamere i konfiguriran u go2rtc za ovaj stream." + }, + "available": "Audio je dostupan za ovaj stream", + "unavailable": "Audio nije dostupan za ovaj stream" + }, + "debug": { + "picker": "Izbor streama nije dostupan u režimu debuga. Pregled debuga uvijek koristi stream dodeljen ulozi detekcije." + }, + "twoWayTalk": { + "tips": "Vaš uređaj mora podržavati funkciju, a WebRTC mora biti konfiguriran za dvosmernu komunikaciju.", + "available": "Dvosmerna komunikacija je dostupna za ovaj stream", + "unavailable": "Dvosmerna komunikacija nije dostupna za ovaj stream" + }, + "lowBandwidth": { + "tips": "Živo prikazivanje je u režimu niske propusnosti zbog buferiranja ili grešaka u streamu.", + "resetStream": "Ponovno postavi stream" + }, + "playInBackground": { + "label": "Ponovno postavi stream", + "tips": "Omogući ovu opciju da nastavi streamanje kada je pokazivač sakriven." + } + }, + "cameraSettings": { + "title": "{{camera}} Postavke", + "cameraEnabled": "Kamera omogućena", + "objectDetection": "Detekcija objekata", + "recording": "Snimanje", + "snapshots": "Snimci", + "audioDetection": "Detekcija zvuka", + "transcription": "Transkripcija zvuka", + "autotracking": "Autotračenje" + }, + "history": { + "label": "Prikaži povijesne snimke" + }, + "effectiveRetainMode": { + "modes": { + "all": "Sve", + "motion": "Kretanje", + "active_objects": "Aktivni objekti" + } + }, + "editLayout": { + "label": "Uredi raspored", + "group": { + "label": "Uredi grupu kamera" + }, + "exitEdit": "Izađi iz uređivanja" + }, + "noCameras": { + "title": "Nema konfiguriranih kamera", + "description": "Počnite tako što ćete povezati kameru s Frigate.", + "buttonText": "Dodaj kameru", + "restricted": { + "title": "Nema dostupnih kamera", + "description": "Nemate dozvolu za pregled bilo koje kamere u ovoj grupi." + }, + "default": { + "title": "Nema konfiguriranih kamera", + "description": "Počnite tako što ćete povezati kameru s Frigate.", + "buttonText": "Dodaj kameru" + }, + "group": { + "title": "Nema kamera u grupi", + "description": "Ova grupa kamera nema dodeljene ili omogućene kamere.", + "buttonText": "Upravljajte grupama" + } + } +} diff --git a/web/public/locales/bs/views/motionSearch.json b/web/public/locales/bs/views/motionSearch.json new file mode 100644 index 0000000000..f9e417fd19 --- /dev/null +++ b/web/public/locales/bs/views/motionSearch.json @@ -0,0 +1,77 @@ +{ + "documentTitle": "Pretraga pokreta - Frigate", + "title": "Pretraga pokreta", + "description": "Nacrtaj poligon da biste definirali regiju interesa, a zatim navedite vremenski raspon za pretragu promjena pokreta unutar te regije.", + "selectCamera": "Pretraga pokreta učitava se", + "startSearch": "Počni pretragu", + "searchStarted": "Pretraga započeta", + "searchCancelled": "Pretraga otkazana", + "cancelSearch": "Otkaži", + "searching": "Pretraga u toku.", + "searchComplete": "Pretraga završena", + "noResultsYet": "Pokrenite pretragu da biste pronašli promjene pokreta u odabranoj regiji", + "noChangesFound": "Nisu otkrivene promjene piksela u odabranoj regiji", + "changesFound_one": "Pronađeno {{count}} promjena pokreta", + "changesFound_few": "Pronađeno {{count}} nekoliko pokreta", + "changesFound_other": "Pronađeno {{count}} promjene pokreta", + "framesProcessed": "{{count}} okvir procesiran", + "jumpToTime": "Preskoči na ovo vrijeme", + "results": "Rezultati", + "showSegmentHeatmap": "Top mapa", + "newSearch": "Nova pretraga", + "clearResults": "Očisti rezultate", + "clearROI": "Očisti poligon", + "polygonControls": { + "points_one": "{{count}} tačka", + "points_few": "{{count}} tačke", + "points_other": "{{count}} tačke", + "undo": "Poništi posljednju tačku", + "reset": "Ponovi poligon" + }, + "motionHeatmapLabel": "Top mapa pokreta", + "dialog": { + "title": "Pretraga pokreta", + "cameraLabel": "Kamera", + "previewAlt": "Pregled kamere za {{camera}}" + }, + "timeRange": { + "title": "Opseg pretrage", + "start": "Početno vrijeme", + "end": "Krajnje vrijeme" + }, + "settings": { + "title": "Postavke pretrage", + "parallelMode": "Paralelni način", + "parallelModeDesc": "Skeniranje više segmenata snimaka istovremeno (brže, ali značajno intenzivnije za CPU)", + "threshold": "Praga osjetljivosti", + "thresholdDesc": "Niže vrijednosti detektiraju manje promjene (1-255)", + "minArea": "Minimalna površina promjene", + "minAreaDesc": "Minimalni postotak područja interesa koji mora promijeniti da bi se smatrao značajnim", + "frameSkip": "Preskoči okvir", + "frameSkipDesc": "Obrađujte svaki N-ti okvir. Postavite ovo na brzinu okvira vaše kamere da biste obradili jedan okvir po sekundi (npr. 5 za 5 FPS kameru, 30 za 30 FPS kameru). Više vrijednosti će biti brže, ali mogu propustiti kratke događaje pokreta.", + "maxResults": "Maksimalni rezultati", + "maxResultsDesc": "Zaustavi nakon ovog broja odgovarajućih vremenskih oznaka" + }, + "errors": { + "noCamera": "Molimo odaberite kameru", + "noROI": "Molimo nacrtajte područje interesa", + "noTimeRange": "Molimo odaberite vremenski opseg", + "invalidTimeRange": "Krajnje vrijeme mora biti nakon početnog vremena", + "searchFailed": "Pretraga neuspješna: {{message}}", + "polygonTooSmall": "Poligon mora imati najmanje 3 točke", + "unknown": "Nepoznata greška" + }, + "changePercentage": "{{percentage}}% promijenjeno", + "metrics": { + "title": "Metrike pretrage", + "segmentsScanned": "Skenirani segmenti", + "segmentsProcessed": "Obrađeno", + "segmentsSkippedInactive": "Preskočeno (bez aktivnosti)", + "segmentsSkippedHeatmap": "Preskočeno (bez preklapanja ROI)", + "fallbackFullRange": "Povratni put skeniranje cijelog opsega", + "framesDecoded": "Dekodirani okviri", + "wallTime": "Vrijeme pretrage", + "segmentErrors": "Greške segmenta", + "seconds": "{{seconds}}s" + } +} diff --git a/web/public/locales/bs/views/recording.json b/web/public/locales/bs/views/recording.json new file mode 100644 index 0000000000..1810fadad2 --- /dev/null +++ b/web/public/locales/bs/views/recording.json @@ -0,0 +1,12 @@ +{ + "filter": "Filtar", + "export": "Izvoz", + "calendar": "Kalendar", + "filters": "Filtari", + "toast": { + "error": { + "noValidTimeSelected": "Nije odabran valjan vremenski opseg", + "endTimeMustAfterStartTime": "Krajnje vrijeme mora biti nakon početnog vremena" + } + } +} diff --git a/web/public/locales/bs/views/replay.json b/web/public/locales/bs/views/replay.json new file mode 100644 index 0000000000..0b102b53f2 --- /dev/null +++ b/web/public/locales/bs/views/replay.json @@ -0,0 +1,59 @@ +{ + "title": "Debug ponavljanje", + "description": "Ponovno prikazivanje snimaka kamere za ispitivanje. Lista objekata prikazuje zakasnjelje sažetak detektiranih objekata, a kartica Zapisi prikazuje tok unutrašnjih poruka Frigate iz snimaka ponavljanja.", + "websocket_messages": "Poruke", + "dialog": { + "title": "Počni debug ponavljanje", + "description": "Kreiraj privremenu kameru za ponavljanje koja ponavlja povijesne snimke za ispitivanje problema detekcije i praćenja objekata. Kamera za ponavljanje će imati istu konfiguraciju detekcije kao i izvorna kamera. Odaberite vremenski raspon za početak.", + "camera": "Izvorna kamera", + "timeRange": "Vremenski opseg", + "preset": { + "1m": "Posljednja 1 minuta", + "5m": "Posljednje 5 minuta", + "timeline": "Iz vremenske linije", + "custom": "Prilagođeno" + }, + "startButton": "Počni ponavljanje", + "selectFromTimeline": "Odaberite", + "starting": "Pokretanje ponavljanja...", + "startLabel": "Početak", + "endLabel": "Kraj", + "toast": { + "error": "Neuspješno pokretanje debug ponavljanja: {{error}}", + "alreadyActive": "Već postoji aktivna sesija ponavljanja", + "stopError": "Neuspješno zaustavljanje debug ponavljanja: {{error}}", + "goToReplay": "Idi na ponavljanje" + } + }, + "page": { + "noSession": "Nema aktivne sesije ponavljanja", + "noSessionDesc": "Pokrenite debug ponavljanje iz pogleda Povijest klikom na dugme Debug Replay u alatnoj traci.", + "goToRecordings": "Idi na povijest", + "sourceCamera": "Izvorna kamera", + "replayCamera": "Kamera za ponavljanje", + "initializingReplay": "Inicijalizacija ponavljanja...", + "stoppingReplay": "Zaustavljanje ponavljanja...", + "stopReplay": "Zaustavi ponavljanje", + "confirmStop": { + "title": "Zaustavi režim ponavljanja za debagovanje?", + "description": "Ovo će zaustaviti sesiju ponavljanja i očistiti sve privremene podatke. Sigurni li?", + "confirm": "Zaustavi ponavljanje", + "cancel": "Otkaži" + }, + "activity": "Aktivnost", + "objects": "Popis objekata", + "audioDetections": "Audio detekcije", + "noActivity": "Nema detektovane aktivnosti", + "activeTracking": "Aktivno praćenje", + "noActiveTracking": "Nema aktivnog praćenja", + "configuration": "Konfiguracija", + "configurationDesc": "Podesiti precizno detekciju pokreta i praćenje objekata za kameru za debagovanje ponavljanja. Promjene se ne čuvaju u datoteci konfiguracije Frigate.", + "preparingClip": "Pripremam klip…", + "preparingClipDesc": "Frigate spaja snimke za odabrani vremenski raspon. Ovo može potrajati minut za duže raspone.", + "startingCamera": "Pokretanje ponovnog pokretanja otklanjanja grešaka…", + "startError": { + "title": "Neuspjelo pokretanje ponovnog prikaza otklanjanja grešaka", + "back": "Povratak na historiju" + } + } +} diff --git a/web/public/locales/bs/views/search.json b/web/public/locales/bs/views/search.json new file mode 100644 index 0000000000..f33ff1025b --- /dev/null +++ b/web/public/locales/bs/views/search.json @@ -0,0 +1,73 @@ +{ + "search": "Pretraga", + "button": { + "save": "Sačuvaj pretragu", + "clear": "Očisti pretragu", + "delete": "Obriši sačuvanu pretragu", + "filterInformation": "Filtrirajte informacije", + "filterActive": "Filtari aktivni" + }, + "savedSearches": "Sačuvane pretrage", + "searchFor": "Pretraga za {{inputValue}}", + "trackedObjectId": "ID praćenog objekta", + "filter": { + "label": { + "cameras": "Kamere", + "labels": "Oznake", + "zones": "Zone", + "sub_labels": "Podoznake", + "attributes": "Atributi", + "search_type": "Tip pretrage", + "time_range": "Vremenski opseg", + "before": "Prije", + "after": "Nakon", + "min_score": "Min. bodovi", + "max_score": "Max. bodovi", + "min_speed": "Min. brzina", + "max_speed": "Max. brzina", + "recognized_license_plate": "Prepoznata tablica", + "has_clip": "Ima klip", + "has_snapshot": "Ima snimak" + }, + "searchType": { + "thumbnail": "Minijatura", + "description": "Opis" + }, + "toast": { + "error": { + "beforeDateBeLaterAfter": "Datum 'before' mora biti kasniji od datuma 'after'.", + "afterDatebeEarlierBefore": "Datum 'after' mora biti raniji od datuma 'before'.", + "minScoreMustBeLessOrEqualMaxScore": "Vrijednost 'min_score' mora biti manja ili jednaka vrijednosti 'max_score'.", + "maxScoreMustBeGreaterOrEqualMinScore": "Vrijednost 'max_score' mora biti veća ili jednaka vrijednosti 'min_score'.", + "minSpeedMustBeLessOrEqualMaxSpeed": "Vrijednost 'min_speed' mora biti manja ili jednaka vrijednosti 'max_speed'.", + "maxSpeedMustBeGreaterOrEqualMinSpeed": "Vrijednost 'max_speed' mora biti veća ili jednaka vrijednosti 'min_speed'." + } + }, + "tips": { + "title": "Kako koristiti tekstualne filtere", + "desc": { + "text": "Filteri vam pomažu da sužite rezultate pretrage. Evo kako ih koristiti u polju za unos:", + "step1": "Unesite ime ključa filtera, zatim dvojtočku (npr. \"kamere:\").", + "step2": "Izaberite vrijednost iz predloga ili unesite vlastitu.", + "step3": "Koristite više filtera dodavanjem jednog za drugim s razmakom između.", + "step4": "Filteri datuma (pre: i nakon:) koriste {{DateFormat}} format.", + "step5": "Filter raspona vremena koristi format {{exampleTime}}.", + "step6": "Uklonite filtre klikom na 'x' pored njih.", + "exampleLabel": "Primjer:" + } + }, + "header": { + "currentFilterType": "Vrijednosti filtera", + "noFilters": "Filtari", + "activeFilters": "Aktivni filteri" + } + }, + "similaritySearch": { + "title": "Pretraga sličnosti", + "active": "Pretraga sličnosti aktivna", + "clear": "Očisti pretragu sličnosti" + }, + "placeholder": { + "search": "Pretraži…" + } +} diff --git a/web/public/locales/bs/views/settings.json b/web/public/locales/bs/views/settings.json new file mode 100644 index 0000000000..10126febb0 --- /dev/null +++ b/web/public/locales/bs/views/settings.json @@ -0,0 +1,1698 @@ +{ + "documentTitle": { + "default": "Postavke - Frigate", + "authentication": "Postavke autentifikacije - Frigate", + "cameraManagement": "Upravljanje kamerama - Frigate", + "cameraReview": "Postavke pregleda kamera - Frigate", + "enrichments": "Postavke bogatstva - Frigate", + "masksAndZones": "Uređivač maski i zona - Frigate", + "motionTuner": "Podešavanje pokreta - Frigate", + "object": "Debug - Frigate", + "general": "Postavke korisničkog sučelja - Frigate", + "globalConfig": "Globalna konfiguracija - Frigate", + "cameraConfig": "Konfiguracija kamere - Frigate", + "frigatePlus": "Postavke Frigate+ - Frigate", + "notifications": "Postavke obavijesti - Frigate", + "maintenance": "Održavanje - Frigate", + "profiles": "Profili - Frigate" + }, + "menu": { + "system": "Sistem", + "profiles": "Profili", + "general": "Općenito", + "globalConfig": "Globalna konfiguracija", + "integrations": "Integracije", + "cameras": "Konfiguracija kamere", + "ui": "UI", + "uiSettings": "Postavke korisničkog sučelja", + "globalDetect": "Detekcija objekata", + "globalRecording": "Snimanje", + "globalSnapshots": "Snimci", + "globalFfmpeg": "FFmpeg", + "globalMotion": "Detekcija pokreta", + "globalObjects": "Objekti", + "globalReview": "Pregled", + "globalAudioEvents": "Audio događaji", + "globalLivePlayback": "Uživo prikaz", + "globalTimestampStyle": "Stil vremenske oznake", + "systemDatabase": "Baza podataka", + "systemTls": "TLS", + "systemAuthentication": "Autentifikacija", + "systemNetworking": "Mrežno", + "systemProxy": "Proxy", + "systemUi": "UI", + "systemLogging": "Zapisi", + "systemEnvironmentVariables": "Okolinski varijable", + "systemTelemetry": "Telemetrija", + "systemBirdseye": "Birdseye", + "systemFfmpeg": "FFmpeg", + "systemDetectorHardware": "Hardver detektora", + "systemDetectionModel": "Model detekcije", + "systemMqtt": "MQTT", + "systemGo2rtcStreams": "go2rtc streams", + "integrationSemanticSearch": "Semantička pretraga", + "integrationGenerativeAi": "Generativna AI", + "integrationFaceRecognition": "Prepoznavanje lica", + "integrationLpr": "Prepoznavanje tablice za registraciju", + "integrationObjectClassification": "Klasifikacija objekata", + "integrationAudioTranscription": "Transkripcija zvuka", + "cameraDetect": "Detekcija objekata", + "cameraFfmpeg": "FFmpeg", + "cameraRecording": "Snimanje", + "cameraSnapshots": "Snimci", + "cameraMotion": "Detekcija pokreta", + "cameraObjects": "Objekti", + "cameraConfigReview": "Pregled", + "cameraAudioEvents": "Audio događaji", + "cameraAudioTranscription": "Transkripcija zvuka", + "cameraNotifications": "Obavještenja", + "cameraLivePlayback": "Uživo prikaz", + "cameraBirdseye": "Birdseye", + "cameraFaceRecognition": "Prepoznavanje lica", + "cameraLpr": "Prepoznavanje tablice za registraciju", + "cameraMqttConfig": "MQTT", + "cameraOnvif": "ONVIF", + "cameraUi": "Kamera UI", + "cameraTimestampStyle": "Stil vremenske oznake", + "cameraMqtt": "Kamera MQTT", + "cameraManagement": "Upravljanje", + "cameraReview": "Pregled", + "masksAndZones": "Maska / Zone", + "motionTuner": "Regulator kretanja", + "enrichments": "Poboljšanja", + "users": "Korisnici", + "roles": "Uloge", + "notifications": "Obavještenja", + "triggers": "Pokretači", + "debug": "Debug", + "frigateplus": "Frigate+", + "maintenance": "Održavanje", + "mediaSync": "Sinkronizacija medija", + "regionGrid": "Mreža regija" + }, + "button": { + "overriddenGlobal": "Prekriveno (Globalno)", + "overriddenGlobalTooltip": "Ova kamera prekriva globalne postavke konfiguracije u ovom odjeljku", + "overriddenBaseConfig": "Prekriveno (Bazna konfiguracija)", + "overriddenBaseConfigTooltip": "Profil {{profile}} prekriva postavke konfiguracije u ovom odjeljku", + "overriddenInCameras": { + "label_one": "Nadjačano u {{count}} kameri", + "label_few": "Nadjačano u {{count}} kamere", + "label_other": "Nadjačano u {{count}} kamera", + "tooltip_one": "{{count}} kamera nadjačava vrijednosti u ovom odjeljku. Kliknite za detalje.", + "tooltip_few": "{{count}} kamere nadjačavaju vrijednosti u ovom odjeljku. Kliknite za detalje.", + "tooltip_other": "{{count}} kamera nadjačava vrijednosti u ovom odjeljku. Kliknite za detalje.", + "heading_one": "Ovaj globalni odjeljak ima polja koja su nadjačana u {{count}} kameri.", + "heading_few": "Ovaj globalni odjeljak ima polja koja su nadjačana u {{count}} kamere.", + "heading_other": "Ovaj globalni odjeljak ima polja koja su nadjačana u {{count}} kamera.", + "othersField_one": "{{count}} drugo", + "othersField_few": "{{count}} druga", + "othersField_other": "{{count}} drugih", + "profilePrefix": "{{profile}} profil: {{fields}}" + } + }, + "dialog": { + "unsavedChanges": { + "title": "Imate nečuvane promjene.", + "desc": "Želite li da sačuvate promjene prije nego što nastavite?" + } + }, + "saveAllPreview": { + "title": "Promjene koje treba sačuvati", + "triggerLabel": "Pregledajte čekajuće promjene", + "empty": "Nema čekajućih promjena.", + "scope": { + "label": "Opseg", + "global": "Globalni", + "camera": "Kamera: {{cameraName}}" + }, + "profile": { + "label": "Profil" + }, + "field": { + "label": "Polje" + }, + "value": { + "label": "Nova vrijednost", + "reset": "Resetuj" + } + }, + "cameraSetting": { + "camera": "Kamera", + "noCamera": "Nema kamere" + }, + "general": { + "title": "Postavke korisničkog sučelja", + "liveDashboard": { + "title": "Uživo upravljačko sučelje", + "automaticLiveView": { + "label": "Automatski pregled uživo", + "desc": "Automatski pređite na pregled uživo kamere kada se detektira aktivnost. Onemogućavanje ove opcije uzrokuje da statične slike kamere na upravljačkom sučelju uživo ažuriraju se jednom na minut." + }, + "playAlertVideos": { + "label": "Pregledajte videozapise upozorenja", + "desc": "Zaduženo, nedavna upozorenja na upravljačkom sučelju uživo se prikazuju kao mala petlja videa. Onemogućavanje ove opcije omogućava prikaz samo statične slike nedavnih upozorenja na ovom uređaju/pregledniku." + }, + "displayCameraNames": { + "label": "Uvijek prikaži imena kamere", + "desc": "Uvijek prikaži imena kamere u čipu u pregledu uživo više kamera." + }, + "liveFallbackTimeout": { + "label": "Vrijeme čekanja za povratno prelazak igrača uživo", + "desc": "Kada je visokokvalitetni tok uživo za kameru nedostupan, pređite na način s niskom širinom pojasa nakon ovog broja sekundi. Zadano: 3." + } + }, + "storedLayouts": { + "title": "Pohranjene izgleda", + "desc": "Izgled kamere u grupi kamera može se povući/ponoviti. Položaji se pohranjuju u lokalno skladište vašeg preglednika.", + "clearAll": "Izbriši sve izgleda" + }, + "cameraGroupStreaming": { + "title": "Postavke streaminga za grupu kamera", + "desc": "Postavke streaminga za svaku grupu kamera pohranjene su u lokalno skladište vašeg preglednika.", + "clearAll": "Izbriši sve postavke streaminga" + }, + "recordingsViewer": { + "title": "Pregledač snimaka", + "defaultPlaybackRate": { + "label": "Zadani brzina reprodukcije", + "desc": "Zadani brzina reprodukcije za prikazivanje snimaka." + } + }, + "calendar": { + "title": "Kalendar", + "firstWeekday": { + "label": "Prvi radni dan", + "desc": "Dan na kojem počinju tjedni pregled kalendar.", + "sunday": "Nedjelja", + "monday": "Ponedjeljak" + } + }, + "toast": { + "success": { + "clearStoredLayout": "Izbrisana pohranjena izgled za {{cameraName}}", + "clearStreamingSettings": "Izbrisane postavke streaminga za sve grupe kamera." + }, + "error": { + "clearStoredLayoutFailed": "Nije uspješno obrisano pohranjeno raspoređenje: {{errorMessage}}", + "clearStreamingSettingsFailed": "Nije uspješno obrisano pohranjene postavke streaminga: {{errorMessage}}" + } + } + }, + "enrichments": { + "title": "Postavke obogaćivanja", + "unsavedChanges": "Nespremljene promjene postavki bogatstva", + "birdClassification": { + "title": "Klasifikacija ptica", + "desc": "Klasifikacija ptica identifikuje poznate ptice pomoću kvantiziranog Tensorflow modela. Kada se prepozna poznata ptica, njezino uobičajeno ime bit će dodato kao sub_label. Ova informacija uključena je u UI, filtere, kao i u obavijesti." + }, + "semanticSearch": { + "title": "Semantička pretraga", + "desc": "Semantička pretraga u Frigate omogućava vam da pronađete praćene objekte unutar vaših stavki za pregled pomoću same slike, korisnički definiranog tekstualnog opisa ili automatski generiranog.", + "reindexNow": { + "label": "Ponovno indeksiraj sada", + "desc": "Ponovno indeksiranje će ponovno generirati ugrađivanja za sve praćene objekte. Ovaj proces se izvršava u pozadini i može iskoristiti punu snagu vašeg CPU-a i može trajati značajno vrijeme ovisno o broju praćenih objekata koje imate.", + "confirmTitle": "Potvrdi ponovno indeksiranje", + "confirmDesc": "Sigurni li ste da želite ponovno indeksirati ugrađivanja svih praćenih objekata? Ovaj proces će se izvršavati u pozadini, ali može iskoristiti punu snagu vašeg CPU-a i može trajati značajno vrijeme. Možete pratiti napredak na stranici Istraživanje.", + "confirmButton": "Ponovno indeksiraj", + "success": "Ponovno indeksiranje je uspješno pokrenuto.", + "alreadyInProgress": "Ponovno indeksiranje već je u toku.", + "error": "Nije uspješno pokrenuto ponovno indeksiranje: {{errorMessage}}" + }, + "modelSize": { + "label": "Veličina modela", + "desc": "Veličina modela korištenog za semantičke pretrage ugrađivanja.", + "small": { + "title": "mali", + "desc": "Korištenje mali koristi kvantiziranu verziju modela koja koristi manje RAM-a i brže se izvršava na CPU-u s vrlo zanemarivim razlikama u kvaliteti ugrađivanja." + }, + "large": { + "title": "veliki", + "desc": "Korištenje veliki koristi puni Jina model i automatski će se izvršavati na GPU-u ako je primjenjivo." + } + } + }, + "faceRecognition": { + "title": "Prepoznavanje lica", + "desc": "Prepoznavanje lica omogućava ljudima da se dodele imena, a kada se prepozna lice, Frigate će dodijeliti ime osobe kao sub_label. Ova informacija uključena je u UI, filtere, kao i u obavijesti.", + "modelSize": { + "label": "Veličina modela", + "desc": "Veličina modela korištenog za prepoznavanje lica.", + "small": { + "title": "mali", + "desc": "Korišćenje mali koristi model za ugradnju lica FaceNet koji učinkovito radi na većini CPU-ova." + }, + "large": { + "title": "veliki", + "desc": "Korišćenje veliki koristi model za ugradnju lica ArcFace i automatski će se pokrenuti na GPU-u ako je primjenjivo." + } + } + }, + "licensePlateRecognition": { + "title": "Prepoznavanje tablice vozila", + "desc": "Frigate može prepoznati registracijske tablice na vozilima i automatski dodati detektirane znakove u polje recognized_license_plate ili poznato ime kao sub_label objektima koji su tipa automobil. Često korišćeni slučaj može biti čitanje registracijskih tablica automobila koji se voze u dvorište ili automobila koji prolaze ulicom." + }, + "restart_required": "Potrebno je ponovno pokretanje (promijenjene postavke bogatstva)", + "toast": { + "success": "Postavke bogatstva su sačuvane. Ponovno pokrenite Frigate da biste primijenili svoje promjene.", + "error": "Nije uspješno sačuvana promjena konfiguracije: {{errorMessage}}" + } + }, + "cameraWizard": { + "title": "Dodaj kameru", + "description": "Slijedite korake ispod da biste dodali novu kameru u svoju instalaciju Frigate.", + "steps": { + "nameAndConnection": "Ime i Povezivanje", + "probeOrSnapshot": "Testiranje ili Snimak", + "streamConfiguration": "Konfiguracija strujanja", + "validationAndTesting": "Validacija i Testiranje" + }, + "save": { + "success": "Uspješno sačuvana nova kamera {{cameraName}}.", + "failure": "Greška prilikom sačuvavanja {{cameraName}}." + }, + "testResultLabels": { + "resolution": "Rezolucija", + "video": "Video", + "audio": "Audio", + "fps": "FPS" + }, + "commonErrors": { + "noUrl": "Molimo unesite važeću URL adresu za strujanje", + "testFailed": "Test strujanja nije uspio: {{error}}" + }, + "step1": { + "description": "Unesite detalje o svojoj kameri i odaberite testiranje kamere ili ručno odaberite proizvođača.", + "cameraName": "Ime kamere", + "cameraNamePlaceholder": "npr. front_door ili Pregled zadnjeg dvorišta", + "host": "Host/IP adresa", + "port": "Port", + "username": "Korisničko ime", + "usernamePlaceholder": "Opcionalno", + "password": "Lozinka", + "passwordPlaceholder": "Opcionalno", + "selectTransport": "Odaberite protokol prenosa", + "cameraBrand": "Marka kamere", + "selectBrand": "Odaberite marku kamere za URL predložak", + "customUrl": "Prilagođeni URL tokova", + "brandInformation": "Informacije o brendu", + "brandUrlFormat": "Za kamere sa formatom RTSP URL-a kao: {{exampleUrl}}", + "customUrlPlaceholder": "rtsp://username:password@host:port/path", + "connectionSettings": "Postavke veze", + "detectionMethod": "Metoda detekcije tokova", + "onvifPort": "Port ONVIF", + "probeMode": "Pregledaj kameru", + "manualMode": "Ručna selekcija", + "detectionMethodDescription": "Pregledaj kameru pomoću ONVIF (ako je podržano) kako bi pronašao URL-ove tokova kamere, ili ručno odaberi brend kamere za korištenje preddefiniranih URL-ova. Za unos prilagođenog RTSP URL-a, odaberi ručni metod i odaberi \"Ostalo\".", + "onvifPortDescription": "Za kamere koje podržavaju ONVIF, ovo je obično 80 ili 8080.", + "useDigestAuth": "Koristi digest autentifikaciju", + "useDigestAuthDescription": "Koristi HTTP digest autentifikaciju za ONVIF. Neke kamere mogu zahtijevati dedikovane korisničko ime/lozinku ONVIF umjesto standardnog korisnika admin.", + "errors": { + "brandOrCustomUrlRequired": "Odaberite brend kamere s host/IP ili odaberite 'Ostalo' s prilagođenim URL-om", + "nameRequired": "Ime kamere je obavezno", + "nameLength": "Ime kamere mora imati 64 znaka ili manje", + "invalidCharacters": "Ime kamere sadrži nevažeće znakove", + "nameExists": "Ime kamere već postoji", + "customUrlRtspRequired": "Prilagođeni URL-ovi moraju početi s \"rtsp://\". Za tokove kamere koji nisu RTSP potrebna je ručna konfiguracija." + } + }, + "step2": { + "description": "Pregledajte kameru za dostupne tokove ili konfigurirajte ručne postavke na temelju odabrane metode detekcije.", + "testSuccess": "Test veze uspješan!", + "testFailed": "Test veze nije uspio. Molimo provjerite svoj unos i pokušajte ponovno.", + "testFailedTitle": "Test nije uspio", + "streamDetails": "Detalji tokova", + "probing": "Pregledavanje kamere...", + "retry": "Pokušaj ponovno", + "testing": { + "probingMetadata": "Pregledavanje metapodataka kamere...", + "fetchingSnapshot": "Učitavanje snimka kamere..." + }, + "probeFailed": "Neuspješno ispitivanje kamere: {{error}}", + "probingDevice": "Ispitivanje uređaja...", + "probeSuccessful": "Uspješno ispitivanje", + "probeError": "Greška ispitivanja", + "probeNoSuccess": "Neuspješno ispitivanje", + "deviceInfo": "Informacije o uređaju", + "manufacturer": "Proizvođač", + "model": "Model", + "firmware": "Firmver", + "profiles": "Profili", + "ptzSupport": "Podrška PTZ", + "autotrackingSupport": "Podrška za autotračenje", + "presets": "Podešavanja", + "rtspCandidates": "RTSP kandidati", + "rtspCandidatesDescription": "Sljedeće RTSP URL-ovi su pronađeni iz ispitivanja kamere. Testirajte vezu za pregled metapodataka o streamu.", + "noRtspCandidates": "Nisu pronađeni RTSP URL-ovi iz kamere. Vaše vjerodajnice mogu biti netočne, ili kamera možda ne podržava ONVIF ili metod za dobivanje RTSP URL-ova. Vrati se i ručno unesi RTSP URL.", + "candidateStreamTitle": "Kandidat {{number}}", + "useCandidate": "Koristi", + "uriCopy": "Kopiraj", + "uriCopied": "URI kopiran na međuspremnik", + "testConnection": "Testiraj vezu", + "toggleUriView": "Kliknite za prebacivanje u puni prikaz URI-ja", + "connected": "Povezani", + "notConnected": "Nije povezan", + "errors": { + "hostRequired": "Potrebna je adresa hosta/IP" + } + }, + "step3": { + "description": "Konfigurirajte uloge streamova i dodajte dodatne streamove za vašu kameru.", + "streamsTitle": "Streamovi kamere", + "addStream": "Dodaj snimak", + "addAnotherStream": "Dodaj još jedan snimak", + "streamTitle": "Snimak {{number}}", + "streamUrl": "URL snimka", + "streamUrlPlaceholder": "rtsp://username:password@host:port/path", + "selectStream": "Odaberite snimak", + "searchCandidates": "Pretražite kandidate...", + "noStreamFound": "Nijedan snimak nije pronađen", + "url": "URL", + "resolution": "Rezolucija", + "selectResolution": "Odaberite rezoluciju", + "quality": "Kvalitet", + "selectQuality": "Odaberite kvalitet", + "roles": "Uloge", + "roleLabels": { + "detect": "Detekcija objekata", + "record": "Snimanje", + "audio": "Audio" + }, + "testStream": "Testirajte vezu", + "testSuccess": "Test snimka uspješan!", + "testFailed": "Test snimka neuspješan", + "testFailedTitle": "Test neuspješan", + "connected": "Povezani", + "notConnected": "Nije povezan", + "featuresTitle": "Funkcije", + "go2rtc": "Smanjite povezivanje s kamerom", + "detectRoleWarning": "Bar jedan snimak mora imati ulogu \"detektirati\" da biste nastavili.", + "rolesPopover": { + "title": "Uloge snimka", + "detect": "Glavni tok za detekciju objekata.", + "record": "Sprema segmente video toka na temelju postavki konfiguracije.", + "audio": "Tok za detekciju na temelju zvuka." + }, + "featuresPopover": { + "title": "Značajke snimka", + "description": "Koristite go2rtc restreaming za smanjenje povezivanja s vašom kamerom." + } + }, + "step4": { + "description": "Konačna validacija i analiza prije spašavanja vaše nove kamere. Povežite svaki tok prije spašavanja.", + "validationTitle": "Validacija toka", + "connectAllStreams": "Poveži sve toke", + "reconnectionSuccess": "Ponovno povezivanje uspješno.", + "reconnectionPartial": "Neki tokovi su neuspješno ponovno povezani.", + "streamUnavailable": "Pregled toka nije dostupan", + "reload": "Ponovno učitaj", + "connecting": "Povezivanje...", + "streamTitle": "Tok {{number}}", + "valid": "Važeći", + "failed": "Neuspješno", + "notTested": "Nije testirano", + "connectStream": "Poveži", + "connectingStream": "Povezivanje", + "disconnectStream": "Odspoji", + "estimatedBandwidth": "Procijenjena širina pojasa", + "roles": "Uloge", + "ffmpegModule": "Koristi režim kompatibilnosti toka", + "ffmpegModuleDescription": "Ako tok ne učita nakon nekoliko pokušaja, pokušajte omogućavanje ovoga. Kada je omogućeno, Frigate će koristiti modul ffmpeg sa go2rtc. Ovo može pružiti bolju kompatibilnost sa nekim tokovima kamera.", + "none": "Nijedan", + "error": "Greška", + "streamValidated": "Tok {{number}} uspješno validiran", + "streamValidationFailed": "Tok {{number}} validacija neuspješna", + "saveAndApply": "Sačuvaj novu kameru", + "saveError": "Neispravna konfiguracija. Molimo provjerite svoje postavke.", + "issues": { + "title": "Validacija toka", + "videoCodecGood": "Video kodak je {{codec}}.", + "audioCodecGood": "Audio kodak je {{codec}}.", + "resolutionHigh": "Rezolucija od {{resolution}} može uzrokovati povećanu upotrebu resursa.", + "resolutionLow": "Rezolucija od {{resolution}} može biti preniska za pouzdanu detekciju malih objekata.", + "resolutionUnknown": "Rezolucija ovog tokova nije moguća za ispitivanje. Trebalo bi ručno postaviti detekciju rezolucije u Postavkama ili vašoj konfiguraciji.", + "noAudioWarning": "Nije detektovan zvuk za ovaj tok, snimci neće imati zvuk.", + "audioCodecRecordError": "Potreban je AAC audio kodac za podršku zvuku u snimcima.", + "audioCodecRequired": "Potreban je audio tok za podržavanje detekcije zvuka.", + "restreamingWarning": "Smanjenje povezivanja sa kamerom za snimljeni tok može lagano povećati upotrebu CPU.", + "brands": { + "reolink-rtsp": "Reolink RTSP nije preporučen. Omogući HTTP u postavkama firmvera kamere i ponovo pokreni čarobnjaka.", + "reolink-http": "Reolink HTTP tokovi trebaju koristiti FFmpeg za bolju kompatibilnost. Omogući 'Korištenje režima kompatibilnosti tokova' za ovaj tok." + }, + "dahua": { + "substreamWarning": "Podstrujak 1 je zaključan na nisku rezoluciju. Mnoge kamere Dahua / Amcrest / EmpireTech podržavaju dodatne podstrujke koje treba omogućiti u postavkama kamere. Preporučuje se da ih provjerite i iskoristite ako su dostupne." + }, + "hikvision": { + "substreamWarning": "Podstrujak 1 je zaključan na nisku rezoluciju. Mnoge kamere Hikvision podržavaju dodatne podstrujke koje treba omogućiti u postavkama kamere. Preporučuje se da ih provjerite i iskoristite ako su dostupne." + } + } + } + }, + "cameraManagement": { + "title": "Upravljanje kamerama", + "addCamera": "Dodaj novu kameru", + "deleteCamera": "Obriši kameru", + "deleteCameraDialog": { + "title": "Obriši kameru", + "description": "Brisanje kamere trajno uklanja sve snimke, praćene objekte i konfiguraciju za tu kameru. Bilo bi potrebno ručno ukloniti sve go2rtc tokove povezane s ovom kamerom.", + "selectPlaceholder": "Izaberite kameru...", + "confirmTitle": "Sigurni ste?", + "confirmWarning": "Brisanje {{cameraName}} ne može se povući.", + "deleteExports": "Takođe obriši izvoze za ovu kameru", + "confirmButton": "Obriši trajno", + "success": "Kamera {{cameraName}} uspješno obrisana", + "error": "Neuspješno brisanje kamere {{cameraName}}" + }, + "editCamera": "Uredi kameru:", + "selectCamera": "Izaberite kameru", + "backToSettings": "Povratak na postavke kamere", + "streams": { + "title": "Omogući / Onemogući kamere", + "enableLabel": "Omogućene kamere", + "enableDesc": "Privremeno onemogući omogućenu kameru dok Frigate ne ponovo započne. Onemogućavanje kamere potpuno zaustavlja obradu tokova ove kamere od strane Frigate. Detekcija, snimanje i praćenje nedostat će.
    Napomena: Ovo ne onemogućava restreamove go2rtc.", + "disableLabel": "Onemogućene kamere", + "disableDesc": "Omogući kameru koja trenutno nije vidljiva u UI-ju i onemogućena u konfiguraciji. Potrebno je ponovno pokrenuti Frigate nakon omogućavanja.", + "enableSuccess": "Omogućena {{cameraName}} u konfiguraciji. Ponovno pokrenite Frigate da biste primijenili promjene.", + "friendlyName": { + "edit": "Uredi prikazano ime kamere", + "title": "Uredi prikazano ime", + "description": "Postavite prijateljsko ime koje će se prikazivati za ovu kameru kroz cijeli Frigate UI. Ostavite prazno da biste koristili ID kamere.", + "rename": "Preimenuj" + } + }, + "cameraConfig": { + "add": "Dodaj kameru", + "edit": "Uredi kameru", + "description": "Konfiguriraj postavke kamere uključujući ulazne tokove i uloge.", + "name": "Ime kamere", + "nameRequired": "Ime kamere je obavezno", + "nameLength": "Ime kamere mora imati manje od 64 znaka.", + "namePlaceholder": "npr. front_door ili Pregled zadnjeg dvorišta", + "enabled": "Omogućeno", + "ffmpeg": { + "inputs": "Ulazni tokovi", + "path": "Putanja toka", + "pathRequired": "Putanja toka je obavezna", + "pathPlaceholder": "rtsp://...", + "roles": "Uloge", + "rolesRequired": "Potrebna je bar jedna uloga", + "rolesUnique": "Svaka uloga (audio, detekcija, snimanje) može se dodijeliti samo jednom toku", + "addInput": "Dodaj ulazni tok", + "removeInput": "Ukloni ulazni tok", + "inputsRequired": "Potrebno je bar jedan ulazni tok" + }, + "go2rtcStreams": "go2rtc Tokovi", + "streamUrls": "URL-ovi tokova", + "addUrl": "Dodaj URL", + "addGo2rtcStream": "Dodaj go2rtc tok", + "toast": { + "success": "Kamera {{cameraName}} je uspješno sačuvana" + } + }, + "profiles": { + "title": "Prekrižavanja kamere profila", + "selectLabel": "Odaberi profil", + "description": "Konfigurirajte koje kamere su omogućene ili onemogućene kada je profil aktiviran. Kamere postavljene na \"Naslijeđivanje\" očuvaju svoje osnovno stanje omogućeno.", + "inherit": "Naslijeđivanje", + "enabled": "Omogućeno", + "disabled": "Onemogućeno" + }, + "cameraType": { + "title": "Tip kamere", + "label": "Tip kamere", + "description": "Postavite tip za svaku kameru. Posvećene LPR kamere su jednonamjenske kamere sa snažnim optičkim zumom za hvatanje registarskih tablica na udaljenim vozilima. Većina kamera treba koristiti normalan tip kamere, osim ako je kamera namjenski za LPR i ima usko fokusiran pogled na registarske tablice.", + "normal": "Normalna", + "dedicatedLpr": "Posvećena LPR", + "saveSuccess": "Tip kamere ažuriran za {{cameraName}}. Ponovo pokrenite Frigate da bi se promjene primijenile." + } + }, + "cameraReview": { + "title": "Postavke pregleda kamere", + "object_descriptions": { + "title": "Opisi objekata generativne AI", + "desc": "Privremeno omogući/onemogući opise objekata generativne AI za ovu kameru dok Frigate ne ponovo pokrene. Kada je onemogućeno, opisi generirani AI-om neće se tražiti za praćene objekte na ovoj kameri." + }, + "review_descriptions": { + "title": "Opisi pregleda generativne AI", + "desc": "Privremeno omogući/onemogući opise pregleda generativne AI za ovu kameru dok Frigate ne ponovo pokrene. Kada je onemogućeno, opisi generirani AI-om neće se tražiti za stavke pregleda na ovoj kameri." + }, + "review": { + "title": "Pregled", + "desc": "Privremeno omogući/onemogući upozorenja i detekcije za ovu kameru dok Frigate ne ponovo pokrene. Kada je onemogućeno, neće se generirati nove stavke pregleda. ", + "alerts": "Upozorenja ", + "detections": "Detekcije " + }, + "reviewClassification": { + "title": "Klasifikacija pregleda", + "desc": "Frigate klasificira stavke pregleda kao Upozorenja i Detekcije. Po defaultu, svi osobe i automobili objekti se smatraju Upozorenjima. Možete usavršiti klasifikaciju svojih stavki pregleda konfiguriranjem potrebnih zona za njih.", + "noDefinedZones": "Nema definiranih zona za ovu kameru.", + "objectAlertsTips": "Svi {{alertsLabels}} objekti na {{cameraName}} bit će prikazani kao Upozorenja.", + "zoneObjectAlertsTips": "Svi {{alertsLabels}} objekti detektirani u {{zone}} na {{cameraName}} bit će prikazani kao Upozorenja.", + "objectDetectionsTips": "Svi {{detectionsLabels}} objekti koji nisu kategorizirani na {{cameraName}} bit će prikazani kao Detekcije bez obzira na kojoj se zoni nalaze.", + "zoneObjectDetectionsTips": { + "text": "Svi {{detectionsLabels}} objekti koji nisu kategorizirani u {{zone}} na {{cameraName}} bit će prikazani kao Detekcije.", + "notSelectDetections": "Svi {{detectionsLabels}} objekti detektirani u {{zone}} na {{cameraName}} koji nisu kategorizirani kao Upozorenja bit će prikazani kao Detekcije bez obzira na kojoj se zoni nalaze.", + "regardlessOfZoneObjectDetectionsTips": "Svi {{detectionsLabels}} objekti koji nisu kategorizirani na {{cameraName}} bit će prikazani kao Detekcije bez obzira na koju zonu se nalaze." + }, + "unsavedChanges": "Nespremljene postavke klasifikacije pregleda za {{camera}}", + "selectAlertsZones": "Odaberite zone za Upozorenja", + "selectDetectionsZones": "Odaberite zone za Detekcije", + "limitDetections": "Ograničite detekcije na specifične zone", + "toast": { + "success": "Konfiguracija klasifikacije pregleda je sačuvana. Ponovo pokrenite Frigate da biste primijenili promjene." + } + } + }, + "masksAndZones": { + "filter": { + "all": "Svi Maski i Zone" + }, + "restart_required": "Potrebno je ponovo pokrenuti (maska/zone promijenjene)", + "disabledInConfig": "Stavka je onemogućena u datoteci konfiguracije", + "addDisabledProfile": "Prvo dodajte u osnovnu konfiguraciju, zatim prekrijte u profilu", + "profileBase": "(osnovna)", + "profileOverride": "(preklop)", + "toast": { + "success": { + "copyCoordinates": "Koordinate za {{polyName}} su kopirane u međuspremnik." + }, + "error": { + "copyCoordinatesFailed": "Nemoguće kopirati koordinate u međuspremnik." + } + }, + "motionMaskLabel": "Maska za pokret {{number}}", + "objectMaskLabel": "Maska za objekt {{number}}", + "form": { + "id": { + "error": { + "mustNotBeEmpty": "ID ne smije biti prazan.", + "alreadyExists": "Postoji maska s ovim ID-om za ovu kameru." + } + }, + "name": { + "error": { + "mustNotBeEmpty": "Ime ne smije biti prazno." + } + }, + "zoneName": { + "error": { + "mustBeAtLeastTwoCharacters": "Ime zone mora imati najmanje 2 karaktera.", + "mustNotBeSameWithCamera": "Ime zone ne smije biti isto kao ime kamere.", + "alreadyExists": "Postoji zona s ovim imenom za ovu kameru.", + "mustNotContainPeriod": "Ime zone ne smije sadržavati tačke.", + "hasIllegalCharacter": "Ime zone sadrži nedozvoljene znakove.", + "mustHaveAtLeastOneLetter": "Ime zone mora imati bar jedan slovo." + } + }, + "distance": { + "error": { + "text": "Rastojanje mora biti veće ili jednako 0.1.", + "mustBeFilled": "Sva polja za rastojanje moraju biti popunjena da biste koristili procjenu brzine." + } + }, + "inertia": { + "error": { + "mustBeAboveZero": "Inercija mora biti iznad 0." + } + }, + "loiteringTime": { + "error": { + "mustBeGreaterOrEqualZero": "Vrijeme loiteringa mora biti veće ili jednako 0." + } + }, + "speed": { + "error": { + "mustBeGreaterOrEqualTo": "Prag brzine mora biti veći ili jednak 0.1." + } + }, + "polygonDrawing": { + "type": { + "zone": "Zona", + "motion_mask": "maska pokreta", + "object_mask": "maska objekta" + }, + "removeLastPoint": "Ukloni posljednju tačku", + "reset": { + "label": "Obriši sve tačke" + }, + "snapPoints": { + "true": "Prilagodi tačke", + "false": "Ne prilagođavaj tačke" + }, + "delete": { + "title": "Potvrdi brisanje", + "desc": "Da li ste sigurni da želite izbrisati {{type}} {{name}}?", + "success": "{{name}} je izbrisan." + }, + "revertOverride": { + "title": "Povrati se na osnovnu konfiguraciju", + "desc": "Ovo će ukloniti preklop profila za {{type}} {{name}} i povrati se na osnovnu konfiguraciju." + }, + "error": { + "mustBeFinished": "Crtanje poligona mora se završiti prije spašavanja." + } + } + }, + "zones": { + "label": "Zone", + "documentTitle": "Uredi zonu - Frigate", + "desc": { + "title": "Zona omogućava da definirate specifičnu područje okvira da biste odredili je li objekt unutar određenog područja.", + "documentation": "Dokumentacija" + }, + "add": "Dodaj zonu", + "edit": "Uredi zonu", + "point_one": "{{count}} tačka", + "point_few": "{{count}} tačke", + "point_other": "{{count}} tačke", + "clickDrawPolygon": "Kliknite da nacrtate poligon na slici.", + "name": { + "title": "Ime", + "inputPlaceHolder": "Unesite ime…", + "tips": "Ime mora imati najmanje 2 karaktera, mora imati bar jedan slovo, i ne smije biti ime kamere ili druge zone na ovoj kameri." + }, + "enabled": { + "title": "Omogućeno", + "description": "Da li je ova zona aktivna i omogućena u konfiguracionoj datoteci. Ako je onemogućena, ne može se omogućiti putem MQTT. Onemogućene zone se zanemaruju u vremenu izvršavanja." + }, + "inertia": { + "title": "Inercija", + "desc": "Određuje koliko okvira mora objekt biti u zoni prije nego što se uzima u obzir u zoni. Podrazumevano: 3" + }, + "loiteringTime": { + "title": "Vrijeme loiteringa", + "desc": "Postavlja minimalno vrijeme u sekundama koje objekt mora biti u zoni da bi aktivirao. Zadano: 0" + }, + "objects": { + "title": "Objekti", + "desc": "Popis objekata koji se odnose na ovu zonu." + }, + "allObjects": "Svi objekti", + "speedEstimation": { + "title": "Procjena brzine", + "desc": "Omogući procjenu brzine za objekte u ovoj zoni. Zona mora imati točno 4 točke.", + "lineADistance": "Udaljenost linije A ({{unit}})", + "lineBDistance": "Udaljenost linije B ({{unit}})", + "lineCDistance": "Udaljenost linije C ({{unit}})", + "lineDDistance": "Udaljenost linije D ({{unit}})" + }, + "speedThreshold": { + "title": "Prag brzine ({{unit}})", + "desc": "Određuje minimalnu brzinu za objekte da bi se smatrali u ovoj zoni.", + "toast": { + "error": { + "pointLengthError": "Procjena brzine je onemogućena za ovu zonu. Zone s procjenom brzine moraju imati točno 4 točke.", + "loiteringTimeError": "Zone s vremenima trajanja većim od 0 ne bi trebale se koristiti s procjenom brzine." + } + } + }, + "toast": { + "success": "Zona ({{zoneName}}) je sačuvana." + } + }, + "motionMasks": { + "label": "Maska pokreta", + "documentTitle": "Uredi masku pokreta - Frigate", + "desc": { + "title": "Maska pokreta koristi se za spriječavanje neželjenih vrsta pokreta da aktiviraju detekciju. Prekrižavanje će napraviti teško praćenje objekata.", + "documentation": "Dokumentacija" + }, + "add": "Nova maska pokreta", + "edit": "Uredi masku pokreta", + "defaultName": "Maska pokreta {{number}}", + "context": { + "title": "Maska pokreta koristi se za spriječavanje neželjenih vrsta pokreta da aktiviraju detekciju (primjer: grančice stabala, vremenske oznake kamere). Maska pokreta treba se koristiti vrlo retko, prekrižavanje će napraviti teško praćenje objekata." + }, + "point_one": "{{count}} tačka", + "point_few": "{{count}} tačke", + "point_other": "{{count}} tačke", + "clickDrawPolygon": "Kliknite da nacrtate poligon na slici.", + "name": { + "title": "Ime", + "description": "Nepovlačni prijateljski naziv za ovu masku pokreta.", + "placeholder": "Unesite ime..." + }, + "polygonAreaTooLarge": { + "title": "Maska pokreta pokriva {{polygonArea}}% okvirnog slike kamere. Velike maske pokreta nisu preporučene.", + "tips": "Maska za pokret ne sprječava detekciju objekata. Umjesto toga, trebalo bi koristiti obaveznu zonu." + }, + "toast": { + "success": { + "title": "{{polygonName}} je sačuvan.", + "noName": "Maska za pokret je sačuvana." + } + } + }, + "objectMasks": { + "label": "Maska za objekte", + "documentTitle": "Uredi masku za objekte - Frigate", + "desc": { + "title": "Maska za filtriranje objekata koristi se za uklanjanje lažnih pozitivnih rezultata za određeni tip objekta na temelju lokacije.", + "documentation": "Dokumentacija" + }, + "add": "Dodaj masku za objekte", + "edit": "Uredi masku za objekte", + "context": "Maska za filtriranje objekata koristi se za uklanjanje lažnih pozitivnih rezultata za određeni tip objekta na temelju lokacije.", + "point_one": "{{count}} tačka", + "point_few": "{{count}} tačke", + "point_other": "{{count}} tačke", + "clickDrawPolygon": "Kliknite da nacrtate poligon na slici.", + "name": { + "title": "Ime", + "description": "Nepovlaženo prijateljivo ime za ovu masku za objekte.", + "placeholder": "Unesite ime..." + }, + "objects": { + "title": "Objekti", + "desc": "Tip objekta koji se odnosi na ovu masku za objekte.", + "allObjectTypes": "Svi tipovi objekata" + }, + "toast": { + "success": { + "title": "{{polygonName}} je sačuvan.", + "noName": "Maska za objekte je sačuvana." + } + } + }, + "masks": { + "enabled": { + "title": "Omogućeno", + "description": "Da li je ova maska omogućena u konfiguracijskoj datoteci. Ako je onemogućena, ne može se omogućiti putem MQTT. Onemogućene maske se zanemaruju tijekom izvršavanja." + } + } + }, + "motionDetectionTuner": { + "title": "Podešavač detekcije pokreta", + "unsavedChanges": "Nespremljene promjene podešavača detekcije pokreta ({{camera}})", + "desc": { + "title": "Frigate koristi detekciju pokreta kao prvi korak provjere da li se nešto događa u okviru vrijedno provjere pomoću detekcije objekata.", + "documentation": "Pročitajte vodič za podešavanje detekcije pokreta" + }, + "Threshold": { + "title": "Prag", + "desc": "Vrijednost pragodiktira koliko promjene u svjetlosnosti piksela je potrebno za razmatranje kao pokret. Default: 30" + }, + "contourArea": { + "title": "Površina kontura", + "desc": "Vrijednost površine kontura koristi se za odluku koja skupine promijenjenih piksela kvalificiraju kao pokret. Default: 10" + }, + "improveContrast": { + "title": "Poboljšaj kontrast", + "desc": "Poboljšaj kontrast za tamnije scene. Zadano: UKLJUČENO" + }, + "toast": { + "success": "Postavke pokreta su sačuvane." + } + }, + "debug": { + "title": "Uklanjanje grešaka", + "detectorDesc": "Frigate koristi vaše detektore ({{detectors}}) za detekciju objekata u vašem video toku kamere.", + "desc": "Pregled u režimu uklanjanja grešaka prikazuje stvarni pregled praćenih objekata i njihovih statistika. Lista objekata prikazuje zakasnjeni pregled detektovanih objekata.", + "openCameraWebUI": "Otvori Web UI {{camera}}", + "debugging": "Uklanjanje grešaka", + "objectList": "Popis objekata", + "noObjects": "Nema objekata", + "audio": { + "title": "Audio", + "noAudioDetections": "Nema detekcija zvuka", + "score": "poena", + "currentRMS": "Trenutni RMS", + "currentdbFS": "Trenutni dbFS" + }, + "boundingBoxes": { + "title": "Okvirne kutije", + "desc": "Prikaži okvirne kutije oko praćenih objekata", + "colors": { + "label": "Boje okvirnih kutija objekata", + "info": "
  • Na početku, različite boje će biti dodijeljene svakom oznaci objekta
  • Tanjira crna linija označava da objekt nije detektovan u ovom trenutku
  • Tanjira siva linija označava da objekt detektovan kao stacionaran
  • Deblja linija označava da je objekt subjekt automatskog praćenja (kada je omogućeno)
  • " + } + }, + "timestamp": { + "title": "Vremenski pečat", + "desc": "Prikazati vremenski pečat na slici" + }, + "zones": { + "title": "Zone", + "desc": "Prikaži konturu definisanih zona" + }, + "mask": { + "title": "Maska za pokret", + "desc": "Prikaži poligone maski za pokret" + }, + "motion": { + "title": "Kutije za pokret", + "desc": "Prikaži kutije oko područja gdje je detektovan pokret", + "tips": "

    Kutije za pokret


    Crvene kutije će biti prikazane na područjima okvira gdje se trenutno detektuje pokret

    " + }, + "regions": { + "title": "Regije", + "desc": "Prikaži kutiju područja interesa poslatog objektu detektora", + "tips": "

    Kutije regija


    Sjajno zelene kutije bit će preklopljene na područjima zanimanja u okviru koji se šalju detektoru objekata.

    " + }, + "paths": { + "title": "Putanje", + "desc": "Prikaži značajne točke putanje praćenog objekta", + "tips": "

    Putanje


    Linije i krugovi će pokazati značajne točke koje je praćeni objekt prešao tokom svojeg života.

    " + }, + "objectShapeFilterDrawing": { + "title": "Crtanje filtera oblika objekta", + "desc": "Nacrtaj pravokutnik na slici da bi pogledao detalje površine i omjera", + "tips": "Omogući ovu opciju da nacrtate pravokutnik na slici kamere da biste prikazali njegovu površinu i omjer. Ove vrijednosti zatim mogu se koristiti za postavljanje parametara filtera oblika objekta u vašoj konfiguraciji.", + "score": "Rezultat", + "ratio": "Omjer", + "area": "Površina" + } + }, + "timestampPosition": { + "tl": "Gornji lijevo", + "tr": "Gornji desno", + "bl": "Donji lijevo", + "br": "Donji desno" + }, + "users": { + "title": "Korisnici", + "management": { + "title": "Upravljanje korisnicima", + "desc": "Upravljajte računima korisnika ove instance Frigate." + }, + "addUser": "Dodaj korisnika", + "updatePassword": "Ponovno postavi lozinku", + "toast": { + "success": { + "createUser": "Korisnik {{user}} uspješno stvoren", + "deleteUser": "Korisnik {{user}} uspješno obrisan", + "updatePassword": "Lozinka uspješno ažurirana.", + "roleUpdated": "Uloga ažurirana za {{user}}" + }, + "error": { + "setPasswordFailed": "Neuspješno spremanje lozinke: {{errorMessage}}", + "createUserFailed": "Neuspješno stvaranje korisnika: {{errorMessage}}", + "deleteUserFailed": "Neuspješno brisanje korisnika: {{errorMessage}}", + "roleUpdateFailed": "Neuspješno ažuriranje uloge: {{errorMessage}}" + } + }, + "table": { + "username": "Korisničko ime", + "actions": "Akcije", + "role": "Uloga", + "noUsers": "Nema pronađenih korisnika.", + "changeRole": "Promijeni ulogu korisnika", + "password": "Ponovno postavi lozinku", + "deleteUser": "Obriši korisnika" + }, + "dialog": { + "form": { + "user": { + "title": "Korisničko ime", + "desc": "Dozvoljeno su samo slova, brojevi, tačke i donje crte.", + "placeholder": "Unesite korisničko ime" + }, + "password": { + "title": "Lozinka", + "placeholder": "Unesite lozinku", + "show": "Prikaži lozinku", + "hide": "Sakrij lozinku", + "confirm": { + "title": "Potvrdite lozinku", + "placeholder": "Potvrdite lozinku" + }, + "strength": { + "title": "Jakoća lozinke: ", + "weak": "Slaba", + "medium": "Srednja", + "strong": "Jaka", + "veryStrong": "Veoma jaka" + }, + "requirements": { + "title": "Zahtjevi za lozinku:", + "length": "Bar 12 karaktera" + }, + "match": "Lozinke se poklapaju", + "notMatch": "Lozinke se ne poklapaju" + }, + "newPassword": { + "title": "Nova lozinka", + "placeholder": "Unesite novu lozinku", + "confirm": { + "placeholder": "Ponovite novu lozinku" + } + }, + "currentPassword": { + "title": "Trenutna lozinka", + "placeholder": "Unesite svoju trenutnu lozinku" + }, + "usernameIsRequired": "Korisničko ime je obavezno", + "passwordIsRequired": "Lozinka je obavezna" + }, + "createUser": { + "title": "Kreirajte novog korisnika", + "desc": "Dodajte novi korisnički račun i odredite ulogu za pristup područjima sučelja Frigate.", + "usernameOnlyInclude": "Korisničko ime može sadržavati samo slova, brojeve, . ili _", + "confirmPassword": "Molimo potvrdite svoju lozinku" + }, + "deleteUser": { + "title": "Obriši korisnika", + "desc": "Ova akcija ne može se poništiti. Ovo će trajno izbrisati korisnički račun i ukloniti sve povezane podatke.", + "warn": "Sigurni ste da želite izbrisati {{username}}?" + }, + "passwordSetting": { + "cannotBeEmpty": "Lozinka ne može biti prazna", + "doNotMatch": "Lozinke se ne podudaraju", + "currentPasswordRequired": "Trenutna lozinka je obavezna", + "incorrectCurrentPassword": "Trenutna lozinka je netočna", + "passwordVerificationFailed": "Neuspješno provjeravanje lozinke", + "updatePassword": "Ažurirajte lozinku za {{username}}", + "setPassword": "Postavi lozinku", + "desc": "Napravite jaku lozinku za sigurnost ovog računa.", + "multiDeviceWarning": "Bilo koje druge uređaje na kojima ste prijavljeni bit će potrebno ponovno se prijaviti unutar {{refresh_time}}.", + "multiDeviceAdmin": "Takođe možete obavezati sve korisnike da se odmah ponovno autentificiraju rotiranjem vaše tajne JWT." + }, + "changeRole": { + "title": "Promijenite ulogu korisnika", + "select": "Odaberite ulogu", + "desc": "Ažurirajte dozvole za {{username}}", + "roleInfo": { + "intro": "Odaberite odgovarajuću ulogu za ovog korisnika:", + "admin": "Administrator", + "adminDesc": "Pun pristup svim funkcijama.", + "viewer": "Pregledač", + "viewerDesc": "Ograničeno na Uživo tablo, pregled, istraživanje i izvoze.", + "customDesc": "Prilagođena uloga s određenim pristupom kamerama." + } + } + } + }, + "roles": { + "management": { + "title": "Upravljanje ulogama gledatelja", + "desc": "Upravljajte prilagođenim ulogama gledatelja i njihovim dozvolama za pristup kamerama za ovu instancu Frigate." + }, + "addRole": "Dodaj ulogu", + "table": { + "role": "Uloga", + "cameras": "Kamere", + "actions": "Akcije", + "noRoles": "Nisu pronađene prilagođene uloge.", + "editCameras": "Uredi Kamere", + "deleteRole": "Obriši ulogu" + }, + "toast": { + "success": { + "createRole": "Uloga {{role}} uspješno stvorena", + "updateCameras": "Kamere ažurirane za ulogu {{role}}", + "deleteRole": "Uloga {{role}} uspješno obrisana", + "userRolesUpdated_one": "{{count}} korisnik dodeljen ovoj ulogi je ažuriran na 'viewer', koji ima pristup svim kamerama.", + "userRolesUpdated_few": "{{count}} korisnici dodeljeni ovoj ulogi su ažurirani na 'viewer', koji ima pristup svim kamerama.", + "userRolesUpdated_other": "{{count}} korisnici dodeljeni ovoj ulogi su ažurirani na 'viewer', koji ima pristup svim kamerama." + }, + "error": { + "createRoleFailed": "Neuspješno stvaranje uloge: {{errorMessage}}", + "updateCamerasFailed": "Neuspješno ažuriranje kamera: {{errorMessage}}", + "deleteRoleFailed": "Neuspješno brisanje uloge: {{errorMessage}}", + "userUpdateFailed": "Neuspješno ažuriranje uloga korisnika: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Stvori novu ulogu", + "desc": "Dodaj novu ulogu i specifično odredi dozvole za pristup kamerama." + }, + "editCameras": { + "title": "Uredi kamere uloge", + "desc": "Ažuriraj pristup kamerama za ulogu {{role}}." + }, + "deleteRole": { + "title": "Obriši ulogu", + "desc": "Ova akcija ne može biti poništena. Ovo će trajno izbrisati ulogu i dodeliti sve korisnike s ovom ulogom ulogi 'viewer', što će im dati pristup svim kamerama.", + "warn": "Da li ste sigurni da želite izbrisati {{role}}?", + "deleting": "Brisanje..." + }, + "form": { + "role": { + "title": "Ime uloge", + "placeholder": "Unesite ime uloge", + "desc": "Dozvoljeno su samo slova, brojevi, tačke i donje crte.", + "roleIsRequired": "Ime uloge je obavezno", + "roleOnlyInclude": "Ime uloge može sadržavati samo slova, brojeve, . ili _", + "roleExists": "Uloga s ovim imenom već postoji." + }, + "cameras": { + "title": "Kamere", + "desc": "Odaberite kamere kojima ova uloga ima pristup. Potreban je bar jedan pristup.", + "required": "Mora biti odabrana bar jedna kamera." + } + } + } + }, + "notification": { + "title": "Obavještenja", + "notificationSettings": { + "title": "Postavke obavijesti", + "desc": "Frigate može nativno slati obavijesti na vaš uređaj kada radi u pregledaču ili je instalirana kao PWA." + }, + "notificationUnavailable": { + "title": "Obavijesti nedostupne", + "desc": "Web obavijesti zahtijevaju sigurni kontekst (https://…). Ovo je ograničenje pregledača. Pristupite Frigate sigurno da biste koristili obavijesti." + }, + "globalSettings": { + "title": "Globalne postavke", + "desc": "Privremeno zaustavi obavijesti za određene kamere na svim registrovanim uređajima." + }, + "email": { + "title": "E-mail", + "placeholder": "npr. example@email.com", + "desc": "Potrebna je važeća e-mail adresa i koristit će se za obavijestavanje ako dođe do problema sa uslugom slanja obavijesti." + }, + "cameras": { + "title": "Kamere", + "noCameras": "Nema dostupnih kamera", + "desc": "Odaberite koje kamere omogućiti za obavijesti." + }, + "deviceSpecific": "Postavke specifične za uređaj", + "registerDevice": "Registrujte ovaj uređaj", + "unregisterDevice": "Deregistrujte ovaj uređaj", + "sendTestNotification": "Pošaljite test obavijest", + "unsavedRegistrations": "Nečuvane registracije obavijesti", + "unsavedChanges": "Nečuvane promjene obavijesti", + "active": "Obavijesti aktivne", + "suspended": "Obavijesti zaustavljene {{time}}", + "suspendTime": { + "suspend": "Zaustavi", + "5minutes": "Zaustavi za 5 minuta", + "10minutes": "Zaustavi za 10 minuta", + "30minutes": "Zaustavi za 30 minuta", + "1hour": "Zaustavi za 1 sat", + "12hours": "Zaustavi za 12 sati", + "24hours": "Odložiti za 24 sata", + "untilRestart": "Odložiti do ponovnog pokretanja" + }, + "cancelSuspension": "Otkaži odloženje", + "toast": { + "success": { + "registered": "Uspješno registrovan za obaveštenja. Potrebno je ponovno pokrenuti Frigate prije nego što se mogu slati obaveštenja (uključujući test obaveštenje).", + "settingSaved": "Postavke obaveštenja su sačuvane." + }, + "error": { + "registerFailed": "Neuspješno sačuvana registracija obaveštenja." + } + } + }, + "frigatePlus": { + "title": "Postavke Frigate+", + "description": "Frigate+ je usluga pretplate koja pruža pristup dodatnim funkcijama i mogućnostima za vašu instancu Frigate, uključujući mogućnost korištenja prilagođenih modela detekcije objekata treniranih na vašim podacima. Ovdje možete upravljati postavkama modela Frigate+.", + "cardTitles": { + "api": "API", + "currentModel": "Trenutni model", + "otherModels": "Drugi modeli", + "configuration": "Konfiguracija" + }, + "apiKey": { + "title": "Frigate+ API ključ", + "validated": "Frigate+ API ključ je detektovan i validiran", + "notValidated": "Frigate+ API ključ nije detektovan ili nije validiran", + "desc": "Frigate+ API ključ omogućava integraciju sa uslugom Frigate+.", + "plusLink": "Pročitajte više o Frigate+" + }, + "snapshotConfig": { + "title": "Konfiguracija snimaka", + "desc": "Slanje na Frigate+ zahtijeva da su snimci omogućeni u vašoj konfiguraciji.", + "cleanCopyWarning": "Neki uređaji imaju isključene snimke", + "table": { + "camera": "Kamera", + "snapshots": "Snimci" + } + }, + "modelInfo": { + "title": "Informacije o modelu", + "modelType": "Tip modela", + "trainDate": "Datum treniranja", + "baseModel": "Osnovni model", + "plusModelType": { + "baseModel": "Osnovni model", + "userModel": "Podeseno" + }, + "supportedDetectors": "Podržani detektori", + "cameras": "Kamere", + "loading": "Učitavanje informacija o modelu…", + "error": "Neuspješno učitavanje informacija o modelu", + "availableModels": "Dostupni modeli", + "loadingAvailableModels": "Učitavanje dostupnih modela…", + "modelSelect": "Vaši dostupni modeli na Frigate+ mogu se odabrati ovdje. Napomena: samo modeli kompatibilni s vašom trenutnom konfiguracijom detektora mogu se odabrati." + }, + "unsavedChanges": "Nespremljene promjene postavki Frigate+", + "restart_required": "Potrebno je ponovno pokretanje (model Frigate+ promijenjen)", + "toast": { + "success": "Postavke Frigate+ su spremljene. Ponovno pokrenite Frigate da biste primijenili promjene.", + "error": "Nije uspješno sačuvana promjena konfiguracije: {{errorMessage}}" + } + }, + "detectionModel": { + "plusActive": { + "title": "Upravljanje modelima Frigate+", + "label": "Trenutni izvor modela", + "description": "Ova instanca pokreće model Frigate+. Odaberite ili promijenite svoj model u postavkama Frigate+.", + "goToFrigatePlus": "Idi na postavke Frigate+", + "showModelForm": "Ručno konfigurirajte model" + } + }, + "triggers": { + "documentTitle": "Pokretači", + "semanticSearch": { + "title": "Semantička pretraga je onemogućena", + "desc": "Semantička pretraga mora biti omogućena da biste koristili izazivače." + }, + "management": { + "title": "Pokretači", + "desc": "Upravljanje izazivačima za {{camera}}. Korištenjem tipa prikaznog slika, izazivači se mogu aktivirati za slične prikazne slike odabranom praćenom objektu, a tipom opisa za slične opise teksta koji navodite." + }, + "addTrigger": "Dodaj izazivač", + "table": { + "name": "Ime", + "type": "Tip", + "content": "Sadržaj", + "threshold": "Prag", + "actions": "Akcije", + "noTriggers": "Nema konfiguriranih izazivača za ovu kameru.", + "edit": "Uredi", + "deleteTrigger": "Obriši izazivač", + "lastTriggered": "Zadnji put izazvan" + }, + "type": { + "thumbnail": "Minijatura", + "description": "Opis" + }, + "actions": { + "notification": "Pošalji obavijest", + "sub_label": "Dodaj podnaziv", + "attribute": "Dodaj atribut" + }, + "dialog": { + "createTrigger": { + "title": "Kreiraj izazov", + "desc": "Kreiraj izazov za kameru {{camera}}" + }, + "editTrigger": { + "title": "Uredi izazov", + "desc": "Uredi postavke za izazov na kameri {{camera}}" + }, + "deleteTrigger": { + "title": "Obriši izazov", + "desc": "Da li ste sigurni da želite obrisati izazov {{triggerName}}? Ova akcija ne može biti poništena." + }, + "form": { + "name": { + "title": "Ime", + "placeholder": "Daj ime ovom izazovu", + "description": "Unesite jedinstveno ime ili opis da biste identifikovali ovaj izazov", + "error": { + "minLength": "Polje mora imati najmanje 2 karaktera.", + "invalidCharacters": "Polje može sadržavati samo slova, brojeve, donje crte i crte.", + "alreadyExists": "Izazov sa ovim imenom već postoji za ovu kameru." + } + }, + "enabled": { + "description": "Omogući ili onemogući ovaj izazov" + }, + "type": { + "title": "Tip", + "placeholder": "Odaberite vrstu izazova", + "description": "Izazov kada se detektuje opis sličnog praćenog objekta", + "thumbnail": "Izazov kada se detektuje minijaturna slika sličnog praćenog objekta" + }, + "content": { + "title": "Sadržaj", + "imagePlaceholder": "Odaberite minijaturnu sliku", + "textPlaceholder": "Unesite tekstualni sadržaj", + "imageDesc": "Prikazivaju se samo najnovije 100 minijaturnih slika. Ako ne možete pronaći željenu minijaturnu sliku, pregledajte ranije objekte u Pretraživanju i postavite izazov iz menija tamo.", + "textDesc": "Unesite tekst za izazivanje ove akcije kada se detektuje opis sličnog praćenog objekta.", + "error": { + "required": "Sadržaj je obavezan." + } + }, + "threshold": { + "title": "Prag", + "desc": "Postavite prag sličnosti za ovaj izazov. Viši prag znači da je potrebno bliže podudaranje da bi se izazov aktivirao.", + "error": { + "min": "Prag mora biti bar 0", + "max": "Prag mora biti najviše 1" + } + }, + "actions": { + "title": "Akcije", + "desc": "Po defaultu, Frigate šalje poruku MQTT za sve izazovnike. Podnošnici dodaju ime izazovnog događaja u oznaku objekta. Atributi su pretraživi metapodaci pohranjeni zasebno u metapodacima praćenih objekata.", + "error": { + "min": "Mora se odabrati bar jedna akcija." + } + } + } + }, + "wizard": { + "title": "Kreiraj izazov", + "step1": { + "description": "Konfiguriraj osnovne postavke za tvoj izazov." + }, + "step2": { + "description": "Postavi sadržaj koji će izazvati ovu akciju." + }, + "step3": { + "description": "Konfiguriraj prag i akcije za ovaj izazov." + }, + "steps": { + "nameAndType": "Ime i Tip", + "configureData": "Konfiguriraj podatke", + "thresholdAndActions": "Prag i Akcije" + } + }, + "toast": { + "success": { + "createTrigger": "Izazov {{name}} uspješno kreiran.", + "updateTrigger": "Izazov {{name}} uspješno ažuriran.", + "deleteTrigger": "Izazov {{name}} uspješno obrisan." + }, + "error": { + "createTriggerFailed": "Neuspješno kreiranje izazova: {{errorMessage}}", + "updateTriggerFailed": "Neuspješno ažuriranje izazova: {{errorMessage}}", + "deleteTriggerFailed": "Neuspješno brisanje izazova: {{errorMessage}}" + } + } + }, + "maintenance": { + "title": "Održavanje", + "sync": { + "title": "Sinkronizacija medija", + "desc": "Frigate će periodično čistiti medije prema regularnom rasporedu u skladu s vašom konfiguracijom retencije. Normalno je da se vidi nekoliko orfaniranih datoteka dok Frigate radi. Koristite ovu funkciju za uklanjanje orfaniranih datoteka medija s diska koje više nisu referencirane u bazi podataka.", + "started": "Sinkronizacija započeta.", + "alreadyRunning": "Postoji već pokrenuta poslovna jedinica", + "error": "Neuspješno pokretanje sinkronizacije", + "currentStatus": "Status", + "jobId": "ID posla", + "startTime": "Vrijeme početka", + "endTime": "Vrijeme kraja", + "statusLabel": "Status", + "results": "Rezultati", + "errorLabel": "Greška", + "mediaTypes": "Tipovi medija", + "allMedia": "Svi mediji", + "dryRun": "Sušenje", + "dryRunEnabled": "Nijedna datoteka neće biti obrisana", + "dryRunDisabled": "Datoteke će biti obrisane", + "force": "Silovito", + "forceDesc": "Preskočiti prag sigurnosti i završiti sinkronizaciju čak i ako bi više od 50% datoteka bilo obrisano.", + "verbose": "Detaljan", + "verboseDesc": "Napisati pun popis siročića na disk za pregled.", + "running": "Sinkronizacija u toku...", + "start": "Pokreni sinkronizaciju", + "inProgress": "Sinkronizacija je u toku. Ova stranica je onemogućena.", + "status": { + "queued": "U redu", + "running": "Pokretanje", + "completed": "Završeno", + "failed": "Neuspešno", + "notRunning": "Nije u toku" + }, + "resultsFields": { + "filesChecked": "Provjerene datoteke", + "orphansFound": "Nađeni siročići", + "orphansDeleted": "Obrisani siročići", + "aborted": "Prekinuto. Brisanje bi premašilo prag sigurnosti.", + "error": "Greška", + "totals": "Ukupno" + }, + "event_snapshots": "Snimci praćenih objekata", + "event_thumbnails": "Minijature praćenih objekata", + "review_thumbnails": "Pregled minijatura", + "previews": "Pregledi", + "exports": "Izvozi", + "recordings": "Snimci" + }, + "regionGrid": { + "title": "Mreža regija", + "desc": "Mreža regija je optimizacija koja uči gdje se objekti različitih veličina obično pojavljuju u svakoj kamere polju pogleda. Frigate koristi ove podatke da učinkovito postavi regije detekcije. Mreža se automatski gradi tokom vremena iz podataka o praćenim objektima.", + "clear": "Očisti rešetku područja", + "clearConfirmTitle": "Očisti Rešetku Područja", + "clearConfirmDesc": "Očišćavanje rešetke područja nije preporučeno osim ako ste nedavno promijenili veličinu modela detektora ili promijenili fizičku poziciju kamere i imate probleme s praćenjem objekata. Rešetka će se automatski ponovno izgraditi tokom vremena kada se objekti praćuju. Potreban je ponovni pokretanje Frigate-a za primjenu promjena.", + "clearSuccess": "Rešetka područja uspješno očišćena", + "clearError": "Neuspješno očišćavanje rešetke područja", + "restartRequired": "Potreban je ponovni pokretanje za primjenu promjena rešetke područja" + } + }, + "configForm": { + "global": { + "title": "Globalne postavke", + "description": "Ove postavke se primjenjuju na sve kamere osim ako nisu prekrivene u postavkama specifičnim za kameru." + }, + "camera": { + "title": "Postavke kamere", + "description": "Ove postavke se primjenjuju samo na ovu kameru i prekrivaju globalne postavke.", + "noCameras": "Nema dostupnih kamera" + }, + "advancedSettingsCount": "Napredne postavke ({{count}})", + "advancedCount": "Napredno ({{count}})", + "showAdvanced": "Prikaži napredne postavke", + "tabs": { + "sharedDefaults": "Dijeljene zadane vrijednosti", + "system": "Sistem", + "integrations": "Integracije" + }, + "additionalProperties": { + "keyLabel": "Ključ", + "valueLabel": "Vrijednost", + "keyPlaceholder": "Novi ključ", + "remove": "Ukloni" + }, + "knownPlates": { + "namePlaceholder": "npr. Automobil supružnice", + "platePlaceholder": "Broj ploče ili regex" + }, + "timezone": { + "defaultOption": "Koristi vremensku zonu pregledača" + }, + "roleMap": { + "empty": "Nema mapiranja uloga", + "roleLabel": "Uloga", + "groupsLabel": "Grupe", + "addMapping": "Dodaj mapiranje uloga", + "remove": "Ukloni" + }, + "ffmpegArgs": { + "preset": "Predefinisana postavka", + "manual": "Ručni argumenti", + "inherit": "Naslijeđuj iz postavke kamere", + "none": "Nijedan", + "useGlobalSetting": "Naslijeđuj iz globalne postavke", + "selectPreset": "Odaberite predpostavljeno", + "manualPlaceholder": "Unesite argumente FFmpeg", + "presetLabels": { + "preset-rpi-64-h264": "Raspberry Pi (H.264)", + "preset-rpi-64-h265": "Raspberry Pi (H.265)", + "preset-vaapi": "VAAPI (Intel/AMD GPU)", + "preset-intel-qsv-h264": "Intel QuickSync (H.264)", + "preset-intel-qsv-h265": "Intel QuickSync (H.265)", + "preset-nvidia": "NVIDIA GPU", + "preset-jetson-h264": "NVIDIA Jetson (H.264)", + "preset-jetson-h265": "NVIDIA Jetson (H.265)", + "preset-rkmpp": "Rockchip RKMPP", + "preset-http-jpeg-generic": "HTTP JPEG (Općenito)", + "preset-http-mjpeg-generic": "HTTP MJPEG (Općenito)", + "preset-http-reolink": "HTTP - Kamere Reolink", + "preset-rtmp-generic": "RTMP (Općenito)", + "preset-rtsp-generic": "RTSP (Općenito)", + "preset-rtsp-restream": "RTSP - Ponovno preusmjeravanje iz go2rtc", + "preset-rtsp-restream-low-latency": "RTSP - Ponovno preusmjeravanje iz go2rtc (Niska kašnjenja)", + "preset-rtsp-udp": "RTSP - UDP", + "preset-rtsp-blue-iris": "RTSP - Blue Iris", + "preset-record-generic": "Snimanje (Općenito, bez zvuka)", + "preset-record-generic-audio-copy": "Snimanje (Općenito + Kopiraj zvuk)", + "preset-record-generic-audio-aac": "Snimanje (Općenito + Zvuk u AAC)", + "preset-record-mjpeg": "Snimanje - Kamere MJPEG", + "preset-record-jpeg": "Snimanje - JPEG Kamere", + "preset-record-ubiquiti": "Snimanje - Ubiquiti Kamere" + } + }, + "cameraInputs": { + "itemTitle": "Prijenos {{index}}" + }, + "restartRequiredField": "Potrebno je ponovno pokretanje", + "restartRequiredFooter": "Konfiguracija promijenjena - Potrebno je ponovno pokretanje", + "sections": { + "detect": "Detekcija", + "record": "Snimanje", + "snapshots": "Snimci", + "motion": "Kretanje", + "objects": "Objekti", + "review": "Pregled", + "audio": "Audio", + "notifications": "Obavještenja", + "live": "Pregled uživo", + "timestamp_style": "Vremenske oznake", + "mqtt": "MQTT", + "database": "Baza podataka", + "telemetry": "Telemetrija", + "auth": "Autentifikacija", + "tls": "TLS", + "proxy": "Proxy", + "go2rtc": "go2rtc", + "ffmpeg": "FFmpeg", + "detectors": "Detektori", + "model": "Model", + "semantic_search": "Semantička pretraga", + "genai": "GenAI", + "face_recognition": "Prepoznavanje lica", + "lpr": "Prepoznavanje tablice vozila", + "birdseye": "Birdseye", + "masksAndZones": "Maskice / Zone" + }, + "detect": { + "title": "Postavke detekcije" + }, + "detectors": { + "title": "Postavke detektora", + "singleType": "Dozvoljen je samo jedan {{type}} detektor.", + "keyRequired": "Ime detektora je obavezno.", + "keyDuplicate": "Ime detektora već postoji.", + "noSchema": "Nema dostupnih šema detektora.", + "none": "Nema konfiguriranih instanci detektora.", + "add": "Dodaj detektor", + "addCustomKey": "Dodaj prilagođeni ključ" + }, + "record": { + "title": "Postavke snimanja" + }, + "snapshots": { + "title": "Postavke snimka" + }, + "motion": { + "title": "Postavke pokreta" + }, + "objects": { + "title": "Postavke objekta" + }, + "audioLabels": { + "summary": "Odabrano {{count}} audio oznake", + "empty": "Nema dostupnih audio oznaka" + }, + "objectLabels": { + "summary": "Odabrano {{count}} tipova objekata", + "empty": "Nema dostupnih oznaka objekata" + }, + "reviewLabels": { + "summary": "Odabrano {{count}} oznaka", + "empty": "Nema dostupnih oznaka" + }, + "filters": { + "objectFieldLabel": "{{field}} za {{label}}" + }, + "zoneNames": { + "summary": "{{count}} odabrano", + "empty": "Nema dostupnih zona" + }, + "inputRoles": { + "summary": "Odabrano {{count}} uloga", + "empty": "Nema dostupnih uloga", + "options": { + "detect": "Detektiraj", + "record": "Snimi", + "audio": "Audio" + } + }, + "genaiRoles": { + "options": { + "embeddings": "Ugrađivanje", + "vision": "Vizija", + "tools": "Alati" + } + }, + "semanticSearchModel": { + "placeholder": "Odaberi model…", + "builtIn": "Ugrađeni modeli", + "genaiProviders": "Dostavljatelji GenAI" + }, + "review": { + "title": "Pregled postavki" + }, + "audio": { + "title": "Postavke audija" + }, + "notifications": { + "title": "Postavke obavijesti" + }, + "live": { + "title": "Postavke pregleda uživo" + }, + "timestamp_style": { + "title": "Postavke vremenske oznake" + }, + "searchPlaceholder": "Pretraži...", + "addCustomLabel": "Dodaj prilagođenu oznaku...", + "genaiModel": { + "placeholder": "Odaberi model…", + "search": "Pretraži modele…", + "noModels": "Nema dostupnih modela" + } + }, + "globalConfig": { + "title": "Globalna konfiguracija", + "description": "Konfigurirajte globalne postavke koje se primjenjuju na sve kamere osim ako nisu prekriveni.", + "toast": { + "success": "Globalne postavke uspješno sačuvane", + "error": "Neuspješno spremanje globalnih postavki", + "validationError": "Validacija neuspješna" + } + }, + "cameraConfig": { + "title": "Konfiguracija kamere", + "description": "Konfigurirajte postavke za pojedinačne kamere. Postavke prekrivaju globalne podrazumijevane vrijednosti.", + "overriddenBadge": "Preklopljeno", + "resetToGlobal": "Vrati na globalno", + "toast": { + "success": "Postavke kamere uspješno sačuvane", + "error": "Neuspješno spremanje postavki kamere" + } + }, + "toast": { + "success": "Postavke uspješno sačuvane", + "applied": "Postavke uspješno primijenjene", + "successRestartRequired": "Postavke uspješno sačuvane. Ponovo pokrenite Frigate da biste primijenili svoje promjene.", + "error": "Neuspješno spremanje postavki", + "validationError": "Validacija neuspješna: {{message}}", + "resetSuccess": "Poništi i vratiti se na globalne podrazumijevane vrijednosti", + "resetError": "Neuspješno poništavanje postavki", + "saveAllSuccess_one": "Uspješno sačuvan odjeljak {{count}}.", + "saveAllSuccess_few": "Svi odjeljci {{count}} uspješno sačuvani.", + "saveAllSuccess_other": "Svi odjeljci {{count}} uspješno sačuvani.", + "saveAllPartial_one": "{{successCount}} od {{totalCount}} odjeljka sačuvan. {{failCount}} neuspješno.", + "saveAllPartial_few": "{{successCount}} od {{totalCount}} odjeljaka sačuvanih. {{failCount}} neuspješno.", + "saveAllPartial_other": "{{successCount}} od {{totalCount}} odjeljaka sačuvanih. {{failCount}} neuspješno.", + "saveAllFailure": "Neuspješno spremanje svih odjeljaka." + }, + "profiles": { + "title": "Profili", + "activeProfile": "Aktivni profil", + "noActiveProfile": "Nema aktivnog profila", + "active": "Aktivno", + "activated": "Profil '{{profile}}' aktiviran", + "activateFailed": "Neuspješno postavljanje profila", + "deactivated": "Profil deaktiviran", + "noProfiles": "Nema definisanih profila.", + "noOverrides": "Nema prekriženja", + "cameraCount_one": "kamera {{count}}", + "cameraCount_few": "{{count}} kamere", + "cameraCount_other": "{{count}} kamere", + "columnCamera": "Kamera", + "columnOverrides": "Prekriženja profila", + "baseConfig": "Bazna konfiguracija", + "addProfile": "Dodaj profil", + "newProfile": "Novi profil", + "profileNamePlaceholder": "npr. Opremljen, Odsutan, Noćni režim", + "friendlyNameLabel": "Ime profila", + "profileIdLabel": "ID profila", + "profileIdDescription": "Unutarnji identifikator korišten u konfiguraciji i automatizacijama", + "nameInvalid": "Dozvoljena su samo mala slova, brojevi i donje crte", + "nameDuplicate": "Profil s ovim imenom već postoji", + "error": { + "mustBeAtLeastTwoCharacters": "Mora imati najmanje 2 karaktera", + "mustNotContainPeriod": "Ne smije sadržavati tačke", + "alreadyExists": "Profil s ovim ID-om već postoji" + }, + "renameProfile": "Preimenuj profil", + "renameSuccess": "Profil preimenovan u '{{profile}}'", + "deleteProfile": "Obriši profil", + "deleteProfileConfirm": "Obriši profil \"{{profile}}\" sa svih kamera? Ovo ne može biti poništeno.", + "deleteSuccess": "Profil '{{profile}}' obrisan", + "createSuccess": "Profil '{{profile}}' kreiran", + "removeOverride": "Ukloni prekrivanje profila", + "deleteSection": "Izbriši prekrivanja sekcije", + "deleteSectionConfirm": "Ukloni prekrivanja {{section}} za profil {{profile}} na {{camera}}?", + "deleteSectionSuccess": "Uklonjena prekrivanja {{section}} za {{profile}}", + "enableSwitch": "Omogući profile", + "enabledDescription": "Profilei su omogućeni. Napravite novi profil ispod, pređite na sekciju konfiguracije kamere da biste napravili promjene i sačuvajte da bi promjene bile primijenjene.", + "disabledDescription": "Profilei vam omogućavaju da definirate imenovane skupove prekrivanja konfiguracije kamere (npr., opremljen, odsutan, noć) koji se mogu aktivirati na zahtjev." + }, + "unsavedChanges": "Imate nepohranjene promjene", + "confirmReset": "Potvrdi ponovno postavljanje", + "resetToDefaultDescription": "Ovo će ponovno postaviti sve postavke u ovoj sekciji na svoje zadane vrijednosti. Ova akcija ne može se povući.", + "resetToGlobalDescription": "Ovo će ponovno postaviti postavke u ovoj sekciji na globalne zadane vrijednosti. Ova akcija ne može se povući.", + "go2rtcStreams": { + "title": "go2rtc streamovi", + "description": "Upravljajte konfiguracijama go2rtc streamova za ponovno praćenje kamere. Svaki stream ima ime i jednu ili više izvornih URL-ova.", + "addStream": "Dodaj stream", + "addStreamDesc": "Unesite ime za novi stream. Ovo ime će se koristiti za referenciranje streama u vašoj konfiguraciji kamere.", + "addUrl": "Dodaj URL", + "streamName": "Ime streama", + "streamNamePlaceholder": "npr., front_door", + "streamUrlPlaceholder": "npr., rtsp://user:pass@192.168.1.100/stream", + "deleteStream": "Izbriši stream", + "deleteStreamConfirm": "Sigurni ste da želite izbrisati stream \"{{streamName}}\"? Kamere koje se referiraju na ovaj stream mogu prestati da rade.", + "noStreams": "Nema konfiguriranih go2rtc streamova. Dodajte stream da biste započeli.", + "validation": { + "nameRequired": "Ime streama je obavezno", + "nameDuplicate": "Stream s ovim imenom već postoji", + "nameInvalid": "Ime streama može sadržavati samo slova, brojeve, donje crte i crte za odvajanje", + "urlRequired": "Potrebna je bar jedna URL adresa" + }, + "renameStream": "Preimenuj tok", + "renameStreamDesc": "Unesite novi naziv za ovaj tok. Preimenovanje toka može oštetiti kamere ili druge toke koji se reference na njega po nazivu.", + "newStreamName": "Novi naziv toka", + "ffmpeg": { + "useFfmpegModule": "Koristi režim kompatibilnosti (ffmpeg)", + "video": "Video", + "audio": "Audio", + "hardware": "Hardverska ubrzanja", + "videoCopy": "Kopiraj", + "videoH264": "Prevedi na H.264", + "videoH265": "Prevedi na H.265", + "videoExclude": "Izuzmi", + "audioCopy": "Kopiraj", + "audioAac": "Prevedi na AAC", + "audioOpus": "Prevedi na Opus", + "audioPcmu": "Prevedi na PCM μ-law", + "audioPcma": "Prevedi na PCM A-law", + "audioPcm": "Prevedi na PCM", + "audioMp3": "Prevedi na MP3", + "audioExclude": "Izuzmi", + "hardwareNone": "Bez hardverske ubrzanja", + "hardwareAuto": "Automatska hardverska ubrzanja" + } + }, + "onvif": { + "profileAuto": "Automatski", + "profileLoading": "Učitavanje profila..." + }, + "configMessages": { + "review": { + "recordDisabled": "Snimanje je onemogućeno, stavke za pregled neće biti generisane.", + "detectDisabled": "Detekcija objekata je onemogućena. Stavke za pregled zahtijevaju detektovane objekte za kategorizaciju upozorenja i detekcija.", + "allNonAlertDetections": "Sve aktivnosti koje nisu upozorenja bit će uključene kao detekcije." + }, + "audio": { + "noAudioRole": "Nijedan tok nema definisan ulogu zvuka. Morate omogućiti ulogu zvuka da bi detekcija zvuka mogla da funkcioniše." + }, + "audioTranscription": { + "audioDetectionDisabled": "Detekcija zvuka nije omogućena za ovu kameru. Transkripcija zvuka zahtijeva da detekcija zvuka bude aktivna." + }, + "detect": { + "fpsGreaterThanFive": "Postavljanje vrijednosti detect FPS veće od 5 nije preporučljivo. Veće vrijednosti mogu uzrokovati probleme s performansama i neće pružiti nikakvu korist." + }, + "faceRecognition": { + "globalDisabled": "Prepoznavanje lica nije omogućeno na globalnom nivou. Omogućite ga u Obogaćivanjima da bi prepoznavanje lica na nivou kamere funkcioniralo.", + "personNotTracked": "Prepoznavanje lica zahtijeva da se objekat 'osoba' praći. Osigurajte da je 'osoba' u listi praćenja objekata." + }, + "lpr": { + "globalDisabled": "Prepoznavanje registarskih tablica nije omogućeno na globalnom nivou. Omogućite ga u Obogaćivanjima da bi LPR na nivou kamere funkcionirao.", + "vehicleNotTracked": "Prepoznavanje tablice zahtijeva da se praći 'automobil' ili 'motocikl'." + }, + "record": { + "noRecordRole": "Nema streamova koji imaju definisanu ulogu snimanja. Snimanje neće funkcionišati." + }, + "birdseye": { + "objectsModeDetectDisabled": "Birdseye je postavljen na režim 'objekti', ali je detekcija objekata onemogućena za ovu kameru. Kamera neće biti prikazana u Birdseye." + }, + "snapshots": { + "detectDisabled": "Detekcija objekata je onemogućena. Snimci se generišu iz praćenih objekata i neće biti kreirani." + }, + "detectors": { + "mixedTypes": "Svi detektori moraju koristiti isti tip. Uklonite postojet će detektore da biste koristili drugi tip.", + "mixedTypesSuggestion": "Svi detektori moraju koristiti isti tip. Uklonite postojet će detektore ili izaberite {{type}}." + } + } +} diff --git a/web/public/locales/bs/views/system.json b/web/public/locales/bs/views/system.json new file mode 100644 index 0000000000..b36221ec36 --- /dev/null +++ b/web/public/locales/bs/views/system.json @@ -0,0 +1,256 @@ +{ + "documentTitle": { + "cameras": "Statistika kamere - Frigate", + "storage": "Statistika skladišta - Frigate", + "general": "Opća statistika - Frigate", + "enrichments": "Statistika bogatstva - Frigate", + "logs": { + "frigate": "Zapisi Frigate - Frigate", + "go2rtc": "Zapisi Go2RTC - Frigate", + "nginx": "Zapisi Nginx - Frigate", + "websocket": "Zapisi poruka - Frigate" + } + }, + "title": "Sistem", + "metrics": "Sistem metrike", + "logs": { + "websocket": { + "label": "Zapisi", + "pause": "Pauziraj", + "resume": "Nastavi", + "clear": "Očisti", + "filter": { + "all": "Svi temi", + "topics": "Teme", + "events": "Događaji", + "reviews": "Pregledi", + "classification": "Klasifikacija", + "face_recognition": "Prepoznavanje lica", + "lpr": "LPR", + "camera_activity": "Aktivnost kamere", + "system": "Sistem", + "camera": "Kamera", + "all_cameras": "Sve kamere", + "cameras_count_one": "{{count}} Kamera", + "cameras_count_other": "{{count}} Kamere" + }, + "empty": "Nema još prihvaćenih poruka", + "count_one": "{{count}} poruka", + "count_other": "{{count}} poruke", + "expanded": { + "payload": "Opterećenje" + } + }, + "download": { + "label": "Preuzimanje zapisa" + }, + "copy": { + "label": "Kopiraj u clipboard", + "success": "Zapisi su kopirani u clipboard", + "error": "Nije moguće kopirati zapise u clipboard" + }, + "type": { + "label": "Tip", + "timestamp": "Vremenski pečat", + "tag": "Oznaka", + "message": "Poruka" + }, + "tips": "Zapisi se prenose sa servera", + "toast": { + "error": { + "fetchingLogsFailed": "Greška prilikom preuzimanja zapisa: {{errorMessage}}", + "whileStreamingLogs": "Greška prilikom prijenosa protokola: {{errorMessage}}" + } + } + }, + "general": { + "title": "Općenito", + "detector": { + "title": "Detektori", + "inferenceSpeed": "Brzina zaključivanja detektora", + "temperature": "Temperatura detektora", + "cpuUsage": "Korištenje CPU detektora", + "cpuUsageInformation": "CPU korištena za pripremu ulaznih i izlaznih podataka za/iz modela detekcije. Ova vrijednost ne mjeri korištenje zaključivanja, čak i ako se koristi GPU ili ubrzivač.", + "memoryUsage": "Korištenje memorije detektora" + }, + "hardwareInfo": { + "title": "Hardverske informacije", + "gpuUsage": "Korištenje GPU", + "gpuMemory": "Memorija GPU", + "gpuEncoder": "Kodiralo GPU", + "gpuCompute": "GPU Izračunavanje / Kodiranje", + "gpuDecoder": "Dekodiranje GPU", + "gpuTemperature": "Temperatura GPU", + "gpuInfo": { + "vainfoOutput": { + "title": "Vainfo Izlaz", + "returnCode": "Kod povratka: {{code}}", + "processOutput": "Izlaz procesa:", + "processError": "Greška procesa:" + }, + "nvidiaSMIOutput": { + "title": "Nvidia SMI Izlaz", + "name": "Ime: {{name}}", + "driver": "Vozač: {{driver}}", + "cudaComputerCapability": "CUDA sposobnost izračunavanja: {{cuda_compute}}", + "vbios": "VBios informacije: {{vbios}}" + }, + "closeInfo": { + "label": "Zatvori informacije GPU" + }, + "copyInfo": { + "label": "Kopiraj informacije GPU" + }, + "toast": { + "success": "Kopirano informacije GPU u međuspremnik" + } + }, + "npuUsage": "Korišćenje NPU", + "npuMemory": "Memorija NPU", + "npuTemperature": "Temperatura NPU", + "intelGpuWarning": { + "title": "Upozorenje o statistikama Intel GPU", + "message": "Statistike GPU nedostupne", + "description": "Ovo je poznati bug u alatima za prikaz statistika Intel GPU (intel_gpu_top) gdje će se prekiniti i ponovo vratiti GPU korišćenje od 0% čak i u slučajevima kada se hardverska akceleracija i detekcija objekata ispravno izvršavaju na (i)GPU. Ovo nije bug Frigate. Možete ponovo pokrenuti host kako biste privremeno popravili problem i potvrdili da GPU radi ispravno. Ovo ne utiče na performanse." + } + }, + "otherProcesses": { + "title": "Drugi procesi", + "processCpuUsage": "Korišćenje CPU procesa", + "processMemoryUsage": "Korišćenje memorije procesa", + "series": { + "go2rtc": "go2rtc", + "recording": "Snimanje", + "review_segment": "pregled segmenta", + "embeddings": "Ugrađivanja", + "audio_detector": "audio detektor" + } + } + }, + "storage": { + "title": "Skladište", + "overview": "Pregled", + "recordings": { + "title": "Snimci", + "tips": "Ova vrijednost predstavlja ukupno skladište koje se koristi za snimke u bazi podataka Frigate. Frigate ne praćenje korišćenje skladišta za sve datoteke na vašem disku.", + "earliestRecording": "Najstariji dostupni snimak:" + }, + "shm": { + "title": "Alokacija SHM (deljenja memorije)", + "warning": "Trenutna veličina SHM od {{total}}MB je prevelika. Povećajte je na najmanje {{min_shm}}MB.", + "frameLifetime": { + "title": "Vijek trajanja okvira", + "description": "Svaka kamera ima {{frames}} slotova za okvire u deljenoj memoriji. Na najbržoj brzini okvira kamere, svaki okvir je dostupan za približno {{lifetime}}s prije nego što se prepiše." + } + }, + "cameraStorage": { + "title": "Skladište kamere", + "camera": "Kamera", + "unusedStorageInformation": "Informacije o neiskorišćenom skladištu", + "storageUsed": "Skladište", + "percentageOfTotalUsed": "Postotak ukupno", + "bandwidth": "Širina pojasa", + "unused": { + "title": "Neiskorišćeno", + "tips": "Ova vrijednost može nepravilno predstavljati slobodno prostor dostupan Frigate ako imate druge datoteke pohranjene na vašem disku izvan snimaka Frigate. Frigate ne praćenje korišćenje skladišta izvan svojih snimaka." + } + } + }, + "cameras": { + "title": "Kamere", + "overview": "Pregled", + "info": { + "aspectRatio": "odnos stranica", + "cameraProbeInfo": "{{camera}} Informacije o ispitivanju kamere", + "streamDataFromFFPROBE": "Podaci o prijenosu se dobijaju pomoću ffprobe.", + "fetching": "Prenošenje podataka o kameri", + "stream": "Prijenos {{idx}}", + "video": "Video:", + "codec": "Kodek:", + "resolution": "Rješenje:", + "fps": "FPS:", + "unknown": "Nepoznato", + "audio": "Zvuk:", + "error": "Greška: {{error}}", + "tips": { + "title": "Informacije o ispitivanju kamere" + } + }, + "framesAndDetections": "Okviri / Detekcije", + "label": { + "camera": "Kamera", + "detect": "detektirati", + "skipped": "preskočeno", + "ffmpeg": "FFmpeg", + "capture": "snimiti", + "overallFramesPerSecond": "ukupni okviri po sekundi", + "overallDetectionsPerSecond": "ukupne detekcije po sekundi", + "overallSkippedDetectionsPerSecond": "ukupno preskočene detekcije po sekundi", + "cameraFfmpeg": "{{camName}} FFmpeg", + "cameraCapture": "{{camName}} snimiti", + "cameraDetect": "{{camName}} detektirati", + "cameraGpu": "{{camName}} GPU", + "cameraFramesPerSecond": "{{camName}} okviri po sekundi", + "cameraDetectionsPerSecond": "{{camName}} detekcije po sekundi", + "cameraSkippedDetectionsPerSecond": "{{camName}} preskočenih detekcija u sekundi" + }, + "connectionQuality": { + "title": "Kvaliteta veze", + "excellent": "Izuzetno dobra", + "fair": "Uredna", + "poor": "Loša", + "unusable": "Nepogodna", + "fps": "FPS", + "expectedFps": "Očekivani FPS", + "reconnectsLastHour": "Ponovne povezivanja (posljednje satu)", + "stallsLastHour": "Pauze (posljednje satu)" + }, + "toast": { + "success": { + "copyToClipboard": "Podaci o testiranju kopirani u clipboard." + }, + "error": { + "unableToProbeCamera": "Nemoguće testiranje kamere: {{errorMessage}}" + } + } + }, + "lastRefreshed": "Posljednje ažuriranje: ", + "stats": { + "ffmpegHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za FFmpeg ({{ffmpegAvg}}%)", + "detectHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za detekciju ({{detectAvg}}%)", + "healthy": "Sistem je zdrav", + "reindexingEmbeddings": "Ponovno indeksiranje ugrađenih vjerodajnica ({{processed}}% završeno)", + "cameraIsOffline": "{{camera}} je offline", + "detectIsSlow": "{{detect}} je spor ({{speed}} ms)", + "detectIsVerySlow": "{{detect}} je vrlo spor ({{speed}} ms)", + "shmTooLow": "/dev/shm alokacija ({{total}} MB) treba povećati na najmanje {{min}} MB.", + "debugReplayActive": "Debug ponavljanje sesije je aktivno" + }, + "enrichments": { + "title": "Obogaćivanja", + "infPerSecond": "Inferencije po sekundi", + "averageInf": "Prosjek vremena inferencije", + "embeddings": { + "image_embedding": "Slika ugrađenih vjerodajnica", + "text_embedding": "Tekst ugrađenih vjerodajnica", + "face_recognition": "Prepoznavanje lica", + "plate_recognition": "Prepoznavanje ploča", + "image_embedding_speed": "Brzina ugradnje slika", + "face_embedding_speed": "Brzina ugradnje lica", + "face_recognition_speed": "Brzina prepoznavanja lica", + "plate_recognition_speed": "Brzina prepoznavanja ploča", + "text_embedding_speed": "Brzina ugradnje teksta", + "yolov9_plate_detection_speed": "Brzina detekcije ploča YOLOv9", + "yolov9_plate_detection": "Detekcija ploča YOLOv9", + "review_description": "Pregled opisa", + "review_description_speed": "Brzina pregleda opisa", + "review_description_events_per_second": "Pregled opisa", + "object_description": "Opis objekta", + "object_description_speed": "Brzina opisa objekta", + "object_description_events_per_second": "Opis objekta", + "classification": "{{name}} Klasifikacija", + "classification_speed": "{{name}} Brzina klasifikacije", + "classification_events_per_second": "{{name}} Događaji klasifikacije po sekundi" + } + } +} diff --git a/web/public/locales/ca/common.json b/web/public/locales/ca/common.json index d1593e9486..a712459c3e 100644 --- a/web/public/locales/ca/common.json +++ b/web/public/locales/ca/common.json @@ -109,7 +109,8 @@ "classification": "Classificació", "chat": "Xat", "actions": "Accions", - "profiles": "Perfils" + "profiles": "Perfils", + "features": "Característiques" }, "pagination": { "previous": { @@ -241,7 +242,7 @@ "done": "Fet", "disabled": "Deshabilitat", "disable": "Deshabilitar", - "save": "Guardar", + "save": "Desa", "copy": "Copiar", "back": "Enrere", "pictureInPicture": "Imatge en Imatge", diff --git a/web/public/locales/ca/components/dialog.json b/web/public/locales/ca/components/dialog.json index 9e2900d8aa..6f527e4df7 100644 --- a/web/public/locales/ca/components/dialog.json +++ b/web/public/locales/ca/components/dialog.json @@ -60,15 +60,76 @@ "noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid", "failed": "No s'ha pogut inciar l'exportació: {{error}}" }, - "view": "Vista" + "view": "Vista", + "queued": "Exporta a la cua. Mostra el progrés a la pàgina d'exportacions.", + "batchSuccess_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.", + "batchSuccess_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.", + "batchSuccess_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.", + "batchPartial": "S'han iniciat {{successful}} de {{total}} exportacions. Càmeres fallides: {{failedCameras}}", + "batchFailed": "No s'han pogut iniciar {{total}} exportacions. Càmeres fallides: {{failedCameras}}", + "batchQueuedSuccess_one": "Exporta a la cua 1. Obrint el cas ara.", + "batchQueuedSuccess_many": "{{count}} exportacions a la cua. Obrint el cas ara.", + "batchQueuedSuccess_other": "{{count}} exportacions a la cua. Obrint el cas ara.", + "batchQueuedPartial": "{{successful}} de {{total}} exportacions a la cua. Càmeres fallides: {{failedCameras}}", + "batchQueueFailed": "No s'han pogut posar a la cua {{total}} exportacions. Càmeres fallides: {{failedCameras}}" }, "fromTimeline": { "saveExport": "Guardar exportació", - "previewExport": "Previsualitzar exportació" + "previewExport": "Previsualitzar exportació", + "queueingExport": "S'està fent la cua de l'exportació...", + "useThisRange": "Utilitza aquest interval" }, "case": { "label": "Cas", - "placeholder": "Selecciona un cas" + "placeholder": "Selecciona un cas", + "newCaseOption": "Crea un cas no", + "newCaseNamePlaceholder": "Nom de cas nou", + "newCaseDescriptionPlaceholder": "Descripció del cas", + "nonAdminHelp": "Es crearà un nou cas per a aquestes exportacions." + }, + "queueing": "S'està fent la cua de l'exportació...", + "tabs": { + "export": "Càmera única", + "multiCamera": "Multicàmera" + }, + "multiCamera": { + "timeRange": "Interval de temps", + "selectFromTimeline": "Selecciona des de la línia de temps", + "cameraSelection": "Càmeres", + "cameraSelectionHelp": "Les càmeres amb objectes rastrejats en aquest interval de temps estan preseleccionades", + "checkingActivity": "Comprovant l'activitat de la càmera...", + "noCameras": "No hi ha càmeres disponibles", + "detectionCount_one": "1 objecte rastrejat", + "detectionCount_many": "{{count}} objectes rastrejats", + "detectionCount_other": "{{count}} objectes rastrejats", + "nameLabel": "Nom de l'exportació", + "namePlaceholder": "Nom base opcional per a aquestes exportacions", + "queueingButton": "S'estan posant a la cua les exportacions...", + "exportButton_one": "Exporta 1 càmera", + "exportButton_many": "Exporta {{count}} càmeres", + "exportButton_other": "Exporta {{count}} càmeres" + }, + "multi": { + "title_one": "Exporta {{count}} ressenyes", + "title_many": "Exporta {{count}} ressenyes", + "title_other": "Exporta {{count}} ressenyes", + "description": "Exporta cada revisió seleccionada. Totes les exportacions s'agruparan en un sol cas.", + "descriptionNoCase": "Exporta cada revisió seleccionada.", + "caseNamePlaceholder": "Exporta la revisió - {{date}}", + "exportButton_one": "Exporta {{count}} ressenyes", + "exportButton_many": "Exporta {{count}} ressenyes", + "exportButton_other": "Exporta {{count}} ressenyes", + "exportingButton": "S'està exportant...", + "toast": { + "started_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.", + "started_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.", + "started_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.", + "startedNoCase_one": "S'ha iniciat l'exportació 1.", + "startedNoCase_many": "S'han iniciat {{count}} exportacions.", + "startedNoCase_other": "S'han iniciat {{count}} exportacions.", + "partial": "S'han iniciat {{successful}} de {{total}} exportacions. Ha fallat: {{failedItems}}", + "failed": "No s'han pogut iniciar {{total}} exportacions. Ha fallat: {{failedItems}}" + } } }, "streaming": { @@ -116,6 +177,14 @@ "success": "Els enregistraments de vídeo associats als elements de revisió seleccionats s’han suprimit correctament.", "error": "No s'ha pogut suprimir: {{error}}" } + }, + "shareTimestamp": { + "label": "Comparteix la marca horària", + "title": "Comparteix la marca horària", + "description": "Comparteix un URL amb marca horària de la posició actual del jugador o tria una marca horària personalitzada. Tingueu en compte que aquest no és un URL de compartició pública i només és accessible per als usuaris amb accés a Frigate i aquesta càmera.", + "custom": "Marca horària personalitzada", + "button": "Comparteix l'URL de la marca horària", + "shareTitle": "Marca de temps de revisió de Frigate: {{camera}}" } }, "imagePicker": { diff --git a/web/public/locales/ca/components/player.json b/web/public/locales/ca/components/player.json index 1fed78eff8..88be512c96 100644 --- a/web/public/locales/ca/components/player.json +++ b/web/public/locales/ca/components/player.json @@ -32,7 +32,8 @@ "noPreviewFoundFor": "No s'ha trobat cap previsualització per a {{cameraName}}", "submitFrigatePlus": { "title": "Enviar aquesta imatge a Frigate+?", - "submit": "Enviar" + "submit": "Enviar", + "previewError": "No s'ha pogut carregar la vista prèvia de la instantània. És possible que l'enregistrament no estigui disponible en aquest moment." }, "livePlayerRequiredIOSVersion": "Es requereix iOS 17.1 o superior per a aquest tipus de reproducció en directe.", "streamOffline": { diff --git a/web/public/locales/ca/config/cameras.json b/web/public/locales/ca/config/cameras.json index 090de49fb9..433bcf5ff6 100644 --- a/web/public/locales/ca/config/cameras.json +++ b/web/public/locales/ca/config/cameras.json @@ -13,7 +13,7 @@ "description": "Habilitat" }, "audio": { - "label": "Esdeveniments d'àudio", + "label": "Detecció d'àudio", "description": "Configuració per a la detecció d'esdeveniments basats en àudio per a aquesta càmera.", "enabled": { "label": "Habilita la detecció d'àudio", @@ -485,6 +485,10 @@ "hwaccel_args": { "label": "Exporta els arguments de l'hwaccel", "description": "Args d'acceleració de maquinari a utilitzar per a operacions d'exportació/transcodificació." + }, + "max_concurrent": { + "label": "Màxim d'exportacions concurrents", + "description": "Nombre màxim de treballs d'exportació a processar al mateix temps." } }, "preview": { diff --git a/web/public/locales/ca/config/global.json b/web/public/locales/ca/config/global.json index d81735a614..693e8c2840 100644 --- a/web/public/locales/ca/config/global.json +++ b/web/public/locales/ca/config/global.json @@ -341,6 +341,10 @@ "hwaccel_args": { "label": "Exporta els arguments de l'hwaccel", "description": "Args d'acceleració de maquinari a utilitzar per a operacions d'exportació/transcodificació." + }, + "max_concurrent": { + "label": "Màxim d'exportacions concurrents", + "description": "Nombre màxim de treballs d'exportació a processar al mateix temps." } }, "preview": { @@ -975,8 +979,8 @@ "description": "Habilita el monitoratge d'amplada de banda per procés per als processos i detectors de ffmpeg de càmera (requereix capacitats)." }, "intel_gpu_device": { - "label": "Dispositiu SR-IOV", - "description": "Identificador de dispositiu utilitzat quan es tracten les GPU d'Intel com a SR-IOV per corregir les estadístiques de GPU." + "label": "Dispositiu GPU d'Intel", + "description": "Adreça de bus PCI o camí del dispositiu DRM (p. ex. /dev/dri/card1) utilitzat per fixar les estadístiques de GPU d'Intel a un dispositiu específic quan hi ha múltiples." } }, "version_check": { @@ -1951,7 +1955,7 @@ }, "roles": { "label": "Rols", - "description": "Funcions genAI (eines, visió, incrustacions); un proveïdor per rol." + "description": "Rols de GenAI (xat, descripcions, incrustacions); un proveïdor per rol." }, "provider_options": { "label": "Opcions del proveïdor", @@ -1963,7 +1967,7 @@ } }, "audio": { - "label": "Esdeveniments d'àudio", + "label": "Detecció d'àudio", "description": "Configuració per a la detecció d'esdeveniments basats en àudio per a totes les càmeres; es pot substituir per càmera.", "enabled": { "label": "Habilita la detecció d'àudio", diff --git a/web/public/locales/ca/views/chat.json b/web/public/locales/ca/views/chat.json new file mode 100644 index 0000000000..064c0d81bf --- /dev/null +++ b/web/public/locales/ca/views/chat.json @@ -0,0 +1,46 @@ +{ + "documentTitle": "Xat - Frigate", + "title": "Xat Frigate", + "subtitle": "El teu assistent d'AI per a gestionar càmeres i coneixements", + "placeholder": "Pregunta qualsevol cosa...", + "error": "Alguna cosa ha fallat. Torna-ho a provar.", + "processing": "Processant...", + "toolsUsed": "Usades: {{tools}}", + "showTools": "Mostra eines ({{count}})", + "hideTools": "Amaga eines", + "call": "Truca", + "result": "Resultat", + "arguments": "Variables:", + "response": "Resposta:", + "attachment_chip_label": "{{label}} a {{camera}}", + "attachment_chip_remove": "Elimina l'adjunt", + "open_in_explore": "Obre en l'explorador", + "attach_event_aria": "Adjunta l'esdeveniment {{eventId}}", + "attachment_picker_paste_label": "O enganxa l'ID de l'esdeveniment", + "attachment_picker_attach": "Adjunta", + "attachment_picker_placeholder": "Adjunta un esdeveniment", + "quick_reply_find_similar": "Troba albiraments similars", + "quick_reply_tell_me_more": "Explica'm més sobre això", + "quick_reply_when_else": "Quan més es va veure?", + "quick_reply_find_similar_text": "Troba albiraments similars a això.", + "quick_reply_tell_me_more_text": "Parla'm més d'aquest.", + "quick_reply_when_else_text": "Quan més es va veure això?", + "anchor": "Referència", + "similarity_score": "Similitud", + "no_similar_objects_found": "No s'ha trobat cap objecte similar.", + "semantic_search_required": "La cerca semàntica ha d'estar habilitada per trobar objectes similars.", + "send": "Envia", + "suggested_requests": "Proveu de preguntar:", + "starting_requests": { + "show_recent_events": "Mostra els esdeveniments recents", + "show_camera_status": "Mostra l'estat de la càmera", + "recap": "Què va passar mentre jo era fora?", + "watch_camera": "Observa una càmera per a l'activitat" + }, + "starting_requests_prompts": { + "show_recent_events": "Mostra'm els esdeveniments recents de l'última hora", + "show_camera_status": "Quin és l'estat actual de les meves càmeres?", + "recap": "Què va passar mentre jo era fora?", + "watch_camera": "Vigila la porta d'entrada i fes-me saber si algú apareix" + } +} diff --git a/web/public/locales/ca/views/events.json b/web/public/locales/ca/views/events.json index afacccbf9b..a0563a991f 100644 --- a/web/public/locales/ca/views/events.json +++ b/web/public/locales/ca/views/events.json @@ -27,7 +27,9 @@ }, "documentTitle": "Revisió - Frigate", "recordings": { - "documentTitle": "Enregistraments - Frigate" + "documentTitle": "Enregistraments - Frigate", + "invalidSharedLink": "No s'ha pogut obrir l'enllaç d'enregistrament amb marques de temps a causa d'un error d'anàlisi.", + "invalidSharedCamera": "No s'ha pogut obrir l'enllaç d'enregistrament amb marques de temps a causa d'una càmera desconeguda o no autoritzada." }, "calendarFilter": { "last24Hours": "Últimes 24 hores" diff --git a/web/public/locales/ca/views/explore.json b/web/public/locales/ca/views/explore.json index a923baa954..c9a11a0c4b 100644 --- a/web/public/locales/ca/views/explore.json +++ b/web/public/locales/ca/views/explore.json @@ -248,7 +248,7 @@ "dialog": { "confirmDelete": { "title": "Confirmar la supressió", - "desc": "Eliminant aquest objecte seguit borrarà l'snapshot, qualsevol embedding gravat, i qualsevol detall de seguiment. Les imatges gravades d'aquest objecte seguit en l'historial NO seràn eliminades.

    Estas segur que vols continuar?" + "desc": "Suprimir aquest objecte rastrejat elimina la instantània, qualsevol incrustació desada, i qualsevol entrada de detalls de seguiment associada. Les imatges gravades d'aquest objecte seguit en l'historial NO seràn eliminades.

    Estas segur que vols continuar?" }, "toast": { "error": "S'ha produït un error en suprimir aquest objecte rastrejat: {{errorMessage}}" @@ -289,7 +289,10 @@ "zones": "Zones", "ratio": "Ràtio", "area": "Àrea", - "score": "Puntuació" + "score": "Puntuació", + "computedScore": "Puntuació calculada", + "topScore": "Puntuació superior", + "toggleAdvancedScores": "Commuta les puntuacions avançades" } }, "annotationSettings": { diff --git a/web/public/locales/ca/views/exports.json b/web/public/locales/ca/views/exports.json index ccb5366b55..194d87ae40 100644 --- a/web/public/locales/ca/views/exports.json +++ b/web/public/locales/ca/views/exports.json @@ -14,7 +14,9 @@ "toast": { "error": { "renameExportFailed": "Error al canviar el nom de l’exportació: {{errorMessage}}", - "assignCaseFailed": "No s'ha pogut actualitzar l'assignació de cas:{{errorMessage}}" + "assignCaseFailed": "No s'ha pogut actualitzar l'assignació de cas:{{errorMessage}}", + "caseSaveFailed": "No s'ha pogut desar el cas: {{errorMessage}}", + "caseDeleteFailed": "No s'ha pogut suprimir el cas: {{errorMessage}}" } }, "tooltip": { @@ -22,7 +24,8 @@ "downloadVideo": "Baixa el vídeo", "editName": "Edita el nom", "deleteExport": "Suprimeix l'exportació", - "assignToCase": "Afegeix al cas" + "assignToCase": "Afegeix al cas", + "removeFromCase": "Elimina del cas" }, "headings": { "cases": "Casos", @@ -35,5 +38,91 @@ "newCaseOption": "Crea un cas nou", "nameLabel": "Nom del cas", "descriptionLabel": "Descripció" + }, + "toolbar": { + "newCase": "Cas nou", + "addExport": "Afegeix una exportació", + "editCase": "Edita el cas", + "deleteCase": "Suprimeix el cas" + }, + "deleteCase": { + "label": "Suprimeix el cas", + "desc": "Esteu segur que voleu suprimir {{caseName}}?", + "descKeepExports": "Les exportacions continuaran estant disponibles com a exportacions sense categoria.", + "descDeleteExports": "Totes les exportacions en aquest cas s'eliminaran permanentment.", + "deleteExports": "Elimina també les exportacions" + }, + "caseCard": { + "emptyCase": "Encara no hi ha exportacions" + }, + "jobCard": { + "defaultName": "Exportació de {{camera}}", + "queued": "En cua", + "running": "En execució", + "preparing": "Preparant", + "copying": "Copiant", + "encoding": "Codificant", + "encodingRetry": "Codificant (reintent)", + "finalizing": "Finalitzant" + }, + "caseView": { + "noDescription": "Sense descripció", + "createdAt": "{{value}} creat", + "exportCount_one": "1 exportació", + "exportCount_other": "{{count}} exportacions", + "cameraCount_one": "1 càmera", + "cameraCount_other": "{{count}} càmeres", + "showMore": "Mostra'n més", + "showLess": "Mostra menys", + "emptyTitle": "Aquest cas és buit", + "emptyDescription": "Afegeix les exportacions no categoritzades existents per mantenir el cas organitzat.", + "emptyDescriptionNoExports": "Encara no hi ha exportacions sense categoria per afegir." + }, + "caseEditor": { + "createTitle": "Crea un cas", + "editTitle": "Edita el cas", + "namePlaceholder": "Nom del cas", + "descriptionPlaceholder": "Afegeix notes o context per a aquest cas" + }, + "addExportDialog": { + "title": "Afegeix l'exportació a {{caseName}}", + "searchPlaceholder": "Cerca exportacions sense categoria", + "empty": "No hi ha exportacions sense categoria que coincideixin amb aquesta cerca.", + "addButton_one": "Afegeix 1 exportació", + "addButton_other": "Afegeix {{count}} exportacions", + "adding": "S'està afegint..." + }, + "selected_one": "{{count}} seleccionats", + "selected_other": "{{count}} seleccionats", + "bulkActions": { + "addToCase": "Afegeix al cas", + "moveToCase": "Mou al cas", + "removeFromCase": "Elimina del cas", + "delete": "Suprimeix", + "deleteNow": "Suprimeix ara" + }, + "bulkDelete": { + "title": "Suprimeix les exportacions", + "desc_one": "Esteu segur que voleu suprimir {{count}} l'exportació?", + "desc_other": "steu segur que voleu suprimir {{count}} exportacions?" + }, + "bulkRemoveFromCase": { + "title": "Elimina del cas", + "desc_one": "Voleu suprimir {{count}} d'aquest cas?", + "desc_other": "Voleu eliminar {{count}} exportacions d'aquest cas?", + "descKeepExports": "Les exportacions es mouran a sense categoria.", + "descDeleteExports": "Les exportacions s'eliminaran permanentment.", + "deleteExports": "Suprimeix les exportacions" + }, + "bulkToast": { + "success": { + "delete": "Exportacions suprimides amb èxit", + "reassign": "Assignació de cas actualitzada amb èxit", + "remove": "S'han eliminat les exportacions del cas" + }, + "error": { + "deleteFailed": "No s'han pogut suprimir les exportacions: {{errorMessage}}", + "reassignFailed": "No s'ha pogut actualitzar l'assignació de cas: {{errorMessage}}" + } } } diff --git a/web/public/locales/ca/views/faceLibrary.json b/web/public/locales/ca/views/faceLibrary.json index 1cc77f1a60..ea19924ac2 100644 --- a/web/public/locales/ca/views/faceLibrary.json +++ b/web/public/locales/ca/views/faceLibrary.json @@ -38,7 +38,7 @@ "uploadFace": "Puja una imatge del rostre", "nextSteps": "Següents passos", "description": { - "uploadFace": "Puja una imatge de {{name}} que mostri el seu rostre de cares. No cal que la imatge estigui retallada només al rostre." + "uploadFace": "Pugeu una imatge de {{name}} que mostra la seva cara des d'un angle frontal. La imatge no necessita ser retallada a la seva cara." } }, "selectFace": "Seleccionar rostre", diff --git a/web/public/locales/ca/views/live.json b/web/public/locales/ca/views/live.json index b40f02e35a..20db54905b 100644 --- a/web/public/locales/ca/views/live.json +++ b/web/public/locales/ca/views/live.json @@ -70,7 +70,8 @@ }, "recording": { "enable": "Habilitar gravació", - "disable": "Deshabilita l'enregistrament" + "disable": "Deshabilita l'enregistrament", + "disabledInConfig": "L'enregistrament primer s'ha d'habilitar a la configuració d'aquesta càmera." }, "snapshots": { "enable": "Habilita captura d'instantània", diff --git a/web/public/locales/ca/views/motionSearch.json b/web/public/locales/ca/views/motionSearch.json new file mode 100644 index 0000000000..cf41e934d1 --- /dev/null +++ b/web/public/locales/ca/views/motionSearch.json @@ -0,0 +1,77 @@ +{ + "documentTitle": "Busca Deteccións - Frigate", + "title": "Búsqueda de Deteccions", + "selectCamera": "Búsqueda de Deteccions s'esta carregant", + "startSearch": "Començar Búsqueda", + "searchStarted": "Búsqueda inicada", + "searchCancelled": "Búsqueda cancel·lada", + "cancelSearch": "Cancel·lar", + "searching": "Búsqueda en progrés.", + "searchComplete": "Búsqueda completa", + "description": "Dibuixa un polígon per definir la regió d'interès, i especifica un interval de temps per cercar canvis de moviment dins d'aquesta regió.", + "noResultsYet": "Executa una cerca per a trobar canvis de moviment a la regió seleccionada", + "noChangesFound": "No s'ha detectat cap canvi de píxel a la regió seleccionada", + "changesFound_one": "S'ha trobat el canvi de moviment {{count}}", + "changesFound_many": "S'han trobat {{count}} canvis de moviment", + "changesFound_other": "S'han trobat {{count}} canvis de moviment", + "framesProcessed": "{{count}} fotogrames processats", + "jumpToTime": "Salta a aquesta hora", + "results": "Resultats", + "showSegmentHeatmap": "Mapa de calor", + "newSearch": "Cerca nova", + "clearResults": "Neteja els resultats", + "clearROI": "Neteja el polígon", + "polygonControls": { + "points_one": "{{count}} punt", + "points_many": "{{count}} punts", + "points_other": "{{count}} punts", + "undo": "Desfés l'últim punt", + "reset": "Restableix el polígon" + }, + "motionHeatmapLabel": "Mapa de calor del moviment", + "dialog": { + "title": "Cerca de moviment", + "cameraLabel": "Càmara", + "previewAlt": "Vista prèvia de la càmera per a {{camera}}" + }, + "timeRange": { + "title": "Interval de cerca", + "start": "Hora d'inici", + "end": "Hora final" + }, + "settings": { + "title": "Configuració de la cerca", + "parallelMode": "Mode paral·lel", + "parallelModeDesc": "Escaneja múltiples segments d'enregistrament al mateix temps (més ràpid, però significativament més intensiu en CPU)", + "threshold": "Llindar de la sensibilitat", + "thresholdDesc": "Els valors més baixos detecten canvis més petits (1-255)", + "minArea": "Àrea de canvi mínim", + "minAreaDesc": "Percentatge mínim de la regió d'interès que s'ha de canviar per considerar-se significatiu", + "frameSkip": "Omet el fotograma", + "frameSkipDesc": "Processa cada N fotograma. Establiu això a la velocitat de fotogrames de la càmera per processar un fotograma per segon (p. ex. 5 per a una càmera de 5 FPS, 30 per a una càmera de 30 FPS). Els valors més alts seran més ràpids, però poden perdre els esdeveniments de curt moviment.", + "maxResults": "Resultats màxims", + "maxResultsDesc": "Atura després d'aquestes quantes marques horàries coincidents" + }, + "errors": { + "noCamera": "Seleccioneu una càmera", + "noROI": "Dibuixeu una regió d'interès", + "noTimeRange": "Seleccioneu un interval de temps", + "invalidTimeRange": "L'hora de finalització ha de ser posterior a l'hora d'inici", + "searchFailed": "Ha fallat la cerca: {{message}}", + "polygonTooSmall": "El polígon ha de tenir almenys 3 punts", + "unknown": "Error desconegut" + }, + "changePercentage": "{{percentage}}% canviat", + "metrics": { + "title": "Cerca les mètriques", + "segmentsScanned": "Segments escanejats", + "segmentsProcessed": "Processat", + "segmentsSkippedInactive": "S'ha omès (sense activitat)", + "segmentsSkippedHeatmap": "S'ha omès (sense superposició ROI)", + "fallbackFullRange": "Escaneig de rang complet alternatiu", + "framesDecoded": "Fotogrames descodificats", + "wallTime": "Temps de cerca", + "segmentErrors": "Errors del segment", + "seconds": "{{seconds}}s" + } +} diff --git a/web/public/locales/ca/views/replay.json b/web/public/locales/ca/views/replay.json new file mode 100644 index 0000000000..36eccd8a6c --- /dev/null +++ b/web/public/locales/ca/views/replay.json @@ -0,0 +1,59 @@ +{ + "page": { + "startError": { + "back": "Torna a l'Historial", + "title": "No s'ha pogut iniciar la repetició de la depuració" + }, + "sourceCamera": "Camera d'origen", + "replayCamera": "Reproduïr Càmera", + "initializingReplay": "Inicialitzant depurar repetició...", + "stoppingReplay": "Parant depurar repetició...", + "stopReplay": "Parar Repetició", + "confirmStop": { + "title": "Parar Depurar Repetició?", + "description": "Aixó pararà la sessió i netejarà les dades temporals. Estás segur?", + "confirm": "Parar Repetició", + "cancel": "Cancel·lar" + }, + "activity": "Activitat", + "objects": "Llista d'Objectes", + "audioDetections": "Deteccions d'Audio", + "noActivity": "Sense activitat detectada", + "activeTracking": "Tracking Actiu", + "noActiveTracking": "Sense tracking actiu", + "configuration": "Configuració", + "configurationDesc": "Configuració d'ajust fi de detecció de moviment i tracking d'objectes per a la depuració de reproducció de càmera. Cap canvi es graba en el teu arxiu de configuració de Frigate.", + "noSession": "No hi ha una sessió activa de reproducció de depuració", + "noSessionDesc": "Inicia una reproducció de depuració des de la vista Historial fent clic al botó Accions a la barra d'eines i escollint Depura Repeteix.", + "goToRecordings": "Ves a l'historial", + "preparingClip": "S'està preparant el clip…", + "preparingClipDesc": "Frigate està cosint enregistraments per a l'interval de temps seleccionat. Això pot trigar un minut en intervals més llargs.", + "startingCamera": "S'està iniciant la repetició de la depuració…" + }, + "title": "Repetició de depuració", + "websocket_messages": "Missatges", + "dialog": { + "title": "Iniciar Depuració de Repeticions", + "camera": "Càmera Font", + "timeRange": "Rang de Temps", + "preset": { + "1m": "Últim 1 Minut", + "5m": "Últims 5 Minuts", + "timeline": "Desde la Línia de Temps", + "custom": "Personalitzat" + }, + "description": "Crea una càmera de reproducció temporal que fa bucles de metratge històric per depurar la detecció d'objectes i els problemes de seguiment. La càmera de reproducció tindrà la mateixa configuració de detecció que la càmera d'origen. Trieu un interval de temps per començar.", + "startButton": "Inicia la repetició", + "selectFromTimeline": "Selecciona", + "starting": "S'està iniciant la repetició...", + "startLabel": "Inici", + "endLabel": "Final", + "toast": { + "error": "No s'ha pogut iniciar la repetició de depuració: {{error}}", + "alreadyActive": "Ja hi ha activada una sessió de reproducció", + "stopError": "No s'ha pogut aturar la repetició de depuració: {{error}}", + "goToReplay": "Ves a la repetició" + } + }, + "description": "Reprodueix els enregistraments de la càmera per a la depuració. La llista d'objectes mostra un resum retardat en el temps dels objectes detectats i la pestanya Missatges mostra un flux de missatges interns de la fragata a partir del metratge de reproducció." +} diff --git a/web/public/locales/ca/views/settings.json b/web/public/locales/ca/views/settings.json index 187132bf8e..b540b05861 100644 --- a/web/public/locales/ca/views/settings.json +++ b/web/public/locales/ca/views/settings.json @@ -43,7 +43,7 @@ "globalMotion": "Detecció de moviment", "globalObjects": "Objectes", "globalReview": "Revisió", - "globalAudioEvents": "Esdeveniments d'àudio", + "globalAudioEvents": "Detecció d'àudio", "globalLivePlayback": "Reproducció en directe", "globalTimestampStyle": "Estil de la marca horària", "systemDatabase": "Base de dades", @@ -73,7 +73,7 @@ "cameraMotion": "Detecció de moviment", "cameraObjects": "Objectes", "cameraConfigReview": "Revisió", - "cameraAudioEvents": "Esdeveniments d'àudio", + "cameraAudioEvents": "Detecció d'àudio", "cameraAudioTranscription": "Transcripció d'àudio", "cameraNotifications": "Notificacions", "cameraLivePlayback": "Reproducció en directe", @@ -1280,7 +1280,8 @@ }, "hikvision": { "substreamWarning": "El substream 1 està bloquejat a una resolució baixa. Moltes càmeres Hikvision suporten subfluxos addicionals que han d'estar habilitats a la configuració de la càmera. Es recomana comprovar i utilitzar aquests corrents si estan disponibles." - } + }, + "resolutionUnknown": "La resolució d'aquest flux no s'ha pogut investigar. Heu d'establir manualment la resolució de detecció a Configuració o a la configuració." } } }, @@ -1297,7 +1298,13 @@ "enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.
    Nota: això no desactiva les retransmissions de go2rtc.", "disableLabel": "Càmeres inhabilitades", "disableDesc": "Habilita una càmera que actualment no és visible a la interfície d'usuari i està desactivada a la configuració. Es requereix un reinici de Frigate després d'activar-la.", - "enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis." + "enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis.", + "friendlyName": { + "edit": "Edita el nom de la pantalla de la càmera", + "title": "Edita el nom de la pantalla", + "description": "Estableix el nom amigable que es mostra per a aquesta càmera a tota la interfície d'usuari de la Fragata. Deixeu-ho en blanc per utilitzar l'ID de la càmera.", + "rename": "Canvia el nom" + } }, "cameraConfig": { "add": "Afegeix una càmera", @@ -1347,6 +1354,14 @@ "inherit": "Hereta", "enabled": "Habilitat", "disabled": "Desactivat" + }, + "cameraType": { + "title": "Tipus de càmera", + "label": "Tipus de càmera", + "description": "Estableix el tipus per a cada càmera. Les càmeres LPR dedicades són càmeres d'un sol ús amb un potent zoom òptic per capturar matrícules en vehicles distants. La majoria de les càmeres haurien d'utilitzar el tipus de càmera normal llevat que la càmera sigui específicament per a LPR i tingui una vista molt centrada en les matrícules.", + "dedicatedLpr": "LPR dedicat", + "saveSuccess": "Tipus de càmera actualitzat per {{cameraName}}. Reinicia la fragata per aplicar els canvis.", + "normal": "Normal" } }, "cameraReview": { @@ -1659,7 +1674,16 @@ "empty": "No hi ha etiquetes disponibles", "allNonAlertDetections": "Totes les activitats no alertes s'inclouran com a deteccions." }, - "addCustomLabel": "Afegeix una etiqueta personalitzada..." + "addCustomLabel": "Afegeix una etiqueta personalitzada...", + "genaiModel": { + "placeholder": "Selecciona el model…", + "search": "Cerca models…", + "noModels": "No hi ha models disponibles" + }, + "knownPlates": { + "namePlaceholder": "per exemple. Cotxe de la parella", + "platePlaceholder": "Matricula o regex" + } }, "globalConfig": { "title": "Configuració global", @@ -1704,7 +1728,22 @@ "overriddenGlobal": "Sobreescrit (Global)", "overriddenGlobalTooltip": "Aquesta càmera anul·la la configuració global d'aquesta secció", "overriddenBaseConfig": "Sobreescrit (Configuració base)", - "overriddenBaseConfigTooltip": "El perfil {{profile}} substitueix la configuració d'aquesta secció" + "overriddenBaseConfigTooltip": "El perfil {{profile}} substitueix la configuració d'aquesta secció", + "overriddenInCameras": { + "label_one": "Sobreescrit a la càmera {{count}}", + "label_many": "Sobreescrit en {{count}} càmeres", + "label_other": "Sobreescrit en {{count}} càmeres", + "tooltip_one": "{{count}} la càmera anul·la els valors d'aquesta secció. Feu clic per veure els detalls.", + "tooltip_many": "{{count}} càmeres substitueixen els valors d'aquesta secció. Feu clic per veure els detalls.", + "tooltip_other": "{{count}} càmeres substitueixen els valors d'aquesta secció. Feu clic per veure els detalls.", + "heading_one": "Aquesta secció global té camps que estan sobreescrits a la càmera {{count}}.", + "heading_many": "Aquesta secció global té camps que estan sobreescrits en {{count}} càmeres.", + "heading_other": "Aquesta secció global té camps que estan sobreescrits en {{count}} càmeres.", + "othersField_one": "{{count}} altre", + "othersField_many": "{{count}} altres", + "othersField_other": "{{count}} altres", + "profilePrefix": "Perfil {{profile}}: {{fields}}" + } }, "profiles": { "title": "Perfils", @@ -1805,7 +1844,8 @@ "review": { "recordDisabled": "L'enregistrament està desactivat, els elements de revisió no es generaran.", "detectDisabled": "La detecció d'objectes està desactivada. Els elements de revisió requereixen objectes detectats per categoritzar alertes i deteccions.", - "allNonAlertDetections": "Totes les activitats no alertes s'inclouran com a deteccions." + "allNonAlertDetections": "Totes les activitats no alertes s'inclouran com a deteccions.", + "genaiImageSourceRecordingsRecordDisabled": "La font d'imatges està configurada com a 'enregistraments', però l'enregistrament està desactivat. La fragata tornarà a la vista prèvia de les imatges." }, "audio": { "noAudioRole": "Cap flux té definit el rol d'àudio. Heu d'habilitar el rol d'àudio per a la detecció d'àudio perquè funcioni." @@ -1814,15 +1854,18 @@ "audioDetectionDisabled": "La detecció d'àudio no està activada per a aquesta càmera. La transcripció d'àudio requereix que la detecció d'àudio estigui activa." }, "detect": { - "fpsGreaterThanFive": "No es recomana establir el detect FPS superior a 5." + "fpsGreaterThanFive": "No es recomana establir el detect FPS superior a 5. Els valors més alts poden causar problemes de rendiment i no proporcionaran cap benefici.", + "disabled": "La detecció d'objectes està desactivada. Les instantànies, articles de revisió i enriquiments com el reconeixement de rostres, el reconeixement de matrícules i la IA Generativa no funcionaran." }, "faceRecognition": { - "globalDisabled": "El reconeixement de cares no està habilitat a nivell global. Habilita-ho en la configuració global per al reconeixement facial a nivell de càmera per funcionar.", - "personNotTracked": "El reconeixement de cares requereix que l'objecte 'persona' sigui rastrejat. Assegureu-vos que «persona» estigui a la llista de seguiment d'objectes." + "globalDisabled": "L'enriquiment del reconeixement facial s'ha d'habilitar perquè les funcions de reconeixement facial funcionin en aquesta càmera.", + "personNotTracked": "El reconeixement de cares requereix que l'objecte 'persona' sigui rastrejat. Habilita «persona» en objectes per a aquesta càmera.", + "modelSizeLarge": "El model 'gran' requereix una GPU o NPU per a un rendiment raonable. Usa «petit» en sistemes només de CPU." }, "lpr": { - "globalDisabled": "El reconeixement de la matrícula no està habilitat a nivell global. Habilita-ho en la configuració global per al funcionament de LPR a nivell de càmera.", - "vehicleNotTracked": "El reconeixement de la matrícula requereix que es faci un seguiment del 'cotxe' o de la 'motocicleta'." + "globalDisabled": "L'enriquiment de reconeixement de matrícules ha d'estar habilitat perquè les funcions LPR funcionin en aquesta càmera.", + "vehicleNotTracked": "El reconeixement de la matrícula requereix que es faci un seguiment del 'cotxe' o de la 'motocicleta'.", + "modelSizeLarge": "El model 'gran' està optimitzat per a matrícules multilínies. El model 'petit' proporciona un millor rendiment sobre 'gran' i s'ha d'utilitzar tret que la vostra regió utilitzi formats de placa multilínia." }, "record": { "noRecordRole": "Cap flux té el rol de registre definit. L'enregistrament no funcionarà." @@ -1836,6 +1879,12 @@ "detectors": { "mixedTypes": "Tots els detectors han d'utilitzar el mateix tipus. Elimina els detectors existents per utilitzar un tipus diferent.", "mixedTypesSuggestion": "Tots els detectors han d'utilitzar el mateix tipus. Suprimiu detectors existents o seleccioneu {{type}}." + }, + "objects": { + "genaiNoDescriptionsProvider": "Heu de configurar un proveïdor de GenAI amb el rol 'descripcions' per a les descripcions que es generaran." + }, + "semanticSearch": { + "jinav2SmallModelSize": "La mida 'petita' amb el model Jina V2 té un alt cost de RAM i d'inferència. Es recomana el model 'gran' amb una GPU discreta." } } } diff --git a/web/public/locales/ca/views/system.json b/web/public/locales/ca/views/system.json index 22ecd1fa81..595e7f8f60 100644 --- a/web/public/locales/ca/views/system.json +++ b/web/public/locales/ca/views/system.json @@ -213,6 +213,9 @@ "expectedFps": "FPS esperat", "reconnectsLastHour": "Reconnecta (última hora)", "stallsLastHour": "Parades (última hora)" + }, + "noCameras": { + "title": "No s'ha trobat cap càmera" } }, "lastRefreshed": "Darrera actualització: ", diff --git a/web/public/locales/cs/views/chat.json b/web/public/locales/cs/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/cs/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/cs/views/motionSearch.json b/web/public/locales/cs/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/cs/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/cs/views/replay.json b/web/public/locales/cs/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/cs/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/da/views/chat.json b/web/public/locales/da/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/da/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/da/views/motionSearch.json b/web/public/locales/da/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/da/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/da/views/replay.json b/web/public/locales/da/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/da/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/de/common.json b/web/public/locales/de/common.json index 8924da381e..7f9848fe28 100644 --- a/web/public/locales/de/common.json +++ b/web/public/locales/de/common.json @@ -250,7 +250,8 @@ "classification": "Klassifizierung", "actions": "Aktion", "chat": "Chat", - "profiles": "Profile" + "profiles": "Profile", + "features": "Funktionen" }, "unit": { "speed": { diff --git a/web/public/locales/de/components/camera.json b/web/public/locales/de/components/camera.json index e9f39cb8e7..a2b443bd66 100644 --- a/web/public/locales/de/components/camera.json +++ b/web/public/locales/de/components/camera.json @@ -66,7 +66,7 @@ "label": "Kameras", "desc": "Wähle Kameras für diese Gruppe aus." }, - "label": "Kameragruppen", + "label": "Kamera Gruppen", "edit": "Kameragruppe bearbeiten", "success": "Kameragruppe {{name}} wurde gespeichert." }, diff --git a/web/public/locales/de/components/dialog.json b/web/public/locales/de/components/dialog.json index e91a68fe4f..59dac7aeda 100644 --- a/web/public/locales/de/components/dialog.json +++ b/web/public/locales/de/components/dialog.json @@ -64,20 +64,73 @@ "toast": { "error": { "endTimeMustAfterStartTime": "Die Endzeit darf nicht vor der Startzeit liegen", - "failed": "Fehler beim Starten des Exports: {{error}}", + "failed": "Fehler beim Export in die Warteschlange: {{error}}", "noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt" }, "success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite.", - "view": "Ansicht" + "view": "Ansicht", + "queued": "Export in Warteschlange gestellt. Fortschritt auf der Exportseite verfolgen.", + "batchSuccess_one": "1 Export gestartet. Öffne den Fall jetzt.", + "batchSuccess_other": "{{count}} Exports gestartet. Öffne den Fall jetzt.", + "batchPartial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagene Kameras: {{failedCameras}}", + "batchFailed": "Fehler beim Starten der {{total}} Exporte. Fehlgeschlagene Kameras: {{failedCameras}}", + "batchQueuedSuccess_one": "1 Export in die Warteschlange gestellt. Fall wird jetzt geöffnet.", + "batchQueuedSuccess_other": "{{count}} Exporte in der Warteschlange. Fall wird jetzt geöffnet.", + "batchQueuedPartial": "{{successful}} von {{total}} Exporten in die Warteschlange gestellt. Fehlerhafte Kameras: {{failedCameras}}", + "batchQueueFailed": "Fehler beim Einreihen von {{total}} Exporten in die Warteschlange. Fehlerhafte Kameras: {{failedCameras}}" }, "fromTimeline": { "saveExport": "Export speichern", - "previewExport": "Exportvorschau" + "previewExport": "Exportvorschau", + "queueingExport": "Export wird in die Warteschlange gestellt...", + "useThisRange": "Nutzen Sie diesen Bereich" }, "export": "Exportieren", "case": { "label": "Fall", - "placeholder": "Einen Fall auswählen" + "placeholder": "Einen Fall auswählen", + "newCaseOption": "Einen neuen Fall erstellen", + "newCaseNamePlaceholder": "Neuer Fallname", + "newCaseDescriptionPlaceholder": "Fall Beschreibung", + "nonAdminHelp": "Für diese Exporte wird ein neuer Fall angelegt." + }, + "queueing": "Export wird in die Warteschlange gestellt...", + "tabs": { + "export": "Einzelne Kamera", + "multiCamera": "Mehrere-Kameras" + }, + "multiCamera": { + "timeRange": "Zeitbereich", + "selectFromTimeline": "Wählen Sie aus der Zeitleiste aus", + "cameraSelection": "Kameras", + "cameraSelectionHelp": "Kameras, die in diesem Zeitbereich Objekte verfolgen, sind vorausgewählt", + "checkingActivity": "Kameraaktivität wird überprüft...", + "noCameras": "keine kamaeras verfügbar", + "detectionCount_one": "1 verfolgtes Objekt", + "detectionCount_other": "{{count}} verfolgtesObjekte", + "nameLabel": "Export Name", + "namePlaceholder": "Optionaler Basisname für diese Exporte", + "queueingButton": "Exporte werden in die Warteschlange gestellt...", + "exportButton_one": "Export 1 Kamera", + "exportButton_other": "xport {{count}} Kameras" + }, + "multi": { + "title_one": "1 Bewertung exportieren", + "title_other": "{{count}} Bewertung exportieren", + "description": "Exportieren Sie jede ausgewählte Rezension. Alle Exporte werden in einem einzigen Fall zusammengefasst.", + "descriptionNoCase": "Jede ausgewählte Bewertung exportieren.", + "caseNamePlaceholder": "Export prüfen - {{date}}", + "exportButton_one": "1 Bewertung exportieren", + "exportButton_other": "{{count}} Bewertung exportieren", + "exportingButton": "Exportieren...", + "toast": { + "started_one": "1 Export gestartet. Fall wird jetzt geöffnet.", + "started_other": "{{count}} Exporte gestartet. Fall wird jetzt geöffnet.", + "startedNoCase_one": "1 Export gestartet.", + "startedNoCase_other": "{{count}} Exports gestartet.", + "partial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagen: {{failedItems}}", + "failed": "Fehler beim Starten der {{total}} Exporte. Fehler: {{failedItems}}" + } } }, "streaming": { @@ -125,6 +178,14 @@ "markAsReviewed": "Als geprüft markieren", "deleteNow": "Jetzt löschen", "markAsUnreviewed": "Als ungeprüft markieren" + }, + "shareTimestamp": { + "label": "Zeitstempel teilen", + "title": "Zeitstempel teilen", + "description": "Teile eine URL mit Zeitstempel, die die aktuelle Position des Players angibt, oder wähle einen benutzerdefinierten Zeitstempel aus. Beachte, dass es sich hierbei nicht um eine öffentliche Freigabe-URL handelt und dass nur Benutzer Zugriff darauf haben, die Zugriff auf Frigate und diese Kamera haben.", + "custom": "Benutzerdefinierter Zeitstempel", + "button": "URL des Zeitstempels teilen", + "shareTitle": "Zeitstempel der Fregattenbewertung: {{camera}}" } }, "imagePicker": { diff --git a/web/public/locales/de/components/player.json b/web/public/locales/de/components/player.json index 56a1950539..ad56cf2ce4 100644 --- a/web/public/locales/de/components/player.json +++ b/web/public/locales/de/components/player.json @@ -3,7 +3,8 @@ "noPreviewFound": "Keine Vorschau gefunden", "submitFrigatePlus": { "title": "Dieses Bild an Frigate+ senden?", - "submit": "Senden" + "submit": "Absenden", + "previewError": "Schnappschuss Vorschau konnte nicht geladen werden. Die Aufnahme ist möglicherweise derzeit nicht verfügbar." }, "livePlayerRequiredIOSVersion": "iOS 17.1 oder höher ist für diesen Typ eines Live-Streams erforderlich.", "streamOffline": { diff --git a/web/public/locales/de/config/cameras.json b/web/public/locales/de/config/cameras.json index 9a0ab8b174..7080fe186a 100644 --- a/web/public/locales/de/config/cameras.json +++ b/web/public/locales/de/config/cameras.json @@ -9,7 +9,7 @@ "description": "Aktiviert" }, "audio": { - "label": "Audioereignisse", + "label": "Audioerkennung", "description": "Einstellungen für audiobasierte Ereigniserkennung für diese Kamera.", "enabled": { "label": "Aktivieren der Audioerkennung", @@ -537,6 +537,10 @@ "hwaccel_args": { "label": "hwaccel-Argumente exportieren", "description": "Argumente für die Hardwarebeschleunigung bei Export- und Transkodierungsvorgängen." + }, + "max_concurrent": { + "label": "Maximale Anzahl gleichzeitiger Exporte", + "description": "Maximale Anzahl der gleichzeitig zu verarbeitenden Exportaufträge." } }, "preview": { diff --git a/web/public/locales/de/config/global.json b/web/public/locales/de/config/global.json index b7758bfeab..9df4d44c8a 100644 --- a/web/public/locales/de/config/global.json +++ b/web/public/locales/de/config/global.json @@ -8,7 +8,7 @@ "description": "Wenn aktiviert, startet Frigate im abgesicherten Modus mit reduzierten Features für die Fehlersuche." }, "audio": { - "label": "Audioereignisse", + "label": "Audioerkennung", "enabled": { "label": "Aktivieren der Audioerkennung", "description": "Aktivieren oder deaktivieren Sie die Erkennung von Audioereignissen für alle Kameras; diese Einstellung kann für jede Kamera individuell überschrieben werden." @@ -538,8 +538,8 @@ "description": "Aktivieren Sie die prozessbezogene Überwachung der Netzwerkbandbreite für Kamera-FFmpeg-Prozesse und Detektoren (erfordert entsprechende Funktionen)." }, "intel_gpu_device": { - "label": "SR-IOV-Gerät", - "description": "Gerätekennung, die verwendet wird, wenn Intel-GPUs als SR-IOV behandelt werden, um die GPU-Statistiken zu korrigieren." + "label": "Intel GPU", + "description": "PCI-Bus-Adresse oder DRM-Gerätepfad (z. B. /dev/dri/card1), der verwendet wird, um die Intel-GPU-Statistiken einem bestimmten Gerät zuzuordnen, wenn mehrere vorhanden sind." } }, "version_check": { @@ -1357,6 +1357,10 @@ "hwaccel_args": { "label": "hwaccel-Argumente exportieren", "description": "Argumente für die Hardwarebeschleunigung bei Export- und Transkodierungsvorgängen." + }, + "max_concurrent": { + "label": "Maximale Anzahl gleichzeitiger Exporte", + "description": "Maximale Anzahl der gleichzeitig zu verarbeitenden Exportaufträge." } }, "preview": { @@ -1710,7 +1714,7 @@ }, "roles": { "label": "Rollen", - "description": "GenAI-Rollen (Tools, Vision, Einbettungen); ein Anbieter pro Rolle." + "description": "GenAI-Rollen (Nachrichten, Beschreibung, Einbettungen); ein Anbieter pro Rolle." }, "provider_options": { "label": "Anbieter Optionen", diff --git a/web/public/locales/de/views/chat.json b/web/public/locales/de/views/chat.json new file mode 100644 index 0000000000..5a87ce9e10 --- /dev/null +++ b/web/public/locales/de/views/chat.json @@ -0,0 +1,46 @@ +{ + "documentTitle": "Chat - Frigate", + "title": "Frigate Chat", + "subtitle": "Ihr KI-Assistent für die Kameraverwaltung und Analysen", + "placeholder": "Frag mich alles...", + "error": "Es ist ein Fehler aufgetreten. Bitte versuche es erneut.", + "processing": "Wird verarbeitet...", + "toolsUsed": "Verwendet: {{tools}}", + "showTools": "Werkzeuge anzeigen ({{count}})", + "hideTools": "Werkzeuge ausblenden", + "call": "Anruf", + "result": "Ergebnis", + "arguments": "Argumente:", + "response": "Antwort:", + "attachment_chip_label": "{{label}} auf der {{camera}}", + "attachment_chip_remove": "Anhang entfernen", + "open_in_explore": "In „Explore“ öffnen", + "attach_event_aria": "Ereignis {{eventId}} hinzufügen", + "attachment_picker_paste_label": "Oder fügen Sie die Ereignis-ID ein", + "attachment_picker_attach": "Anhängen", + "attachment_picker_placeholder": "Ereignis hinzufügen", + "quick_reply_find_similar": "Ähnliche Sichtungen finden", + "quick_reply_tell_me_more": "Erzähl mir mehr darüber", + "quick_reply_when_else": "Wann wurde es sonst noch gesehen?", + "quick_reply_find_similar_text": "Ähnliche Sichtungen finden.", + "quick_reply_tell_me_more_text": "Erzähl mir mehr darüber.", + "quick_reply_when_else_text": "Wann gab es das sonst noch?", + "anchor": "Referenz", + "similarity_score": "Ähnlichkeit", + "no_similar_objects_found": "Es wurden keine ähnlichen Objekte gefunden.", + "semantic_search_required": "Die semantische Suche muss aktiviert sein, um ähnliche Objekte zu finden.", + "send": "Senden", + "suggested_requests": "Versuchen Sie doch mal zu fragen:", + "starting_requests": { + "show_recent_events": "Aktuelle Ereignisse anzeigen", + "show_camera_status": "Kamerastatus anzeigen", + "recap": "Was ist passiert, während ich weg war?", + "watch_camera": "Beobachten Sie eine Kamera auf Bewegungen" + }, + "starting_requests_prompts": { + "show_recent_events": "Zeige mir die Ereignisse der letzten Stunde", + "show_camera_status": "Wie ist der aktuelle Status meiner Kameras?", + "recap": "Was ist passiert, während ich weg war?", + "watch_camera": "Pass auf die Haustür auf und sag mir Bescheid, wenn jemand kommt" + } +} diff --git a/web/public/locales/de/views/events.json b/web/public/locales/de/views/events.json index 589a6e1a16..c943bec24a 100644 --- a/web/public/locales/de/views/events.json +++ b/web/public/locales/de/views/events.json @@ -25,7 +25,9 @@ }, "documentTitle": "Überprüfung - Frigate", "recordings": { - "documentTitle": "Aufnahmen - Frigate" + "documentTitle": "Aufnahmen - Frigate", + "invalidSharedLink": "Der Link zur zeitgestempelten Aufzeichnung kann aufgrund eines Parsing-Fehlers nicht geöffnet werden.", + "invalidSharedCamera": "Der Link zur zeitgestempelten Aufzeichnung kann nicht geöffnet werden, da es sich um eine unbekannte oder nicht autorisierte Kamera handelt." }, "calendarFilter": { "last24Hours": "Letzte 24 Stunden" diff --git a/web/public/locales/de/views/explore.json b/web/public/locales/de/views/explore.json index 5ca822d746..071d887905 100644 --- a/web/public/locales/de/views/explore.json +++ b/web/public/locales/de/views/explore.json @@ -73,7 +73,7 @@ "label": "Schnappschuss Bewertung" }, "score": { - "label": "Ergebnis" + "label": "Treffer" }, "editAttributes": { "title": "Attribute bearbeiten", @@ -282,7 +282,10 @@ "zones": "Zonen", "ratio": "Verhältnis", "area": "Bereich", - "score": "Bewertung" + "score": "Bewertung", + "computedScore": "Berechnetes Ergebnis", + "topScore": "Bester Treffer", + "toggleAdvancedScores": "Erweiterte Ergebnisse umschalten" } }, "annotationSettings": { diff --git a/web/public/locales/de/views/exports.json b/web/public/locales/de/views/exports.json index 26d3eae16a..da604de91a 100644 --- a/web/public/locales/de/views/exports.json +++ b/web/public/locales/de/views/exports.json @@ -14,7 +14,9 @@ "toast": { "error": { "renameExportFailed": "Umbenennen des Exports fehlgeschlagen: {{errorMessage}}", - "assignCaseFailed": "Aktualisierung der Fallzuweisung fehlgeschlagen: {{errorMessage}}" + "assignCaseFailed": "Aktualisierung der Fallzuweisung fehlgeschlagen: {{errorMessage}}", + "caseSaveFailed": "Fehler beim speichern vom Fall: {{errorMessage}}", + "caseDeleteFailed": "Fehler beim löschem vom Fall: {{errorMessage}}" } }, "tooltip": { @@ -22,7 +24,8 @@ "downloadVideo": "Video herunterladen", "editName": "Name ändern", "deleteExport": "Export löschen", - "assignToCase": "Hinzufügen zum Fall" + "assignToCase": "Hinzufügen zum Fall", + "removeFromCase": "Vom Gehäuse entfernen" }, "headings": { "cases": "Fälle", @@ -35,5 +38,91 @@ "newCaseOption": "Neuen Fall erstellen", "nameLabel": "Fallname", "descriptionLabel": "Beschreibung" + }, + "toolbar": { + "newCase": "Neuer Fall", + "addExport": "Zum expotieren hinzufügen", + "editCase": "Fall bearbeiten", + "deleteCase": "Fall löschen" + }, + "deleteCase": { + "label": "Fall löschen", + "desc": "Sind sie sich sicher löschen von{{caseName}}?", + "descKeepExports": "Exporte bleiben als nicht kategorisierte Exporte verfügbar.", + "descDeleteExports": "Alle Exporte werden in diesem Fall endgültig gelöscht.", + "deleteExports": "Exporte auch löschen" + }, + "caseCard": { + "emptyCase": "Noch keine Exporte" + }, + "jobCard": { + "defaultName": "{{camera}} export", + "queued": "In der Warteschlange", + "running": "läuft", + "preparing": "Vorbereitung", + "copying": "kopieren", + "encoding": "Codierung", + "encodingRetry": "Kodierung (Wiederholung)", + "finalizing": "Abschließen" + }, + "caseView": { + "noDescription": "keine Beschreibung", + "createdAt": "Erstellt {{value}}", + "exportCount_one": "1 Export", + "exportCount_other": "{{count}} Exports", + "cameraCount_one": "1 Kamera", + "cameraCount_other": "{{count}} Kameras", + "showMore": "Mehr anzeigen", + "showLess": "Weniger Anzeigen", + "emptyTitle": "Der Fall ist leer", + "emptyDescription": "Fügen Sie vorhandene, nicht kategorisierte Exporte hinzu, um den Fall übersichtlich zu halten.", + "emptyDescriptionNoExports": "Es sind noch keine nicht kategorisierten Exporte zum Hinzufügen verfügbar." + }, + "caseEditor": { + "createTitle": "Fall erstellen", + "editTitle": "Fall bearbeiten", + "namePlaceholder": "Fall Name", + "descriptionPlaceholder": "Fügen Sie Anmerkungen oder Kontext zu diesem Fall hinzu" + }, + "addExportDialog": { + "title": "Export zum {{caseName}} hinzufügen", + "searchPlaceholder": "Suche nach nicht kategorisierten Exporten", + "empty": "Es wurden keine nicht kategorisierten Exporte gefunden, die dieser Suche entsprechen.", + "addButton_one": "1 Export hinzufügen", + "addButton_other": "Fügen Sie {{count}} Exporte hinzu", + "adding": "Hinzufügen..." + }, + "selected_one": "{{count}} ausgewählt", + "selected_other": "{{count}} ausgewählt", + "bulkActions": { + "addToCase": "Zum Fall hinzufügen", + "moveToCase": "Zum Fall wechseln", + "removeFromCase": "Aus dem Fall nehmen", + "delete": "löschen", + "deleteNow": "jetzt löschen" + }, + "bulkDelete": { + "title": "Exporte löschen", + "desc_one": "Möchten Sie den Export {{count}} wirklich löschen?", + "desc_other": "Möchten Sie wirklich {{count}} Exporte löschen?" + }, + "bulkRemoveFromCase": { + "title": "Aus dem Fall nehmen", + "desc_one": "{{count}}-Export aus diesem Fall entfernen?", + "desc_other": "{{count}} Exporte aus diesem Fall entfernen?", + "descKeepExports": "Die Exporte werden in die Kategorie „Nicht kategorisiert“ verschoben.", + "descDeleteExports": "Exporte werden endgültig gelöscht.", + "deleteExports": "Löschen Sie stattdessen Exporte" + }, + "bulkToast": { + "success": { + "delete": "Exporte erfolgreich gelöscht", + "reassign": "Fallzuweisung erfolgreich aktualisiert", + "remove": "Exporte erfolgreich aus dem Fall entfernt" + }, + "error": { + "deleteFailed": "Fehler beim Löschen der Exporte: {{errorMessage}}", + "reassignFailed": "Fehler beim Aktualisieren der Fallzuordnung: {{errorMessage}}" + } } } diff --git a/web/public/locales/de/views/live.json b/web/public/locales/de/views/live.json index 854886b363..5405265314 100644 --- a/web/public/locales/de/views/live.json +++ b/web/public/locales/de/views/live.json @@ -62,7 +62,8 @@ }, "recording": { "disable": "Aufzeichnung deaktivieren", - "enable": "Aufzeichnung aktivieren" + "enable": "Aufzeichnung aktivieren", + "disabledInConfig": "Aufnahme muss erst in den Einstellung für diese Kamera aktiviert werden." }, "snapshots": { "enable": "Schnappschüsse aktivieren", diff --git a/web/public/locales/de/views/motionSearch.json b/web/public/locales/de/views/motionSearch.json new file mode 100644 index 0000000000..3008f10d85 --- /dev/null +++ b/web/public/locales/de/views/motionSearch.json @@ -0,0 +1,75 @@ +{ + "documentTitle": "Bewegungssuche - Frigate", + "title": "Bewegungssuche", + "description": "Zeichnen Sie ein Polygon, um den gewünschten Bereich zu definieren, und geben Sie einen Zeitbereich an, um innerhalb dieses Bereichs nach Bewegungsänderungen zu suchen.", + "selectCamera": "Die Bewegungssuche wird geladen", + "startSearch": "Suche starten", + "searchStarted": "Die Suche wurde gestartet", + "searchCancelled": "Suche abgebrochen", + "cancelSearch": "Abbrechen", + "searching": "Suche läuft.", + "searchComplete": "Suche abgeschlossen", + "noResultsYet": "Führen Sie eine Suche durch, um Bewegungsänderungen im ausgewählten Bereich zu finden", + "noChangesFound": "Im ausgewählten Bereich wurden keine Pixeländerungen festgestellt", + "changesFound_one": "Es wurde {{count}} Bewegungsänderungen gefunden", + "changesFound_other": "Es wurden {{count}} Bewegungsänderungen gefunden", + "framesProcessed": "{{count}} Bilder verarbeitet", + "jumpToTime": "Zu diesem Zeitpunkt springen", + "results": "Ergebnisse", + "showSegmentHeatmap": "Heatmap", + "newSearch": "Neue Suche", + "clearResults": "Eindeutige Ergebnisse", + "clearROI": "Polygon löschen", + "polygonControls": { + "points_one": "{{count}} Punkt", + "points_other": "{{count}} Punkte", + "undo": "Letzten Schritt rückgängig machen", + "reset": "Polygon zurücksetzen" + }, + "motionHeatmapLabel": "Bewegungs-Heatmap", + "dialog": { + "title": "Bewegungssuche", + "cameraLabel": "Kamera", + "previewAlt": "Kamera-Vorschau für {{camera}}" + }, + "timeRange": { + "title": "Suchbereich", + "start": "Startzeit", + "end": "Endzeit" + }, + "settings": { + "title": "Sucheinstellungen", + "parallelMode": "Parallelbetrieb", + "parallelModeDesc": "Mehrere Aufzeichnungssegmente gleichzeitig scannen (schneller, aber deutlich rechenintensiver)", + "threshold": "Empfindlichkeitsschwelle", + "thresholdDesc": "Niedrigere Werte erkennen geringere Veränderungen (1–255)", + "minArea": "Mindestwechselbereich", + "minAreaDesc": "Mindestanteil der untersuchten Region, der sich ändern muss, damit die Veränderung als signifikant gilt", + "frameSkip": "Bild überspringen", + "frameSkipDesc": "Verarbeite jeden N-ten Frame. Stelle diesen Wert auf die Bildrate deiner Kamera ein, um einen Frame pro Sekunde zu verarbeiten (z. B. 5 für eine Kamera mit 5 FPS, 30 für eine Kamera mit 30 FPS). Höhere Werte sorgen für eine schnellere Verarbeitung, können jedoch kurze Bewegungsabläufe übersehen.", + "maxResults": "Maximale Ergebnisse", + "maxResultsDesc": "Nach dieser Anzahl übereinstimmender Zeitstempel anhalten" + }, + "errors": { + "noCamera": "Bitte wählen Sie eine Kamera aus", + "noROI": "Bitte zeichnen Sie einen Bereich von Interesse ein", + "noTimeRange": "Bitte wählen Sie einen Zeitraum aus", + "invalidTimeRange": "Die Endzeit muss nach der Startzeit liegen", + "searchFailed": "Suche fehlgeschlagen: {{message}}", + "polygonTooSmall": "Ein Polygon muss mindestens 3 Punkte haben", + "unknown": "Unbekannter Fehler" + }, + "changePercentage": "Um {{percentage}} % verändert", + "metrics": { + "title": "Suchmetriken", + "segmentsScanned": "Durchsuchte Segmente", + "segmentsProcessed": "Bearbeitet", + "segmentsSkippedInactive": "Übersprungen (keine Aktivität)", + "segmentsSkippedHeatmap": "Übersprungen (keine Überschneidung der ROI)", + "fallbackFullRange": "Ausweich-Vollbereichsscan", + "framesDecoded": "Rahmen decodiert", + "wallTime": "Suchzeit", + "segmentErrors": "Segmentfehler", + "seconds": "{{seconds}}s" + } +} diff --git a/web/public/locales/de/views/replay.json b/web/public/locales/de/views/replay.json new file mode 100644 index 0000000000..6c28045baa --- /dev/null +++ b/web/public/locales/de/views/replay.json @@ -0,0 +1,59 @@ +{ + "title": "Debug-Wiedergabe", + "description": "Spielen Sie Kameraaufnahmen zur Fehlerbehebung ab. Die Objektliste zeigt eine zeitversetzte Übersicht der erkannten Objekte an, und auf der Registerkarte „Meldungen“ wird ein Stream der internen Meldungen von Frigate aus dem Wiedergabematerial angezeigt.", + "websocket_messages": "Nachrichten", + "dialog": { + "title": "Debug-Wiedergabe starten", + "description": "Erstellen Sie eine temporäre Wiedergabekamera, die historisches Bildmaterial in einer Schleife wiedergibt, um Probleme bei der Objekterkennung und -verfolgung zu beheben. Die Wiedergabekamera verfügt über dieselbe Erkennungskonfiguration wie die Quellkamera. Wählen Sie einen Zeitbereich aus, ab dem die Wiedergabe beginnen soll.", + "camera": "Quellkamera", + "timeRange": "Zeitraum", + "preset": { + "1m": "Letzte Minute", + "5m": "Die letzten 5 Minuten", + "timeline": "Aus der Zeitleiste", + "custom": "Benutzerdefiniert" + }, + "startButton": "Wiedergabe starten", + "selectFromTimeline": "Auswählen", + "starting": "Wiedergabe wird gestartet...", + "startLabel": "Start", + "endLabel": "Ende", + "toast": { + "error": "Fehler beim Starten der Debug-Wiedergabe: {{error}}", + "alreadyActive": "Eine Wiederholungssitzung ist bereits aktiv", + "stopError": "Die Wiedergabe der Debug-Daten konnte nicht beendet werden: {{error}}", + "goToReplay": "Zur Aufzeichnung" + } + }, + "page": { + "noSession": "Keine aktive Debug-Wiedergabesitzung", + "noSessionDesc": "Starten Sie eine Debug-Wiedergabe aus der Verlaufsansicht, indem Sie in der Symbolleiste auf die Schaltfläche „Aktionen“ klicken und „Debug-Wiedergabe“ auswählen.", + "goToRecordings": "Zur Historie", + "preparingClip": "Clip wird vorbereitet…", + "preparingClipDesc": "Frigate fasst die Aufzeichnungen für den ausgewählten Zeitraum zusammen. Bei längeren Zeiträumen kann dies eine Minute dauern.", + "startingCamera": "Debug-Wiedergabe wird gestartet…", + "startError": { + "title": "Debug Replay konnte nicht gestartet werden", + "back": "Zurück zur Übersicht" + }, + "sourceCamera": "Quell Kamera", + "replayCamera": "Wiederholungskamera", + "initializingReplay": "Debug-Wiedergabe wird initialisiert...", + "stoppingReplay": "Debug-Wiedergabe wird angehalten...", + "stopReplay": "Stopp Wiederholung", + "confirmStop": { + "title": "Debug-Wiedergabe anhalten?", + "description": "Dadurch wird die Sitzung beendet und alle temporären Daten werden gelöscht. Sind Sie sicher?", + "confirm": "Anhalten Wiederholen", + "cancel": "Abbrechen" + }, + "activity": "Aktivität", + "objects": "Objektliste", + "audioDetections": "Audioerkennungen", + "noActivity": "Es wurde keine Aktivität festgestellt", + "activeTracking": "Aktive Verfolgung", + "noActiveTracking": "Keine aktive Nachverfolgung", + "configuration": "Konfiguration", + "configurationDesc": "Passen Sie die Einstellungen für die Bewegungserkennung und die Objektverfolgung der Debug-Replay-Kamera an. Es werden keine Änderungen in Ihrer Frigate-Konfigurationsdatei gespeichert." + } +} diff --git a/web/public/locales/de/views/settings.json b/web/public/locales/de/views/settings.json index 81606f16ee..6193333491 100644 --- a/web/public/locales/de/views/settings.json +++ b/web/public/locales/de/views/settings.json @@ -45,7 +45,7 @@ "globalMotion": "Bewegungserkennung", "globalObjects": "Objekte", "globalReview": "Überprüfung", - "globalAudioEvents": "Audio Events", + "globalAudioEvents": "Audioerkennung", "globalLivePlayback": "Live-Wiedergabe", "globalTimestampStyle": "Zeitstempelformat", "systemDatabase": "Datenbank", @@ -75,7 +75,7 @@ "cameraMotion": "Bewegungserkennung", "cameraObjects": "Objekte", "cameraConfigReview": "Überprüfung", - "cameraAudioEvents": "Audio Evente", + "cameraAudioEvents": "Audioerkennung", "cameraAudioTranscription": "Audio-Transkription", "cameraNotifications": "Benachrichtigung", "cameraLivePlayback": "Live-Wiedergabe", @@ -345,6 +345,10 @@ "zone": "Zone", "motion_mask": "Bewegungsmaske", "object_mask": "Objektmaske" + }, + "revertOverride": { + "title": "Auf Standardkonfiguration zurücksetzen", + "desc": "Dadurch wird die Profilüberschreibung für {{type}}{{name}} aufgehoben und die Grundkonfiguration wiederhergestellt." } }, "speed": { @@ -507,7 +511,8 @@ "title": "Aktiviert", "description": "Ob diese Maske in der Konfigurationsdatei aktiviert ist. Ist sie deaktiviert, kann sie nicht über MQTT aktiviert werden. Deaktivierte Masken werden zur Laufzeit ignoriert." } - } + }, + "addDisabledProfile": "Fügen Sie es zuerst der Basiskonfiguration hinzu und überschreiben Sie es dann im Profil" }, "debug": { "objectShapeFilterDrawing": { @@ -1328,7 +1333,8 @@ }, "hikvision": { "substreamWarning": "Substream 1 ist auf eine niedrige Auflösung festgelegt. Viele Hikvision-Kameras unterstützen zusätzliche Substreams, die in den Kameraeinstellungen aktiviert werden müssen. Es wird empfohlen, diese Streams zu überprüfen und zu nutzen, sofern sie verfügbar sind." - } + }, + "resolutionUnknown": "Die Auflösung dieses Streams konnte nicht ermittelt werden. Sie sollten die Erkennungsauflösung manuell in den Einstellungen oder in Ihrer Konfiguration festlegen." } } }, @@ -1345,7 +1351,13 @@ "enableDesc": "Eine aktivierte Kamera vorübergehend deaktivieren, bis Frigate neu gestartet wird. Durch das Deaktivieren einer Kamera wird die Verarbeitung der Streams dieser Kamera durch Frigate vollständig unterbrochen. Erkennung, Aufzeichnung und Fehlerbehebung stehen dann nicht mehr zur Verfügung.
    Hinweis: go2rtc-Restreams werden dadurch nicht deaktiviert.", "disableLabel": "Deaktivierte Kameras", "disableDesc": "Aktivieren Sie eine Kamera, die derzeit in der Benutzeroberfläche nicht sichtbar und in der Konfiguration deaktiviert ist. Nach der Aktivierung ist ein Neustart von Frigate erforderlich.", - "enableSuccess": "{{cameraName}} wurde in der Konfiguration aktiviert. Starte Frigate neu, um die Änderungen zu übernehmen." + "enableSuccess": "{{cameraName}} wurde in der Konfiguration aktiviert. Starte Frigate neu, um die Änderungen zu übernehmen.", + "friendlyName": { + "edit": "Anzeigenamen der Kamera bearbeiten", + "title": "Anzeigenamen bearbeiten", + "description": "Legen Sie den Anzeigenamen fest, der für diese Kamera in der gesamten Benutzeroberfläche von „Frigate“ angezeigt wird. Lassen Sie das Feld leer, um die Kamera-ID zu verwenden.", + "rename": "Umbenennen" + } }, "cameraConfig": { "add": "Kamera hinzufügen", @@ -1395,6 +1407,14 @@ "inherit": "Erben", "enabled": "Aktiviert", "disabled": "Deaktiviert" + }, + "cameraType": { + "title": "Kamerytyp", + "label": "Kameratyp", + "description": "Legen Sie den Kameratyp für jede Kamera fest. Spezielle LPR-Kameras sind Kameras mit leistungsstarkem optischen Zoom, um Kennzeichen von weit entfernten Fahrzeugen zu erfassen. Für die meisten Kameras sollte der normale Kameratyp verwendet werden, es sei denn, die Kamera ist speziell für LPR vorgesehen und verfügt über einen stark fokussierten Blickwinkel auf die Kennzeichen.", + "normal": "Normal", + "dedicatedLpr": "Spezielles LPR-System", + "saveSuccess": "Der Kameratyp für {{cameraName}} wurde aktualisiert. Starte Frigate neu, um die Änderungen zu übernehmen." } }, "cameraReview": { @@ -1458,7 +1478,18 @@ "overriddenGlobalTooltip": "Diese Kamera überschreibt globale Konfigurationseinstellungen in diesem Abschnitt", "overriddenBaseConfig": "Überschrieben (Basiskonfiguration)", "overriddenBaseConfigTooltip": "Das {{profile}}-Profil überschreibt Konfigurationseinstellungen in diesem Abschnitt", - "overriddenGlobal": "Überschrieben (Global)" + "overriddenGlobal": "Überschrieben (Global)", + "overriddenInCameras": { + "label_one": "In {{count}} Kamera überschrieben", + "label_other": "In {{count}} Kameras überschrieben", + "tooltip_one": "Die Kamera mit der Nummer {{count}} überschreibt die Werte in diesem Abschnitt. Klicken Sie hier, um Details anzuzeigen.", + "tooltip_other": "Die Kamera mit der Nummer {{count}} überschreibt die Werte in diesem Abschnitt. Klicken Sie hier, um Details anzuzeigen.", + "heading_one": "Dieser globale Abschnitt enthält Felder, die in {{count}} Kamera überschrieben werden.", + "heading_other": "Dieser globale Abschnitt enthält Felder, die bei {{count}} Kameras überschrieben werden.", + "othersField_one": "{{count}} andere", + "othersField_other": "{{count}} weitere", + "profilePrefix": "{{profile}} Profile: {{fields}}" + } }, "timestampPosition": { "tl": "Oben links", @@ -1486,7 +1517,7 @@ "currentStatus": "Status", "jobId": "Job ID", "startTime": "Startzeit", - "endTime": "Endzeit", + "endTime": "End Zeit", "statusLabel": "Status", "results": "Ergebnisse", "errorLabel": "Fehler", @@ -1647,7 +1678,8 @@ "keyDuplicate": "Der Name des Detektors ist bereits vorhanden.", "noSchema": "Es sind keine Detektorschemata verfügbar.", "none": "Es sind keine Detektorinstanzen konfiguriert.", - "add": "Detektor hinzufügen" + "add": "Detektor hinzufügen", + "addCustomKey": "Benutzter Schlüssel hinzufügen" }, "record": { "title": "Aufnahmeeinstellungen" @@ -1718,7 +1750,16 @@ "title": "Einstellungen für Zeitstempel" }, "searchPlaceholder": "Suche...", - "addCustomLabel": "Benutzerdefiniertes Etikett hinzufügen..." + "addCustomLabel": "Benutzerdefiniertes Etikett hinzufügen...", + "knownPlates": { + "namePlaceholder": "z.B. das Auto der Frau", + "platePlaceholder": "Kennzeichen oder regulärer Ausdruck" + }, + "genaiModel": { + "placeholder": "Modell auswählen…", + "search": "Modell suchen…", + "noModels": "Keine Modelle verfügbar" + } }, "globalConfig": { "title": "Globale Konfiguration", @@ -1849,7 +1890,8 @@ "review": { "recordDisabled": "Aufnahme ist deaktiviert, Überprüfungspunkt konnte nicht erstellt werden.", "detectDisabled": "Die Objekterkennung ist deaktiviert. Für die Überprüfung von Elementen müssen Objekte erkannt werden, um Warnmeldungen und Erkennungen zu kategorisieren.", - "allNonAlertDetections": "Alle Aktivitäten, die keine Warnmeldungen auslösen, werden als Erkennungen erfasst." + "allNonAlertDetections": "Alle Aktivitäten, die keine Warnmeldungen auslösen, werden als Erkennungen erfasst.", + "genaiImageSourceRecordingsRecordDisabled": "Als Bildquelle ist „Aufnahmen“ eingestellt, die Aufnahmefunktion ist jedoch deaktiviert. Frigate greift in diesem Fall auf Vorschaubilder zurück." }, "audio": { "noAudioRole": "Für keinen Stream ist die Audio-Rolle definiert. Sie müssen die Audio-Rolle aktivieren, damit die Audioerkennung funktioniert." @@ -1858,15 +1900,18 @@ "audioDetectionDisabled": "Die Audioerkennung ist für diese Kamera nicht aktiviert. Für die Audio-Transkription muss die Audioerkennung aktiviert sein." }, "detect": { - "fpsGreaterThanFive": "Es wird nicht empfohlen, den Wert für die FPS-Erkennung auf mehr als 5 einzustellen." + "fpsGreaterThanFive": "Es wird nicht empfohlen, den Wert für die FPS-Erkennung auf mehr als 5 zu setzen. Höhere Werte können zu Leistungseinbußen führen und bieten keinerlei Vorteile.", + "disabled": "Die Objekterkennung ist deaktiviert. Momentaufnahmen, Überprüfungselemente und Erweiterungsfunktionen wie Gesichtserkennung, Kennzeichenerkennung und generative KI funktionieren nicht." }, "faceRecognition": { - "globalDisabled": "Die Gesichtserkennung ist auf globaler Ebene nicht aktiviert. Aktivieren Sie sie in den globalen Einstellungen, damit die Gesichtserkennung auf Kameraebene funktioniert.", - "personNotTracked": "Für die Gesichtserkennung muss das Objekt „person“ verfolgt werden. Stellen Sie sicher, dass „person“ in der Objektverfolgungsliste enthalten ist." + "globalDisabled": "Die Gesichtserkennungserweiterung muss aktiviert sein, damit die Gesichtserkennungsfunktionen bei dieser Kamera funktionieren.", + "personNotTracked": "Für die Gesichtserkennung muss das Objekt „Person“ verfolgt werden. Aktivieren Sie „Person“ unter „Objekte“ für diese Kamera.", + "modelSizeLarge": "Das „große“ Modell erfordert eine GPU oder NPU, um eine angemessene Leistung zu erzielen. Verwenden Sie auf reinen CPU-Systemen die Option „klein“." }, "lpr": { - "globalDisabled": "Die Kennzeichenerkennung ist auf globaler Ebene nicht aktiviert. Aktivieren Sie sie in den globalen Einstellungen, damit die Kennzeichenerkennung auf Kameraebene funktioniert.", - "vehicleNotTracked": "Für die Kennzeichenerkennung muss entweder ein „Pkw“ oder ein „Motorrad“ erfasst werden." + "globalDisabled": "Die Erweiterung zur Kennzeichenerkennung muss aktiviert sein, damit die LPR-Funktionen bei dieser Kamera funktionieren.", + "vehicleNotTracked": "Für die Kennzeichenerkennung muss entweder „Auto“ oder „Motorrad“ erfasst werden. Aktivieren Sie „Auto“ oder „Motorrad“ unter „Objekte“ für diese Kamera.", + "modelSizeLarge": "Das „große“ Modell ist für mehrzeilige Kennzeichen optimiert. Das „kleine“ Modell bietet eine bessere Leistung als das „große“ und sollte verwendet werden, sofern in Ihrer Region keine mehrzeiligen Kennzeichenformate verwendet werden." }, "record": { "noRecordRole": "Für keinen Stream ist die Rolle „Record“ definiert. Die Aufzeichnung funktioniert nicht." @@ -1876,6 +1921,16 @@ }, "snapshots": { "detectDisabled": "Die Objekterkennung ist deaktiviert. Es werden keine Momentaufnahmen von verfolgten Objekten erstellt." + }, + "detectors": { + "mixedTypes": "Alle Detektoren müssen von gleichem Typ sein, Entferne bestehende Detektoren um einen anderen Typ zu benutzen.", + "mixedTypesSuggestion": "Alle Detektoren müssen vom gleichem Typ sein. Entferne bestehende oder wähle {{type}}." + }, + "objects": { + "genaiNoDescriptionsProvider": "Sie müssen einen GenAI-Anbieter mit der Rolle „Beschreibung“ konfigurieren, damit Beschreibungen generiert werden können." + }, + "semanticSearch": { + "jinav2SmallModelSize": "Die „kleine“ Variante des Jina V2-Modells verursacht hohe RAM- und Inferenzkosten. Es wird das „große“ Modell mit einer dedizierten GPU empfohlen." } } } diff --git a/web/public/locales/de/views/system.json b/web/public/locales/de/views/system.json index 3b41b03b7f..20d5cc1fa4 100644 --- a/web/public/locales/de/views/system.json +++ b/web/public/locales/de/views/system.json @@ -213,6 +213,9 @@ "expectedFps": "Erwartete FPS", "reconnectsLastHour": "Wiederverbindungen (letzte Stunde)", "stallsLastHour": "Stände (letzte Stunde)" + }, + "noCameras": { + "title": "keine Kameras gefunden" } }, "enrichments": { diff --git a/web/public/locales/el/config/cameras.json b/web/public/locales/el/config/cameras.json index 0967ef424b..b9d465ec92 100644 --- a/web/public/locales/el/config/cameras.json +++ b/web/public/locales/el/config/cameras.json @@ -1 +1,3 @@ -{} +{ + "label": "Ρύθμιση της κάμερας" +} diff --git a/web/public/locales/el/config/global.json b/web/public/locales/el/config/global.json index 0967ef424b..5b60515cb1 100644 --- a/web/public/locales/el/config/global.json +++ b/web/public/locales/el/config/global.json @@ -1 +1,14 @@ -{} +{ + "version": { + "label": "Τρέχουσα έκδοση διαμόρφωσης" + }, + "safe_mode": { + "label": "Ασφαλής λειτουργία" + }, + "auth": { + "reset_admin_password": { + "label": "Επανέφερε κωδικού πρόσβασης για τον διαχειριστή admin", + "description": "Άμα είναι αλήθεια, επαναφέρει τον κωδικό πρόσβασης του χρήστη διαχειριστή(admin) κατά την εκκίνηση και εκτύπωση του νέου κωδικού πρόσβασης στα αρχείο καταγραφής(logs)" + } + } +} diff --git a/web/public/locales/el/config/groups.json b/web/public/locales/el/config/groups.json index 0967ef424b..23a118f019 100644 --- a/web/public/locales/el/config/groups.json +++ b/web/public/locales/el/config/groups.json @@ -1 +1,7 @@ -{} +{ + "audio": { + "global": { + "detection": "Παγκόσμια Ανίχνευση" + } + } +} diff --git a/web/public/locales/el/config/validation.json b/web/public/locales/el/config/validation.json index 0967ef424b..d99e52717b 100644 --- a/web/public/locales/el/config/validation.json +++ b/web/public/locales/el/config/validation.json @@ -1 +1,3 @@ -{} +{ + "minimum": "Πρέπει να είναι τουλάχιστον {{limit}}" +} diff --git a/web/public/locales/el/views/chat.json b/web/public/locales/el/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/el/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/el/views/motionSearch.json b/web/public/locales/el/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/el/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/el/views/replay.json b/web/public/locales/el/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/el/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 90fd7d53b4..2e16f84f1c 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -208,6 +208,7 @@ "th": "ไทย (Thai)", "ca": "Català (Catalan)", "hr": "Hrvatski (Croatian)", + "bs": "Bosanski (Bosnian)", "sr": "Српски (Serbian)", "sl": "Slovenščina (Slovenian)", "lt": "Lietuvių (Lithuanian)", @@ -258,6 +259,7 @@ "export": "Export", "actions": "Actions", "uiPlayground": "UI Playground", + "features": "Features", "faceLibrary": "Face Library", "classification": "Classification", "chat": "Chat", diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index 1b524c347d..f645dd33a1 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -13,7 +13,7 @@ "description": "Enabled" }, "audio": { - "label": "Audio events", + "label": "Audio detection", "description": "Settings for audio-based event detection for this camera.", "enabled": { "label": "Enable audio detection", @@ -33,7 +33,11 @@ }, "filters": { "label": "Audio filters", - "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives." + "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", + "threshold": { + "label": "Minimum audio confidence", + "description": "Minimum confidence threshold for the audio event to be counted." + } }, "enabled_in_config": { "label": "Original audio state", @@ -485,6 +489,10 @@ "hwaccel_args": { "label": "Export hwaccel args", "description": "Hardware acceleration args to use for export/transcode operations." + }, + "max_concurrent": { + "label": "Maximum concurrent exports", + "description": "Maximum number of export jobs to process at the same time." } }, "preview": { @@ -942,4 +950,4 @@ "label": "Original camera state", "description": "Keep track of original state of camera." } -} +} \ No newline at end of file diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 69c77fad11..b10f0a7afc 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -242,8 +242,8 @@ "description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities)." }, "intel_gpu_device": { - "label": "SR-IOV device", - "description": "Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats." + "label": "Intel GPU device", + "description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present." } }, "version_check": { @@ -539,7 +539,7 @@ } }, "audio": { - "label": "Audio events", + "label": "Audio detection", "description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.", "enabled": { "label": "Enable audio detection", @@ -559,7 +559,11 @@ }, "filters": { "label": "Audio filters", - "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives." + "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", + "threshold": { + "label": "Minimum audio confidence", + "description": "Minimum confidence threshold for the audio event to be counted." + } }, "enabled_in_config": { "label": "Original audio state", @@ -1000,6 +1004,10 @@ "hwaccel_args": { "label": "Export hwaccel args", "description": "Hardware acceleration args to use for export/transcode operations." + }, + "max_concurrent": { + "label": "Maximum concurrent exports", + "description": "Maximum number of export jobs to process at the same time." } }, "preview": { @@ -1589,4 +1597,4 @@ "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication." } } -} +} \ No newline at end of file diff --git a/web/public/locales/en/objects.json b/web/public/locales/en/objects.json index 1315104be4..d48f609a13 100644 --- a/web/public/locales/en/objects.json +++ b/web/public/locales/en/objects.json @@ -121,5 +121,9 @@ "royal_mail": "Royal Mail", "school_bus": "School Bus", "skunk": "Skunk", - "kangaroo": "Kangaroo" + "kangaroo": "Kangaroo", + "baby": "Baby", + "baby_stroller": "Baby Stroller", + "rickshaw": "Rickshaw", + "rodent": "Rodent" } diff --git a/web/public/locales/en/views/chat.json b/web/public/locales/en/views/chat.json index 6d78dc71f8..bc320c2049 100644 --- a/web/public/locales/en/views/chat.json +++ b/web/public/locales/en/views/chat.json @@ -42,5 +42,23 @@ "show_camera_status": "What is the current status of my cameras?", "recap": "What happened while I was away?", "watch_camera": "Watch the front door and let me know if anyone shows up" + }, + "new_chat": "New chat", + "settings": { + "title": "Chat settings", + "show_stats": { + "title": "Show stats", + "desc": "Show generation rate and context size for chat responses.", + "while_generating": "While generating", + "always": "Always" + }, + "auto_scroll": { + "title": "Auto-scroll", + "desc": "Follow new messages as they arrive." + } + }, + "stats": { + "context": "{{tokens}} tokens", + "tokens_per_second": "{{rate}} t/s" } } diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index 4f8a9cf2da..27e5454601 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -32,7 +32,11 @@ "title": "Recent Recognitions", "titleShort": "Recent", "aria": "Select recent recognitions", - "empty": "There are no recent face recognition attempts" + "empty": "There are no recent face recognition attempts", + "emptyNoLibrary": { + "title": "Upload a face", + "description": "You must add at least one face to the library for face recognition to function." + } }, "deleteFaceLibrary": { "title": "Delete Name", diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index 37e6b15dbb..3aa892222a 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -70,7 +70,8 @@ }, "recording": { "enable": "Enable Recording", - "disable": "Disable Recording" + "disable": "Disable Recording", + "disabledInConfig": "Recording must first be enabled in Settings for this camera." }, "snapshots": { "enable": "Enable Snapshots", diff --git a/web/public/locales/en/views/replay.json b/web/public/locales/en/views/replay.json index a966626f5f..e8f50d7b7e 100644 --- a/web/public/locales/en/views/replay.json +++ b/web/public/locales/en/views/replay.json @@ -19,26 +19,31 @@ "startLabel": "Start", "endLabel": "End", "toast": { - "success": "Debug replay started successfully", "error": "Failed to start debug replay: {{error}}", "alreadyActive": "A replay session is already active", - "stopped": "Debug replay stopped", "stopError": "Failed to stop debug replay: {{error}}", "goToReplay": "Go to Replay" } }, "page": { - "noSession": "No Active Replay Session", - "noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.", + "noSession": "No Active Debug Replay Session", + "noSessionDesc": "Start a Debug Replay from History view by clicking the Actions button in the toolbar and choosing Debug Replay.", "goToRecordings": "Go to History", + "preparingClip": "Preparing clip…", + "preparingClipDesc": "Frigate is stitching together recordings for the selected time range. This can take a minute for longer ranges.", + "startingCamera": "Starting Debug Replay…", + "startError": { + "title": "Failed to start Debug Replay", + "back": "Back to History" + }, "sourceCamera": "Source Camera", "replayCamera": "Replay Camera", - "initializingReplay": "Initializing replay...", - "stoppingReplay": "Stopping replay...", + "initializingReplay": "Initializing Debug Replay...", + "stoppingReplay": "Stopping Debug Replay...", "stopReplay": "Stop Replay", "confirmStop": { "title": "Stop Debug Replay?", - "description": "This will stop the replay session and clean up all temporary data. Are you sure?", + "description": "This will stop the session and clean up all temporary data. Are you sure?", "confirm": "Stop Replay", "cancel": "Cancel" }, @@ -49,6 +54,6 @@ "activeTracking": "Active tracking", "noActiveTracking": "No active tracking", "configuration": "Configuration", - "configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file." + "configurationDesc": "Fine tune motion detection and object tracking settings for the Debug Replay camera. No changes are saved to your Frigate configuration file." } } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index d1ddbc1500..8363cd13c3 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -19,8 +19,25 @@ "button": { "overriddenGlobal": "Overridden (Global)", "overriddenGlobalTooltip": "This camera overrides global configuration settings in this section", + "overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:", + "overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:", + "overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.", "overriddenBaseConfig": "Overridden (Base Config)", - "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section" + "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section", + "overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:", + "overriddenBaseConfigHeading_other": "The {{profile}} profile overrides {{count}} fields from the base config:", + "overriddenBaseConfigNoDeltas": "The {{profile}} profile overrides this section, but no field values differ from the base config.", + "overriddenInCameras": { + "label_one": "Overridden in {{count}} camera", + "label_other": "Overridden in {{count}} cameras", + "tooltip_one": "{{count}} camera overrides values in this section. Click to see details.", + "tooltip_other": "{{count}} cameras override values in this section. Click to see details.", + "heading_one": "This global section has fields that are overridden in {{count}} camera.", + "heading_other": "This global section has fields that are overridden in {{count}} cameras.", + "othersField_one": "{{count}} other", + "othersField_other": "{{count}} others", + "profilePrefix": "{{profile}} profile: {{fields}}" + } }, "menu": { "general": "General", @@ -38,7 +55,7 @@ "globalMotion": "Motion detection", "globalObjects": "Objects", "globalReview": "Review", - "globalAudioEvents": "Audio events", + "globalAudioEvents": "Audio detection", "globalLivePlayback": "Live playback", "globalTimestampStyle": "Timestamp style", "systemDatabase": "Database", @@ -69,7 +86,7 @@ "cameraMotion": "Motion detection", "cameraObjects": "Objects", "cameraConfigReview": "Review", - "cameraAudioEvents": "Audio events", + "cameraAudioEvents": "Audio detection", "cameraAudioTranscription": "Audio transcription", "cameraNotifications": "Notifications", "cameraLivePlayback": "Live playback", @@ -415,6 +432,7 @@ "audioCodecGood": "Audio codec is {{codec}}.", "resolutionHigh": "A resolution of {{resolution}} may cause increased resource usage.", "resolutionLow": "A resolution of {{resolution}} may be too low for reliable detection of small objects.", + "resolutionUnknown": "The resolution of this stream could not be probed. You should manually set the detect resolution in Settings or your config.", "noAudioWarning": "No audio detected for this stream, recordings will not have audio.", "audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.", "audioCodecRequired": "An audio stream is required to support audio detection.", @@ -434,6 +452,7 @@ }, "cameraManagement": { "title": "Manage Cameras", + "description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.", "addCamera": "Add New Camera", "deleteCamera": "Delete Camera", "deleteCameraDialog": { @@ -456,7 +475,13 @@ "enableDesc": "Temporarily disable an enabled 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.", "disableLabel": "Disabled cameras", "disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.", - "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes." + "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.", + "friendlyName": { + "edit": "Edit camera display name", + "title": "Edit Display Name", + "description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", + "rename": "Rename" + } }, "cameraConfig": { "add": "Add Camera", @@ -494,6 +519,14 @@ "inherit": "Inherit", "enabled": "Enabled", "disabled": "Disabled" + }, + "cameraType": { + "title": "Camera Type", + "label": "Camera type", + "description": "Set the type for each camera. Dedicated LPR cameras are single-purpose cameras with powerful optical zoom to capture license plates on distant vehicles. Most cameras should use the normal camera type unless the camera is specifically for LPR and has a tightly focused view on license plates.", + "normal": "Normal", + "dedicatedLpr": "Dedicated LPR", + "saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes." } }, "cameraReview": { @@ -1114,8 +1147,16 @@ "cameras": "Cameras", "loading": "Loading model information…", "error": "Failed to load model information", + "noModelLoaded": "No Frigate+ model is currently loaded.", "availableModels": "Available Models", "loadingAvailableModels": "Loading available models…", + "selectModel": "Select a model", + "noModelsAvailable": "No models available", + "filter": { + "ariaLabel": "Filter models by type", + "baseModels": "Base Models", + "fineTunedModels": "Fine-tuned Models" + }, "modelSelect": "Your available models on Frigate+ can be selected here. Note that only models compatible with your current detector configuration can be selected." }, "unsavedChanges": "Unsaved Frigate+ settings changes", @@ -1477,8 +1518,8 @@ "genaiRoles": { "options": { "embeddings": "Embedding", - "vision": "Vision", - "tools": "Tools" + "descriptions": "Descriptions", + "chat": "Chat" } }, "semanticSearchModel": { @@ -1630,15 +1671,78 @@ "hardwareAuto": "Automatic hardware acceleration" } }, + "birdseye": { + "trackingMode": { + "objects": "Objects", + "motion": "Motion", + "continuous": "Continuous" + } + }, + "retainMode": { + "all": "All", + "motion": "Motion", + "active_objects": "Active Objects" + }, + "previewQuality": { + "very_high": "Very High", + "high": "High", + "medium": "Medium", + "low": "Low", + "very_low": "Very Low" + }, + "ui": { + "timeFormat": { + "browser": "Browser", + "12hour": "12 hour", + "24hour": "24 hour" + }, + "TimeOrDateStyle": { + "full": "Full", + "long": "Long", + "medium": "Medium", + "short": "Short" + }, + "unitSystem": { + "metric": "Metric", + "imperial": "Imperial" + } + }, + "review": { + "imageSource": { + "recordings": "Recordings", + "previews": "Previews" + } + }, + "logger": { + "logLevel": { + "debug": "Debug", + "info": "Info", + "warning": "Warning", + "error": "Error", + "critical": "Critical" + } + }, "onvif": { "profileAuto": "Auto", - "profileLoading": "Loading profiles..." + "profileLoading": "Loading profiles...", + "autotracking": { + "zooming": { + "disabled": "Disabled", + "absolute": "Absolute", + "relative": "Relative" + } + } + }, + "modelSize": { + "small": "Small", + "large": "Large" }, "configMessages": { "review": { "recordDisabled": "Recording is disabled, review items will not be generated.", "detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.", - "allNonAlertDetections": "All non-alert activity will be included as detections." + "allNonAlertDetections": "All non-alert activity will be included as detections.", + "genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images." }, "audio": { "noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function." @@ -1647,15 +1751,21 @@ "audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active." }, "detect": { - "fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended." + "fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.", + "disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function." + }, + "objects": { + "genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated." }, "faceRecognition": { - "globalDisabled": "Face recognition is not enabled at the global level. Enable it in global settings for camera-level face recognition to function.", - "personNotTracked": "Face recognition requires the 'person' object to be tracked. Ensure 'person' is in the object tracking list." + "globalDisabled": "The face recognition enrichment must be enabled for face recognition features to function on this camera.", + "personNotTracked": "Face recognition requires the 'person' object to be tracked. Enable 'person' in Objects for this camera.", + "modelSizeLarge": "The 'large' model requires a GPU or NPU for reasonable performance. Use 'small' on CPU-only systems." }, "lpr": { - "globalDisabled": "License plate recognition is not enabled at the global level. Enable it in global settings for camera-level LPR to function.", - "vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked." + "globalDisabled": "The license plate recognition enrichment must be enabled for LPR features to function on this camera.", + "vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked. Enable 'car' or 'motorcycle' in Objects for this camera.", + "modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats." }, "record": { "noRecordRole": "No streams have the record role defined. Recording will not function." @@ -1669,6 +1779,9 @@ "detectors": { "mixedTypes": "All detectors must use the same type. Remove existing detectors to use a different type.", "mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}." + }, + "semanticSearch": { + "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended." } } } diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 6c3f37f71a..b824e0749c 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -177,6 +177,9 @@ } }, "framesAndDetections": "Frames / Detections", + "noCameras": { + "title": "No Cameras Found" + }, "label": { "camera": "camera", "detect": "detect", diff --git a/web/public/locales/es/common.json b/web/public/locales/es/common.json index 49e06c508b..8faa18fe75 100644 --- a/web/public/locales/es/common.json +++ b/web/public/locales/es/common.json @@ -195,7 +195,8 @@ "explore": "Explorar", "uiPlayground": "Zona de pruebas de la interfaz de usuario", "faceLibrary": "Biblioteca de rostros", - "classification": "Clasificación" + "classification": "Clasificación", + "profiles": "Perfiles" }, "unit": { "speed": { diff --git a/web/public/locales/es/components/dialog.json b/web/public/locales/es/components/dialog.json index e8f59f05a0..848285dafa 100644 --- a/web/public/locales/es/components/dialog.json +++ b/web/public/locales/es/components/dialog.json @@ -77,7 +77,11 @@ "saveExport": "Guardar exportación", "previewExport": "Vista previa de la exportación" }, - "selectOrExport": "Seleccionar o exportar" + "selectOrExport": "Seleccionar o exportar", + "case": { + "label": "Caso", + "newCaseDescriptionPlaceholder": "Descripción de caso" + } }, "streaming": { "restreaming": { @@ -124,6 +128,9 @@ "markAsReviewed": "Marcar como revisado", "deleteNow": "Eliminar ahora", "markAsUnreviewed": "Marcar como no revisado" + }, + "shareTimestamp": { + "description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara." } }, "imagePicker": { diff --git a/web/public/locales/es/components/player.json b/web/public/locales/es/components/player.json index 2a3e4deb1f..c277d9a5a2 100644 --- a/web/public/locales/es/components/player.json +++ b/web/public/locales/es/components/player.json @@ -3,7 +3,8 @@ "noPreviewFoundFor": "No se encontró vista previa para {{cameraName}}", "submitFrigatePlus": { "submit": "Enviar", - "title": "¿Enviar este fotograma a Frigate+?" + "title": "¿Enviar este fotograma a Frigate+?", + "previewError": "No se pudo cargar la vista previa de la instantánea. Es posible que la grabación no esté disponible en este momento." }, "streamOffline": { "desc": "No se han recibido fotogramas en la transmisión detect de {{cameraName}}, revisa los registros de errores", diff --git a/web/public/locales/es/config/cameras.json b/web/public/locales/es/config/cameras.json index aeb6083714..d6a120fdfd 100644 --- a/web/public/locales/es/config/cameras.json +++ b/web/public/locales/es/config/cameras.json @@ -42,10 +42,32 @@ "label": "Nombre descriptivo", "description": "Nombre descriptivo de la cámara utilizado en la interfaz de usuario de Frigate" }, - "label": "Configuración de Cámara", + "label": "Configuración de cámara", "onvif": { "profile": { - "label": "Perfil ONVIF" + "label": "Perfil ONVIF", + "description": "Perfil multimedia ONVIF específico que se utilizará para el control PTZ, identificado por token o nombre. Si no se especifica, se selecciona automáticamente el primer perfil con una configuración PTZ válida." + }, + "autotracking": { + "zoom_factor": { + "description": "Controla el nivel de zoom en los objetos rastreados. Los valores más bajos mantienen una mayor parte de la escena a la vista; los valores más altos acercan la imagen, pero pueden provocar la pérdida del rastreo. Valores entre 0.1 y 0.75." + }, + "calibrate_on_startup": { + "description": "Mida la velocidad de los motores PTZ al encenderlos para mejorar la precisión del seguimiento. Frigate actualizará la configuración con los `movement_weights` tras la calibración." + }, + "description": "Realice un seguimiento automático de objetos en movimiento y manténgalos centrados en el encuadre mediante movimientos de cámara PTZ.", + "zooming": { + "description": "Control del comportamiento del zoom: deshabilitado (solo panorámica/inclinación), absoluto (mayor compatibilidad) o relativo (panorámica/inclinación/zoom simultáneos)." + }, + "return_preset": { + "description": "Nombre del preajuste ONVIF configurado en el firmware de la cámara al que regresar una vez finalizado el seguimiento." + }, + "timeout": { + "description": "Espere esta cantidad de segundos después de perder el seguimiento antes de devolver la cámara a la posición preestablecida." + } + }, + "tls_insecure": { + "description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras)." } }, "zones": { @@ -83,7 +105,24 @@ "max_area": { "description": "Área máxima del cuadro delimitador (píxeles o porcentaje) permitida para este tipo de objeto. Puede expresarse en píxeles (entero) o como porcentaje (decimal entre 0,000001 y 0,99).", "label": "Área máxima del objeto" - } + }, + "description": "Filtros para aplicar a los objetos dentro de esta zona. Se utilizan para reducir los falsos positivos o restringir qué objetos se consideran presentes en la zona." + }, + "objects": { + "description": "Lista de tipos de objetos (del mapa de etiquetas) que pueden activar esta zona. Puede ser una cadena de texto o una lista de cadenas. Si está vacío, se consideran todos los objetos." + }, + "description": "Las zonas le permiten definir un área específica del fotograma, de modo que pueda determinar si un objeto se encuentra o no dentro de un área determinada.", + "speed_threshold": { + "description": "Velocidad mínima (en unidades del mundo real, si se han configurado distancias) requerida para que un objeto se considere presente en la zona. Se utiliza para los disparadores de zona basados en la velocidad." + }, + "friendly_name": { + "description": "Un nombre fácil de usar para la zona, que se muestra en la interfaz de usuario de Frigate. Si no se especifica, se utilizará una versión formateada del nombre de la zona." + }, + "inertia": { + "description": "Número de fotogramas consecutivos en los que se debe detectar un objeto dentro de la zona antes de considerarlo presente. Ayuda a filtrar las detecciones transitorias." + }, + "loitering_time": { + "description": "Número de segundos que un objeto debe permanecer en la zona para ser considerado como merodeo. Establezca en 0 para desactivar la detección de merodeo." } }, "objects": { @@ -100,7 +139,151 @@ "use_snapshot": { "label": "Usar instantáneas", "description": "Usar instantáneas de objetos en lugar de miniaturas para la generación de descripciones de GenAI." + }, + "send_triggers": { + "after_significant_updates": { + "description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado." + }, + "description": "Define cuándo se deben enviar los fotogramas a GenAI (al finalizar, después de las actualizaciones, etc.)." + }, + "required_zones": { + "description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI." } } + }, + "mqtt": { + "label": "MQTT", + "required_zones": { + "description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT." + } + }, + "notifications": { + "email": { + "label": "Email de notificacion" + } + }, + "audio_transcription": { + "description": "Configuración para la transcripción de audio en vivo y de voz, utilizada para eventos y subtítulos en tiempo real.", + "enabled": { + "label": "Habilitar transcripción" + } + }, + "motion": { + "skip_motion_threshold": { + "description": "Si se establece en un valor entre 0,0 y 1,0, y más de esta fracción de la imagen cambia en un solo fotograma, el detector no devolverá cuadros de movimiento y se recalibrará inmediatamente. Esto puede ahorrar recursos de CPU y reducir los falsos positivos durante tormentas eléctricas, tempestades, etc., aunque podría pasar por alto eventos reales, como el seguimiento automático de un objeto por parte de una cámara PTZ. La disyuntiva está entre descartar unos cuantos megabytes de grabaciones o revisar un par de clips cortos. Deje este parámetro sin establecer (None) para desactivar esta función." + }, + "lightning_threshold": { + "description": "Umbral para detectar e ignorar breves picos de luz (un valor menor indica mayor sensibilidad; valores entre 0,3 y 1,0). Esto no impide por completo la detección de movimiento; Simplemente provoca que el detector deje de analizar fotogramas adicionales una vez que se supera el umbral. Durante estos eventos aún se realizan grabaciones basadas en el movimiento." + }, + "threshold": { + "description": "Umbral de diferencia de píxeles utilizado por el detector de movimiento; los valores más altos reducen la sensibilidad (rango 1-255)." + } + }, + "lpr": { + "enhancement": { + "description": "Nivel de mejora (0-10) que se aplicará a los recortes de matrículas antes del OCR; los valores más altos no siempre mejoran los resultados, y los niveles superiores a 5 podrían funcionar únicamente con matrículas capturadas de noche, por lo que deben utilizarse con precaución." + }, + "expire_time": { + "description": "Tiempo en segundos tras el cual una matrícula no detectada caduca en el sistema de seguimiento (solo para cámaras LPR dedicadas)." + } + }, + "detect": { + "fps": { + "description": "Fotogramas por segundo deseados para ejecutar la detección; los valores más bajos reducen el uso de la CPU (el valor recomendado es 5; establezca un valor superior —como máximo de 10— únicamente si realiza el seguimiento de objetos que se mueven con extrema rapidez)." + }, + "min_initialized": { + "description": "Número de detecciones consecutivas requeridas antes de crear un objeto rastreado. Auméntelo para reducir las inicializaciones falsas. El valor predeterminado es los FPS divididos por 2." + }, + "height": { + "description": "Altura (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión." + }, + "width": { + "description": "Ancho (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión." + }, + "stationary": { + "description": "Configuración para detectar y gestionar objetos que permanecen inmóviles durante un periodo de tiempo." + } + }, + "record": { + "motion": { + "description": "Número de días para conservar las grabaciones activadas por movimiento, independientemente de los objetos rastreados. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones." + }, + "continuous": { + "description": "Número de días para conservar las grabaciones, independientemente de los objetos rastreados o del movimiento. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones." + }, + "detections": { + "pre_capture": { + "description": "Número de segundos antes del evento de detección que se incluirán en la grabación." + }, + "post_capture": { + "description": "Número de segundos después del evento de detección que se incluirán en la grabación." + } + }, + "alerts": { + "pre_capture": { + "description": "Número de segundos antes del evento de detección que se incluirán en la grabación." + }, + "post_capture": { + "description": "Número de segundos después del evento de detección que se incluirán en la grabación." + } + } + }, + "ui": { + "dashboard": { + "description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz." + } + }, + "live": { + "height": { + "description": "Altura (en píxeles) para renderizar la transmisión en vivo de jsmpeg en la interfaz web; debe ser <= a la altura de la transmisión de detección." + }, + "description": "Configuraciones utilizadas por la interfaz web para controlar la selección, la resolución y la calidad de transmisiónes en vivo." + }, + "review": { + "description": "Configuraciones que controlan las alertas, las detecciones y los resúmenes de revisión de GenAI utilizados por la interfaz de usuario y el almacenamiento de esta cámara.", + "alerts": { + "required_zones": { + "description": "Zonas en las que debe entrar un objeto para ser considerado una alerta; dejar vacío para permitir cualquier zona." + }, + "labels": { + "description": "Lista de etiquetas de objetos que califican como alertas (por ejemplo: car, person)." + } + }, + "detections": { + "required_zones": { + "description": "Zonas en las que debe entrar un objeto para ser considerado detectado; dejar vacío para permitir cualquier zona." + }, + "description": "Configuración para determinar qué objetos rastreados generan detecciones (no alertas) y cómo se retienen dichas detecciones." + }, + "genai": { + "image_source": { + "description": "Fuente de las imágenes enviadas a GenAI ('preview' o 'recordings'); La opción 'recordings' utiliza fotogramas de mayor calidad, pero requiere más tokens." + }, + "additional_concerns": { + "description": "Una lista de preocupaciones o notas adicionales que GenAI debería tener en cuenta al evaluar la actividad en esta cámara." + }, + "activity_context_prompt": { + "description": "Instrucción personalizada que describe qué constituye y qué no una actividad sospechosa, con el fin de proporcionar contexto para los resúmenes generados por GenAI." + }, + "description": "Controla el uso de IA generativa (GenAI) para la elaboración de descripciones y resúmenes de elementos de revisión.", + "debug_save_thumbnails": { + "description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión." + } + } + }, + "birdseye": { + "description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista." + }, + "ffmpeg": { + "retry_interval": { + "description": "Segundos de espera antes de intentar reconectar la transmisión de una cámara tras un fallo. El valor predeterminado es 10." + }, + "path": { + "description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\")." + }, + "output_args": { + "description": "Argumentos de salida predeterminados utilizados para diferentes roles de FFmpeg, tales como detección y grabación." + }, + "description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol." } } diff --git a/web/public/locales/es/config/global.json b/web/public/locales/es/config/global.json index 53cdd0aa6e..1fc7df1ccd 100644 --- a/web/public/locales/es/config/global.json +++ b/web/public/locales/es/config/global.json @@ -70,11 +70,45 @@ "cookie_secure": { "label": "Flag de cookie segura", "description": "Establece el flag de seguridad en la cookie de autenticación; debe ser 'true' cuando se utilice TLS." + }, + "failed_login_rate_limit": { + "label": "Limite de intento de acceso fallidos" + }, + "session_length": { + "description": "Duración de la sesión en segundos para sesiones de JWT." + }, + "admin_first_time_login": { + "description": "Cuando se establece en true, la interfaz de usuario puede mostrar un enlace de ayuda en la página de inicio de sesión, informando a los usuarios sobre cómo iniciar sesión tras el restablecimiento de la contraseña de administrador. " + }, + "refresh_time": { + "description": "Cuando a una sesión le queden menos de esta cantidad de segundos para expirar, actualícela para restablecer su duración completa." } }, "onvif": { "profile": { - "label": "Perfil ONVIF" + "label": "Perfil ONVIF", + "description": "Perfil multimedia ONVIF específico que se utilizará para el control PTZ, identificado por token o nombre. Si no se especifica, se selecciona automáticamente el primer perfil con una configuración PTZ válida." + }, + "autotracking": { + "zoom_factor": { + "description": "Controla el nivel de zoom en los objetos rastreados. Los valores más bajos mantienen una mayor parte de la escena a la vista; los valores más altos acercan la imagen, pero pueden provocar la pérdida del rastreo. Valores entre 0.1 y 0.75." + }, + "calibrate_on_startup": { + "description": "Mida la velocidad de los motores PTZ al encenderlos para mejorar la precisión del seguimiento. Frigate actualizará la configuración con los `movement_weights` tras la calibración." + }, + "description": "Realice un seguimiento automático de objetos en movimiento y manténgalos centrados en el encuadre mediante movimientos de cámara PTZ.", + "zooming": { + "description": "Control del comportamiento del zoom: deshabilitado (solo panorámica/inclinación), absoluto (mayor compatibilidad) o relativo (panorámica/inclinación/zoom simultáneos)." + }, + "return_preset": { + "description": "Nombre del preajuste ONVIF configurado en el firmware de la cámara al que regresar una vez finalizado el seguimiento." + }, + "timeout": { + "description": "Espere esta cantidad de segundos después de perder el seguimiento antes de devolver la cámara a la posición preestablecida." + } + }, + "tls_insecure": { + "description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras)." } }, "objects": { @@ -91,7 +125,19 @@ "use_snapshot": { "label": "Usar instantáneas", "description": "Usar instantáneas de objetos en lugar de miniaturas para la generación de descripciones de GenAI." + }, + "send_triggers": { + "after_significant_updates": { + "description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado." + }, + "description": "Define cuándo se deben enviar los fotogramas a GenAI (al finalizar, después de las actualizaciones, etc.)." + }, + "required_zones": { + "description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI." } + }, + "track": { + "description": "Lista de etiquetas de objetos a rastrear para todas las cámaras; puede anularse por cámara." } }, "detectors": { @@ -107,6 +153,310 @@ "api_key": { "label": "Clave de API de DeepStack (si es necesaria)" } + }, + "type": { + "label": "Tipo" + }, + "label": "Detector de hardware", + "cpu": { + "label": "CPU", + "num_threads": { + "label": "Número de hilos para detección" + }, + "description": "Detector TFLite de CPU que ejecuta modelos de TensorFlow Lite en la CPU del host sin aceleración por hardware. No recomendado." + }, + "axengine": { + "label": "Motor AX NPU" + }, + "teflon_tfl": { + "description": "Detector de delegados Teflon para TFLite, que utiliza la biblioteca de delegados Mesa Teflon para acelerar la inferencia en las GPU compatibles." + }, + "synaptics": { + "description": "Detector NPU de Synaptics para modelos en formato .synap, utilizando el Synap SDK en hardware de Synaptics." + }, + "zmq": { + "description": "Detector ZMQ IPC que descarga la inferencia a un proceso externo a través de un punto de conexión IPC de ZeroMQ." + }, + "hailo8l": { + "description": "Detector Hailo-8/Hailo-8L que utiliza modelos HEF y el SDK HailoRT para la inferencia en hardware Hailo." + }, + "onnx": { + "description": "Detector ONNX para ejecutar modelos ONNX; utilizará los backends de aceleración disponibles (CUDA/ROCm/OpenVINO) cuando estén disponibles." + }, + "description": "Configuración para detectores de objetos (backends de CPU, GPU y ONNX) y cualquier ajuste del modelo específico del detector.", + "openvino": { + "description": "Detector OpenVINO para CPU AMD e Intel, GPU Intel y hardware VPU Intel." + }, + "tensorrt": { + "description": "Detector TensorRT para dispositivos Nvidia Jetson que utiliza motores TensorRT serializados para una inferencia acelerada." + }, + "degirum": { + "description": "Detector DeGirum para ejecutar modelos a través de la nube de DeGirum o servicios de inferencia local." + }, + "rknn": { + "description": "Detector RKNN para NPUs de Rockchip; ejecuta modelos compilados para RKNN en hardware de Rockchip." + } + }, + "database": { + "label": "Base de datos", + "description": "Configuración de la base de datos SQLite utilizada por Frigate para almacenar los metadatos de los objetos rastreados y las grabaciones." + }, + "mqtt": { + "label": "MQTT", + "port": { + "label": "Puerto MQTT" + }, + "tls_client_cert": { + "label": "Certificado cliente" + }, + "description": "Configuración para conectar y publicar telemetría, instantáneas y detalles de eventos en un broker MQTT.", + "topic_prefix": { + "description": "Prefijo del tema MQTT para todos los temas de Frigate; debe ser único si se ejecutan múltiples instancias." + }, + "client_id": { + "description": "Identificador de cliente utilizado al conectarse al broker MQTT; debe ser único para cada instancia." + } + }, + "notifications": { + "email": { + "label": "Email de notificacion" + } + }, + "networking": { + "ipv6": { + "label": "Configuración IPV6" + }, + "listen": { + "internal": { + "label": "Puerto interno" + }, + "external": { + "label": "Puerto externo", + "description": "Puerto externo de escucha para Frigate (por defecto 8791)." + }, + "description": "Configuración de los puertos de escucha internos y externos. Esto es para usuarios avanzados. Para la mayoría de los casos de uso, se recomienda modificar la sección de puertos de su configuración de Docker Compose." + } + }, + "proxy": { + "label": "Proxy", + "separator": { + "label": "Carácter de separación" + }, + "default_role": { + "description": "Rol predeterminado asignado a los usuarios autenticados por proxy cuando no se aplica ningún mapeo de roles (administrador o espectador)." + }, + "description": "Configuración para integrar Frigate detrás de un proxy inverso que transmite encabezados de usuario autenticados.", + "header_map": { + "description": "Mapear los encabezados de proxy entrantes a los campos de usuario y rol de Frigate para la autenticación basada en proxy.", + "role": { + "description": "Encabezado que contiene el rol o los grupos del usuario autenticado provenientes del proxy ascendente." + } + } + }, + "telemetry": { + "label": "Telemetria", + "stats": { + "intel_gpu_stats": { + "label": "Estadísticas GPU Intel", + "description": "Habilitar la recopilación de estadísticas de la GPU Intel si hay una GPU Intel presente." + }, + "network_bandwidth": { + "label": "Ancho de banda" + }, + "amd_gpu_stats": { + "label": "Estadísticas GPU Amd", + "description": "Habilitar la recopilación de estadísticas de la GPU AMD si hay una GPU AMD presente." + }, + "intel_gpu_device": { + "description": "Identificador de dispositivo utilizado al tratar las GPU Intel como SR-IOV para corregir las estadísticas de la GPU." + } + }, + "version_check": { + "description": "Habilite una verificación saliente para detectar si hay disponible una versión más reciente de Frigate." + } + }, + "ui": { + "timezone": { + "label": "Uso horario", + "description": "Zona horaria opcional que se mostrará en la interfaz de usuario (si no se especifica, se utilizará la hora local del navegador)." + }, + "unit_system": { + "label": "Unidad de sistema", + "description": "Sistema de unidades para la visualización (métrico o imperial) utilizado en la interfaz de usuario y en MQTT." + } + }, + "audio_transcription": { + "description": "Configuración para la transcripción de audio en vivo y de voz, utilizada para eventos y subtítulos en tiempo real.", + "language": { + "description": "Código de idioma utilizado para la transcripción/traducción (por ejemplo, 'es' para Español). Consulte https://whisper-api.com/docs/languages/ para ver los códigos de idioma compatibles." + }, + "enabled": { + "description": "Habilitar o deshabilitar la transcripción automática de audio para todas las cámaras; puede anularse por cámara." + } + }, + "motion": { + "skip_motion_threshold": { + "description": "Si se establece en un valor entre 0,0 y 1,0, y más de esta fracción de la imagen cambia en un solo fotograma, el detector no devolverá cuadros de movimiento y se recalibrará inmediatamente. Esto puede ahorrar recursos de CPU y reducir los falsos positivos durante tormentas eléctricas, tempestades, etc., aunque podría pasar por alto eventos reales, como el seguimiento automático de un objeto por parte de una cámara PTZ. La disyuntiva está entre descartar unos cuantos megabytes de grabaciones o revisar un par de clips cortos. Deje este parámetro sin establecer (None) para desactivar esta función." + }, + "lightning_threshold": { + "description": "Umbral para detectar e ignorar breves picos de luz (un valor menor indica mayor sensibilidad; valores entre 0,3 y 1,0). Esto no impide por completo la detección de movimiento; Simplemente provoca que el detector deje de analizar fotogramas adicionales una vez que se supera el umbral. Durante estos eventos aún se realizan grabaciones basadas en el movimiento." + }, + "threshold": { + "description": "Umbral de diferencia de píxeles utilizado por el detector de movimiento; los valores más altos reducen la sensibilidad (rango 1-255)." + }, + "enabled": { + "description": "Habilitar o deshabilitar la detección de movimiento para todas las cámaras; puede anularse para cada cámara individualmente." + } + }, + "lpr": { + "enhancement": { + "description": "Nivel de mejora (0-10) que se aplicará a los recortes de matrículas antes del OCR; los valores más altos no siempre mejoran los resultados, y los niveles superiores a 5 podrían funcionar únicamente con matrículas capturadas de noche, por lo que deben utilizarse con precaución." + }, + "expire_time": { + "description": "Tiempo en segundos tras el cual una matrícula no detectada caduca en el sistema de seguimiento (solo para cámaras LPR dedicadas)." + }, + "enabled": { + "description": "Habilitar o deshabilitar el reconocimiento de matrículas para todas las cámaras; puede anularse por cámara." + }, + "min_plate_length": { + "description": "Número mínimo de caracteres que debe contener una matrícula reconocida para ser considerada válida." + } + }, + "detect": { + "fps": { + "description": "Fotogramas por segundo deseados para ejecutar la detección; los valores más bajos reducen el uso de la CPU (el valor recomendado es 5; establezca un valor superior —como máximo de 10— únicamente si realiza el seguimiento de objetos que se mueven con extrema rapidez)." + }, + "min_initialized": { + "description": "Número de detecciones consecutivas requeridas antes de crear un objeto rastreado. Auméntelo para reducir las inicializaciones falsas. El valor predeterminado es los FPS divididos por 2." + }, + "height": { + "description": "Altura (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión." + }, + "width": { + "description": "Ancho (en píxeles) de los fotogramas utilizados para la transmisión de detección; déjelo vacío para utilizar la resolución nativa de la transmisión." + }, + "stationary": { + "description": "Configuración para detectar y gestionar objetos que permanecen inmóviles durante un periodo de tiempo." + }, + "enabled": { + "description": "Habilitar o deshabilitar la detección de objetos para todas las cámaras; puede anularse para cada cámara individualmente." + } + }, + "record": { + "motion": { + "description": "Número de días para conservar las grabaciones activadas por movimiento, independientemente de los objetos rastreados. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones." + }, + "continuous": { + "description": "Número de días para conservar las grabaciones, independientemente de los objetos rastreados o del movimiento. Establézcalo en 0 si solo desea conservar las grabaciones de alertas y detecciones." + }, + "detections": { + "pre_capture": { + "description": "Número de segundos antes del evento de detección que se incluirán en la grabación." + }, + "post_capture": { + "description": "Número de segundos después del evento de detección que se incluirán en la grabación." + } + }, + "alerts": { + "pre_capture": { + "description": "Número de segundos antes del evento de detección que se incluirán en la grabación." + }, + "post_capture": { + "description": "Número de segundos después del evento de detección que se incluirán en la grabación." + } + } + }, + "camera_ui": { + "dashboard": { + "description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz." + } + }, + "live": { + "description": "Configuración para controlar la resolución y la calidad de la transmisión en vivo de jsmpeg. Esto no afecta a las cámaras retransmitidas que utilizan go2rtc para la visualización en vivo.", + "height": { + "description": "Altura (en píxeles) para renderizar la transmisión en vivo de jsmpeg en la interfaz web; debe ser <= a la altura de la transmisión de detección." + } + }, + "semantic_search": { + "model": { + "description": "El modelo de embeddings a utilizar para la búsqueda semántica (por ejemplo, 'jinav1'), o el nombre de un proveedor de GenAI con el rol de embeddings." + } + }, + "review": { + "alerts": { + "required_zones": { + "description": "Zonas en las que debe entrar un objeto para ser considerado una alerta; dejar vacío para permitir cualquier zona." + }, + "labels": { + "description": "Lista de etiquetas de objetos que califican como alertas (por ejemplo: car, person)." + } + }, + "detections": { + "required_zones": { + "description": "Zonas en las que debe entrar un objeto para ser considerado detectado; dejar vacío para permitir cualquier zona." + }, + "description": "Configuración para determinar qué objetos rastreados generan detecciones (no alertas) y cómo se retienen dichas detecciones." + }, + "genai": { + "image_source": { + "description": "Fuente de las imágenes enviadas a GenAI ('preview' o 'recordings'); La opción 'recordings' utiliza fotogramas de mayor calidad, pero requiere más tokens." + }, + "additional_concerns": { + "description": "Una lista de preocupaciones o notas adicionales que GenAI debería tener en cuenta al evaluar la actividad en esta cámara." + }, + "activity_context_prompt": { + "description": "Instrucción personalizada que describe qué constituye y qué no una actividad sospechosa, con el fin de proporcionar contexto para los resúmenes generados por GenAI." + }, + "description": "Controla el uso de IA generativa (GenAI) para la elaboración de descripciones y resúmenes de elementos de revisión.", + "debug_save_thumbnails": { + "description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión." + } + } + }, + "birdseye": { + "description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista.", + "restream": { + "description": "Retransmita la salida de video de Birdseye como una transmisión en vivo RTSP; al habilitar esta opción, Birdseye se mantendrá en ejecución de forma continua." + }, + "layout": { + "max_cameras": { + "description": "Número máximo de cámaras a mostrar simultáneamente en Birdseye; muestra las cámaras más recientes." + } + } + }, + "ffmpeg": { + "retry_interval": { + "description": "Segundos de espera antes de intentar reconectar la transmisión de una cámara tras un fallo. El valor predeterminado es 10." + }, + "path": { + "description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\")." + }, + "output_args": { + "description": "Argumentos de salida predeterminados utilizados para diferentes roles de FFmpeg, tales como detección y grabación." + }, + "description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol." + }, + "go2rtc": { + "description": "Configuración del servicio integrado de retransmisión go2rtc, utilizado para el relevo y la traducción de transmisiones en vivo." + }, + "genai": { + "description": "Configuración para los proveedores integrados de IA generativa (GenAI) utilizados para generar descripciones de objetos y resúmenes de reseñas.", + "api_key": { + "description": "Clave de API requerida por algunos proveedores (también puede configurarse mediante variables de entorno)." + }, + "base_url": { + "description": "URL base para proveedores autoalojados o compatibles (por ejemplo, una instancia de Ollama)." + }, + "model": { + "description": "El modelo del proveedor que se utilizará para generar descripciones o resúmenes." + } + }, + "face_recognition": { + "description": "Configuración para la detección y el reconocimiento facial en todas las cámaras; puede anularse por cámara." + }, + "camera_mqtt": { + "required_zones": { + "description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT." } } } diff --git a/web/public/locales/es/config/groups.json b/web/public/locales/es/config/groups.json index d6b2b9d81e..a8cb25b469 100644 --- a/web/public/locales/es/config/groups.json +++ b/web/public/locales/es/config/groups.json @@ -59,6 +59,15 @@ "global": { "retention": "Retención global", "events": "Eventos globales" + }, + "cameras": { + "events": "Evento", + "retention": "Retención" + } + }, + "ffmpeg": { + "cameras": { + "cameraFfmpeg": "Argumentos de FFmpeg específicos de la cámara" } } } diff --git a/web/public/locales/es/config/validation.json b/web/public/locales/es/config/validation.json index faf7032f87..b78ae972f3 100644 --- a/web/public/locales/es/config/validation.json +++ b/web/public/locales/es/config/validation.json @@ -19,7 +19,8 @@ "ffmpeg": { "inputs": { "rolesUnique": "Cada rol solo puede asignarse a un flujo de entrada.", - "detectRequired": "Al menos un flujo de entrada debe tener asignado el rol 'detect'." + "detectRequired": "Al menos un flujo de entrada debe tener asignado el rol 'detect'.", + "hwaccelDetectOnly": "Solo el flujo de entrada con la función \"detect\" puede definir argumentos de aceleración por hardware." } }, "anyOf": "Debe coincidir con al menos uno de los esquemas permitidos", diff --git a/web/public/locales/es/objects.json b/web/public/locales/es/objects.json index 0fd02208a6..fe4d16915e 100644 --- a/web/public/locales/es/objects.json +++ b/web/public/locales/es/objects.json @@ -47,7 +47,7 @@ "carrot": "Zanahoria", "hot_dog": "Perrito caliente", "pizza": "Pizza", - "donut": "Donut", + "donut": "Rosquilla", "chair": "Silla", "couch": "Sofá", "potted_plant": "Planta en maceta", diff --git a/web/public/locales/es/views/chat.json b/web/public/locales/es/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/es/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/es/views/classificationModel.json b/web/public/locales/es/views/classificationModel.json index ee6fc5ed10..0f5ec539b9 100644 --- a/web/public/locales/es/views/classificationModel.json +++ b/web/public/locales/es/views/classificationModel.json @@ -12,12 +12,12 @@ }, "toast": { "success": { - "deletedCategory_one": "Clase Borrada", - "deletedCategory_many": "", - "deletedCategory_other": "", - "deletedImage_one": "Imágenes Borradas", - "deletedImage_many": "", - "deletedImage_other": "", + "deletedCategory_one": "Se eliminó {{count}} clase", + "deletedCategory_many": "Se eliminaron {{count}} clases", + "deletedCategory_other": "Se eliminaron {{count}} clases", + "deletedImage_one": "Se eliminó {{count}} imagen", + "deletedImage_many": "Se eliminaron {{count}} imágenes", + "deletedImage_other": "Se eliminaron {{count}} imágenes", "deletedModel_one": "Borrado con éxito {{count}} modelo", "deletedModel_many": "Borrados con éxito {{count}} modelos", "deletedModel_other": "Borrados con éxito {{count}} modelos", @@ -68,7 +68,7 @@ "details": { "scoreInfo": "La puntuación representa la confianza media de clasificación en todas las detecciones de este objeto.", "unknown": "Desconocido", - "none": "Ninguna" + "none": "Ninguno" }, "categorizeImage": "Clasificar Imagen", "menu": { diff --git a/web/public/locales/es/views/exports.json b/web/public/locales/es/views/exports.json index 1099d45c89..cc2306da06 100644 --- a/web/public/locales/es/views/exports.json +++ b/web/public/locales/es/views/exports.json @@ -22,17 +22,26 @@ "downloadVideo": "Descargar video", "editName": "Editar nombre", "deleteExport": "Eliminar exportación", - "assignToCase": "Añadir al caso" + "assignToCase": "Añadir al caso", + "removeFromCase": "Remover del contenedor" }, "headings": { "cases": "Casos", - "uncategorizedExports": "Exportaciones sin categorizar" + "uncategorizedExports": "Exportaciones sin Categorizar" }, "caseDialog": { "title": "Añadir al caso", "newCaseOption": "Crear nuevo caso", "nameLabel": "Nombre del caso", "description": "Elige un caso existente o crea uno nuevo.", - "selectLabel": "Caso" + "selectLabel": "Caso", + "descriptionLabel": "Descripción" + }, + "toolbar": { + "addExport": "Añadir Exportación" + }, + "deleteCase": { + "label": "Eliminar caso", + "desc": "¿Estás seguro de que quieres eliminar {{caseName}}?" } } diff --git a/web/public/locales/es/views/live.json b/web/public/locales/es/views/live.json index fa473384a2..4bc98b5cbc 100644 --- a/web/public/locales/es/views/live.json +++ b/web/public/locales/es/views/live.json @@ -17,7 +17,7 @@ "label": "Haz clic en el marco para centrar la cámara", "enable": "Habilitar clic para mover", "disable": "Deshabilitar clic para mover", - "enableWithZoom": "Activar clic para mover / arrastrar para hacer zoom" + "enableWithZoom": "Habilitar clic para mover / arrastrar para aumentar" }, "up": { "label": "Mover la cámara PTZ hacia arriba" diff --git a/web/public/locales/es/views/motionSearch.json b/web/public/locales/es/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/es/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/es/views/replay.json b/web/public/locales/es/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/es/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/es/views/settings.json b/web/public/locales/es/views/settings.json index c6157a7507..075b7131ff 100644 --- a/web/public/locales/es/views/settings.json +++ b/web/public/locales/es/views/settings.json @@ -21,7 +21,7 @@ "menu": { "cameras": "Configuración de Cámara", "debug": "Depuración", - "ui": "Interfaz de usuario", + "ui": "Interfaz de Usuario", "classification": "Clasificación", "motionTuner": "Ajuste de movimiento", "masksAndZones": "Máscaras / Zonas", @@ -35,7 +35,22 @@ "cameraReview": "Revisar", "general": "General", "globalConfig": "Configuración Global", - "system": "Sistema" + "system": "Sistema", + "integrations": "Integraciones", + "uiSettings": "Configuración de Interfaz de Usuario", + "profiles": "Perfiles", + "globalDetect": "Detección de Objetos", + "globalRecording": "Grabación", + "globalSnapshots": "Instantáneas", + "globalFfmpeg": "FFmpeg", + "globalMotion": "Detección de Movimiento", + "globalObjects": "Objetos", + "globalReview": "Revisión", + "globalAudioEvents": "Eventos de Audio", + "globalLivePlayback": "Reproducción en Vivo", + "globalTimestampStyle": "Estilo de Marca de Tiempo", + "systemDatabase": "Base de Datos", + "systemAuthentication": "Autenticación" }, "dialog": { "unsavedChanges": { @@ -353,6 +368,9 @@ "allObjects": "Todos los objetos", "toast": { "success": "La zona ({{zoneName}}) ha sido guardada." + }, + "enabled": { + "description": "Indica si esta zona está activa y habilitada en la configuración. Si está deshabilitado, no puede ser habilitado por MQTT. Las zonas deshabilitadas se ignoran durante la ejecución." } }, "toast": { @@ -697,7 +715,7 @@ "cleanCopySnapshots": "clean_copy Instantáneas" }, "desc": "Enviar a Frigate+ requiere que tanto las capturas instantáneas como las capturas clean_copy estén habilitadas en tu configuración.", - "cleanCopyWarning": "Algunas cámaras tienen las instantáneas habilitadas pero tienen la copia limpia desactivada. Necesitas habilitar clean_copy en tu configuración de instantáneas para poder enviar imágenes de estas cámaras a Frigate+." + "cleanCopyWarning": "Algunas cámaras tienen las instantáneas deshabilitadas" }, "modelInfo": { "title": "Información del modelo", @@ -722,7 +740,8 @@ "error": "No se pudieron guardar los cambios en la configuración: {{errorMessage}}" }, "restart_required": "Es necesario reiniciar (se ha cambiado el modelo Frigate+)", - "unsavedChanges": "Cambios en la configuración de Frigate+ no guardados" + "unsavedChanges": "Cambios en la configuración de Frigate+ no guardados", + "description": "Frigate+ es un servicio de suscripción que proporciona acceso a funciones y capacidades adicionales para su instancia de Frigate, incluida la posibilidad de utilizar modelos de detección de objetos personalizados entrenados con sus propios datos. Puede gestionar la configuración de sus modelos de Frigate+ aquí." }, "enrichments": { "title": "Configuración de Enriquecimientos", @@ -1172,7 +1191,8 @@ "backToSettings": "Volver a configuración de la cámara", "streams": { "title": "Habilitar/deshabilitar cámaras", - "desc": "Desactiva temporalmente una cámara hasta que Frigate se reinicie. Desactivar una cámara detiene por completo el procesamiento de las transmisiones de Frigate. La detección, la grabación y la depuración no estarán disponibles.
    Nota: Esto no desactiva las retransmisiones de go2rtc." + "desc": "Desactiva temporalmente una cámara hasta que Frigate se reinicie. Desactivar una cámara detiene por completo el procesamiento de las transmisiones de Frigate. La detección, la grabación y la depuración no estarán disponibles.
    Nota: Esto no desactiva las retransmisiones de go2rtc.", + "enableDesc": "Deshabilita temporalmente una cámara habilitada hasta que Frigate se reinicie. Deshabilitar una cámara detiene por completo el procesamiento de las transmisiones de esa cámara por parte de Frigate. La detección, la grabación y la depuración no estarán disponibles.
    Nota: Esto no deshabilita las retransmisiones de go2rtc." }, "cameraConfig": { "add": "Añadir cámara", @@ -1202,6 +1222,9 @@ "toast": { "success": "Cámara {{cameraName}} guardada correctamente" } + }, + "deleteCameraDialog": { + "description": "Eliminar una cámara borrará permanentemente todas las grabaciones, los objetos rastreados y la configuración de esa cámara. Es posible que sea necesario eliminar manualmente cualquier transmisión go2rtc asociada a esta cámara." } }, "cameraReview": { @@ -1253,12 +1276,133 @@ "maintenance": { "sync": { "verboseDesc": "Escribe una lista completa de archivos huérfanos en el disco para su revisión.", - "verbose": "Detallado" + "verbose": "Detallado", + "desc": "Frigate limpiará periódicamente los archivos multimedia según un cronograma regular, de acuerdo con su configuración de retención. Es normal ver algunos archivos huérfanos mientras Frigate se ejecuta. Utilice esta función para eliminar del disco los archivos multimedia huérfanos que ya no se referencian en la base de datos.", + "forceDesc": "Omitir el umbral de seguridad y completar la sincronización incluso si se eliminara más del 50% de los archivos." + }, + "regionGrid": { + "clearConfirmDesc": "No se recomienda borrar la cuadrícula de la región a menos que haya cambiado recientemente el tamaño del modelo de su detector o la posición física de su cámara y esté experimentando problemas de seguimiento de objetos. La cuadrícula se reconstruirá automáticamente con el tiempo a medida que se realice el seguimiento de los objetos. Es necesario reiniciar Frigate para que los cambios surtan efecto.", + "desc": "La cuadrícula de regiones es una optimización que aprende dónde suelen aparecer los objetos de diferentes tamaños en el campo de visión de cada cámara. Frigate utiliza estos datos para dimensionar de forma eficiente las regiones de detección. La cuadrícula se construye automáticamente a lo largo del tiempo a partir de los datos de los objetos rastreados." } }, "configForm": { "camera": { - "noCameras": "No hay cámaras disponibles" + "noCameras": "No hay cámaras disponibles", + "description": "Estos ajustes se aplican únicamente a esta cámara y anulan los ajustes globales." + }, + "genaiModel": { + "noModels": "No hay modelos disponibles" + }, + "global": { + "description": "Estos ajustes se aplican a todas las cámaras, a menos que se anulen en los ajustes específicos de cada cámara." + } + }, + "globalConfig": { + "title": "Configuración global", + "description": "Configura los ajustes globales que se aplican a todas las cámaras, a menos que se sobrescriban.", + "toast": { + "success": "Ajustes globales guardados con éxito", + "error": "Error al guardar los ajustes globales", + "validationError": "Error de validación" + } + }, + "cameraConfig": { + "title": "Configuración de cámara", + "description": "Configura los ajustes de cámaras individuales. Estos ajustes sobrescriben los valores globales predeterminados.", + "overriddenBadge": "Sobrescrito", + "resetToGlobal": "Restablecer al valor global", + "toast": { + "success": "Ajustes de cámara guardados con éxito", + "error": "Error al guardar los ajustes de cámara" + } + }, + "toast": { + "success": "Ajustes guardados con éxito", + "applied": "Ajustes aplicados con éxito", + "successRestartRequired": "Ajustes guardados con éxito. Reinicia Frigate para aplicar los cambios.", + "error": "Error al guardar los ajustes", + "validationError": "Error de validación: {{message}}", + "resetSuccess": "Restablecido a los valores globales predeterminados", + "resetError": "Error al restablecer los ajustes", + "saveAllSuccess_one": "Se ha guardado {{count}} sección con éxito.", + "saveAllSuccess_many": "Se han guardado las {{count}} secciones con éxito.", + "saveAllSuccess_other": "Se han guardado {{count}} secciones con éxito.", + "saveAllPartial_one": "Se ha guardado {{successCount}} de {{totalCount}} sección. {{failCount}} ha fallado.", + "saveAllPartial_many": "Se han guardado {{successCount}} de {{totalCount}} secciones. {{failCount}} han fallado.", + "saveAllPartial_other": "Se han guardado {{successCount}} de {{totalCount}} secciones. {{failCount}} han fallado.", + "saveAllFailure": "Error al guardar todas las secciones." + }, + "profiles": { + "title": "Perfiles", + "activeProfile": "Perfil activo", + "noActiveProfile": "Sin perfil activo", + "active": "Activo", + "activated": "Perfil '{{profile}}' activado", + "activateFailed": "Error al establecer el perfil", + "deactivated": "Perfil desactivado", + "noProfiles": "No hay perfiles definidos.", + "noOverrides": "Sin sobrescripciones", + "cameraCount_one": "{{count}} cámara", + "cameraCount_many": "{{count}} de cámaras", + "cameraCount_other": "{{count}} cámaras", + "columnCamera": "Cámara", + "columnOverrides": "Sobrescripciones del perfil", + "baseConfig": "Configuración base", + "addProfile": "Añadir perfil", + "newProfile": "Nuevo perfil", + "profileNamePlaceholder": "ej. Armado, Fuera de casa, Modo noche", + "friendlyNameLabel": "Nombre del perfil", + "profileIdLabel": "ID del perfil", + "profileIdDescription": "Identificador interno utilizado en la configuración y automatizaciones", + "nameInvalid": "Solo se permiten letras minúsculas, números y guiones bajos", + "nameDuplicate": "Ya existe un perfil con este nombre", + "error": { + "mustBeAtLeastTwoCharacters": "Debe tener al menos 2 caracteres", + "mustNotContainPeriod": "No debe contener puntos", + "alreadyExists": "Ya existe un perfil con este ID" + }, + "renameProfile": "Renombrar perfil", + "renameSuccess": "Perfil renombrado a '{{profile}}'", + "enabledDescription": "Los perfiles están habilitados. Cree un nuevo perfil a continuación, navegue a una sección de configuración de cámara para realizar sus cambios y guarde para que estos surtan efecto.", + "disabledDescription": "Los perfiles le permiten definir conjuntos con nombre de anulaciones de configuración de la cámara (por ejemplo: armado, fuera, noche) que pueden activarse bajo demanda." + }, + "go2rtcStreams": { + "renameStreamDesc": "Introduce un nuevo nombre para esta transmisión. Cambiar el nombre de una transmisión puede provocar fallos en las cámaras u otras transmisiones que hagan referencia a ella por su nombre.", + "addStreamDesc": "Introduce un nombre para la nueva transmisión. Este nombre se utilizará para hacer referencia a la transmisión en la configuración de su cámara.", + "description": "Gestione las configuraciones de transmisión de go2rtc para la retransmisión de cámaras. Cada transmisión tiene un nombre y una o más URL de origen.", + "deleteStreamConfirm": "¿Está seguro de que desea eliminar la transmisión \"{{streamName}}\"? Las cámaras que hagan referencia a esta transmisión podrían dejar de funcionar." + }, + "configMessages": { + "birdseye": { + "objectsModeDetectDisabled": "Birdseye está configurado en modo 'objects', pero la detección de objetos está desactivada para esta cámara. La cámara no aparecerá en Birdseye." + }, + "lpr": { + "globalDisabled": "El reconocimiento de matrículas no está habilitado a nivel global. Habilítelo en la configuración global para que funcione el reconocimiento de matrículas a nivel de cámara." + }, + "audio": { + "noAudioRole": "Ninguna transmisión tiene definido el rol de audio. Debe habilitar el rol de audio para que funcione la detección de audio." + }, + "faceRecognition": { + "personNotTracked": "El reconocimiento facial requiere que se realice el seguimiento del objeto 'person'. Asegúrese de que 'person' se encuentre en la lista de seguimiento de objetos." + }, + "audioTranscription": { + "audioDetectionDisabled": "La detección de audio no está habilitada para esta cámara. La transcripción de audio requiere que la detección de audio esté activa." + }, + "snapshots": { + "detectDisabled": "La detección de objetos está desactivada. Las instantáneas se generan a partir de los objetos rastreados y no se crearán." + }, + "detectors": { + "mixedTypes": "Todos los detectores deben ser del mismo tipo. Retire los detectores existentes para utilizar un tipo diferente." + }, + "review": { + "detectDisabled": "La detección de objetos está desactivada. Los elementos de revisión requieren objetos detectados para categorizar las alertas y detecciones." + } + }, + "resetToDefaultDescription": "Esto restablecerá todos los ajustes de esta sección a sus valores predeterminados. Esta acción no se puede deshacer.", + "resetToGlobalDescription": "Esto restablecerá la configuración de esta sección a los valores predeterminados globales. Esta acción no se puede deshacer.", + "detectionModel": { + "plusActive": { + "description": "Esta instancia está ejecutando un modelo de Frigate+. Seleccione o cambie su modelo en la configuración de Frigate+." } } } diff --git a/web/public/locales/es/views/system.json b/web/public/locales/es/views/system.json index 6c211a77c4..e0a0157a1a 100644 --- a/web/public/locales/es/views/system.json +++ b/web/public/locales/es/views/system.json @@ -45,10 +45,17 @@ "reviews": "Revisiones", "face_recognition": "Reconocimiento facial", "camera_activity": "Actividad de cámara", - "classification": "Clasificación" + "classification": "Clasificación", + "system": "Sistema", + "camera": "Cámara", + "all_cameras": "Todas las cámaras", + "cameras_count_one": "{{count}} Cámara", + "cameras_count_other": "{{count}} Cámaras", + "lpr": "Reconocimiento de matriculas" }, "count_other": "{{count}} mensajes", - "count_one": "{{count}} mensaje" + "count_one": "{{count}} mensaje", + "empty": "No se han capturado mensaje aún" } }, "title": "Sistema", @@ -99,7 +106,10 @@ "title": "Aviso de estadísticas Intel GPU", "message": "Estadísticas de GPU no disponibles", "description": "Este es un error conocido en las herramientas de informes de estadísticas de GPU de Intel (intel_gpu_top). El error se produce y muestra repetidamente un uso de GPU del 0 %, incluso cuando la aceleración de hardware y la detección de objetos se ejecutan correctamente en la (i)GPU. No se trata de un error de Frigate. Puede reiniciar el host para solucionar el problema temporalmente y confirmar que la GPU funciona correctamente. Esto no afecta al rendimiento." - } + }, + "npuTemperature": "Temperatura NPU", + "gpuCompute": "Cálculo GPU / Codificación", + "gpuTemperature": "Temperatura GPU" }, "otherProcesses": { "title": "Otros Procesos", @@ -136,7 +146,11 @@ }, "shm": { "title": "Asignación de SHM (memoria compartida)", - "warning": "El tamaño actual de SHM de {{total}}MB es muy pequeño. Aumente al menos a {{min_shm}}MB." + "warning": "El tamaño actual de SHM de {{total}}MB es muy pequeño. Aumente al menos a {{min_shm}}MB.", + "frameLifetime": { + "title": "Tiempo de vida del fotograma", + "description": "Cada cámara tiene espacio en la memoria compartida para {{frames}} cuadros. Si la velocidad de cuadros de la cámara es alta, cada cuadro se guarda aproximadamente {{lifetime}} antes de ser sobreescrito." + } } }, "cameras": { @@ -174,7 +188,8 @@ "cameraDetect": "{{camName}} detectar", "cameraFramesPerSecond": "{{camName}} cuadros por segundo", "cameraDetectionsPerSecond": "{{camName}} detecciones por segundo", - "overallSkippedDetectionsPerSecond": "detecciones omitidas por segundo totales" + "overallSkippedDetectionsPerSecond": "detecciones omitidas por segundo totales", + "cameraGpu": "{{camName}} GPU" }, "toast": { "success": { @@ -183,6 +198,17 @@ "error": { "unableToProbeCamera": "No se pudo sondear la cámara: {{errorMessage}}" } + }, + "connectionQuality": { + "excellent": "Excelente", + "poor": "Debil", + "title": "Calidad de la conexión", + "fps": "Cuadros por segundo", + "expectedFps": "Cuadros por segundo esperados", + "reconnectsLastHour": "Reconexiones (última hora)", + "unusable": "No usable", + "fair": "Normal", + "stallsLastHour": "Bloqueos (última hora)" } }, "lastRefreshed": "Última actualización: ", @@ -221,6 +247,7 @@ "detectIsSlow": "{{detect}} es lento ({{speed}} ms)", "cameraIsOffline": "{{camera}} está desconectada", "detectIsVerySlow": "{{detect}} es muy lento ({{speed}} ms)", - "shmTooLow": "Asignación de /dev/shm ({{total}} MB) debe aumentarse al menos a {{min}} MB." + "shmTooLow": "Asignación de /dev/shm ({{total}} MB) debe aumentarse al menos a {{min}} MB.", + "debugReplayActive": "Sesión de depuración activa" } } diff --git a/web/public/locales/et/audio.json b/web/public/locales/et/audio.json index b0dfec6609..541ffa88ce 100644 --- a/web/public/locales/et/audio.json +++ b/web/public/locales/et/audio.json @@ -113,5 +113,165 @@ "quack": "Prääksumine", "goose": "Hani", "honk": "Kaagatamine", - "wild_animals": "Metsloomad" + "wild_animals": "Metsloomad", + "roaring_cats": "Möirgavad kassid", + "roar": "Möirgamine", + "chirp": "Sirisemine", + "squawk": "Prääksatamine", + "pigeon": "Tuvi", + "coo": "Kudrutamine", + "crow": "Vares", + "caw": "Kraaksumine", + "owl": "Öökull", + "hoot": "Huikamine", + "flapping_wings": "Tiibade laperdamine", + "buzz": "Sumisemine", + "frog": "Konn", + "croak": "Krooksumine", + "snake": "Madu", + "rattle": "Kõristamine/lõgistamine", + "whale_vocalization": "Vaalaskala häälitsused", + "music": "Muusika", + "musical_instrument": "Pill", + "plucked_string_instrument": "Keelpill", + "guitar": "Kitarr", + "electric_guitar": "Elektrikitarr", + "bass_guitar": "Basskitarr", + "acoustic_guitar": "Akustiline kitarr", + "sitar": "Sitar", + "mandolin": "Mandoliin", + "banjo": "Bändžo", + "zither": "Kannel/tsitter", + "ukulele": "Ukulele", + "piano": "Klaver", + "electric_piano": "Elektriklaver", + "organ": "Orel", + "electronic_organ": "Elektriorel", + "hammond_organ": "Hammond-orel", + "synthesizer": "Süntesaator", + "sampler": "Sämpler", + "harpsichord": "Klavessiin", + "percussion": "Löökriistad", + "drum_kit": "Trummikomplekt", + "bass_drum": "Basstrumm", + "tambourine": "Tamburiin", + "glockenspiel": "Ksülofon", + "vibraphone": "Vibrafon (metalltorudega ksülofon)", + "marimba": "Marimbafon", + "tubular_bells": "Torukellad", + "gong": "Gong", + "orchestra": "Orkester", + "cello": "Tšello", + "pizzicato": "Pizzicato (poogenpilli sõrmega mängimine)", + "violin": "Viiul", + "string_section": "Keelpillid", + "trombone": "Tromboon", + "trumpet": "Trompet", + "french_horn": "Metsasarv", + "brass_instrument": "Puhkpillid", + "double_bass": "Kontrabass", + "wind_instrument": "Puhkpill", + "flute": "Flööt", + "saxophone": "Saksofon", + "clarinet": "Klarnet", + "harp": "Harf", + "bell": "Kellad", + "church_bell": "Kirikukell", + "jingle_bell": "Aisakell", + "bicycle_bell": "Rattakell", + "tuning_fork": "Helihark/kammertoon", + "bagpipes": "Torupillid", + "didgeridoo": "Didžeriduu", + "pop_music": "Popmuusika", + "hip_hop_music": "Hiphop muusika", + "rock_music": "Rokkmuusika", + "beatboxing": "Beatbox", + "heavy_metal": "Hevimuusika", + "punk_rock": "Punkrokk", + "grunge": "Grunge", + "progressive_rock": "Progressiivne rokk", + "rhythm_and_blues": "Rütmibluus", + "soul_music": "Soulmuusika", + "reggae": "Reggae", + "country": "Kantrimuusika", + "funk": "Funkmuusika", + "folk_music": "Rahvamuusika", + "middle_eastern_music": "Lähis-Ida muusika", + "jazz": "Džäss", + "disco": "Disko", + "classical_music": "Klassikaline muusika", + "opera": "Ooper", + "electronic_music": "Elektrooniline muusika", + "house_music": "House-muusika", + "techno": "Tekno", + "dubstep": "Dubstep", + "drum_and_bass": "Drum and Bass", + "electronic_dance_music": "Elektrooniline tantsumuusika", + "music_of_latin_america": "Ladina-Ameerika muusika", + "salsa_music": "Salsa", + "flamenco": "Flamenko", + "blues": "Bluus", + "music_for_children": "Lastemuusika", + "new-age_music": "New Age muusika", + "vocal_music": "Laulmine", + "a_capella": "A Capella", + "music_of_africa": "Aafrika muusika", + "afrobeat": "Afrobeat", + "christian_music": "Kristlik muusika", + "gospel_music": "Gospelmuusika", + "music_of_asia": "Aasia muusika", + "music_of_bollywood": "Bollywoodi muusika", + "ska": "Ska", + "carnatic_music": "Karnataka muusika", + "trance_music": "Trance muusika", + "ambient_music": "Ambient muusika", + "electronica": "Electronica", + "swing_music": "Svingmuusika", + "bluegrass": "Bluegrass", + "psychedelic_rock": "Psühhedeelne rokk", + "rock_and_roll": "Rock'n'roll", + "scratching": "Kriipimine/kraapimine", + "theremin": "Teremin", + "accordion": "Akordion", + "harmonica": "Suupill", + "wind_chime": "Tuulekell", + "chime": "Kelluke", + "traditional_music": "Traditsiooniline muusika", + "independent_music": "Sõltumatu muusika", + "song": "Laul", + "background_music": "Taustamuusika", + "lullaby": "Hällilaul", + "christmas_music": "Jõulumuusika", + "video_game_music": "Videomängude muusika", + "dance_music": "Tantsumuusika", + "wedding_music": "Pulmamuusika", + "happy_music": "Rõõmus muusika", + "sad_music": "Kurb muusika", + "tender_music": "Tundeline muusika", + "angry_music": "Vihane muusika", + "exciting_music": "Põnev muusika", + "scary_music": "Hirmutav muusika", + "wind": "Tuul", + "thunderstorm": "Äikesetorm", + "thunder": "Kõu/äike", + "water": "Vesi", + "rain": "Vihm", + "raindrop": "Vihmapiisk", + "spray": "Pritsimine", + "pump": "Pumpamine", + "stir": "Segamine/nihelemine", + "boiling": "Keemine", + "sonar": "Kajalood", + "arrow": "Nool", + "whoosh": "Vuhh/vuhisemine", + "thump": "Potsatus/mütsatus", + "thunk": "Põmakas", + "doorbell": "Uksekell", + "traffic_noise": "Liiklusmüra", + "rail_transport": "Raudteetransport", + "train_whistle": "Rongivile", + "sailboat": "Purjekas", + "soundtrack_music": "Filmimuusika", + "jingle": "Kõlisemine/tilisemine", + "theme_music": "Tunnusmuusika" } diff --git a/web/public/locales/et/common.json b/web/public/locales/et/common.json index d066f85142..4455b56973 100644 --- a/web/public/locales/et/common.json +++ b/web/public/locales/et/common.json @@ -181,7 +181,8 @@ "classification": "Klassifikatsioon", "chat": "Vestlus", "actions": "Tegevused", - "profiles": "Profiilid" + "profiles": "Profiilid", + "features": "Funktsionaalsused" }, "unit": { "speed": { diff --git a/web/public/locales/et/components/filter.json b/web/public/locales/et/components/filter.json index 0df74f6d03..316aeebe25 100644 --- a/web/public/locales/et/components/filter.json +++ b/web/public/locales/et/components/filter.json @@ -4,13 +4,16 @@ "toast": { "error": "Jälgitavate objektide kustutamine ei õnnestunud: {{errorMessage}}", "success": "Jälgitavate objektide kustutamine õnnestus." - } + }, + "title": "Kinnita kustutamine", + "desc": "Nende {{objectLength}} jälgitava objekti kustutamine eemaldab tõmmise salvestuse, kõik seotud salvestatud sissekanded ja kõik seotud objekti elutsükli kirjed. Ajaloo vaates salvestatud videomaterjali nende jälgitavate objektide kohta EI kustutata.

    Kas soovid kindlasti jätkata?

    Hoia Shift-klahvi all, et seda teateakent tulevikus vahele jätta." }, "cameras": { "all": { "title": "Kõik kaamerad", "short": "Kaamerad" - } + }, + "label": "Kaamerate filter" }, "labels": { "all": { @@ -38,7 +41,8 @@ "defaultView": { "title": "Vaikimisi vaade", "summary": "Kokkuvõte", - "unfilteredGrid": "Filtreerimata ruudustik" + "unfilteredGrid": "Filtreerimata ruudustik", + "desc": "Kui filtreid pole valitud, näita viimaste jälgitud objektide kokkuvõtet sildi kohta või näita filtreerimata ruudustikuvaadet." }, "gridColumns": { "title": "Ruudustiku veerud", @@ -48,16 +52,26 @@ "options": { "thumbnailImage": "Pisipilt", "description": "Kirjeldus" - } + }, + "label": "Otsinguallikas", + "desc": "Vali, kas soovid otsida sinu jälgitavate objektide pisipilte või kirjeldusi." + } + }, + "date": { + "selectDateBy": { + "label": "Vali kuupäev, mille alusel tahad filtreerida" } } }, "logSettings": { "loading": { - "title": "Laadin" + "title": "Laadin", + "desc": "Kui logipaneeli vaade on keritud lõpuni, siis kuvatakse lisanduvad logikirjed automaatselt kohe." }, "disableLogStreaming": "Keela logi voogedastus", - "allLogs": "Kõik logid" + "allLogs": "Kõik logid", + "label": "Logimistase filtri jaoks", + "filterBySeverity": "Kriitilisus filtri jaoks" }, "classes": { "label": "Klassid", @@ -83,6 +97,44 @@ "estimatedSpeed": "Hinnanguline kiirus: ({{unit}})", "features": { "label": "Omadused", - "hasSnapshot": "Leidub hetkvõte" + "hasSnapshot": "Leidub hetkvõte", + "hasVideoClip": "Videoklipp on olemas", + "submittedToFrigatePlus": { + "label": "Saadetud teenusesse Frigate+", + "tips": "Sa pead filtreerima jälgitavaid objekte, millel on tõmmis.

    Kui jälgitaval objektil pole tõmmist, siis teda Frigate+ teenusesse saata ei saa." + } + }, + "attributes": { + "label": "Klassifitseerimisatribuudid", + "all": "Kõik atribuudid" + }, + "sort": { + "label": "Järjestus", + "dateAsc": "Kuupäev (kasvavalt)", + "dateDesc": "Kuupäev (kahanevalt)", + "scoreAsc": "Objekti punktiskoor (kasvavalt)", + "scoreDesc": "Objekti punktiskoor (kahanevalt)", + "speedAsc": "Hinnanguline kiirus (kasvavalt)", + "speedDesc": "Hinnanguline kiirus (kahanevalt)", + "relevance": "Teemakohasus" + }, + "review": { + "showReviewed": "Näita ülevaadatuid" + }, + "motion": { + "showMotionOnly": "Näita vaid liikumisega klippe" + }, + "zoneMask": { + "filterBy": "Tsoonimask filtri jaoks" + }, + "recognizedLicensePlates": { + "title": "Tuvastatud sõiduki numbrimärgid", + "loadFailed": "Tuvastatud sõiduki numbrimärkide laadimine ei õnnestunud.", + "loading": "Laadin tuvastatud sõiduki numbrimärke…", + "placeholder": "Sõidukite numbrimärkide otsimiseks kirjuta midagi…", + "noLicensePlatesFound": "Sõidukite numbrimärke ei leidu.", + "selectPlatesFromList": "Vali loendist üks või enam sõiduki numbrimärki.", + "selectAll": "Vali kõik", + "clearAll": "Eemalda kõik" } } diff --git a/web/public/locales/et/components/player.json b/web/public/locales/et/components/player.json index 76d41dd28b..bbf77830d8 100644 --- a/web/public/locales/et/components/player.json +++ b/web/public/locales/et/components/player.json @@ -4,7 +4,8 @@ "noPreviewFoundFor": "{{cameraName}} kaamera eelvaadet ei leidu", "submitFrigatePlus": { "submit": "Saada", - "title": "Kas saadad selle kaadri Frigate+ teenusesse?" + "title": "Kas saadad selle kaadri Frigate+ teenusesse?", + "previewError": "Hetktõmmise eelvaate laadimine ei õnnestu. Salvestus ei pruugi olla hetkel saadaval." }, "cameraDisabled": "Kaamera on kasutuselt eemaldatud", "stats": { diff --git a/web/public/locales/et/config/cameras.json b/web/public/locales/et/config/cameras.json index c2ff153faa..6c2bc5811d 100644 --- a/web/public/locales/et/config/cameras.json +++ b/web/public/locales/et/config/cameras.json @@ -2,5 +2,21 @@ "name": { "label": "Kaamera nimi", "description": "Kaamera nimi on nõutav" + }, + "friendly_name": { + "label": "Sõbralik nimi", + "description": "Frigate UI-s kasutatud kaamerasõbralik nimi" + }, + "enabled": { + "label": "Kasutusel", + "description": "Kasutusel" + }, + "audio": { + "label": "Helisündmused" + }, + "birdseye": { + "mode": { + "label": "Jälgimisrežiim" + } } } diff --git a/web/public/locales/et/config/global.json b/web/public/locales/et/config/global.json index 0967ef424b..ab44041b5c 100644 --- a/web/public/locales/et/config/global.json +++ b/web/public/locales/et/config/global.json @@ -1 +1,10 @@ -{} +{ + "audio": { + "label": "Helisündmused" + }, + "birdseye": { + "mode": { + "label": "Jälgimisrežiim" + } + } +} diff --git a/web/public/locales/et/config/groups.json b/web/public/locales/et/config/groups.json index 0967ef424b..e8c7956b5d 100644 --- a/web/public/locales/et/config/groups.json +++ b/web/public/locales/et/config/groups.json @@ -1 +1,73 @@ -{} +{ + "audio": { + "global": { + "detection": "Üldine tuvastamine", + "sensitivity": "Üldine tundlikkus" + }, + "cameras": { + "detection": "Tuvastamine", + "sensitivity": "Tundlikkus" + } + }, + "motion": { + "global": { + "sensitivity": "Üldine tundlikkus", + "algorithm": "Üldine algoritm" + }, + "cameras": { + "sensitivity": "Tundlikkus", + "algorithm": "Algoritm" + } + }, + "snapshots": { + "global": { + "display": "Üldine vaade" + }, + "cameras": { + "display": "Vaade" + } + }, + "timestamp_style": { + "global": { + "appearance": "Üldine välimus" + }, + "cameras": { + "appearance": "Välimus" + } + }, + "detect": { + "global": { + "resolution": "Üldine eraldusvõime", + "tracking": "Üldine jälgimine" + }, + "cameras": { + "resolution": "Eraldusvõime", + "tracking": "Jälgimine" + } + }, + "objects": { + "global": { + "filtering": "Üldine filtreerimine", + "tracking": "Üldine jälgimine" + }, + "cameras": { + "filtering": "Filtreerimine", + "tracking": "Jälgimine" + } + }, + "record": { + "global": { + "retention": "Üldine säilitamine", + "events": "Üldised sündmused" + }, + "cameras": { + "retention": "Säilitamine", + "events": "Sündmused" + } + }, + "ffmpeg": { + "cameras": { + "cameraFfmpeg": "Kaamerakohased FFmpegi argumendid" + } + } +} diff --git a/web/public/locales/et/config/validation.json b/web/public/locales/et/config/validation.json index 0967ef424b..ce014359a8 100644 --- a/web/public/locales/et/config/validation.json +++ b/web/public/locales/et/config/validation.json @@ -1 +1,32 @@ -{} +{ + "minimum": "Peab olema vähemalt {{limit}}", + "maximum": "Võib olla kuni {{limit}}", + "exclusiveMinimum": "Peab olema suurem, kui {{limit}}", + "exclusiveMaximum": "Peab olema väiksem, kui {{limit}}", + "minLength": "Peab olema vähemalt {{limit}} tähemärk(i) pikk", + "maxLength": "Võib olla kuni {{limit}} tähemärk(i) pikk", + "minItems": "Peab sisaldama vähemalt {{limit}} objekti", + "maxItems": "Võib sisaldada kuni {{limit}} objekti", + "pattern": "Vigane vorming", + "required": "See väli on kohustuslik", + "type": "Vigane väärtuse tüüp", + "enum": "Peab olema üks lubatud väärtustest", + "const": "Väärtus ei vasta eeldatud konstandile", + "uniqueItems": "Kõik väärtused peavad olema unikaalsed", + "format": "Vigane vorming", + "additionalProperties": "Tundmatu omadus pole lubatud", + "oneOf": "Peab vastama täpselt ühele lubatud skeemile", + "anyOf": "Peab vastama vähemalt ühele lubatud skeemile", + "proxy": { + "header_map": { + "roleHeaderRequired": "Kui rollide vastendused on seadistatud, siis rollide päis on nõutav." + } + }, + "ffmpeg": { + "inputs": { + "rolesUnique": "Iga rolli saad määrata ühele sisendvoole.", + "detectRequired": "„Tuvasta“ rollile pead määrama vähemalt ühe sisendvoo.", + "hwaccelDetectOnly": "Vaid „Tuvasta“ rolliga sisendvoog võib määratleda raudvaralise kiirenduse argumente." + } + } +} diff --git a/web/public/locales/et/views/chat.json b/web/public/locales/et/views/chat.json new file mode 100644 index 0000000000..cf68fe1e85 --- /dev/null +++ b/web/public/locales/et/views/chat.json @@ -0,0 +1,9 @@ +{ + "documentTitle": "Frigate - vestlus tehisaruga", + "title": "Vestlus tehisaruga Frigate'is", + "subtitle": "Tehisaru abil töötav abiline kaamerate haldamiseks ja analüüside koostamiseks", + "placeholder": "Küsi mida iganes…", + "error": "Midagi läks valesti. Palun proovi uuesti.", + "processing": "Töötlen…", + "toolsUsed": "Kasutatud: {{tools}}" +} diff --git a/web/public/locales/et/views/events.json b/web/public/locales/et/views/events.json index 75e4a3d5ce..b8f0e3ff6d 100644 --- a/web/public/locales/et/views/events.json +++ b/web/public/locales/et/views/events.json @@ -34,7 +34,9 @@ "normalActivity": "Tavaline", "needsReview": "Vajab ülevaatamist", "securityConcern": "Võib olla turvaprobleem", - "timeline": "Ajajoon", + "timeline": { + "label": "Ajajoon" + }, "timeline.aria": "Vali ajajoon", "zoomIn": "Suumi sisse", "zoomOut": "Suumi välja", @@ -53,7 +55,9 @@ }, "documentTitle": "Ülevaatamine - Frigate", "recordings": { - "documentTitle": "Salvestised - Frigate" + "documentTitle": "Salvestised - Frigate", + "invalidSharedLink": "Töötlemisvea tõttu ei õnnestu avada ajatempliga salvestuse linki.", + "invalidSharedCamera": "Tundmatu või volituseta kaamera tõttu ei õnnestu avada ajatempliga salvestuse linki." }, "calendarFilter": { "last24Hours": "Viimased 24 tundi" @@ -61,5 +65,28 @@ "objectTrack": { "clickToSeek": "Klõpsa siia ajapunkti kerimiseks", "trackedPoint": "Jälgitav punkt" + }, + "motionSearch": { + "menuItem": "Liikumise otsing", + "openMenu": "Kaamera valikud" + }, + "motionPreviews": { + "menuItem": "Vaata liikumiste eelvaateid", + "title": "Liikumiste eelvaated: {{camera}}", + "mobileSettingsTitle": "Liikumiste eelvaadete seadistused", + "mobileSettingsDesc": "Kohenda taasesituse kiirust ja heledust ning vali kuupäev, et vaadata läbi ainult liikumist kajastavaid klipid.", + "dim": "Hämarus", + "dimAria": "Muuda hämarust", + "dimDesc": "Kohenda hämarust parandamaks liikumisala nähtavust.", + "speed": "Kiirus", + "speedAria": "Vali eelvaate taasesituse kiirus", + "speedDesc": "Määratle kiirus, millega eelvaate klippe näidatakse.", + "back": "Tagasi", + "empty": "Ühtegi eelvaadet pole saadaval", + "noPreview": "Eelvaade pole saadaval", + "seekAria": "Keri „{{camera}}“ kaamera vaade ajatempli juurde: {{time}}", + "filter": "Filtreeri", + "filterDesc": "Näitamaks ainult liikumisega klippe antud aladel, vali soovitud piirkonnad.", + "filterClear": "Tühjenda" } } diff --git a/web/public/locales/et/views/explore.json b/web/public/locales/et/views/explore.json index 1676f3ccdb..b3bdbef570 100644 --- a/web/public/locales/et/views/explore.json +++ b/web/public/locales/et/views/explore.json @@ -36,7 +36,10 @@ "ratio": "Suhtarv", "area": "Ala", "score": "Punktiskoor" - } + }, + "external": "{{label}} on tuvastatud", + "heard": "{{label}} on kuuldud", + "gone": "{{label}} on jäänud" }, "title": "Jälgimise üksikasjad", "noImageFound": "Selle ajatempli kohta ei leidu pilti.", @@ -44,7 +47,8 @@ "carousel": { "previous": "Eelmine slaid", "next": "Järgmine slaid" - } + }, + "count": "{{first}} / {{second}}" }, "documentTitle": "Avasta - Frigate", "generativeAI": "Generatiivne tehisaru", diff --git a/web/public/locales/et/views/faceLibrary.json b/web/public/locales/et/views/faceLibrary.json index 7e47792d45..64e8fb90e1 100644 --- a/web/public/locales/et/views/faceLibrary.json +++ b/web/public/locales/et/views/faceLibrary.json @@ -34,6 +34,11 @@ }, "details": { "timestamp": "Ajatampel", - "unknown": "Pole teada" + "unknown": "Pole teada", + "scoreInfo": "Skoor on kõigi nägude hindete kaalutud keskmine, kus kaalukoefitsiendiks on iga pildi näo suurus." + }, + "uploadFaceImage": { + "title": "Laadi näopilt üles", + "desc": "Laadi üles pilt, et otsida sellelt nägusid ja lisada see {{pageToggle}}'i jaoks" } } diff --git a/web/public/locales/et/views/live.json b/web/public/locales/et/views/live.json index 891568c4de..27f8ff3fd8 100644 --- a/web/public/locales/et/views/live.json +++ b/web/public/locales/et/views/live.json @@ -14,7 +14,9 @@ "autotracking": "Automaatne jälgimine", "recording": "Salvestus" }, - "documentTitle": "Otseülekanne - Frigate", + "documentTitle": { + "default": "Frigate reaalajas" + }, "documentTitle.withCamera": "{{camera}} - Otseülekanne - Frigate", "lowBandwidthMode": "Väikese ribalaiusega režiim", "twoWayTalk": { @@ -30,7 +32,8 @@ "clickMove": { "label": "Kaamerapildi joondamiseks keskele klõpsa kaadris", "enable": "Kasuta klõpsamisega teisaldamist", - "disable": "Ära kasuta klõpsamisega teisaldamist" + "disable": "Ära kasuta klõpsamisega teisaldamist", + "enableWithZoom": "Luba liigutamine klõpsuga / suumimine lohistamisega" }, "left": { "label": "Pööra liigutatavat kaamerat vasakule" @@ -78,7 +81,8 @@ }, "recording": { "enable": "Lülita salvestamine sisse", - "disable": "Lülita salvestamine välja" + "disable": "Lülita salvestamine välja", + "disabledInConfig": "Pead olema selle kaamera jaoks Seadistustest määranud salvestamise." }, "snapshots": { "enable": "Lülita hetkvõtted sisse", @@ -100,11 +104,18 @@ }, "audio": { "available": "Selles voogedastuses on heliriba saadaval", - "unavailable": "Selles voogedastuses pole heliriba saadaval" + "unavailable": "Selles voogedastuses pole heliriba saadaval", + "tips": { + "title": "Heli peab tulema sinu kaamerast ja selle voogedastuse jaoks peab see go2rtc-s olema seadistatud." + } }, "title": "Voogedastus", "lowBandwidth": { - "resetStream": "Lähtesta voogedastus" + "resetStream": "Lähtesta voogedastus", + "tips": "Reaalaja pilt on puhverdamise või voogedastuse vigade tõttu madala ribalaiusega režiimis." + }, + "debug": { + "picker": "Voogedastuse osa valik pole silumisrežiimis saadaval. Silumisvaade kasutab alati voogedastust, millele on määratud tuvastamisroll." } }, "notifications": "Teavitused", @@ -137,7 +148,15 @@ "showStats": { "label": "Näita statistikat", "desc": "Selle eelistuse puhul näidatakse voogedastuse statistikat kaamerapildi peal." - } + }, + "tips": "Laadi alla hetktõmmis või käivita käsitsi sündmus vastavalt selle kaamera salvestiste säilitamise seadistustele.", + "start": "Alusta tellimuspõhist salvestamist", + "started": "Alustasin käsitsi tellitavat salvestamist.", + "failedToStart": "Käsitsi tellitava salvestamise alustamine ei õnnestunud.", + "recordDisabledTips": "Kuna selle kaamera seadistustes on salvestamine keelatud või piiratud, siis salvestatakse ainult pilt.", + "end": "Lõpeta tellimuspõhine salvestamine", + "ended": "Lõpetasin käsitsi tellitava salvestamise.", + "failedToEnd": "Käsitsi tellitava salvestamise lõpetamine ei õnnestunud." }, "noCameras": { "buttonText": "Lisa kaamera", @@ -174,5 +193,8 @@ }, "history": { "label": "Näita varasemat sisu" + }, + "suspend": { + "forTime": "Peatamise aeg: " } } diff --git a/web/public/locales/et/views/motionSearch.json b/web/public/locales/et/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/et/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/et/views/replay.json b/web/public/locales/et/views/replay.json new file mode 100644 index 0000000000..2f80829a43 --- /dev/null +++ b/web/public/locales/et/views/replay.json @@ -0,0 +1,17 @@ +{ + "dialog": { + "camera": "Lähtekaamera", + "timeRange": "Ajavahemik", + "preset": { + "1m": "Viimase ühe minuti jooksul", + "5m": "Viimase viie minuti jooksul", + "timeline": "Ajajoonelt", + "custom": "Kohandatud" + }, + "startButton": "Käivita kordus", + "selectFromTimeline": "Vali", + "starting": "Käivitan kordust…", + "startLabel": "Algus", + "endLabel": "Lõpp" + } +} diff --git a/web/public/locales/et/views/search.json b/web/public/locales/et/views/search.json index 52b917d226..6780001fbe 100644 --- a/web/public/locales/et/views/search.json +++ b/web/public/locales/et/views/search.json @@ -9,7 +9,8 @@ "clear": "Tühjenda otsing", "save": "Salvesta otsing", "delete": "Kustuta salvestatud otsing", - "filterInformation": "Filtri teave" + "filterInformation": "Filtri teave", + "filterActive": "Filtreid valituna" }, "filter": { "label": { @@ -17,7 +18,23 @@ "cameras": "Kaamerad", "labels": "Sildid", "zones": "Tsoonid", - "sub_labels": "Alamsildid" + "sub_labels": "Alamsildid", + "attributes": "Omadused", + "search_type": "Otsingutüüp", + "time_range": "Ajavahemik", + "before": "Enne", + "after": "Pärast" + }, + "searchType": { + "thumbnail": "Pisipilt", + "description": "Kirjeldus" + }, + "toast": { + "error": { + "beforeDateBeLaterAfter": "„Enne“ kuupäev peab olema varasem, kui „Pärast“ kuupäev.", + "afterDatebeEarlierBefore": "„Pärast“ kuupäev peab olema hilisem, kui „Enne“ kuupäev." + } } - } + }, + "trackedObjectId": "Jälgitava objekti tunnus" } diff --git a/web/public/locales/fa/views/chat.json b/web/public/locales/fa/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/fa/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/fa/views/motionSearch.json b/web/public/locales/fa/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/fa/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/fa/views/replay.json b/web/public/locales/fa/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/fa/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/fi/common.json b/web/public/locales/fi/common.json index 5cebc89399..ea16d95d81 100644 --- a/web/public/locales/fi/common.json +++ b/web/public/locales/fi/common.json @@ -38,11 +38,12 @@ "s": "{{time}}s", "minute_one": "{{time}}minuutti", "minute_other": "{{time}}minuuttia", - "second_one": "{{time}}sekuntti", - "second_other": "{{time}}sekunttia", + "second_one": "{{time}} sekunti", + "second_other": "{{time}} sekuntia", "formattedTimestampHourMinute": { "24hour": "HH:mm" - } + }, + "never": "Ei koskaan" }, "pagination": { "next": { diff --git a/web/public/locales/fi/components/auth.json b/web/public/locales/fi/components/auth.json index f81993d86b..167310fd32 100644 --- a/web/public/locales/fi/components/auth.json +++ b/web/public/locales/fi/components/auth.json @@ -10,6 +10,7 @@ "loginFailed": "Kirjautuminen epäonnistui", "unknownError": "Tuntematon virhe. Tarkista logit.", "webUnknownError": "Tuntematon virhe. Tarkista konsolilogi." - } + }, + "firstTimeLogin": "Ensimmäistä kertaa kirjautumassa sisään? Tunnukset löytyvät Frigaten lokista." } } diff --git a/web/public/locales/fi/components/dialog.json b/web/public/locales/fi/components/dialog.json index 819e4a55ea..7466d85132 100644 --- a/web/public/locales/fi/components/dialog.json +++ b/web/public/locales/fi/components/dialog.json @@ -6,7 +6,8 @@ "title": "Fregatti käynnistyy uudelleen", "content": "Tämä sivu latautuu uudelleen {{countdown}} sekunnin kuluttua.", "button": "Pakota uudelleenlataus nyt" - } + }, + "description": "Tämä sammuttaa Frigaten lyhyeksi aikaa uudelleenkäynnistyksen ajaksi." }, "explore": { "plus": { diff --git a/web/public/locales/fi/components/player.json b/web/public/locales/fi/components/player.json index e40d8b1145..ecb35f78ee 100644 --- a/web/public/locales/fi/components/player.json +++ b/web/public/locales/fi/components/player.json @@ -4,7 +4,8 @@ "noRecordingsFoundForThisTime": "Ei tallenteita valitulta ajalta", "submitFrigatePlus": { "title": "Lähetä tämä kuva Frigate+:aan?", - "submit": "Lähetä" + "submit": "Lähetä", + "previewError": "Pysäytyskuvan esikatselua ei voi ladata. Tallenne ei ole ehkä saatavissa tällä hetkellä." }, "livePlayerRequiredIOSVersion": "iOS 17.1 tai uudempi vaaditaan tälle suoratoistotyypille.", "streamOffline": { diff --git a/web/public/locales/fi/config/cameras.json b/web/public/locales/fi/config/cameras.json index 0967ef424b..ac6cd5a49b 100644 --- a/web/public/locales/fi/config/cameras.json +++ b/web/public/locales/fi/config/cameras.json @@ -1 +1,22 @@ -{} +{ + "label": "Kamerakonfiguraatio", + "name": { + "label": "Kameran nimi", + "description": "Kameran nimi vaaditaan" + }, + "friendly_name": { + "label": "Kutsumanimi", + "description": "Kameran kutsumanimeä käytetään Frigaten käyttöliittymässä" + }, + "enabled": { + "description": "Käytössä" + }, + "audio": { + "label": "Ääni tapahtumat", + "description": "Äänipohjaisen havaitsemisen asetukset tälle kameralle.", + "enabled": { + "label": "Ota ääni havainnointi käyttöön", + "description": "Ota tai poista käytöstä ääni tapahtuman havaiseminen tälle kameralle." + } + } +} diff --git a/web/public/locales/fi/config/global.json b/web/public/locales/fi/config/global.json index 0967ef424b..fc3e5ac35c 100644 --- a/web/public/locales/fi/config/global.json +++ b/web/public/locales/fi/config/global.json @@ -1 +1,21 @@ -{} +{ + "version": { + "label": "Nykyinen konfigurointiversio" + }, + "safe_mode": { + "label": "Vikasietotila", + "description": "Kun käytössä, käynnistä Frigate vikasietotilassa rajoitetuilla ominaisuuksilla vianselvitystä varten." + }, + "logger": { + "label": "Lokitus", + "default": { + "label": "Lokituksen taso" + } + }, + "audio": { + "label": "Ääni tapahtumat", + "enabled": { + "label": "Ota ääni havainnointi käyttöön" + } + } +} diff --git a/web/public/locales/fi/config/groups.json b/web/public/locales/fi/config/groups.json index 0967ef424b..4e54fa92d5 100644 --- a/web/public/locales/fi/config/groups.json +++ b/web/public/locales/fi/config/groups.json @@ -1 +1,30 @@ -{} +{ + "audio": { + "global": { + "detection": "Globaali tunnistus", + "sensitivity": "Globaali herkkyys" + }, + "cameras": { + "detection": "Havaitseminen", + "sensitivity": "Herkkyys" + } + }, + "timestamp_style": { + "global": { + "appearance": "Globaali vaikutelma" + }, + "cameras": { + "appearance": "Vaikutelma" + } + }, + "motion": { + "global": { + "sensitivity": "Globaali herkkyys", + "algorithm": "Globaali algoritmi" + }, + "cameras": { + "sensitivity": "Herkkyys", + "algorithm": "Algoritmi" + } + } +} diff --git a/web/public/locales/fi/config/validation.json b/web/public/locales/fi/config/validation.json index 0967ef424b..a361e49c69 100644 --- a/web/public/locales/fi/config/validation.json +++ b/web/public/locales/fi/config/validation.json @@ -1 +1,13 @@ -{} +{ + "minimum": "Täytyy olla vähintään {{limit}}", + "maximum": "Täytyy olla korkeitaan {{limit}}", + "exclusiveMinimum": "Täytyy olla suurempi kuin {{limit}}", + "exclusiveMaximum": "Täytyy olla vähemmän kuin {{limit}}", + "minLength": "Täytyy olla vähintään {{limit}} merkkiä", + "maxLength": "Täytyy olla enintään {{limit}} merkkiä", + "minItems": "Täytyy olla vähintään {{limit}} kappaletta", + "maxItems": "Täytyy olla enintään {{limit}} kappaletta", + "pattern": "Väärä formaatti", + "required": "Tämä kenttä on pakollinen", + "type": "Väärä arvon tyyppi" +} diff --git a/web/public/locales/fi/views/chat.json b/web/public/locales/fi/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/fi/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/fi/views/classificationModel.json b/web/public/locales/fi/views/classificationModel.json index 477b0e2e91..554a664dd3 100644 --- a/web/public/locales/fi/views/classificationModel.json +++ b/web/public/locales/fi/views/classificationModel.json @@ -2,10 +2,16 @@ "documentTitle": "Luokittelumallit - Frigate", "details": { "scoreInfo": "Pistemäärä edustaa tämän objektin kaikkien havaintojen keskimääräistä luokitteluvarmuutta.", - "none": "Ei mitään" + "none": "Ei mitään", + "unknown": "Tuntematon" }, "button": { "deleteImages": "Poista kuvat", - "trainModel": "Kouluta malli" + "trainModel": "Kouluta malli", + "deleteClassificationAttempts": "Poista luokitellut kuvat", + "deleteCategory": "Poista luokka", + "addClassification": "Lisää luokitus", + "deleteModels": "Poista mallit", + "editModel": "Muokkaa mallia" } } diff --git a/web/public/locales/fi/views/events.json b/web/public/locales/fi/views/events.json index 57eb44a808..4be6cc1dab 100644 --- a/web/public/locales/fi/views/events.json +++ b/web/public/locales/fi/views/events.json @@ -1,9 +1,12 @@ { - "alerts": "Hälytyset", + "alerts": "Hälytykset", "empty": { "detection": "Ei havaintoja tarkastettavaksi", "motion": "Ei liiketietoja", - "alert": "Ei hälyytyksiä tarkastettavaksi" + "alert": "Ei hälyytyksiä tarkastettavaksi", + "recordingsDisabled": { + "title": "Tallenteet täytyy ottaa käyttöön" + } }, "detections": "Havainnot", "motion": { @@ -11,7 +14,9 @@ "only": "Vain liike" }, "allCameras": "Kaikki kamerat", - "timeline": "Aikajana", + "timeline": { + "label": "Aikajana" + }, "timeline.aria": "Valitse aikajana", "events": { "label": "Tapahtumat", diff --git a/web/public/locales/fi/views/exports.json b/web/public/locales/fi/views/exports.json index 22f39ceb11..7ba06ef0e9 100644 --- a/web/public/locales/fi/views/exports.json +++ b/web/public/locales/fi/views/exports.json @@ -8,13 +8,21 @@ } }, "noExports": "Ei vietyjä kohteita", - "deleteExport": "Poista viety kohde", + "deleteExport": { + "label": "Poista vienti" + }, "editExport": { "title": "Nimeä uudelleen", "desc": "Anna uusi nimi viedylle kohteelle.", "saveExport": "Tallenna vienti" }, "tooltip": { - "editName": "Muokkaa nimeä" + "editName": "Muokkaa nimeä", + "shareExport": "Jaa vienti", + "downloadVideo": "Lataa video" + }, + "headings": { + "cases": "Tapaukset", + "uncategorizedExports": "Kategorisoimattomat viennit" } } diff --git a/web/public/locales/fi/views/faceLibrary.json b/web/public/locales/fi/views/faceLibrary.json index dc69f36940..6e1872ba3b 100644 --- a/web/public/locales/fi/views/faceLibrary.json +++ b/web/public/locales/fi/views/faceLibrary.json @@ -2,7 +2,8 @@ "description": { "addFace": "Opastus: Uuden kokoelman lisääminen Kasvokirjastoon.", "invalidName": "Virheellinen nimi. Nimi voi sisältää vain merkkejä, numeroita, välejä, heittomerkkejä, alaviivoja ja väliviivoja.", - "placeholder": "Anna nimi kokoelmalle" + "placeholder": "Anna nimi kokoelmalle", + "nameCannotContainHash": "Nimi ei voi sisältää \"#\"." }, "uploadFaceImage": { "desc": "Lähetä kuva kasvojen tunnistukseen ja lisää se sivulle {{pageToggle}}", diff --git a/web/public/locales/fi/views/live.json b/web/public/locales/fi/views/live.json index d38703565a..93984cf502 100644 --- a/web/public/locales/fi/views/live.json +++ b/web/public/locales/fi/views/live.json @@ -1,5 +1,7 @@ { - "documentTitle": "Suora - Frigate", + "documentTitle": { + "default": "Suora - Frigate" + }, "documentTitle.withCamera": "{{camera}} - Suora - Frigate", "lowBandwidthMode": "Pienen kaistanleveyden tila", "twoWayTalk": { diff --git a/web/public/locales/fi/views/motionSearch.json b/web/public/locales/fi/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/fi/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/fi/views/replay.json b/web/public/locales/fi/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/fi/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/fi/views/settings.json b/web/public/locales/fi/views/settings.json index df2f2eb563..c988472abc 100644 --- a/web/public/locales/fi/views/settings.json +++ b/web/public/locales/fi/views/settings.json @@ -8,10 +8,12 @@ "general": "Yleiset asetukset - Frigate", "frigatePlus": "Frigate+ asetukset - Frigate", "object": "Virheenjäljitys - Frigate", - "authentication": "Autentikointiuasetukset - Frigate", + "authentication": "Autentikointiasetukset - Frigate", "notifications": "Ilmoitusasetukset - Frigate", "enrichments": "Laajennusasetukset – Frigate", - "cameraManagement": "Hallitse Kameroita - Frigate" + "cameraManagement": "Hallitse Kameroita - Frigate", + "globalConfig": "Globaali konfiguraatio - Frigate", + "cameraConfig": "Kamera konfiguraatio - Frigate" }, "menu": { "ui": "Käyttöliittymä", diff --git a/web/public/locales/fi/views/system.json b/web/public/locales/fi/views/system.json index 04952692e2..2bb08286dd 100644 --- a/web/public/locales/fi/views/system.json +++ b/web/public/locales/fi/views/system.json @@ -20,6 +20,10 @@ "fetchingLogsFailed": "Virhe noudettaessa lokeja: {{errorMessage}}", "whileStreamingLogs": "Virhe toistettaessa lokeja: {{errorMessage}}" } + }, + "websocket": { + "label": "Viestit", + "pause": "Pysäytä" } }, "documentTitle": { @@ -30,7 +34,8 @@ "logs": { "frigate": "Frigaten lokit - Frigate", "go2rtc": "Go2RTC lokit - Frigate", - "nginx": "Nginx lokit - Frigate" + "nginx": "Nginx lokit - Frigate", + "websocket": "Viestilokit - Frigate" } }, "title": "Järjestelmä", diff --git a/web/public/locales/fr/common.json b/web/public/locales/fr/common.json index 2ba13dd185..ff940a27d3 100644 --- a/web/public/locales/fr/common.json +++ b/web/public/locales/fr/common.json @@ -1,6 +1,6 @@ { "time": { - "untilForRestart": "Jusqu'au redémarrage de Frigate", + "untilForRestart": "Jusqu'à ce que Frigate redémarre.", "untilRestart": "Jusqu'au redémarrage", "untilForTime": "Jusqu'à {{time}}", "justNow": "À l'instant", @@ -139,7 +139,9 @@ "resetToDefault": "Réinitialiser aux réglages par défaut", "saveAll": "Tout enregistrer", "savingAll": "Enregistrement de tout en cours…", - "undoAll": "Tout annuler" + "undoAll": "Tout annuler", + "applying": "Enregistrement…", + "retry": "Réessayer" }, "menu": { "configuration": "Configuration", @@ -244,7 +246,10 @@ "faceLibrary": "Bibliothèque de visages", "languages": "Langues", "classification": "Classification", - "profiles": "Profils" + "profiles": "Profils", + "actions": "Actions", + "features": "Fonctionnalités", + "chat": "Discuter" }, "toast": { "save": { @@ -252,9 +257,10 @@ "error": { "noMessage": "Echec lors de l'enregistrement des changements de configuration", "title": "Échec de l'enregistrement des changements de configuration : {{errorMessage}}" - } + }, + "success": "Modifications enregistrées avec succès." }, - "copyUrlToClipboard": "URL copiée dans le presse-papiers" + "copyUrlToClipboard": "URL copiée dans le presse-papiers." }, "role": { "title": "Rôle", @@ -324,5 +330,7 @@ "two": "{{0}} et {{1}}", "many": "{{items}}, et {{last}}", "separatorWithSpace": ", " - } + }, + "no_items": "Aucun élément", + "validation_errors": "Erreurs de validation" } diff --git a/web/public/locales/fr/components/camera.json b/web/public/locales/fr/components/camera.json index 0e95c70e32..6204915d09 100644 --- a/web/public/locales/fr/components/camera.json +++ b/web/public/locales/fr/components/camera.json @@ -82,6 +82,7 @@ }, "boundingBox": "Cadre de détection", "zones": "Zones", - "regions": "Régions" + "regions": "Régions", + "paths": "Chemins" } } diff --git a/web/public/locales/fr/components/dialog.json b/web/public/locales/fr/components/dialog.json index a2accb9308..4465dcd449 100644 --- a/web/public/locales/fr/components/dialog.json +++ b/web/public/locales/fr/components/dialog.json @@ -80,7 +80,53 @@ }, "case": { "label": "Dossier", - "placeholder": "Sélectionner un dossier" + "placeholder": "Sélectionner un dossier", + "newCaseOption": "Créer un nouveau cas", + "newCaseNamePlaceholder": "Nouveau nom de cas", + "newCaseDescriptionPlaceholder": "Description de cas", + "nonAdminHelp": "Un nouveau cas sera créé pour ces exports." + }, + "queueing": "Mise en file d'attente de l'export...", + "tabs": { + "export": "Caméra unique", + "multiCamera": "Multi-caméra" + }, + "multiCamera": { + "timeRange": "Intervalle de temps", + "selectFromTimeline": "Sélectionner depuis la chronologie", + "cameraSelection": "Caméras", + "cameraSelectionHelp": "Les caméras avec des objets suivis dans cette intervalle sont pré-sélectionnées", + "checkingActivity": "Vérification de l'activité de la caméra...", + "noCameras": "Aucune caméra disponible", + "detectionCount_one": "{{count}} objet suivi", + "detectionCount_many": "{{count}} objets suivis", + "detectionCount_other": "{{count}} objets suivis", + "nameLabel": "Nom d'export", + "namePlaceholder": "Nom de base optionnel pour ces exports", + "queueingButton": "Mise en file d'attente des exports...", + "exportButton_one": "Exporter {{count}} caméra", + "exportButton_many": "Exporter {{count}} caméras", + "exportButton_other": "Exporter {{count}} caméras" + }, + "multi": { + "title_one": "Export {{count}} revue", + "title_many": "Export {{count}} revues", + "title_other": "Export {{count}} revues", + "description": "Export chaque revue sélectionnée. Tous les exports sont regroupés sous un cas unique.", + "descriptionNoCase": "Exporter chaque revue sélectionnée.", + "caseNamePlaceholder": "Vérification de l'export – {{date}}", + "exportButton_one": "Exporter {{count}} revue", + "exportButton_many": "Exporter {{count}} revues", + "exportButton_other": "Exporter {{count}} revues", + "exportingButton": "Exportation...", + "toast": { + "started_one": "Un export a démarré. Ouverture du dossier en cours", + "started_many": "{{count}} exports ont démarré. Ouverture du dossier en cours", + "started_other": "", + "startedNoCase_one": "Un export a démarré.", + "startedNoCase_many": "{{count}} exports ont démarré.", + "startedNoCase_other": "{{count}} exports ont démarré." + } } }, "search": { diff --git a/web/public/locales/fr/components/player.json b/web/public/locales/fr/components/player.json index 6450c12617..12e0108841 100644 --- a/web/public/locales/fr/components/player.json +++ b/web/public/locales/fr/components/player.json @@ -4,7 +4,8 @@ "noPreviewFound": "Aucun aperçu trouvé", "submitFrigatePlus": { "title": "Soumettre cette image à Frigate+ ?", - "submit": "Soumettre" + "submit": "Soumettre", + "previewError": "Impossible de télécharger le snapshot. L'enregistrement ne pas disponible sur cette période." }, "streamOffline": { "title": "Flux hors ligne", diff --git a/web/public/locales/fr/config/cameras.json b/web/public/locales/fr/config/cameras.json index ca00146dba..919ee6df89 100644 --- a/web/public/locales/fr/config/cameras.json +++ b/web/public/locales/fr/config/cameras.json @@ -78,8 +78,8 @@ "label": "Détection d'objets", "description": "Réglages pour la détection ou le rôle de détection utilisé pour exécuter la détection des objets et initialiser les traceurs.", "enabled": { - "label": "Détection activée", - "description": "Activer ou désactiver la détection des objets pour cette caméra. La détection doit être activée pour que le suivi des objets fonctionne." + "label": "Activer la détection d'objet", + "description": "Activer ou désactiver la détection des objets pour cette caméra." }, "height": { "label": "Hauteur de détection", @@ -299,6 +299,10 @@ }, "raw_mask": { "label": "Masque brut" + }, + "skip_motion_threshold": { + "label": "Ignorer le seuil de détection de mouvement", + "description": "Si une valeur entre 0,0 et 1,0 est définie, et que plus de cette fraction de l'image change en une seule trame, le détecteur ne retournera aucune zone de mouvement et se recalibrera immédiatement. Cela peut économiser du CPU et réduire les faux positifs lors d'éclairs, d'orages, etc., mais peut manquer des événements réels comme une caméra PTZ suivant automatiquement un objet. Le compromis est entre perdre quelques mégaoctets d'enregistrements ou visionner quelques courts clips. Laisser vide (None) pour désactiver cette fonctionnalité." } }, "objects": { @@ -312,9 +316,17 @@ "label": "Filtres d'objets", "description": "Filtres appliqués aux objets détectés afin de réduire les faux positifs (aire, rapport, facteur de confiance).", "min_area": { - "label": "Aire minimal de l'objet" + "label": "Aire minimal de l'objet", + "description": "Surface minimale de la boîte englobante (en pixels ou pourcentage) requise pour ce type d'objet. Peut être exprimée en pixels (entier) ou en pourcentage (flottant entre 0,000001 et 0,99)." + }, + "max_area": { + "label": "Zone d'objet maximum", + "description": "Zone de boite englobante maximum (pixels ou pourcentage) autorisée pour ce type d'objet. Peut être en pixels (entier) ou pourcentage (décimale entre 0,000001 and 0,99)." + }, + "min_ratio": { + "label": "Rapport d'aspect minimal" } } }, - "label": "ConfigurationCamera" + "label": "Configuration de la caméra" } diff --git a/web/public/locales/fr/config/global.json b/web/public/locales/fr/config/global.json index b3dd9d23ff..1108b2d1b7 100644 --- a/web/public/locales/fr/config/global.json +++ b/web/public/locales/fr/config/global.json @@ -1,7 +1,7 @@ { "version": { "label": "Version actuelle de la configuration", - "description": "Version numérique ou sous forme de chaîne de la configuration active, permettant de détecter les migrations ou les changements de format" + "description": "Version numérique ou textuelle de la configuration active, utilisée pour aider à détecter les migrations ou les changements de format." }, "safe_mode": { "label": "Mode sans échec", @@ -77,5 +77,40 @@ "path": { "label": "Chemin vers la base de donnée" } + }, + "genai": { + "provider": { + "label": "Fournisseur" + } + }, + "birdseye": { + "quality": { + "label": "Qualité d'encodage" + } + }, + "detect": { + "enabled": { + "label": "Activer la détection d'objet" + } + }, + "motion": { + "skip_motion_threshold": { + "label": "Ignorer le seuil de détection de mouvement", + "description": "Si une valeur entre 0,0 et 1,0 est définie, et que plus de cette fraction de l'image change en une seule trame, le détecteur ne retournera aucune zone de mouvement et se recalibrera immédiatement. Cela peut économiser du CPU et réduire les faux positifs lors d'éclairs, d'orages, etc., mais peut manquer des événements réels comme une caméra PTZ suivant automatiquement un objet. Le compromis est entre perdre quelques mégaoctets d'enregistrements ou visionner quelques courts clips. Laisser vide (None) pour désactiver cette fonctionnalité." + } + }, + "objects": { + "filters": { + "min_area": { + "description": "Surface minimale de la boîte englobante (en pixels ou pourcentage) requise pour ce type d'objet. Peut être exprimée en pixels (entier) ou en pourcentage (flottant entre 0,000001 et 0,99)." + }, + "max_area": { + "label": "Zone d'objet maximum", + "description": "Zone de boite englobante maximum (pixels ou pourcentage) autorisée pour ce type d'objet. Peut être en pixels (entier) ou pourcentage (décimale entre 0,000001 and 0,99)." + }, + "min_ratio": { + "label": "Rapport d'aspect minimal" + } + } } } diff --git a/web/public/locales/fr/config/validation.json b/web/public/locales/fr/config/validation.json index aa4acd887a..ac3853b8ff 100644 --- a/web/public/locales/fr/config/validation.json +++ b/web/public/locales/fr/config/validation.json @@ -1,6 +1,6 @@ { "minimum": "Doit être au moins de {{limit}}", - "maximum": "Ne doit pas dépasser {{limit}}", + "maximum": "Doit être au maximum {{limit}}", "exclusiveMinimum": "Doit être supérieur à {{limit}}", "exclusiveMaximum": "Doit être inférieur à {{limit}}", "minLength": "Doit contenir au moins {{limit}} caractère(s)", diff --git a/web/public/locales/fr/objects.json b/web/public/locales/fr/objects.json index 9c9d5a6cf3..afc5791aea 100644 --- a/web/public/locales/fr/objects.json +++ b/web/public/locales/fr/objects.json @@ -116,5 +116,10 @@ "dining_table": "Table à manger", "vase": "Vase", "purolator": "Purolator", - "postnord": "PostNord" + "postnord": "PostNord", + "canada_post": "Poste du Canada", + "royal_mail": "Poste du Royaume Uni", + "school_bus": "Bus scolaire", + "skunk": "Mouffette", + "kangaroo": "Kangourou" } diff --git a/web/public/locales/fr/views/chat.json b/web/public/locales/fr/views/chat.json new file mode 100644 index 0000000000..5d75f17db7 --- /dev/null +++ b/web/public/locales/fr/views/chat.json @@ -0,0 +1,20 @@ +{ + "documentTitle": "Messagerie - Frigate", + "title": "Messagerie Frigate", + "subtitle": "Votre assistant IA pour la gestion des camera et des aperçus", + "placeholder": "Posez moi toutes vos questions...", + "error": "Il y a eu un problème. Merci de bien vouloir réessayer.", + "processing": "Traitement en cours...", + "hideTools": "Masquer les outils", + "call": "Appeler", + "result": "Résultat", + "arguments": "Arguments :", + "response": "Réponse :", + "attachment_chip_label": "{{label}} sur {{camera}}", + "attachment_chip_remove": "Supprimer la pièce jointe", + "open_in_explore": "Ouvrir dans l'explorateur", + "attach_event_aria": "Attacher l'événement {{eventId}}", + "attachment_picker_paste_label": "Ou coller l'event ID", + "attachment_picker_placeholder": "Attacher un événement", + "quick_reply_find_similar": "Trouver des observations similaires" +} diff --git a/web/public/locales/fr/views/explore.json b/web/public/locales/fr/views/explore.json index 6379364508..6c116ef9c1 100644 --- a/web/public/locales/fr/views/explore.json +++ b/web/public/locales/fr/views/explore.json @@ -113,7 +113,8 @@ "attributes": "Attributs de classification", "title": { "label": "Titre" - } + }, + "scoreInfo": "Information score" }, "type": { "details": "détails", @@ -222,12 +223,22 @@ "downloadCleanSnapshot": { "label": "Télécharger l'instantané vierge", "aria": "Télécharger l'instantané vierge" + }, + "debugReplay": { + "label": "Relecture de débogage", + "aria": "Visualiser cet objet suivi dans la vue de la session de relecture de déboggage" + }, + "more": { + "aria": "Plus" } }, "dialog": { "confirmDelete": { "title": "Confirmer la suppression", "desc": "La suppression de cet objet suivi supprime l'instantané, les embeddings enregistrés et les entrées du cycle de vie de l'objet associé. Les images enregistrées de cet objet suivi dans la vue Chronologie NE seront PAS supprimées.

    Êtes-vous sûr de vouloir continuer ?" + }, + "toast": { + "error": "Une erreur est survenue lors de la suppression de cet objet suivi : {{errorMessage}}" } }, "noTrackedObjects": "Aucun objet suivi trouvé", @@ -278,7 +289,10 @@ "zones": "Zones", "ratio": "Ratio", "area": "Surface", - "score": "Score" + "score": "Score", + "computedScore": "Score calculé", + "topScore": "Meilleur score", + "toggleAdvancedScores": "Afficher/masquer les scores avancés" } }, "annotationSettings": { diff --git a/web/public/locales/fr/views/exports.json b/web/public/locales/fr/views/exports.json index 9e26a27e57..f182eb60ec 100644 --- a/web/public/locales/fr/views/exports.json +++ b/web/public/locales/fr/views/exports.json @@ -35,5 +35,10 @@ "newCaseOption": "Créer un nouveau dossier", "nameLabel": "Nom du dossier", "descriptionLabel": "Description" + }, + "deleteCase": { + "desc": "Êtes-vous sûr de vouloir supprimer {{caseName}}?", + "descKeepExports": "Les exports seront disponibles comme exports non catégorisés.", + "deleteExports": "Supprimer aussi les exports" } } diff --git a/web/public/locales/fr/views/faceLibrary.json b/web/public/locales/fr/views/faceLibrary.json index 83138d7eca..e61bfe9a2c 100644 --- a/web/public/locales/fr/views/faceLibrary.json +++ b/web/public/locales/fr/views/faceLibrary.json @@ -67,7 +67,8 @@ "deletedFace_many": "{{count}} visages supprimés avec succès", "deletedFace_other": "{{count}} visages supprimés avec succès", "trainedFace": "Visage entraîné avec succès", - "renamedFace": "Visage renommé avec succès en {{name}}" + "renamedFace": "Visage renommé avec succès en {{name}}", + "reclassifiedFace": "Visage reclassifié avec succès." }, "error": { "uploadingImageFailed": "Échec du téléversement de l'image : {{errorMessage}}", @@ -76,7 +77,8 @@ "updateFaceScoreFailed": "Échec de la mise à jour du score du visage : {{errorMessage}}", "addFaceLibraryFailed": "Échec de l'attribution du nom au visage : {{errorMessage}}", "deleteNameFailed": "Échec de la suppression du nom : {{errorMessage}}", - "renameFaceFailed": "Échec du changement de nom du visage : {{errorMessage}}" + "renameFaceFailed": "Échec du changement de nom du visage : {{errorMessage}}", + "reclassifyFailed": "Échec de la reclassification du visage : {{errorMessage}}" } }, "trainFaceAs": "Entraîner le visage comme :", @@ -101,5 +103,7 @@ "desc_other": "Êtes-vous sûr de vouloir supprimer {{count}} visages ? Cette action est irréversible." }, "nofaces": "Aucun visage disponible", - "pixels": "{{area}} pixels" + "pixels": "{{area}} pixels", + "reclassifyFaceAs": "Reclassifier le visage en :", + "reclassifyFace": "Reclassifier le visage" } diff --git a/web/public/locales/fr/views/motionSearch.json b/web/public/locales/fr/views/motionSearch.json new file mode 100644 index 0000000000..5f47e9942a --- /dev/null +++ b/web/public/locales/fr/views/motionSearch.json @@ -0,0 +1,41 @@ +{ + "documentTitle": "Recherche de mouvement - Frigate", + "title": "Recherche de mouvement", + "description": "Dessinez un polygone pour définir la zonecible, et spécifiez une fourchette de temps pour chercher les mouvements dans cette zone.", + "selectCamera": "Recherche de mouvement en cours de chargement", + "startSearch": "Commencer la recherche", + "searchStarted": "Recherche démarrée", + "searchCancelled": "Recherche annulée", + "cancelSearch": "Annuler recherche", + "searching": "Recherche en cours.", + "searchComplete": "Recherche terminée", + "noResultsYet": "Lancez une recherche pour trouver les changements de mouvement dans la zone sélectionnée", + "noChangesFound": "Aucun changement de pixel détecté dans la zone sélectionnée", + "changesFound_one": "{{count}} changement de mouvement détecté", + "changesFound_many": "{{count}} changements de mouvement détectés", + "changesFound_other": "{{count}} changements de mouvement détectés", + "framesProcessed": "{{count}} images traitées", + "jumpToTime": "Aller à ce moment", + "results": "Résultats", + "showSegmentHeatmap": "Carte thermique", + "newSearch": "Nouvelle recherche", + "clearResults": "Effacer les résultats", + "clearROI": "Effacer le polygone", + "polygonControls": { + "points_one": "{{count}} point", + "points_many": "{{count}} points", + "points_other": "{{count}} points", + "undo": "Annuler le dernier point", + "reset": "Réinitialiser le polygone" + }, + "motionHeatmapLabel": "Carte thermique des mouvements", + "dialog": { + "title": "Recherche de mouvement", + "cameraLabel": "Caméra", + "previewAlt": "Aperçu de la caméra {{camera}}" + }, + "timeRange": { + "title": "Plage de recherche", + "start": "Plage de recherche" + } +} diff --git a/web/public/locales/fr/views/replay.json b/web/public/locales/fr/views/replay.json new file mode 100644 index 0000000000..e230ad3f7c --- /dev/null +++ b/web/public/locales/fr/views/replay.json @@ -0,0 +1,8 @@ +{ + "title": "Debug - Rejouer", + "description": "Rejouer les enregistrement de la camera, à but de débogage. La liste d'objets montre un résumé avec retard des objets détectés; et l'onglet Messages montre le flux des messages internes à Frigate liés à la vidéo rejouée.", + "websocket_messages": "Messages", + "dialog": { + "title": "Démarrer le Rejeu-Debogage" + } +} diff --git a/web/public/locales/fr/views/settings.json b/web/public/locales/fr/views/settings.json index c9b3ccb87c..3a45a6ef9c 100644 --- a/web/public/locales/fr/views/settings.json +++ b/web/public/locales/fr/views/settings.json @@ -89,7 +89,8 @@ "cameraMqtt": "MQTT de la caméra", "maintenance": "Maintenance", "uiSettings": "Paramètres IU", - "profiles": "Profils" + "profiles": "Profils", + "systemGo2rtcStreams": "Streams go2rtc" }, "dialog": { "unsavedChanges": { @@ -448,6 +449,17 @@ "error": { "mustBeGreaterOrEqualTo": "Le seuil de vitesse doit être supérieur ou égal à 0.1." } + }, + "id": { + "error": { + "mustNotBeEmpty": "L'ID ne doit pas être vide.", + "alreadyExists": "Un masque avec cet ID existe déjà pour cette caméra." + } + }, + "name": { + "error": { + "mustNotBeEmpty": "Le nom ne doit pas être vide." + } } }, "zones": { @@ -572,7 +584,11 @@ }, "restart_required": "Redémarrage requis (masques/zones changés)", "objectMaskLabel": "Masque d'objet {{number}}", - "motionMaskLabel": "Masque de mouvement {{number}}" + "motionMaskLabel": "Masque de mouvement {{number}}", + "disabledInConfig": "Cet objet est désactivé dans le fichier de configuration", + "addDisabledProfile": "Ajouter dans la configuration de base d’abord puis remplacez le dans le profil", + "profileBase": "(base)", + "profileOverride": "(remplacer)" }, "motionDetectionTuner": { "title": "Réglage de la détection de mouvement", @@ -1308,7 +1324,11 @@ "backToSettings": "Retour aux paramètres de la caméra", "streams": { "title": "Activer / désactiver les caméras", - "desc": "Désactive temporairement une caméra jusqu'au redémarrage de Frigate. La désactivation d'une caméra interrompt complètement le traitement des flux de la caméra par Frigate. La détection, l'enregistrement et le débogage deviennent alors indisponibles.
    Remarque : cela n'affecte pas les rediffusions des flux go2rtc." + "desc": "Désactive temporairement une caméra jusqu'au redémarrage de Frigate. La désactivation d'une caméra interrompt complètement le traitement des flux de la caméra par Frigate. La détection, l'enregistrement et le débogage deviennent alors indisponibles.
    Remarque : cela n'affecte pas les rediffusions des flux go2rtc.", + "enableLabel": "Caméras activées", + "disableLabel": "Caméra désactivées", + "disableDesc": "Activer une caméra qui n'est pas visible dans l'interface et désactivée dans la configuration. Un redémarrage de Frigate est nécessaire après l'activation.", + "enableSuccess": "Activer {{cameraName}} dans la configuration. Redémarrer Frigate pour appliquer les changements." }, "cameraConfig": { "add": "Ajouter une caméra", @@ -1338,6 +1358,25 @@ "toast": { "success": "La caméra {{cameraName}} a été enregistrée avec succès" } + }, + "deleteCamera": "Supprimer la caméra", + "deleteCameraDialog": { + "title": "Supprimer la caméra", + "description": "Supprimer la caméra va supprimer de façon permanente les enregistrements, les objets suivis, et la configuration de la caméra. Tous les streams go2rtc associés à la caméra devront être supprimés manuellement.", + "selectPlaceholder": "Choisir une caméra...", + "confirmTitle": "Êtes-vous sûr?", + "confirmWarning": "Supprimer \n{{cameraName}}\n ne peut être annulé.", + "deleteExports": "Supprimer aussi les exports de cette caméra", + "confirmButton": "Suppression permanente", + "success": "Caméra {{cameraName}} supprimée avec succès", + "error": "Impossible de supprimer la caméra {{cameraName}}" + }, + "profiles": { + "selectLabel": "Choisir un profil", + "description": "Configurer quelles caméras sont activées ou désactivées quand un profil est activé. Les caméras activées avec \"Inherit\" conservent leur statut de base.", + "inherit": "Hériter", + "enabled": "Activé", + "disabled": "Désactivé" } }, "cameraReview": { @@ -1392,6 +1431,9 @@ "value": { "label": "Nouvelle valeur", "reset": "Réinitialiser" + }, + "profile": { + "label": "Profil" } }, "button": { @@ -1405,5 +1447,115 @@ "sync": { "title": "Synchronisation du Média" } - } + }, + "configMessages": { + "lpr": { + "vehicleNotTracked": "La reconnaissance de plaque d'immatriculation requiert que 'voiture' ou 'moto' soit suivi.", + "globalDisabled": "La reconnaissance de numéro d'immatriculation n'est pas activée au niveau global. Activez-la dans les paramètres globaux pour que la reconnaissance de plaques fonctionne au niveau caméra." + }, + "review": { + "recordDisabled": "L'enregistrement est désactivé, aucune révision ne sera générée.", + "detectDisabled": "La détection d'objet est désactivée. Les révisions requièrent que les objets détectés catégorisent les alertes et les détections.", + "allNonAlertDetections": "Toutes les activités de non alerte seront incluses en tant que détections." + }, + "audio": { + "noAudioRole": "Aucun flux ne possède de rôle audio défini. Vous devez activer le rôle audio afin de faire fonctionner la détection audio." + }, + "audioTranscription": { + "audioDetectionDisabled": "La détection audio n'est pas active pour cette caméra. La transcription audio nécessite que la détection audio soit active." + }, + "detect": { + "fpsGreaterThanFive": "Il n'est pas recommandé de régler la détection au-delà de 5 FPS." + }, + "faceRecognition": { + "globalDisabled": "La reconnaissance faciale n'est pas activée au niveau global. Activez-la dans les paramètres globaux pour que la reconnaissance faciale fonctionne au niveau caméra.", + "personNotTracked": "La reconnaissance faciale requiert que l'objet 'person' soit suivie. Assurez-vous que 'person' soit dans la liste d'objets suivis." + } + }, + "go2rtcStreams": { + "ffmpeg": { + "audioMp3": "Transcoder en PM3", + "audioExclude": "Exclure", + "hardwareNone": "Pas d'accélération matérielle", + "hardwareAuto": "Accélération matérielle automatique", + "audioCopy": "Copier", + "audioAac": "Transcoder en AAC", + "audioOpus": "Transcoder vers Opus", + "audioPcmu": "Transcoder vers PCM μ-law", + "video": "Vidéo", + "audio": "Audio", + "hardware": "Accélération matérielle", + "videoCopy": "Copier", + "videoH264": "Transcoder vers H.264", + "videoH265": "Transcoder vers H.265", + "videoExclude": "Exclure", + "useFfmpegModule": "Utiliser le mode de compatibilité (ffmpeg)", + "audioPcma": "Transcoder vers PCM A-law", + "audioPcm": "Transcoder vers PCM" + }, + "renameStream": "Renommer le flux", + "renameStreamDesc": "Saisir un nouveau nom pour ce flux. Le renommage d'un flux peut induire un problème avec les caméras ou les autres flux qui le référence par nom.", + "addStream": "Ajouter un flux", + "title": "Flux go2rtc", + "description": "Gérer les paramètres de flux go2rtc pour la rediffusion de caméra. Chaque flux possède un nom et une ou plusieurs URLs source.", + "deleteStream": "Supprimer flux", + "deleteStreamConfirm": "Êtes-vous sûr de vouloir supprimer le flux \"{{streamName}}\" ? Les caméras qui référencent ce flux pourraient ne plus fonctionner.", + "noStreams": "Aucun flux go2rtc configuré. Ajoutez un flux pour commencer.", + "validation": { + "nameRequired": "Le nom de flux est obligatoire", + "nameDuplicate": "Un flux avec ce nom existe déjà", + "nameInvalid": "Le nom de flux ne peut contenir que des lettres, nombres, underscores et tirets", + "urlRequired": "Au moins une URL est requise" + }, + "newStreamName": "Nouveau nom de flux", + "addUrl": "Ajouter URL", + "streamName": "Nom de flux", + "streamNamePlaceholder": "p. ex., porte_entree", + "streamUrlPlaceholder": "p. ex., rtsp://utilisateur:motpasse@192.168.1.100/flux", + "addStreamDesc": "Saisir un nom pour ce nouveau flux. Ce nom sera utilisé pour référencer le flux dans les paramètres de votre caméra." + }, + "onvif": { + "profileAuto": "Automatique", + "profileLoading": "Chargement des profils..." + }, + "profiles": { + "enableSwitch": "Activer les profils", + "enabledDescription": "Les profils sont actifs. Créer un nouveau profil ci-dessous, naviguer vers la section de configuration de la caméra afin de faire vos changements, et les sauvegarder afin de les prendre en compte.", + "error": { + "mustBeAtLeastTwoCharacters": "Doit comporter au moins 2 caractères", + "mustNotContainPeriod": "Ne doit pas contenir de points", + "alreadyExists": "Un profil avec cet identifiant existe déjà" + }, + "deactivated": "Profil désactivé", + "noProfiles": "Aucun profil défini.", + "noOverrides": "Aucune surcharge", + "cameraCount_one": "{{count}} caméra", + "cameraCount_many": "{{count}} caméras", + "cameraCount_other": "{{count}} caméras", + "columnCamera": "Caméra", + "columnOverrides": "Surcharges de profil", + "baseConfig": "Configuration de base", + "addProfile": "Ajouter un profil", + "newProfile": "Nouveau profil", + "friendlyNameLabel": "Nom profil", + "profileIdLabel": "ID profil", + "profileIdDescription": "Identifiant interne utilisé dans la configuration et automatisations", + "nameInvalid": "Ne sont autorisés que les lettres minuscules, nombres et underscores", + "nameDuplicate": "Un profil avec ce nom existe déjà", + "renameProfile": "Renommer profil", + "renameSuccess": "Profil renommé en '{{profile}}'", + "deleteProfile": "Supprimer Profil", + "deleteProfileConfirm": "Supprimer profil \"{{profile}}\" de toutes les caméras ? Ceci ne peut être annulé.", + "deleteSuccess": "Le profil '{{profile}}' a été supprimé", + "createSuccess": "Le profil '{{profile}}' a été créé", + "removeOverride": "Supprimer le profil surchargé", + "deleteSection": "Supprimer la section de surcharges", + "deleteSectionConfirm": "Supprimer les surcharges de {{section}} pour le profil {{profile}} sur {{camera}} ?", + "deleteSectionSuccess": "Surcharges de {{section}} supprimées pour {{profile}}", + "disabledDescription": "Les profils vous permettent de définir des ensembles nommés de surcharges de configuration de caméra (p. ex. armé, absent, nuit) qui peuvent être activés à la demande." + }, + "unsavedChanges": "Vous avez des changements non sauvegardés", + "confirmReset": "Confirmer réinitialisation", + "resetToDefaultDescription": "Cela va réinitialiser les paramètres dans cette section avec les valeurs d'usine. Cette action ne peut être annulée.", + "resetToGlobalDescription": "Ceci va réinitialiser les paramètres de cette section vers les paramètres globaux. Cette action ne peut être annulée." } diff --git a/web/public/locales/fr/views/system.json b/web/public/locales/fr/views/system.json index f29b871707..74394a324a 100644 --- a/web/public/locales/fr/views/system.json +++ b/web/public/locales/fr/views/system.json @@ -111,7 +111,8 @@ "description": "Il s'agit d'un bug connu de l'outil de statistiques GPU d'Intel (intel_gpu_top) : il peut afficher à tort une utilisation de 0 %, même lorsque l'accélération matérielle et la détection d'objets fonctionnent correctement sur l'iGPU. Ce problème ne vient pas de Frigate. Vous pouvez redémarrer l'hôte pour rétablir temporairement l'affichage et confirmer le fonctionnement du GPU. Les performances ne sont pas affectées." }, "gpuTemperature": "Température du GPU", - "npuTemperature": "Température du NPU" + "npuTemperature": "Température du NPU", + "gpuCompute": "Calcul / Encodage GPU" }, "otherProcesses": { "title": "Autres processus", @@ -148,7 +149,11 @@ "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 petite. Augmentez-la au moins à {{min_shm}} Mo." + "warning": "La taille actuelle de la SHM de {{total}} Mo est trop petite. Augmentez-la au moins à {{min_shm}} Mo.", + "frameLifetime": { + "title": "Durée de vie de la trame", + "description": "Chaque caméra dispose de {{frames}} emplacements de trames en mémoire partagée. À la fréquence d'images de la caméra la plus rapide, chaque trame est disponible pendant environ {{lifetime}}s avant d'être écrasée." + } } }, "cameras": { @@ -185,7 +190,8 @@ "cameraCapture": "{{camName}} capture", "cameraDetect": "{{camName}} détection", "cameraFramesPerSecond": "{{camName}} images par seconde", - "cameraDetectionsPerSecond": "{{camName}} détections par seconde" + "cameraDetectionsPerSecond": "{{camName}} détections par seconde", + "cameraGpu": "GPU {{camName}}" }, "overview": "Vue d'ensemble", "toast": { @@ -217,7 +223,8 @@ "cameraIsOffline": "{{camera}} est hors ligne", "detectIsSlow": "{{detect}} est 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." + "shmTooLow": "L'allocation /dev/shm ({{total}} Mo) devrait être augmentée à au moins {{min}} Mo.", + "debugReplayActive": "Session de relecture de débogage active" }, "enrichments": { "title": "Enrichissements", diff --git a/web/public/locales/gl/views/chat.json b/web/public/locales/gl/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/gl/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/gl/views/motionSearch.json b/web/public/locales/gl/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/gl/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/gl/views/replay.json b/web/public/locales/gl/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/gl/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/he/views/chat.json b/web/public/locales/he/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/he/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/he/views/motionSearch.json b/web/public/locales/he/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/he/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/he/views/replay.json b/web/public/locales/he/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/he/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hi/views/chat.json b/web/public/locales/hi/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hi/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hi/views/motionSearch.json b/web/public/locales/hi/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hi/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hi/views/replay.json b/web/public/locales/hi/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hi/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hr/audio.json b/web/public/locales/hr/audio.json index 55d1e5fce5..8531e48d8d 100644 --- a/web/public/locales/hr/audio.json +++ b/web/public/locales/hr/audio.json @@ -11,7 +11,7 @@ "laughter": "Smijeh", "train": "Vlak", "snicker": "Smješkanje", - "boat": "ÄŒamac", + "boat": "Brod", "crying": "Plakanje", "singing": "Pjevanje", "choir": "Zbor", @@ -350,7 +350,7 @@ "microwave_oven": "Mikrovalna pećnica", "water_tap": "Vodovodna slavina", "bathtub": "Kada", - "toilet_flush": "Ispiranje WC-a", + "toilet_flush": "Ispiranje toaleta", "electric_toothbrush": "Električna četkica za zube", "vacuum_cleaner": "Usisavač", "zipper": "Patentni zatvarač", diff --git a/web/public/locales/hr/common.json b/web/public/locales/hr/common.json index 68b1cb41f0..8206a7d3ba 100644 --- a/web/public/locales/hr/common.json +++ b/web/public/locales/hr/common.json @@ -22,11 +22,11 @@ "pm": "pm", "am": "am", "ago": "prije {{timeAgo}}", - "yr": "{{time}}g.", + "yr": "{{time}}g", "year_one": "{{time}} godina", "year_few": "{{time}} godine", "year_other": "{{time}} godina", - "mo": "{{time}}mj.", + "mo": "{{time}}mj", "month_one": "{{time}} mjesec", "month_few": "{{time}} mjeseca", "month_other": "{{time}} mjeseci", diff --git a/web/public/locales/hr/config/cameras.json b/web/public/locales/hr/config/cameras.json index 0967ef424b..1ffab63889 100644 --- a/web/public/locales/hr/config/cameras.json +++ b/web/public/locales/hr/config/cameras.json @@ -1 +1,3 @@ -{} +{ + "label": "Konfiguracijakamere" +} diff --git a/web/public/locales/hr/config/global.json b/web/public/locales/hr/config/global.json index 0967ef424b..9a3929845d 100644 --- a/web/public/locales/hr/config/global.json +++ b/web/public/locales/hr/config/global.json @@ -1 +1,5 @@ -{} +{ + "version": { + "label": "Trenutna verzija konfiguracije" + } +} diff --git a/web/public/locales/hr/config/groups.json b/web/public/locales/hr/config/groups.json index 0967ef424b..fd99a882c1 100644 --- a/web/public/locales/hr/config/groups.json +++ b/web/public/locales/hr/config/groups.json @@ -1 +1,7 @@ -{} +{ + "audio": { + "global": { + "detection": "Globalna detekcija" + } + } +} diff --git a/web/public/locales/hr/objects.json b/web/public/locales/hr/objects.json index 955ebe0cd7..b27b693e1f 100644 --- a/web/public/locales/hr/objects.json +++ b/web/public/locales/hr/objects.json @@ -6,7 +6,7 @@ "airplane": "Zrakoplov", "bus": "Autobus", "train": "Vlak", - "boat": "ÄŒamac", + "boat": "Brod", "traffic_light": "Semafor", "fire_hydrant": "Hidrant", "street_sign": "Prometni znak", diff --git a/web/public/locales/hr/views/chat.json b/web/public/locales/hr/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hr/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hr/views/live.json b/web/public/locales/hr/views/live.json index 9fce430f57..a60f87cd51 100644 --- a/web/public/locales/hr/views/live.json +++ b/web/public/locales/hr/views/live.json @@ -1,5 +1,7 @@ { - "documentTitle": "Uživo - Frigate", + "documentTitle": { + "default": "Uživo - Frigate" + }, "documentTitle.withCamera": "{{camera}} - Uživo - Frigate", "twoWayTalk": { "enable": "Omogući dvosmjerni razgovor", diff --git a/web/public/locales/hr/views/motionSearch.json b/web/public/locales/hr/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hr/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hr/views/replay.json b/web/public/locales/hr/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hr/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hu/common.json b/web/public/locales/hu/common.json index 6e5df9f1de..fdebbf0e9a 100644 --- a/web/public/locales/hu/common.json +++ b/web/public/locales/hu/common.json @@ -178,7 +178,8 @@ "configuration": "Konfiguráció", "systemLogs": "Rendszer naplók", "settings": "Beállítások", - "classification": "Osztályozás" + "classification": "Osztályozás", + "profiles": "Profilok" }, "role": { "viewer": "Néző", @@ -273,7 +274,8 @@ "export": "Exportálás", "deleteNow": "Törlés Most", "next": "Következő", - "continue": "Tovább" + "continue": "Tovább", + "add": "Hozzáad" }, "label": { "back": "Vissza", diff --git a/web/public/locales/hu/components/player.json b/web/public/locales/hu/components/player.json index 31ee991378..1e420ed309 100644 --- a/web/public/locales/hu/components/player.json +++ b/web/public/locales/hu/components/player.json @@ -3,7 +3,8 @@ "noPreviewFound": "Nincs elérhető előkép", "submitFrigatePlus": { "title": "Elküldi ezt a képet a Frigate+-nak?", - "submit": "Küldés" + "submit": "Küldés", + "previewError": "Nem sikerült betölteni a pillanatkép előnézetét. Előfordulhat, hogy a felvétel jelenleg nem elérhető." }, "noPreviewFoundFor": "Nem található előnézet {{cameraName}}-hoz/-hez/-höz", "livePlayerRequiredIOSVersion": "iOS 17.1 vagy újabb szükséges ehhez az élő adás típushoz.", diff --git a/web/public/locales/hu/config/cameras.json b/web/public/locales/hu/config/cameras.json index e228cd9786..5bc97239a6 100644 --- a/web/public/locales/hu/config/cameras.json +++ b/web/public/locales/hu/config/cameras.json @@ -39,6 +39,41 @@ "description": "A Frigate felhasználói felületén használt, könnyen megjegyezhető kamera név" }, "enabled": { - "label": "Engedélyezve" + "label": "Engedélyezve", + "description": "Engedélyezve" + }, + "audio": { + "label": "Hangesemények", + "description": "Hangalapú eseményérzékelés beállításai ennél a kameránál.", + "enabled": { + "label": "Hangalapú eseményérzékelés engedélyezése", + "description": "A hangalapú eseményérzékelés engedélyezése vagy letiltása ennél a kameránál." + }, + "max_not_heard": { + "description": "Ennyi másodperc után fejeződik be a hangesemény, ha a beállított hangtípus nem észlelhető.", + "label": "Időtúllépés befejezése" + }, + "min_volume": { + "label": "Minimális hangerő", + "description": "Minimum RMS hangerő a hangérzékelés futtatásához; az alacsonyabb értékek növelik az érzékenységet (pl: 200 magas, 500 közepes, 1000 alacsony érzékenységet jelent)." + }, + "listen": { + "label": "Hallgatási típúsok", + "description": "Lista a hangalapú eseményekről amit érzékelni szeretnél (angolul) (például: bark, fire_alarm, scream, speech, yell)." + }, + "filters": { + "label": "Audio szűrők (filters)", + "description": "Hangtípusonkénti szűrőbeállítások (filter), mint például a téves találatok számát mérsékelő konfidencia-küszöbök." + }, + "enabled_in_config": { + "label": "Eredeti audio állapot" + } + }, + "audio_transcription": { + "label": "Hang Feliratozás", + "description": "„Beállítások élő hang és beszéd automatikus szöveggé alakításához, eseményekhez és élő feliratozáshoz.", + "enabled": { + "label": "Hangról szövegre alakítás engedélyezése" + } } } diff --git a/web/public/locales/hu/config/global.json b/web/public/locales/hu/config/global.json index 8a43985e3f..1389d61a53 100644 --- a/web/public/locales/hu/config/global.json +++ b/web/public/locales/hu/config/global.json @@ -40,5 +40,65 @@ "environment_vars": { "label": "Környezeti változók", "description": "A Home Assistant OS rendszerben a Frigate folyamat számára beállítandó környezeti változói. A nem HAOS-felhasználóknak helyette a Docker konfigurációját kell használniuk." + }, + "logger": { + "label": "Naplózás", + "description": "Az alapértelmezett naplózási részletességet és a komponensenkénti naplózási szintek felülírását vezérli.", + "default": { + "label": "Naplózási részletesség", + "description": "Alapértelmezett globális naplórészletesség (debug, info, warning, error)." + }, + "logs": { + "label": "Folyamatonkénti naplózási szint", + "description": "Összetevőnkénti naplózási szint felülbírálások az egyes modulok részletességének növeléséhez vagy csökkentéséhez." + } + }, + "audio": { + "label": "Hangesemények", + "enabled": { + "label": "Hangalapú eseményérzékelés engedélyezése" + }, + "max_not_heard": { + "description": "Ennyi másodperc után fejeződik be a hangesemény, ha a beállított hangtípus nem észlelhető.", + "label": "Időtúllépés befejezése" + }, + "min_volume": { + "label": "Minimális hangerő", + "description": "Minimum RMS hangerő a hangérzékelés futtatásához; az alacsonyabb értékek növelik az érzékenységet (pl: 200 magas, 500 közepes, 1000 alacsony érzékenységet jelent)." + }, + "listen": { + "label": "Hallgatási típúsok", + "description": "Lista a hangalapú eseményekről amit érzékelni szeretnél (angolul) (például: bark, fire_alarm, scream, speech, yell)." + }, + "filters": { + "label": "Audio szűrők (filters)", + "description": "Hangtípusonkénti szűrőbeállítások (filter), mint például a téves találatok számát mérsékelő konfidencia-küszöbök." + }, + "enabled_in_config": { + "label": "Eredeti audio állapot" + } + }, + "auth": { + "label": "Azonosítás", + "description": "Bejelentkezési és munkamenet-beállítások, többek között süti- és lekérdezési korlátok (rate limit) megadásához.", + "enabled": { + "label": "Bejelentkezés engedélyezése", + "description": "Natív bejelentkezés (azonosítás) engedélyezése a Frigate felületén." + }, + "reset_admin_password": { + "label": "Admin jelszó visszaállítása", + "description": "Ha igaz, akkor visszaállítja az admin felhasználó jelszavát, és induláskor a naplóba írja ki az új jelszót." + }, + "cookie_name": { + "label": "JWT süti neve", + "description": "A süti neve ami a JWT tokent tárolja a natív bejelentkezéshez." + } + }, + "audio_transcription": { + "label": "Hang Feliratozás", + "description": "„Beállítások élő hang és beszéd automatikus szöveggé alakításához, eseményekhez és élő feliratozáshoz.", + "enabled": { + "label": "Hangról szövegre alakítás engedélyezése" + } } } diff --git a/web/public/locales/hu/config/groups.json b/web/public/locales/hu/config/groups.json index a50d820663..6d334c5a75 100644 --- a/web/public/locales/hu/config/groups.json +++ b/web/public/locales/hu/config/groups.json @@ -16,5 +16,43 @@ "cameras": { "appearance": "Kinézet" } + }, + "motion": { + "global": { + "sensitivity": "Globális érzékenység", + "algorithm": "Globális Algoritmus" + }, + "cameras": { + "sensitivity": "Érzékenység", + "algorithm": "Algoritmus" + } + }, + "detect": { + "global": { + "resolution": "Globális Felbontás", + "tracking": "Globális követés" + }, + "cameras": { + "resolution": "Felbontás", + "tracking": "Követés (tracking)" + } + }, + "snapshots": { + "global": { + "display": "Globális kijelző" + }, + "cameras": { + "display": "Kijelző" + } + }, + "objects": { + "global": { + "tracking": "Globális objektumkövetés", + "filtering": "Globális szűrés (filtering)" + }, + "cameras": { + "tracking": "Követés", + "filtering": "Szűrés (filtering)" + } } } diff --git a/web/public/locales/hu/config/validation.json b/web/public/locales/hu/config/validation.json index 7b3ab646b6..2e6fafa925 100644 --- a/web/public/locales/hu/config/validation.json +++ b/web/public/locales/hu/config/validation.json @@ -4,5 +4,22 @@ "exclusiveMinimum": "Nagyobbnak kell lennie, mint {{limit}}", "exclusiveMaximum": "Kevesebbnek kell lennie, mint {{limit}}", "minLength": "Legalább {{limit}} karaktert kell megadni", - "maxLength": "Legfeljebb {{limit}} karakter lehet" + "maxLength": "Legfeljebb {{limit}} karakter lehet", + "minItems": "Legalább {{limit}} elemnek kell lennie", + "maxItems": "Legfeljebb {{limit}} elem lehet", + "pattern": "Érvénytelen formátum", + "required": "Ezt a mezőt kötelező kitölteni", + "type": "Érvénytelen értéktípus", + "enum": "Az engedélyezett értékek közül legalább egy kell legyen", + "const": "Az érték nem egyezik a várt állandóval", + "uniqueItems": "Minden elemnek egyedinek kell lennie", + "format": "Érvénytelen formátum", + "additionalProperties": "Ismeretlen tulajdonság nem engedélyezett", + "oneOf": "Pontosan az egyik engedélyezett sémának kell megfelelnie", + "anyOf": "Legalább az egyik engedélyezett sémának kell megfelelnie", + "ffmpeg": { + "inputs": { + "rolesUnique": "Mindegyik szerepkör (role) csak egy bemeneti (input) streamhez rendelhető hozzá." + } + } } diff --git a/web/public/locales/hu/views/chat.json b/web/public/locales/hu/views/chat.json new file mode 100644 index 0000000000..cd90ba0738 --- /dev/null +++ b/web/public/locales/hu/views/chat.json @@ -0,0 +1,18 @@ +{ + "documentTitle": "Chat - Frigate", + "title": "Frigate Chat", + "subtitle": "A kamerakezeléshez és elemzésekhez szükséges MI asszisztensed", + "placeholder": "Kérdezz bármit...", + "error": "Hiba történt. Kérjük, próbáld meg újra.", + "processing": "Feldolgozás...", + "toolsUsed": "Használatban: {{tools}}", + "showTools": "Eszközök megjelenítése ({{count}})", + "hideTools": "Eszközök elrejtése", + "call": "Hívás", + "result": "Eredmény", + "response": "Válasz:", + "attachment_chip_remove": "Melléklet eltávolítása", + "open_in_explore": "Megnyitás Böngészőben", + "attachment_picker_paste_label": "Vagy illeszd be az esemény ID-t", + "attachment_picker_attach": "Melléklet" +} diff --git a/web/public/locales/hu/views/events.json b/web/public/locales/hu/views/events.json index 904a013368..82e7b72025 100644 --- a/web/public/locales/hu/views/events.json +++ b/web/public/locales/hu/views/events.json @@ -15,7 +15,9 @@ "only": "Csak mozgások" }, "allCameras": "Összes kamera", - "timeline": "Idővonal", + "timeline": { + "label": "Idővonal" + }, "detected": "észlelve", "events": { "label": "Események", diff --git a/web/public/locales/hu/views/exports.json b/web/public/locales/hu/views/exports.json index f1880b1252..ebf428f178 100644 --- a/web/public/locales/hu/views/exports.json +++ b/web/public/locales/hu/views/exports.json @@ -3,7 +3,9 @@ "search": "Keresés", "noExports": "Export nem található", "deleteExport.desc": "Biztos, hogy törölni akarja {{exportName}}-t?", - "deleteExport": "Export törlése", + "deleteExport": { + "label": "Export törlése" + }, "editExport": { "title": "Exportálás átnevezése", "desc": "Adjon meg egy új nevet ennek az exportnak.", @@ -23,5 +25,8 @@ "headings": { "cases": "Esetek", "uncategorizedExports": "Kategória nélküli exportok" + }, + "toolbar": { + "addExport": "Export hozzáadása" } } diff --git a/web/public/locales/hu/views/live.json b/web/public/locales/hu/views/live.json index b7a5ff9672..a24a0e6bb5 100644 --- a/web/public/locales/hu/views/live.json +++ b/web/public/locales/hu/views/live.json @@ -3,7 +3,9 @@ "enable": "Kétirányú kommunikáció engedélyezése", "disable": "Kétirányú kommunikáció tiltása" }, - "documentTitle": "Élő - Frigate", + "documentTitle": { + "default": "Élő - Frigate" + }, "lowBandwidthMode": "Alacsony felbontású mód", "documentTitle.withCamera": "{{camera}} - Élő - Frigate", "cameraAudio": { @@ -15,7 +17,8 @@ "clickMove": { "label": "Kattintson a képre a kamera középre igazításához", "enable": "Engedélyezze a kattintást a mozgatáshoz", - "disable": "Kattintással húzás kikapcsolása" + "disable": "Kattintással húzás kikapcsolása", + "enableWithZoom": "Kattintással történő mozgatás és húzással való nagyítás engedélyezése" }, "left": { "label": "PTZ kamera balra mozgatása" diff --git a/web/public/locales/hu/views/motionSearch.json b/web/public/locales/hu/views/motionSearch.json new file mode 100644 index 0000000000..e84683d3ba --- /dev/null +++ b/web/public/locales/hu/views/motionSearch.json @@ -0,0 +1,22 @@ +{ + "documentTitle": "Mozgás Keresés - Frigate", + "title": "Mozgás Keresés", + "description": "Rajzoljon be egy sokszöget a vizsgálandó terület kijelöléséhez, majd adja meg az időtartományt, amelyen belül a mozgásváltozásokat szeretné keresni ezen a területen.", + "selectCamera": "Mozgás Keresés betöltése folyamatban", + "startSearch": "Keresés Indítása", + "searchStarted": "Megkezdődött a keresés", + "searchCancelled": "Keresés megszakítva", + "cancelSearch": "Mégse", + "searching": "Keresés folyamatban.", + "searchComplete": "A keresés befejeződött", + "noResultsYet": "Futtass keresést a kijelölt területen a mozgásváltozások felkutatásához", + "noChangesFound": "A kijelölt területen nem történt pixelváltozás", + "changesFound_one": "Észlelhető {{count}} mozgásváltozás", + "changesFound_other": "Észlelhető {{count}} mozgásváltozás", + "framesProcessed": "{{count}} képkocka feldolgozva", + "jumpToTime": "Ugrás erre az időpontra", + "results": "Eredmények", + "showSegmentHeatmap": "Hőtérkép", + "newSearch": "Új Keresés", + "clearResults": "Eredmények törlése" +} diff --git a/web/public/locales/hu/views/replay.json b/web/public/locales/hu/views/replay.json new file mode 100644 index 0000000000..7df3327315 --- /dev/null +++ b/web/public/locales/hu/views/replay.json @@ -0,0 +1,25 @@ +{ + "title": "Hibakeresés visszajátszás", + "websocket_messages": "Üzenetek", + "dialog": { + "title": "Hibakeresés visszajátszásának indítása", + "camera": "Forráskamera", + "timeRange": "Időtartomány", + "preset": { + "1m": "Legutóbbi 1 perc", + "5m": "Legutóbbi 5 perc", + "timeline": "Az Idővonalból", + "custom": "Egyedi" + }, + "startButton": "Visszajátszás indítása", + "selectFromTimeline": "Válassz", + "starting": "Visszajátszás indítása...", + "startLabel": "Indítás", + "endLabel": "Vége", + "toast": { + "error": "A hibakeresési visszajátszás elindítása sikertelen: {{error}}", + "alreadyActive": "A visszajátszási munkamenet már aktív", + "goToReplay": "Ugrás a visszajátszásra" + } + } +} diff --git a/web/public/locales/hu/views/settings.json b/web/public/locales/hu/views/settings.json index c8bd38614d..3cf386f72b 100644 --- a/web/public/locales/hu/views/settings.json +++ b/web/public/locales/hu/views/settings.json @@ -12,7 +12,11 @@ "motionTuner": "Mozgás Hangoló - Frigate", "enrichments": "Kiegészítés Beállítások - Frigate", "cameraManagement": "Kamerák kezelése - Frigate", - "cameraReview": "Kamera beállítások áttekintése – Frigate" + "cameraReview": "Kamera beállítások áttekintése – Frigate", + "globalConfig": "Globális Konfiguráció - Frigate", + "cameraConfig": "Kamera Konfiguráció - Frigate", + "maintenance": "Karbantartás - Frigate", + "profiles": "Profilok - Frigate" }, "menu": { "ui": "UI", @@ -28,7 +32,24 @@ "triggers": "Triggerek", "roles": "Szerepkörök", "cameraManagement": "Menedzsment", - "cameraReview": "Vizsgálat" + "cameraReview": "Vizsgálat", + "general": "Általános", + "globalConfig": "Globális konfiguráció", + "system": "Rendszer", + "integrations": "Integrációk", + "uiSettings": "UI beállítások", + "profiles": "Profilok", + "globalDetect": "Tárgy felismerés", + "globalRecording": "Felvétel", + "globalSnapshots": "Pillanatképek", + "globalFfmpeg": "FFmpeg", + "globalMotion": "Mozgásérzékelés", + "globalObjects": "Tárgyak", + "globalReview": "Áttekintés", + "globalAudioEvents": "Hangesemények", + "cameraAudioEvents": "Hangesemények", + "cameraAudioTranscription": "Hang Feliratozás", + "integrationAudioTranscription": "Hang Feliratozás" }, "dialog": { "unsavedChanges": { @@ -517,7 +538,7 @@ "all": "Mindem Maszk és Zóna" }, "motionMaskLabel": "Mozgási Maszk {{number}}", - "objectMaskLabel": "Tárgy Maszk {{number}} {{label}}", + "objectMaskLabel": "Tárgy Maszk {{number}}", "toast": { "success": { "copyCoordinates": "A {{polyName}} koordinátái vágólapra másolva." @@ -861,5 +882,16 @@ "streamConfiguration": "Stream beállítások", "validationAndTesting": "Validálás és tesztelés" } + }, + "button": { + "overriddenGlobal": "Felülírt (Globális)", + "overriddenGlobalTooltip": "Ez a kamera felülírja a globális konfigurációs beállításokat ebben a részben", + "overriddenBaseConfig": "Felülírt (Alapbeállítás)", + "overriddenBaseConfigTooltip": "A {{profile}} profil felülírja a konfigurációs beállításokat ebben a részben" + }, + "detectionModel": { + "plusActive": { + "label": "A jelenlegi modell forrása" + } } } diff --git a/web/public/locales/hu/views/system.json b/web/public/locales/hu/views/system.json index d99cfbcb32..73580e256a 100644 --- a/web/public/locales/hu/views/system.json +++ b/web/public/locales/hu/views/system.json @@ -6,7 +6,8 @@ "logs": { "frigate": "Frigate naplók - Frigate", "go2rtc": "Go2RTC naplók - Frigate", - "nginx": "Nginx naplók - Frigate" + "nginx": "Nginx naplók - Frigate", + "websocket": "Üzenet naplók - Frigate" }, "enrichments": "Kiegészítés statisztikák - Frigate" }, @@ -78,7 +79,22 @@ "download": { "label": "Naplók letöltése" }, - "tips": "A naplók a szerverről érkeznek" + "tips": "A naplók a szerverről érkeznek", + "websocket": { + "label": "Üzenetek", + "pause": "Szüneteltetés", + "resume": "Folytatás", + "filter": { + "all": "Összes téma (topic)", + "topics": "Témák (topics)", + "events": "Események", + "reviews": "Értékelések (reviews)", + "face_recognition": "Arcfelismerés", + "classification": "Osztályozás", + "lpr": "LPR" + }, + "clear": "Töröl" + } }, "general": { "title": "Általános", diff --git a/web/public/locales/hy/views/chat.json b/web/public/locales/hy/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hy/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hy/views/motionSearch.json b/web/public/locales/hy/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hy/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/hy/views/replay.json b/web/public/locales/hy/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/hy/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/id/audio.json b/web/public/locales/id/audio.json index cb3f2539f2..c7cb4475a3 100644 --- a/web/public/locales/id/audio.json +++ b/web/public/locales/id/audio.json @@ -1,6 +1,6 @@ { "yell": "Teriakan", - "speech": "Bahasa", + "speech": "Percakapan", "babbling": "Ocehan", "bellow": "Di bawah", "whoop": "Teriakan", @@ -87,5 +87,12 @@ "clapping": "Tepukan", "camera": "Kamera", "wheeze": "Nafas", - "gasp": "Tersedak" + "gasp": "Tersedak", + "sound_effect": "Efek Suara", + "environmental_noise": "Suara Lingkungan", + "static": "Statis", + "white_noise": "Suara Derau", + "television": "Televisi", + "radio": "Radio", + "scream": "Teriakan" } diff --git a/web/public/locales/id/common.json b/web/public/locales/id/common.json index b1498de076..c5eb6634aa 100644 --- a/web/public/locales/id/common.json +++ b/web/public/locales/id/common.json @@ -6,7 +6,7 @@ "justNow": "Sekarang", "today": "Hari ini", "yesterday": "Kemarin", - "untilForTime": "Hingga {{time}}", + "untilForTime": "Sampai", "last7": "7 hari terakhir", "last14": "14 hari terakhir", "last30": "30 hari terakhir", diff --git a/web/public/locales/id/components/player.json b/web/public/locales/id/components/player.json index 0372a797c7..0adef26797 100644 --- a/web/public/locales/id/components/player.json +++ b/web/public/locales/id/components/player.json @@ -5,7 +5,7 @@ "submit": "Kirim", "title": "Kirim frame ini ke Frigate+?" }, - "noRecordingsFoundForThisTime": "Tidak ada Rekaman pada waktu ini", + "noRecordingsFoundForThisTime": "Tidak ada rekaman ditemukan pada waktu ini", "livePlayerRequiredIOSVersion": "iOS 17.1 atau yang lebih tinggi diperlukan untuk tipe siaran langsung ini.", "streamOffline": { "title": "Stream Tidak Aktif", diff --git a/web/public/locales/id/config/cameras.json b/web/public/locales/id/config/cameras.json index 0967ef424b..9151d7d340 100644 --- a/web/public/locales/id/config/cameras.json +++ b/web/public/locales/id/config/cameras.json @@ -1 +1,3 @@ -{} +{ + "label": "Pengaturan Kamera" +} diff --git a/web/public/locales/id/config/global.json b/web/public/locales/id/config/global.json index 0967ef424b..345b593f5c 100644 --- a/web/public/locales/id/config/global.json +++ b/web/public/locales/id/config/global.json @@ -1 +1,5 @@ -{} +{ + "version": { + "label": "Versi konfigurasi" + } +} diff --git a/web/public/locales/id/config/groups.json b/web/public/locales/id/config/groups.json index 0967ef424b..7e1fd9f862 100644 --- a/web/public/locales/id/config/groups.json +++ b/web/public/locales/id/config/groups.json @@ -1 +1,7 @@ -{} +{ + "audio": { + "global": { + "detection": "Deteksi Global" + } + } +} diff --git a/web/public/locales/id/config/validation.json b/web/public/locales/id/config/validation.json index 0967ef424b..3be9f0d2f8 100644 --- a/web/public/locales/id/config/validation.json +++ b/web/public/locales/id/config/validation.json @@ -1 +1,3 @@ -{} +{ + "minimum": "Nilai harus lebih besar dari {{limit}}" +} diff --git a/web/public/locales/id/views/chat.json b/web/public/locales/id/views/chat.json new file mode 100644 index 0000000000..4fc251afdb --- /dev/null +++ b/web/public/locales/id/views/chat.json @@ -0,0 +1,3 @@ +{ + "documentTitle": "Chat - Frigate" +} diff --git a/web/public/locales/id/views/classificationModel.json b/web/public/locales/id/views/classificationModel.json index 55ca9051dd..9ea38ad20d 100644 --- a/web/public/locales/id/views/classificationModel.json +++ b/web/public/locales/id/views/classificationModel.json @@ -2,7 +2,7 @@ "documentTitle": "Klasifikasi Model - Frigate", "details": { "scoreInfo": "Skor tersebut mewakili rata-rata kepercayaan klasifikasi di seluruh deteksi objek ini.", - "none": "Tidak ada", + "none": "Tidak Ada", "unknown": "Tidak diketahui" }, "button": { diff --git a/web/public/locales/id/views/exports.json b/web/public/locales/id/views/exports.json index 043c313de1..79775d60bf 100644 --- a/web/public/locales/id/views/exports.json +++ b/web/public/locales/id/views/exports.json @@ -5,7 +5,7 @@ "deleteExport": "Hapus Ekspor", "deleteExport.desc": "Apakah Anda yakin ingin menghapus {{exportName}}?", "editExport": { - "title": "Ganti Nama Ekspor", + "title": "Ubah nama ekspor", "desc": "Masukkan nama baru untuk ekspor ini.", "saveExport": "Simpan Ekspor" }, diff --git a/web/public/locales/id/views/live.json b/web/public/locales/id/views/live.json index 96e4575230..36202b238c 100644 --- a/web/public/locales/id/views/live.json +++ b/web/public/locales/id/views/live.json @@ -1,6 +1,8 @@ { "documentTitle.withCamera": "{{camera}} - Langsung - Frigate", - "documentTitle": "Langsung - Frigate", + "documentTitle": { + "default": "Siaran Langsung - Frigate" + }, "lowBandwidthMode": "Mode Bandwith-Rendah", "twoWayTalk": { "enable": "Nyalakan Komunikasi dua arah", diff --git a/web/public/locales/id/views/motionSearch.json b/web/public/locales/id/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/id/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/id/views/replay.json b/web/public/locales/id/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/id/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/id/views/settings.json b/web/public/locales/id/views/settings.json index 1a74776d59..831d9bb684 100644 --- a/web/public/locales/id/views/settings.json +++ b/web/public/locales/id/views/settings.json @@ -7,7 +7,7 @@ "masksAndZones": "Editor Mask dan Zona - Frigate", "motionTuner": "Penyetel Gerakan - Frigate", "general": "Frigate - Pengaturan Umum", - "object": "Debug - Frigate", + "object": "Pengawakutu - Frigate", "enrichments": "Frigate - Pengaturan Pengayaan", "cameraManagement": "Pengaturan Kamera - Frigate", "cameraReview": "Pengaturan Ulasan Kamera - Frigate", @@ -47,5 +47,21 @@ "desc": "Secara otomatis beralih ke tampilan langsung kamera saat aktivitas terdeteksi. Menonaktifkan opsi ini menyebabkan gambar statis kamera di dasbor langsung hanya diperbarui sekali per menit." } } + }, + "configMessages": { + "audioTranscription": { + "audioDetectionDisabled": "Pendeteksi suara tidak dinyalakan untuk kamera ini. Transkripsi suara memerlukan pendeteksi suara untuk dinyalakan." + }, + "detect": { + "fpsGreaterThanFive": "Pengaturan FPS untuk pendeteksian lebih dari 5 tidak disarankan." + }, + "faceRecognition": { + "globalDisabled": "Pendeteksi muka tidak dinyalakan dalam level global. Nyalakan pendeteksi muka dalam pengaturan global agar per-kamera deteksi muka dapat bekerja.", + "personNotTracked": "Pendeteksi muka memerlukan 'orang' sebagai objek deteksi. Pastikan 'orang' berada dalam hal yang dideteksi." + }, + "lpr": { + "globalDisabled": "Pendeteksian plat nomor tidak dinyalakan dalam pengaturan global. Nyalakan deteksi plat nomor dalam pengaturan global agar fungsi ini dapat bekerja.", + "vehicleNotTracked": "Pendeteksian plat nomor memerlukan 'mobil' atau 'motor' untuk dideteksi." + } } } diff --git a/web/public/locales/is/views/chat.json b/web/public/locales/is/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/is/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/is/views/motionSearch.json b/web/public/locales/is/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/is/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/is/views/replay.json b/web/public/locales/is/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/is/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/it/common.json b/web/public/locales/it/common.json index 4067fe4fca..f571f11a94 100644 --- a/web/public/locales/it/common.json +++ b/web/public/locales/it/common.json @@ -258,7 +258,7 @@ "label": "Documentazione di Frigate" }, "restart": "Riavvia Frigate", - "review": "Rivedi", + "review": "Revisiona", "explore": "Esplora", "export": "Esporta", "uiPlayground": "Interfaccia area prove", @@ -275,11 +275,12 @@ "classification": "Classificazione", "chat": "Chat", "profiles": "Profili", - "actions": "Azioni" + "actions": "Azioni", + "features": "Caratteristiche" }, "pagination": { "next": { - "title": "Successiva", + "title": "Successivo", "label": "Vai alla pagina successiva" }, "previous": { @@ -292,8 +293,8 @@ "role": { "title": "Ruolo", "admin": "Amministratore", - "viewer": "Spettatore", - "desc": "Gli amministratori hanno accesso completo a tutte le funzionalità dell'interfaccia utente di Frigate. Gli spettatori possono visualizzare solo le telecamere, gli elementi di revisione e i filmati storici nell'interfaccia utente." + "viewer": "Visualizzatore", + "desc": "Gli amministratori hanno accesso completo a tutte le funzionalità dell'interfaccia utente di Frigate. I visualizzatori possono visualizzare solo le telecamere, gli elementi di revisione e i filmati storici nell'interfaccia utente." }, "accessDenied": { "desc": "Non hai i permessi per visualizzare questa pagina.", diff --git a/web/public/locales/it/components/camera.json b/web/public/locales/it/components/camera.json index 29ee897f36..8dc27755dc 100644 --- a/web/public/locales/it/components/camera.json +++ b/web/public/locales/it/components/camera.json @@ -36,7 +36,7 @@ "label": "Trasmissione continua", "desc": { "warning": "La trasmissione continua può causare un elevato utilizzo di larghezza di banda e problemi di prestazioni. Da usare con cautela.", - "title": "L'immagine della telecamera sarà sempre trasmessa dal vivo quando è visibile sulla schermata, anche se non viene rilevata alcuna attività." + "title": "L'immagine della telecamera sarà sempre trasmessa dal vivo quando è visibile sul cruscotto, anche se non viene rilevata alcuna attività." } }, "noStreaming": { @@ -57,7 +57,7 @@ } }, "audioIsUnavailable": "L'audio non è disponibile per questo flusso", - "desc": "Modifica le opzioni di trasmissione dal vivo per la schermata di questo gruppo di telecamere. Queste impostazioni sono specifiche del dispositivo/browser.", + "desc": "Modifica le opzioni di trasmissione dal vivo per il cruscotto di questo gruppo di telecamere. Queste impostazioni sono specifiche del dispositivo/browser.", "stream": "Flusso", "placeholder": "Scegli un flusso" }, diff --git a/web/public/locales/it/components/dialog.json b/web/public/locales/it/components/dialog.json index d0b09d38cd..1cf05d4137 100644 --- a/web/public/locales/it/components/dialog.json +++ b/web/public/locales/it/components/dialog.json @@ -64,15 +64,28 @@ "toast": { "success": "Esportazione avviata correttamente. Visualizza il file nella pagina delle esportazioni.", "error": { - "failed": "Impossibile avviare l'esportazione: {{error}}", + "failed": "Impossibile mettere in coda l'esportazione: {{error}}", "endTimeMustAfterStartTime": "L'ora di fine deve essere successiva all'ora di inizio", "noVaildTimeSelected": "Nessun intervallo di tempo valido selezionato" }, - "view": "Visualizzazione" + "view": "Visualizzazione", + "queued": "Esportazione in coda. Visualizza lo stato di avanzamento nella pagina delle esportazioni.", + "batchSuccess_one": "Avviata 1 esportazione. Apertura del caso in corso.", + "batchSuccess_many": "Avviate {{count}} esportazioni. Apertura del caso in corso.", + "batchSuccess_other": "Avviate {{count}} esportazioni. Apertura del caso in corso.", + "batchPartial": "Avviate {{successful}} esportazioni su un totale di {{total}}. Telecamere non riuscite: {{failedCameras}}", + "batchFailed": "Impossibile avviare {{total}} esportazioni. Telecamere non riuscite: {{failedCameras}}", + "batchQueuedSuccess_one": "1 esportazione in coda. Apertura del caso in corso.", + "batchQueuedSuccess_many": "Sono in coda {{count}} esportazioni. Apertura del caso in corso.", + "batchQueuedSuccess_other": "Sono in coda {{count}} esportazioni. Apertura del caso in corso.", + "batchQueuedPartial": "In coda {{successful}} di {{total}} esportazioni. Telecamere non riuscite: {{failedCameras}}", + "batchQueueFailed": "Impossibile mettere in coda {{total}} esportazioni. Telecamere non riuscite: {{failedCameras}}" }, "fromTimeline": { "saveExport": "Salva esportazione", - "previewExport": "Anteprima esportazione" + "previewExport": "Anteprima esportazione", + "queueingExport": "Accodamento per l'esportazione...", + "useThisRange": "Utilizza questa intervallo" }, "select": "Seleziona", "name": { @@ -80,7 +93,55 @@ }, "case": { "label": "Caso", - "placeholder": "Seleziona un caso" + "placeholder": "Seleziona un caso", + "newCaseOption": "Crea un nuovo caso", + "newCaseNamePlaceholder": "Nuovo nome del caso", + "newCaseDescriptionPlaceholder": "Descrizione del caso", + "nonAdminHelp": "Per queste esportazioni verrà creato un nuovo caso." + }, + "queueing": "Accodamento per l'esportazione...", + "tabs": { + "export": "Telecamera singola", + "multiCamera": "Multicamera" + }, + "multiCamera": { + "timeRange": "Intervallo di tempo", + "selectFromTimeline": "Seleziona dalla cronologia", + "cameraSelection": "Telecamere", + "cameraSelectionHelp": "Le telecamere con oggetti tracciati in questo intervallo di tempo sono preselezionate", + "checkingActivity": "Controllo dell'attività della telecamera...", + "noCameras": "Nessuna telecamera disponibile", + "detectionCount_one": "1 oggetto tracciato", + "detectionCount_many": "{{count}} oggetti tracciati", + "detectionCount_other": "{{count}} oggetti tracciati", + "nameLabel": "Nome di esportazione", + "namePlaceholder": "Nome base facoltativo per queste esportazioni", + "queueingButton": "Accodamento delle esportazioni...", + "exportButton_one": "Esporta 1 telecamera", + "exportButton_many": "Esporta {{count}} telecamere", + "exportButton_other": "Esporta {{count}} telecamere" + }, + "multi": { + "title_one": "Esporta 1 revisione", + "title_many": "Esporta {{count}} revisioni", + "title_other": "Esporta {{count}} revisioni", + "description": "Esporta ogni revisione selezionata. Tutte le esportazioni saranno raggruppate in un unico caso.", + "descriptionNoCase": "Esporta ogni revisione selezionata.", + "caseNamePlaceholder": "Esporta revisione - {{date}}", + "exportButton_one": "Esporta 1 revisione", + "exportButton_many": "Esporta {{count}} revisioni", + "exportButton_other": "Esporta {{count}} revisioni", + "exportingButton": "Esportazione...", + "toast": { + "started_one": "Avviata 1 esportazione. Apertura del caso in corso.", + "started_many": "Avviate {{count}} esportazioni. Apertura del caso in corso.", + "started_other": "Avviate {{count}} esportazioni. Apertura del caso in corso.", + "startedNoCase_one": "Avviata 1 esportazione.", + "startedNoCase_many": "Avviate {{count}} esportazioni.", + "startedNoCase_other": "Avviate {{count}} esportazioni.", + "partial": "Avviate {{successful}} esportazioni su un totale di {{total}}. Fallite: {{failedItems}}", + "failed": "Impossibile avviare {{total}} esportazioni. Errori: {{failedItems}}" + } } }, "streaming": { @@ -128,6 +189,14 @@ "success": "Il filmato associato agli elementi di recensione selezionati è stato eliminato correttamente.", "error": "Impossibile eliminare: {{error}}" } + }, + "shareTimestamp": { + "label": "Condividi orario", + "title": "Condividi orario", + "description": "Condividi un URL con l'orario della posizione attuale del lettore oppure scegli un orario personalizzato. Tieni presente che questo URL non è pubblico ed è accessibile solo agli utenti che hanno accesso a Frigate e a questa telecamera.", + "custom": "Orario personalizzato", + "button": "URL dell'orario di condivisione", + "shareTitle": "Orario revisione Frigate: {{camera}}" } }, "imagePicker": { diff --git a/web/public/locales/it/components/player.json b/web/public/locales/it/components/player.json index 2aee1a7815..e8a1f5bbbf 100644 --- a/web/public/locales/it/components/player.json +++ b/web/public/locales/it/components/player.json @@ -4,7 +4,8 @@ "noPreviewFoundFor": "Nessuna anteprima trovata per {{cameraName}}", "submitFrigatePlus": { "title": "Vuoi inviare questo fotogramma a Frigate+?", - "submit": "Invia" + "submit": "Invia", + "previewError": "Impossibile caricare l'anteprima dell'istantanea. La registrazione potrebbe non essere disponibile al momento." }, "livePlayerRequiredIOSVersion": "Per questo tipo di trasmissione dal vivo è richiesto iOS 17.1 o versione successiva.", "stats": { @@ -47,5 +48,5 @@ "submitFrigatePlusFailed": "Impossibile inviare il fotogramma a Frigate+" } }, - "cameraDisabled": "La telecamera è disattivata" + "cameraDisabled": "La telecamera è disabilita" } diff --git a/web/public/locales/it/config/cameras.json b/web/public/locales/it/config/cameras.json index 491b69052e..ebf816b539 100644 --- a/web/public/locales/it/config/cameras.json +++ b/web/public/locales/it/config/cameras.json @@ -2,30 +2,219 @@ "label": "Configurazione telecamera", "name": { "label": "Nome telecamera", - "description": "Il nome della telecamera è necessario" + "description": "Il nome della telecamera è obbligatorio" }, "friendly_name": { "description": "Nome amichevole della telecamera utilizzato nell'interfaccia utente di Frigate", "label": "Nome amichevole" }, "enabled": { - "label": "Abilitato", - "description": "Abilitato" + "label": "Abilitata", + "description": "Abilitata" }, "audio": { - "label": "Eventi audio", + "label": "Rilevamento audio", "description": "Impostazioni per il rilevamento di eventi audio per questa telecamera.", "enabled": { "label": "Abilita il rilevamento audio", "description": "Abilita o disabilita il rilevamento degli eventi audio per questa telecamera." }, "min_volume": { - "label": "Volume minimo" + "label": "Volume minimo", + "description": "È richiesta una soglia minima di volume RMS per eseguire il rilevamento audio; valori inferiori aumentano la sensibilità (ad esempio, 200 alta, 500 media, 1000 bassa)." + }, + "max_not_heard": { + "label": "Fine pausa", + "description": "Numero di secondi senza il tipo di audio configurato prima che l'evento audio termini." + }, + "listen": { + "label": "Tipi di ascolto", + "description": "Elenco dei tipi di eventi audio da rilevare (ad esempio: abbaio, allarme antincendio, urlo, parlato, grido)." + }, + "filters": { + "label": "Filtri audio", + "description": "Impostazioni di filtro per ciascun tipo di audio, come le soglie di confidenza utilizzate per ridurre i falsi positivi." + }, + "enabled_in_config": { + "label": "Stato audio originale", + "description": "Indica se il rilevamento audio era originariamente abilitato nel file di configurazione statico." + }, + "num_threads": { + "label": "Processi di rilevamento", + "description": "Numero di processi da utilizzare per l'elaborazione del rilevamento audio." } }, "ffmpeg": { "path": { "label": "Percorso FFmpeg" + }, + "label": "FFmpeg", + "hwaccel_args": { + "label": "Argomenti di accelerazione hardware", + "description": "Argomenti di accelerazione hardware per FFmpeg. Si consiglia di utilizzare preimpostazioni specifiche del provider." + }, + "inputs": { + "hwaccel_args": { + "label": "Argomenti di accelerazione hardware", + "description": "Argomenti di accelerazione hardware per questo flusso di ingresso." + } + }, + "gpu": { + "description": "Indice GPU predefinito utilizzato per l'accelerazione hardware, se disponibile." } + }, + "audio_transcription": { + "label": "Trascrizione audio", + "description": "Impostazioni per la trascrizione audio in tempo reale e del parlato utilizzata per eventi e sottotitoli in tempo reale.", + "enabled": { + "label": "Abilita la trascrizione", + "description": "Abilita o disabilita la trascrizione manuale degli eventi audio." + }, + "enabled_in_config": { + "label": "Stato di trascrizione originale" + }, + "live_enabled": { + "label": "Trascrizione dal vivo", + "description": "Abilita la trascrizione in diretta dell'audio non appena viene ricevuto." + } + }, + "mqtt": { + "label": "MQTT" + }, + "onvif": { + "tls_insecure": { + "label": "Disabilita verifica TLS" + }, + "profile": { + "label": "Profilo ONVIF" + }, + "autotracking": { + "label": "Tracciamento automatico", + "enabled": { + "label": "Abilita il tracciamento automatico" + }, + "calibrate_on_startup": { + "label": "Calibra all'avvio" + }, + "zooming": { + "label": "Modalità ingrandimento" + }, + "zoom_factor": { + "label": "Fattore di ingrandimento" + }, + "track": { + "label": "Oggetti tracciati", + "description": "Elenco dei tipi di oggetto che dovrebbero attivare il tracciamento automatico." + }, + "required_zones": { + "label": "Zone richieste" + }, + "timeout": { + "label": "Scadenza di ritorno", + "description": "Attendi questo numero di secondi dopo aver perso il tracciamento prima di riportare la telecamera nella posizione preimpostata." + }, + "movement_weights": { + "description": "Valori di calibrazione generati automaticamente dalla calibrazione della telecamera. Non modificare manualmente." + }, + "enabled_in_config": { + "label": "Stato originale del tracciamento automatico" + } + }, + "ignore_time_mismatch": { + "label": "Ignora la discrepanza oraria" + }, + "label": "ONVIF", + "port": { + "label": "Porta ONVIF" + } + }, + "detect": { + "label": "Rilevamento oggetti" + }, + "face_recognition": { + "label": "Riconoscimento facciale" + }, + "review": { + "label": "Revisiona" + }, + "profiles": { + "label": "Profili" + }, + "record": { + "label": "Registrazione", + "export": { + "description": "Impostazioni utilizzate durante l'esportazione delle registrazioni come timelapse e accelerazione hardware.", + "hwaccel_args": { + "description": "Argomenti di accelerazione hardware da utilizzare per le operazioni di esportazione/transcodifica." + } + } + }, + "snapshots": { + "label": "Istantanee" + }, + "motion": { + "label": "Rilevamento movimento", + "contour_area": { + "label": "Area di contorno" + }, + "improve_contrast": { + "label": "Migliora il contrasto" + } + }, + "objects": { + "label": "Oggetti" + }, + "live": { + "label": "Riproduzione in diretta" + }, + "timestamp_style": { + "label": "Stile orario" + }, + "notifications": { + "label": "Notifiche", + "enabled": { + "label": "Abilita le notifiche" + }, + "email": { + "label": "Email di notifica", + "description": "Indirizzo email utilizzato per le notifiche push o richiesto da alcuni fornitori di servizi di notifica." + }, + "cooldown": { + "label": "Periodo di raffreddamento", + "description": "Tempo di attesa (in secondi) tra le notifiche per evitare di inviare spam ai destinatari." + }, + "enabled_in_config": { + "label": "Stato delle notifiche originali", + "description": "Indica se le notifiche erano abilitate nella configurazione statica originale." + } + }, + "birdseye": { + "label": "Birdseye" + }, + "semantic_search": { + "label": "Ricerca semantica", + "triggers": { + "label": "Inneschi" + } + }, + "lpr": { + "label": "Riconoscimento targhe" + }, + "ui": { + "description": "Visualizza l'ordine e la visibilità di questa telecamera nell'interfaccia utente. L'ordine influisce sul cruscotto predefinito. Per un controllo più granulare, utilizza i gruppi di telecamere.", + "order": { + "description": "L'ordine numerico viene utilizzato per ordinare le telecamere nell'interfaccia utente (cruscotto ed elenchi predefiniti); i numeri più grandi compaiono successivamente." + }, + "dashboard": { + "label": "Mostra nell'interfaccia utente", + "description": "Abilita o disabilita la visualizzazione di questa telecamera in ogni punto dell'interfaccia utente di Frigate. Disabilitando questa opzione, sarà necessario modificare manualmente la configurazione per visualizzare nuovamente la telecamera nell'interfaccia utente." + }, + "label": "Interfaccia utente telecamera" + }, + "zones": { + "enabled": { + "label": "Abilitata" + }, + "label": "Zone" } } diff --git a/web/public/locales/it/config/global.json b/web/public/locales/it/config/global.json index dbd4f3ec65..5bb38cf431 100644 --- a/web/public/locales/it/config/global.json +++ b/web/public/locales/it/config/global.json @@ -12,24 +12,46 @@ "description": "Versione numerica o stringa della configurazione attiva per facilitare il rilevamento di migrazioni o modifiche di formato." }, "audio": { - "label": "Eventi audio", + "label": "Rilevamento audio", "enabled": { "label": "Abilita il rilevamento audio" }, "min_volume": { - "label": "Volume minimo" + "label": "Volume minimo", + "description": "È richiesta una soglia minima di volume RMS per eseguire il rilevamento audio; valori inferiori aumentano la sensibilità (ad esempio, 200 alta, 500 media, 1000 bassa)." + }, + "max_not_heard": { + "label": "Fine pausa", + "description": "Numero di secondi senza il tipo di audio configurato prima che l'evento audio termini." + }, + "listen": { + "label": "Tipi di ascolto", + "description": "Elenco dei tipi di eventi audio da rilevare (ad esempio: abbaio, allarme antincendio, urlo, parlato, grido)." + }, + "filters": { + "label": "Filtri audio", + "description": "Impostazioni di filtro per ciascun tipo di audio, come le soglie di confidenza utilizzate per ridurre i falsi positivi." + }, + "enabled_in_config": { + "label": "Stato audio originale", + "description": "Indica se il rilevamento audio era originariamente abilitato nel file di configurazione statico." + }, + "num_threads": { + "label": "Processi di rilevamento", + "description": "Numero di processi da utilizzare per l'elaborazione del rilevamento audio." } }, "logger": { "description": "Consente di controllare il livello di dettaglio predefinito dei registri e le opzioni di sovrascrittura per ciascun componente.", "default": { - "label": "Livello di registrazione", + "label": "Livello del registro", "description": "Livello di dettaglio predefinito del registro globale (debug, info, warning, error)." }, "logs": { "label": "Livello di registro per processo", "description": "Opzioni di sovrsacrittura del livello di registro per ciascun componente, per aumentare o diminuire il livello di dettaglio dei singoli moduli." - } + }, + "label": "Registro" }, "auth": { "label": "Autenticazione", @@ -41,11 +63,387 @@ "reset_admin_password": { "label": "Reimposta la password di amministratore", "description": "Se la condizione è vera, reimposta la password dell'utente amministratore all'avvio e stampa la nuova password nei registri." + }, + "cookie_name": { + "label": "Nome del cookie JWT", + "description": "Nome del cookie utilizzato per memorizzare il token JWT per l'autenticazione nativa." + }, + "cookie_secure": { + "label": "Attributo dei cookie sicuri", + "description": "Imposta l'attributo 'sicuro' sul cookie di autenticazione; deve essere impostato su 'vero' quando si utilizza TLS." + }, + "session_length": { + "label": "Durata della sessione", + "description": "Durata della sessione in secondi per le sessioni basate su JWT." + }, + "refresh_time": { + "label": "Finestra di aggiornamento della sessione", + "description": "Quando una sessione sta per scadere entro questo numero di secondi, aggiornala per ripristinarne la durata completa." + }, + "trusted_proxies": { + "label": "Proxy affidabili", + "description": "Elenco degli indirizzi IP proxy attendibili utilizzati per determinare l'indirizzo IP del client ai fini della limitazione della velocità." + }, + "roles": { + "label": "Mappatura dei ruoli" + }, + "failed_login_rate_limit": { + "label": "Limiti di accesso non riusciti", + "description": "Regole di limitazione della frequenza per i tentativi di accesso non riusciti al fine di ridurre gli attacchi di forza bruta." } }, "ffmpeg": { "path": { "label": "Percorso FFmpeg" + }, + "label": "FFmpeg", + "hwaccel_args": { + "label": "Argomenti di accelerazione hardware", + "description": "Argomenti di accelerazione hardware per FFmpeg. Si consiglia di utilizzare preimpostazioni specifiche del provider." + }, + "inputs": { + "hwaccel_args": { + "label": "Argomenti di accelerazione hardware", + "description": "Argomenti di accelerazione hardware per questo flusso di ingresso." + } + }, + "gpu": { + "description": "Indice GPU predefinito utilizzato per l'accelerazione hardware, se disponibile." + } + }, + "detectors": { + "cpu": { + "num_threads": { + "label": "Numero di processi di rilevamento", + "description": "Il numero di processi utilizzati per l'inferenza basata sulla CPU." + }, + "description": "Rilevatore CPU TFLite che esegue modelli TensorFlow Lite sulla CPU di sistema senza accelerazione hardware. Sconsigliato." + }, + "label": "Dispositivo di rilevamento", + "hailo8l": { + "description": "Rilevatore Hailo-8/Hailo-8L che utilizza modelli HEF e l'SDK HailoRT per l'inferenza sul dispositivo Hailo." + }, + "openvino": { + "description": "Rilevatore OpenVINO per CPU AMD e Intel, GPU Intel e dispositivo Intel VPU." + }, + "rknn": { + "description": "Rilevatore RKNN per NPU Rockchip; esegue modelli RKNN compilati su dispositivo Rockchip." + }, + "synaptics": { + "description": "Rilevatore NPU Synaptics per modelli in formato .synap utilizzando l'SDK Synap su dispositivo Synaptics." + }, + "type": { + "label": "Tipo" + } + }, + "audio_transcription": { + "label": "Trascrizione audio", + "description": "Impostazioni per la trascrizione audio in tempo reale e del parlato utilizzata per eventi e sottotitoli in tempo reale.", + "enabled": { + "label": "Abilita la trascrizione audio" + }, + "live_enabled": { + "label": "Trascrizione dal vivo", + "description": "Abilita la trascrizione in diretta dell'audio non appena viene ricevuto." + }, + "model_size": { + "label": "Dimensioni del modello" + } + }, + "mqtt": { + "label": "MQTT", + "enabled": { + "label": "Abilita MQTT", + "description": "Abilita o disabilita l'integrazione MQTT per stato, eventi e istantanee." + }, + "host": { + "label": "Sistema MQTT", + "description": "Nome sistema o indirizzo IP del broker MQTT." + }, + "port": { + "label": "Porta MQTT", + "description": "Porta del broker MQTT (solitamente 1883 per MQTT standard)." + }, + "topic_prefix": { + "label": "Prefisso argomento", + "description": "Prefisso dell'argomento MQTT per tutti gli argomenti Frigate; deve essere univoco se si eseguono più istanze." + }, + "client_id": { + "label": "ID client", + "description": "Identificativo del client utilizzato per la connessione al broker MQTT; deve essere univoco per ogni istanza." + }, + "stats_interval": { + "label": "Intervallo statistiche", + "description": "Intervallo in secondi per la pubblicazione delle statistiche di sistema e della telecamera su MQTT." + }, + "user": { + "label": "Nome utente MQTT", + "description": "Nome utente MQTT facoltativo; può essere fornito tramite variabili d'ambiente o segreti." + }, + "password": { + "label": "Password MQTT", + "description": "Password MQTT facoltativa; può essere fornita tramite variabili d'ambiente o segreti." + }, + "tls_ca_certs": { + "label": "Certificati CA TLS", + "description": "Percorso al certificato CA per le connessioni TLS al broker (per certificati autofirmati)." + }, + "tls_client_cert": { + "label": "Certificato client", + "description": "Percorso del certificato client per l'autenticazione reciproca TLS; non impostare nome utente/password quando si utilizzano certificati client." + }, + "description": "Impostazioni per la connessione e la pubblicazione di dati di telemetria, istantanee e dettagli degli eventi a un broker MQTT.", + "tls_client_key": { + "label": "Chiave client", + "description": "Percorso della chiave privata per il certificato client." + }, + "tls_insecure": { + "label": "TLS non sicuro", + "description": "Consenti connessioni TLS non sicure saltando la verifica del nome sistema (sconsigliato)." + }, + "qos": { + "label": "QoS MQTT", + "description": "Livello di qualità del servizio per le pubblicazioni/sottoscrizioni MQTT (0, 1 o 2)." + } + }, + "onvif": { + "tls_insecure": { + "label": "Disabilita verifica TLS" + }, + "profile": { + "label": "Profilo ONVIF" + }, + "autotracking": { + "label": "Tracciamento automatico", + "enabled": { + "label": "Abilita il tracciamento automatico" + }, + "calibrate_on_startup": { + "label": "Calibra all'avvio" + }, + "zooming": { + "label": "Modalità ingrandimento" + }, + "zoom_factor": { + "label": "Fattore di ingrandimento" + }, + "track": { + "label": "Oggetti tracciati", + "description": "Elenco dei tipi di oggetto che dovrebbero attivare il tracciamento automatico." + }, + "required_zones": { + "label": "Zone richieste" + }, + "timeout": { + "label": "Scadenza di ritorno", + "description": "Attendi questo numero di secondi dopo aver perso il tracciamento prima di riportare la telecamera nella posizione preimpostata." + }, + "movement_weights": { + "description": "Valori di calibrazione generati automaticamente dalla calibrazione della telecamera. Non modificare manualmente." + }, + "enabled_in_config": { + "label": "Stato originale del tracciamento automatico" + } + }, + "ignore_time_mismatch": { + "label": "Ignora la discrepanza oraria" + }, + "label": "ONVIF", + "port": { + "label": "Porta ONVIF" + } + }, + "detect": { + "label": "Rilevamento oggetti" + }, + "face_recognition": { + "label": "Riconoscimento facciale", + "model_size": { + "label": "Dimensioni del modello" + } + }, + "proxy": { + "logout_url": { + "description": "URL per reindirizzare gli utenti al momento della registrazione tramite il proxy.", + "label": "URL di disconnessione" + }, + "label": "Proxy", + "description": "Impostazioni per l'integrazione di Frigate dietro un proxy inverso che trasmette le intestazioni utente autenticate.", + "header_map": { + "label": "Mappatura dell'intestazione", + "description": "Mappa le intestazioni proxy in entrata ai campi utente e ruolo di Frigate per l'autenticazione basata su proxy.", + "user": { + "label": "Intestazione utente", + "description": "Intestazione contenente il nome utente autenticato fornito dal proxy a monte." + }, + "role": { + "label": "Intestazione ruolo", + "description": "Intestazione contenente il ruolo o i gruppi dell'utente autenticato, provenienti dal proxy a monte." + }, + "role_map": { + "label": "Mappatura dei ruoli", + "description": "Mappa i valori dei gruppi a monte dei ruoli di Frigate (ad esempio, mappa i gruppi di amministrazione al ruolo di amministratore)." + } + }, + "auth_secret": { + "label": "Segreto di proxy", + "description": "Segreto opzionale verificato rispetto all'intestazione X-Proxy-Secret per convalidare i proxy attendibili." + }, + "default_role": { + "label": "Ruolo predefinito", + "description": "Ruolo predefinito assegnato agli utenti autenticati tramite proxy quando non si applica alcuna mappatura dei ruoli (amministratore o visualizzatore)." + } + }, + "review": { + "label": "Revisiona" + }, + "ui": { + "label": "Interfaccia utente", + "description": "Preferenze dell'interfaccia utente come fuso orario, formato di data/ora e unità di misura." + }, + "profiles": { + "label": "Profili" + }, + "record": { + "label": "Registrazione", + "export": { + "description": "Impostazioni utilizzate durante l'esportazione delle registrazioni come timelapse e accelerazione hardware.", + "hwaccel_args": { + "description": "Argomenti di accelerazione hardware da utilizzare per le operazioni di esportazione/transcodifica." + } + } + }, + "snapshots": { + "label": "Istantanee" + }, + "motion": { + "label": "Rilevamento movimento", + "contour_area": { + "label": "Area di contorno" + }, + "improve_contrast": { + "label": "Migliora il contrasto" + } + }, + "objects": { + "label": "Oggetti" + }, + "live": { + "label": "Riproduzione in diretta" + }, + "timestamp_style": { + "label": "Stile orario" + }, + "database": { + "label": "Database", + "description": "Impostazioni per il database SQLite utilizzato da Frigate per memorizzare i metadati relativi agli oggetti tracciati e alle registrazioni.", + "path": { + "label": "Percorso del database", + "description": "Percorso del filesystem in cui verrà memorizzato il file del database Frigate SQLite." + } + }, + "go2rtc": { + "label": "go2rtc", + "description": "Impostazioni per il servizio di ritrasmissione integrato go2rtc utilizzato per la ritrasmissione e la traduzione di flussi dal vivo." + }, + "camera_mqtt": { + "label": "MQTT" + }, + "notifications": { + "label": "Notifiche", + "description": "Impostazioni per abilitare e controllare le notifiche per tutte le telecamere; possono essere modificate per ogni singola telecamera.", + "enabled": { + "label": "Abilita le notifiche", + "description": "Abilita o disabilita le notifiche per tutte le telecamere; l'impostazione può essere modificata per ogni singola telecamera." + }, + "email": { + "label": "Email di notifica", + "description": "Indirizzo email utilizzato per le notifiche push o richiesto da alcuni fornitori di servizi di notifica." + }, + "cooldown": { + "label": "Periodo di raffreddamento", + "description": "Tempo di attesa (in secondi) tra le notifiche per evitare di inviare spam ai destinatari." + }, + "enabled_in_config": { + "label": "Stato delle notifiche originali", + "description": "Indica se le notifiche erano abilitate nella configurazione statica originale." + } + }, + "networking": { + "label": "Reti", + "description": "Impostazioni relative alla rete, come l'abilitazione di IPv6 per i dispositivi Frigate.", + "ipv6": { + "label": "Configurazione IPv6", + "description": "Impostazioni specifiche IPv6 per i servizi di rete Frigate.", + "enabled": { + "label": "Abilita IPv6", + "description": "Abilita il supporto IPv6 per i servizi Frigate (API e interfaccia utente) ove applicabile." + } + }, + "listen": { + "label": "Configurazione delle porte di ascolto", + "description": "Configurazione per le porte di ascolto interne ed esterne. Questa sezione è destinata agli utenti esperti. Per la maggior parte dei casi, si consiglia di modificare la sezione relativa alle porte nel file Docker Compose.", + "internal": { + "label": "Porta interna", + "description": "Porta di ascolto interna per Frigate (predefinita 5000)." + }, + "external": { + "label": "Porta esterna", + "description": "Porta di ascolto esterna per Frigate (predefinita 8971)." + } + } + }, + "tls": { + "label": "TLS" + }, + "telemetry": { + "label": "Telemetria" + }, + "birdseye": { + "label": "Birdseye" + }, + "model": { + "label": "Modello di rilevamento" + }, + "semantic_search": { + "label": "Ricerca semantica", + "triggers": { + "label": "Inneschi" + }, + "model_size": { + "label": "Dimensioni del modello" + } + }, + "lpr": { + "label": "Riconoscimento targhe", + "model_size": { + "label": "Dimensioni del modello" + } + }, + "classification": { + "label": "Classificazione oggetti", + "bird": { + "enabled": { + "label": "Classificazione uccelli" + } + } + }, + "genai": { + "roles": { + "label": "Ruoli" + }, + "model": { + "label": "Modello" + } + }, + "camera_ui": { + "description": "Visualizza l'ordine e la visibilità di questa telecamera nell'interfaccia utente. L'ordine influisce sul cruscotto predefinito. Per un controllo più granulare, utilizza i gruppi di telecamere.", + "order": { + "description": "L'ordine numerico viene utilizzato per ordinare le telecamere nell'interfaccia utente (cruscotto ed elenchi predefiniti); i numeri più grandi compaiono successivamente." + }, + "dashboard": { + "label": "Mostra nell'interfaccia utente", + "description": "Abilita o disabilita la visualizzazione di questa telecamera in ogni punto dell'interfaccia utente di Frigate. Disabilitando questa opzione, sarà necessario modificare manualmente la configurazione per visualizzare nuovamente la telecamera nell'interfaccia utente." } } } diff --git a/web/public/locales/it/config/validation.json b/web/public/locales/it/config/validation.json index a37fcd3c71..eaba21cb21 100644 --- a/web/public/locales/it/config/validation.json +++ b/web/public/locales/it/config/validation.json @@ -4,5 +4,29 @@ "exclusiveMinimum": "Deve essere maggiore di {{limit}}", "exclusiveMaximum": "Deve essere minore di {{limit}}", "minLength": "Deve essere almeno {{limit}} carattere(i)", - "maxLength": "Deve essere al massimo {{limit}} carattere(i)" + "maxLength": "Deve essere al massimo {{limit}} carattere(i)", + "minItems": "Deve contenere almeno {{limit}} elementi", + "maxItems": "Deve avere al massimo {{limit}} elementi", + "pattern": "Formato non valido", + "required": "Questo campo è obbligatorio", + "type": "Tipo di valore non valido", + "enum": "Deve essere uno dei valori consentiti", + "const": "Il valore non corrisponde alla costante prevista", + "uniqueItems": "Tutti gli elementi devono essere unici", + "format": "Formato non valido", + "additionalProperties": "Proprietà sconosciuta non consentita", + "oneOf": "Deve corrispondere esattamente a uno degli schemi consentiti", + "anyOf": "Deve corrispondere ad almeno uno degli schemi consentiti", + "proxy": { + "header_map": { + "roleHeaderRequired": "L'intestazione del ruolo è obbligatoria quando si configurano le mappature dei ruoli." + } + }, + "ffmpeg": { + "inputs": { + "rolesUnique": "Ciascun ruolo può essere assegnato a un solo flusso di ingresso.", + "detectRequired": "Ad almeno un flusso di ingresso deve essere assegnato il ruolo di 'rilevamento'.", + "hwaccelDetectOnly": "Solo il flusso di ingresso con il ruolo di rilevamento può definire argomenti di accelerazione hardware." + } + } } diff --git a/web/public/locales/it/views/chat.json b/web/public/locales/it/views/chat.json new file mode 100644 index 0000000000..67b93ffdd5 --- /dev/null +++ b/web/public/locales/it/views/chat.json @@ -0,0 +1,46 @@ +{ + "documentTitle": "Chat - Frigate", + "title": "Chat Frigate", + "subtitle": "Il tuo assistente IA per la gestione e l'analisi delle telecamere", + "placeholder": "Chiedi qualsiasi cosa...", + "error": "Si è verificato un errore. Riprova.", + "processing": "Elaborazione in corso...", + "toolsUsed": "Utilizzato: {{tools}}", + "showTools": "Mostra strumenti ({{count}})", + "hideTools": "Nascondi strumenti", + "call": "Chiama", + "result": "Risultato", + "arguments": "Argomenti:", + "response": "Risposta:", + "attachment_chip_label": "{{label}} sulla {{camera}}", + "attachment_chip_remove": "Rimuovi allegato", + "open_in_explore": "Apri in Esplora", + "attach_event_aria": "Allega evento {{eventId}}", + "attachment_picker_paste_label": "Oppure incolla ID dell'evento", + "attachment_picker_attach": "Allega", + "attachment_picker_placeholder": "Allega un evento", + "quick_reply_find_similar": "Trova avvistamenti simili", + "quick_reply_tell_me_more": "Raccontami di più su questo", + "quick_reply_when_else": "In quali altre occasioni è stato avvistato?", + "quick_reply_find_similar_text": "Trova avvistamenti simili a questo.", + "quick_reply_tell_me_more_text": "Raccontami di più su questo.", + "quick_reply_when_else_text": "Quando è stato osservato in altre volte?", + "anchor": "Riferimento", + "similarity_score": "Somiglianza", + "no_similar_objects_found": "Nessun oggetto simile trovato.", + "semantic_search_required": "Per trovare oggetti simili è necessario abilitare la ricerca semantica.", + "send": "Invia", + "suggested_requests": "Prova a chiedere:", + "starting_requests": { + "show_recent_events": "Mostra gli eventi recenti", + "show_camera_status": "Mostra lo stato della telecamera", + "recap": "Cosa è successo mentre ero via?", + "watch_camera": "Guarda la telecamera per attività" + }, + "starting_requests_prompts": { + "show_recent_events": "Mostrami gli eventi recenti dell'ultima ora", + "show_camera_status": "Qual è lo stato attuale delle mie telecamere?", + "recap": "Cosa è successo mentre ero via?", + "watch_camera": "Controlla la porta d'ingresso e fammi sapere se arriva qualcuno" + } +} diff --git a/web/public/locales/it/views/events.json b/web/public/locales/it/views/events.json index f1a9255f7e..45289b6452 100644 --- a/web/public/locales/it/views/events.json +++ b/web/public/locales/it/views/events.json @@ -20,9 +20,11 @@ }, "markTheseItemsAsReviewed": "Segna questi elementi come visti", "markAsReviewed": "Segna come visto", - "documentTitle": "Rivedi - Frigate", + "documentTitle": "Revisiona - Frigate", "allCameras": "Tutte le camere", - "timeline": "Cronologia", + "timeline": { + "label": "Linea temporale" + }, "timeline.aria": "Seleziona la cronologia", "events": { "label": "Eventi", @@ -30,7 +32,9 @@ "noFoundForTimePeriod": "Nessun evento trovato per questo intervallo." }, "recordings": { - "documentTitle": "Registrazioni - Frigate" + "documentTitle": "Registrazioni - Frigate", + "invalidSharedLink": "Impossibile aprire il collegamento alla registrazione con orario a causa di un errore di analisi.", + "invalidSharedCamera": "Impossibile aprire il collegamento alla registrazione con orario a causa di una telecamera sconosciuta o non autorizzata." }, "calendarFilter": { "last24Hours": "Ultime 24 ore" @@ -44,7 +48,7 @@ "threateningActivity": "Attività minacciosa", "detail": { "noDataFound": "Nessun dato dettagliato da rivedere", - "aria": "Attiva/disattiva la visualizzazione dettagliata", + "aria": "Abilita/disabilita la visualizzazione dettagliata", "trackedObject_one": "{{count}} oggetto", "trackedObject_other": "{{count}} oggetti", "noObjectDetailData": "Non sono disponibili dati dettagliati sull'oggetto.", @@ -64,5 +68,28 @@ "normalActivity": "Normale", "needsReview": "Necessita revisione", "securityConcern": "Rischio per la sicurezza", - "select_all": "Tutti" + "select_all": "Tutti", + "motionSearch": { + "menuItem": "Ricerca movimento", + "openMenu": "Opzioni telecamera" + }, + "motionPreviews": { + "title": "Anteprime di movimento: {{camera}}", + "mobileSettingsTitle": "Impostazioni di anteprima del movimento", + "mobileSettingsDesc": "Regola la velocità di riproduzione e la luminosità, poi scegli una data per rivedere i filmati che mostrano solo il movimento.", + "dim": "Attenua", + "dimAria": "Regola l'intensità della luce", + "dimDesc": "Aumenta l'attenuazione per migliorare la visibilità delle aree in movimento.", + "speed": "Velocità", + "speedAria": "Seleziona la velocità di riproduzione dell'anteprima", + "speedDesc": "Scegli la velocità di riproduzione dei video di anteprima.", + "back": "Indietro", + "empty": "Nessuna anteprima disponibile", + "noPreview": "Anteprima non disponibile", + "seekAria": "Cerca il riproduttore {{camera}} a {{time}}", + "filter": "Filtro", + "filterDesc": "Seleziona le aree per visualizzare solo i video con movimento in quelle regioni.", + "filterClear": "Pulisci", + "menuItem": "Visualizza le anteprime del movimento" + } } diff --git a/web/public/locales/it/views/explore.json b/web/public/locales/it/views/explore.json index 7cb9b4b806..e01329aada 100644 --- a/web/public/locales/it/views/explore.json +++ b/web/public/locales/it/views/explore.json @@ -224,8 +224,8 @@ "aria": "Scarica istantanea pulita" }, "debugReplay": { - "label": "Riproduzione di correzione", - "aria": "Visualizza questo oggetto tracciato nella vista di riproduzione di correzione" + "label": "Riproduzione di correzioni", + "aria": "Visualizza questo oggetto tracciato nella vista di riproduzione di correzioni" }, "more": { "aria": "Altri" @@ -289,7 +289,10 @@ "zones": "Zone", "ratio": "Rapporto", "area": "Area", - "score": "Punteggio" + "score": "Punteggio", + "computedScore": "Punteggio calcolato", + "topScore": "Punteggio massimo", + "toggleAdvancedScores": "Attiva/disattiva i punteggi avanzati" } }, "annotationSettings": { diff --git a/web/public/locales/it/views/exports.json b/web/public/locales/it/views/exports.json index 63bebbefa7..df3ee48982 100644 --- a/web/public/locales/it/views/exports.json +++ b/web/public/locales/it/views/exports.json @@ -14,7 +14,9 @@ "toast": { "error": { "renameExportFailed": "Impossibile rinominare l'esportazione: {{errorMessage}}", - "assignCaseFailed": "Impossibile aggiornare l'assegnazione del caso: {{errorMessage}}" + "assignCaseFailed": "Impossibile aggiornare l'assegnazione del caso: {{errorMessage}}", + "caseSaveFailed": "Impossibile salvare il caso: {{errorMessage}}", + "caseDeleteFailed": "Impossibile eliminare il caso: {{errorMessage}}" } }, "tooltip": { @@ -22,7 +24,8 @@ "downloadVideo": "Scarica video", "editName": "Modifica nome", "deleteExport": "Elimina esportazione", - "assignToCase": "Aggiungi al caso" + "assignToCase": "Aggiungi al caso", + "removeFromCase": "Rimuovi dal caso" }, "headings": { "cases": "Casi", @@ -35,5 +38,91 @@ "newCaseOption": "Crea un nuovo caso", "nameLabel": "Nome del caso", "descriptionLabel": "Descrizione" + }, + "toolbar": { + "newCase": "Nuovo caso", + "addExport": "Aggiungi esportazione", + "editCase": "Modifica caso", + "deleteCase": "Elimina il caso" + }, + "deleteCase": { + "label": "Eliminare il caso", + "desc": "Sei sicuro di voler eliminare {{caseName}}?", + "descKeepExports": "Le esportazioni rimarranno disponibili come esportazioni non categorizzate.", + "descDeleteExports": "In questo caso, tutte le esportazioni verranno eliminate definitivamente.", + "deleteExports": "Elimina anche le esportazioni" + }, + "caseCard": { + "emptyCase": "Ancora nessuna esportazione" + }, + "jobCard": { + "defaultName": "{{camera}} esporta", + "queued": "In coda", + "running": "In esecuzione", + "preparing": "Preparazione", + "copying": "Copia", + "encoding": "Codifica", + "encodingRetry": "Codifica (riprova)", + "finalizing": "Finalizzazione" + }, + "caseView": { + "noDescription": "Nessuna descrizione", + "createdAt": "Creato {{value}}", + "exportCount_one": "1 esportazione", + "exportCount_other": "{{count}} esportazioni", + "cameraCount_one": "1 telecamera", + "cameraCount_other": "{{count}} telecamere", + "showMore": "Mostra altro", + "showLess": "Mostra meno", + "emptyTitle": "Questo caso è vuoto", + "emptyDescription": "Aggiungi le esportazioni esistenti non categorizzate per mantenere il caso organizzato.", + "emptyDescriptionNoExports": "Al momento non sono disponibili esportazioni non categorizzate da aggiungere." + }, + "caseEditor": { + "createTitle": "Crea un caso", + "editTitle": "Modifica caso", + "namePlaceholder": "Nome del caso", + "descriptionPlaceholder": "Aggiungi note o contesto per questo caso" + }, + "addExportDialog": { + "title": "Aggiungi l'esportazione a {{caseName}}", + "searchPlaceholder": "Cerca esportazioni non categorizzate", + "empty": "Nessuna esportazione non categorizzata corrisponde a questa ricerca.", + "addButton_one": "Aggiungi 1 esportazione", + "addButton_other": "Aggiungi {{count}} esportazioni", + "adding": "In aggiunta..." + }, + "selected_one": "{{count}} selezionati", + "selected_other": "{{count}} selezionati", + "bulkActions": { + "addToCase": "Aggiungi al caso", + "moveToCase": "Sposta al caso", + "removeFromCase": "Rimuovi dal caso", + "delete": "Elimina", + "deleteNow": "Elimina ora" + }, + "bulkDelete": { + "title": "Elimina le esportazioni", + "desc_one": "Sei sicuro di voler eliminare l'esportazione {{count}}?", + "desc_other": "Sei sicuro di voler eliminare {{count}} esportazioni?" + }, + "bulkRemoveFromCase": { + "title": "Rimuovi dal caso", + "desc_one": "Rimuovere l'esportazione {{count}} da questo caso?", + "desc_other": "Rimuovere {{count}} esportazioni da questo caso?", + "descKeepExports": "Le esportazioni verranno spostate nella categoria \"non classificate\".", + "descDeleteExports": "Le esportazioni verranno eliminate definitivamente.", + "deleteExports": "Elimina almeno le esportazioni" + }, + "bulkToast": { + "success": { + "delete": "Esportazioni eliminate con successo", + "reassign": "Assegnazione del caso aggiornata con successo", + "remove": "Esportazioni rimosse con successo dal caso" + }, + "error": { + "deleteFailed": "Impossibile eliminare le esportazioni: {{errorMessage}}", + "reassignFailed": "Impossibile aggiornare l'assegnazione del caso: {{errorMessage}}" + } } } diff --git a/web/public/locales/it/views/live.json b/web/public/locales/it/views/live.json index 7aa3302c94..466110ad10 100644 --- a/web/public/locales/it/views/live.json +++ b/web/public/locales/it/views/live.json @@ -110,7 +110,8 @@ }, "recording": { "enable": "Abilita registrazione", - "disable": "Disabilita registrazione" + "disable": "Disabilita registrazione", + "disabledInConfig": "Per questa telecamera è necessario prima abilitare la registrazione nelle impostazioni." }, "audioDetect": { "enable": "Abilita rilevamento audio", diff --git a/web/public/locales/it/views/motionSearch.json b/web/public/locales/it/views/motionSearch.json new file mode 100644 index 0000000000..06a80167d9 --- /dev/null +++ b/web/public/locales/it/views/motionSearch.json @@ -0,0 +1,77 @@ +{ + "documentTitle": "Ricerca movimenti - Frigate", + "title": "Ricerca movimenti", + "description": "Disegna un poligono per definire la regione di interesse e specifica un intervallo di tempo per la ricerca di cambiamenti di movimento all'interno di tale regione.", + "selectCamera": "La ricerca di movimenti è in fase di caricamento", + "startSearch": "Avvia ricerca", + "searchStarted": "Ricerca avviata", + "searchCancelled": "Ricerca annullata", + "cancelSearch": "Annulla", + "searching": "Ricerca in corso.", + "searchComplete": "Ricerca completata", + "noResultsYet": "Esegui una ricerca per trovare le variazioni di movimento nella regione selezionata", + "noChangesFound": "Nessuna modifica dei pixel rilevata nella regione selezionata", + "changesFound_one": "Trovato {{count}} cambiamento di movimento", + "changesFound_many": "Trovati {{count}} cambiamenti di movimento", + "changesFound_other": "Trovati {{count}} cambiamenti di movimento", + "framesProcessed": "{{count}} fotogrammi elaborati", + "jumpToTime": "Vai a questo momento", + "results": "Risultati", + "showSegmentHeatmap": "Mappa di calore", + "newSearch": "Nuova ricerca", + "clearResults": "Pulisci risultati", + "clearROI": "Pulisci poligono", + "polygonControls": { + "points_one": "{{count}} punto", + "points_many": "{{count}} punti", + "points_other": "{{count}} punti", + "undo": "Annulla ultimo punto", + "reset": "Reimposta poligono" + }, + "motionHeatmapLabel": "Mappa di calore del movimento", + "dialog": { + "title": "Ricerca di movimento", + "cameraLabel": "Telecamera", + "previewAlt": "Anteprima della telecamera per {{camera}}" + }, + "timeRange": { + "title": "Intervallo di ricerca", + "start": "Ora di inizio", + "end": "Ora di fine" + }, + "settings": { + "title": "Impostazioni di ricerca", + "parallelMode": "Modalità parallela", + "parallelModeDesc": "Scansiona più segmenti di registrazione contemporaneamente (più veloce, ma richiede un utilizzo della CPU significativamente maggiore)", + "threshold": "Soglia di sensibilità", + "thresholdDesc": "Valori più bassi indicano cambiamenti minori (1-255)", + "minArea": "Area di cambio minimo", + "minAreaDesc": "Percentuale minima della regione di interesse che deve cambiare per essere considerata significativa", + "frameSkip": "Salta fotogrammi", + "frameSkipDesc": "Elabora ogni N-esimo fotogramma. Imposta questo valore sulla frequenza dei fotogrammi della tua telecamera per elaborare un fotogramma al secondo (ad esempio, 5 per una telecamera a 5 FPS, 30 per una telecamera a 30 FPS). Valori più alti saranno più veloci, ma potrebbero perdere eventi di movimento brevi.", + "maxResults": "Risultati massimi", + "maxResultsDesc": "Interrompi dopo questo numero di orario corrispondenti" + }, + "errors": { + "noCamera": "Seleziona una telecamera", + "noROI": "Disegna una regione di interesse", + "noTimeRange": "Seleziona un intervallo di tempo", + "invalidTimeRange": "L'ora di fine deve essere successiva all'ora di inizio", + "searchFailed": "Ricerca non riuscita: {{message}}", + "polygonTooSmall": "Il poligono deve avere almeno 3 punti", + "unknown": "Errore sconosciuto" + }, + "changePercentage": "{{percentage}}% modificato", + "metrics": { + "title": "Metriche di ricerca", + "segmentsScanned": "Segmenti scansionati", + "segmentsProcessed": "Elaborati", + "segmentsSkippedInactive": "Saltati (nessuna attività)", + "segmentsSkippedHeatmap": "Saltati (nessuna sovrapposizione del ROI)", + "fallbackFullRange": "Scansione gamma completa alternativa", + "framesDecoded": "Fotogrammi decodificati", + "wallTime": "Tempo di ricerca", + "segmentErrors": "Errori di segmento", + "seconds": "{{seconds}}s" + } +} diff --git a/web/public/locales/it/views/replay.json b/web/public/locales/it/views/replay.json new file mode 100644 index 0000000000..fc698eb04a --- /dev/null +++ b/web/public/locales/it/views/replay.json @@ -0,0 +1,59 @@ +{ + "title": "Riproduzione correzioni", + "description": "Riproduci le registrazioni della telecamera per le correzioni. L'elenco degli oggetti mostra un riepilogo ritardato degli oggetti rilevati e la scheda Messaggi mostra un flusso di messaggi interni di Frigate tratti dal filmato riprodotto.", + "websocket_messages": "Messaggi", + "dialog": { + "title": "Avvia la riproduzione delle correzioni", + "description": "Crea una telecamera di riproduzione temporanea che riproduca in ciclo i filmati storici per la correzione dei problemi di rilevamento e tracciamento degli oggetti. La telecamera di riproduzione avrà la stessa configurazione di rilevamento della telecamera sorgente. Scegli un intervallo di tempo da cui iniziare.", + "camera": "Telecamera sorgente", + "timeRange": "Intervallo di tempo", + "preset": { + "1m": "Ultimo minuto", + "5m": "Ultimi 5 minuti", + "timeline": "Dalla cronologia", + "custom": "Personalizza" + }, + "startButton": "Avvia riproduzione", + "selectFromTimeline": "Seleziona", + "starting": "Avvio riproduzione...", + "startLabel": "Inizio", + "endLabel": "Fine", + "toast": { + "error": "Impossibile avviare la riproduzione di correzioni: {{error}}", + "alreadyActive": "È già attiva una sessione di riproduzione", + "stopError": "Impossibile interrompere la riproduzione di correzioni: {{error}}", + "goToReplay": "Vai alla riproduzione" + } + }, + "page": { + "noSession": "Nessuna sessione di riproduzione di correzioni attiva", + "noSessionDesc": "Avvia una riproduzione di correzioni dalla visualizzazione Cronologia facendo clic sul pulsante Azioni nella barra degli strumenti e scegliendo Riproduzione di correzioni.", + "goToRecordings": "Vai alla Cronologia", + "preparingClip": "Preparazione video…", + "preparingClipDesc": "Frigate sta unendo le registrazioni relative all'intervallo di tempo selezionato. Questa operazione può richiedere un minuto per intervalli più lunghi.", + "startingCamera": "Avvio della riproduzione di correzioni…", + "startError": { + "title": "Impossibile avviare la riproduzione di correzioni", + "back": "Torna alla Cronologia" + }, + "sourceCamera": "Telecamera sorgente", + "replayCamera": "Telecamera di riproduzione", + "initializingReplay": "Inizializzazione riproduzione di correzioni...", + "stoppingReplay": "Interruzione riproduzione di correzioni...", + "stopReplay": "Ferma riproduzione", + "confirmStop": { + "title": "Interrompere la riproduzione di correzioni?", + "description": "In questo modo la sessione verrà interrotta e tutti i dati temporanei verranno eliminati. Sei sicuro?", + "confirm": "Ferma riproduzione", + "cancel": "Annulla" + }, + "activity": "Attività", + "objects": "Elenco degli oggetti", + "audioDetections": "Rilevamento audio", + "noActivity": "Nessuna attività rilevata", + "activeTracking": "Tracciamento attivo", + "noActiveTracking": "Nessun tracciamento attivo", + "configuration": "Configurazione", + "configurationDesc": "Regola con precisione le impostazioni di rilevamento del movimento e tracciamento degli oggetti per la telecamera Riproduzione di correzioni. Nessuna modifica verrà salvata nel file di configurazione di Frigate." + } +} diff --git a/web/public/locales/it/views/settings.json b/web/public/locales/it/views/settings.json index 38951855ed..2b4aea94e8 100644 --- a/web/public/locales/it/views/settings.json +++ b/web/public/locales/it/views/settings.json @@ -9,10 +9,14 @@ "object": "Correzioni - Frigate", "general": "Impostazioni interfaccia - Frigate", "frigatePlus": "Impostazioni Frigate+ - Frigate", - "notifications": "Impostazioni di notifiche - Frigate", + "notifications": "Impostazioni di notifica - Frigate", "enrichments": "Impostazioni di miglioramento - Frigate", "cameraManagement": "Gestisci telecamere - Frigate", - "cameraReview": "Impostazioni revisione telecamera - Frigate" + "cameraReview": "Impostazioni revisione telecamera - Frigate", + "globalConfig": "Configurazione globale - Frigate", + "cameraConfig": "Configurazione telecamera - Frigate", + "maintenance": "Manutenzione - Frigate", + "profiles": "Profili - Frigate" }, "frigatePlus": { "snapshotConfig": { @@ -22,7 +26,7 @@ "camera": "Telecamera", "cleanCopySnapshots": "Istantanee clean_copy" }, - "desc": "Per inviare a Frigate+ è necessario che nella configurazione siano abilitate sia le istantanee che le istantanee clean_copy.", + "desc": "Per inviare i dati a Frigate+ è necessario abilitare le istantanee nella configurazione.", "documentation": "Leggi la documentazione", "title": "Configurazione istantanee" }, @@ -56,7 +60,14 @@ }, "title": "Impostazioni Frigate+", "restart_required": "Riavvio richiesto (modello Frigate+ modificato)", - "unsavedChanges": "Modifiche alle impostazioni di Frigate+ non salvate" + "unsavedChanges": "Modifiche alle impostazioni di Frigate+ non salvate", + "description": "Frigate+ è un servizio in abbonamento che offre accesso a funzionalità e capacità aggiuntive per la tua istanza di Frigate, tra cui la possibilità di utilizzare modelli di rilevamento oggetti personalizzati addestrati sui tuoi dati. Puoi gestire le impostazioni del tuo modello Frigate+ qui.", + "cardTitles": { + "api": "API", + "currentModel": "Modello attuale", + "otherModels": "Altri modelli", + "configuration": "Configurazione" + } }, "debug": { "timestamp": { @@ -146,6 +157,12 @@ "title": "{{polygonName}} è stato salvato.", "noName": "La maschera di movimento è stata salvata." } + }, + "defaultName": "Maschera di movimento {{number}}", + "name": { + "title": "Nome", + "description": "Un nome amichevole opzionale per questa maschera di movimento.", + "placeholder": "Inserisci un nome..." } }, "form": { @@ -230,7 +247,7 @@ "desc": "Specifica una velocità minima affinché gli oggetti vengano presi in considerazione in questa zona.", "toast": { "error": { - "pointLengthError": "La stima della velocità è stata disattivata per questa zona. Le zone con stima della velocità devono avere esattamente 4 punti.", + "pointLengthError": "La stima della velocità è stata disabilitata per questa zona. Le zone con stima della velocità devono avere esattamente 4 punti.", "loiteringTimeError": "Le zone con tempi di permanenza superiori a 0 non devono essere utilizzate per la stima della velocità." } }, @@ -267,6 +284,10 @@ "allObjects": "Tutti gli oggetti", "toast": { "success": "La zona ({{zoneName}}) è stata salvata." + }, + "enabled": { + "title": "Abilitata", + "description": "Indica se questa zona è attiva e abilitata nel file di configurazione. Se disabilitata, non può essere abilitata tramite MQTT. Le zone disabilitate vengono ignorate in fase di esecuzione." } }, "objectMasks": { @@ -293,11 +314,22 @@ } }, "label": "Maschere di oggetti", - "documentTitle": "Modifica maschera oggetti - Frigate" + "documentTitle": "Modifica maschera oggetti - Frigate", + "name": { + "title": "Nome", + "placeholder": "Inserisci un nome...", + "description": "Un nome amichevole facoltativo per questa maschera oggetto." + } }, "restart_required": "Riavvio richiesto (maschere/zone modificate)", "motionMaskLabel": "Maschera di movimento {{number}}", - "objectMaskLabel": "Maschera di oggetto {{number}}" + "objectMaskLabel": "Maschera di oggetto {{number}}", + "masks": { + "enabled": { + "title": "Abilitata", + "description": "Indica se questa maschera è abilitata nel file di configurazione. Se disabilitata, non può essere abilitata tramite MQTT. Le maschere disabilitate vengono ignorate in fase di esecuzione." + } + } }, "cameraSetting": { "camera": "Telecamera", @@ -380,7 +412,7 @@ "notifications": "Notifiche", "ui": "Interfaccia utente", "classification": "Classificazione", - "cameras": "Impostazioni telecamera", + "cameras": "Configurazione telecamera", "masksAndZones": "Maschere / Zone", "debug": "Correzioni", "users": "Utenti", @@ -389,18 +421,73 @@ "triggers": "Inneschi", "roles": "Ruoli", "cameraManagement": "Gestione", - "cameraReview": "Rivedi", - "profiles": "Profili" + "cameraReview": "Revisiona", + "profiles": "Profili", + "general": "Generale", + "globalConfig": "Configurazione globale", + "system": "Sistema", + "integrations": "Integrazioni", + "uiSettings": "Impostazioni interfaccia utente", + "globalDetect": "Rilevamento oggetti", + "globalRecording": "Registrazione", + "globalSnapshots": "Istantanee", + "globalFfmpeg": "FFmpeg", + "globalMotion": "Rilevamento movimento", + "globalObjects": "Oggetti", + "globalReview": "Revisiona", + "globalAudioEvents": "Rilevamento audio", + "globalLivePlayback": "Riproduzione in diretta", + "globalTimestampStyle": "Stile orario", + "systemDatabase": "Database", + "systemTls": "TLS", + "systemAuthentication": "Autenticazione", + "systemNetworking": "Reti", + "systemProxy": "Proxy", + "systemUi": "Interfaccia utente", + "systemLogging": "Registro", + "systemEnvironmentVariables": "Variabili d'ambiente", + "systemTelemetry": "Telemetria", + "systemBirdseye": "Birdseye", + "systemFfmpeg": "FFmpeg", + "systemDetectorHardware": "Dispositivo di rilevamento", + "systemDetectionModel": "Modello di rilevamento", + "systemMqtt": "MQTT", + "systemGo2rtcStreams": "Flussi go2rtc", + "integrationSemanticSearch": "Ricerca semantica", + "integrationGenerativeAi": "IA Generativa", + "integrationFaceRecognition": "Riconoscimento facciale", + "integrationLpr": "Riconoscimento targhe", + "integrationObjectClassification": "Classificazione oggetti", + "integrationAudioTranscription": "Trascrizione audio", + "cameraDetect": "Rilevamento oggetti", + "cameraFfmpeg": "FFmpeg", + "cameraRecording": "Registrazione", + "cameraSnapshots": "Istantanee", + "cameraMotion": "Rilevamento movimento", + "cameraObjects": "Oggetti", + "cameraConfigReview": "Revisiona", + "cameraAudioEvents": "Rilevamento audio", + "cameraAudioTranscription": "Trascrizione audio", + "cameraNotifications": "Notifiche", + "cameraLivePlayback": "Riproduzione in diretta", + "cameraBirdseye": "Birdseye", + "cameraFaceRecognition": "Riconoscimento facciale", + "cameraLpr": "Riconoscimento targhe", + "cameraMqttConfig": "MQTT", + "cameraOnvif": "ONVIF", + "cameraTimestampStyle": "Stile orario", + "cameraUi": "Interfaccia utente telecamera", + "mediaSync": "Sincronizzazione multimediale" }, "users": { "dialog": { "changeRole": { "roleInfo": { - "viewerDesc": "Limitato solo alle schermate dal vivo, alle revisioni, alle esplorazioni e alle esportazioni.", + "viewerDesc": "Limitato solo ai cruscotti dal vivo, alle revisioni, alle esplorazioni e alle esportazioni.", "intro": "Seleziona il ruolo appropriato per questo utente:", "admin": "Amministratore", "adminDesc": "Accesso completo a tutte le funzionalità.", - "viewer": "Spettatore", + "viewer": "Visualizzatore", "customDesc": "Ruolo personalizzato con accesso specifico alla telecamera." }, "title": "Cambia ruolo utente", @@ -424,7 +511,7 @@ "placeholder": "Conferma password" }, "strength": { - "title": "Forza della password: ", + "title": "Complessità della password: ", "weak": "Debole", "medium": "Media", "strong": "Forte", @@ -511,14 +598,14 @@ "general": { "liveDashboard": { "automaticLiveView": { - "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.", + "desc": "Passa automaticamente alla visualizzazione dal vivo di una telecamera quando viene rilevata attività. Disabilitando questa opzione, le immagini statiche della telecamera nel cruscotto dal vivo verranno aggiornate solo una volta al minuto.", "label": "Visualizzazione automatica dal vivo" }, "playAlertVideos": { "label": "Riproduci video di avvisi", - "desc": "Per impostazione predefinita, gli avvisi recenti nella schermata dal vivo vengono riprodotti come brevi video in ciclo. Disattiva questa opzione per visualizzare solo un'immagine statica degli avvisi recenti su questo dispositivo/browser." + "desc": "Per impostazione predefinita, gli avvisi recenti nel cruscotto dal vivo vengono riprodotti come brevi video in ciclo. Disabilita questa opzione per visualizzare solo un'immagine statica degli avvisi recenti su questo dispositivo/browser." }, - "title": "Schermata dal vivo", + "title": "Cruscotto dal vivo", "displayCameraNames": { "label": "Mostra sempre i nomi delle telecamere", "desc": "Mostra sempre i nomi delle telecamere in una scheda nel cruscotto della visualizzazione dal vivo multi telecamera." @@ -528,7 +615,7 @@ "desc": "Quando la trasmissione dal vivo ad alta qualità di una telecamera non è disponibile, dopo questo numero di secondi torna alla modalità a bassa larghezza di banda. Valore predefinito: 3." } }, - "title": "Impostazioni interfaccia", + "title": "Impostazioni interfaccia utente", "storedLayouts": { "title": "Formati memorizzati", "desc": "La disposizione delle telecamere in un gruppo può essere trascinata/ridimensionata. Le posizioni vengono salvate nella memoria locale del browser.", @@ -552,7 +639,7 @@ "label": "Primo giorno della settimana", "desc": "Giorno in cui iniziano le settimane del calendario di revisione.", "sunday": "Domenica", - "monday": "Lunedi" + "monday": "Lunedì" } }, "toast": { @@ -630,7 +717,7 @@ }, "dialog": { "unsavedChanges": { - "title": "Ci sono modifiche non salvate.", + "title": "Sono presenti modifiche non salvate.", "desc": "Vuoi salvare le modifiche prima di continuare?" } }, @@ -661,7 +748,7 @@ "email": { "placeholder": "es. esempio@email.com", "desc": "È richiesto un indirizzo email valido che verrà utilizzato per avvisarti in caso di problemi con il servizio push.", - "title": "E-mail" + "title": "Email" }, "cameras": { "title": "Telecamere", @@ -704,7 +791,7 @@ }, "title": "Notifiche", "notificationSettings": { - "title": "Impostazioni notifiche", + "title": "Impostazioni di notifica", "desc": "Frigate può inviare notifiche push in modo nativo al tuo dispositivo quando è in esecuzione nel browser o installato come PWA.", "documentation": "Leggi la documentazione" }, @@ -764,7 +851,7 @@ }, "birdClassification": { "desc": "La classificazione degli uccelli identifica gli uccelli noti utilizzando un modello Tensorflow quantizzato. Quando un uccello noto viene riconosciuto, il suo nome comune viene aggiunto come sub_label. Queste informazioni sono incluse nell'interfaccia utente, nei filtri e nelle notifiche.", - "title": "Classificazione degli uccelli" + "title": "Classificazione 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 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.", @@ -903,8 +990,8 @@ }, "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." + "title": "Gestione del ruolo visualizzatore", + "desc": "Gestisci i ruoli di visualizzatori personalizzati e le relative autorizzazioni di accesso alla telecamera per questa istanza Frigate." }, "addRole": "Aggiungi ruolo", "table": { @@ -920,9 +1007,9 @@ "createRole": "Ruolo {{role}} creato con successo", "updateCameras": "Telecamere aggiornate per il ruolo {{role}}", "deleteRole": "Ruolo {{role}} eliminato con successo", - "userRolesUpdated_one": "{{count}} utente assegnato a questo ruolo è stato aggiornato a \"spettatore\", che ha accesso a tutte le telecamere.", - "userRolesUpdated_many": "{{count}} utenti assegnati a questo ruolo sono stati aggiornati a \"spettatore\", che ha accesso a tutte le telecamere.", - "userRolesUpdated_other": "{{count}} utenti assegnati a questo ruolo sono stati aggiornati a \"spettatore\", che ha accesso a tutte le telecamere." + "userRolesUpdated_one": "{{count}} utente assegnato a questo ruolo è stato aggiornato a \"visualizzatore\", che ha accesso a tutte le telecamere.", + "userRolesUpdated_many": "{{count}} utenti assegnati a questo ruolo sono stati aggiornati a \"visualizzatore\", che ha accesso a tutte le telecamere.", + "userRolesUpdated_other": "{{count}} utenti assegnati a questo ruolo sono stati aggiornati a \"visualizzatore\", che ha accesso a tutte le telecamere." }, "error": { "createRoleFailed": "Impossibile creare il ruolo: {{errorMessage}}", @@ -942,7 +1029,7 @@ }, "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.", + "desc": "Questa azione non può essere annullata. Ciò eliminerà definitivamente il ruolo e assegnerà a tutti gli utenti il ruolo di 'visualizzatore', che darà loro accesso a tutte le telecamere.", "warn": "Sei sicuro di voler eliminare {{role}}?", "deleting": "Eliminazione in corso..." }, @@ -966,15 +1053,15 @@ "cameraReview": { "title": "Impostazioni revisione telecamera", "object_descriptions": { - "title": "Descrizioni oggetti IA generativa", + "title": "Descrizioni oggetti IA Generativa", "desc": "Abilita/disabilita temporaneamente le descrizioni degli oggetti generate dall'IA per questa telecamera fino al riavvio di Frigate. Se disabilitate, le descrizioni generate dall'IA non verranno richieste per gli oggetti tracciati su questa telecamera." }, "review_descriptions": { - "title": "Descrizioni revisioni IA generativa", + "title": "Descrizioni revisioni IA Generativa", "desc": "Abilita/disabilita temporaneamente le descrizioni di revisione generate dall'IA per questa telecamera fino al riavvio di Frigate. Se disabilitate, le descrizioni generate dall'IA non saranno richieste per gli elementi di revisione su questa telecamera." }, "review": { - "title": "Rivedi", + "title": "Revisiona", "desc": "Abilita/disabilita temporaneamente avvisi e rilevamenti per questa telecamera fino al riavvio di Frigate. Se disabilitato, non verranno generati nuovi elementi di revisione. ", "alerts": "Avvisi ", "detections": "Rilevamenti " @@ -1058,7 +1145,7 @@ "quality": "Qualità", "selectQuality": "Seleziona la qualità", "roleLabels": { - "detect": "Rilevamento di oggetti", + "detect": "Rilevamento oggetti", "record": "Registrazione", "audio": "Audio" }, @@ -1066,8 +1153,8 @@ "testSuccess": "Prova del flusso riuscita!", "testFailed": "Prova del flusso fallita", "testFailedTitle": "Prova fallita", - "connected": "Connesso", - "notConnected": "Non connesso", + "connected": "Connessa", + "notConnected": "Non connessa", "featuresTitle": "Caratteristiche", "go2rtc": "Riduci le connessioni alla telecamera", "detectRoleWarning": "Per procedere, almeno un flusso deve avere il ruolo \"rilevamento\".", @@ -1247,7 +1334,7 @@ "roles": "Ruoli", "ffmpegModule": "Utilizza la modalità di compatibilità del flusso", "ffmpegModuleDescription": "Se il flusso non si carica dopo diversi tentativi, prova ad abilitare questa opzione. Se abilitata, Frigate utilizzerà il modulo ffmpeg con go2rtc. Questo potrebbe garantire una migliore compatibilità con alcuni flussi di telecamere.", - "none": "Nessuno", + "none": "Nessuna", "error": "Errore", "streamValidated": "Flusso {{number}} convalidato con successo", "streamValidationFailed": "Convalida del flusso {{number}} non riuscita", @@ -1284,7 +1371,18 @@ "backToSettings": "Torna alle impostazioni della telecamera", "streams": { "title": "Abilita/Disabilita telecamere", - "desc": "Disattiva temporaneamente una telecamera fino al riavvio di Frigate. La disattivazione completa di una telecamera interrompe l'elaborazione dei flussi di questa telecamera da parte di Frigate. Rilevamento, registrazione e correzioni non saranno disponibili.
    Nota: questa operazione non disattiva 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 di questa telecamera da parte di Frigate. Rilevamento, registrazione e correzioni non saranno disponibili.
    Nota: questa operazione non disattiva le ritrasmissioni di go2rtc.", + "enableLabel": "Telecamere abilitate", + "enableDesc": "Disabilita temporaneamente una telecamera abilitata fino al riavvio di Frigate. La disabilitazione completa di una telecamera interrompe l'elaborazione dei flussi video di tale telecamera da parte di Frigate. Le funzioni di rilevamento, registrazione e correzioni non saranno disponibili.
    Nota: questa operazione non disabilita le ritrasmissioni go2rtc.", + "disableLabel": "Telecamere disabilitate", + "disableDesc": "Abilita una telecamera attualmente non visibile nell'interfaccia utente e disabilitata nella configurazione. Dopo l'abilitazione è necessario riavviare Frigate.", + "enableSuccess": "{{cameraName}} abilitata nella configurazione. Riavvia Frigate per applicare le modifiche.", + "friendlyName": { + "edit": "Modifica il nome visualizzato della telecamera", + "title": "Modifica il nome visualizzato", + "description": "Imposta il nome amichevole visualizzato per questa telecamera nell'interfaccia utente di Frigate. Lascia vuoto per utilizzare l'ID della telecamera.", + "rename": "Rinomina" + } }, "cameraConfig": { "add": "Aggiungi telecamera", @@ -1314,6 +1412,176 @@ "streamUrls": "URL dei flussi", "addUrl": "Aggiungi URL", "addGo2rtcStream": "Aggiungi flusso go2rtc" + }, + "profiles": { + "enabled": "Abilitato" + } + }, + "button": { + "overriddenGlobal": "Sovrascritto (Globale)", + "overriddenGlobalTooltip": "Questa telecamera sovrascrive le impostazioni di configurazione globali in questa sezione", + "overriddenBaseConfig": "Sovrascritto (Configurazione di base)", + "overriddenBaseConfigTooltip": "Il profilo {{profile}} sovrascrive le impostazioni di configurazione in questa sezione", + "overriddenInCameras": { + "label_one": "Sovrascritto in {{count}} telecamera", + "label_many": "Sovrascritto in {{count}} telecamere", + "label_other": "Sovrascritto in {{count}} telecamere", + "tooltip_one": "{{count}} telecamera sovrascrive i valori in questa sezione. Fai clic per visualizzare i dettagli.", + "tooltip_many": "{{count}} telecamere sovrascrivono i valori in questa sezione. Fai clic per visualizzare i dettagli.", + "tooltip_other": "{{count}} telecamere sovrascrivono i valori in questa sezione. Fai clic per visualizzare i dettagli.", + "heading_one": "Questa sezione globale contiene campi che vengono sovrascritti in {{count}} telecamera.", + "heading_many": "Questa sezione globale contiene campi che vengono sovrascritti in {{count}} telecamere.", + "heading_other": "Questa sezione globale contiene campi che vengono sovrascritti in {{count}} telecamere.", + "othersField_one": "{{count}} altro", + "othersField_many": "{{count}} altri", + "othersField_other": "{{count}} altri", + "profilePrefix": "Profilo {{profile}}: {{fields}}" + } + }, + "go2rtcStreams": { + "title": "Flussi go2rtc", + "ffmpeg": { + "video": "Video", + "audio": "Audio", + "audioCopy": "Copia", + "videoCopy": "Copia", + "hardware": "Accelerazione hardware", + "hardwareNone": "Nessuna accelerazione hardware", + "hardwareAuto": "Accelerazione hardware automatica" + } + }, + "configForm": { + "sections": { + "review": "Revisiona", + "record": "Registrazione", + "snapshots": "Istantanee", + "ffmpeg": "FFmpeg", + "objects": "Oggetti", + "database": "Database", + "mqtt": "MQTT", + "notifications": "Notifiche", + "tls": "TLS", + "auth": "Autenticazione", + "proxy": "Proxy", + "telemetry": "Telemetria", + "birdseye": "Birdseye", + "semantic_search": "Ricerca semantica", + "lpr": "Riconoscimento targhe", + "face_recognition": "Riconoscimento facciale", + "masksAndZones": "Maschere / Zone", + "audio": "Audio", + "model": "Modello" + }, + "tabs": { + "system": "Sistema", + "integrations": "Integrazioni", + "sharedDefaults": "Impostazioni predefinite condivise" + }, + "inputRoles": { + "options": { + "audio": "Audio" + } + }, + "roleMap": { + "roleLabel": "Ruolo", + "remove": "Rimuovi" + }, + "notifications": { + "title": "Impostazioni di notifica" + }, + "global": { + "title": "Impostazioni globali", + "description": "Queste impostazioni si applicano a tutte le telecamere a meno che non vengano modificate nelle impostazioni specifiche della singola telecamera." + }, + "camera": { + "noCameras": "Nessuna telecamera disponibile", + "title": "Impostazioni telecamera", + "description": "Queste impostazioni si applicano solo a questa telecamera e sovrascrivono le impostazioni globali." + }, + "advancedSettingsCount": "Impostazioni avanzate ({{count}})", + "advancedCount": "Avanzato ({{count}})", + "showAdvanced": "Mostra impostazioni avanzate", + "additionalProperties": { + "keyLabel": "Chiave", + "valueLabel": "Valore", + "keyPlaceholder": "Nuova chiave", + "remove": "Rimuovi" + }, + "ffmpegArgs": { + "preset": "Preimpostazione", + "manual": "Argomenti manuali", + "inherit": "Eredita dalle impostazioni della telecamera", + "none": "Nessuna", + "useGlobalSetting": "Eredita dalle impostazioni globali", + "selectPreset": "Seleziona preimpostazione", + "manualPlaceholder": "Inserisci argomenti FFmpeg", + "presetLabels": { + "preset-rpi-64-h264": "Raspberry Pi (H.264)", + "preset-rpi-64-h265": "Raspberry Pi (H.265)", + "preset-vaapi": "VAAPI (GPU Intel/AMD)" + } + } + }, + "globalConfig": { + "title": "Configurazione globale" + }, + "cameraConfig": { + "title": "Configurazione telecamera" + }, + "profiles": { + "title": "Profili", + "columnCamera": "Telecamera" + }, + "timestampPosition": { + "tl": "In alto a sinistra", + "tr": "In alto a destra", + "bl": "In basso a sinistra", + "br": "In basso a destra" + }, + "detectionModel": { + "plusActive": { + "title": "Gestione del modello Frigate+", + "label": "Fonte del modello attuale", + "description": "Questa istanza utilizza un modello Frigate+. Seleziona o modifica il tuo modello nelle impostazioni di Frigate+.", + "goToFrigatePlus": "Vai alle impostazioni di Frigate+", + "showModelForm": "Configura manualmente un modello" + } + }, + "maintenance": { + "title": "Manutenzione", + "sync": { + "title": "Sincronizzazione multimediale", + "desc": "Frigate pulirà periodicamente i supporti di memorizzazione secondo una pianificazione regolare, in base alla configurazione di conservazione. È normale che Frigate visualizzi alcuni file orfani durante il suo funzionamento. Utilizza questa funzione per rimuovere dal disco i file multimediali orfani che non sono più referenziati nel database.", + "started": "Sincronizzazione multimediale avviata.", + "alreadyRunning": "È già in corso un'operazione di sincronizzazione", + "error": "Impossibile avviare la sincronizzazione", + "currentStatus": "Stato", + "statusLabel": "Stato", + "jobId": "ID lavoro", + "startTime": "Ora di inizio", + "endTime": "Ora di fine", + "results": "Risultati", + "errorLabel": "Errore", + "resultsFields": { + "error": "Errore", + "totals": "Totali" + }, + "event_snapshots": "Istantanee degli oggetti tracciati", + "event_thumbnails": "Miniature degli oggetti tracciati", + "review_thumbnails": "Anteprima delle miniature", + "previews": "Anteprime", + "exports": "Esportazioni", + "recordings": "Registrazioni" + }, + "regionGrid": { + "title": "Griglia di regioni", + "desc": "La griglia di regioni è un algoritmo di ottimizzazione che apprende dove gli oggetti di diverse dimensioni appaiono tipicamente nel campo visivo di ciascuna telecamera. Frigate utilizza questi dati per dimensionare in modo efficiente le regioni di rilevamento. La griglia viene creata automaticamente nel tempo a partire dai dati degli oggetti tracciati.", + "clear": "Pulisci griglia di regioni", + "clearConfirmTitle": "Pulisci griglia di regioni", + "clearConfirmDesc": "La pulizia della griglia di regioni non è consigliata a meno che non si sia recentemente modificato il modello del rilevatore o la posizione fisica della telecamera, riscontrando problemi di tracciamento degli oggetti. La griglia verrà ricostruita automaticamente nel tempo man mano che gli oggetti vengono tracciati. Per rendere effettive le modifiche è necessario riavviare Frigate.", + "clearSuccess": "Griglia di regioni pulita con successo", + "clearError": "Impossibile pulire la griglia di regioni", + "restartRequired": "È necessario riavviare il sistema affinché le modifiche alla griglia di regioni abbiano effetto" } } } diff --git a/web/public/locales/it/views/system.json b/web/public/locales/it/views/system.json index 6883fc3976..ca6a0ab9ce 100644 --- a/web/public/locales/it/views/system.json +++ b/web/public/locales/it/views/system.json @@ -7,7 +7,8 @@ "logs": { "frigate": "Registri Frigate - Frigate", "go2rtc": "Registri Go2RTC - Frigate", - "nginx": "Registri Nginx - Frigate" + "nginx": "Registri Nginx - Frigate", + "websocket": "Registri dei messaggi - Frigate" } }, "logs": { @@ -31,6 +32,33 @@ "label": "Copia negli appunti", "success": "Registri copiati negli appunti", "error": "Impossibile copiare i registri negli appunti" + }, + "websocket": { + "label": "Messaggi", + "pause": "Pausa", + "resume": "Riprendi", + "clear": "Pulisci", + "filter": { + "all": "Tutti gli argomenti", + "topics": "Argomenti", + "events": "Eventi", + "reviews": "Rivisti", + "classification": "Classificazione", + "face_recognition": "Riconoscimento facciale", + "lpr": "Risconoscimento targhe (LPR)", + "camera_activity": "Attività della telecamera", + "system": "Sistema", + "camera": "Telecamera", + "all_cameras": "Tutte le telecamere", + "cameras_count_one": "{{count}} telecamera", + "cameras_count_other": "{{count}} telecamere" + }, + "empty": "Nessun messaggio ancora catturato", + "count_one": "{{count}} messaggio", + "count_other": "{{count}} messaggi", + "expanded": { + "payload": "Carico" + } } }, "general": { @@ -72,7 +100,8 @@ "description": "Si tratta di un problema noto negli strumenti di reportistica delle statistiche GPU di Intel (intel_gpu_top), che si interrompe e restituisce ripetutamente un utilizzo della GPU pari a 0% anche nei casi in cui l'accelerazione hardware e il rilevamento degli oggetti funzionano correttamente sulla (i)GPU. Non si tratta di un problema di Frigate. È possibile riavviare il sistema per risolvere temporaneamente il problema e verificare che la GPU funzioni correttamente. Ciò non influisce sulle prestazioni." }, "gpuTemperature": "Temperatura GPU", - "npuTemperature": "Temperatura NPU" + "npuTemperature": "Temperatura NPU", + "gpuCompute": "Calcolo / Codifica GPU" }, "detector": { "inferenceSpeed": "Velocità inferenza rilevatore", @@ -158,7 +187,8 @@ "cameraFramesPerSecond": "{{camName}} fotogrammi al secondo", "cameraDetectionsPerSecond": "{{camName}} rilevamenti al secondo", "cameraSkippedDetectionsPerSecond": "{{camName}} rilevamenti saltati al secondo", - "cameraFfmpeg": "{{camName}} FFmpeg" + "cameraFfmpeg": "{{camName}} FFmpeg", + "cameraGpu": "GPU {{camName}}" }, "toast": { "success": { @@ -178,6 +208,9 @@ "expectedFps": "FPS previsti", "reconnectsLastHour": "Riconnessioni (ultima ora)", "stallsLastHour": "Blocchi (ultima ora)" + }, + "noCameras": { + "title": "Nessuna telecamera trovata" } }, "stats": { @@ -188,7 +221,8 @@ "cameraIsOffline": "{{camera}} è disconnessa", "detectIsSlow": "{{detect}} è lento ({{speed}} ms)", "detectIsVerySlow": "{{detect}} è molto lento ({{speed}} ms)", - "shmTooLow": "L'allocazione /dev/shm ({{total}} MB) dovrebbe essere aumentata almeno a {{min}} MB." + "shmTooLow": "L'allocazione /dev/shm ({{total}} MB) dovrebbe essere aumentata almeno a {{min}} MB.", + "debugReplayActive": "La sessione di riproduzione delle correzioni è attiva" }, "title": "Sistema", "metrics": "Metriche di sistema", @@ -215,7 +249,11 @@ "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" + "readTheDocumentation": "Leggi la documentazione", + "frameLifetime": { + "title": "Durata del fotogramma", + "description": "Ogni telecamera dispone di {{frames}} posti per i fotogrammi nella memoria condivisa. Alla frequenza di fotogrammi più elevata della telecamera, ogni fotogramma è disponibile per circa {{lifetime}} secondi prima di essere sovrascritto." + } } }, "lastRefreshed": "Ultimo aggiornamento: " diff --git a/web/public/locales/ja/common.json b/web/public/locales/ja/common.json index 3f04d464f2..ffceee4199 100644 --- a/web/public/locales/ja/common.json +++ b/web/public/locales/ja/common.json @@ -133,7 +133,7 @@ "unsuspended": "再開", "play": "再生", "unselect": "選択解除", - "export": "書き出し", + "export": "エクスポート", "deleteNow": "今すぐ削除", "next": "次へ", "continue": "続行" @@ -181,7 +181,7 @@ }, "review": "レビュー", "explore": "ブラウズ", - "export": "書き出し", + "export": "エクスポート", "uiPlayground": "UI テスト環境", "faceLibrary": "顔データベース", "user": { @@ -237,7 +237,8 @@ }, "hr": "Hrvatski (クロアチア語)" }, - "classification": "分類" + "classification": "分類", + "profiles": "プロファイル" }, "toast": { "copyUrlToClipboard": "URLをクリップボードにコピーしました。", diff --git a/web/public/locales/ja/components/dialog.json b/web/public/locales/ja/components/dialog.json index c7f2b0944d..9364141401 100644 --- a/web/public/locales/ja/components/dialog.json +++ b/web/public/locales/ja/components/dialog.json @@ -46,23 +46,57 @@ } }, "name": { - "placeholder": "書き出しに名前を付ける" + "placeholder": "エクスポートに名前を付ける" }, "select": "選択", - "export": "書き出し", - "selectOrExport": "選択または書き出し", + "export": "エクスポート", + "selectOrExport": "選択またはエクスポート", "toast": { - "success": "書き出しを開始しました。出力ページでファイルを確認できます。", + "success": "エクスポートを開始しました。エクスポートページでファイルを確認できます。", "error": { - "failed": "書き出しの開始に失敗しました: {{error}}", + "failed": "エクスポートキューの開始に失敗しました: {{error}}", "endTimeMustAfterStartTime": "終了時間は開始時間より後である必要があります", "noVaildTimeSelected": "有効な時間範囲が選択されていません" }, - "view": "表示" + "view": "表示", + "queued": "エクスポートがキューに追加されました。進捗状況はエクスポートページで確認できます。", + "batchQueuedSuccess_other": "{{count}} 件のエクスポートがキューに登録されました。現在ケースをオープンしています。", + "batchQueuedPartial": "{{total}} 件中 {{successful}} 件のエクスポートがキューに追加されました。失敗したカメラ: {{failedCameras}}", + "batchQueueFailed": "{{total}} 件のエクスポートをキューに追加できませんでした。失敗したカメラ: {{failedCameras}}" }, "fromTimeline": { - "saveExport": "書き出しを保存", - "previewExport": "書き出しをプレビュー" + "saveExport": "エクスポートを保存", + "previewExport": "エクスポートをプレビュー", + "queueingExport": "エクスポートをキューイングしています..." + }, + "queueing": "エクスポートをキューイングしています...", + "multiCamera": { + "queueingButton": "エクスポートをキューイングしています...", + "timeRange": "期間", + "selectFromTimeline": "タイムラインから選択", + "cameraSelection": "カメラ", + "cameraSelectionHelp": "この期間に追跡対象が含まれるカメラは、あらかじめ選択されています", + "checkingActivity": "カメラの動作を確認中...", + "noCameras": "利用可能なカメラがありません", + "detectionCount_other": "{{count}} 追跡対象", + "nameLabel": "エクスポート名", + "namePlaceholder": "これらのエクスポート用オプションのベース名", + "exportButton_other": "{{count}} 台のカメラをエクスポート" + }, + "case": { + "newCaseOption": "新しいケースを作成する", + "newCaseNamePlaceholder": "新しいケース名", + "newCaseDescriptionPlaceholder": "ケースの説明", + "label": "ケース", + "nonAdminHelp": "これらのエクスポートに対して新しいケースが作成されます。", + "placeholder": "ケースを選択" + }, + "tabs": { + "export": "シングルカメラ", + "multiCamera": "マルチカメラ" + }, + "multi": { + "title_other": "{{count}} 件のレビューをエクスポート" } }, "streaming": { @@ -105,7 +139,7 @@ } }, "button": { - "export": "書き出し", + "export": "エクスポート", "markAsReviewed": "レビュー済みにする", "deleteNow": "今すぐ削除", "markAsUnreviewed": "未レビューに戻す" diff --git a/web/public/locales/ja/components/filter.json b/web/public/locales/ja/components/filter.json index bbcc3149d0..e5bc120e74 100644 --- a/web/public/locales/ja/components/filter.json +++ b/web/public/locales/ja/components/filter.json @@ -114,7 +114,7 @@ }, "trackedObjectDelete": { "title": "削除の確認", - "desc": "これら {{objectLength}} 件の追跡オブジェクトを削除すると、スナップショット、保存された埋め込み、関連するオブジェクトのライフサイクル項目が削除されます。履歴ビューの録画映像は削除されません

    続行してもよろしいですか?

    今後このダイアログを表示しない場合は Shift キーを押しながら操作してください。", + "desc": "これら {{objectLength}} 件の追跡オブジェクトを削除すると、スナップショット、保存された埋め込み、関連するオブジェクトのライフサイクル項目が削除されます。履歴ビューの録画映像は削除されません

    続行してもよろしいですか?

    今後このダイアログを表示しない場合は Shift キーを押しながら操作してください。", "toast": { "success": "追跡オブジェクトを削除しました。", "error": "追跡オブジェクトの削除に失敗しました: {{errorMessage}}" diff --git a/web/public/locales/ja/components/player.json b/web/public/locales/ja/components/player.json index 93befd9745..0fa36434d7 100644 --- a/web/public/locales/ja/components/player.json +++ b/web/public/locales/ja/components/player.json @@ -8,7 +8,8 @@ }, "submitFrigatePlus": { "title": "このフレームを Frigate+ に送信しますか?", - "submit": "送信" + "submit": "送信", + "previewError": "スナップショットのプレビューを読み込めませんでした。現在、この録画は利用できない可能性があります。" }, "livePlayerRequiredIOSVersion": "このライブストリームタイプには iOS 17.1 以上が必要です。", "cameraDisabled": "カメラは無効です", diff --git a/web/public/locales/ja/config/cameras.json b/web/public/locales/ja/config/cameras.json index 8c5cb3254f..24c0782779 100644 --- a/web/public/locales/ja/config/cameras.json +++ b/web/public/locales/ja/config/cameras.json @@ -1,22 +1,96 @@ { "label": "カメラ設定", "name": { - "label": "カメラ名" + "label": "カメラ名", + "description": "カメラ名は必須です" }, "enabled": { "label": "有効", "description": "有効" }, "audio": { - "label": "音声イベント", + "label": "音声検出", "enabled": { - "label": "音声検知を有効化" + "label": "音声検知を有効化", + "description": "このカメラのオーディオイベント検出を有効または無効にします。" }, "min_volume": { - "label": "最小ボリューム" + "label": "最小ボリューム", + "description": "オーディオ検出を実行するために必要な最小RMS音量閾値。値を小さくすると感度が高くなります(例:200=高、500=中、1000=低)。" }, "filters": { - "label": "音声フィルタ" + "label": "音声フィルタ", + "description": "誤検出を減らすために使用される信頼度閾値などのフィルタ設定(オーディオタイプごと)。" + }, + "description": "このカメラの音声ベースのイベント検出設定。", + "max_not_heard": { + "label": "タイムアウト終了", + "description": "オーディオイベントが終了するまでの残り秒数(設定されたオーディオタイプを除く)。" + }, + "listen": { + "label": "リスニングタイプ", + "description": "検出対象の音声イベントの種類一覧(例:吠え声、火災報知器、悲鳴、会話、叫び声)。" + }, + "enabled_in_config": { + "label": "元の音声状態", + "description": "静的設定ファイルで、音声検出が当初有効にされていたかどうかを示します。" + }, + "num_threads": { + "label": "検出スレッド", + "description": "音声検出処理に使用するスレッド数。" } + }, + "friendly_name": { + "label": "表示名", + "description": "Frigate UIで使用されるカメラの表示名" + }, + "audio_transcription": { + "label": "音声文字起こし", + "description": "イベントやリアルタイム字幕に使用される、ライブ音声およびスピーチ音声の文字起こし設定。", + "enabled": { + "label": "音声文字起こしを有効にする", + "description": "手動でトリガーされる音声イベントの文字起こしを有効または無効にします。" + }, + "enabled_in_config": { + "label": "元の文字起こし状態" + }, + "live_enabled": { + "label": "ライブ文字起こし", + "description": "音声を受信した時点で、リアルタイム文字起こしを有効にします。" + } + }, + "birdseye": { + "label": "バードアイ", + "description": "複数のカメラ映像を1つのレイアウトに合成する「バードアイ」合成ビューの設定。", + "enabled": { + "label": "バードアイを有効にする", + "description": "バードアイビュー機能を有効または無効にします。" + }, + "mode": { + "label": "トラッキングモード", + "description": "バードアイにカメラを含めるモード:「オブジェクト」「モーション」または「連続」。" + }, + "order": { + "label": "位置", + "description": "バードアイレイアウトにおけるカメラの並び順を決定する数値。" + } + }, + "detect": { + "label": "物体検出", + "description": "物体検出の実行やトラッカーの初期化に使用される、検出や検出ロールの設定。", + "enabled": { + "label": "物体検知を有効にする", + "description": "このカメラの物体検知機能を有効または無効にします。" + }, + "height": { + "label": "高さを検出", + "description": "検出ストリームに使用するフレーム高さ(ピクセル)。ネイティブストリーム解像度を使用する場合は、空欄のままにしてください。" + }, + "width": { + "label": "幅を検出" + } + }, + "mqtt": { + "label": "MQTT" } } diff --git a/web/public/locales/ja/config/global.json b/web/public/locales/ja/config/global.json index 2073a59d8c..f563742e18 100644 --- a/web/public/locales/ja/config/global.json +++ b/web/public/locales/ja/config/global.json @@ -4,38 +4,164 @@ "description": "有効にすると、トラブルシューティングのため機能を制限したセーフモードでFrigateを起動します。" }, "environment_vars": { - "label": "環境変数" + "label": "環境変数", + "description": "Home Assistant OS の Frigate プロセスに設定する環境変数のキー/値ペア。HAOS をご利用でない場合は、代わりに Docker の環境変数設定を使用してください。" }, "audio": { - "label": "音声イベント", + "label": "音声検出", "enabled": { "label": "音声検知を有効化" }, "min_volume": { - "label": "最小ボリューム" + "label": "最小ボリューム", + "description": "オーディオ検出を実行するために必要な最小RMS音量閾値。値を小さくすると感度が高くなります(例:200=高、500=中、1000=低)。" }, "filters": { - "label": "音声フィルタ" + "label": "音声フィルタ", + "description": "誤検出を減らすために使用される信頼度閾値などのフィルタ設定(オーディオタイプごと)。" + }, + "max_not_heard": { + "label": "タイムアウト終了", + "description": "オーディオイベントが終了するまでの残り秒数(設定されたオーディオタイプを除く)。" + }, + "listen": { + "label": "リスニングタイプ", + "description": "検出対象の音声イベントの種類一覧(例:吠え声、火災報知器、悲鳴、会話、叫び声)。" + }, + "enabled_in_config": { + "label": "元の音声状態", + "description": "静的設定ファイルで、音声検出が当初有効にされていたかどうかを示します。" + }, + "num_threads": { + "label": "検出スレッド", + "description": "音声検出処理に使用するスレッド数。" } }, "logger": { "default": { - "label": "ログレベル" + "label": "ログレベル", + "description": "デフォルトのグローバルログの詳細度 (debug, info, warning, error)。" }, "logs": { - "label": "プロセス毎のログレベル" - } + "label": "プロセス毎のログレベル", + "description": "コンポーネントごとのログレベルの上書きにより、特定のモジュールのログ詳細度を増減できます。" + }, + "label": "ログ記録", + "description": "デフォルトのログ詳細度とコンポーネントごとのログレベルの上書きを制御します。" }, "auth": { "label": "認証", "enabled": { - "label": "認証を有効化" + "label": "認証を有効化", + "description": "Frigate UI でネイティブ認証を有効にする。" }, "reset_admin_password": { - "label": "adminパスワードをリセット" + "label": "adminパスワードをリセット", + "description": "もし本当なら、起動時に管理者ユーザーのパスワードをリセットし、新しいパスワードをログに出力します。" + }, + "description": "認証およびセッション関連の設定(Cookieやレート制限オプションを含む)。", + "cookie_name": { + "label": "JWT Cookie名", + "description": "ネイティブ認証用のJWTトークンを保存するために使用されるCookie名。" + }, + "cookie_secure": { + "label": "Cookie のセキュリティフラグ", + "description": "認証Cookieにセキュアフラグを設定します。TLSを使用する場合はtrueにする必要があります。" + }, + "session_length": { + "label": "セッションの期間", + "description": "JWTベースのセッション継続時間(秒単位)。" + }, + "refresh_time": { + "label": "セッション更新ウィンドウ", + "description": "セッションの有効期限が切れるまで残り数秒になったら、セッションを元の期間に更新します。" + }, + "failed_login_rate_limit": { + "label": "ログイン失敗回数の上限", + "description": "ログイン失敗時の試行回数を制限するルールを設けることで、総当たり攻撃を軽減する。" + }, + "trusted_proxies": { + "label": "信頼できるプロキシ", + "description": "レート制限のためクライアントIPアドレスを特定する際に使用される、信頼できるプロキシIPのリスト。" + }, + "hash_iterations": { + "label": "ハッシュ反復処理", + "description": "ユーザーパスワードのハッシュ化に使用するPBKDF2-SHA256の反復回数。" + }, + "roles": { + "label": "ロールのマッピング", + "description": "ロールをカメラリストに割り当てます。リストが空の場合、そのロールのユーザーは全てのカメラにアクセスできます。" + }, + "admin_first_time_login": { + "label": "初回管理者フラグ", + "description": "この設定が「true」の場合、ログインページにヘルプリンクが表示され、管理者パスワードのリセット後にログインする方法がユーザーに案内されることがあります。 " } }, "version": { - "label": "現在の設定バージョン" + "label": "現在の設定バージョン", + "description": "移行やフォーマット変更の検出に役立つ、アクティブな設定の数値または文字列バージョン。" + }, + "audio_transcription": { + "label": "音声文字起こし", + "description": "イベントやリアルタイム字幕に使用される、ライブ音声およびスピーチ音声の文字起こし設定。", + "live_enabled": { + "label": "ライブ文字起こし", + "description": "音声を受信した時点で、リアルタイム文字起こしを有効にします。" + }, + "enabled": { + "label": "音声文字起こしを有効にする" + } + }, + "birdseye": { + "label": "バードアイ", + "description": "複数のカメラ映像を1つのレイアウトに合成する「バードアイ」合成ビューの設定。", + "enabled": { + "label": "バードアイを有効にする", + "description": "バードアイビュー機能を有効または無効にします。" + }, + "mode": { + "label": "トラッキングモード", + "description": "バードアイにカメラを含めるモード:「オブジェクト」「モーション」または「連続」。" + }, + "order": { + "label": "位置", + "description": "バードアイレイアウトにおけるカメラの並び順を決定する数値。" + } + }, + "database": { + "label": "データベース", + "description": "Frigateが追跡対象や録画メタデータを保存するために使用するSQLiteデータベースの設定。", + "path": { + "label": "データベースパス", + "description": "FrigateのSQLiteデータベースファイルが保存されるファイルシステムパス。" + } + }, + "detect": { + "label": "物体検出", + "description": "物体検出の実行やトラッカーの初期化に使用される、検出や検出ロールの設定。", + "enabled": { + "label": "物体検知を有効にする" + }, + "height": { + "label": "高さを検出", + "description": "検出ストリームに使用するフレーム高さ(ピクセル)。ネイティブストリーム解像度を使用する場合は、空欄のままにしてください。" + }, + "width": { + "label": "幅を検出" + } + }, + "go2rtc": { + "label": "go2rtc", + "description": "ライブストリーム中継および変換に利用される、統合型go2rtcリストリーミングサービスの設定。" + }, + "mqtt": { + "label": "MQTT", + "description": "テレメトリー、スナップショット、およびイベントの詳細をMQTTブローカーに接続して公開するための設定。", + "enabled": { + "label": "MQTTを有効にする" + } + }, + "telemetry": { + "label": "テレメトリー" } } diff --git a/web/public/locales/ja/config/groups.json b/web/public/locales/ja/config/groups.json index 7d00539482..b09db04cd5 100644 --- a/web/public/locales/ja/config/groups.json +++ b/web/public/locales/ja/config/groups.json @@ -12,12 +12,19 @@ "timestamp_style": { "cameras": { "appearance": "外観" + }, + "global": { + "appearance": "全体の外観" } }, "motion": { "cameras": { "sensitivity": "感度", "algorithm": "アルゴリズム" + }, + "global": { + "sensitivity": "グローバル感度", + "algorithm": "グローバルアルゴリズム" } }, "detect": { @@ -42,7 +49,25 @@ }, "record": { "global": { - "events": "グローバルイベント" + "events": "グローバルイベント", + "retention": "グローバルリテンション" + }, + "cameras": { + "retention": "リテンション", + "events": "イベント" + } + }, + "snapshots": { + "global": { + "display": "グローバル表示" + }, + "cameras": { + "display": "表示" + } + }, + "ffmpeg": { + "cameras": { + "cameraFfmpeg": "カメラ固有のFFmpeg引数" } } } diff --git a/web/public/locales/ja/config/validation.json b/web/public/locales/ja/config/validation.json index 5b67869a7e..03073d0763 100644 --- a/web/public/locales/ja/config/validation.json +++ b/web/public/locales/ja/config/validation.json @@ -2,5 +2,31 @@ "pattern": "無効なフォーマット", "required": "この項目は必須です", "type": "無効な値タイプ", - "format": "無効なフォーマット" + "format": "無効なフォーマット", + "minimum": "{{limit}} 以上である必要があります", + "maximum": "{{limit}} 以下でなければなりません", + "exclusiveMinimum": "{{limit}} より大きい値である必要があります", + "exclusiveMaximum": "{{limit}} 未満でなければなりません", + "minLength": "{{limit}} 文字以上入力してください", + "maxLength": "最大 {{limit}} 文字までです", + "minItems": "{{limit}} 個以上のアイテムが必要です", + "maxItems": "アイテムは最大 {{limit}} 個までです", + "enum": "許可された値のいずれかである必要があります", + "const": "値が期待される定数と一致しません", + "uniqueItems": "全てのアイテムは一意である必要があります", + "additionalProperties": "不明なプロパティは使用できません", + "oneOf": "許可されたスキーマのうち、いずれか一つに完全一致する必要があります", + "anyOf": "許可されたスキーマのうち、少なくとも1つに一致する必要があります", + "proxy": { + "header_map": { + "roleHeaderRequired": "ロールのマッピングを設定する際は、ロールヘッダーが必要です。" + } + }, + "ffmpeg": { + "inputs": { + "rolesUnique": "各ロールは、1つの入力ストリームにのみ割り当てることができます。", + "detectRequired": "少なくとも1つの入力ストリームに「detect」ロールを割り当てる必要があります。", + "hwaccelDetectOnly": "ハードウェアアクセラレーション引数を定義できるのは、detect ロールを持つ入力ストリームのみです。" + } + } } diff --git a/web/public/locales/ja/views/chat.json b/web/public/locales/ja/views/chat.json new file mode 100644 index 0000000000..be73c63f59 --- /dev/null +++ b/web/public/locales/ja/views/chat.json @@ -0,0 +1,37 @@ +{ + "documentTitle": "チャット - Frigate", + "title": "Frigate チャット", + "subtitle": "カメラ管理と分析のためのAIアシスタント", + "placeholder": "何でも聞いてください…", + "error": "問題が発生しました。もう一度お試しください。", + "processing": "処理中...", + "toolsUsed": "使用済み: {{tools}}", + "showTools": "ツールを表示 ({{count}})", + "hideTools": "ツールを非表示", + "call": "呼び出す", + "result": "結果", + "arguments": "引数:", + "response": "回答:", + "attachment_chip_label": "{{camera}} の {{label}}", + "attachment_chip_remove": "添付ファイルを削除", + "open_in_explore": "エクスプローラーで開く", + "attach_event_aria": "イベント {{eventId}} を添付する", + "attachment_picker_paste_label": "またはイベントIDを貼り付け", + "attachment_picker_attach": "添付", + "attachment_picker_placeholder": "イベントを添付", + "quick_reply_find_similar": "類似の目撃情報を探す", + "quick_reply_tell_me_more": "これについてもっと詳しく教えてください", + "quick_reply_when_else": "他にいつ目撃されたか?", + "quick_reply_find_similar_text": "これと似た目撃情報を探す。", + "quick_reply_tell_me_more_text": "これについてもっと詳しく教えてください。", + "quick_reply_when_else_text": "これと同じようなことが他にありましたか?", + "anchor": "参照", + "similarity_score": "類似性", + "no_similar_objects_found": "類似するオブジェクトは見つかりませんでした。", + "semantic_search_required": "類似オブジェクトを検索するには、セマンティック検索を有効にする必要があります。", + "send": "送信", + "suggested_requests": "質問してみてください:", + "starting_requests": { + "show_recent_events": "最近のイベントを表示" + } +} diff --git a/web/public/locales/ja/views/classificationModel.json b/web/public/locales/ja/views/classificationModel.json index 1801353900..ccd1c2c07f 100644 --- a/web/public/locales/ja/views/classificationModel.json +++ b/web/public/locales/ja/views/classificationModel.json @@ -12,14 +12,15 @@ }, "toast": { "success": { - "deletedImage_other": "削除された画像", + "deletedImage_other": "{{count}} 件の削除された画像", "categorizedImage": "画像の分類に成功しました", "trainedModel": "モデルを正常に学習させました。", "trainingModel": "モデルのトレーニングを正常に開始しました。", - "deletedCategory_other": "クラスを削除しました", + "deletedCategory_other": "{{count}} 件のクラスを削除しました", "deletedModel_other": "{{count}} 件のモデルを削除しました", "updatedModel": "モデル設定を更新しました", - "renamedCategory": "クラス名を {{name}} に変更しました" + "renamedCategory": "クラス名を {{name}} に変更しました", + "reclassifiedImage": "画像の再分類に成功しました" }, "error": { "deleteImageFailed": "削除に失敗しました: {{errorMessage}}", @@ -29,7 +30,8 @@ "trainingFailed": "モデルの学習に失敗しました。Frigate のログを確認してください。", "trainingFailedToStart": "モデルの学習を開始できませんでした: {{errorMessage}}", "updateModelFailed": "モデルの更新に失敗しました: {{errorMessage}}", - "renameCategoryFailed": "クラス名の変更に失敗しました: {{errorMessage}}" + "renameCategoryFailed": "クラス名の変更に失敗しました: {{errorMessage}}", + "reclassifyFailed": "画像の再分類に失敗しました:{{errorMessage}}" } }, "train": { diff --git a/web/public/locales/ja/views/events.json b/web/public/locales/ja/views/events.json index 544412974f..6e9273cefa 100644 --- a/web/public/locales/ja/views/events.json +++ b/web/public/locales/ja/views/events.json @@ -16,7 +16,9 @@ }, "camera": "カメラ", "allCameras": "全カメラ", - "timeline": "タイムライン", + "timeline": { + "label": "タイムライン" + }, "timeline.aria": "タイムラインを選択", "events": { "label": "イベント", @@ -25,7 +27,9 @@ }, "documentTitle": "レビュー - Frigate", "recordings": { - "documentTitle": "録画 - Frigate" + "documentTitle": "録画 - Frigate", + "invalidSharedLink": "解析エラーのため、タイムスタンプ付きの録画リンクを開くことができません。", + "invalidSharedCamera": "不明または未承認のカメラのため、タイムスタンプ付き録画のリンクを開くことができません。" }, "calendarFilter": { "last24Hours": "直近24時間" @@ -36,8 +40,8 @@ "label": "新しいレビュー項目を表示", "button": "レビューすべき新規項目" }, - "selected_one": "{{count}} 件選択", - "selected_other": "{{count}} 件選択", + "selected_one": "{{count}} 選択済み", + "selected_other": "{{count}} 選択済み", "detected": "検出", "suspiciousActivity": "不審なアクティビティ", "threateningActivity": "脅威となるアクティビティ", @@ -63,5 +67,9 @@ "select_all": "すべて", "normalActivity": "通常", "needsReview": "要確認", - "securityConcern": "セキュリティ上の懸念" + "securityConcern": "セキュリティ上の懸念", + "motionSearch": { + "menuItem": "モーション検索", + "openMenu": "カメラオプション" + } } diff --git a/web/public/locales/ja/views/explore.json b/web/public/locales/ja/views/explore.json index 35265cc506..2789e800f5 100644 --- a/web/public/locales/ja/views/explore.json +++ b/web/public/locales/ja/views/explore.json @@ -224,7 +224,7 @@ "dialog": { "confirmDelete": { "title": "削除の確認", - "desc": "この追跡オブジェクトを削除すると、スナップショット、保存された埋め込み、および関連する追跡詳細項目が削除されます。履歴ビューの録画映像は削除されません

    続行してもよろしいですか?" + "desc": "この追跡オブジェクトを削除すると、スナップショット、保存された埋め込み、および関連する追跡詳細項目が削除されます。履歴ビューの録画映像は削除されません。

    続行してもよろしいですか?" } }, "noTrackedObjects": "追跡オブジェクトは見つかりませんでした", diff --git a/web/public/locales/ja/views/exports.json b/web/public/locales/ja/views/exports.json index b32c8c62f4..767c05a11a 100644 --- a/web/public/locales/ja/views/exports.json +++ b/web/public/locales/ja/views/exports.json @@ -1,23 +1,127 @@ { "documentTitle": "エクスポート - Frigate", - "noExports": "書き出しは見つかりません", + "noExports": "エクスポートが見つかりません", "search": "検索", - "deleteExport": "書き出しを削除", + "deleteExport": { + "label": "エクスポートを削除" + }, "deleteExport.desc": "{{exportName}} を削除してもよろしいですか?", "editExport": { - "title": "書き出し名を変更", - "desc": "この書き出しの新しい名前を入力してください。", - "saveExport": "書き出しを保存" + "title": "エクスポート名を変更", + "desc": "このエクスポートの新しい名前を入力してください。", + "saveExport": "エクスポートを保存" }, "toast": { "error": { - "renameExportFailed": "書き出し名の変更に失敗しました: {{errorMessage}}" + "renameExportFailed": "エクスポート名の変更に失敗しました: {{errorMessage}}", + "assignCaseFailed": "ケース割り当ての更新に失敗しました: {{errorMessage}}", + "caseSaveFailed": "ケースの保存に失敗しました: {{errorMessage}}", + "caseDeleteFailed": "ケースの削除に失敗しました: {{errorMessage}}" } }, "tooltip": { "shareExport": "エクスポートを共有", "downloadVideo": "動画をダウンロード", "editName": "名前を編集", - "deleteExport": "エクスポートを削除" + "deleteExport": "エクスポートを削除", + "assignToCase": "ケースに追加", + "removeFromCase": "ケースから削除" + }, + "headings": { + "cases": "ケース", + "uncategorizedExports": "未分類のエクスポート" + }, + "toolbar": { + "newCase": "新しいケース", + "addExport": "エクスポートに追加", + "editCase": "ケースを編集", + "deleteCase": "ケースを削除" + }, + "deleteCase": { + "label": "ケースを削除", + "desc": "本当に {{caseName}} を削除しますか ?", + "descKeepExports": "エクスポートは、分類されていないエクスポートとして引き続き利用可能です。", + "descDeleteExports": "この場合、すべてのエクスポートは完全に削除されます。", + "deleteExports": "エクスポートも削除する" + }, + "caseDialog": { + "title": "ケースに追加", + "description": "既存のケースを選択するか、新しいケースを作成してください。", + "selectLabel": "ケース", + "newCaseOption": "新しいケースを作成", + "nameLabel": "ケース名", + "descriptionLabel": "説明" + }, + "caseCard": { + "emptyCase": "まだエクスポートされていません" + }, + "jobCard": { + "defaultName": "{{camera}} エクスポート", + "queued": "キューに追加しました", + "running": "実行中", + "preparing": "準備中", + "copying": "コピー中", + "encoding": "エンコード中", + "encodingRetry": "エンコード中 (再試行)", + "finalizing": "終了処理中" + }, + "caseView": { + "noDescription": "説明がありません", + "exportCount_one": "1 件のエクスポート", + "exportCount_other": "{{count}} エクスポート", + "cameraCount_other": "{{count}} カメラ", + "showMore": "さらに表示", + "showLess": "表示を減らす", + "emptyTitle": "このケースは空です", + "emptyDescription": "既存の分類されていないエクスポートを追加して、ケースを整理しましょう。", + "emptyDescriptionNoExports": "まだ追加可能な未分類のエクスポートはありません。", + "createdAt": "作成日 {{value}}" + }, + "caseEditor": { + "createTitle": "ケースを作成", + "editTitle": "ケースを編集", + "namePlaceholder": "ケース名", + "descriptionPlaceholder": "このケースに関するメモや背景情報を追加する" + }, + "addExportDialog": { + "title": "{{caseName}} にエクスポートを追加", + "searchPlaceholder": "未分類のエクスポートを検索", + "empty": "この検索条件に一致する未分類のエクスポートはありません。", + "addButton_one": "1 件のエクスポートを追加", + "addButton_other": "{{count}} 件のエクスポートを追加", + "adding": "追加中..." + }, + "selected_one": "{{count}} 選択済み", + "selected_other": "{{count}} 選択済み", + "bulkActions": { + "addToCase": "ケースに追加", + "moveToCase": "ケースに移動", + "removeFromCase": "ケースから削除", + "delete": "削除", + "deleteNow": "今すぐ削除" + }, + "bulkDelete": { + "title": "エクスポートを削除", + "desc_one": "{{count}} 件のエクスポートを削除してもよろしいですか?", + "desc_other": "{{count}} 件のエクスポートを削除してもよろしいですか?" + }, + "bulkRemoveFromCase": { + "title": "ケースから削除", + "desc_one": "このケースから {{count}} 件のエクスポートを削除しますか?", + "desc_other": "このケースから {{count}} 件のエクスポートを削除しますか?", + "descKeepExports": "エクスポートは未分類に移動されます。", + "descDeleteExports": "エクスポートは完全に削除されます。", + "deleteExports": "代わりにエクスポートを削除する" + }, + "bulkToast": { + "success": { + "delete": "エクスポートの削除に成功しました", + "reassign": "ケース割り当ての更新に成功しました", + "remove": "ケースからエクスポートを正常に削除しました" + }, + "error": { + "deleteFailed": "エクスポートの削除に失敗しました: {{errorMessage}}", + "reassignFailed": "ケース割り当ての更新に失敗しました: {{errorMessage}}" + } } } diff --git a/web/public/locales/ja/views/faceLibrary.json b/web/public/locales/ja/views/faceLibrary.json index fdf43a65cf..9446398abf 100644 --- a/web/public/locales/ja/views/faceLibrary.json +++ b/web/public/locales/ja/views/faceLibrary.json @@ -93,5 +93,7 @@ "trainFailed": "学習に失敗しました: {{errorMessage}}", "updateFaceScoreFailed": "顔スコアの更新に失敗しました: {{errorMessage}}" } - } + }, + "reclassifyFaceAs": "顔を再分類する:", + "reclassifyFace": "顔の再分類" } diff --git a/web/public/locales/ja/views/live.json b/web/public/locales/ja/views/live.json index fe73c1d08e..8fde1adb18 100644 --- a/web/public/locales/ja/views/live.json +++ b/web/public/locales/ja/views/live.json @@ -1,5 +1,7 @@ { - "documentTitle": "ライブ - Frigate", + "documentTitle": { + "default": "ライブ - Frigate" + }, "documentTitle.withCamera": "{{camera}} - ライブ - Frigate", "lowBandwidthMode": "低帯域モード", "twoWayTalk": { @@ -15,7 +17,8 @@ "clickMove": { "label": "フレーム内をクリックしてカメラを中央に移動", "enable": "クリック移動を有効化", - "disable": "クリック移動を無効化" + "disable": "クリック移動を無効化", + "enableWithZoom": "クリックで移動、ドラッグでズームを有効にする" }, "left": { "label": "PTZ カメラを左へ移動" @@ -67,7 +70,8 @@ }, "recording": { "enable": "録画を有効化", - "disable": "録画を無効化" + "disable": "録画を無効化", + "disabledInConfig": "このカメラでは、まず「設定」で録画機能を有効にする必要があります。" }, "snapshots": { "enable": "スナップショットを有効化", diff --git a/web/public/locales/ja/views/motionSearch.json b/web/public/locales/ja/views/motionSearch.json new file mode 100644 index 0000000000..6e0d6b4b64 --- /dev/null +++ b/web/public/locales/ja/views/motionSearch.json @@ -0,0 +1,42 @@ +{ + "documentTitle": "モーション検索 - Frigate", + "title": "モーション検索", + "description": "関心領域を定義する多角形を描画し、その領域内で動きの変化を検索する時間範囲を指定します。", + "selectCamera": "モーション検索を読み込み中です", + "dialog": { + "title": "モーション検索", + "cameraLabel": "カメラ", + "previewAlt": "{{camera}} のカメラプレビュー" + }, + "startSearch": "検索開始", + "searchStarted": "検索を開始しました", + "searchCancelled": "検索がキャンセルされました", + "cancelSearch": "キャンセル", + "searching": "検索中です。", + "searchComplete": "検索完了", + "noResultsYet": "選択した領域内の動きの変化を検索します", + "noChangesFound": "選択した領域でピクセルの変化は検出されませんでした", + "changesFound_other": "{{count}} 件の動きの変化が見つかりました", + "framesProcessed": "{{count}} フレームを処理しました", + "jumpToTime": "この時間に移動", + "results": "結果", + "showSegmentHeatmap": "ヒートマップ", + "newSearch": "新規検索", + "clearResults": "結果をクリア", + "clearROI": "ポリゴンをクリア", + "polygonControls": { + "points_other": "{{count}} ポイント", + "undo": "直前のポイントを元に戻す", + "reset": "ポリゴンをリセット" + }, + "motionHeatmapLabel": "モーションヒートマップ", + "timeRange": { + "title": "検索範囲", + "start": "開始時間", + "end": "終了時間" + }, + "settings": { + "title": "検索設定", + "parallelMode": "並列モード" + } +} diff --git a/web/public/locales/ja/views/replay.json b/web/public/locales/ja/views/replay.json new file mode 100644 index 0000000000..d3c3a6a844 --- /dev/null +++ b/web/public/locales/ja/views/replay.json @@ -0,0 +1,59 @@ +{ + "title": "デバッグリプレイ", + "description": "デバッグ用にカメラの録画をリプレイします。オブジェクトリストには検出されたオブジェクトの遅延サマリーが表示され、「メッセージ」タブにはリプレイ映像からのFrigate内部メッセージのストリームが表示されます。", + "websocket_messages": "メッセージ", + "dialog": { + "title": "デバッグリプレイを開始", + "description": "オブジェクトの検出やトラッキングの問題をデバッグするために、過去の映像をループ再生する一時的なリプレイカメラを作成します。このリプレイカメラは、ソースカメラ(元カメラ)と同じ検出設定を引き継ぎます。開始する時間範囲を選択してください。", + "camera": "ソースカメラ", + "timeRange": "時間範囲", + "preset": { + "1m": "直近1分間", + "5m": "直近5分間", + "timeline": "タイムラインから選択", + "custom": "カスタム" + }, + "startButton": "リプレイ開始", + "selectFromTimeline": "選択", + "starting": "リプレイを開始しています...", + "startLabel": "開始", + "endLabel": "終了", + "toast": { + "error": "デバッグリプレイの開始に失敗しました: {{error}}", + "alreadyActive": "リプレイセッションはすでに実行中です", + "stopError": "デバッグリプレイの停止に失敗しました: {{error}}", + "goToReplay": "リプレイへ移動" + } + }, + "page": { + "noSession": "アクティブなデバッグリプレイセッションはありません", + "noSessionDesc": "履歴ビューからデバッグリプレイを開始するには、ツールバーの「アクション」ボタンをクリックし、「デバッグリプレイ」を選択してください。", + "goToRecordings": "履歴画面へ", + "preparingClip": "クリップを準備しています…", + "preparingClipDesc": "Frigateは選択された時間範囲の録画を結合しています。指定した期間が長い場合、処理に1分ほどかかる場合があります。", + "startingCamera": "デバッグリプレイを開始しています…", + "startError": { + "title": "デバッグリプレイの開始に失敗しました", + "back": "履歴へ戻る" + }, + "sourceCamera": "ソースカメラ", + "replayCamera": "リプレイカメラ", + "initializingReplay": "デバッグリプレイを初期化しています...", + "stoppingReplay": "デバッグリプレイを停止しています...", + "stopReplay": "リプレイを停止", + "confirmStop": { + "title": "デバッグリプレイを停止しますか?", + "description": "セッションを停止し、すべての一時データを削除します。よろしいですか?", + "confirm": "リプレイ停止", + "cancel": "キャンセル" + }, + "activity": "アクティビティ", + "objects": "オブジェクトリスト", + "audioDetections": "オーディオ検出", + "noActivity": "アクティビティは検出されませんでした", + "activeTracking": "アクティブトラッキング", + "noActiveTracking": "アクティブトラッキングなし", + "configuration": "設定", + "configurationDesc": "デバッグリプレイカメラのモーション検知およびオブジェクトトラッキング設定を微調整します。ここで行った変更はFrigateの設定ファイルには保存されません。" + } +} diff --git a/web/public/locales/ja/views/settings.json b/web/public/locales/ja/views/settings.json index 324fec9642..db762c8d5d 100644 --- a/web/public/locales/ja/views/settings.json +++ b/web/public/locales/ja/views/settings.json @@ -13,7 +13,9 @@ "cameraManagement": "カメラ設定 - Frigate", "cameraReview": "カメラレビュー設定 - Frigate", "maintenance": "メンテナンス - Frigate", - "profiles": "プロファイル - Frigate" + "profiles": "プロファイル - Frigate", + "globalConfig": "グローバル設定 - Frigate", + "cameraConfig": "カメラ設定 - Frigate" }, "menu": { "ui": "UI", @@ -31,7 +33,29 @@ "roles": "区分", "general": "一般", "globalConfig": "グローバル設定", - "system": "システム" + "system": "システム", + "integrations": "統合", + "uiSettings": "UI設定", + "profiles": "プロファイル", + "globalDetect": "物体検出", + "globalRecording": "録画", + "globalSnapshots": "スナップショット", + "globalFfmpeg": "FFmpeg", + "globalMotion": "動体検出", + "globalObjects": "オブジェクト", + "globalReview": "レビュー", + "globalAudioEvents": "オーディオイベント", + "globalLivePlayback": "ライブ再生", + "globalTimestampStyle": "タイムスタンプ形式", + "systemDatabase": "データベース", + "systemTls": "TLS", + "systemAuthentication": "認証", + "systemNetworking": "ネットワーキング", + "systemProxy": "プロキシ", + "systemUi": "UI", + "systemLogging": "ロギング", + "systemEnvironmentVariables": "環境変数", + "systemTelemetry": "テレメトリー" }, "dialog": { "unsavedChanges": { @@ -113,7 +137,7 @@ "desc": "Frigate のセマンティック検索では、画像そのもの、ユーザー定義のテキスト説明、または自動生成された説明を用いて、レビュー項目内の追跡オブジェクトを検索できます。", "reindexNow": { "label": "今すぐ再インデックス", - "desc": "再インデックスは、すべての追跡オブジェクトの埋め込みを再生成します。バックグラウンドで実行され、追跡オブジェクト数によっては CPU を使い切り、相応の時間がかかる場合があります。", + "desc": "インデックスの再構築を行うと、追跡対象のすべてのオブジェクトの埋め込みが再生成されます。この処理はバックグラウンドで実行され、追跡対象のオブジェクトの数によってはCPU使用率が最大になり、かなりの時間がかかる場合があります。", "confirmTitle": "再インデックスの確認", "confirmDesc": "すべての追跡オブジェクトの埋め込みを再インデックスしますか?この処理はバックグラウンドで実行されますが、CPU を使い切り、時間がかかる場合があります。進行状況は[探索]ページで確認できます。", "confirmButton": "再インデックス", @@ -244,7 +268,7 @@ } }, "motionMaskLabel": "モーションマスク {{number}}", - "objectMaskLabel": "オブジェクトマスク {{number}}({{label}})", + "objectMaskLabel": "オブジェクトマスク {{number}}", "form": { "zoneName": { "error": { @@ -594,7 +618,7 @@ "admin": "管理者", "adminDesc": "すべての機能にフルアクセス。", "viewer": "閲覧者", - "viewerDesc": "ライブ、レビュー、探索、書き出しに限定。", + "viewerDesc": "ライブ、レビュー、探索、エクスポートに限定。", "customDesc": "特定のカメラアクセスを持つカスタムロール。" } } @@ -725,7 +749,7 @@ "snapshotConfig": { "title": "スナップショット設定", "desc": "Frigate+ への送信には、設定でスナップショットと clean_copy スナップショットの両方を有効にする必要があります。", - "cleanCopyWarning": "一部のカメラではスナップショットは有効ですが、クリーンコピーが無効です。これらのカメラから Frigate+ へ画像を送信するには、スナップショット設定で clean_copy を有効にしてください。", + "cleanCopyWarning": "一部のカメラではスナップショット機能が無効になっています", "table": { "camera": "カメラ", "snapshots": "スナップショット", @@ -937,7 +961,7 @@ "quality": "品質", "selectQuality": "品質を選択", "roleLabels": { - "detect": "オブジェクト検出", + "detect": "物体検出", "record": "録画", "audio": "音声" }, @@ -952,7 +976,7 @@ "detectRoleWarning": "続行するには、少なくとも 1 つのストリームに「検出」ロールが必要です。", "rolesPopover": { "title": "ストリーム ロール", - "detect": "オブジェクト検出用のメイン フィードです。", + "detect": "物体検出用のメイン フィードです。", "record": "設定に基づいて映像フィードのセグメントを保存します。", "audio": "音声ベース検出用のフィードです。" }, @@ -1227,5 +1251,25 @@ "success": "レビュー分類の設定を保存しました。変更を適用するには Frigate を再起動してください。" } } + }, + "maintenance": { + "sync": { + "status": { + "queued": "キューに追加済み" + } + } + }, + "button": { + "overriddenGlobal": "上書き済み(グローバル)", + "overriddenGlobalTooltip": "このカメラは、このセクションのグローバル設定を上書きします", + "overriddenBaseConfig": "上書き済み(基本設定)", + "overriddenBaseConfigTooltip": "{{profile}} プロファイルは、このセクションの設定を上書きします", + "overriddenInCameras": { + "label_other": "{{count}} 台のカメラで上書きされました", + "tooltip_other": "{{count}} 台のカメラがこのセクションの設定値を上書きしています。詳細を表示するにはクリックしてください。", + "heading_other": "このグローバルセクションには、{{count}} 台のカメラで上書きされているフィールドがあります。", + "othersField_other": "{{count}} その他", + "profilePrefix": "{{profile}} プロファイル: {{fields}}" + } } } diff --git a/web/public/locales/ja/views/system.json b/web/public/locales/ja/views/system.json index d3f8f88a71..fd64e58a1b 100644 --- a/web/public/locales/ja/views/system.json +++ b/web/public/locales/ja/views/system.json @@ -7,7 +7,8 @@ "logs": { "frigate": "Frigate ログ - Frigate", "go2rtc": "Go2RTC ログ - Frigate", - "nginx": "Nginx ログ - Frigate" + "nginx": "Nginx ログ - Frigate", + "websocket": "メッセージログ - Frigate" } }, "title": "システム", @@ -42,7 +43,23 @@ "filter": { "events": "イベント", "classification": "分類", - "face_recognition": "顔認識" + "face_recognition": "顔認識", + "all": "全てのトピックス", + "topics": "トピックス", + "reviews": "レビュー", + "lpr": "LPR", + "camera_activity": "カメラアクティビティ", + "system": "システム", + "camera": "カメラ", + "all_cameras": "全てのカメラ", + "cameras_count_one": "{{count}} カメラ", + "cameras_count_other": "{{count}} カメラ" + }, + "empty": "まだメッセージは記録されていません", + "count_one": "{{count}} メッセージ", + "count_other": "{{count}} メッセージ", + "expanded": { + "payload": "ペイロード" } } }, diff --git a/web/public/locales/kn/audio.json b/web/public/locales/kn/audio.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/audio.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/common.json b/web/public/locales/kn/common.json new file mode 100644 index 0000000000..9b5e63fafd --- /dev/null +++ b/web/public/locales/kn/common.json @@ -0,0 +1,5 @@ +{ + "time": { + "untilForTime": "{{time}}ವರೆಗೆ" + } +} diff --git a/web/public/locales/kn/components/auth.json b/web/public/locales/kn/components/auth.json new file mode 100644 index 0000000000..6d4a2c27fe --- /dev/null +++ b/web/public/locales/kn/components/auth.json @@ -0,0 +1,5 @@ +{ + "form": { + "user": "ಬಳಕೆದಾರರಹೆಸರು" + } +} diff --git a/web/public/locales/kn/components/camera.json b/web/public/locales/kn/components/camera.json new file mode 100644 index 0000000000..71a4f6946b --- /dev/null +++ b/web/public/locales/kn/components/camera.json @@ -0,0 +1,5 @@ +{ + "group": { + "label": "ಕ್ಯಾಮೆರಾ ಗುಂಪು" + } +} diff --git a/web/public/locales/kn/components/dialog.json b/web/public/locales/kn/components/dialog.json new file mode 100644 index 0000000000..9fff19e814 --- /dev/null +++ b/web/public/locales/kn/components/dialog.json @@ -0,0 +1,5 @@ +{ + "restart": { + "title": "ನೀವು ಫ್ರಿಗೇಟನ್ನು ಖಂಡಿತವಗೆ ರೀಸ್ಟಾರ್ಟ್ ಮಾಡಬಯಸುತ್ತೀರಾ?" + } +} diff --git a/web/public/locales/kn/components/filter.json b/web/public/locales/kn/components/filter.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/components/filter.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/components/icons.json b/web/public/locales/kn/components/icons.json new file mode 100644 index 0000000000..c2c11d4eed --- /dev/null +++ b/web/public/locales/kn/components/icons.json @@ -0,0 +1,5 @@ +{ + "iconPicker": { + "selectIcon": "ಬಿಂಬವನ್ನು ಆಯ್ಕೆಮಾಡಿ" + } +} diff --git a/web/public/locales/kn/components/input.json b/web/public/locales/kn/components/input.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/components/input.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/components/player.json b/web/public/locales/kn/components/player.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/components/player.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/config/cameras.json b/web/public/locales/kn/config/cameras.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/config/cameras.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/config/global.json b/web/public/locales/kn/config/global.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/config/global.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/config/groups.json b/web/public/locales/kn/config/groups.json new file mode 100644 index 0000000000..70b11825c7 --- /dev/null +++ b/web/public/locales/kn/config/groups.json @@ -0,0 +1,7 @@ +{ + "audio": { + "global": { + "detection": "ಜಾಗತಿಕ ಪತ್ತೆದಾರಿ" + } + } +} diff --git a/web/public/locales/kn/config/validation.json b/web/public/locales/kn/config/validation.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/config/validation.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/objects.json b/web/public/locales/kn/objects.json new file mode 100644 index 0000000000..4eb1d32167 --- /dev/null +++ b/web/public/locales/kn/objects.json @@ -0,0 +1,3 @@ +{ + "person": "ವ್ಯಕ್ತಿ" +} diff --git a/web/public/locales/kn/views/chat.json b/web/public/locales/kn/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/classificationModel.json b/web/public/locales/kn/views/classificationModel.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/classificationModel.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/configEditor.json b/web/public/locales/kn/views/configEditor.json new file mode 100644 index 0000000000..04d0948db8 --- /dev/null +++ b/web/public/locales/kn/views/configEditor.json @@ -0,0 +1,3 @@ +{ + "documentTitle": "ಕಾನ್ಫಿಗ್ ಸಂಪಾದಕ - ಫ್ರಿಗೇಟ್" +} diff --git a/web/public/locales/kn/views/events.json b/web/public/locales/kn/views/events.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/events.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/explore.json b/web/public/locales/kn/views/explore.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/explore.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/exports.json b/web/public/locales/kn/views/exports.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/exports.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/faceLibrary.json b/web/public/locales/kn/views/faceLibrary.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/faceLibrary.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/live.json b/web/public/locales/kn/views/live.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/live.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/motionSearch.json b/web/public/locales/kn/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/recording.json b/web/public/locales/kn/views/recording.json new file mode 100644 index 0000000000..c0a9826aef --- /dev/null +++ b/web/public/locales/kn/views/recording.json @@ -0,0 +1,3 @@ +{ + "export": "ರಪ್ತು ಮಾಡು" +} diff --git a/web/public/locales/kn/views/replay.json b/web/public/locales/kn/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/search.json b/web/public/locales/kn/views/search.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/kn/views/search.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/kn/views/settings.json b/web/public/locales/kn/views/settings.json new file mode 100644 index 0000000000..99f0d31851 --- /dev/null +++ b/web/public/locales/kn/views/settings.json @@ -0,0 +1,5 @@ +{ + "documentTitle": { + "default": "ಸೆಟ್ಟಿಂಗ್‌ಗಳು - ಫ್ರಿಗೇಟ್" + } +} diff --git a/web/public/locales/kn/views/system.json b/web/public/locales/kn/views/system.json new file mode 100644 index 0000000000..c7bd532fee --- /dev/null +++ b/web/public/locales/kn/views/system.json @@ -0,0 +1,5 @@ +{ + "documentTitle": { + "cameras": "ಕ್ಯಾಮೆರಾ ಅಂಕಿಅಂಶಗಳು - ಫ್ರಿಗೇಟ್" + } +} diff --git a/web/public/locales/ko/common.json b/web/public/locales/ko/common.json index 80293f4f03..94f0b194ec 100644 --- a/web/public/locales/ko/common.json +++ b/web/public/locales/ko/common.json @@ -185,7 +185,8 @@ "classification": "분류", "chat": "채팅", "actions": "작업", - "profiles": "프로필" + "profiles": "프로필", + "features": "기능" }, "unit": { "speed": { diff --git a/web/public/locales/ko/components/camera.json b/web/public/locales/ko/components/camera.json index 67b1a2ee67..610fae4c85 100644 --- a/web/public/locales/ko/components/camera.json +++ b/web/public/locales/ko/components/camera.json @@ -81,6 +81,7 @@ "zones": "구역 (Zones)", "mask": "마스크", "motion": "움직임", - "regions": "영역 (Regions)" + "regions": "영역 (Regions)", + "paths": "경로" } } diff --git a/web/public/locales/ko/components/player.json b/web/public/locales/ko/components/player.json index 38ef7daacd..e6b1f0df06 100644 --- a/web/public/locales/ko/components/player.json +++ b/web/public/locales/ko/components/player.json @@ -1,7 +1,8 @@ { "submitFrigatePlus": { "submit": "제출", - "title": "이 프레임을 Frigate+에 제출하시겠습니까?" + "title": "이 프레임을 Frigate+에 제출하시겠습니까?", + "previewError": "스냅샷 미리보기를 불러올 수 없습니다. 현재 녹화된 영상을 사용할 수 없을 수 있습니다." }, "stats": { "bandwidth": { diff --git a/web/public/locales/ko/config/cameras.json b/web/public/locales/ko/config/cameras.json index 3f64349db6..2e5d533706 100644 --- a/web/public/locales/ko/config/cameras.json +++ b/web/public/locales/ko/config/cameras.json @@ -3,5 +3,61 @@ "name": { "label": "카메라 이름", "description": "카메라 이름은 필수 항목입니다" + }, + "friendly_name": { + "label": "별칭", + "description": "Frigate UI에서 사용되는 카메라 별칭" + }, + "enabled": { + "label": "활성화됨", + "description": "활성화됨" + }, + "audio": { + "label": "오디오 이벤트", + "description": "이 카메라의 오디오 기반 이벤트 감지 설정입니다.", + "enabled": { + "label": "오디오 감지 활성화", + "description": "이 카메라의 오디오 이벤트 감지를 활성화하거나 비활성화합니다." + }, + "max_not_heard": { + "label": "종료 타임아웃", + "description": "오디오 이벤트가 종료되기 전, 설정된 오디오 유형이 감지되지 않는 시간(초)입니다." + }, + "min_volume": { + "label": "최소 볼륨", + "description": "오디오 감지를 실행하는 데 필요한 최소 RMS 볼륨 임계값으로, 낮을수록 민감도가 높아집니다(예: 200 높음, 500 보통, 1000 낮음)." + }, + "listen": { + "label": "청취 유형", + "description": "감지할 오디오 이벤트 유형 목록입니다(예: bark, fire_alarm, scream, speech, yell)." + }, + "filters": { + "label": "오디오 필터", + "description": "오탐지를 줄이기 위해 사용되는 신뢰도 임계값과 같은 오디오 유형별 필터 설정입니다." + }, + "enabled_in_config": { + "label": "원래 오디오 상태" + } + }, + "mqtt": { + "label": "MQTT" + }, + "notifications": { + "label": "알림", + "enabled": { + "label": "알림 활성화" + }, + "email": { + "label": "알림 이메일", + "description": "푸시 알림에 사용되거나 특정 알림 제공업체에서 요구하는 이메일 주소입니다." + }, + "cooldown": { + "label": "알림 재발송 대기 시간", + "description": "수신자에게 스팸 메일을 보내는 것을 방지하기 위해 알림 재발송 대기시간(초)을 설정합니다." + }, + "enabled_in_config": { + "label": "초기 알림 활성 상태", + "description": "초기 구성에서 알림이 활성화되었는지 여부를 나타냅니다." + } } } diff --git a/web/public/locales/ko/config/global.json b/web/public/locales/ko/config/global.json index f2cdb1059b..95fa0b1a6d 100644 --- a/web/public/locales/ko/config/global.json +++ b/web/public/locales/ko/config/global.json @@ -4,6 +4,196 @@ "description": "마이그레이션 및 데이터 형식 변경 확인을 위한 현재 설정의 버전 정보(숫자 또는 문자열)입니다." }, "safe_mode": { - "label": "안전 모드" + "label": "안전 모드", + "description": "활성화하면 문제 해결을 위해 기능이 제한된 안전 모드로 Frigate를 시작합니다." + }, + "environment_vars": { + "label": "환경 변수", + "description": "Home Assistant OS에서 Frigate 프로세스에 설정할 환경 변수의 키/값 쌍입니다. HAOS가 아닌 사용자는 대신 Docker 환경 변수 설정을 사용해야 합니다." + }, + "logger": { + "label": "로깅", + "description": "기본 로그 상세 수준 및 구성 요소별 로그 수준 재정의를 제어합니다.", + "default": { + "label": "로그 수준", + "description": "기본 전역 로그 상세 수준(debug, info, warning, error)입니다." + }, + "logs": { + "label": "프로세스별 로그 수준", + "description": "특정 모듈의 상세 수준을 높이거나 낮추기 위한 구성 요소별 로그 수준 재정의입니다." + } + }, + "audio": { + "label": "오디오 이벤트", + "enabled": { + "label": "오디오 감지 활성화" + }, + "max_not_heard": { + "label": "종료 타임아웃", + "description": "오디오 이벤트가 종료되기 전, 설정된 오디오 유형이 감지되지 않는 시간(초)입니다." + }, + "min_volume": { + "label": "최소 볼륨", + "description": "오디오 감지를 실행하는 데 필요한 최소 RMS 볼륨 임계값으로, 낮을수록 민감도가 높아집니다(예: 200 높음, 500 보통, 1000 낮음)." + }, + "listen": { + "label": "청취 유형", + "description": "감지할 오디오 이벤트 유형 목록입니다(예: bark, fire_alarm, scream, speech, yell)." + }, + "filters": { + "label": "오디오 필터", + "description": "오탐지를 줄이기 위해 사용되는 신뢰도 임계값과 같은 오디오 유형별 필터 설정입니다." + }, + "enabled_in_config": { + "label": "원래 오디오 상태" + } + }, + "auth": { + "label": "인증", + "description": "쿠키 및 속도 제한 옵션을 포함한 인증 및 세션 관련 설정입니다.", + "enabled": { + "label": "인증 활성화", + "description": "Frigate UI에 대한 기본 인증을 활성화합니다." + }, + "reset_admin_password": { + "label": "관리자 비밀번호 재설정", + "description": "true로 설정하면 시작 시 관리자 비밀번호를 재설정하고 새 비밀번호를 로그에 출력합니다." + }, + "cookie_name": { + "label": "JWT 쿠키 이름", + "description": "자체 인증용 JWT 토큰을 저장할 쿠키 이름입니다." + }, + "cookie_secure": { + "label": "보안 쿠키 설정", + "description": "인증 쿠키에 보안 플래그를 설정합니다. TLS를 사용하는 경우 'True'로 설정해야 합니다." + }, + "session_length": { + "label": "세션 길이", + "description": "JWT 기반 세션의 유지 시간(초)입니다." + }, + "refresh_time": { + "label": "세션 갱신 주기", + "description": "세션 만료까지 남은 시간이 몇 초 남지 않을 경우, 세션 시간을 다시 최대로 연장합니다." + }, + "failed_login_rate_limit": { + "label": "로그인 실패 제한", + "description": "무차별 대입 공격을 줄이기 위해 로그인 시도 실패 횟수를 제한하는 규칙을 적용합니다." + }, + "trusted_proxies": { + "label": "신뢰할 수 있는 프록시", + "description": "속도 제한을 위해 클라이언트 IP를 결정할 때 사용되는 신뢰할 수 있는 프록시 IP 목록입니다." + }, + "hash_iterations": { + "label": "해시 반복 횟수", + "description": "사용자 암호를 해싱할 때 사용할 PBKDF2-SHA256 반복 횟수입니다." + }, + "roles": { + "label": "역할 할당", + "description": "역할별로 접근 가능한 카메라 목록을 매핑합니다. 목록이 비어 있으면 해당 역할에 모든 카메라 접근 권한을 부여합니다." + }, + "admin_first_time_login": { + "label": "관리자 초기 로그인 설정", + "description": "활성화 시, 관리자 비밀번호 초기화 후 로그인 방법 안내 링크가 로그인 페이지에 표시됩니다. " + } + }, + "database": { + "label": "데이터베이스", + "description": "추적된 객체 및 녹화 메타데이터를 저장하는 SQLite 데이터베이스 설정입니다.", + "path": { + "label": "데이터베이스 경로", + "description": "Frigate SQLite 데이터베이스 파일이 저장될 파일 시스템 경로입니다." + } + }, + "go2rtc": { + "label": "go2rtc", + "description": "라이브 스트림 중계 및 번역에 사용되는 통합 go2rtc 리스트리밍 서비스 설정입니다." + }, + "mqtt": { + "label": "MQTT", + "description": "MQTT 브로커에 원격 측정 데이터, 스냅샷 및 이벤트 세부 정보를 연결하고 게시하기 위한 설정입니다.", + "enabled": { + "label": "MQTT 활성화", + "description": "상태, 이벤트 및 스냅샷에 대한 MQTT 통합을 활성화 또는 비활성화합니다." + }, + "host": { + "label": "MQTT 호스트", + "description": "MQTT 브로커의 호스트 이름 또는 IP 주소입니다." + }, + "port": { + "label": "MQTT 포트", + "description": "MQTT 브로커의 포트 번호입니다 (일반적인 포트는 1883입니다)." + }, + "topic_prefix": { + "label": "토픽 접두사", + "description": "Frigate의 모든 MQTT 메시지에 사용할 접두사입니다. 여러 대의 Frigate를 실행하는 경우 각각 고유한 이름을 사용해야 합니다." + }, + "client_id": { + "label": "클라이언트 ID", + "description": "MQTT 브로커 연결 시 사용하는 클라이언트 식별자입니다. 인스턴스마다 고유한 이름을 사용해야 합니다." + }, + "stats_interval": { + "label": "통계 간격", + "description": "시스템 및 카메라 통계 정보를 MQTT로 전송하는 간격(초)입니다." + }, + "user": { + "label": "MQTT 사용자 이름", + "description": "MQTT 사용자 이름(선택 사항)입니다. 환경 변수나 비밀 값(Secrets)을 통해 입력할 수 있습니다." + }, + "password": { + "label": "MQTT 비밀번호", + "description": "MQTT 비밀번호(선택 사항)입니다. 환경 변수나 비밀 값(Secrets)을 통해 입력할 수 있습니다." + }, + "tls_ca_certs": { + "label": "TLS CA 인증서", + "description": "브로커와의 TLS 연결에 사용할 CA 인증서 경로(자체 서명 인증서의 경우)." + }, + "tls_client_cert": { + "label": "클라이언트 인증서", + "description": "TLS 상호 인증을 위한 클라이언트 인증서 경로입니다. 클라이언트 인증서를 사용할 때는 사용자 이름/암호를 설정하지 마십시오." + }, + "tls_client_key": { + "label": "클라이언트 키", + "description": "클라이언트 인증서의 개인 키 경로입니다." + }, + "tls_insecure": { + "label": "TLS 비보안 모드", + "description": "호스트 이름 확인을 건너뛰어 안전하지 않은 TLS 연결을 허용합니다(권장하지 않음)." + }, + "qos": { + "label": "MQTT QoS", + "description": "MQTT 메시지 전송 및 구독에 대한 서비스 품질(QoS) 등급입니다 (0, 1, 2 중 선택)." + } + }, + "notifications": { + "label": "알림", + "description": "모든 카메라에 대한 알림을 활성화하고 제어하는 설정입니다. 카메라별로 설정을 재정의할 수 있습니다.", + "enabled": { + "label": "알림 활성화", + "description": "모든 카메라에 대한 알림을 활성화 또는 비활성화할 수 있으며, 카메라별로 설정을 재정의할 수 있습니다." + }, + "email": { + "label": "알림 이메일", + "description": "푸시 알림에 사용되거나 특정 알림 제공업체에서 요구하는 이메일 주소입니다." + }, + "cooldown": { + "label": "알림 재발송 대기 시간", + "description": "수신자에게 스팸 메일을 보내는 것을 방지하기 위해 알림 재발송 대기시간(초)을 설정합니다." + }, + "enabled_in_config": { + "label": "초기 알림 활성 상태", + "description": "초기 구성에서 알림이 활성화되었는지 여부를 나타냅니다." + } + }, + "networking": { + "label": "네트워킹", + "description": "Frigate 엔드포인트에 대한 IPv6 활성화와 같은 네트워크 관련 설정입니다.", + "ipv6": { + "label": "IPv6 구성", + "description": "Frigate 네트워크 서비스에 대한 IPv6 관련 설정입니다.", + "enabled": { + "label": "IPv6 활성화", + "description": "Frigate 서비스(API 및 UI)에 IPv6 지원이 필요한 경우 활성화하십시오." + } + } } } diff --git a/web/public/locales/ko/config/groups.json b/web/public/locales/ko/config/groups.json index 78b422e831..4578c83cf7 100644 --- a/web/public/locales/ko/config/groups.json +++ b/web/public/locales/ko/config/groups.json @@ -5,7 +5,53 @@ "sensitivity": "전체 민감도" }, "cameras": { - "detection": "감지" + "detection": "감지", + "sensitivity": "민감도" + } + }, + "timestamp_style": { + "global": { + "appearance": "전역 외관" + }, + "cameras": { + "appearance": "외관" + } + }, + "motion": { + "global": { + "sensitivity": "전역 민감도", + "algorithm": "전역 알고리즘" + }, + "cameras": { + "sensitivity": "민감도", + "algorithm": "알고리즘" + } + }, + "snapshots": { + "global": { + "display": "전역 표시" + }, + "cameras": { + "display": "표시" + } + }, + "detect": { + "global": { + "resolution": "전역 해상도", + "tracking": "전역 추적" + }, + "cameras": { + "resolution": "해상도", + "tracking": "추적" + } + }, + "objects": { + "global": { + "tracking": "전역 추적", + "filtering": "전역 필터링" + }, + "cameras": { + "tracking": "추적" } } } diff --git a/web/public/locales/ko/views/chat.json b/web/public/locales/ko/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ko/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ko/views/classificationModel.json b/web/public/locales/ko/views/classificationModel.json index 227621f10c..832c3723f5 100644 --- a/web/public/locales/ko/views/classificationModel.json +++ b/web/public/locales/ko/views/classificationModel.json @@ -8,6 +8,28 @@ "button": { "deleteClassificationAttempts": "분류 이미지 삭제", "renameCategory": "클래스 이름 변경", - "deleteCategory": "클래스 삭제" + "deleteCategory": "클래스 삭제", + "deleteImages": "이미지 삭제", + "trainModel": "모델 훈련", + "addClassification": "분류 추가", + "deleteModels": "모델 삭제", + "editModel": "모델 편집" + }, + "tooltip": { + "trainingInProgress": "모델이 현재 학습 중입니다", + "noNewImages": "훈련할 새 이미지가 없습니다. 먼저 데이터셋에서 더 많은 이미지를 분류하세요.", + "noChanges": "마지막 훈련 이후 데이터셋에 변경 사항이 없습니다.", + "modelNotReady": "모델이 훈련 준비가 되지 않았습니다" + }, + "toast": { + "success": { + "deletedModel_other": "{{count}}개 모델을 성공적으로 삭제했습니다", + "categorizedImage": "이미지 분류 성공", + "reclassifiedImage": "이미지 재분류 성공", + "trainedModel": "모델 훈련 완료." + } + }, + "train": { + "titleShort": "최근" } } diff --git a/web/public/locales/ko/views/events.json b/web/public/locales/ko/views/events.json index 971494a814..3e357be850 100644 --- a/web/public/locales/ko/views/events.json +++ b/web/public/locales/ko/views/events.json @@ -9,9 +9,15 @@ "empty": { "alert": "다시 볼 '경보' 영상이 없습니다", "detection": "다시 볼 '대상 감지' 영상이 없습니다", - "motion": "움직임 감지 데이터가 없습니다" + "motion": "움직임 감지 데이터가 없습니다", + "recordingsDisabled": { + "title": "녹화가 활성화되어야 합니다", + "description": "다시 보기 항목은 해당 카메라에서 녹화가 활성화된 경우에만 카메라에 대해 생성할 수 있습니다." + } + }, + "timeline": { + "label": "타임라인" }, - "timeline": "타임라인", "timeline.aria": "타임라인 선택", "events": { "label": "이벤트", @@ -23,7 +29,8 @@ "aria": "상세 보기", "trackedObject_one": "추적 대상", "trackedObject_other": "추적 대상", - "noObjectDetailData": "상세 보기 데이터가 없습니다." + "noObjectDetailData": "상세 보기 데이터가 없습니다.", + "label": "세부 정보" }, "objectTrack": { "trackedPoint": "추적 포인트", @@ -47,5 +54,7 @@ "camera": "카메라", "detected": "감지됨", "suspiciousActivity": "수상한 행동", - "threateningActivity": "위협적인 행동" + "threateningActivity": "위협적인 행동", + "zoomIn": "확대", + "zoomOut": "축소" } diff --git a/web/public/locales/ko/views/explore.json b/web/public/locales/ko/views/explore.json index 513d90d84e..5b9c9d5872 100644 --- a/web/public/locales/ko/views/explore.json +++ b/web/public/locales/ko/views/explore.json @@ -22,10 +22,18 @@ "visionModelFeatureExtractor": "비전 모델 특징 추출기", "textModel": "Text model", "textTokenizer": "텍스트 토크나이저" - } + }, + "tips": { + "context": "모델이 다운로드된 후 추적 객체의 임베딩을 색인 재구성하는 것이 좋습니다." + }, + "error": "오류가 발생했습니다. Frigate 로그를 확인하세요." } }, "details": { "timestamp": "시간 기록" + }, + "trackedObjectDetails": "추적 객체 세부 정보", + "type": { + "details": "세부 정보" } } diff --git a/web/public/locales/ko/views/exports.json b/web/public/locales/ko/views/exports.json index 94b1a5ab78..588cdd800a 100644 --- a/web/public/locales/ko/views/exports.json +++ b/web/public/locales/ko/views/exports.json @@ -2,7 +2,9 @@ "documentTitle": "내보내기 - Frigate", "search": "검색", "noExports": "내보내기가 없습니다", - "deleteExport": "내보내기 삭제", + "deleteExport": { + "label": "내보내기 삭제" + }, "deleteExport.desc": "{{exportName}}을 지우시겠습니까?", "editExport": { "title": "내보내기 이름 변경", @@ -11,10 +13,24 @@ }, "toast": { "error": { - "renameExportFailed": "내보내기 이름 변경에 실패했습니다: {{errorMessage}}" + "renameExportFailed": "내보내기 이름 변경에 실패했습니다: {{errorMessage}}", + "assignCaseFailed": "케이스 할당 업데이트 실패: {{errorMessage}}" } }, "headings": { - "uncategorizedExports": "분류되지 않은 내보내기" + "uncategorizedExports": "분류되지 않은 내보내기", + "cases": "케이스" + }, + "tooltip": { + "shareExport": "내보내기 공유", + "downloadVideo": "동영상 다운로드", + "editName": "이름 편집", + "deleteExport": "내보내기 삭제", + "assignToCase": "케이스에 추가" + }, + "caseDialog": { + "title": "케이스에 추가", + "description": "기존 케이스를 선택하거나 새 케이스를 만드세요.", + "selectLabel": "케이스" } } diff --git a/web/public/locales/ko/views/faceLibrary.json b/web/public/locales/ko/views/faceLibrary.json index a04ac45cc5..a99cb3875d 100644 --- a/web/public/locales/ko/views/faceLibrary.json +++ b/web/public/locales/ko/views/faceLibrary.json @@ -16,22 +16,28 @@ "selectItem": "{{item}} 선택", "documentTitle": "얼굴 라이브러리 - Frigate", "uploadFaceImage": { - "title": "얼굴 사진 올리기" + "title": "얼굴 사진 올리기", + "desc": "얼굴을 스캔하고 {{pageToggle}}에 포함하기 위해 이미지를 업로드하세요" }, "collections": "모음집", "createFaceLibrary": { "title": "모음집 만들기", "desc": "새로운 모음집 만들기", - "new": "새 얼굴 만들기" + "new": "새 얼굴 만들기", + "nextSteps": "강력한 기반을 구축하려면:
  • 최근 인식 탭을 사용하여 감지된 각 사람의 이미지를 선택하고 학습하세요.
  • 최상의 결과를 위해 정면 이미지에 집중하고, 각도가 있는 얼굴이 촬영된 이미지는 학습에 사용하지 마세요.
  • " }, "steps": { "faceName": "얼굴 이름 입력", "uploadFace": "얼굴 사진 올리기", - "nextSteps": "다음 단계" + "nextSteps": "다음 단계", + "description": { + "uploadFace": "정면을 향한 {{name}}의 얼굴이 보이는 이미지를 업로드하세요. 이미지를 얼굴만 자를 필요는 없습니다." + } }, "train": { - "title": "학습", - "aria": "학습 선택" + "title": "최근 인식", + "aria": "최근 인식 선택", + "titleShort": "최근" }, "selectFace": "얼굴 선택", "deleteFaceLibrary": { diff --git a/web/public/locales/ko/views/live.json b/web/public/locales/ko/views/live.json index 5a825a08f5..c2b05d04ea 100644 --- a/web/public/locales/ko/views/live.json +++ b/web/public/locales/ko/views/live.json @@ -15,7 +15,8 @@ "clickMove": { "label": "클릭해서 카메라 중앙 배치", "enable": "클릭해서 움직이기 기능 활성화", - "disable": "클릭해서 움직이기 기능 비활성화" + "disable": "클릭해서 움직이기 기능 비활성화", + "enableWithZoom": "클릭하여 이동 / 드래그하여 확대 활성화" }, "left": { "label": "PTZ 카메라 왼쪽으로 이동" diff --git a/web/public/locales/ko/views/motionSearch.json b/web/public/locales/ko/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ko/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ko/views/replay.json b/web/public/locales/ko/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ko/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ko/views/search.json b/web/public/locales/ko/views/search.json index b898fb8265..efb5c93870 100644 --- a/web/public/locales/ko/views/search.json +++ b/web/public/locales/ko/views/search.json @@ -5,7 +5,24 @@ "clear": "검색 초기화", "save": "검색 저장", "filterInformation": "필터 정보", - "delete": "저장된 검색 삭제" + "delete": "저장된 검색 삭제", + "filterActive": "필터 활성화됨" }, - "searchFor": "{{inputValue}} 검색" + "searchFor": "{{inputValue}} 검색", + "trackedObjectId": "추적 객체 ID", + "filter": { + "label": { + "cameras": "카메라", + "labels": "레이블", + "zones": "구역", + "sub_labels": "하위 레이블", + "attributes": "속성", + "search_type": "검색 유형", + "time_range": "시간 범위", + "before": "이전", + "after": "이후", + "min_score": "최소 점수", + "max_score": "최대 점수" + } + } } diff --git a/web/public/locales/ko/views/settings.json b/web/public/locales/ko/views/settings.json index c17eaa7fd5..220cceb8f2 100644 --- a/web/public/locales/ko/views/settings.json +++ b/web/public/locales/ko/views/settings.json @@ -29,14 +29,15 @@ "masksAndZones": "마스크와 구역 편집기 - Frigate", "motionTuner": "움직임 감지 조정 - Frigate", "object": "디버그 - Frigate", - "general": "프로필 설정 - Frigate", + "general": "UI 설정 - Frigate", "frigatePlus": "Frigate+ 설정 - Frigate", "notifications": "알림 설정 - Frigate", "cameraManagement": "카메라 관리 - Frigate", "cameraReview": "카메라 다시보기 설정 - Frigate", "globalConfig": "전체 설정 - Frigate", "cameraConfig": "카메라 설정 - Frigate", - "maintenance": "유지 관리 - Frigate" + "maintenance": "유지 관리 - Frigate", + "profiles": "프로필 - Frigate" }, "users": { "table": { @@ -219,5 +220,11 @@ "label": "새 값", "reset": "초기화" } + }, + "button": { + "overriddenGlobal": "전역 재정의됨", + "overriddenGlobalTooltip": "이 카메라는 이 섹션의 전역 설정을 재정의합니다", + "overriddenBaseConfig": "기본 설정 재정의됨", + "overriddenBaseConfigTooltip": "{{profile}} 프로필은 이 섹션의 구성 설정을 재정의합니다" } } diff --git a/web/public/locales/lt/views/chat.json b/web/public/locales/lt/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/lt/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/lt/views/motionSearch.json b/web/public/locales/lt/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/lt/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/lt/views/replay.json b/web/public/locales/lt/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/lt/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/lv/views/chat.json b/web/public/locales/lv/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/lv/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/lv/views/motionSearch.json b/web/public/locales/lv/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/lv/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/lv/views/replay.json b/web/public/locales/lv/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/lv/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ml/views/chat.json b/web/public/locales/ml/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ml/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ml/views/motionSearch.json b/web/public/locales/ml/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ml/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ml/views/replay.json b/web/public/locales/ml/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ml/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/nb-NO/common.json b/web/public/locales/nb-NO/common.json index 921ddc77b3..411463881c 100644 --- a/web/public/locales/nb-NO/common.json +++ b/web/public/locales/nb-NO/common.json @@ -171,7 +171,7 @@ "configuration": "Konfigurasjon", "systemLogs": "Systemlogger", "settings": "Innstillinger", - "configurationEditor": "Rediger konfigurasjonen", + "configurationEditor": "Rediger konfigurasjonsfil", "languages": "Språk", "language": { "en": "English (Engelsk)", diff --git a/web/public/locales/nb-NO/components/dialog.json b/web/public/locales/nb-NO/components/dialog.json index 6f38ca4242..ebe531b4c4 100644 --- a/web/public/locales/nb-NO/components/dialog.json +++ b/web/public/locales/nb-NO/components/dialog.json @@ -59,15 +59,26 @@ "toast": { "success": "Eksport startet. Se filen på eksportsiden.", "error": { - "failed": "Klarte ikke å starte eksport: {{error}}", + "failed": "Kunne ikke legge eksport i kø: {{error}}", "noVaildTimeSelected": "Ingen gyldig tidsperiode valgt", "endTimeMustAfterStartTime": "Sluttid må være etter starttid" }, - "view": "Vis" + "view": "Vis", + "queued": "Eksport lagt i kø. Se fremdrift på eksportsiden.", + "batchPartial": "Startet {{successful}} av {{total}} eksporter. Kameraer som feilet: {{failedCameras}}", + "batchFailed": "Kunne ikke starte {{total}} eksporter. Kameraer som feilet: {{failedCameras}}", + "batchQueuedPartial": "La {{successful}} av {{total}} eksporter i kø. Kameraer som feilet: {{failedCameras}}", + "batchQueueFailed": "Kunne ikke legge {{total}} eksporter i kø. Kameraer som feilet: {{failedCameras}}", + "batchSuccess_one": "Startet 1 eksport. Åpner saken nå.", + "batchSuccess_other": "Startet {{count}} eksporter. Åpner saken nå.", + "batchQueuedSuccess_one": "La 1 eksport i kø. Åpner saken nå.", + "batchQueuedSuccess_other": "La {{count}} eksporter i kø. Åpner saken nå." }, "fromTimeline": { "previewExport": "Forhåndsvis eksport", - "saveExport": "Lagre eksport" + "saveExport": "Lagre eksport", + "queueingExport": "Legger eksport i kø...", + "useThisRange": "Bruk dette tidsrommet" }, "name": { "placeholder": "Gi eksporten et navn" @@ -77,7 +88,49 @@ "selectOrExport": "Velg eller eksporter", "case": { "label": "Sak", - "placeholder": "Velg en sak" + "placeholder": "Velg en sak", + "newCaseOption": "Opprett ny sak", + "newCaseNamePlaceholder": "Navn på ny sak", + "newCaseDescriptionPlaceholder": "Saksbeskrivelse", + "nonAdminHelp": "En ny sak vil bli opprettet for disse eksportene." + }, + "queueing": "Legger eksport i kø...", + "tabs": { + "export": "Enkeltkamera", + "multiCamera": "Multikamera" + }, + "multiCamera": { + "timeRange": "Tidsrom", + "selectFromTimeline": "Velg fra tidslinje", + "cameraSelection": "Kameraer", + "cameraSelectionHelp": "Kameraer med sporede objekter i dette tidsrommet er forhåndsvalgt", + "checkingActivity": "Sjekker kameraaktivitet...", + "noCameras": "Ingen kameraer tilgjengelig", + "nameLabel": "Eksportnavn", + "namePlaceholder": "Valgfritt navneprefiks for disse eksportene", + "queueingButton": "Legger eksporter i kø...", + "detectionCount_one": "1 sporet objekt", + "detectionCount_other": "{{count}} sporede objekter", + "exportButton_one": "Eksporter 1 kamera", + "exportButton_other": "Eksporter {{count}} kameraer" + }, + "multi": { + "description": "Eksporter hver valgte inspeksjon. Alle eksporter vil bli gruppert under én sak.", + "descriptionNoCase": "Eksporter hver valgte inspeksjon.", + "caseNamePlaceholder": "Eksport av inspeksjon - {{date}}", + "exportingButton": "Eksporterer...", + "toast": { + "partial": "Startet {{successful}} av {{total}} eksporter. Feilet: {{failedItems}}", + "failed": "Kunne ikke starte {{total}} eksporter. Feilet: {{failedItems}}", + "started_one": "Startet 1 eksport. Åpner saken nå.", + "started_other": "Startet {{count}} eksporter. Åpner saken nå.", + "startedNoCase_one": "Startet 1 eksport.", + "startedNoCase_other": "Startet {{count}} eksporter." + }, + "title_one": "Eksporter 1 inspeksjon", + "title_other": "Eksporter {{count}} inspeksjon", + "exportButton_one": "Eksporter 1 inspeksjon", + "exportButton_other": "Eksporter {{count}} inspeksjon" } }, "streaming": { @@ -125,6 +178,14 @@ "markAsReviewed": "Merk som inspisert", "deleteNow": "Slett nå", "markAsUnreviewed": "Merk som ikke inspisert" + }, + "shareTimestamp": { + "description": "Del en tidsstemplet URL fra avspillerens nåværende posisjon, eller velg et egendefinert tidsstempel. Merk at dette ikke er en offentlig delingslenke, og at den kun er tilgjengelig for brukere med tilgang til Frigate og dette kameraet.", + "custom": "Egendefinert tidsstempel", + "title": "Del tidsstempel", + "label": "Del tidsstempel", + "button": "Del URL med tidsstempel", + "shareTitle": "Tidsstempel for Frigate-inspeksjon: {{camera}}" } }, "imagePicker": { diff --git a/web/public/locales/nb-NO/components/player.json b/web/public/locales/nb-NO/components/player.json index b08459cfcf..56fe61e9ab 100644 --- a/web/public/locales/nb-NO/components/player.json +++ b/web/public/locales/nb-NO/components/player.json @@ -32,7 +32,8 @@ "noPreviewFoundFor": "Ingen forhåndsvisning funnet for {{cameraName}}", "submitFrigatePlus": { "title": "Send dette bildet til Frigate+?", - "submit": "Send" + "submit": "Send", + "previewError": "Kunne ikke laste forhåndsvisning av stillbilde. Opptaket er kanskje ikke tilgjengelig for øyeblikket." }, "livePlayerRequiredIOSVersion": "iOS 17.1 eller høyere kreves for denne typen direkte-strømming.", "streamOffline": { diff --git a/web/public/locales/nb-NO/config/cameras.json b/web/public/locales/nb-NO/config/cameras.json index ef94b6f352..d2a44f51e5 100644 --- a/web/public/locales/nb-NO/config/cameras.json +++ b/web/public/locales/nb-NO/config/cameras.json @@ -840,7 +840,7 @@ "label": "Opprinnelig kamerastatus" }, "friendly_name": { - "description": "Kamerats visningsnavn i Frigate-grensesnittet", + "description": "Kameraets visningsnavn i Frigate-grensesnittet", "label": "Visningsnavn" }, "label": "Kamerakonfigurasjon", diff --git a/web/public/locales/nb-NO/config/global.json b/web/public/locales/nb-NO/config/global.json index d123063200..4c669b31db 100644 --- a/web/public/locales/nb-NO/config/global.json +++ b/web/public/locales/nb-NO/config/global.json @@ -5,7 +5,7 @@ }, "safe_mode": { "label": "Trygg modus", - "description": "Når aktivert, start Frigate i trygg modus med reduserte funksjoner for feilsøking." + "description": "Når aktivert, start Frigate i trygg modus med redusert funksjonalitet for feilsøking." }, "environment_vars": { "label": "Miljøvariabler", @@ -15,7 +15,7 @@ "label": "Logging", "description": "Kontrollerer standard loggdetaljnivå og overstyringer av loggnivå per komponent.", "default": { - "label": "Loggnivå", + "label": "Loggenivå", "description": "Standard globale loggedetaljer (debug, info, warning, error)." }, "logs": { @@ -527,7 +527,7 @@ }, "roles": { "label": "Roller", - "description": "GenAI-roller (verktøy, bildeforståelse/syn, vektorrepresentasjoner); én leverandør per rolle." + "description": "GenAI-roller (chat, beskrivelser, vektorrepresentasjoner); én leverandør per rolle." }, "provider_options": { "label": "Leverandøralternativer", diff --git a/web/public/locales/nb-NO/views/chat.json b/web/public/locales/nb-NO/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/nb-NO/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/nb-NO/views/configEditor.json b/web/public/locales/nb-NO/views/configEditor.json index df0cd00a9a..2aa6fade07 100644 --- a/web/public/locales/nb-NO/views/configEditor.json +++ b/web/public/locales/nb-NO/views/configEditor.json @@ -1,5 +1,5 @@ { - "documentTitle": "Konfigurasjonseditor - Frigate", + "documentTitle": "Konfigurasjonsfil - Frigate", "toast": { "error": { "savingError": "Feil ved lagring av konfigurasjon" @@ -8,11 +8,11 @@ "copyToClipboard": "Konfigurasjonen ble kopiert til utklippstavlen." } }, - "configEditor": "Konfig-editor", + "configEditor": "Konfigurasjonsfil", "copyConfig": "Kopier konfigurasjonen", "saveAndRestart": "Lagre og omstart", "saveOnly": "Kun lagre", "confirm": "Avslutt uten å lagre?", - "safeConfigEditor": "Konfig-editor (Sikker modus)", + "safeConfigEditor": "Konfigurasjonsfil (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 d1c3b02de5..4a46f3f973 100644 --- a/web/public/locales/nb-NO/views/events.json +++ b/web/public/locales/nb-NO/views/events.json @@ -31,7 +31,9 @@ "timeline.aria": "Velg tidslinje", "documentTitle": "Inspeksjon - Frigate", "recordings": { - "documentTitle": "Opptak - Frigate" + "documentTitle": "Opptak - Frigate", + "invalidSharedLink": "Kunne ikke åpne tidsstemplet opptakslenke på grunn av tolkningsfeil.", + "invalidSharedCamera": "Kunne ikke åpne tidsstemplet opptakslenke på grunn av et ukjent eller uautorisert kamera." }, "calendarFilter": { "last24Hours": "Siste 24 timer" @@ -39,7 +41,7 @@ "markAsReviewed": "Merk som inspisert", "markTheseItemsAsReviewed": "Merk disse elementene som inspiserte", "selected_one": "{{count}} valgt", - "selected_other": "{{count}} valgt", + "selected_other": "{{count}} valgte", "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 6aac95d76e..1e3b485bfe 100644 --- a/web/public/locales/nb-NO/views/explore.json +++ b/web/public/locales/nb-NO/views/explore.json @@ -287,7 +287,10 @@ "zones": "Soner", "ratio": "Sideforhold", "area": "Område", - "score": "Score" + "score": "Score", + "computedScore": "Beregnet score", + "topScore": "Toppscore", + "toggleAdvancedScores": "Vis/skjul avanserte scoringer" } }, "annotationSettings": { diff --git a/web/public/locales/nb-NO/views/exports.json b/web/public/locales/nb-NO/views/exports.json index 481750f5c3..d455fd8c5c 100644 --- a/web/public/locales/nb-NO/views/exports.json +++ b/web/public/locales/nb-NO/views/exports.json @@ -14,7 +14,9 @@ "toast": { "error": { "renameExportFailed": "Kunne ikke gi nytt navn til eksport: {{errorMessage}}", - "assignCaseFailed": "Kunne ikke oppdatere sakstilknytning: {{errorMessage}}" + "assignCaseFailed": "Kunne ikke oppdatere sakstilknytning: {{errorMessage}}", + "caseSaveFailed": "Kunne ikke lagre sak: {{errorMessage}}", + "caseDeleteFailed": "Kunne ikke slette sak: {{errorMessage}}" } }, "tooltip": { @@ -22,7 +24,8 @@ "downloadVideo": "Last ned video", "editName": "Rediger navn", "deleteExport": "Slett eksport", - "assignToCase": "Legg til i sak" + "assignToCase": "Legg til i sak", + "removeFromCase": "Fjern fra sak" }, "caseDialog": { "nameLabel": "Saksnavn", @@ -35,5 +38,91 @@ "headings": { "cases": "Saker", "uncategorizedExports": "Eksporter uten sak" + }, + "toolbar": { + "newCase": "Ny sak", + "addExport": "Legg til eksport", + "editCase": "Rediger sak", + "deleteCase": "Slett sak" + }, + "deleteCase": { + "label": "Slett sak", + "desc": "Er du sikker på at du vil slette {{caseName}}?", + "descKeepExports": "Eksporter vil fortsatt være tilgjengelige som eksporter uten sak.", + "descDeleteExports": "Alle eksporter i denne saken vil bli slettet permanent.", + "deleteExports": "Slett også eksporter" + }, + "caseCard": { + "emptyCase": "Ingen eksporter ennå" + }, + "jobCard": { + "defaultName": "{{camera}}-eksport", + "queued": "Lagt i kø", + "running": "Kjører", + "preparing": "Forbereder", + "copying": "Kopierer", + "encoding": "Enkoder", + "encodingRetry": "Enkoder (nytt forsøk)", + "finalizing": "Fullfører" + }, + "caseView": { + "noDescription": "Ingen beskrivelse", + "createdAt": "Opprettet {{value}}", + "exportCount_one": "1 eksport", + "exportCount_other": "{{count}} eksporter", + "cameraCount_one": "1 kamera", + "cameraCount_other": "{{count}} kameraer", + "showMore": "Vis mer", + "showLess": "Vis mindre", + "emptyTitle": "Denne saken er tom", + "emptyDescription": "Legg til eksisterende eksporter uten sak for å holde orden i saken.", + "emptyDescriptionNoExports": "Det er ingen ledige eksporter tilgjengelig for å legges til ennå." + }, + "caseEditor": { + "createTitle": "Opprett sak", + "editTitle": "Rediger sak", + "namePlaceholder": "Saksnavn", + "descriptionPlaceholder": "Legg til notater eller kontekst for denne saken" + }, + "addExportDialog": { + "title": "Legg til eksport i {{caseName}}", + "searchPlaceholder": "Søk i eksporter uten sak", + "empty": "Ingen eksporter uten sak samsvarer med søket.", + "addButton_one": "Legg til 1 eksport", + "addButton_other": "Legg til {{count}} eksporter", + "adding": "Legger til..." + }, + "selected_one": "{{count}} valgt", + "selected_other": "{{count}} valgte", + "bulkActions": { + "addToCase": "Legg til i sak", + "moveToCase": "Flytt til sak", + "removeFromCase": "Fjern fra sak", + "delete": "Slett", + "deleteNow": "Slett nå" + }, + "bulkDelete": { + "title": "Slett eksporter", + "desc_one": "Er du sikker på at du vil slette {{count}} eksport?", + "desc_other": "Er du sikker på at du vil slette {{count}} eksporter?" + }, + "bulkRemoveFromCase": { + "title": "Fjern fra sak", + "desc_one": "Fjerne {{count}} eksport fra denne saken?", + "desc_other": "Fjerne {{count}} eksporter fra denne saken?", + "descKeepExports": "Eksporter vil bli flyttet til \"uten sak\".", + "descDeleteExports": "Eksporter vil bli slettet permanent.", + "deleteExports": "Slett eksporter i stedet" + }, + "bulkToast": { + "success": { + "delete": "Eksporter ble slettet", + "reassign": "Sakstilknytning ble oppdatert", + "remove": "Eksporter ble fjernet fra saken" + }, + "error": { + "deleteFailed": "Kunne ikke slette eksporter: {{errorMessage}}", + "reassignFailed": "Kunne ikke oppdatere sakstilknytning: {{errorMessage}}" + } } } diff --git a/web/public/locales/nb-NO/views/motionSearch.json b/web/public/locales/nb-NO/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/nb-NO/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/nb-NO/views/replay.json b/web/public/locales/nb-NO/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/nb-NO/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/nb-NO/views/settings.json b/web/public/locales/nb-NO/views/settings.json index 3b0f3b4f09..6d907f91ea 100644 --- a/web/public/locales/nb-NO/views/settings.json +++ b/web/public/locales/nb-NO/views/settings.json @@ -1630,7 +1630,16 @@ "itemTitle": "Strøm {{index}}" }, "searchPlaceholder": "Søk...", - "showAdvanced": "Vis avanserte innstillinger" + "showAdvanced": "Vis avanserte innstillinger", + "genaiModel": { + "placeholder": "Velg modell…", + "search": "Søk modeller…", + "noModels": "Ingen modeller tilgjengelig" + }, + "knownPlates": { + "platePlaceholder": "Kjennemerke nummer eller regex", + "namePlaceholder": "f.eks konas bil" + } }, "button": { "overriddenBaseConfigTooltip": "{{profile}}-profilen overstyrer konfigurasjonsinnstillinger i denne seksjonen", diff --git a/web/public/locales/ne/audio.json b/web/public/locales/ne/audio.json new file mode 100644 index 0000000000..474844fc05 --- /dev/null +++ b/web/public/locales/ne/audio.json @@ -0,0 +1,10 @@ +{ + "speech": "बोली", + "bicycle": "साइकल", + "yell": "चिच्याउनु", + "car": "कार", + "bellow": "तलतिर", + "motorcycle": "मोटरसाइकल", + "whoop": "हुप (Whoop)", + "whispering": "सानो बोल्दै" +} diff --git a/web/public/locales/ne/common.json b/web/public/locales/ne/common.json new file mode 100644 index 0000000000..f252005ef8 --- /dev/null +++ b/web/public/locales/ne/common.json @@ -0,0 +1,8 @@ +{ + "time": { + "untilForRestart": "फ्रिगेट पुनः सुरु नभएसम्म।", + "untilRestart": "पुन: सुरु नभएसम्म", + "never": "कहिल्यै होइन", + "ago": "{{timeAgo}} अघि" + } +} diff --git a/web/public/locales/ne/components/auth.json b/web/public/locales/ne/components/auth.json new file mode 100644 index 0000000000..81ffe7c340 --- /dev/null +++ b/web/public/locales/ne/components/auth.json @@ -0,0 +1,11 @@ +{ + "form": { + "user": "प्रयोगकर्ता नाम", + "password": "पासवर्ड", + "login": "लगइन", + "firstTimeLogin": "पहिलो पटक लग इन गर्ने प्रयास गर्दै हुनुहुन्छ? प्रमाणपत्रहरू फ्रिगेट लगहरूमा छापिएका हुन्छन्।", + "errors": { + "usernameRequired": "प्रयोगकर्ता नाम आवश्यक छ" + } + } +} diff --git a/web/public/locales/ne/components/camera.json b/web/public/locales/ne/components/camera.json new file mode 100644 index 0000000000..98f5d8338d --- /dev/null +++ b/web/public/locales/ne/components/camera.json @@ -0,0 +1,13 @@ +{ + "group": { + "label": "क्यामेरा समूहहरू", + "add": "क्यामेरा समूह थप्नुहोस्", + "edit": "क्यामेरा समूह सम्पादन गर्नुहोस्", + "delete": { + "label": "क्यामेरा समूह मेटाउनुहोस्", + "confirm": { + "title": "मेटाउने पुष्टि गर्नुहोस्" + } + } + } +} diff --git a/web/public/locales/ne/components/dialog.json b/web/public/locales/ne/components/dialog.json new file mode 100644 index 0000000000..634f89b00e --- /dev/null +++ b/web/public/locales/ne/components/dialog.json @@ -0,0 +1,11 @@ +{ + "restart": { + "title": "के तपाईं फ्रिगेट पुन: सुरु गर्न चाहनुहुन्छ?", + "description": "यसले फ्रिगेट पुन: सुरु हुँदा केही समयको लागि रोक्नेछ।", + "button": "पुनः सुरु", + "restarting": { + "title": "फ्रिगेट पुन: सुरु हुँदैछ", + "content": "यो पृष्ठ {{countdown}} सेकेन्डमा पुन: लोड हुनेछ।" + } + } +} diff --git a/web/public/locales/ne/components/filter.json b/web/public/locales/ne/components/filter.json new file mode 100644 index 0000000000..d520a85389 --- /dev/null +++ b/web/public/locales/ne/components/filter.json @@ -0,0 +1,11 @@ +{ + "filter": "फिल्टर गर्नुहोस्", + "classes": { + "label": "कक्षाहरू", + "all": { + "title": "सबै कक्षाहरू" + }, + "count_one": "{{count}} कक्षा", + "count_other": "{{count}} कक्षाहरू" + } +} diff --git a/web/public/locales/ne/components/icons.json b/web/public/locales/ne/components/icons.json new file mode 100644 index 0000000000..208c39a2a7 --- /dev/null +++ b/web/public/locales/ne/components/icons.json @@ -0,0 +1,8 @@ +{ + "iconPicker": { + "selectIcon": "आइकन चयन गर्नुहोस्", + "search": { + "placeholder": "आइकन खोज्नुहोस्…" + } + } +} diff --git a/web/public/locales/ne/components/input.json b/web/public/locales/ne/components/input.json new file mode 100644 index 0000000000..c1990b9937 --- /dev/null +++ b/web/public/locales/ne/components/input.json @@ -0,0 +1,10 @@ +{ + "button": { + "downloadVideo": { + "label": "डाउनलोड भिडियो", + "toast": { + "success": "तपाईंको समीक्षा वस्तुको भिडियो डाउनलोड हुन थालेको छ।" + } + } + } +} diff --git a/web/public/locales/ne/components/player.json b/web/public/locales/ne/components/player.json new file mode 100644 index 0000000000..64663e4518 --- /dev/null +++ b/web/public/locales/ne/components/player.json @@ -0,0 +1,9 @@ +{ + "noPreviewFound": "कुनै पूर्वावलोकन फेला परेन", + "noPreviewFoundFor": "{{cameraName}} को लागि कुनै पूर्वावलोकन फेला परेन।", + "submitFrigatePlus": { + "title": "यो फ्रेम Frigate+ मा बुझाउने हो?", + "submit": "पेश गर्नुहोस्", + "previewError": "स्न्यापसट पूर्वावलोकन लोड गर्न सकिएन। रेकर्डिङ यस समयमा उपलब्ध नहुन सक्छ।" + } +} diff --git a/web/public/locales/ne/config/cameras.json b/web/public/locales/ne/config/cameras.json new file mode 100644 index 0000000000..4f9f3489ad --- /dev/null +++ b/web/public/locales/ne/config/cameras.json @@ -0,0 +1,11 @@ +{ + "label": "क्यामेरा कन्फिग", + "name": { + "label": "क्यामेराको नाम", + "description": "क्यामेराको नाम आवश्यक छ" + }, + "friendly_name": { + "label": "मैत्रीपूर्ण नाम", + "description": "फ्रिगेट UI मा प्रयोग गरिएको क्यामेरा मैत्री नाम" + } +} diff --git a/web/public/locales/ne/config/global.json b/web/public/locales/ne/config/global.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ne/config/global.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ne/config/groups.json b/web/public/locales/ne/config/groups.json new file mode 100644 index 0000000000..e80784d181 --- /dev/null +++ b/web/public/locales/ne/config/groups.json @@ -0,0 +1,17 @@ +{ + "audio": { + "global": { + "detection": "विश्वव्यापी पत्ता-लगाउने", + "sensitivity": "विश्वव्यापी संवेदनशीलता" + }, + "cameras": { + "detection": "पत्ता-लगाउने", + "sensitivity": "संवेदनशीलता" + } + }, + "timestamp_style": { + "global": { + "appearance": "विश्वव्यापी उपस्थिति" + } + } +} diff --git a/web/public/locales/ne/config/validation.json b/web/public/locales/ne/config/validation.json new file mode 100644 index 0000000000..7592167cde --- /dev/null +++ b/web/public/locales/ne/config/validation.json @@ -0,0 +1,7 @@ +{ + "minimum": "कम्तिमा हुनुपर्छ {{limit}}", + "maximum": "बढीमा हुनुपर्छ {{limit}}", + "exclusiveMinimum": "{{limit}} भन्दा बढी हुनुपर्छ", + "exclusiveMaximum": ".{{limit}} भन्दा कम हुनुपर्छ", + "minLength": "कम्तिमा {{limit}} वर्ण(हरू) हुनुपर्छ।" +} diff --git a/web/public/locales/ne/objects.json b/web/public/locales/ne/objects.json new file mode 100644 index 0000000000..74f59f8ad5 --- /dev/null +++ b/web/public/locales/ne/objects.json @@ -0,0 +1,7 @@ +{ + "person": "व्यक्ति", + "bicycle": "साइकल", + "car": "कार", + "motorcycle": "मोटरसाइकल", + "airplane": "हवाइजहाज" +} diff --git a/web/public/locales/ne/views/chat.json b/web/public/locales/ne/views/chat.json new file mode 100644 index 0000000000..774d1fcdb2 --- /dev/null +++ b/web/public/locales/ne/views/chat.json @@ -0,0 +1,7 @@ +{ + "documentTitle": "च्याट - फ्रिगेट", + "title": "फ्रिगेट च्याट", + "subtitle": "क्यामेरा व्यवस्थापन र अन्तर्दृष्टिको लागि तपाईंको एआई सहायक", + "placeholder": "सोध्नुहोस्...", + "error": "केही गडबड भयो। कृपया फेरि प्रयास गर्नुहोस्।" +} diff --git a/web/public/locales/ne/views/classificationModel.json b/web/public/locales/ne/views/classificationModel.json new file mode 100644 index 0000000000..4b42e1ec57 --- /dev/null +++ b/web/public/locales/ne/views/classificationModel.json @@ -0,0 +1,15 @@ +{ + "documentTitle": "वर्गीकरण मोडेलहरू - फ्रिगेट", + "details": { + "scoreInfo": "स्कोरले यस वस्तुको सबै पत्ता लगाउने कार्यहरूमा औसत वर्गीकरण विश्वासलाई प्रतिनिधित्व गर्दछ।", + "none": "कुनै पनि होइन", + "unknown": "अज्ञात" + }, + "description": { + "invalidName": "अमान्य नाम। नामहरूमा अक्षर, संख्या, खाली ठाउँ, अपोस्ट्रोफी, अन्डरस्कोर र हाइफन मात्र समावेश हुन सक्छन्।" + }, + "button": { + "deleteClassificationAttempts": "वर्गीकरण छविहरू मेटाउनुहोस्", + "renameCategory": "वर्गको नाम बदल्नुहोस्" + } +} diff --git a/web/public/locales/ne/views/configEditor.json b/web/public/locales/ne/views/configEditor.json new file mode 100644 index 0000000000..fb7d183536 --- /dev/null +++ b/web/public/locales/ne/views/configEditor.json @@ -0,0 +1,7 @@ +{ + "documentTitle": "कन्फिग सम्पादक - फ्रिगेट", + "configEditor": "कन्फिग सम्पादक", + "safeConfigEditor": "कन्फिग सम्पादक (सुरक्षित मोड)", + "safeModeDescription": "कन्फिग प्रमाणीकरण त्रुटिको कारणले फ्रिगेट सुरक्षित मोडमा छ।", + "copyConfig": "कन्फिग प्रतिलिपि गर्नुहोस्" +} diff --git a/web/public/locales/ne/views/events.json b/web/public/locales/ne/views/events.json new file mode 100644 index 0000000000..9dcd78594a --- /dev/null +++ b/web/public/locales/ne/views/events.json @@ -0,0 +1,9 @@ +{ + "alerts": "अलर्टहरू", + "detections": "पत्ता लगाउने", + "motion": { + "label": "गति", + "only": "गति मात्र" + }, + "allCameras": "सबै क्यामेराहरू" +} diff --git a/web/public/locales/ne/views/explore.json b/web/public/locales/ne/views/explore.json new file mode 100644 index 0000000000..815bcd764b --- /dev/null +++ b/web/public/locales/ne/views/explore.json @@ -0,0 +1,5 @@ +{ + "details": { + "timestamp": "टाइमस्ट्याम्प" + } +} diff --git a/web/public/locales/ne/views/exports.json b/web/public/locales/ne/views/exports.json new file mode 100644 index 0000000000..e9d15ea91d --- /dev/null +++ b/web/public/locales/ne/views/exports.json @@ -0,0 +1,8 @@ +{ + "search": "खोज्नुहोस्", + "noExports": "कुनै निर्यात भेटिएन", + "headings": { + "cases": "केसहरू", + "uncategorizedExports": "वर्गीकृत नगरिएका निर्यातहरू" + } +} diff --git a/web/public/locales/ne/views/faceLibrary.json b/web/public/locales/ne/views/faceLibrary.json new file mode 100644 index 0000000000..67b7c6a9d5 --- /dev/null +++ b/web/public/locales/ne/views/faceLibrary.json @@ -0,0 +1,12 @@ +{ + "description": { + "addFace": "आफ्नो पहिलो तस्विर अपलोड गरेर अनुहार पुस्तकालयमा नयाँ संग्रह थप्नुहोस्।", + "placeholder": "यो सङ्ग्रहको लागि नाम प्रविष्ट गर्नुहोस्", + "invalidName": "अमान्य नाम। नामहरूमा अक्षर, संख्या, खाली ठाउँ, अपोस्ट्रोफी, अन्डरस्कोर र हाइफन मात्र समावेश हुन सक्छन्।", + "nameCannotContainHash": "नाममा # हुन सक्दैन।" + }, + "details": { + "unknown": "अज्ञात", + "timestamp": "टाइमस्ट्याम्प" + } +} diff --git a/web/public/locales/ne/views/live.json b/web/public/locales/ne/views/live.json new file mode 100644 index 0000000000..6e2b6dbac9 --- /dev/null +++ b/web/public/locales/ne/views/live.json @@ -0,0 +1,11 @@ +{ + "documentTitle": { + "default": "प्रत्यक्ष - फ्रिगेट", + "withCamera": "{{camera}} - प्रत्यक्ष - फ्रिगेट" + }, + "lowBandwidthMode": "कम-ब्यान्डविथ मोड", + "twoWayTalk": { + "enable": "दुईतर्फी कुराकानी सक्षम पार्नुहोस्", + "disable": "दुईतर्फी कुराकानी असक्षम पार्नुहोस्" + } +} diff --git a/web/public/locales/ne/views/motionSearch.json b/web/public/locales/ne/views/motionSearch.json new file mode 100644 index 0000000000..1cae42fd4c --- /dev/null +++ b/web/public/locales/ne/views/motionSearch.json @@ -0,0 +1,7 @@ +{ + "documentTitle": "गति खोज - फ्रिगेट", + "title": "गति खोज", + "description": "रुचिको क्षेत्र परिभाषित गर्न बहुभुज कोर्नुहोस्, र त्यो क्षेत्र भित्र गति परिवर्तनहरू खोज्नको लागि समय दायरा निर्दिष्ट गर्नुहोस्।", + "selectCamera": "गति खोज लोड हुँदैछ", + "startSearch": "खोज सुरु गर्नुहोस्" +} diff --git a/web/public/locales/ne/views/recording.json b/web/public/locales/ne/views/recording.json new file mode 100644 index 0000000000..439507f27d --- /dev/null +++ b/web/public/locales/ne/views/recording.json @@ -0,0 +1,11 @@ +{ + "export": "निर्यात", + "filter": "फिल्टर गर्नुहोस्", + "calendar": "कैलेंडर", + "filters": "फिल्टरहरू", + "toast": { + "error": { + "noValidTimeSelected": "कुनै मान्य समय दायरा चयन गरिएको छैन" + } + } +} diff --git a/web/public/locales/ne/views/replay.json b/web/public/locales/ne/views/replay.json new file mode 100644 index 0000000000..320d74c42f --- /dev/null +++ b/web/public/locales/ne/views/replay.json @@ -0,0 +1,10 @@ +{ + "title": "डिबग उत्तर", + "description": "डिबगिङको लागि क्यामेरा रेकर्डिङहरू पुन: प्ले गर्नुहोस्। वस्तु सूचीले पत्ता लगाइएका वस्तुहरूको समय-ढिलाइ भएको सारांश देखाउँछ र सन्देश ट्याबले रिप्ले फुटेजबाट फ्रिगेटको आन्तरिक सन्देशहरूको स्ट्रिम देखाउँछ।", + "websocket_messages": "सन्देशहरू", + "dialog": { + "title": "डिबग रिप्ले सुरु गर्नुहोस्", + "description": "वस्तु पत्ता लगाउने र ट्र्याकिङ समस्याहरू डिबग गर्न ऐतिहासिक फुटेज लुप गर्ने अस्थायी रिप्ले क्यामेरा सिर्जना गर्नुहोस्। रिप्ले क्यामेरामा स्रोत क्यामेरा जस्तै पत्ता लगाउने कन्फिगरेसन हुनेछ। सुरु गर्न समय दायरा छनौट गर्नुहोस्।", + "camera": "स्रोत क्यामेरा" + } +} diff --git a/web/public/locales/ne/views/search.json b/web/public/locales/ne/views/search.json new file mode 100644 index 0000000000..065d64d379 --- /dev/null +++ b/web/public/locales/ne/views/search.json @@ -0,0 +1,9 @@ +{ + "search": "खोज्नुहोस्", + "savedSearches": "सुरक्षित गरिएका खोजहरू", + "searchFor": "खोज्नुहोस् {{inputValue}}", + "button": { + "clear": "खोज खाली गर्नुहोस्", + "save": "खोज बचत गर्नुहोस्" + } +} diff --git a/web/public/locales/ne/views/settings.json b/web/public/locales/ne/views/settings.json new file mode 100644 index 0000000000..6f7076bc20 --- /dev/null +++ b/web/public/locales/ne/views/settings.json @@ -0,0 +1,9 @@ +{ + "documentTitle": { + "default": "सेटिङहरू - फ्रिगेट", + "authentication": "प्रमाणीकरण सेटिङहरू - फ्रिगेट", + "cameraManagement": "क्यामेराहरू व्यवस्थापन गर्नुहोस् - फ्रिगेट", + "cameraReview": "क्यामेरा समीक्षा सेटिङहरू - फ्रिगेट", + "enrichments": "संवर्धन सेटिङहरू - फ्रिगेट" + } +} diff --git a/web/public/locales/ne/views/system.json b/web/public/locales/ne/views/system.json new file mode 100644 index 0000000000..86c449ac83 --- /dev/null +++ b/web/public/locales/ne/views/system.json @@ -0,0 +1,12 @@ +{ + "documentTitle": { + "cameras": "क्यामेरा तथ्याङ्क - फ्रिगेट", + "storage": "भण्डारण तथ्याङ्क - फ्रिगेट", + "general": "सामान्य तथ्याङ्क - फ्रिगेट", + "enrichments": "संवर्धन तथ्याङ्क - फ्रिगेट", + "logs": { + "frigate": "फ्रिगेट लगहरू - फ्रिगेट", + "go2rtc": "Go2RTC लगहरू - फ्रिगेट" + } + } +} diff --git a/web/public/locales/nl/views/chat.json b/web/public/locales/nl/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/nl/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/nl/views/classificationModel.json b/web/public/locales/nl/views/classificationModel.json index 40a947afc3..7d655b134a 100644 --- a/web/public/locales/nl/views/classificationModel.json +++ b/web/public/locales/nl/views/classificationModel.json @@ -22,7 +22,8 @@ "deletedModel_one": "{{count}} model succesvol verwijderd", "deletedModel_other": "{{count}} modellen succesvol verwijderd", "updatedModel": "Modelconfiguratie succesvol bijgewerkt", - "renamedCategory": "Klasse succesvol hernoemd naar {{name}}" + "renamedCategory": "Klasse succesvol hernoemd naar {{name}}", + "reclassifiedImage": "Afbeelding succesvol opnieuw geclassificeerd" }, "error": { "deleteImageFailed": "Verwijderen mislukt: {{errorMessage}}", diff --git a/web/public/locales/nl/views/exports.json b/web/public/locales/nl/views/exports.json index ffeda4a9ad..e90ebd92fa 100644 --- a/web/public/locales/nl/views/exports.json +++ b/web/public/locales/nl/views/exports.json @@ -4,7 +4,8 @@ "toast": { "error": { "renameExportFailed": "Het is niet gelukt om de export te hernoemen: {{errorMessage}}", - "assignCaseFailed": "Kan toewijzing aan dossier niet bijwerken: {{errorMessage}}" + "assignCaseFailed": "Kan toewijzing aan dossier niet bijwerken: {{errorMessage}}", + "caseSaveFailed": "Mislukt om zaak op te slaan: {{errorMessage}}" } }, "editExport": { @@ -22,7 +23,8 @@ "downloadVideo": "Download video", "editName": "Naam bewerken", "deleteExport": "Verwijder export", - "assignToCase": "Toevoegen aan dossier" + "assignToCase": "Toevoegen aan dossier", + "removeFromCase": "Van zaak verwijderen" }, "headings": { "cases": "Gevallen", @@ -35,5 +37,31 @@ "newCaseOption": "Nieuw dossier aanmaken", "nameLabel": "Dossiernaam", "descriptionLabel": "Beschrijving" + }, + "deleteCase": { + "desc": "Weet je zeker dat je {{caseName}} wilt verwijderen?" + }, + "caseCard": { + "emptyCase": "Nog geen exports" + }, + "caseEditor": { + "createTitle": "Zaak aanmaken", + "editTitle": "Zaak wijzigen", + "namePlaceholder": "Zaaknaam", + "descriptionPlaceholder": "Voeg notities of context toe voor deze zaak" + }, + "addExportDialog": { + "title": "Voeg export toe aan {{caseName}}", + "searchPlaceholder": "Zoek ongecategoriseerde exports", + "empty": "Geen niet-gecategoriseerde exports voldoen aan deze zoekopdracht.", + "addButton_one": "Voeg 1 export toe", + "addButton_other": "Voeg {{count}} exports toe", + "adding": "Toevoegen..." + }, + "toolbar": { + "newCase": "Nieuwe zaak", + "addExport": "Export toevoegen", + "editCase": "Wijzig zaak", + "deleteCase": "Verwijder zaak" } } diff --git a/web/public/locales/nl/views/live.json b/web/public/locales/nl/views/live.json index a0b6cce79e..198af35fb5 100644 --- a/web/public/locales/nl/views/live.json +++ b/web/public/locales/nl/views/live.json @@ -13,7 +13,8 @@ "clickMove": { "label": "Klik in het frame om de camera te centreren", "enable": "Klikken om te bewegen inschakelen", - "disable": "Klikken om te bewegen uitschakelen" + "disable": "Klikken om te bewegen uitschakelen", + "enableWithZoom": "Schakel klikken om te verplaatsen / slepen om te zoomen in" }, "right": { "label": "Beweeg de PTZ-camera naar rechts" diff --git a/web/public/locales/nl/views/motionSearch.json b/web/public/locales/nl/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/nl/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/nl/views/replay.json b/web/public/locales/nl/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/nl/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/pl/components/player.json b/web/public/locales/pl/components/player.json index 227813f9c9..3cadb947f5 100644 --- a/web/public/locales/pl/components/player.json +++ b/web/public/locales/pl/components/player.json @@ -33,7 +33,8 @@ "noPreviewFoundFor": "Nie znaleziono podglądu dla {{cameraName}}", "submitFrigatePlus": { "title": "Wyślij tę klatkę do Frigate+?", - "submit": "Wyślij" + "submit": "Wyślij", + "previewError": "Nie udało się załadować podglądu nagrania. Nagranie może być niedostępne w podanym czasie." }, "livePlayerRequiredIOSVersion": "Wymagana wersja iOS 17.1 lub nowsza dla tego typu transmisji na żywo.", "streamOffline": { diff --git a/web/public/locales/pl/config/global.json b/web/public/locales/pl/config/global.json index ed12af3c70..ff2108fcbd 100644 --- a/web/public/locales/pl/config/global.json +++ b/web/public/locales/pl/config/global.json @@ -38,6 +38,35 @@ } }, "version": { - "label": "Aktualna wersja" + "label": "Bieżąca wersja konfiguracji", + "description": "Liczbowa lub znakowa wersja aktywnej konfiguracji w celu wykrywania migracji lub zmiany formatu." + }, + "safe_mode": { + "label": "Tryb bezpieczny", + "description": "Po włączeniu Frigate uruchomi się w trybie awaryjnym z ograniczonymi funkcjami, co ułatwi rozwiązywanie problemów." + }, + "environment_vars": { + "label": "Zmienne środowiskowe", + "description": "Pary klucz-wartość zmiennych środowiskowych, które należy ustawić dla procesu Frigate w systemie Home Assistant. Użytkownicy niekorzystający z Home Assistant powinni zamiast tego skorzystać z konfiguracji zmiennych środowiskowych w Dockerze." + }, + "logger": { + "label": "Rejestrowanie", + "description": "Konfiguracja domyślnej szczegółowości logów oraz indywidualnych ustawień dla komponentów.", + "default": { + "label": "Poziom szczegółowości logów", + "description": "Domyślny poziom szczegółowości logów globalnych (debug, info, warning, error)." + }, + "logs": { + "label": "Poziom logowania dla poszczególnych procesów", + "description": "Zmiana poziomu logowania dla poszczególnych komponentów w celu zwiększenia lub zmniejszenia szczegółowości logów dla określonych modułów." + } + }, + "auth": { + "label": "Uwierzytelnianie", + "description": "Ustawienia związane z uwierzytelnianiem i sesjami, w tym opcje dotyczące plików cookie i ograniczeń częstotliwości.", + "enabled": { + "label": "Włącz uwierzytelnianie", + "description": "Włącz natywne uwierzytelnianie dla interfejsu Frigate." + } } } diff --git a/web/public/locales/pl/config/groups.json b/web/public/locales/pl/config/groups.json index 0967ef424b..1d6d94b34e 100644 --- a/web/public/locales/pl/config/groups.json +++ b/web/public/locales/pl/config/groups.json @@ -1 +1,73 @@ -{} +{ + "audio": { + "global": { + "detection": "Ogólne Wykrywanie", + "sensitivity": "Ogólna Czułość" + }, + "cameras": { + "detection": "Wykrywanie", + "sensitivity": "Czułość" + } + }, + "timestamp_style": { + "global": { + "appearance": "Ogólny Wygląd" + }, + "cameras": { + "appearance": "Wygląd" + } + }, + "motion": { + "global": { + "sensitivity": "Ogólna Czułość", + "algorithm": "Ogólny Algorytm" + }, + "cameras": { + "sensitivity": "Czułość", + "algorithm": "Algorytm" + } + }, + "snapshots": { + "global": { + "display": "Ogólne Ustawienia Wyświetlania" + }, + "cameras": { + "display": "Wyświetlanie" + } + }, + "detect": { + "global": { + "resolution": "Ogólna Rozdzielczość", + "tracking": "Ogólne Śledzenie" + }, + "cameras": { + "resolution": "Rozdzielczość", + "tracking": "Śledzenie" + } + }, + "objects": { + "global": { + "tracking": "Ogólne Śledzenie", + "filtering": "Ogólne Filtrowanie" + }, + "cameras": { + "tracking": "Śledzenie", + "filtering": "Filtrowanie" + } + }, + "record": { + "global": { + "retention": "Ogólne Przechowywanie", + "events": "Ogólne Zdarzenia" + }, + "cameras": { + "retention": "Przechowywanie", + "events": "Zdarzenia" + } + }, + "ffmpeg": { + "cameras": { + "cameraFfmpeg": "Argumenty FFmpeg dotyczące konkretnej kamery" + } + } +} diff --git a/web/public/locales/pl/config/validation.json b/web/public/locales/pl/config/validation.json index 0967ef424b..cded1fbb3d 100644 --- a/web/public/locales/pl/config/validation.json +++ b/web/public/locales/pl/config/validation.json @@ -1 +1,18 @@ -{} +{ + "minimum": "Musi wynosić przynajmniej {{limit}}", + "maximum": "Może wynosić najwyżej {{limit}}", + "exclusiveMinimum": "Musi być większe niż {{limit}}", + "exclusiveMaximum": "Musi być mniejsze niż {{limit}}", + "minLength": "Musi być co najmniej {{limit}} znaków", + "maxLength": "Maksymalnie może być {{limit}} znaków", + "minItems": "Musi zawierać co najmniej {{limit}} pozycji", + "maxItems": "Maksymalnie {{limit}} pozycji", + "pattern": "Nieprawidłowy format", + "required": "To pole jest obowiązkowe", + "type": "Nieprawidłowy typ wartości", + "enum": "Musi to być jedna z dozwolonych wartości", + "const": "Wartość niezgodna z oczekiwaną stałą", + "uniqueItems": "Wszystkie pozycje muszą być unikalne", + "format": "Nieprawidłowy format", + "additionalProperties": "Nieznana właściwość jest niedozwolona" +} diff --git a/web/public/locales/pl/views/chat.json b/web/public/locales/pl/views/chat.json new file mode 100644 index 0000000000..f16ccc2c32 --- /dev/null +++ b/web/public/locales/pl/views/chat.json @@ -0,0 +1,4 @@ +{ + "documentTitle": "Czat - Frigate", + "title": "Frigate Czat" +} diff --git a/web/public/locales/pl/views/classificationModel.json b/web/public/locales/pl/views/classificationModel.json index bb29f4598a..a56cd62b3c 100644 --- a/web/public/locales/pl/views/classificationModel.json +++ b/web/public/locales/pl/views/classificationModel.json @@ -17,12 +17,12 @@ }, "toast": { "success": { - "deletedCategory_one": "Usunięte klasy", - "deletedCategory_few": "", - "deletedCategory_many": "", - "deletedImage_one": "Usunięte obrazy", - "deletedImage_few": "", - "deletedImage_many": "", + "deletedCategory_one": "Usunięto {{count}} klasę", + "deletedCategory_few": "Usunięto {{count}} klasy", + "deletedCategory_many": "Usunięto {{count}} klas", + "deletedImage_one": "Usunięto {{count}} obraz", + "deletedImage_few": "Usunięto {{count}} obrazy", + "deletedImage_many": "Usunięto {{count}} obrazów", "deletedModel_one": "Pomyślenie usunięto {{count}} model", "deletedModel_few": "Pomyślenie usunięto {{count}} modele", "deletedModel_many": "Pomyślenie usunięto {{count}} modeli", diff --git a/web/public/locales/pl/views/events.json b/web/public/locales/pl/views/events.json index 0ffc5419f3..1e85b72650 100644 --- a/web/public/locales/pl/views/events.json +++ b/web/public/locales/pl/views/events.json @@ -16,7 +16,9 @@ "description": "Elementy przeglądu można tworzyć dla kamery tylko wtedy, gdy dla tej kamery włączono nagrywanie." } }, - "timeline": "Oś czasu", + "timeline": { + "label": "Oś czasu" + }, "timeline.aria": "Wybierz oś czasu", "events": { "label": "Zdarzenia", diff --git a/web/public/locales/pl/views/explore.json b/web/public/locales/pl/views/explore.json index d18d065b85..3dfe9f509e 100644 --- a/web/public/locales/pl/views/explore.json +++ b/web/public/locales/pl/views/explore.json @@ -85,7 +85,8 @@ "attributes": "Atrybuty klasyfikacji", "title": { "label": "Tytuł" - } + }, + "scoreInfo": "Informacje o wyniku" }, "objectLifecycle": { "annotationSettings": { @@ -222,6 +223,13 @@ }, "hideObjectDetails": { "label": "Ukryj ścieżkę obiektu" + }, + "debugReplay": { + "label": "Odtwarzanie debugowania", + "aria": "Wyświetl śledzony obiekt w widoku odtwarzania debugowania" + }, + "more": { + "aria": "Więcej" } }, "trackedObjectsCount_one": "{{count}} śledzony obiekt ", @@ -232,6 +240,9 @@ "confirmDelete": { "desc": "Usunięcie tego śledzonego obiektu usuwa zrzut ekranu, wszelkie zapisane osadzenia i wszystkie powiązane wpisy śledzenia obiektu. Nagrany materiał tego śledzonego obiektu w widoku Historii NIE zostanie usunięty.

    Czy na pewno chcesz kontynuować?", "title": "Potwierdź usunięcie" + }, + "toast": { + "error": "Błąd kasowania śledzonego obiektu: {{errorMessage}}" } }, "fetchingTrackedObjectsFailed": "Błąd pobierania śledzonych obiektów: {{errorMessage}}", @@ -276,7 +287,10 @@ "zones": "Strefy", "area": "Powierzchnia", "score": "Wynik", - "ratio": "Proporcje" + "ratio": "Proporcje", + "toggleAdvancedScores": "Przełącz widok wyników zaawansowanych", + "computedScore": "Obliczony wynik", + "topScore": "Najwyższy wynik" }, "heard": "{{label}} słyszałem" }, diff --git a/web/public/locales/pl/views/exports.json b/web/public/locales/pl/views/exports.json index b0d41bbc38..82e6857fc1 100644 --- a/web/public/locales/pl/views/exports.json +++ b/web/public/locales/pl/views/exports.json @@ -2,7 +2,9 @@ "search": "Szukaj", "documentTitle": "Eksportuj - Frigate", "noExports": "Nie znaleziono eksportów", - "deleteExport": "Usuń eksport", + "deleteExport": { + "label": "Usuń eksport" + }, "deleteExport.desc": "Czy na pewno chcesz usunąć {{exportName}}?", "editExport": { "title": "Zmień nazwę eksportu", @@ -18,6 +20,11 @@ "shareExport": "Udostępnij eksport", "downloadVideo": "Pobierz wideo", "editName": "Edytuj nazwę", - "deleteExport": "Usuń eksport" + "deleteExport": "Usuń eksport", + "assignToCase": "Dodaj do sprawy" + }, + "headings": { + "cases": "Przypadki", + "uncategorizedExports": "Bez kategorii Eksport" } } diff --git a/web/public/locales/pl/views/live.json b/web/public/locales/pl/views/live.json index b4ab24def8..77f1a4229e 100644 --- a/web/public/locales/pl/views/live.json +++ b/web/public/locales/pl/views/live.json @@ -1,5 +1,7 @@ { - "documentTitle": "Na żywo - Frigate", + "documentTitle": { + "default": "Transmisja - Frigate" + }, "documentTitle.withCamera": "{{camera}}- Na żywo - Frigate", "lowBandwidthMode": "Tryb niskiej przepustowości", "twoWayTalk": { @@ -15,7 +17,8 @@ "clickMove": { "label": "Kliknij w ramce, aby wyśrodkować kamerę", "enable": "Włącz kliknięcie do przesuwania", - "disable": "Wyłącz kliknięcie do przesuwania" + "disable": "Wyłącz kliknięcie do przesuwania", + "enableWithZoom": "Włącz kliknij, aby przesunąć / przeciągnij, aby przybliżyć" }, "left": { "label": "Przesuń kamerę PTZ w lewo" diff --git a/web/public/locales/pl/views/motionSearch.json b/web/public/locales/pl/views/motionSearch.json new file mode 100644 index 0000000000..6406859bb3 --- /dev/null +++ b/web/public/locales/pl/views/motionSearch.json @@ -0,0 +1,58 @@ +{ + "documentTitle": "Wyszukiwanie zdarzeń ruchu - Frigate", + "title": "Wyszukiwanie zdarzeń ruchu", + "description": "Narysuj wielokąt aby wyznaczyć obszar zainteresowania, a następnie określ przedział czasowy w którym chcesz wyszukać zmiany ruchu w tym obszarze.", + "selectCamera": "Ładowanie wyszukiwania zdarzeń ruchu", + "startSearch": "Start wyszukiwania", + "searchStarted": "Uruchomiono wyszukiwanie", + "searchCancelled": "Zatrzymano wyszukiwanie", + "cancelSearch": "Anuluj", + "searching": "Wyszukiwanie w trakcie.", + "searchComplete": "Wyszukiwanie zakończono", + "noResultsYet": "Przeprowadź wyszukiwanie aby znaleźć zmiany ruchu w zaznaczonym obszarze", + "noChangesFound": "W wybranym obszarze nie wykryto żadnych zmian w pikselach", + "framesProcessed": "{{count}} przetworzonych klatek obrazu", + "jumpToTime": "Przejdź do tego momentu", + "results": "Wyniki", + "showSegmentHeatmap": "Mapa cieplna", + "newSearch": "Nowe wyszukiwanie", + "clearResults": "Wyczyść wyniki", + "clearROI": "Wyczyść strefę", + "polygonControls": { + "undo": "Cofnij ostatnią zmianę", + "reset": "Reset strefy" + }, + "motionHeatmapLabel": "Mapa aktywności ruchu", + "dialog": { + "title": "Wyszukiwanie według ruchu", + "cameraLabel": "Kamera", + "previewAlt": "Podgląd z kamery {{camera}}" + }, + "timeRange": { + "title": "Zakres wyszukiwania", + "start": "Czas rozpoczęcia", + "end": "Czas zakończenia" + }, + "settings": { + "title": "Ustawienia wyszukiwania", + "parallelMode": "Tryb równoległy", + "parallelModeDesc": "Przeskanuj wiele fragmentów nagrania jednocześnie (szybsze, ale znacznie bardziej obciążające procesor)", + "threshold": "Próg czułości", + "thresholdDesc": "Niższe wartości pozwalają wykrywać mniejsze zmiany (1–255)", + "minArea": "Obszar minimalnej zmiany", + "minAreaDesc": "Minimalny procent obszaru zainteresowania który musi ulec zmianie aby uznano to za istotne", + "frameSkip": "Pominięcie klatki", + "frameSkipDesc": "Przetwarza co N klatkę. Ustaw wartość na liczbę klatek na sekundę swojej kamery, aby przetwarzać jedną klatkę na sekundę (np. 5 dla kamery o 5 klatkach na sekundę, 30 dla kamery o 30 klatkach na sekundę). Wyższe wartości zapewniają większą szybkość ale mogą powodować pominięcie krótkich zdarzeń ruchu.", + "maxResults": "Maksymalne wyniki", + "maxResultsDesc": "Zatrzymaj się po osiągnięciu określonej liczby pasujących znaczników czasu" + }, + "errors": { + "noCamera": "Wybierz kamerę", + "noROI": "Zaznacz obszar zainteresowania", + "noTimeRange": "Ustaw przedział czasowy", + "invalidTimeRange": "Czas zakończenia musi być późniejszy niż czas rozpoczęcia", + "searchFailed": "Wyszukiwanie nie powiodło się: {{message}}", + "polygonTooSmall": "Obszar musi mieć co najmniej 3 punkty", + "unknown": "Nieznany błąd" + } +} diff --git a/web/public/locales/pl/views/replay.json b/web/public/locales/pl/views/replay.json new file mode 100644 index 0000000000..bfc3c4fe3a --- /dev/null +++ b/web/public/locales/pl/views/replay.json @@ -0,0 +1,6 @@ +{ + "page": { + "preparingClip": "Przygotuje urywek…" + }, + "title": "Debugowanie nagrań" +} diff --git a/web/public/locales/pl/views/settings.json b/web/public/locales/pl/views/settings.json index f7440b0468..38cb2cc4ac 100644 --- a/web/public/locales/pl/views/settings.json +++ b/web/public/locales/pl/views/settings.json @@ -7,7 +7,7 @@ "cameras": "Ustawienia Kamery", "frigateplus": "Frigate+", "masksAndZones": "Maski / Strefy", - "motionTuner": "Konfigurator Ruchu", + "motionTuner": "Konfigurator ruchu", "debug": "Debugowanie", "enrichments": "Wzbogacenia", "triggers": "Wyzwalacze", @@ -96,7 +96,11 @@ "notifications": "Ustawienia powiadomień - Frigate", "enrichments": "Ustawienia wzbogacania - Frigate", "cameraManagement": "Zarządzanie kamerami – Frigate", - "cameraReview": "Ustawienia przeglądu kamer - Frigate" + "cameraReview": "Ustawienia przeglądu kamer - Frigate", + "globalConfig": "Konfiguracja globalna - Frigate", + "cameraConfig": "Konfiguracja kamery - Frigate", + "maintenance": "Konserwacja – Frigate", + "profiles": "Profile - Frigate" }, "classification": { "title": "Ustawienia Klasyfikacji", @@ -410,7 +414,7 @@ }, "restart_required": "Wymagane ponowne uruchomienie (maski/strefy zmienione)", "motionMaskLabel": "Maska Ruchu {{number}}", - "objectMaskLabel": "Maska Obiektu {{number}} ({{label}})" + "objectMaskLabel": "Maska Obiektu {{number}}" }, "debug": { "objectList": "Lista Obiektów", @@ -684,7 +688,7 @@ "cleanCopySnapshots": "Zrzuty ekranu clean_copy", "camera": "Kamera" }, - "cleanCopyWarning": "Niektóre kamery mają włączone zrzuty ekranu, ale mają wyłączoną funkcję czystej kopii. Musisz włączyć clean_copy w konfiguracji zrzutów ekranu, aby móc przesyłać obrazy z tych kamer do Frigate+." + "cleanCopyWarning": "Niektóre kamery mają wyłączone migawki" }, "modelInfo": { "title": "Informacje o modelu", diff --git a/web/public/locales/pl/views/system.json b/web/public/locales/pl/views/system.json index ebcc114633..cc5851067f 100644 --- a/web/public/locales/pl/views/system.json +++ b/web/public/locales/pl/views/system.json @@ -7,7 +7,8 @@ "logs": { "frigate": "Logi Frigate - Frigate", "go2rtc": "Logi Go2RTC - Frigate", - "nginx": "Logi Nginx - Frigate" + "nginx": "Logi Nginx - Frigate", + "websocket": "Logi Websocket - Frigate" } }, "general": { @@ -163,6 +164,16 @@ "fetchingLogsFailed": "Błąd pobierania logów: {{errorMessage}}", "whileStreamingLogs": "Błąd podczas strumieniowania logów: {{errorMessage}}" } + }, + "websocket": { + "label": "Wiadomości", + "pause": "Pauza", + "resume": "Wznów", + "clear": "Wyczyść", + "filter": { + "all": "Wszystkie tematy", + "topics": "Tematy" + } } }, "title": "System", diff --git a/web/public/locales/pt-BR/common.json b/web/public/locales/pt-BR/common.json index 632155ddd1..f02bdc03e2 100644 --- a/web/public/locales/pt-BR/common.json +++ b/web/public/locales/pt-BR/common.json @@ -262,7 +262,8 @@ "setPassword": "Definir Senha" }, "classification": "Classificação", - "chat": "Chat" + "chat": "Chat", + "profiles": "Perfis" }, "toast": { "copyUrlToClipboard": "URL copiada para a área de transferência.", diff --git a/web/public/locales/pt-BR/config/cameras.json b/web/public/locales/pt-BR/config/cameras.json index b065dbb258..48b0de7d78 100644 --- a/web/public/locales/pt-BR/config/cameras.json +++ b/web/public/locales/pt-BR/config/cameras.json @@ -45,6 +45,13 @@ }, "label": "Configuração da Câmera", "audio_transcription": { - "label": "Transcrição de áudio" + "label": "Transcrição de áudio", + "enabled": { + "label": "Habilitar transcrição" + }, + "live_enabled": { + "label": "Transcrição em tempo real" + }, + "description": "Configurações de transcrição de áudio e voz ao vivo para eventos e legendas em tempo real." } } diff --git a/web/public/locales/pt-BR/config/global.json b/web/public/locales/pt-BR/config/global.json index a9cbd3f9c6..a503c8ee94 100644 --- a/web/public/locales/pt-BR/config/global.json +++ b/web/public/locales/pt-BR/config/global.json @@ -71,9 +71,19 @@ "session_length": { "label": "Duração da sessão", "description": "Duração da sessão em segundos para sessões baseadas em JWT." + }, + "failed_login_rate_limit": { + "label": "Limites de falha de login" + }, + "refresh_time": { + "label": "Janela de atualização da sessão" } }, "audio_transcription": { - "label": "Transcrição de áudio" + "label": "Transcrição de áudio", + "live_enabled": { + "label": "Transcrição em tempo real" + }, + "description": "Configurações de transcrição de áudio e voz ao vivo para eventos e legendas em tempo real." } } diff --git a/web/public/locales/pt-BR/views/chat.json b/web/public/locales/pt-BR/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/pt-BR/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/pt-BR/views/classificationModel.json b/web/public/locales/pt-BR/views/classificationModel.json index afa3fafbb5..36f3539a04 100644 --- a/web/public/locales/pt-BR/views/classificationModel.json +++ b/web/public/locales/pt-BR/views/classificationModel.json @@ -15,8 +15,8 @@ "deletedCategory_one": "Classe Apagada", "deletedCategory_many": "", "deletedCategory_other": "", - "deletedImage_one": "Imagens Apagadas", - "deletedImage_many": "", + "deletedImage_one": "Imagen Apagada", + "deletedImage_many": "Imagens Apagadas", "deletedImage_other": "", "categorizedImage": "Imagem Classificada com Sucesso", "trainedModel": "Modelo treinado com sucesso.", @@ -29,14 +29,15 @@ "reclassifiedImage": "Imagem reclassificada com sucesso" }, "error": { - "deleteImageFailed": "Falha ao deletar:{{errorMessage}}", - "deleteCategoryFailed": "Falha ao deletar classe:{{errorMessage}}", + "deleteImageFailed": "Falha ao excluir:{{errorMessage}}", + "deleteCategoryFailed": "Falha ao excluir classe:{{errorMessage}}", "categorizeFailed": "Falha ao categorizar imagem:{{errorMessage}}", "deleteModelFailed": "Falha ao excluir o modelo: {{errorMessage}}", "trainingFailed": "Treinamento do modelo falhou. Verifique os logs do Frigate para mais detalhes.", "trainingFailedToStart": "Falha ao iniciar o treinamento do modelo: {{errorMessage}}", "updateModelFailed": "Falha ao atualizar modelo: {{errorMessage}}", - "renameCategoryFailed": "Falha ao renomear classe: {{errorMessage}}" + "renameCategoryFailed": "Falha ao renomear classe: {{errorMessage}}", + "reclassifyFailed": "Falha ao reclassificar imagem: {{errorMessage}}" } }, "deleteCategory": { diff --git a/web/public/locales/pt-BR/views/exports.json b/web/public/locales/pt-BR/views/exports.json index db100ff0cf..83a5c298b2 100644 --- a/web/public/locales/pt-BR/views/exports.json +++ b/web/public/locales/pt-BR/views/exports.json @@ -22,7 +22,8 @@ "downloadVideo": "Baixar vídeo", "editName": "Editar nome", "deleteExport": "Apagar exportação", - "assignToCase": "Adicionar ao caso" + "assignToCase": "Adicionar ao caso", + "removeFromCase": "Remover da caixa" }, "headings": { "uncategorizedExports": "Exportações não categorizadas", diff --git a/web/public/locales/pt-BR/views/live.json b/web/public/locales/pt-BR/views/live.json index c2459b6403..a1b72767f4 100644 --- a/web/public/locales/pt-BR/views/live.json +++ b/web/public/locales/pt-BR/views/live.json @@ -17,7 +17,8 @@ "clickMove": { "label": "Clique no quadro para centralizar a câmera", "enable": "Ativar clique para mover", - "disable": "Desativar clique para mover" + "disable": "Desativar clique para mover", + "enableWithZoom": "Habilitar clicar para mover / arrastar para zoom" }, "left": { "label": "Mova a câmera PTZ para a esquerda" diff --git a/web/public/locales/pt-BR/views/motionSearch.json b/web/public/locales/pt-BR/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/pt-BR/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/pt-BR/views/replay.json b/web/public/locales/pt-BR/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/pt-BR/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/pt-BR/views/settings.json b/web/public/locales/pt-BR/views/settings.json index 7998227749..c937906c41 100644 --- a/web/public/locales/pt-BR/views/settings.json +++ b/web/public/locales/pt-BR/views/settings.json @@ -34,7 +34,15 @@ "general": "Geral", "globalConfig": "Configuração global", "system": "Sistema", - "integrations": "Integrações" + "integrations": "Integrações", + "uiSettings": "Configurações de interface", + "profiles": "Perfis", + "globalDetect": "Detecção de objeto", + "globalRecording": "Gravando", + "globalFfmpeg": "FFmpeg", + "globalMotion": "Detecção de movimento", + "globalObjects": "Objetos", + "globalReview": "Revisar" }, "dialog": { "unsavedChanges": { diff --git a/web/public/locales/pt-BR/views/system.json b/web/public/locales/pt-BR/views/system.json index 9226297196..431fd6ba06 100644 --- a/web/public/locales/pt-BR/views/system.json +++ b/web/public/locales/pt-BR/views/system.json @@ -50,8 +50,10 @@ "lpr": "LPR", "camera_activity": "Atividade da câmera", "system": "Sistema", - "camera": "Camera" - } + "camera": "Camera", + "all_cameras": "Todas as cameras" + }, + "empty": "Nenhuma mensagem capturada ainda" } }, "general": { diff --git a/web/public/locales/pt/views/chat.json b/web/public/locales/pt/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/pt/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/pt/views/motionSearch.json b/web/public/locales/pt/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/pt/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/pt/views/replay.json b/web/public/locales/pt/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/pt/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ro/common.json b/web/public/locales/ro/common.json index 1938c3d11e..00716ac2c4 100644 --- a/web/public/locales/ro/common.json +++ b/web/public/locales/ro/common.json @@ -188,7 +188,8 @@ "classification": "Clasificare", "chat": "Chat", "actions": "Acțiuni", - "profiles": "Profile" + "profiles": "Profile", + "features": "Funcționalități" }, "button": { "cameraAudio": "Sunet cameră", diff --git a/web/public/locales/ro/components/dialog.json b/web/public/locales/ro/components/dialog.json index ec929c0237..56dd59dcfb 100644 --- a/web/public/locales/ro/components/dialog.json +++ b/web/public/locales/ro/components/dialog.json @@ -59,6 +59,14 @@ "desc": { "selected": "Ești sigur că vrei să ștergi toate videoclipurile înregistrate asociate acestui element de revizuire?

    Ține apăsată tasta Shift pentru a sări peste această confirmare pe viitor." } + }, + "shareTimestamp": { + "label": "Partajează timestamp-ul", + "title": "Partajează timestamp-ul", + "description": "Partajează un URL cu timestamp al poziției actuale din player sau alege un timestamp personalizat. Reține că acesta nu este un URL de partajare publică și este accesibil doar utilizatorilor care au acces la Frigate și la această cameră.", + "custom": "Timestamp personalizat", + "button": "Partajează URL-ul cu timestamp", + "shareTitle": "Timestamp review Frigate: {{camera}}" } }, "export": { @@ -86,19 +94,80 @@ "toast": { "success": "Exportul a început cu succes. Vizualizați fișierul pe pagina de exporturi.", "error": { - "failed": "Eroare la pornirea exportului: {{error}}", + "failed": "Nu s-a putut adăuga exportul în coadă: {{error}}", "endTimeMustAfterStartTime": "Ora de sfârșit trebuie să fie după ora de început", "noVaildTimeSelected": "Nu a fost selectat un interval de timp valid" }, - "view": "Vizualizează" + "view": "Vizualizează", + "queued": "Export pus la coadă. Vezi progresul pe pagina de exporturi.", + "batchSuccess_one": "A început 1 export. Se deschide cazul acum.", + "batchSuccess_few": "Au început {{count}} exporturi. Se deschide cazul acum.", + "batchSuccess_other": "Au început {{count}} de exporturi. Se deschide cazul acum.", + "batchPartial": "Au început {{successful}} din {{total}} exporturi. Camere eșuate: {{failedCameras}}", + "batchFailed": "Eșec la pornirea a {{total}} exporturi. Camere eșuate: {{failedCameras}}", + "batchQueuedSuccess_one": "1 export pus la coadă. Se deschide cazul acum.", + "batchQueuedSuccess_few": "{{count}} exporturi puse la coadă. Se deschide cazul acum.", + "batchQueuedSuccess_other": "{{count}} de exporturi puse la coadă. Se deschide cazul acum.", + "batchQueuedPartial": "S-au pus la coadă {{successful}} din {{total}} exporturi. Camere eșuate: {{failedCameras}}", + "batchQueueFailed": "Eșec la punerea la coadă a {{total}} exporturi. Camere eșuate: {{failedCameras}}" }, "fromTimeline": { "saveExport": "Salvează exportul", - "previewExport": "Previzualizează exportul" + "previewExport": "Previzualizează exportul", + "queueingExport": "Se adaugă exportul la coadă...", + "useThisRange": "Folosește acest interval" }, "case": { "label": "Caz", - "placeholder": "Selectează caz" + "placeholder": "Selectează caz", + "newCaseOption": "Creează un caz nou", + "newCaseNamePlaceholder": "Nume caz nou", + "newCaseDescriptionPlaceholder": "Descrierea cazului", + "nonAdminHelp": "Un caz nou va fi creat pentru aceste exporturi." + }, + "queueing": "Se adaugă exportul la coadă...", + "tabs": { + "export": "O singură cameră", + "multiCamera": "Mai multe camere" + }, + "multiCamera": { + "timeRange": "Interval de timp", + "selectFromTimeline": "Selectează din timeline", + "cameraSelection": "Camere", + "cameraSelectionHelp": "Camerele cu obiecte urmărite în acest interval de timp sunt pre-selectate", + "checkingActivity": "Se verifică activitatea camerei...", + "noCameras": "Nu sunt camere disponibile", + "detectionCount_one": "1 obiect urmărit", + "detectionCount_few": "{{count}} obiecte urmărite", + "detectionCount_other": "{{count}} de obiecte urmărite", + "nameLabel": "Nume export", + "namePlaceholder": "Nume de bază opțional pentru aceste exporturi", + "queueingButton": "Se adaugă exporturile la coadă...", + "exportButton_one": "Exportă 1 cameră", + "exportButton_few": "Exportă {{count}} camere", + "exportButton_other": "Exportă {{count}} de camere" + }, + "multi": { + "title_one": "Exportă 1 recenzie", + "title_few": "Exportă {{count}} recenzii", + "title_other": "Exportă {{count}} de recenzii", + "description": "Exportă fiecare recenzie selectată. Toate exporturile vor fi grupate sub un singur caz.", + "descriptionNoCase": "Exportă fiecare recenzie selectată.", + "caseNamePlaceholder": "Export recenzie - {{date}}", + "exportButton_one": "Exportă 1 recenzie", + "exportButton_few": "Exportă {{count}} recenzii", + "exportButton_other": "Exportă {{count}} de recenzii", + "exportingButton": "Se exportă...", + "toast": { + "started_one": "A început 1 export. Se deschide cazul acum.", + "started_few": "Au început {{count}} exporturi. Se deschide cazul acum.", + "started_other": "Au început {{count}} de exporturi. Se deschide cazul acum.", + "startedNoCase_one": "A început 1 export.", + "startedNoCase_few": "Au început {{count}} exporturi.", + "startedNoCase_other": "Au început {{count}} de exporturi.", + "partial": "Au început {{successful}} din {{total}} exporturi. Au eșuat: {{failedItems}}", + "failed": "Eșec la pornirea a {{total}} exporturi. Au eșuat: {{failedItems}}" + } } }, "streaming": { diff --git a/web/public/locales/ro/components/player.json b/web/public/locales/ro/components/player.json index bbd8ceab88..ebcad44a25 100644 --- a/web/public/locales/ro/components/player.json +++ b/web/public/locales/ro/components/player.json @@ -4,7 +4,8 @@ "noPreviewFoundFor": "Nu există previzualizari pentru {{cameraName}}", "submitFrigatePlus": { "title": "Trimiteti acest cadru catre Frigate+?", - "submit": "Trimite" + "submit": "Trimite", + "previewError": "Nu s-a putut încărca previzualizarea snapshot-ului. S-ar putea ca înregistrarea să nu fie disponibilă în acest moment." }, "livePlayerRequiredIOSVersion": "iOS 17.1 sau mai recent este necesar pentru acest tip de stream live.", "streamOffline": { diff --git a/web/public/locales/ro/config/cameras.json b/web/public/locales/ro/config/cameras.json index 01c256adf4..918598b0ad 100644 --- a/web/public/locales/ro/config/cameras.json +++ b/web/public/locales/ro/config/cameras.json @@ -13,7 +13,7 @@ "description": "Activată" }, "audio": { - "label": "Evenimente audio", + "label": "Detecție audio", "description": "Setări pentru detectarea evenimentelor bazate pe sunet pentru această cameră.", "enabled": { "label": "Activare detecție audio", @@ -485,6 +485,10 @@ "hwaccel_args": { "label": "Argumente hwaccel export", "description": "Argumente de accelerare hardware pentru operațiunile de export/transcodare." + }, + "max_concurrent": { + "description": "Numărul maxim de sarcini de export de procesat în același timp.", + "label": "Număr maxim de exporturi simultane" } }, "preview": { diff --git a/web/public/locales/ro/config/global.json b/web/public/locales/ro/config/global.json index d07e3bab4b..f7207df758 100644 --- a/web/public/locales/ro/config/global.json +++ b/web/public/locales/ro/config/global.json @@ -1,6 +1,6 @@ { "audio": { - "label": "Evenimente audio", + "label": "Detecție audio", "enabled": { "label": "Activare detecție audio", "description": "Activează sau dezactivează detecția audio pentru toate camerele." @@ -594,6 +594,10 @@ "hwaccel_args": { "label": "Argumente hwaccel export", "description": "Argumente de accelerare hardware pentru operațiunile de export/transcodare." + }, + "max_concurrent": { + "description": "Numărul maxim de sarcini de export de procesat în același timp.", + "label": "Număr maxim de exporturi simultane" } }, "preview": { @@ -1161,8 +1165,8 @@ "description": "Activează monitorizarea lățimii de bandă a rețelei pe proces pentru procesele ffmpeg ale camerelor și detectoare (necesită capabilități)." }, "intel_gpu_device": { - "label": "Dispozitiv SR-IOV", - "description": "Identificator de dispozitiv folosit când GPU-urile Intel sunt tratate ca SR-IOV pentru a repara statisticile GPU." + "label": "Dispozitiv GPU Intel", + "description": "Adresa magistralei PCI sau calea dispozitivului DRM (ex./dev/dri/card1) folosită pentru a fixa statisticile GPU Intel la un anumit dispozitiv când sunt prezente mai multe." } }, "version_check": { @@ -2161,7 +2165,7 @@ }, "roles": { "label": "Roluri", - "description": "Roluri GenAI (unelte, viziune, înglobări); un furnizor per rol." + "description": "Roluri GenAI (chat, descrieri, înglobări); un furnizor per rol." }, "provider_options": { "label": "Opțiuni furnizor", diff --git a/web/public/locales/ro/views/chat.json b/web/public/locales/ro/views/chat.json new file mode 100644 index 0000000000..b87ef2145f --- /dev/null +++ b/web/public/locales/ro/views/chat.json @@ -0,0 +1,46 @@ +{ + "documentTitle": "Chat - Frigate", + "title": "Chat Frigate", + "subtitle": "Asistentul tău AI pentru gestionarea camerelor și informații", + "placeholder": "Întreabă orice...", + "error": "Ceva a mers prost. Te rog încearcă din nou.", + "processing": "Procesare...", + "toolsUsed": "Folosit: {{tools}}", + "showTools": "Arată uneltele ({{count}})", + "hideTools": "Ascunde uneltele", + "call": "Apelează", + "result": "Rezultat", + "arguments": "Argumente:", + "response": "Răspuns:", + "attachment_chip_label": "{{label}} pe {{camera}}", + "attachment_chip_remove": "Elimină atașamentul", + "open_in_explore": "Deschide în Explorare", + "attach_event_aria": "Atașează evenimentul {{eventId}}", + "attachment_picker_paste_label": "Sau lipește ID-ul evenimentului", + "attachment_picker_attach": "Atașează", + "attachment_picker_placeholder": "Atașează un eveniment", + "quick_reply_find_similar": "Găsește apariții similare", + "quick_reply_tell_me_more": "Spune-mi mai multe despre asta", + "quick_reply_when_else": "Când a mai fost văzut?", + "quick_reply_find_similar_text": "Găsește apariții similare cu aceasta.", + "quick_reply_tell_me_more_text": "Spune-mi mai multe despre acesta.", + "quick_reply_when_else_text": "Când a mai fost văzut acesta?", + "anchor": "Referință", + "similarity_score": "Similaritate", + "no_similar_objects_found": "Nu au fost găsite obiecte similare.", + "semantic_search_required": "Căutarea semantică trebuie să fie activată pentru a găsi obiecte similare.", + "send": "Trimite", + "suggested_requests": "Încearcă să întrebi:", + "starting_requests": { + "show_recent_events": "Arată evenimentele recente", + "show_camera_status": "Arată starea camerei", + "recap": "Ce s-a întâmplat cât am fost plecat?", + "watch_camera": "Urmărește o cameră pentru activitate" + }, + "starting_requests_prompts": { + "show_recent_events": "Arată-mi evenimentele recente din ultima oră", + "show_camera_status": "Care este starea actuală a camerelor mele?", + "recap": "Ce s-a întâmplat cât am fost plecat?", + "watch_camera": "Urmărește ușa din față și anunță-mă dacă apare cineva" + } +} diff --git a/web/public/locales/ro/views/events.json b/web/public/locales/ro/views/events.json index 455257a92d..5adb14169c 100644 --- a/web/public/locales/ro/views/events.json +++ b/web/public/locales/ro/views/events.json @@ -25,7 +25,9 @@ }, "documentTitle": "Revizuieste - Frigate", "recordings": { - "documentTitle": "Inregistrari - frigate" + "documentTitle": "Inregistrari - frigate", + "invalidSharedLink": "Nu s-a putut deschide linkul înregistrării cu timestamp din cauza unei erori de parsare.", + "invalidSharedCamera": "Nu s-a putut deschide linkul înregistrării cu timestamp din cauza unei camere necunoscute sau neautorizate." }, "calendarFilter": { "last24Hours": "Ultimele 24 de ore" @@ -39,7 +41,7 @@ "camera": "Camera foto", "detections": "Detecții", "detected": "detectat", - "selected_one": "{{count}} selectate", + "selected_one": "{{count}} selectat", "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 5d4057b0b2..4cb9f3c7ff 100644 --- a/web/public/locales/ro/views/explore.json +++ b/web/public/locales/ro/views/explore.json @@ -289,7 +289,10 @@ "zones": "Zone", "ratio": "Raport", "area": "Aria", - "score": "Scor" + "score": "Scor", + "computedScore": "Scor calculat", + "topScore": "Cel mai mare scor", + "toggleAdvancedScores": "Comută scorurile avansate" } }, "annotationSettings": { diff --git a/web/public/locales/ro/views/exports.json b/web/public/locales/ro/views/exports.json index 1b1e0b2d87..be984037f2 100644 --- a/web/public/locales/ro/views/exports.json +++ b/web/public/locales/ro/views/exports.json @@ -14,7 +14,9 @@ "toast": { "error": { "renameExportFailed": "Redenumirea exportului a eșuat: {{errorMessage}}", - "assignCaseFailed": "Actualizarea atribuirii cazului a eșuat: {{errorMessage}}" + "assignCaseFailed": "Actualizarea atribuirii cazului a eșuat: {{errorMessage}}", + "caseSaveFailed": "Salvarea cazului a eșuat: {{errorMessage}}", + "caseDeleteFailed": "Ștergerea cazului a eșuat: {{errorMessage}}" } }, "tooltip": { @@ -22,7 +24,8 @@ "downloadVideo": "Descarcă video", "editName": "Editează numele", "deleteExport": "Șterge exportul", - "assignToCase": "Adaugă la un caz" + "assignToCase": "Adaugă la un caz", + "removeFromCase": "Elimină din caz" }, "headings": { "cases": "Cazuri", @@ -35,5 +38,91 @@ "newCaseOption": "Creează un caz nou", "nameLabel": "Numele cazului", "descriptionLabel": "Descriere" + }, + "toolbar": { + "newCase": "Caz nou", + "addExport": "Adaugă export", + "editCase": "Editează cazul", + "deleteCase": "Șterge cazul" + }, + "deleteCase": { + "label": "Șterge cazul", + "desc": "Sigur vrei să ștergi {{caseName}}?", + "descKeepExports": "Exporturile vor rămâne disponibile ca exporturi necategorizate.", + "descDeleteExports": "Toate exporturile din acest caz vor fi șterse definitiv.", + "deleteExports": "Șterge și exporturile" + }, + "caseCard": { + "emptyCase": "Niciun export încă" + }, + "jobCard": { + "defaultName": "Export {{camera}}", + "queued": "În așteptare", + "running": "În rulare", + "preparing": "Pregătire", + "copying": "Copiere", + "encoding": "Codare", + "encodingRetry": "Codare (reîncercare)", + "finalizing": "Finalizare" + }, + "caseView": { + "noDescription": "Fără descriere", + "createdAt": "Creat {{value}}", + "exportCount_one": "1 export", + "exportCount_other": "{{count}} exporturi", + "cameraCount_one": "1 cameră", + "cameraCount_other": "{{count}} camere", + "showMore": "Afișează mai mult", + "showLess": "Afișează mai puțin", + "emptyTitle": "Acest caz este gol", + "emptyDescription": "Adaugă exporturile necategorizate existente pentru a menține cazul organizat.", + "emptyDescriptionNoExports": "Nu există încă exporturi necategorizate disponibile pentru a fi adăugate." + }, + "caseEditor": { + "createTitle": "Creează caz", + "editTitle": "Editează cazul", + "namePlaceholder": "Nume caz", + "descriptionPlaceholder": "Adaugă note sau context pentru acest caz" + }, + "addExportDialog": { + "title": "Adaugă export la {{caseName}}", + "searchPlaceholder": "Caută exporturi necategorizate", + "empty": "Niciun export necategorizat nu se potrivește cu această căutare.", + "addButton_one": "Adaugă 1 export", + "addButton_other": "Adaugă {{count}} exporturi", + "adding": "Se adaugă..." + }, + "selected_one": "{{count}} selectat", + "selected_other": "{{count}} selectate", + "bulkActions": { + "addToCase": "Adaugă la caz", + "moveToCase": "Mută la caz", + "removeFromCase": "Elimină din caz", + "delete": "Șterge", + "deleteNow": "Șterge acum" + }, + "bulkDelete": { + "title": "Șterge exporturile", + "desc_one": "Sigur vrei să ștergi {{count}} export?", + "desc_other": "Sigur vrei să ștergi {{count}} exporturi?" + }, + "bulkRemoveFromCase": { + "title": "Elimină din caz", + "desc_one": "Elimini {{count}} export din acest caz?", + "desc_other": "Elimini {{count}} exporturi din acest caz?", + "descKeepExports": "Exporturile vor fi mutate la necategorizate.", + "descDeleteExports": "Exporturile vor fi șterse definitiv.", + "deleteExports": "Șterge exporturile în schimb" + }, + "bulkToast": { + "success": { + "delete": "Exporturile au fost șterse cu succes", + "reassign": "Alocarea cazului a fost actualizată cu succes", + "remove": "Exporturile au fost eliminate din caz cu succes" + }, + "error": { + "deleteFailed": "Ștergerea exporturilor a eșuat: {{errorMessage}}", + "reassignFailed": "Actualizarea alocării cazului a eșuat: {{errorMessage}}" + } } } diff --git a/web/public/locales/ro/views/live.json b/web/public/locales/ro/views/live.json index 6b8c8c979e..59f9c34060 100644 --- a/web/public/locales/ro/views/live.json +++ b/web/public/locales/ro/views/live.json @@ -70,7 +70,8 @@ }, "recording": { "enable": "Activează înregistrarea", - "disable": "Dezactivează înregistrarea" + "disable": "Dezactivează înregistrarea", + "disabledInConfig": "Înregistrarea trebuie mai întâi activată în Setări pentru această cameră." }, "snapshots": { "disable": "Dezactivează snapshoturile", diff --git a/web/public/locales/ro/views/motionSearch.json b/web/public/locales/ro/views/motionSearch.json new file mode 100644 index 0000000000..0f12367484 --- /dev/null +++ b/web/public/locales/ro/views/motionSearch.json @@ -0,0 +1,77 @@ +{ + "documentTitle": "Căutare mișcare - Frigate", + "title": "Căutare mișcare", + "description": "Desenează un poligon pentru a defini regiunea de interes și specifică un interval de timp pentru a căuta schimbări de mișcare în acea regiune.", + "selectCamera": "Căutarea de mișcare se încarcă", + "startSearch": "Începe căutarea", + "searchStarted": "Căutarea a început", + "searchCancelled": "Căutare anulată", + "cancelSearch": "Anulează", + "searching": "Căutare în curs.", + "searchComplete": "Căutare finalizată", + "noResultsYet": "Rulează o căutare pentru a găsi schimbări de mișcare în regiunea selectată", + "noChangesFound": "Nu au fost detectate schimbări de pixeli în regiunea selectată", + "changesFound_one": "Am găsit {{count}} schimbare de mișcare", + "changesFound_few": "Am găsit {{count}} schimbări de mișcare", + "changesFound_other": "Am găsit {{count}} de schimbări de mișcare", + "framesProcessed": "{{count}} cadre procesate", + "jumpToTime": "Sari la acest timp", + "results": "Rezultate", + "showSegmentHeatmap": "Hartă termică", + "newSearch": "Căutare nouă", + "clearResults": "Curăță rezultatele", + "clearROI": "Curăță poligonul", + "polygonControls": { + "points_one": "{{count}} punct", + "points_few": "{{count}} puncte", + "points_other": "{{count}} de puncte", + "undo": "Anulează ultimul punct", + "reset": "Resetează poligonul" + }, + "motionHeatmapLabel": "Harta termică a mișcării", + "dialog": { + "title": "Căutare mișcare", + "cameraLabel": "Cameră", + "previewAlt": "Previzualizarea camerei pentru {{camera}}" + }, + "timeRange": { + "title": "Interval de căutare", + "start": "Timp de început", + "end": "Timp de sfârșit" + }, + "settings": { + "title": "Setări de căutare", + "parallelMode": "Mod paralel", + "parallelModeDesc": "Scanează mai multe segmente de înregistrare în același timp (mai rapid, dar consumă semnificativ mai mult procesorul)", + "threshold": "Prag de sensibilitate", + "thresholdDesc": "Valorile mai mici detectează schimbări mai mici (1-255)", + "minArea": "Arie minimă de schimbare", + "minAreaDesc": "Procentul minim din regiunea de interes care trebuie să se schimbe pentru a fi considerat semnificativ", + "frameSkip": "Omitere cadre", + "frameSkipDesc": "Procesează fiecare al N-lea cadru. Setează asta la rata de cadre a camerei tale pentru a procesa un cadru pe secundă (ex. 5 pentru o cameră de 5 FPS, 30 pentru o cameră de 30 FPS). Valorile mai mari vor fi mai rapide, dar pot rata evenimente scurte de mișcare.", + "maxResults": "Rezultate maxime", + "maxResultsDesc": "Oprește-te după acest număr de marcaje de timp potrivite" + }, + "errors": { + "noCamera": "Te rog selectează o cameră", + "noROI": "Te rog desenează o regiune de interes", + "noTimeRange": "Te rog selectează un interval de timp", + "invalidTimeRange": "Timpul de sfârșit trebuie să fie după timpul de început", + "searchFailed": "Căutarea a eșuat: {{message}}", + "polygonTooSmall": "Poligonul trebuie să aibă cel puțin 3 puncte", + "unknown": "Eroare necunoscută" + }, + "changePercentage": "{{percentage}}% schimbat", + "metrics": { + "title": "Metrici de căutare", + "segmentsScanned": "Segmente scanate", + "segmentsProcessed": "Procesat", + "segmentsSkippedInactive": "Omis (fără activitate)", + "segmentsSkippedHeatmap": "Omis (fără suprapunere ROI)", + "fallbackFullRange": "Scanare completă de rezervă", + "framesDecoded": "Cadre decodate", + "wallTime": "Timp de căutare", + "segmentErrors": "Erori segment", + "seconds": "{{seconds}}s" + } +} diff --git a/web/public/locales/ro/views/replay.json b/web/public/locales/ro/views/replay.json new file mode 100644 index 0000000000..b3c854f742 --- /dev/null +++ b/web/public/locales/ro/views/replay.json @@ -0,0 +1,59 @@ +{ + "title": "Reluare de depanare", + "description": "Redă înregistrările camerei pentru depanare. Lista de obiecte arată un rezumat decalat în timp al obiectelor detectate, iar tab-ul Mesaje arată un flux de mesaje interne ale Frigate din înregistrarea redată.", + "websocket_messages": "Mesaje", + "dialog": { + "title": "Pornește reluarea de depanare", + "description": "Creează o cameră temporară de reluare care rulează în buclă înregistrări istorice pentru depanarea problemelor de detecție și urmărire a obiectelor. Camera de reluare va avea aceeași configurație de detecție ca și camera sursă. Alege un interval de timp pentru a începe.", + "camera": "Cameră sursă", + "timeRange": "Interval de timp", + "preset": { + "1m": "Ultimul minut", + "5m": "Ultimele 5 minute", + "timeline": "Din cronologie", + "custom": "Personalizat" + }, + "startButton": "Începe reluarea", + "selectFromTimeline": "Selectează", + "starting": "Pornire reluare...", + "startLabel": "Început", + "endLabel": "Sfârșit", + "toast": { + "error": "Pornirea reluării de depanare a eșuat: {{error}}", + "alreadyActive": "O sesiune de reluare este deja activă", + "stopError": "Oprirea reluării de depanare a eșuat: {{error}}", + "goToReplay": "Mergi la reluare" + } + }, + "page": { + "noSession": "Nicio sesiune de reluare de depanare activă", + "noSessionDesc": "Pornește o reluare de depanare din vizualizarea Istoric dând click pe butonul Acțiuni din bara de instrumente și alegând Reluare depanare.", + "goToRecordings": "Mergi la istoric", + "preparingClip": "Pregătire clip…", + "preparingClipDesc": "Frigate îmbină înregistrările pentru intervalul de timp selectat. Acest lucru poate dura un minut pentru intervale mai mari.", + "startingCamera": "Pornire reluare depanare…", + "startError": { + "title": "Pornirea reluării de depanare a eșuat", + "back": "Înapoi la istoric" + }, + "sourceCamera": "Camera sursă", + "replayCamera": "Camera de reluare", + "initializingReplay": "Inițializare reluare depanare...", + "stoppingReplay": "Oprire reluare depanare...", + "stopReplay": "Oprește reluarea", + "confirmStop": { + "title": "Oprești reluarea de depanare?", + "description": "Aceasta va opri sesiunea și va șterge toate datele temporare. Ești sigur?", + "confirm": "Oprește reluarea", + "cancel": "Anulează" + }, + "activity": "Activitate", + "objects": "Listă de obiecte", + "audioDetections": "Detecții audio", + "noActivity": "Nicio activitate detectată", + "activeTracking": "Urmărire activă", + "noActiveTracking": "Nicio urmărire activă", + "configuration": "Configurație", + "configurationDesc": "Ajustează setările de detecție a mișcării și urmărire a obiectelor pentru camera de reluare de depanare. Nicio modificare nu este salvată în fișierul tău de configurare Frigate." + } +} diff --git a/web/public/locales/ro/views/settings.json b/web/public/locales/ro/views/settings.json index 632f6137eb..4babba1d9e 100644 --- a/web/public/locales/ro/views/settings.json +++ b/web/public/locales/ro/views/settings.json @@ -44,7 +44,7 @@ "globalMotion": "Detecție mișcare", "globalObjects": "Obiecte", "globalReview": "Recenzie", - "globalAudioEvents": "Evenimente audio", + "globalAudioEvents": "Detecție audio", "globalLivePlayback": "Redare live", "globalTimestampStyle": "Stil timestamp", "systemDatabase": "Bază de date", @@ -74,7 +74,7 @@ "cameraMotion": "Detecție mișcare", "cameraObjects": "Obiecte", "cameraConfigReview": "Recenzie", - "cameraAudioEvents": "Evenimente audio", + "cameraAudioEvents": "Detecție audio", "cameraAudioTranscription": "Transcriere audio", "cameraNotifications": "Notificări", "cameraLivePlayback": "Redare live", @@ -1282,7 +1282,8 @@ }, "hikvision": { "substreamWarning": "Substream 1 este limitat la o rezoluție mică. Multe camere Hikvision suportă substream-uri adiționale care trebuie activate din setările camerei. Se recomandă verificarea și utilizarea acestora." - } + }, + "resolutionUnknown": "Rezoluția acestui stream nu a putut fi sondată. Ar trebui să setezi manual rezoluția de detecție în Setări sau în configurația ta." } } }, @@ -1299,7 +1300,13 @@ "enableDesc": "Dezactivează temporar o cameră până la repornirea Frigate. Dezactivarea oprește procesarea stream-urilor pentru această cameră. Detecția, înregistrarea și depanarea vor fi indisponibile.
    Notă: Acest lucru nu dezactivează restream-urile go2rtc.", "disableLabel": "Camere dezactivate", "disableDesc": "Activează o cameră care este ascunsă în interfață și dezactivată în configurație. Este necesară repornirea Frigate după activare.", - "enableSuccess": "Am activat {{cameraName}} în configurație. Repornește Frigate pentru a aplica modificările." + "enableSuccess": "Am activat {{cameraName}} în configurație. Repornește Frigate pentru a aplica modificările.", + "friendlyName": { + "edit": "Editează numele afișat al camerei", + "title": "Editează numele afișat", + "description": "Setează numele afișat pentru această cameră în întreaga interfață Frigate. Lasă necompletat pentru a folosi ID-ul camerei.", + "rename": "Redenumește" + } }, "cameraConfig": { "add": "Adaugă Cameră", @@ -1349,6 +1356,14 @@ "inherit": "Moștenire", "enabled": "Activat", "disabled": "Dezactivat" + }, + "cameraType": { + "title": "Tip cameră", + "label": "Tip cameră", + "description": "Setează tipul pentru fiecare cameră. Camerele LPR dedicate sunt camere cu un singur scop, cu zoom optic puternic pentru a captura plăcuțele de înmatriculare ale vehiculelor aflate la distanță. Majoritatea camerelor ar trebui să folosească tipul normal de cameră, cu excepția cazului în care camera este special pentru LPR și are o vedere strâns focalizată pe plăcuțele de înmatriculare.", + "normal": "Normal", + "dedicatedLpr": "LPR dedicat", + "saveSuccess": "Tipul camerei a fost actualizat pentru {{cameraName}}. Repornește Frigate pentru a aplica modificările." } }, "cameraReview": { @@ -1661,7 +1676,16 @@ "empty": "Nicio etichetă disponibilă", "allNonAlertDetections": "Toată activitatea fără alertă va fi inclusă ca detecții." }, - "addCustomLabel": "Adaugă etichetă personalizată..." + "addCustomLabel": "Adaugă etichetă personalizată...", + "genaiModel": { + "placeholder": "Selectează modelul…", + "search": "Caută modele…", + "noModels": "Niciun model disponibil" + }, + "knownPlates": { + "namePlaceholder": "ex. Mașina soției", + "platePlaceholder": "Număr plăcuță sau regex" + } }, "globalConfig": { "title": "Configurare Globală", @@ -1706,7 +1730,22 @@ "overriddenGlobal": "Suprascris (global)", "overriddenGlobalTooltip": "Această cameră suprascrie setările globale de configurare din această secțiune", "overriddenBaseConfig": "Suprascris (configurația de bază)", - "overriddenBaseConfigTooltip": "Profilul {{profile}} suprascrie setările de configurare din această secțiune" + "overriddenBaseConfigTooltip": "Profilul {{profile}} suprascrie setările de configurare din această secțiune", + "overriddenInCameras": { + "label_one": "Suprascris în {{count}} cameră", + "label_few": "Suprascris în {{count}} camere", + "label_other": "Suprascris în {{count}} de camere", + "tooltip_one": "{{count}} cameră suprascrie valorile din această secțiune. Click pentru a vedea detaliile.", + "tooltip_few": "{{count}} camere suprascriu valorile din această secțiune. Click pentru a vedea detaliile.", + "tooltip_other": "{{count}} de camere suprascriu valorile din această secțiune. Click pentru a vedea detaliile.", + "heading_one": "Această secțiune globală are câmpuri care sunt suprascrise în {{count}} cameră.", + "heading_few": "Această secțiune globală are câmpuri care sunt suprascrise în {{count}} camere.", + "heading_other": "Această secțiune globală are câmpuri care sunt suprascrise în {{count}} de camere.", + "othersField_one": "{{count}} alta", + "othersField_few": "{{count}} alte", + "othersField_other": "{{count}} de alte", + "profilePrefix": "Profil {{profile}}: {{fields}}" + } }, "profiles": { "title": "Profile", @@ -1807,7 +1846,8 @@ "review": { "recordDisabled": "Înregistrarea este dezactivată, elementele de revizuire nu vor fi generate.", "detectDisabled": "Detecția obiectelor este dezactivată. Elementele de revizuire necesită obiecte detectate pentru a categorisi alertele și detecțiile.", - "allNonAlertDetections": "Toată activitatea fără alertă va fi inclusă ca detecții." + "allNonAlertDetections": "Toată activitatea fără alertă va fi inclusă ca detecții.", + "genaiImageSourceRecordingsRecordDisabled": "Sursa imaginii este setată pe 'recordings', dar înregistrarea este dezactivată. Frigate va reveni la imaginile de previzualizare." }, "audio": { "noAudioRole": "Niciun flux nu are rolul audio definit. Trebuie să activați rolul audio pentru ca detecția audio să funcționeze." @@ -1816,15 +1856,18 @@ "audioDetectionDisabled": "Detecția audio nu este activată pentru această cameră. Transcrierea audio necesită ca detecția audio să fie activă." }, "detect": { - "fpsGreaterThanFive": "Setarea cadrelor pe secundă pentru detecție la o valoare mai mare de 5 nu este recomandată." + "fpsGreaterThanFive": "Setarea FPS-ului de detecție mai mare de 5 nu este recomandată. Valorile mai mari pot cauza probleme de performanță și nu vor oferi niciun beneficiu.", + "disabled": "Detecția de obiecte este dezactivată. Snapshot-urile, elementele de revizuire și îmbogățirile precum recunoașterea facială, recunoașterea plăcuțelor de înmatriculare și AI-ul generativ nu vor funcționa." }, "faceRecognition": { - "globalDisabled": "Recunoașterea facială nu este activată la nivel global. Activați-o în setările globale pentru ca recunoașterea facială la nivel de cameră să funcționeze.", - "personNotTracked": "Recunoașterea facială necesită urmărirea obiectului „person”. Asigurați-vă că „person” este în lista de urmărire a obiectelor." + "globalDisabled": "Îmbogățirea pentru recunoaștere facială trebuie activată pentru ca funcțiile de recunoaștere facială să funcționeze pe această cameră.", + "personNotTracked": "Recunoașterea facială necesită ca obiectul 'person' să fie urmărit. Activează 'person' în Obiecte pentru această cameră.", + "modelSizeLarge": "Modelul 'large' necesită un GPU sau NPU pentru o performanță rezonabilă. Folosește 'small' pe sistemele doar cu CPU." }, "lpr": { - "globalDisabled": "Recunoașterea plăcuțelor de înmatriculare nu este activată la nivel global. Activați-o în setările globale pentru ca recunoașterea la nivel de cameră să funcționeze.", - "vehicleNotTracked": "Recunoașterea plăcuțelor de înmatriculare necesită ca „car” sau „motorcycle” să fie urmărite." + "globalDisabled": "Îmbogățirea pentru recunoașterea plăcuțelor de înmatriculare trebuie activată pentru ca funcțiile LPR să funcționeze pe această cameră.", + "vehicleNotTracked": "Recunoașterea plăcuțelor de înmatriculare necesită ca „car” sau „motorcycle” să fie urmărite.", + "modelSizeLarge": "Modelul 'large' este optimizat pentru plăcuțele de înmatriculare pe mai multe rânduri. Modelul 'small' oferă o performanță mai bună decât 'large' și ar trebui folosit cu excepția cazului în care regiunea ta folosește formate de plăcuțe pe mai multe rânduri." }, "record": { "noRecordRole": "Niciun flux nu are rolul de înregistrare definit. Înregistrarea nu va funcționa." @@ -1838,6 +1881,12 @@ "detectors": { "mixedTypes": "Toți detectorii trebuie să folosească același tip. Șterge detectorii existenți pentru a folosi un alt tip.", "mixedTypesSuggestion": "Toți detectorii trebuie să folosească același tip. Șterge detectorii existenți sau selectează {{type}}." + }, + "objects": { + "genaiNoDescriptionsProvider": "Trebuie să configurezi un furnizor GenAI cu rolul 'descriptions' pentru ca descrierile să fie generate." + }, + "semanticSearch": { + "jinav2SmallModelSize": "Dimensiunea 'small' cu modelul Jina V2 are un cost ridicat de RAM și inferență. Modelul 'large' cu un GPU dedicat este recomandat." } } } diff --git a/web/public/locales/ro/views/system.json b/web/public/locales/ro/views/system.json index e829edd602..ef285da8b6 100644 --- a/web/public/locales/ro/views/system.json +++ b/web/public/locales/ro/views/system.json @@ -241,6 +241,9 @@ "expectedFps": "FPS așteptat", "reconnectsLastHour": "Reconectări (ultima oră)", "stallsLastHour": "Blocaje (ultima oră)" + }, + "noCameras": { + "title": "Nicio cameră găsită" } }, "stats": { diff --git a/web/public/locales/ru/common.json b/web/public/locales/ru/common.json index 54e214855d..db9390ed80 100644 --- a/web/public/locales/ru/common.json +++ b/web/public/locales/ru/common.json @@ -260,7 +260,8 @@ "setPassword": "Установить пароль" }, "appearance": "Внешний вид", - "classification": "Распознование" + "classification": "Распознование", + "profiles": "Профили" }, "pagination": { "label": "пагинация", diff --git a/web/public/locales/ru/components/dialog.json b/web/public/locales/ru/components/dialog.json index b935670c2e..562e8bc088 100644 --- a/web/public/locales/ru/components/dialog.json +++ b/web/public/locales/ru/components/dialog.json @@ -6,7 +6,8 @@ "title": "Frigate перезапускается", "content": "Эта страница перезагрузится через {{countdown}} сек.", "button": "Принудительная перезагрузка" - } + }, + "description": "Это перезагрузки перезагрузит Frigate." }, "explore": { "plus": { @@ -76,6 +77,10 @@ "fromTimeline": { "saveExport": "Сохранить экспорт", "previewExport": "Предпросмотр экспорта" + }, + "case": { + "label": "Случай", + "placeholder": "Выберите случай" } }, "streaming": { diff --git a/web/public/locales/ru/config/global.json b/web/public/locales/ru/config/global.json index 5e7de1ab3b..64b4958111 100644 --- a/web/public/locales/ru/config/global.json +++ b/web/public/locales/ru/config/global.json @@ -83,5 +83,59 @@ "label": "Конфигурация стационарных объектов", "description": "Настройки для обнаружения и управления объектами, которые остаются неподвижными в течение определенного периода времени." } + }, + "version": { + "label": "Текущая версия конфигурации", + "description": "Число или строка версии текущей конфигурации, которая может использоваться для определения миграций или форматирования изменений." + }, + "safe_mode": { + "label": "Безопасный режим", + "description": "Когда включено, Frigate запустится в безопасном режиме с ограниченными функциями для поиска неисправностей." + }, + "environment_vars": { + "label": "Переменные окружения", + "description": "Пары ключ/значения для переменных окружения которые необходимо задать для процесса Frigate в Home Assistant OS. Пользователи, которые не исползуют HAOS должны испольовать переменные окружения в Docker." + }, + "logger": { + "label": "Логирование", + "description": "Управляет уровнем логирования по умолчанию и переопределением уровня для каждого компонента.", + "default": { + "label": "Уровень логирования", + "description": "Стандартный глобальный уровень логирования (debug, info, warning, error)." + }, + "logs": { + "label": "Уровень логирования для каждого процесса" + } + }, + "auth": { + "label": "Аутентификация", + "description": "Настройки аутентификации и сеанса, включая параметры cookie и ограничения скорости.", + "enabled": { + "label": "Включить аутентификацию", + "description": "Включить встроенную аутентификацию для интерфейса Frigate." + }, + "reset_admin_password": { + "label": "Сбросить пароль администратора", + "description": "Если выбрано, сбросить пароль администратора при запуске и отобразить новый пароль в логе." + }, + "cookie_name": { + "label": "Имя куки JWT", + "description": "Имя куки, используемого для хранения JWT токена для стандартной аутенфикации." + }, + "cookie_secure": { + "label": "Флаг \"безопасный куки\"", + "description": "Устанавливает флаг \"secure\" на куки аутенфикации; должно быть включено когда используется TLS." + }, + "session_length": { + "label": "Длинна сессии", + "description": "Длина сессии в секундах для JWT сессий." + }, + "refresh_time": { + "label": "Окно обновления сессии", + "description": "Когда сессия в стольки секундах от истечения, обновить её обратно к полной длительности." + }, + "failed_login_rate_limit": { + "label": "Лимит неудавшихся попыток логина" + } } } diff --git a/web/public/locales/ru/config/groups.json b/web/public/locales/ru/config/groups.json index 0967ef424b..d69c495829 100644 --- a/web/public/locales/ru/config/groups.json +++ b/web/public/locales/ru/config/groups.json @@ -1 +1,64 @@ -{} +{ + "audio": { + "global": { + "sensitivity": "Общая чувствительность" + }, + "cameras": { + "detection": "Обнаружение", + "sensitivity": "Чувствительность" + } + }, + "timestamp_style": { + "global": { + "appearance": "Глобальный вид" + }, + "cameras": { + "appearance": "Вид" + } + }, + "motion": { + "global": { + "sensitivity": "Глобальная чувствительность", + "algorithm": "Глобальный алгоритм" + }, + "cameras": { + "sensitivity": "Чувствительность", + "algorithm": "Алгоритм" + } + }, + "detect": { + "global": { + "resolution": "Глобальное разрешение", + "tracking": "Глобальное отслеживание" + }, + "cameras": { + "resolution": "Разрешение", + "tracking": "Отслеживание" + } + }, + "objects": { + "global": { + "tracking": "Глобальное отслеживание", + "filtering": "Глобальная фильтрация" + }, + "cameras": { + "tracking": "Отслеживание", + "filtering": "Фильтрация" + } + }, + "record": { + "global": { + "retention": "Глобальное сохранение данных", + "events": "Глобальные события" + }, + "cameras": { + "retention": "Сохранение данных", + "events": "События" + } + }, + "ffmpeg": { + "cameras": { + "cameraFfmpeg": "Аргументы FFmpeg для этой камеры" + } + } +} diff --git a/web/public/locales/ru/config/validation.json b/web/public/locales/ru/config/validation.json index 0967ef424b..1d16abf7a4 100644 --- a/web/public/locales/ru/config/validation.json +++ b/web/public/locales/ru/config/validation.json @@ -1 +1,31 @@ -{} +{ + "maximum": "Должно быть максимум {{limit}}", + "exclusiveMinimum": "Должно быть больше {{limit}}", + "exclusiveMaximum": "Должно быть не более {{limit}}", + "minLength": "Должно быть не менее {{limit}} символов", + "maxLength": "Должно быть не более {{limit}} символов", + "minItems": "Должно быть не менее {{limit}} значений", + "maxItems": "Должно быть не более {{limit}} значений", + "pattern": "Неправильный формат", + "required": "Это поле обязательно", + "type": "Неправильный тип значения", + "enum": "Должно быть одним из списка разрешенных значений", + "const": "Значение не совпадает с ожидаемой константой", + "uniqueItems": "Все значения должны быть уникальны", + "format": "Неправильный формат", + "additionalProperties": "Неизвестное значение недопустимо", + "oneOf": "Должно совпадать только с одной из разрешенных схем", + "anyOf": "Должно совпадать как минимум с одной из разрешенных схем", + "proxy": { + "header_map": { + "roleHeaderRequired": "Заголовок роли требуется когда маппинги ролей настроены." + } + }, + "ffmpeg": { + "inputs": { + "rolesUnique": "Каждой роли может быть назначен только один входной поток.", + "detectRequired": "Как минимум один входной поток должен быть назначен роли 'detect'.", + "hwaccelDetectOnly": "Только входной поток с ролью detect может настраивать аппаратное ускорение." + } + } +} diff --git a/web/public/locales/ru/views/chat.json b/web/public/locales/ru/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ru/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ru/views/classificationModel.json b/web/public/locales/ru/views/classificationModel.json index 6dbe7a4b16..1213912961 100644 --- a/web/public/locales/ru/views/classificationModel.json +++ b/web/public/locales/ru/views/classificationModel.json @@ -18,11 +18,11 @@ "toast": { "success": { "deletedCategory_one": "Класс удалён", - "deletedCategory_few": "", - "deletedCategory_many": "", + "deletedCategory_few": "Класса удалено", + "deletedCategory_many": "Классов удалено", "deletedImage_one": "Изображения удалены", - "deletedImage_few": "", - "deletedImage_many": "", + "deletedImage_few": "Изображения удалено", + "deletedImage_many": "Изображений удалено", "deletedModel_one": "Успешно удалена {{count}} модель", "deletedModel_few": "Успешно удалены {{count}} модели", "deletedModel_many": "Успешно удалены {{count}} моделей", @@ -30,7 +30,8 @@ "trainedModel": "Модель успешно обучена.", "trainingModel": "Обучение модели успешно запущено.", "updatedModel": "Конфигурация модели успешно обновлена", - "renamedCategory": "Класс успешно переименован в {{name}}" + "renamedCategory": "Класс успешно переименован в {{name}}", + "reclassifiedImage": "Изображение успешно переклассифцировано" }, "error": { "deleteImageFailed": "Не удалось удалить: {{errorMessage}}", diff --git a/web/public/locales/ru/views/events.json b/web/public/locales/ru/views/events.json index a506ea4529..20fa143aed 100644 --- a/web/public/locales/ru/views/events.json +++ b/web/public/locales/ru/views/events.json @@ -16,7 +16,9 @@ "description": "Элементы обзора могут быть созданы для камеры только в том случае, если запись включена для этой камеры." } }, - "timeline": "Таймлайн", + "timeline": { + "label": "Хронология" + }, "timeline.aria": "Выбор таймлайна", "events": { "label": "События", diff --git a/web/public/locales/ru/views/exports.json b/web/public/locales/ru/views/exports.json index c14a578cab..70f8753b6e 100644 --- a/web/public/locales/ru/views/exports.json +++ b/web/public/locales/ru/views/exports.json @@ -2,7 +2,9 @@ "documentTitle": "Экспорт - Frigate", "search": "Поиск", "noExports": "Не найдено файлов экспорта", - "deleteExport": "Удалить экспорт", + "deleteExport": { + "label": "Удалить экспорт" + }, "deleteExport.desc": "Вы уверены, что хотите удалить {{exportName}}?", "editExport": { "title": "Переименовать экспорт", @@ -11,13 +13,27 @@ }, "toast": { "error": { - "renameExportFailed": "Не удалось переименовать экспорт: {{errorMessage}}" + "renameExportFailed": "Не удалось переименовать экспорт: {{errorMessage}}", + "assignCaseFailed": "Не удалось обновить назначение случая: {{errorMessage}}" } }, "tooltip": { "shareExport": "Поделиться экспортом", "downloadVideo": "Скачать видео", "editName": "Изменить название", - "deleteExport": "Удалить экспорт" + "deleteExport": "Удалить экспорт", + "assignToCase": "Добавить в случай" + }, + "headings": { + "cases": "Случаи", + "uncategorizedExports": "Некатегоризированные экспорты" + }, + "caseDialog": { + "title": "Добавить в случай", + "description": "Выберите существующий случай или создайте новый.", + "selectLabel": "Случай", + "newCaseOption": "Создать новый случай", + "nameLabel": "Название случая", + "descriptionLabel": "Описание" } } diff --git a/web/public/locales/ru/views/faceLibrary.json b/web/public/locales/ru/views/faceLibrary.json index 90aa901d18..d3950b3a92 100644 --- a/web/public/locales/ru/views/faceLibrary.json +++ b/web/public/locales/ru/views/faceLibrary.json @@ -13,7 +13,8 @@ "description": { "placeholder": "Введите название коллекции", "addFace": "Добавьте новую коллекцию в библиотеку лиц, загрузив свое первое изображение.", - "invalidName": "Недопустимое имя. Имена могут содержать только буквы, цифры, пробелы, апострофы, подчёркивания и дефисы." + "invalidName": "Недопустимое имя. Имена могут содержать только буквы, цифры, пробелы, апострофы, подчёркивания и дефисы.", + "nameCannotContainHash": "Имя не может содержать #." }, "createFaceLibrary": { "desc": "Создание новой коллекции", diff --git a/web/public/locales/ru/views/live.json b/web/public/locales/ru/views/live.json index 3cf017a943..437a44af61 100644 --- a/web/public/locales/ru/views/live.json +++ b/web/public/locales/ru/views/live.json @@ -1,5 +1,7 @@ { - "documentTitle": "Прямой эфир - Frigate", + "documentTitle": { + "default": "Прямой эфир - Frigate" + }, "documentTitle.withCamera": "{{camera}} - Прямой эфир - Frigate", "lowBandwidthMode": "Экономичный режим", "twoWayTalk": { @@ -15,7 +17,8 @@ "clickMove": { "label": "Кликните в кадре для центрирования камеры", "enable": "Включить перемещение по клику", - "disable": "Отключить перемещение по клику" + "disable": "Отключить перемещение по клику", + "enableWithZoom": "Включить \"клик для перемещения / перетащить для масштабирования\"" }, "left": { "label": "Переместить PTZ-камеру влево" diff --git a/web/public/locales/ru/views/motionSearch.json b/web/public/locales/ru/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ru/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ru/views/replay.json b/web/public/locales/ru/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ru/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ru/views/settings.json b/web/public/locales/ru/views/settings.json index 504c51178a..8eb69ecc43 100644 --- a/web/public/locales/ru/views/settings.json +++ b/web/public/locales/ru/views/settings.json @@ -12,7 +12,11 @@ "notifications": "Настройки уведомлений - Frigate", "enrichments": "Настройки обогащения - Frigate", "cameraManagement": "Управление камерами - Frigate", - "cameraReview": "Настройки просмотра камеры - Frigate" + "cameraReview": "Настройки просмотра камеры - Frigate", + "globalConfig": "Глобальная конфигурация - Frigate", + "cameraConfig": "Настройки камеры - Frigate", + "maintenance": "Обслуживание - Frigate", + "profiles": "Профили - Frigate" }, "menu": { "cameras": "Настройки камеры", @@ -28,7 +32,14 @@ "triggers": "Триггеры", "cameraManagement": "Управление", "cameraReview": "Обзор", - "roles": "Роли" + "roles": "Роли", + "general": "Общее", + "globalConfig": "Глобальная конфигурация", + "system": "Система", + "integrations": "Интеграции", + "uiSettings": "Настройки интерфейса", + "profiles": "Профили", + "globalDetect": "Обнаружение объектов" }, "dialog": { "unsavedChanges": { @@ -1258,5 +1269,11 @@ "success": "Конфигурация классификации обзора была сохранена. Перезапустите Frigate для применения изменений." } } + }, + "button": { + "overriddenGlobal": "Перезаписано (глобально)", + "overriddenGlobalTooltip": "Эта камера перезаписывает глобальные настройки в этой секции", + "overriddenBaseConfig": "Перезаписано (базовые настройки)", + "overriddenBaseConfigTooltip": "Перезаписи настроек профиля {{profile}} в этой секции" } } diff --git a/web/public/locales/ru/views/system.json b/web/public/locales/ru/views/system.json index 0317136326..887678a201 100644 --- a/web/public/locales/ru/views/system.json +++ b/web/public/locales/ru/views/system.json @@ -7,7 +7,8 @@ "logs": { "frigate": "Логи Frigate - Frigate", "go2rtc": "Логи Go2RTC - Frigate", - "nginx": "Логи Nginx - Frigate" + "nginx": "Логи Nginx - Frigate", + "websocket": "Логи сообщений - Frigate" } }, "title": "Система", @@ -33,6 +34,27 @@ "fetchingLogsFailed": "Ошибка получения логов: {{errorMessage}}", "whileStreamingLogs": "Ошибка при потоковой передаче логов: {{errorMessage}}" } + }, + "websocket": { + "label": "Сообщения", + "pause": "Пауза", + "resume": "Продолжить", + "clear": "Очистить", + "filter": { + "all": "Все топики", + "topics": "Топики", + "events": "События", + "classification": "Классификация", + "face_recognition": "Распознавание лиц", + "lpr": "Распознавание номерных знаков", + "camera_activity": "Активность камеры", + "system": "Система", + "camera": "Камера", + "all_cameras": "Все камеры", + "cameras_count_one": "{{count}} камера", + "cameras_count_other": "{{count}} камеры" + }, + "empty": "Сообщения ещё не были получены" } }, "general": { diff --git a/web/public/locales/sk/views/chat.json b/web/public/locales/sk/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sk/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sk/views/motionSearch.json b/web/public/locales/sk/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sk/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sk/views/replay.json b/web/public/locales/sk/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sk/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sl/views/chat.json b/web/public/locales/sl/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sl/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sl/views/motionSearch.json b/web/public/locales/sl/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sl/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sl/views/replay.json b/web/public/locales/sl/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sl/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sq/views/chat.json b/web/public/locales/sq/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sq/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sq/views/motionSearch.json b/web/public/locales/sq/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sq/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sq/views/replay.json b/web/public/locales/sq/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sq/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sr/views/chat.json b/web/public/locales/sr/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sr/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sr/views/motionSearch.json b/web/public/locales/sr/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sr/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sr/views/replay.json b/web/public/locales/sr/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sr/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sv/config/cameras.json b/web/public/locales/sv/config/cameras.json index 0967ef424b..bfa6612cd2 100644 --- a/web/public/locales/sv/config/cameras.json +++ b/web/public/locales/sv/config/cameras.json @@ -1 +1,6 @@ -{} +{ + "label": "Kamera konfiguration", + "name": { + "label": "Kameranamn" + } +} diff --git a/web/public/locales/sv/config/global.json b/web/public/locales/sv/config/global.json index 0967ef424b..f123fa26cf 100644 --- a/web/public/locales/sv/config/global.json +++ b/web/public/locales/sv/config/global.json @@ -1 +1,5 @@ -{} +{ + "version": { + "label": "Nuvarande konfigurationsversion" + } +} diff --git a/web/public/locales/sv/config/groups.json b/web/public/locales/sv/config/groups.json index 0967ef424b..4a81abf8e8 100644 --- a/web/public/locales/sv/config/groups.json +++ b/web/public/locales/sv/config/groups.json @@ -1 +1,7 @@ -{} +{ + "audio": { + "global": { + "sensitivity": "Global känslighet" + } + } +} diff --git a/web/public/locales/sv/config/validation.json b/web/public/locales/sv/config/validation.json index 0967ef424b..23e4d27483 100644 --- a/web/public/locales/sv/config/validation.json +++ b/web/public/locales/sv/config/validation.json @@ -1 +1,3 @@ -{} +{ + "minimum": "Måste minst vara {{limit}}" +} diff --git a/web/public/locales/sv/views/chat.json b/web/public/locales/sv/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sv/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sv/views/motionSearch.json b/web/public/locales/sv/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sv/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/sv/views/replay.json b/web/public/locales/sv/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/sv/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/th/views/chat.json b/web/public/locales/th/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/th/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/th/views/motionSearch.json b/web/public/locales/th/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/th/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/th/views/replay.json b/web/public/locales/th/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/th/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/tr/common.json b/web/public/locales/tr/common.json index 2a97d8d0f4..08b415cede 100644 --- a/web/public/locales/tr/common.json +++ b/web/public/locales/tr/common.json @@ -123,7 +123,19 @@ "twoWayTalk": "Çift Yönlü Ses", "close": "Kapat", "delete": "Sil", - "continue": "Devam Et" + "continue": "Devam Et", + "add": "Ekle", + "applying": "Uygulanıyor…", + "undo": "Geri al", + "copiedToClipboard": "Panoya kopyaladı", + "modified": "Değiştirilmiş", + "overridden": "Üstüne yazılmış", + "resetToGlobal": "Genele sıfırla", + "resetToDefault": "Varsayılana sıfırla", + "saveAll": "Hepsini Kaydet", + "savingAll": "Hepsi Kaydediliyor…", + "undoAll": "Hepsini Geri Al", + "retry": "Yeniden dene" }, "menu": { "systemLogs": "Sistem günlükleri", @@ -179,7 +191,8 @@ "bg": "Български (Bulgarca)", "gl": "Galego (Galiçyaca)", "id": "Bahasa Indonesia (Endonezce)", - "ur": "اردو (Urduca)" + "ur": "اردو (Urduca)", + "hr": "Hrvatski (Hırvatça)" }, "withSystem": "Sistem", "theme": { @@ -225,7 +238,10 @@ "faceLibrary": "Yüz Veritabanı", "systemMetrics": "Sistem metrikleri", "uiPlayground": "UI Deneme Alanı", - "classification": "Sınıflandırma" + "classification": "Sınıflandırma", + "profiles": "Profiller", + "actions": "Eylemler", + "chat": "Sohbet" }, "label": { "back": "Geri", @@ -283,7 +299,8 @@ "error": { "noMessage": "Yapılandırma değişiklikleri kaydedilemedi", "title": "Yapılandırma değişiklikleri kaydedilemedi: {{errorMessage}}" - } + }, + "success": "Yapılandırma değişiklikleri kaydedildi." } }, "selectItem": "{{item}} seçin", @@ -305,5 +322,7 @@ }, "information": { "pixels": "{{area}}px" - } + }, + "no_items": "Öge bulunmuyor", + "validation_errors": "Doğrulama Hataları" } diff --git a/web/public/locales/tr/components/dialog.json b/web/public/locales/tr/components/dialog.json index 35fe451704..19cf03c621 100644 --- a/web/public/locales/tr/components/dialog.json +++ b/web/public/locales/tr/components/dialog.json @@ -6,7 +6,8 @@ "content": "Bu sayfa {{countdown}} saniye sonra yeniden yüklenecektir.", "title": "Frigate Yeniden Başlatılıyor", "button": "Şimdi Yeniden Yükle" - } + }, + "description": "Bu Frigate'i kısa süreliğine yeniden başlayana kadar durduracak." }, "explore": { "plus": { @@ -65,7 +66,11 @@ "endTimeMustAfterStartTime": "Bitiş zamanı başlangıç zamanından sonra olmalıdır", "noVaildTimeSelected": "Geçerli bir zaman aralığı seçilmedi" }, - "view": "Görüntüle" + "view": "Görüntüle", + "queued": "Dışa aktarımlar kuyruğa alındı. İlerlemeyi dışa aktarım sayfasından görebilirsiniz.", + "batchSuccess_one": "1 Adet dışa aktarım başlatıldı. Durum açılıyor.", + "batchSuccess_other": "{{count}} Adet dışa aktarım başlatıldı. Durum açılıyor.", + "batchPartial": "{{total}} üzerinden {{successful}} adet dışa aktarım başlatıldı. Başarısız: {{failedCameras}}" }, "fromTimeline": { "saveExport": "Dışa Aktarımı Kaydet", @@ -73,6 +78,52 @@ }, "name": { "placeholder": "Dışa Aktarımı Adlandırın" + }, + "case": { + "newCaseOption": "Yeni durum oluştur", + "newCaseNamePlaceholder": "Yeni durum ismi", + "newCaseDescriptionPlaceholder": "Durum açıklaması", + "label": "Durum", + "nonAdminHelp": "Bu dışa aktarımlar için yeni durumlar oluşturulacak.", + "placeholder": "Durum seç" + }, + "queueing": "Dışa aktarımlar kuyruğa alınıyor...", + "tabs": { + "export": "Tek Kamera", + "multiCamera": "Çoklu Kamera" + }, + "multiCamera": { + "timeRange": "Zaman Aralığı", + "selectFromTimeline": "Zaman çizelgesinden seç", + "cameraSelection": "Kameralar", + "cameraSelectionHelp": "Bu zaman aralığında ki obje takibi olan kameralar önceden seçildi", + "checkingActivity": "Kamera faliyeti kontrol ediliyor...", + "noCameras": "Kamera mevcut değil", + "detectionCount_one": "1 adet takip edilen obje", + "detectionCount_other": "{{count}} adet takip edilen obje", + "nameLabel": "Dışa aktarım ismi", + "namePlaceholder": "Dışa aktarım için opsiyonel temel isim", + "queueingButton": "Dışa aktarımlar kuyruğa alınıyor...", + "exportButton_one": "1 Adet Kamera Dışarı Aktarıldı", + "exportButton_other": "{{count}} Adet Kamera Dışarı Aktarıldı" + }, + "multi": { + "title_one": "1 Adet Değerlendirme Dışarı Aktarıldı", + "title_other": "{{count}} Adet Değerlendirme Dışarı Aktarıldı", + "description": "Seçilmiş değerlendirmeleri tek tek dışa aktarın. Bütün dışa aktarımlar tek bir durum altında toplanacak.", + "descriptionNoCase": "Seçilmiş değerlendirmeleri tek tek dışa aktar.", + "caseNamePlaceholder": "Değerlendirme dışa aktarımı - {{date}}", + "exportButton_one": "1 Adet Değerlendirme'yi dışa akatar", + "exportButton_other": "{{count}} Adet Değerlendirme'yi dışa akatar", + "exportingButton": "Dışa aktarılıyor...", + "toast": { + "started_one": "1 Adet dışa aktarın başladı. Durum açılıyor.", + "started_other": "{{count}} Adet dışa aktarın başladı. Durum açılıyor.", + "startedNoCase_one": "1 Adet dışa aktarım başladı.", + "startedNoCase_other": "{{count}} Adet dışa aktarım başladı.", + "partial": "{{total}} üzerinden {{successful}} dışa aktarıldı. Başarısız: {{failedItems}}", + "failed": "{{total}} Adet dışa aktarım başarısız oldu. Başarısız: {{failedItems}}" + } } }, "streaming": { diff --git a/web/public/locales/tr/components/player.json b/web/public/locales/tr/components/player.json index 6a7950369e..9530944695 100644 --- a/web/public/locales/tr/components/player.json +++ b/web/public/locales/tr/components/player.json @@ -46,6 +46,7 @@ "livePlayerRequiredIOSVersion": "Bu canlı yayın türü için iOS 17.1 veya daha yeni sürüm gereklidir.", "submitFrigatePlus": { "title": "Bu kare Frigate+'ya gönderilsin mi?", - "submit": "Gönder" + "submit": "Gönder", + "previewError": "Önizleme şuan aktif edilemiyor. Kayıt şuan mevcut olmayabilir." } } diff --git a/web/public/locales/tr/config/cameras.json b/web/public/locales/tr/config/cameras.json index 7bc693e879..9ee3e04425 100644 --- a/web/public/locales/tr/config/cameras.json +++ b/web/public/locales/tr/config/cameras.json @@ -1,5 +1,18 @@ { "name": { - "label": "Kamera ismi" - } + "label": "Kamera adı", + "description": "Kamera adı gereklidir" + }, + "friendly_name": { + "label": "Kolay ad", + "description": "Frigate arayüzünde kullanılacak kolay ad" + }, + "enabled": { + "label": "Etkin", + "description": "Etkin" + }, + "audio": { + "label": "Ses olayları" + }, + "label": "Kamera Konfigürasyonu" } diff --git a/web/public/locales/tr/config/global.json b/web/public/locales/tr/config/global.json index 4b4308cb3c..e86122ead5 100644 --- a/web/public/locales/tr/config/global.json +++ b/web/public/locales/tr/config/global.json @@ -1,8 +1,19 @@ { "safe_mode": { - "label": "Güvenli mod" + "label": "Güvenli mod", + "description": "Etkinleştirildiğinde, Firagate'i sorun girderme için kısıtlı özelliklere sahip güvenli modda başlat." }, "environment_vars": { "label": "Ortam değişkenleri" + }, + "audio": { + "label": "Ses olayları" + }, + "version": { + "label": "Mevcut konfigürasyon versiyonu", + "description": "Taşıma veya biçimlendirme değişikliklerini tespit etmeye yardımcı olmak için etkin konfigürasyonun sayısal veya metin tabanlı sürümü." + }, + "logger": { + "label": "Kayıt" } } diff --git a/web/public/locales/tr/config/groups.json b/web/public/locales/tr/config/groups.json index 0967ef424b..c6e643d6b0 100644 --- a/web/public/locales/tr/config/groups.json +++ b/web/public/locales/tr/config/groups.json @@ -1 +1,71 @@ -{} +{ + "audio": { + "global": { + "detection": "Genel Tespit", + "sensitivity": "Genel Hassasiyet" + }, + "cameras": { + "detection": "Tespit", + "sensitivity": "Hassasiyet" + } + }, + "timestamp_style": { + "global": { + "appearance": "Genel Görünüm" + }, + "cameras": { + "appearance": "Görünüm" + } + }, + "detect": { + "cameras": { + "resolution": "Çözünürlük", + "tracking": "Takip" + }, + "global": { + "resolution": "Genel Çözünürlük", + "tracking": "Genel Takip" + } + }, + "objects": { + "global": { + "tracking": "Genel Takip", + "filtering": "Genel Filtreleme" + }, + "cameras": { + "tracking": "Takip", + "filtering": "Filtreleme" + } + }, + "record": { + "global": { + "events": "Genel Etkinlikler" + }, + "cameras": { + "events": "Etkinlikler" + } + }, + "ffmpeg": { + "cameras": { + "cameraFfmpeg": "Kamera özel FFmpeg argümanları" + } + }, + "motion": { + "global": { + "sensitivity": "Genel Hassasiyet", + "algorithm": "Genel Algoritma" + }, + "cameras": { + "sensitivity": "Hassasiyet", + "algorithm": "Algoritma" + } + }, + "snapshots": { + "global": { + "display": "Genel Görüntü" + }, + "cameras": { + "display": "Görüntü" + } + } +} diff --git a/web/public/locales/tr/config/validation.json b/web/public/locales/tr/config/validation.json index 73b68c5150..aaac6f566a 100644 --- a/web/public/locales/tr/config/validation.json +++ b/web/public/locales/tr/config/validation.json @@ -2,5 +2,7 @@ "minimum": "En az {{limit}} olmalı", "maximum": "En fazla {{limit}} olmalı", "exclusiveMinimum": "{{limit}}’den büyük olmalı", - "exclusiveMaximum": "{{limit}}’den küçük olmalı" + "exclusiveMaximum": "{{limit}}’den küçük olmalı", + "minLength": "En az {{limit}} karakter olmalı", + "maxLength": "En fazla {{limit}} karakter olmalı" } diff --git a/web/public/locales/tr/views/chat.json b/web/public/locales/tr/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/tr/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/tr/views/exports.json b/web/public/locales/tr/views/exports.json index 0c8fec129f..d6ee3ebfe2 100644 --- a/web/public/locales/tr/views/exports.json +++ b/web/public/locales/tr/views/exports.json @@ -1,7 +1,9 @@ { "search": "Arama", "documentTitle": "Dışa Aktar - Frigate", - "deleteExport": "Dışa Aktarımı Sil", + "deleteExport": { + "label": "Dışa Aktarımı Sil" + }, "deleteExport.desc": "{{exportName}} adlı dışa aktarımı silmek istediğinize emin misiniz?", "editExport": { "saveExport": "Dışa Aktarımı Kaydet", @@ -19,5 +21,9 @@ "downloadVideo": "Videoyu İndir", "editName": "İsmi Düzenle", "deleteExport": "Dışa Aktarmayı Sil" + }, + "headings": { + "uncategorizedExports": "Kategorize Edilmemiş Dışa Aktarım", + "cases": "Durumlar" } } diff --git a/web/public/locales/tr/views/motionSearch.json b/web/public/locales/tr/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/tr/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/tr/views/replay.json b/web/public/locales/tr/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/tr/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/tr/views/settings.json b/web/public/locales/tr/views/settings.json index 3d419144f1..72cc56ef85 100644 --- a/web/public/locales/tr/views/settings.json +++ b/web/public/locales/tr/views/settings.json @@ -28,7 +28,8 @@ "triggers": "Tetikler", "cameraManagement": "Yönetim", "cameraReview": "İncele", - "roles": "Roller" + "roles": "Roller", + "profiles": "Profiller" }, "general": { "title": "Kullanıcı Arayüzü Ayarları", diff --git a/web/public/locales/uk/config/cameras.json b/web/public/locales/uk/config/cameras.json index 0967ef424b..c0be2e59ac 100644 --- a/web/public/locales/uk/config/cameras.json +++ b/web/public/locales/uk/config/cameras.json @@ -1 +1,15 @@ -{} +{ + "name": { + "label": "Назва камери", + "description": "Потрібно вказати назву камери" + }, + "label": "Конфігурація камери", + "friendly_name": { + "label": "Зрозуміле ім'я", + "description": "Зручна назва камери, що використовується в інтерфейсі Frigate" + }, + "enabled": { + "label": "Увімкнено", + "description": "Увімкнено" + } +} diff --git a/web/public/locales/uk/views/chat.json b/web/public/locales/uk/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/uk/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/uk/views/motionSearch.json b/web/public/locales/uk/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/uk/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/uk/views/replay.json b/web/public/locales/uk/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/uk/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ur/audio.json b/web/public/locales/ur/audio.json index b6b532255a..6d7904e2b7 100644 --- a/web/public/locales/ur/audio.json +++ b/web/public/locales/ur/audio.json @@ -34,5 +34,20 @@ "train": "ٹرین", "bicycle": "سائیکل", "crying": "رونا", - "sigh": "آہیں" + "sigh": "آہیں", + "choir": "کوئر", + "yodeling": "یوڈیلنگ", + "chant": "نعرہ لگانا", + "mantra": "منتر", + "child_singing": "چائلڈ گانا", + "synthetic_singing": "مصنوعی گانا", + "rapping": "ریپنگ", + "humming": "گنگنانا", + "groan": "کراہنا", + "grunt": "گرنٹ", + "whistling": "سیٹی بجانا", + "breathing": "سانس لینا", + "gasp": "ہانپنا", + "pant": "ہانپنا", + "snort": "خراٹے" } diff --git a/web/public/locales/ur/components/auth.json b/web/public/locales/ur/components/auth.json index e7625b65c5..ab19b2e150 100644 --- a/web/public/locales/ur/components/auth.json +++ b/web/public/locales/ur/components/auth.json @@ -1,6 +1,6 @@ { "form": { - "user": "صارف نام", + "user": "اکاؤنٹ کا نام", "password": "پاسورڈ", "login": "لاگ ان", "errors": { diff --git a/web/public/locales/ur/views/chat.json b/web/public/locales/ur/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ur/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ur/views/classificationModel.json b/web/public/locales/ur/views/classificationModel.json index 0967ef424b..7893f7d013 100644 --- a/web/public/locales/ur/views/classificationModel.json +++ b/web/public/locales/ur/views/classificationModel.json @@ -1 +1,3 @@ -{} +{ + "documentTitle": "درجہ بندی کے ماڈلز - فریگیٹ" +} diff --git a/web/public/locales/ur/views/faceLibrary.json b/web/public/locales/ur/views/faceLibrary.json index cfb7dce625..9185231539 100644 --- a/web/public/locales/ur/views/faceLibrary.json +++ b/web/public/locales/ur/views/faceLibrary.json @@ -1,6 +1,6 @@ { "description": { - "addFace": "فیس لائبریری میں نئی کلیکشن شامل کرنے کا طریقہ بتائیں۔", + "addFace": "اپنی پہلی تصویر اپ لوڈ کرکے فیس لائبریری میں ایک نیا کلیکشن شامل کریں۔", "placeholder": "اس مجموعہ کے لیے ایک نام درج کریں", "invalidName": "غلط نام۔ ناموں میں صرف حروف، اعداد، فاصلے، اپوسٹروف، انڈر اسکور، اور ہائفن شامل ہو سکتے ہیں۔" }, diff --git a/web/public/locales/ur/views/motionSearch.json b/web/public/locales/ur/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ur/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/ur/views/replay.json b/web/public/locales/ur/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/ur/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/uz/views/chat.json b/web/public/locales/uz/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/uz/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/uz/views/motionSearch.json b/web/public/locales/uz/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/uz/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/uz/views/replay.json b/web/public/locales/uz/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/uz/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/vi/views/chat.json b/web/public/locales/vi/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/vi/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/vi/views/motionSearch.json b/web/public/locales/vi/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/vi/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/vi/views/replay.json b/web/public/locales/vi/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/vi/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/yue-Hant/views/chat.json b/web/public/locales/yue-Hant/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/yue-Hant/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/yue-Hant/views/motionSearch.json b/web/public/locales/yue-Hant/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/yue-Hant/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/yue-Hant/views/replay.json b/web/public/locales/yue-Hant/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/yue-Hant/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/zh-CN/common.json b/web/public/locales/zh-CN/common.json index e9337cfc79..5771a2288e 100644 --- a/web/public/locales/zh-CN/common.json +++ b/web/public/locales/zh-CN/common.json @@ -273,7 +273,8 @@ "classification": "目标分类", "actions": "操作", "chat": "聊天", - "profiles": "配置模板" + "profiles": "配置模板", + "features": "功能" }, "toast": { "copyUrlToClipboard": "已复制链接到剪贴板。", @@ -290,7 +291,7 @@ "title": "权限组", "admin": "管理员", "viewer": "成员", - "desc": "管理员可以完全访问Frigate界面上所有功能。成员则仅能查看摄像头、核查项和历史录像。" + "desc": "管理员可以完全访问 Frigate 界面上所有功能。成员则仅能查看摄像头、核查项和历史录像。" }, "accessDenied": { "documentTitle": "没有权限 - Frigate", diff --git a/web/public/locales/zh-CN/components/dialog.json b/web/public/locales/zh-CN/components/dialog.json index d2013adcdb..1cab8c0f82 100644 --- a/web/public/locales/zh-CN/components/dialog.json +++ b/web/public/locales/zh-CN/components/dialog.json @@ -62,19 +62,64 @@ "toast": { "success": "导出成功。进入 导出 页面查看文件。", "error": { - "failed": "导出失败:{{error}}", + "failed": "未能加入导出队列:{{error}}", "endTimeMustAfterStartTime": "结束时间必须在开始时间之后", "noVaildTimeSelected": "未选择有效的时间范围" }, - "view": "查看" + "view": "查看", + "queued": "导出已加入队列。请在导出页面查看进度。", + "batchSuccess_other": "已开始 {{count}} 个导出,正在打开合集。", + "batchPartial": "已开始 {{total}} 个导出中的 {{successful}} 个。失败的摄像头:{{failedCameras}}", + "batchFailed": "启动导出失败(共 {{total}} 个)。失败的摄像头:{{failedCameras}}", + "batchQueuedSuccess_other": "已排队 {{count}} 个导出,正在打开合集。", + "batchQueuedPartial": "已将 {{total}} 个导出中的 {{successful}} 个加入队列。失败的摄像头:{{failedCameras}}", + "batchQueueFailed": "未能将 {{total}} 个导出加入队列。失败的摄像头:{{failedCameras}}" }, "fromTimeline": { "saveExport": "保存导出", - "previewExport": "预览导出" + "previewExport": "预览导出", + "queueingExport": "正在加入导出队列…", + "useThisRange": "使用此范围" }, "case": { "label": "合集", - "placeholder": "选择合集" + "placeholder": "选择合集", + "newCaseOption": "创建新合集", + "newCaseNamePlaceholder": "新合集名称", + "newCaseDescriptionPlaceholder": "合集描述", + "nonAdminHelp": "将为这些导出文件创建一个新的合集。" + }, + "queueing": "正在加入导出队列…", + "tabs": { + "export": "单个摄像头", + "multiCamera": "多个摄像头" + }, + "multiCamera": { + "timeRange": "时间范围", + "selectFromTimeline": "从时间线选择", + "cameraSelection": "摄像头", + "cameraSelectionHelp": "在此时间范围内具有追踪目标的摄像头会被预先选中", + "checkingActivity": "正在检查摄像头活动…", + "noCameras": "没有可用的摄像头", + "detectionCount_other": "{{count}} 个追踪目标", + "nameLabel": "导出名称", + "namePlaceholder": "这些导出文件的可选基础名称", + "queueingButton": "正在加入导出队列…", + "exportButton_other": "导出 {{count}} 个摄像头" + }, + "multi": { + "title_other": "导出 {{count}} 个核查", + "description": "导出每个选定的核查项。所有导出文件将归入同一个合集。", + "descriptionNoCase": "导出每个选定的核查项。", + "caseNamePlaceholder": "核查导出 - {{date}}", + "exportButton_other": "导出 {{count}} 个核查", + "exportingButton": "导出中…", + "toast": { + "started_other": "已开始 {{count}} 个导出。正在打开合集。", + "startedNoCase_other": "已开始 {{count}} 个导出。", + "partial": "已启动 {{total}} 个导出,其中 {{successful}} 个成功。失败项:{{failedItems}}", + "failed": "启动导出失败(共 {{total}} 个)。失败项:{{failedItems}}" + } } }, "streaming": { @@ -122,6 +167,14 @@ "markAsReviewed": "标记为已核查", "deleteNow": "立即删除", "markAsUnreviewed": "标记为未核查" + }, + "shareTimestamp": { + "label": "分享该时间片段", + "title": "分享该时间片段", + "description": "分享带当前录制的播放时间的地址,或选择自定义时间。请注意,这不是公开的分享链接,只有具有 Frigate 和此摄像头访问权限的用户才能访问。", + "custom": "自定义时间", + "button": "分享时间片段地址", + "shareTitle": "Frigate 核查时间:{{camera}}" } }, "imagePicker": { diff --git a/web/public/locales/zh-CN/components/player.json b/web/public/locales/zh-CN/components/player.json index 0336c32a12..6cee6952be 100644 --- a/web/public/locales/zh-CN/components/player.json +++ b/web/public/locales/zh-CN/components/player.json @@ -4,7 +4,8 @@ "noPreviewFoundFor": "没有在 {{cameraName}} 下找到预览", "submitFrigatePlus": { "title": "提交此帧到 Frigate+?", - "submit": "提交" + "submit": "提交", + "previewError": "无法加载快照预览。该录制当前可能不可用。" }, "livePlayerRequiredIOSVersion": "此直播流类型需要 iOS 17.1 或更高版本。", "streamOffline": { diff --git a/web/public/locales/zh-CN/config/cameras.json b/web/public/locales/zh-CN/config/cameras.json index aa627f5494..5bd976c693 100644 --- a/web/public/locales/zh-CN/config/cameras.json +++ b/web/public/locales/zh-CN/config/cameras.json @@ -13,7 +13,7 @@ "description": "开启" }, "audio": { - "label": "音频事件", + "label": "音频检测", "description": "此摄像头的音频事件检测设置。", "enabled": { "label": "开启音频检测", @@ -127,7 +127,7 @@ }, "classifier": { "label": "开启视觉分类器", - "description": "使用视觉分类器,即使检测框有轻微抖动,也能准确判断物体是真的静止。" + "description": "使用视觉分类器,即使检测框有轻微抖动,也能准确判断物体是否为静止。" } }, "annotation_offset": { @@ -268,20 +268,20 @@ "description": "为此摄像头启用和控制通知的设置。" }, "live": { - "label": "实时回放", + "label": "实时监控观看", "streams": { "label": "实时监控流名称", "description": "配置的流名称到用于实时监控播放的 restream/go2rtc 名称的映射。" }, "height": { "label": "实时监控高度", - "description": "在 Web UI 中渲染 jsmpeg 实时监控流的高度(像素);必须小于等于检测流高度。" + "description": "在网页页面中渲染 jsmpeg 实时监控流的高度(像素);必须小于等于检测流高度。" }, "quality": { "label": "实时监控质量", "description": "jsmpeg 流的编码质量(1 最高,31 最低)。" }, - "description": "用于控制实时流选择、分辨率和质量的 Web UI 设置。" + "description": "用于控制实时流选择、分辨率和质量的网页页面设置。" }, "motion": { "label": "画面变动检测", @@ -388,51 +388,51 @@ "label": "原始遮罩" }, "genai": { - "label": "GenAI 目标配置", - "description": "用于描述追踪目标和发送帧进行生成的 GenAI 选项。", + "label": "生成式 AI 目标配置", + "description": "用于发送画面给生成式 AI 进行生成和描述追踪目标的选项。", "enabled": { - "label": "开启 GenAI", - "description": "默认启用 GenAI 生成追踪目标的描述。" + "label": "开启生成式 AI", + "description": "默认开启生成式 AI 生成追踪目标的描述。" }, "use_snapshot": { "label": "使用快照", - "description": "使用目标快照而不是缩略图进行 GenAI 描述生成。" + "description": "使用目标快照而不是缩略图给生成式 AI 进行描述生成。" }, "prompt": { "label": "字幕提示", - "description": "使用 GenAI 生成描述时使用的默认提示模板。" + "description": "使用生成式 AI 生成描述时使用的默认提示模板。" }, "object_prompts": { "label": "目标提示", - "description": "用于自定义特定标签的 GenAI 输出的按目标提示。" + "description": "按目标设置提示词,让生成式 AI 对不同标签的输出进行定制。" }, "objects": { - "label": "GenAI 目标", - "description": "默认发送给 GenAI 的目标标签列表。" + "label": "生成式 AI 目标", + "description": "默认发送给生成式 AI 的目标标签列表。" }, "required_zones": { "label": "必需区域", - "description": "目标必须进入才能符合 GenAI 描述生成条件的区域。" + "description": "目标必须进入这些区域,才会触发生成式 AI 描述生成。" }, "debug_save_thumbnails": { "label": "保存缩略图", - "description": "保存发送给 GenAI 的缩略图用于调试和核查。" + "description": "保存发送给生成式 AI 的缩略图用于调试和核查。" }, "send_triggers": { - "label": "GenAI 触发器", - "description": "定义何时应将帧发送给 GenAI(结束时、更新后等)。", + "label": "生成式 AI 触发器", + "description": "定义画面帧应在何时发送给生成式 AI(如检测结束时、更新后等)。", "tracked_object_end": { "label": "结束时发送", - "description": "当追踪目标结束时向 GenAI 发送请求。" + "description": "目标追踪结束时向生成式 AI 发送请求。" }, "after_significant_updates": { - "label": "早期 GenAI 触发器", - "description": "在追踪目标进行指定次数的重大更新后向 GenAI 发送请求。" + "label": "生成式 AI 提前触发", + "description": "在追踪目标发生指定次数的重要变化后,向生成式 AI 发送请求。" } }, "enabled_in_config": { - "label": "原始 GenAI 状态", - "description": "指示原始静态配置中是否启用了 GenAI。" + "label": "原配置生成式 AI 状态", + "description": "表示在原始静态配置中是否已启用生成式 AI。" } } }, @@ -516,11 +516,15 @@ "hwaccel_args": { "label": "导出硬件加速参数", "description": "用于导出/转码操作的硬件加速参数。" + }, + "max_concurrent": { + "label": "最大并发导出数", + "description": "同时可处理的最大导出任务数量。" } }, "preview": { "label": "预览配置", - "description": "控制 UI 中显示的录像预览质量的设置。", + "description": "控制界面中显示的录像预览质量的设置。", "quality": { "label": "预览质量", "description": "预览质量级别(very_low、low、medium、high、very_high)。" @@ -583,46 +587,46 @@ } }, "genai": { - "label": "GenAI 配置", + "label": "生成式 AI 配置", "description": "控制使用生成式 AI 为核查项生成描述和摘要。", "enabled": { - "label": "开启 GenAI 描述", - "description": "为核查项启用或禁用 GenAI 生成的描述和摘要。" + "label": "开启生成式 AI 描述", + "description": "为核查项开启或关闭使用生成式 AI 生成描述和摘要。" }, "alerts": { - "label": "为警报开启 GenAI", - "description": "使用 GenAI 为警报项生成描述。" + "label": "为警报开启生成式 AI", + "description": "使用生成式 AI 为警报项生成描述。" }, "detections": { - "label": "为检测开启 GenAI", - "description": "使用 GenAI 为检测项生成描述。" + "label": "为检测开启生成式 AI", + "description": "使用生成式 AI 为检测项生成描述。" }, "image_source": { "label": "核查图像来源", - "description": "发送给 GenAI 的图像来源('preview' 或 'recordings');'recordings' 使用更高质量的帧但消耗更多 token。" + "description": "发送给生成式 AI 的画面来源('preview' 或 'recordings');'recordings' 使用更高质量的画面帧,但会消耗更多的 token。" }, "additional_concerns": { "label": "额外关注事项", - "description": "GenAI 在评估此摄像头活动时应考虑的额外关注事项或备注列表。" + "description": "生成式 AI 在分析此摄像头的监控行为时,需要额外注意的事项或说明列表。" }, "debug_save_thumbnails": { "label": "保存缩略图", - "description": "保存发送给 GenAI 提供商的缩略图用于调试和核查。" + "description": "保存发送给生成式 AI 提供商的缩略图用于调试和核查。" }, "enabled_in_config": { - "label": "原始 GenAI 状态", - "description": "追踪原始静态配置中是否启用了 GenAI 核查。" + "label": "原配置生成式 AI 状态", + "description": "记录在静态配置中最初是否已启用生成式 AI 核查功能。" }, "preferred_language": { "label": "首选语言", - "description": "向 GenAI 提供商请求生成响应的首选语言。" + "description": "向生成式 AI 提供商请求生成响应的首选语言。" }, "activity_context_prompt": { "label": "活动上下文提示", - "description": "描述什么是和什么不是可疑活动的自定义提示,为 GenAI 摘要提供上下文。" + "description": "自定义提示词,用于说明可疑行为与非可疑行为的界定,为生成式 AI 生成摘要提供上下文依据。" } }, - "description": "控制此摄像头的警报、检测和 GenAI 核查摘要的设置,用于 UI 和存储。" + "description": "控制此摄像头的警报、检测和生成式 AI 核查总结的设置,这些设置会被界面与存储功能使用。" }, "snapshots": { "label": "快照", @@ -841,15 +845,15 @@ } }, "ui": { - "label": "摄像头 UI", - "description": "此摄像头在 UI 中的显示顺序和可见性。顺序影响默认仪表板。如需更精细的控制,请使用摄像头组。", + "label": "摄像头页面", + "description": "此摄像头在页面中的显示顺序和可见性。显示顺序仅影响默认仪表板。如需更精细的控制,请使用“摄像头组”。", "order": { "label": "UI 顺序", - "description": "用于在 UI 中排序摄像头的数值顺序(默认仪表板和列表);数值越大出现越晚。" + "description": "用于在页面中排序摄像头的顺序(只会影响默认仪表板和列表);数值越大则在越后面。" }, "dashboard": { "label": "在 UI 中显示", - "description": "切换此摄像头在 Frigate UI 的所有位置是否可见。禁用此项将需要手动编辑配置才能在 UI 中再次查看此摄像头。" + "description": "切换此摄像头在 Frigate 页面的所有位置是否可见。禁用此项将需要手动编辑配置才能在页面中再次查看此摄像头。" } }, "best_image_timeout": { @@ -862,7 +866,7 @@ }, "webui_url": { "label": "摄像头 URL", - "description": "从系统页面直接访问摄像头的 URL" + "description": "从系统页面直接访问摄像头管理后台的 URL" }, "zones": { "label": "区域", diff --git a/web/public/locales/zh-CN/config/global.json b/web/public/locales/zh-CN/config/global.json index b14f4acbf1..fed5425d7b 100644 --- a/web/public/locales/zh-CN/config/global.json +++ b/web/public/locales/zh-CN/config/global.json @@ -24,7 +24,7 @@ } }, "audio": { - "label": "音频事件", + "label": "音频检测", "enabled": { "label": "开启音频检测", "description": "为所有摄像头启用或禁用音频事件检测;可按摄像头覆盖。" @@ -231,7 +231,7 @@ }, "classifier": { "label": "开启视觉分类器", - "description": "使用视觉分类器,即使检测框有轻微抖动,也能准确判断物体是真的静止。" + "description": "使用视觉分类器,即使检测框有轻微抖动,也能准确判断物体是否为静止。" } }, "annotation_offset": { @@ -522,8 +522,8 @@ "description": "为摄像头 ffmpeg 进程和检测器启用按进程网络带宽监控(需要权限)。" }, "intel_gpu_device": { - "label": "SR-IOV 设备", - "description": "将 Intel GPU 视为 SR-IOV 时使用的设备标识符,用于修复 GPU 统计信息。" + "label": "Intel GPU 设备", + "description": "当系统存在多个 Intel 显卡时,用于将显卡运行数据绑定到指定设备的 PCI 总线地址或 DRM 设备路径(示例:/dev/dri/card1)。" } }, "version_check": { @@ -536,7 +536,7 @@ "description": "Frigate Web 端点(端口 8971)的 TLS 设置。", "enabled": { "label": "开启 TLS", - "description": "为 Frigate 的 Web 页面和 API 的端口开启 TLS 加密。" + "description": "为 Frigate 的网页页面和 API 的端口开启 TLS 加密。" } }, "ui": { @@ -1470,15 +1470,15 @@ }, "provider": { "label": "提供商", - "description": "要使用的 GenAI 提供商(例如:ollama、gemini、openai)。" + "description": "要使用的生成式 AI 提供商(例如:ollama、gemini、openai 等。国产大模型厂商可使用 openai 接口)。" }, "roles": { "label": "功能", - "description": "生成式 AI 功能(工具、视觉、嵌入);每个功能单独一个提供商。" + "description": "生成式 AI 功能(对话、描述、嵌入);每个功能单独一个提供商。" }, "provider_options": { "label": "提供商选项", - "description": "传递给 GenAI 客户端的附加提供商特定选项。" + "description": "要传递给生成式 AI 客户端的、与服务提供商相关的额外配置项。" }, "runtime_options": { "label": "运行时选项", @@ -1486,7 +1486,7 @@ } }, "live": { - "label": "实时回放", + "label": "实时监控观看", "description": "用于控制 JSMPEG 实时流分辨率与画质的设置。此设置不影响使用 go2rtc 进行实时预览的摄像头。", "streams": { "label": "实时监控流名称", @@ -1494,7 +1494,7 @@ }, "height": { "label": "实时监控高度", - "description": "在 Web UI 中渲染 jsmpeg 实时监控流的高度(像素);必须小于等于检测流高度。" + "description": "在网页页面中渲染 jsmpeg 实时监控流的高度(像素);必须小于等于检测流高度。" }, "quality": { "label": "实时监控质量", @@ -1606,51 +1606,51 @@ "label": "原始遮罩" }, "genai": { - "label": "GenAI 目标配置", - "description": "用于描述追踪目标和发送帧进行生成的 GenAI 选项。", + "label": "生成式 AI 目标配置", + "description": "用于发送画面给生成式 AI 进行生成和描述追踪目标的选项。", "enabled": { - "label": "开启 GenAI", - "description": "默认启用 GenAI 生成追踪目标的描述。" + "label": "开启生成式 AI", + "description": "默认开启生成式 AI 生成追踪目标的描述。" }, "use_snapshot": { "label": "使用快照", - "description": "使用目标快照而不是缩略图进行 GenAI 描述生成。" + "description": "使用目标快照而不是缩略图给生成式 AI 进行描述生成。" }, "prompt": { "label": "字幕提示", - "description": "使用 GenAI 生成描述时使用的默认提示模板。" + "description": "使用生成式 AI 生成描述时使用的默认提示模板。" }, "object_prompts": { "label": "目标提示", - "description": "用于自定义特定标签的 GenAI 输出的按目标提示。" + "description": "按目标设置提示词,让生成式 AI 对不同标签的输出进行定制。" }, "objects": { - "label": "GenAI 目标", - "description": "默认发送给 GenAI 的目标标签列表。" + "label": "生成式 AI 目标", + "description": "默认发送给生成式 AI 的目标标签列表。" }, "required_zones": { "label": "必需区域", - "description": "目标必须进入才能符合 GenAI 描述生成条件的区域。" + "description": "目标必须进入这些区域,才会触发生成式 AI 描述生成。" }, "debug_save_thumbnails": { "label": "保存缩略图", - "description": "保存发送给 GenAI 的缩略图用于调试和核查。" + "description": "保存发送给生成式 AI 的缩略图用于调试和核查。" }, "send_triggers": { - "label": "GenAI 触发器", - "description": "定义何时应将帧发送给 GenAI(结束时、更新后等)。", + "label": "生成式 AI 触发器", + "description": "定义画面帧应在何时发送给生成式 AI(如检测结束时、更新后等)。", "tracked_object_end": { "label": "结束时发送", - "description": "当追踪目标结束时向 GenAI 发送请求。" + "description": "目标追踪结束时向生成式 AI 发送请求。" }, "after_significant_updates": { - "label": "早期 GenAI 触发器", - "description": "在追踪目标进行指定次数的重大更新后向 GenAI 发送请求。" + "label": "生成式 AI 提前触发", + "description": "在追踪目标发生指定次数的重要变化后,向生成式 AI 发送请求。" } }, "enabled_in_config": { - "label": "原始 GenAI 状态", - "description": "指示原始静态配置中是否启用了 GenAI。" + "label": "原配置生成式 AI 状态", + "description": "表示在原始静态配置中是否已启用生成式 AI。" } } }, @@ -1735,11 +1735,15 @@ "hwaccel_args": { "label": "导出硬件加速参数", "description": "用于导出/转码操作的硬件加速参数。" + }, + "max_concurrent": { + "label": "最大并发导出数", + "description": "同时可处理的最大导出任务数量。" } }, "preview": { "label": "预览配置", - "description": "控制 UI 中显示的录像预览质量的设置。", + "description": "控制界面中显示的录像预览质量的设置。", "quality": { "label": "预览质量", "description": "预览质量级别(very_low、low、medium、high、very_high)。" @@ -1802,43 +1806,43 @@ } }, "genai": { - "label": "GenAI 配置", + "label": "生成式 AI 配置", "description": "控制使用生成式 AI 为核查项生成描述和摘要。", "enabled": { - "label": "开启 GenAI 描述", - "description": "为核查项启用或禁用 GenAI 生成的描述和摘要。" + "label": "开启生成式 AI 描述", + "description": "为核查项开启或关闭使用生成式 AI 生成描述和摘要。" }, "alerts": { - "label": "为警报开启 GenAI", - "description": "使用 GenAI 为警报项生成描述。" + "label": "为警报开启生成式 AI", + "description": "使用生成式 AI 为警报项生成描述。" }, "detections": { - "label": "为检测开启 GenAI", - "description": "使用 GenAI 为检测项生成描述。" + "label": "为检测开启生成式 AI", + "description": "使用生成式 AI 为检测项生成描述。" }, "image_source": { "label": "核查图像来源", - "description": "发送给 GenAI 的图像来源('preview' 或 'recordings');'recordings' 使用更高质量的帧但消耗更多 token。" + "description": "发送给生成式 AI 的画面来源('preview' 或 'recordings');'recordings' 使用更高质量的画面帧,但会消耗更多的 token。" }, "additional_concerns": { "label": "额外关注事项", - "description": "GenAI 在评估此摄像头活动时应考虑的额外关注事项或备注列表。" + "description": "生成式 AI 在分析此摄像头的监控行为时,需要额外注意的事项或说明列表。" }, "debug_save_thumbnails": { "label": "保存缩略图", - "description": "保存发送给 GenAI 提供商的缩略图用于调试和核查。" + "description": "保存发送给生成式 AI 提供商的缩略图用于调试和核查。" }, "enabled_in_config": { - "label": "原始 GenAI 状态", - "description": "追踪原始静态配置中是否启用了 GenAI 核查。" + "label": "原配置生成式 AI 状态", + "description": "记录在静态配置中最初是否已启用生成式 AI 核查功能。" }, "preferred_language": { "label": "首选语言", - "description": "向 GenAI 提供商请求生成响应的首选语言。" + "description": "向生成式 AI 提供商请求生成响应的首选语言。" }, "activity_context_prompt": { "label": "活动上下文提示", - "description": "描述什么是和什么不是可疑活动的自定义提示,为 GenAI 摘要提供上下文。" + "description": "自定义提示词,用于说明可疑行为与非可疑行为的界定,为生成式 AI 生成摘要提供上下文依据。" } } }, @@ -2114,18 +2118,18 @@ }, "camera_groups": { "label": "摄像头分组", - "description": "用于在 UI 中组织摄像头的命名摄像头分组配置。", + "description": "用于在页面中组织摄像头的命名摄像头分组配置。", "cameras": { "label": "摄像头列表", "description": "此分组中包含的摄像头名称数组。" }, "icon": { "label": "分组图标", - "description": "在 UI 中代表摄像头分组的图标。" + "description": "在页面中代表摄像头分组的图标。" }, "order": { "label": "排序顺序", - "description": "用于在 UI 中对摄像头分组进行排序的数字顺序;数值越大越靠后。" + "description": "用于在页面中对摄像头分组进行排序的数字顺序;数值越大越靠后。" } }, "camera_mqtt": { @@ -2161,15 +2165,15 @@ } }, "camera_ui": { - "label": "摄像头 UI", - "description": "此摄像头在 UI 中的显示顺序和可见性。顺序影响默认仪表板。如需更精细的控制,请使用摄像头分组。", + "label": "摄像头页面", + "description": "此摄像头在页面中的显示顺序和可见性。显示顺序仅影响默认仪表板。如需更精细的控制,请使用“摄像头组”。", "order": { "label": "UI 顺序", - "description": "用于在 UI 中对摄像头进行排序的数字顺序(默认仪表板和列表);数值越大越靠后。" + "description": "用于在页面中排序摄像头的顺序(只会影响默认仪表板和列表);数值越大则在越后面。" }, "dashboard": { "label": "在 UI 中显示", - "description": "切换此摄像头在 Frigate UI 中是否可见。禁用后需要手动编辑配置才能再次在 UI 中查看此摄像头。" + "description": "切换此摄像头在 Frigate 页面中是否可见。禁用后需要手动编辑配置才能再次在页面中查看此摄像头。" } }, "onvif": { diff --git a/web/public/locales/zh-CN/views/chat.json b/web/public/locales/zh-CN/views/chat.json new file mode 100644 index 0000000000..894b3c6d50 --- /dev/null +++ b/web/public/locales/zh-CN/views/chat.json @@ -0,0 +1,46 @@ +{ + "documentTitle": "聊天 - Frigate", + "title": "Frigate 聊天", + "subtitle": "你的摄像头管理与智能分析 AI 助手", + "placeholder": "尝试问我任何事儿…", + "error": "出现错误,请稍后重试。", + "processing": "进行中…", + "toolsUsed": "使用:{{tools}}", + "showTools": "显示工具({{count}})", + "hideTools": "隐藏工具", + "call": "调用", + "result": "结果", + "arguments": "参数:", + "response": "响应:", + "attachment_chip_label": "在 {{camera}} 的 {{label}}", + "attachment_chip_remove": "移除附件", + "open_in_explore": "从浏览中打开", + "attach_event_aria": "关联事件 {{eventId}}", + "attachment_picker_paste_label": "或粘贴事件 ID", + "attachment_picker_attach": "关联", + "attachment_picker_placeholder": "关联一个事件", + "quick_reply_find_similar": "查找相似抓拍事件", + "quick_reply_tell_me_more": "了解更多详情", + "quick_reply_when_else": "还在哪些时段出现过?", + "quick_reply_find_similar_text": "查找与此相似的抓拍记录。", + "quick_reply_tell_me_more_text": "了解此条更多详情。", + "starting_requests": { + "show_recent_events": "查看近期事件", + "show_camera_status": "显示摄像头状态", + "recap": "我不在的时候发生了什么?", + "watch_camera": "监控摄像头活动" + }, + "quick_reply_when_else_text": "还在哪些时间出现过?", + "anchor": "来源", + "similarity_score": "相似度", + "no_similar_objects_found": "未找到相似目标。", + "semantic_search_required": "必须启用语义搜索才能查找相似目标。", + "send": "发送", + "suggested_requests": "尝试问问:", + "starting_requests_prompts": { + "show_recent_events": "显示最近一小时的事件", + "show_camera_status": "我的摄像头当前状态如何?", + "recap": "我不在的时候发生了什么事?", + "watch_camera": "监控前门,有人出现就通知我" + } +} diff --git a/web/public/locales/zh-CN/views/events.json b/web/public/locales/zh-CN/views/events.json index f02a839076..6035051c8f 100644 --- a/web/public/locales/zh-CN/views/events.json +++ b/web/public/locales/zh-CN/views/events.json @@ -26,7 +26,9 @@ }, "documentTitle": "核查 - Frigate", "recordings": { - "documentTitle": "回放 - Frigate" + "documentTitle": "回放 - Frigate", + "invalidSharedLink": "由于解析错误,无法打开带时间戳的录制链接。", + "invalidSharedCamera": "由于摄像头未知或未获授权,无法打开带时间戳的录制链接。" }, "calendarFilter": { "last24Hours": "过去24小时" diff --git a/web/public/locales/zh-CN/views/explore.json b/web/public/locales/zh-CN/views/explore.json index db062d4556..b5860d18fb 100644 --- a/web/public/locales/zh-CN/views/explore.json +++ b/web/public/locales/zh-CN/views/explore.json @@ -285,7 +285,10 @@ "zones": "区", "ratio": "比例", "area": "大小", - "score": "分数" + "score": "分数", + "computedScore": "计算得分", + "topScore": "最高得分", + "toggleAdvancedScores": "切换高级分数" } }, "annotationSettings": { diff --git a/web/public/locales/zh-CN/views/exports.json b/web/public/locales/zh-CN/views/exports.json index b57b1a1c69..6eaed055cf 100644 --- a/web/public/locales/zh-CN/views/exports.json +++ b/web/public/locales/zh-CN/views/exports.json @@ -14,7 +14,9 @@ "toast": { "error": { "renameExportFailed": "重命名导出失败:{{errorMessage}}", - "assignCaseFailed": "更新合集分配失败:{{errorMessage}}" + "assignCaseFailed": "更新合集分配失败:{{errorMessage}}", + "caseSaveFailed": "保存合集失败:{{errorMessage}}", + "caseDeleteFailed": "删除合集失败:{{errorMessage}}" } }, "tooltip": { @@ -22,7 +24,8 @@ "downloadVideo": "下载视频", "editName": "编辑名称", "deleteExport": "删除导出", - "assignToCase": "加入合集" + "assignToCase": "加入合集", + "removeFromCase": "从合集中移除" }, "headings": { "uncategorizedExports": "未分类导出项", @@ -35,5 +38,91 @@ "selectLabel": "合集", "newCaseOption": "创建新合集", "descriptionLabel": "描述" + }, + "toolbar": { + "newCase": "新合集", + "addExport": "新导出", + "editCase": "编辑合集", + "deleteCase": "删除合集" + }, + "deleteCase": { + "label": "删除合集", + "desc": "你确定要删除 {{caseName}} 吗?", + "descKeepExports": "导出文件将继续保留为未分类导出。", + "descDeleteExports": "此合集中的所有导出项都将被永久删除。", + "deleteExports": "同时删除导出文件" + }, + "caseCard": { + "emptyCase": "暂无导出文件" + }, + "jobCard": { + "defaultName": "{{camera}} 导出", + "queued": "队列中", + "running": "运行中", + "preparing": "准备中", + "copying": "复制中", + "encoding": "编码中", + "encodingRetry": "重试编码中", + "finalizing": "正在完成" + }, + "caseView": { + "noDescription": "没有描述", + "createdAt": "已创建 {{value}}", + "exportCount_one": "1 个导出", + "exportCount_other": "{{count}} 个导出", + "cameraCount_one": "1 个摄像头", + "cameraCount_other": "{{count}} 个摄像头", + "showMore": "显示更多", + "showLess": "显示更少", + "emptyTitle": "该合集为空", + "emptyDescription": "将现有未分类的导出添加进来,以便整理该条目。", + "emptyDescriptionNoExports": "目前没有可添加的未分类导出项。" + }, + "caseEditor": { + "createTitle": "创建合集", + "editTitle": "编辑合集", + "namePlaceholder": "合集名称", + "descriptionPlaceholder": "为该合集添加备注或相关说明" + }, + "addExportDialog": { + "title": "将导出添加到 {{caseName}}", + "searchPlaceholder": "搜索未分类的导出项", + "empty": "未找到匹配的未分类导出。", + "addButton_one": "添加 1 个导出", + "addButton_other": "添加 {{count}} 个导出", + "adding": "添加中…" + }, + "selected_one": "已选择 {{count}} 个", + "selected_other": "已选择 {{count}} 个", + "bulkActions": { + "addToCase": "添加至合集", + "moveToCase": "移动至合集", + "removeFromCase": "从合集中移除", + "delete": "删除", + "deleteNow": "立即删除" + }, + "bulkDelete": { + "title": "删除导出", + "desc_one": "你确定要删除 {{count}} 个导出吗?", + "desc_other": "确定要删除 {{count}} 个导出吗?" + }, + "bulkRemoveFromCase": { + "title": "从合集中移除", + "desc_one": "你确定要从该合集中移除这 {{count}} 个导出吗?", + "desc_other": "你确定要从该合集中移除这 {{count}} 个导出吗?", + "descKeepExports": "导出将被移至未分类。", + "descDeleteExports": "导出将被永久删除。", + "deleteExports": "选择删除导出" + }, + "bulkToast": { + "success": { + "delete": "已删除导出", + "reassign": "已更新合集分配", + "remove": "已从合集中移除导出" + }, + "error": { + "deleteFailed": "删除导出失败:{{errorMessage}}", + "reassignFailed": "更新合集分配失败:{{errorMessage}}" + } } } diff --git a/web/public/locales/zh-CN/views/live.json b/web/public/locales/zh-CN/views/live.json index 10b8641d3f..53688c6dfa 100644 --- a/web/public/locales/zh-CN/views/live.json +++ b/web/public/locales/zh-CN/views/live.json @@ -70,7 +70,8 @@ }, "recording": { "enable": "开启录制", - "disable": "关闭录制" + "disable": "关闭录制", + "disabledInConfig": "必须先在该摄像头的设置中开启录制功能。" }, "snapshots": { "enable": "开启快照", diff --git a/web/public/locales/zh-CN/views/motionSearch.json b/web/public/locales/zh-CN/views/motionSearch.json new file mode 100644 index 0000000000..af8874daf1 --- /dev/null +++ b/web/public/locales/zh-CN/views/motionSearch.json @@ -0,0 +1,73 @@ +{ + "documentTitle": "变动搜索 - Frigate", + "title": "画面变动搜索", + "description": "绘制一个多边形以划定感兴趣区域,并指定时间范围,检索该区域内的异动变化。", + "selectCamera": "画面变动搜索正在加载中", + "startSearch": "开始搜索", + "searchStarted": "搜索已开始", + "searchCancelled": "搜索已取消", + "cancelSearch": "取消", + "searching": "搜索进行中。", + "searchComplete": "搜索完成", + "noResultsYet": "在所选区域内执行搜索,查找异常变化", + "noChangesFound": "所选区域未检测到像素变化", + "changesFound_other": "检测到 {{count}} 处画面变化", + "framesProcessed": "已处理 {{count}} 帧画面", + "jumpToTime": "跳转到该时间", + "results": "结果", + "showSegmentHeatmap": "热力图", + "newSearch": "新的搜索", + "clearResults": "清除结果", + "clearROI": "清除多边形选区", + "polygonControls": { + "points_other": "{{count}} 个点位", + "undo": "撤销上一个点位", + "reset": "重置多边形" + }, + "motionHeatmapLabel": "画面变动热力图", + "dialog": { + "title": "画面变动搜索", + "cameraLabel": "摄像头", + "previewAlt": "{{camera}} 摄像头实时预览" + }, + "timeRange": { + "title": "搜索范围", + "start": "开始时间", + "end": "结束时间" + }, + "settings": { + "title": "搜索设置", + "parallelMode": "并行模式", + "parallelModeDesc": "同时扫描多个录制片段(速度更快,但 CPU 占用会显著升高)", + "threshold": "灵敏度阈值", + "thresholdDesc": "数值越低,可检测到越小的变化(取值范围 1-255)", + "minArea": "最小变化区域", + "minAreaDesc": "最小感兴趣区域变化占比,达到该比例才会判定为有效变动", + "frameSkip": "帧跳过", + "frameSkipDesc": "每隔 N 帧进行一次处理。将该值设置为摄像头的帧率,即可实现每秒处理一帧画面(例如:5 帧 / 秒的摄像头设为 5,30 帧 / 秒的摄像头设为 30)。数值越高处理速度越快,但有可能遗漏短时移动侦测事件。", + "maxResults": "最大结果数", + "maxResultsDesc": "匹配到设定条数的录像事件后,就自动停止检索" + }, + "errors": { + "noCamera": "请选择摄像头", + "noROI": "请绘制感兴趣的区域", + "noTimeRange": "请选择时间范围", + "invalidTimeRange": "结束时间必须在开始时间之后", + "searchFailed": "搜索失败:{{message}}", + "polygonTooSmall": "多边形至少需要 3 个顶点", + "unknown": "未知错误" + }, + "changePercentage": "{{percentage}}% 已变化", + "metrics": { + "title": "搜索指标", + "segmentsScanned": "已扫描片段数", + "segmentsProcessed": "已处理", + "segmentsSkippedInactive": "已跳过(无活动)", + "segmentsSkippedHeatmap": "已跳过(不在感兴趣区域)", + "fallbackFullRange": "备用全范围扫描", + "framesDecoded": "画面已解码", + "wallTime": "搜索时间", + "segmentErrors": "片段异常", + "seconds": "{{seconds}} 秒" + } +} diff --git a/web/public/locales/zh-CN/views/replay.json b/web/public/locales/zh-CN/views/replay.json new file mode 100644 index 0000000000..00634af5d7 --- /dev/null +++ b/web/public/locales/zh-CN/views/replay.json @@ -0,0 +1,59 @@ +{ + "title": "调试回放", + "description": "回放摄像头录像以供调试。目标列表会延时展示已检测目标的汇总信息,消息标签页则实时展示回放录像对应的 Frigate 内部日志信息流。", + "websocket_messages": "消息", + "dialog": { + "title": "开始调试回放", + "description": "创建临时回放摄像头,循环播放历史录制视频,用于调试目标检测与追踪相关问题。临时回放的摄像头将沿用原摄像头的检测配置。请选择一个时间范围开始。", + "camera": "原摄像头", + "timeRange": "时间范围", + "preset": { + "1m": "最后 1 分钟", + "5m": "最后 5 分钟", + "timeline": "从时间线", + "custom": "自定义" + }, + "startButton": "开始回放", + "selectFromTimeline": "选择", + "starting": "开始回放…", + "startLabel": "开始", + "endLabel": "结束", + "toast": { + "error": "调试回放启动失败:{{error}}", + "alreadyActive": "已有回放会话正在运行", + "stopError": "调试回放停止失败:{{error}}", + "goToReplay": "进入回放" + } + }, + "page": { + "noSession": "没有正在进行的调试回放会话", + "noSessionDesc": "从历史回放页面启动调试回放:点击工具栏中的操作按钮,选择调试回放即可。", + "goToRecordings": "查看历史记录", + "preparingClip": "正在准备片段…", + "preparingClipDesc": "Frigate 正在拼接所选时间范围的录像片段。时间跨度较大时,该过程可能需要一分钟左右。", + "startingCamera": "开始调试回放中…", + "startError": { + "title": "调试回放启动失败", + "back": "返回历史记录" + }, + "sourceCamera": "源摄像头", + "replayCamera": "回放摄像头", + "initializingReplay": "初始化调试回放中…", + "stoppingReplay": "正在停止调试回放…", + "stopReplay": "停止回放", + "confirmStop": { + "title": "要停止调试回放吗?", + "description": "这将终止会话并清除所有临时数据。是否确定?", + "confirm": "停止回放", + "cancel": "取消" + }, + "activity": "活动", + "objects": "目标列表", + "audioDetections": "音频检测", + "noActivity": "未检测到活动", + "activeTracking": "活动追踪中", + "noActiveTracking": "没有活动追踪", + "configuration": "配置", + "configurationDesc": "微调调试回放摄像头的移动侦测与目标追踪参数。本次调整不会保存到你的 Frigate 配置文件中。" + } +} diff --git a/web/public/locales/zh-CN/views/settings.json b/web/public/locales/zh-CN/views/settings.json index 55190e53bd..3831dfc56c 100644 --- a/web/public/locales/zh-CN/views/settings.json +++ b/web/public/locales/zh-CN/views/settings.json @@ -45,8 +45,8 @@ "globalMotion": "画面变动检测", "globalObjects": "目标", "globalReview": "核查", - "globalAudioEvents": "音频事件", - "globalLivePlayback": "实时回放", + "globalAudioEvents": "音频检测", + "globalLivePlayback": "实时监控观看", "globalTimestampStyle": "时间戳样式", "systemDatabase": "数据库", "systemTls": "TLS加密链接", @@ -75,16 +75,16 @@ "cameraMotion": "画面变动检测", "cameraObjects": "目标", "cameraConfigReview": "核查", - "cameraAudioEvents": "音频事件", + "cameraAudioEvents": "音频检测", "cameraAudioTranscription": "音频转录", "cameraNotifications": "通知", - "cameraLivePlayback": "实时回放", + "cameraLivePlayback": "实时监控观看", "cameraBirdseye": "鸟瞰图", "cameraFaceRecognition": "人脸识别", "cameraLpr": "车牌识别", "cameraMqttConfig": "MQTT", "cameraOnvif": "ONVIF", - "cameraUi": "摄像头管理页面", + "cameraUi": "摄像头页面", "cameraTimestampStyle": "时间戳样式", "cameraMqtt": "摄像头 MQTT", "mediaSync": "媒体同步", @@ -358,7 +358,8 @@ "object_mask": "目标遮罩" }, "revertOverride": { - "title": "恢复为默认配置" + "title": "恢复为默认配置", + "desc": "这将移除针对 {{type}} {{name}} 的配置覆盖,并恢复为基础配置。" } }, "speed": { @@ -1113,7 +1114,7 @@ "noSnapshot": "无法从配置的视频流中获取快照。" }, "errors": { - "brandOrCustomUrlRequired": "请选择摄像头品牌并配置主机/IP地址,或选择“其他”后手动配置视频流地址", + "brandOrCustomUrlRequired": "请选择摄像头品牌并配置主机/ IP 地址,或选择“其他”后手动配置视频流地址", "nameRequired": "摄像头名称为必填项", "nameLength": "摄像头名称要少于64个字符", "invalidCharacters": "摄像头名称内有不允许使用的字符", @@ -1121,7 +1122,7 @@ "brands": { "reolink-rtsp": "不建议使用萤石 RTSP 协议。建议在摄像头设置中启用 HTTP 协议,并重新运行摄像头添加向导。" }, - "customUrlRtspRequired": "自定义URL必须以“rtsp://”开头;对于非 RTSP 协议的摄像头流,需手动添加至配置文件。" + "customUrlRtspRequired": "自定义 URL 必须以“rtsp://”开头;对于非 RTSP 协议的摄像头流,需手动添加至配置文件。" }, "docs": { "reolink": "https://docs.frigate-cn.video/configuration/camera_specific.html#reolink-cameras" @@ -1138,7 +1139,7 @@ "detectionMethodDescription": "如果摄像头支持 ONVIF 协议,将使用该协议探测摄像头,以自动获取摄像头视频流地址;若不支持,也可手动选择摄像头品牌来使用预设地址。如需输入自定义RTSP地址,请选择“手动选择”并选择“其他”选项。", "onvifPortDescription": "对于支持ONVIF协议的摄像头,该端口通常为80或8080。", "useDigestAuth": "使用摘要认证", - "useDigestAuthDescription": "为ONVIF协议启用HTTP摘要认证。部分摄像头可能需要专用的 ONVIF 用户名/密码,而非默认的admin账户。" + "useDigestAuthDescription": "为 ONVIF 协议启用 HTTP 摘要认证。部分摄像头可能需要专用的 ONVIF 用户名/密码,而非默认的 admin 账户。" }, "step2": { "description": "将根据你选择的检测方式,将会自动查找摄像头可用流配置,或进行手动配置。", @@ -1161,7 +1162,7 @@ }, "testStream": "测试连接", "testSuccess": "视频流测试成功!", - "testFailed": "视频流测试失败", + "testFailed": "连接测试失败,请检查您的输入后重试。", "testFailedTitle": "测试失败", "connected": "已连接", "notConnected": "未连接", @@ -1337,7 +1338,8 @@ }, "hikvision": { "substreamWarning": "子码流1当前被锁定为低分辨率。多数海康威视的摄像头都支持额外的子码流,这些子码流需要在摄像头设置中手动启用。如果你的设备支持,建议你检查并使用这些高分辨率子码流。" - } + }, + "resolutionUnknown": "无法检测此视频流的分辨率。你需要在设置或配置文件中手动指定检测分辨率。" } } }, @@ -1354,7 +1356,13 @@ "enableDesc": "暂时禁用已开启的摄像头,直到 Frigate 重启。禁用摄像头会完全停止 Frigate 对该摄像头视频流的处理。检测、录像和调试功能将不可用。
    注意:这不会禁用 go2rtc 的转推流。", "disableLabel": "关闭摄像头", "disableDesc": "开启在当前在界面中不可见且在配置中被禁用的摄像头。启用后需要重启 Frigate 才能生效。", - "enableSuccess": "已在配置中启用 {{cameraName}}。请重启 Frigate 以应用更改。" + "enableSuccess": "已在配置中启用 {{cameraName}}。请重启 Frigate 以应用更改。", + "friendlyName": { + "edit": "修改摄像头显示名称", + "title": "修改显示名称", + "description": "设置该摄像机在 Frigate 用户界面中显示的名称。若留空,则使用摄像机 ID。", + "rename": "重命名" + } }, "cameraConfig": { "add": "添加摄像头", @@ -1404,6 +1412,14 @@ "inherit": "继承", "enabled": "开启", "disabled": "关闭" + }, + "cameraType": { + "title": "摄像头类型", + "label": "摄像头类型", + "description": "为每路摄像头设置类型。专用车牌识别(LPR)摄像头为单用途设备,配备高倍光学变焦,可抓拍远处车辆的车牌。绝大多数摄像头应选用通用类型;只有专为车牌识别部署、且画面聚焦对准车牌的摄像头,才需选择专用 LPR 类型。", + "normal": "通用", + "dedicatedLpr": "车牌识别专用", + "saveSuccess": "已更新 {{cameraName}} 的摄像头类型,请重启 Frigate 以使更改生效。" } }, "cameraReview": { @@ -1493,7 +1509,7 @@ "tls": "TLS", "proxy": "代理", "go2rtc": "go2rtc", - "ffmpeg": "FFmpeg", + "ffmpeg": "FFmpeg 编解码", "detectors": "检测器", "genai": "生成式 AI", "face_recognition": "人脸识别", @@ -1519,7 +1535,7 @@ "remove": "移除" }, "roleMap": { - "empty": "未配置角色映射。", + "empty": "未配置权限组映射", "addMapping": "添加角色映射", "roleLabel": "角色", "groupsLabel": "用户组", @@ -1655,7 +1671,16 @@ "summary": "已选择 {{count}} 个标签", "empty": "暂无可用标签" }, - "addCustomLabel": "添加自定义标签…" + "addCustomLabel": "添加自定义标签…", + "genaiModel": { + "placeholder": "选择模型…", + "search": "搜索模型…", + "noModels": "暂无模型" + }, + "knownPlates": { + "namePlaceholder": "例如:老婆的车", + "platePlaceholder": "车牌号或正则表达式" + } }, "cameraConfig": { "title": "摄像头配置", @@ -1721,7 +1746,7 @@ "desc": "区域网格是一种优化功能,它会学习不同大小的目标通常出现在每个摄像头视野中的位置。Frigate 利用这些数据来高效地确定检测区域的大小。该网格会根据追踪目标数据自动构建。", "clear": "清除区域网格", "clearConfirmTitle": "清除区域网格", - "clearConfirmDesc": "除非您最近更改了检测器模型大小或摄像头的物理位置,并且遇到了目标追踪问题,否则不建议清除区域网格。网格会随着目标的追踪自动重建。更改需要重启 Frigate 才能生效。", + "clearConfirmDesc": "除非你最近更改了检测器模型大小或摄像头的物理位置,并且遇到了目标追踪问题,否则不建议清除区域网格。网格会随着目标的追踪自动重建。更改需要重启 Frigate 才能生效。", "clearSuccess": "区域网格清除成功", "clearError": "清除区域网格失败", "restartRequired": "需要重启以使区域网格更改生效" @@ -1756,7 +1781,14 @@ "overriddenGlobal": "已覆盖全局通用配置", "overriddenGlobalTooltip": "当前摄像头配置,将优先覆盖全局通用设置", "overriddenBaseConfigTooltip": "当前 {{profile}} 配置模板会覆盖本节所有设置", - "overriddenBaseConfig": "已覆盖默认配置" + "overriddenBaseConfig": "已覆盖默认配置", + "overriddenInCameras": { + "label_other": "已在 {{count}} 个摄像头中单独配置", + "tooltip_other": "{{count}} 个摄像头在此项中存在单独配置,点击查看详情。", + "heading_other": "此全局设置项下有 {{count}} 个摄像头存在自定义单独配置。", + "othersField_other": "其余 {{count}} 个", + "profilePrefix": "{{profile}} 配置方案:{{fields}}" + } }, "profiles": { "title": "配置模板", @@ -1795,7 +1827,7 @@ "deleteSection": "删除节点覆盖", "deleteSectionConfirm": "是否要移除摄像机 {{camera}} 上针对配置文件 {{profile}} 的 {{section}} 覆盖设置?", "deleteSectionSuccess": "已移除 {{profile}} 的 {{section}} 覆盖设置", - "enableSwitch": "开启配置文件", + "enableSwitch": "开启配置模板", "enabledDescription": "配置文件功能已启用。请在下方创建新的配置文件,进入摄像头配置页面进行修改并保存,修改即可生效。", "disabledDescription": "配置文件功能可以让你创建一组带名称的摄像头自定义参数(比如布防、离家、夜间模式),并随时切换启用。" }, @@ -1855,11 +1887,13 @@ "review": { "recordDisabled": "录制已禁用,不会生成核查记录项。", "detectDisabled": "目标检测已禁用。核查记录需要依靠检测到的目标来对警报和检测事件进行分类。", - "allNonAlertDetections": "所有非警报类活动都将被记录为检测事件。" + "allNonAlertDetections": "所有非警报类活动都将被记录为检测事件。", + "genaiImageSourceRecordingsRecordDisabled": "图像源虽然设置为“录制”,但录制功能已关闭。Frigate 将自动降级使用预览图片。" }, "lpr": { - "vehicleNotTracked": "车牌识别需要先开启对 “汽车” 或 “摩托车” 的目标追踪。", - "globalDisabled": "车牌识别未在全局开启。请在全局设置中开启该功能,才能在摄像头下单独配置车牌识别是否开启。" + "vehicleNotTracked": "车牌识别需要先开启对 “汽车” 或 “摩托车” 的目标追踪。请在该摄像头的检测目标中添加“汽车”或“摩托车”。", + "globalDisabled": "要让该摄像头的车牌识别功能正常使用,必须先开启车牌识别增强功能。", + "modelSizeLarge": "大型模型针对多行格式车牌做了优化。小型模型的性能优于大型模型,而且只有小型模型才能支持中文车牌。除非你所在地区使用多行车牌格式,否则建议使用小型模型。" }, "audio": { "noAudioRole": "暂无任何流已开启音频(audio)功能(role)。必须在视频流上启用音频功能,音频检测才能正常工作。" @@ -1868,11 +1902,13 @@ "audioDetectionDisabled": "该摄像头未开启音频检测功能。音频转录需要先开启音频检测。" }, "detect": { - "fpsGreaterThanFive": "不建议设置检测帧率高于 5。" + "fpsGreaterThanFive": "不建议设置检测帧率高于 5,数值设置过高可能引发性能问题,且不会带来任何增益。", + "disabled": "目标检测已禁用。快照、回放条目以及人脸识别、车牌识别、生成式 AI 等增强功能都将无法使用。" }, "faceRecognition": { - "globalDisabled": "人脸识别未在全局开启。请在全局设置中开启该功能,才能在摄像头下单独配置人脸识别是否开启。", - "personNotTracked": "人脸识别需要检测到 “人”(person) 后才能工作。请确保 “person” 已添加到目标追踪列表中。" + "globalDisabled": "必须开启人脸识别增强功能,此摄像头的人脸识别相关功能才能正常使用。", + "personNotTracked": "人脸识别需要检测到 “人”(person) 后才能工作。请在该摄像头的检测目标设置中添加“人”。", + "modelSizeLarge": "大型模型需要 GPU 或 NPU 才能运行正常。仅使用 CPU 的设备请选用小型模型。" }, "record": { "noRecordRole": "暂无任何视频流已配置录制功能,录制功能将无法正常工作。" @@ -1884,7 +1920,14 @@ "detectDisabled": "目标检测已禁用。快照是根据追踪到的目标生成的,因此将不会创建快照。" }, "detectors": { - "mixedTypes": "所有检测器必须为同一类型。若要更换为其他类型,请先移除现有的检测器。" + "mixedTypes": "所有检测器必须为同一类型。若要更换为其他类型,请先移除现有的检测器。", + "mixedTypesSuggestion": "所有检测器必须使用相同类型。请移除现有检测器,或选择 {{type}}。" + }, + "objects": { + "genaiNoDescriptionsProvider": "必须配置具备“描述”功能的生成式 AI 服务商,才能自动生成事件描述。" + }, + "semanticSearch": { + "jinav2SmallModelSize": "Jina V2 的大型模型版本内存占用与推理开销较高,建议搭配独立显卡使用大型模型。" } } } diff --git a/web/public/locales/zh-CN/views/system.json b/web/public/locales/zh-CN/views/system.json index 6e406674a7..79882b6afe 100644 --- a/web/public/locales/zh-CN/views/system.json +++ b/web/public/locales/zh-CN/views/system.json @@ -213,6 +213,9 @@ "expectedFps": "预期帧率", "reconnectsLastHour": "最近一小时重连次数", "stallsLastHour": "最近一小时卡顿次数" + }, + "noCameras": { + "title": "没有找到摄像头" } }, "lastRefreshed": "最后刷新时间: ", diff --git a/web/public/locales/zh-Hant/views/chat.json b/web/public/locales/zh-Hant/views/chat.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/zh-Hant/views/chat.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/zh-Hant/views/motionSearch.json b/web/public/locales/zh-Hant/views/motionSearch.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/zh-Hant/views/motionSearch.json @@ -0,0 +1 @@ +{} diff --git a/web/public/locales/zh-Hant/views/replay.json b/web/public/locales/zh-Hant/views/replay.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/web/public/locales/zh-Hant/views/replay.json @@ -0,0 +1 @@ +{} diff --git a/web/src/api/WsProvider.tsx b/web/src/api/WsProvider.tsx index 4e4f72490b..d00772f9d5 100644 --- a/web/src/api/WsProvider.tsx +++ b/web/src/api/WsProvider.tsx @@ -56,7 +56,14 @@ export function WsProvider({ children }: { children: ReactNode }) { if (reconnectTimer.current) { clearTimeout(reconnectTimer.current); } - wsRef.current?.close(); + const ws = wsRef.current; + if (ws) { + ws.onopen = null; + ws.onmessage = null; + ws.onclose = null; + ws.onerror = null; + ws.close(); + } resetWsStore(); }; }, [wsUrl]); diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 74495ddc70..1b7b11f3d7 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -17,6 +17,9 @@ import { useUserPersistence } from "@/hooks/use-user-persistence"; import { Skeleton } from "../ui/skeleton"; import { Button } from "../ui/button"; import { FaCircleCheck } from "react-icons/fa6"; +import { FaExclamationTriangle } from "react-icons/fa"; +import { MdOutlinePersonSearch } from "react-icons/md"; +import { ThreatLevel } from "@/types/review"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; @@ -127,6 +130,11 @@ export function AnimatedEventCard({ true, ); + const threatLevel = useMemo( + () => (event.data.metadata?.potential_threat_level ?? 0) as ThreatLevel, + [event], + ); + const aspectRatio = useMemo(() => { if ( !config || @@ -152,7 +160,15 @@ export function AnimatedEventCard({ {t("markAsReviewed")} diff --git a/web/src/components/card/EmptyCard.tsx b/web/src/components/card/EmptyCard.tsx index b9943b31a4..5495a6e50c 100644 --- a/web/src/components/card/EmptyCard.tsx +++ b/web/src/components/card/EmptyCard.tsx @@ -12,6 +12,7 @@ type EmptyCardProps = { description?: string; buttonText?: string; link?: string; + onClick?: () => void; }; export function EmptyCard({ className, @@ -21,6 +22,7 @@ export function EmptyCard({ description, buttonText, link, + onClick, }: EmptyCardProps) { let TitleComponent; @@ -39,11 +41,16 @@ export function EmptyCard({ {description}
    )} - {buttonText?.length && ( - - )} + {buttonText?.length && + (onClick ? ( + + ) : ( + + ))}
    ); } diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 966aab4dcc..893f251f8f 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -266,7 +266,7 @@ export function ExportCard({ )} {!exportedRecording.in_progress && !selectionMode && (
    - + { - if (response.status == 200) { + if (response.status < 300) { toast.success(t("export.toast.success"), { position: "top-center", action: ( @@ -275,7 +280,7 @@ export default function ReviewCard({ - + {content} diff --git a/web/src/components/chat/ChatEventThumbnailsRow.tsx b/web/src/components/chat/ChatEventThumbnailsRow.tsx index a12153e894..94eca3f2d6 100644 --- a/web/src/components/chat/ChatEventThumbnailsRow.tsx +++ b/web/src/components/chat/ChatEventThumbnailsRow.tsx @@ -6,7 +6,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; type ChatEvent = { id: string; score?: number }; @@ -37,10 +36,7 @@ export function ChatEventThumbnailsRow({ const renderThumb = (event: ChatEvent, isAnchor = false) => (
    ); diff --git a/web/src/components/chat/ChatMessage.tsx b/web/src/components/chat/ChatMessage.tsx index c5f92b5f46..0a5c02763f 100644 --- a/web/src/components/chat/ChatMessage.tsx +++ b/web/src/components/chat/ChatMessage.tsx @@ -17,6 +17,7 @@ import { import { cn } from "@/lib/utils"; import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip"; import { parseAttachedEvent } from "@/utils/chatUtil"; +import type { ChatStats, ShowStatsMode } from "@/types/chat"; type MessageBubbleProps = { role: "user" | "assistant"; @@ -24,14 +25,29 @@ type MessageBubbleProps = { messageIndex?: number; onEditSubmit?: (messageIndex: number, newContent: string) => void; isComplete?: boolean; + stats?: ChatStats; + showStats?: ShowStatsMode; }; +function formatTokens(n: number | undefined): string | null { + if (n === undefined) return null; + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + +function formatRate(rate: number | undefined): string | null { + if (rate === undefined || rate <= 0) return null; + return rate >= 10 ? rate.toFixed(0) : rate.toFixed(1); +} + export function MessageBubble({ role, content, messageIndex = 0, onEditSubmit, isComplete = true, + stats, + showStats = "while_generating", }: MessageBubbleProps) { const { t } = useTranslation(["views/chat", "common"]); const isUser = role === "user"; @@ -155,14 +171,40 @@ export function MessageBubble({ ) : (
    *:last-child]:inline", !isComplete && - "after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']", + "after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-[''] [&>p:last-child]:inline", )} > ( +

    + ), + ul: ({ node: _n, ...props }) => ( +

      + ), + ol: ({ node: _n, ...props }) => ( +
        + ), + li: ({ node: _n, ...props }) => ( +
      1. + ), + code: ({ node: _n, className, ...props }) => ( + + ), table: ({ node: _n, ...props }) => ( )} -
        +
        {isUser && onEditSubmit != null && ( @@ -230,6 +272,27 @@ export function MessageBubble({ )} + {!isUser && + stats && + (showStats === "always" || !isComplete) && + (() => { + const ctx = formatTokens(stats.promptTokens); + const rate = formatRate(stats.tokensPerSecond); + if (ctx === null && rate === null) return null; + return ( +
        + {ctx !== null && ( + {t("stats.context", { tokens: ctx })} + )} + {ctx !== null && rate !== null && ( + + )} + {rate !== null && ( + {t("stats.tokens_per_second", { rate })} + )} +
        + ); + })()}
        ); diff --git a/web/src/components/chat/ChatSettings.tsx b/web/src/components/chat/ChatSettings.tsx new file mode 100644 index 0000000000..0f68ef22d6 --- /dev/null +++ b/web/src/components/chat/ChatSettings.tsx @@ -0,0 +1,108 @@ +import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { isDesktop } from "react-device-detect"; +import { cn } from "@/lib/utils"; +import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; +import { FaCog } from "react-icons/fa"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; +import { useTranslation } from "react-i18next"; +import type { ShowStatsMode } from "@/types/chat"; + +type ChatSettingsProps = { + showStats: ShowStatsMode; + setShowStats: (mode: ShowStatsMode) => void; + autoScroll: boolean; + setAutoScroll: (enabled: boolean) => void; +}; + +export default function ChatSettings({ + showStats, + setShowStats, + autoScroll, + setAutoScroll, +}: ChatSettingsProps) { + const { t } = useTranslation(["views/chat"]); + const [open, setOpen] = useState(false); + + const trigger = ( + + ); + + const content = ( +
        +
        +
        +
        {t("settings.show_stats.title")}
        +
        + {t("settings.show_stats.desc")} +
        +
        + +
        + +
        +
        + +
        + {t("settings.auto_scroll.desc")} +
        +
        + +
        +
        + ); + + return ( + + ); +} diff --git a/web/src/components/chat/ChatStartingState.tsx b/web/src/components/chat/ChatStartingState.tsx index e6b611bf9a..a0a3a044c8 100644 --- a/web/src/components/chat/ChatStartingState.tsx +++ b/web/src/components/chat/ChatStartingState.tsx @@ -54,7 +54,9 @@ export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {

        {t("title")}

        -

        {t("subtitle")}

        +

        + {t("subtitle")} +

        diff --git a/web/src/components/classification/ClassificationModelWizardDialog.tsx b/web/src/components/classification/ClassificationModelWizardDialog.tsx index 0c43b99425..d9b3b64e9b 100644 --- a/web/src/components/classification/ClassificationModelWizardDialog.tsx +++ b/web/src/components/classification/ClassificationModelWizardDialog.tsx @@ -14,7 +14,6 @@ import Step3ChooseExamples, { Step3FormData, } from "./wizard/Step3ChooseExamples"; import { cn } from "@/lib/utils"; -import { isDesktop } from "react-device-detect"; import axios from "axios"; const OBJECT_STEPS = [ @@ -153,13 +152,9 @@ export default function ClassificationModelWizardDialog({ > 0 && - "max-h-[90%] max-w-[70%] overflow-y-auto xl:max-h-[80%]", + "scrollbar-container max-h-[90%] overflow-y-auto", + wizardState.currentStep == 0 && "xl:max-h-[80%]", + wizardState.currentStep > 0 && "md:max-w-[70%] xl:max-h-[80%]", )} onInteractOutside={(e) => { e.preventDefault(); diff --git a/web/src/components/classification/wizard/Step1NameAndDefine.tsx b/web/src/components/classification/wizard/Step1NameAndDefine.tsx index a4cdc4867f..e6fe64bfc9 100644 --- a/web/src/components/classification/wizard/Step1NameAndDefine.tsx +++ b/web/src/components/classification/wizard/Step1NameAndDefine.tsx @@ -322,7 +322,7 @@ export default function Step1NameAndDefine({ {t("wizard.step1.classificationType")} - +