Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 5d0d89a3f2 Bump mermaid from 11.12.2 to 11.15.0 in /docs
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.12.2 to 11.15.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.2...mermaid@11.15.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 22:56:50 +00:00
303 changed files with 3898 additions and 22796 deletions
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+401
View File
@@ -0,0 +1,401 @@
# GitHub Copilot Instructions for Frigate NVR
This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
## Project Overview
Frigate NVR is a realtime object detection system for IP cameras that uses:
- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
- **Frontend**: React with TypeScript, Vite, TailwindCSS
- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
- **Focus**: Minimal resource usage with maximum performance
## Code Review Guidelines
When reviewing code, do NOT comment on:
- Missing imports - Static analysis tooling catches these
- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
- Minor style inconsistencies already enforced by linters
## Python Backend Standards
### Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use modern Python features:
- Pattern matching
- Type hints (comprehensive typing preferred)
- f-strings (preferred over `%` or `.format()`)
- Dataclasses
- Async/await patterns
### Code Quality Standards
- **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff with rules defined in project config
- **Type Checking**: Use type hints consistently
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
- **Language**: American English for all code, comments, and documentation
### Logging Standards
- **Logger Pattern**: Use module-level logger
```python
import logging
logger = logging.getLogger(__name__)
```
- **Format Guidelines**:
- No periods at end of log messages
- No sensitive data (keys, tokens, passwords)
- Use lazy logging: `logger.debug("Message with %s", variable)`
- **Log Levels**:
- `debug`: Development and troubleshooting information
- `info`: Important runtime events (startup, shutdown, state changes)
- `warning`: Recoverable issues that should be addressed
- `error`: Errors that affect functionality but don't crash the app
- `exception`: Use in except blocks to include traceback
### Error Handling
- **Exception Types**: Choose most specific exception available
- **Try/Catch Best Practices**:
- Only wrap code that can throw exceptions
- Keep try blocks minimal - process data after the try/except
- Avoid bare exceptions except in background tasks
Bad pattern:
```python
try:
data = await device.get_data() # Can throw
# ❌ Don't process data inside try block
processed = data.get("value", 0) * 100
result = processed
except DeviceError:
logger.error("Failed to get data")
```
Good pattern:
```python
try:
data = await device.get_data() # Can throw
except DeviceError:
logger.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
result = processed
```
### Async Programming
- **External I/O**: All external I/O operations must be async
- **Best Practices**:
- Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
- Avoid awaiting in loops - use `asyncio.gather()` instead
- No blocking calls in async functions
- Use `asyncio.create_task()` for background operations
- **Thread Safety**: Use proper synchronization for shared state
### Documentation Standards
- **Module Docstrings**: Concise descriptions at top of files
```python
"""Utilities for motion detection and analysis."""
```
- **Function Docstrings**: Required for public functions and methods
```python
async def process_frame(frame: ndarray, config: Config) -> Detection:
"""Process a video frame for object detection.
Args:
frame: The video frame as numpy array
config: Detection configuration
Returns:
Detection results with bounding boxes
"""
```
- **Comment Style**:
- Explain the "why" not just the "what"
- Keep lines under 88 characters when possible
- Use clear, descriptive comments
### File Organization
- **API Endpoints**: `frigate/api/` - FastAPI route handlers
- **Configuration**: `frigate/config/` - Configuration parsing and validation
- **Detectors**: `frigate/detectors/` - Object detection backends
- **Events**: `frigate/events/` - Event management and storage
- **Utilities**: `frigate/util/` - Shared utility functions
## Frontend (React/TypeScript) Standards
### Internationalization (i18n)
- **CRITICAL**: Never write user-facing strings directly in components
- **Always use react-i18next**: Import and use the `t()` function
```tsx
import { useTranslation } from "react-i18next";
function MyComponent() {
const { t } = useTranslation(["views/live"]);
return <div>{t("camera_not_found")}</div>;
}
```
- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
### Code Quality
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
- **Formatting**: Prettier with Tailwind CSS plugin
- **Type Safety**: TypeScript strict mode enabled
- **Testing**: Vitest for unit tests
### Component Patterns
- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
- **Styling**: TailwindCSS with `cn()` utility for class merging
- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
- **Data Fetching**: Custom hooks with proper loading and error states
### ESLint Rules
Key rules enforced:
- `react-hooks/rules-of-hooks`: error
- `react-hooks/exhaustive-deps`: error
- `no-console`: error (use proper logging or remove)
- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
- Unused variables must be prefixed with `_`
- Comma dangles required for multiline objects/arrays
### File Organization
- **Pages**: `web/src/pages/` - Route components
- **Views**: `web/src/views/` - Complex view components
- **Components**: `web/src/components/` - Reusable components
- **Hooks**: `web/src/hooks/` - Custom React hooks
- **API**: `web/src/api/` - API client functions
- **Types**: `web/src/types/` - TypeScript type definitions
## Testing Requirements
### Backend Testing
- **Framework**: Python unittest
- **Run Command**: `python3 -u -m unittest`
- **Location**: `frigate/test/`
- **Coverage**: Aim for comprehensive test coverage of core functionality
- **Pattern**: Use `TestCase` classes with descriptive test method names
```python
class TestMotionDetection(unittest.TestCase):
def test_detects_motion_above_threshold(self):
# Test implementation
```
### Test Best Practices
- Always have a way to test your work and confirm your changes
- Write tests for bug fixes to prevent regressions
- Test edge cases and error conditions
- Mock external dependencies (cameras, APIs, hardware)
- Use fixtures for test data
## Development Commands
### Python Backend
```bash
# Run all tests
python3 -u -m unittest
# Run specific test file
python3 -u -m unittest frigate.test.test_ffmpeg_presets
# Check formatting (Ruff)
ruff format --check frigate/
# Apply formatting
ruff format frigate/
# Run linter
ruff check frigate/
```
### Frontend (from web/ directory)
```bash
# Start dev server (AI agents should never run this directly unless asked)
npm run dev
# Build for production
npm run build
# Run linter
npm run lint
# Fix linting issues
npm run lint:fix
# Format code
npm run prettier:write
```
### Docker Development
AI agents should never run these commands directly unless instructed.
```bash
# Build local image
make local
# Build debug image
make debug
```
## Common Patterns
### API Endpoint Pattern
```python
from fastapi import APIRouter, Request
from frigate.api.defs.tags import Tags
router = APIRouter(tags=[Tags.Events])
@router.get("/events")
async def get_events(request: Request, limit: int = 100):
"""Retrieve events from the database."""
# Implementation
```
### Configuration Access
```python
# Access Frigate configuration
config: FrigateConfig = request.app.frigate_config
camera_config = config.cameras["front_door"]
```
### Database Queries
```python
from frigate.models import Event
# Use Peewee ORM for database access
events = (
Event.select()
.where(Event.camera == camera_name)
.order_by(Event.start_time.desc())
.limit(limit)
)
```
## Common Anti-Patterns to Avoid
### ❌ Avoid These
```python
# Blocking operations in async functions
data = requests.get(url) # ❌ Use async HTTP client
time.sleep(5) # ❌ Use asyncio.sleep()
# Hardcoded strings in React components
<div>Camera not found</div> # ❌ Use t("camera_not_found")
# Missing error handling
data = await api.get_data() # ❌ No exception handling
# Bare exceptions in regular code
try:
value = await sensor.read()
except Exception: # ❌ Too broad
logger.error("Failed")
# Returning exceptions in JSON responses
except ValueError as e:
return JSONResponse(
content={"success": False, "message": str(e)},
)
```
### ✅ Use These Instead
```python
# Async operations
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
await asyncio.sleep(5) # ✅ Non-blocking
# Translatable strings in React
const { t } = useTranslation();
<div>{t("camera_not_found")}</div> # ✅ Translatable
# Proper error handling
try:
data = await api.get_data()
except ApiException as err:
logger.error("API error: %s", err)
raise
# Specific exceptions
try:
value = await sensor.read()
except SensorException as err: # ✅ Specific
logger.exception("Failed to read sensor")
# Safe error responses
except ValueError:
logger.exception("Invalid parameters for API request")
return JSONResponse(
content={
"success": False,
"message": "Invalid request parameters",
},
)
```
## Project-Specific Conventions
### Configuration Files
- Main config: `config/config.yml`
### Directory Structure
- Backend code: `frigate/`
- Frontend code: `web/`
- Docker files: `docker/`
- Documentation: `docs/`
- Database migrations: `migrations/`
### Code Style Conformance
Always conform new and refactored code to the existing coding style in the project:
- Follow established patterns in similar files
- Match indentation and formatting of surrounding code
- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
- Maintain the same level of verbosity in comments and docstrings
## Additional Resources
- Documentation: https://docs.frigate.video
- Main Repository: https://github.com/blakeblackshear/frigate
- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
-439
View File
@@ -1,439 +0,0 @@
# Agent Instructions for Frigate NVR
This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
## Project Overview
Frigate NVR is a realtime object detection system for IP cameras that uses:
- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
- **Frontend**: React with TypeScript, Vite, TailwindCSS
- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
- **Focus**: Minimal resource usage with maximum performance
## Code Review Guidelines
When reviewing code, do NOT comment on:
- Missing imports - Static analysis tooling catches these
- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
- Minor style inconsistencies already enforced by linters
## Python Backend Standards
### Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use modern Python features:
- Pattern matching
- Type hints (comprehensive typing preferred)
- f-strings (preferred over `%` or `.format()`)
- Dataclasses
- Async/await patterns
### Code Quality Standards
- **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff with rules defined in project config
- **Type Checking**: Use type hints consistently
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
- **Language**: American English for all code, comments, and documentation
### Logging Standards
- **Logger Pattern**: Use module-level logger
```python
import logging
logger = logging.getLogger(__name__)
```
- **Format Guidelines**:
- No periods at end of log messages
- No sensitive data (keys, tokens, passwords)
- Use lazy logging: `logger.debug("Message with %s", variable)`
- **Log Levels**:
- `debug`: Development and troubleshooting information
- `info`: Important runtime events (startup, shutdown, state changes)
- `warning`: Recoverable issues that should be addressed
- `error`: Errors that affect functionality but don't crash the app
- `exception`: Use in except blocks to include traceback
### Error Handling
- **Exception Types**: Choose most specific exception available
- **Try/Catch Best Practices**:
- Only wrap code that can throw exceptions
- Keep try blocks minimal - process data after the try/except
- Avoid bare exceptions except in background tasks
Bad pattern:
```python
try:
data = await device.get_data() # Can throw
# ❌ Don't process data inside try block
processed = data.get("value", 0) * 100
result = processed
except DeviceError:
logger.error("Failed to get data")
```
Good pattern:
```python
try:
data = await device.get_data() # Can throw
except DeviceError:
logger.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
result = processed
```
### Async Programming
- **External I/O**: All external I/O operations must be async
- **Best Practices**:
- Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
- Avoid awaiting in loops - use `asyncio.gather()` instead
- No blocking calls in async functions
- Use `asyncio.create_task()` for background operations
- **Thread Safety**: Use proper synchronization for shared state
### Documentation Standards
- **Module Docstrings**: Concise descriptions at top of files
```python
"""Utilities for motion detection and analysis."""
```
- **Function Docstrings**: Required for public functions and methods
```python
async def process_frame(frame: ndarray, config: Config) -> Detection:
"""Process a video frame for object detection.
Args:
frame: The video frame as numpy array
config: Detection configuration
Returns:
Detection results with bounding boxes
"""
```
- **Comment Style**:
- Explain the "why" not just the "what"
- Keep lines under 88 characters when possible
- Use clear, descriptive comments
### File Organization
- **API Endpoints**: `frigate/api/` - FastAPI route handlers
- **Configuration**: `frigate/config/` - Configuration parsing and validation
- **Detectors**: `frigate/detectors/` - Object detection backends
- **Events**: `frigate/events/` - Event management and storage
- **Utilities**: `frigate/util/` - Shared utility functions
## Frontend (React/TypeScript) Standards
### Internationalization (i18n)
- **CRITICAL**: Never write user-facing strings directly in components
- **Always use react-i18next**: Import and use the `t()` function
```tsx
import { useTranslation } from "react-i18next";
function MyComponent() {
const { t } = useTranslation(["views/live"]);
return <div>{t("camera_not_found")}</div>;
}
```
- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
### Code Quality
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
- **Formatting**: Prettier with Tailwind CSS plugin
- **Type Safety**: TypeScript strict mode enabled
### Component Patterns
- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
- **Styling**: TailwindCSS with `cn()` utility for class merging
- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
- **Data Fetching**: Custom hooks with proper loading and error states
### ESLint Rules
Key rules enforced:
- `react-hooks/rules-of-hooks`: error
- `react-hooks/exhaustive-deps`: error
- `no-console`: error (use proper logging or remove)
- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
- Unused variables must be prefixed with `_`
- Comma dangles required for multiline objects/arrays
### File Organization
- **Pages**: `web/src/pages/` - Route components
- **Views**: `web/src/views/` - Complex view components
- **Components**: `web/src/components/` - Reusable components
- **Hooks**: `web/src/hooks/` - Custom React hooks
- **API**: `web/src/api/` - API client functions
- **Types**: `web/src/types/` - TypeScript type definitions
## Testing Requirements
### Backend Testing
- **Framework**: Python unittest
- **Run Command**: `python3 -u -m unittest`
- **Location**: `frigate/test/`
- **Coverage**: Aim for comprehensive test coverage of core functionality
- **Pattern**: Use `TestCase` classes with descriptive test method names
```python
class TestMotionDetection(unittest.TestCase):
def test_detects_motion_above_threshold(self):
# Test implementation
```
### Test Best Practices
- Always have a way to test your work and confirm your changes
- Write tests for bug fixes to prevent regressions
- Test edge cases and error conditions
- Mock external dependencies (cameras, APIs, hardware)
- Use fixtures for test data
## Development Commands
### Python Backend
```bash
# Run all tests
python3 -u -m unittest
# Run specific test file
python3 -u -m unittest frigate.test.test_ffmpeg_presets
# Check formatting (Ruff)
ruff format --check frigate/
# Apply formatting
ruff format frigate/
# Run linter
ruff check frigate/
# Type check
python3 -u -m mypy --config-file frigate/mypy.ini frigate
```
### Frontend (from web/ directory)
```bash
# Start dev server (AI agents should never run this directly unless asked)
npm run dev
# Build for production
npm run build
# Run linter
npm run lint
# Fix linting issues
npm run lint:fix
# Format code
npm run prettier:write
# E2E: first-time setup
npm install
npx playwright install chromium
# E2E: build the app and run all tests
npm run e2e:build && npm run e2e
# E2E: interactive UI for debugging
npm run e2e:ui
# E2E: run a specific spec
npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts
# E2E: filter by name, or run only desktop/mobile
npx playwright test --config e2e/playwright.config.ts --grep="severity tab"
npx playwright test --config e2e/playwright.config.ts --project=desktop
# E2E: regenerate mock data after backend model changes (from repo root)
PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py
# Regenerate config translations from Pydantic models — outputs to
# web/public/locales/en/config/{global,cameras}.json. NEVER edit those
# JSON files by hand; change the Pydantic field title/description and
# re-run this script. (from repo root)
python3 generate_config_translations.py
# Extract i18n keys from source into the locale files after adding
# new t() calls. Use the :ci variant to verify the locale files are
# in sync with source (fails if extraction would change anything).
npm run i18n:extract
npm run i18n:extract:ci
```
### Docker Development
AI agents should never run these commands directly unless instructed.
```bash
# Build local image
make local
# Build debug image
make debug
```
## Common Patterns
### API Endpoint Pattern
```python
from fastapi import APIRouter, Request
from frigate.api.defs.tags import Tags
router = APIRouter(tags=[Tags.Events])
@router.get("/events")
async def get_events(request: Request, limit: int = 100):
"""Retrieve events from the database."""
# Implementation
```
### Configuration Access
```python
# Access Frigate configuration
config: FrigateConfig = request.app.frigate_config
camera_config = config.cameras["front_door"]
```
### Database Queries
```python
from frigate.models import Event
# Use Peewee ORM for database access
events = (
Event.select()
.where(Event.camera == camera_name)
.order_by(Event.start_time.desc())
.limit(limit)
)
```
## Common Anti-Patterns to Avoid
### ❌ Avoid These
```python
# Blocking operations in async functions
data = requests.get(url) # ❌ Use async HTTP client
time.sleep(5) # ❌ Use asyncio.sleep()
# Hardcoded strings in React components
<div>Camera not found</div> # ❌ Use t("camera_not_found")
# Missing error handling
data = await api.get_data() # ❌ No exception handling
# Bare exceptions in regular code
try:
value = await sensor.read()
except Exception: # ❌ Too broad
logger.error("Failed")
# Returning exceptions in JSON responses
except ValueError as e:
return JSONResponse(
content={"success": False, "message": str(e)},
)
```
### ✅ Use These Instead
```python
# Async operations
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
await asyncio.sleep(5) # ✅ Non-blocking
# Translatable strings in React
const { t } = useTranslation();
<div>{t("camera_not_found")}</div> # ✅ Translatable
# Proper error handling
try:
data = await api.get_data()
except ApiException as err:
logger.error("API error: %s", err)
raise
# Specific exceptions
try:
value = await sensor.read()
except SensorException as err: # ✅ Specific
logger.exception("Failed to read sensor")
# Safe error responses
except ValueError:
logger.exception("Invalid parameters for API request")
return JSONResponse(
content={
"success": False,
"message": "Invalid request parameters",
},
)
```
## WebSocket Broadcasts
Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear.
## Project-Specific Conventions
### Configuration Files
- Main config: `config/config.yml`
### Directory Structure
- Backend code: `frigate/`
- Frontend code: `web/`
- Docker files: `docker/`
- Documentation: `docs/`
- Database migrations: `migrations/`
### Code Style Conformance
Always conform new and refactored code to the existing coding style in the project:
- Follow established patterns in similar files
- Match indentation and formatting of surrounding code
- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
- Maintain the same level of verbosity in comments and docstrings
## Additional Resources
- Documentation: https://docs.frigate.video
- Main Repository: https://github.com/blakeblackshear/frigate
- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
-1
View File
@@ -1 +0,0 @@
AGENTS.md
@@ -3,6 +3,7 @@
import json
import os
import sys
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
@@ -17,12 +18,37 @@ 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:
@@ -102,13 +128,18 @@ 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 is_restricted_go2rtc_source(formatted_stream):
if not ALLOW_ARBITRARY_EXEC and is_restricted_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."
@@ -127,7 +158,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 is_restricted_go2rtc_source(formatted_stream):
if not ALLOW_ARBITRARY_EXEC and is_restricted_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."
+1 -1
View File
@@ -172,7 +172,7 @@ Custom models may also require different input tensor formats. The colorspace co
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and open the **Custom Model** tab to configure the model path, dimensions, and input format.
Navigate to <NavPath path="Settings > System > Detection model" /> to configure the model path, dimensions, and input format.
| Field | Description |
| --------------------------------------------- | ------------------------------------ |
+4 -4
View File
@@ -110,7 +110,7 @@ Here are some common starter configuration examples. These can be configured thr
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the MQTT connection to your Home Assistant Mosquitto broker
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)`
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
3. Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
@@ -189,7 +189,7 @@ cameras:
1. Navigate to <NavPath path="Settings > System > MQTT" /> and set **Enable MQTT** to off
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
3. Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
@@ -266,8 +266,8 @@ cameras:
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the connection to your MQTT broker
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `openvino` and **Device** `AUTO`
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
3. Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `openvino` and **Device** `AUTO`
4. Navigate to <NavPath path="Settings > System > Detection model" /> and configure the OpenVINO model path and settings
5. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
6. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
7. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
+44 -44
View File
@@ -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
@@ -91,7 +91,7 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
</TabItem>
<TabItem value="yaml">
@@ -111,7 +111,7 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
</TabItem>
<TabItem value="yaml">
@@ -136,7 +136,7 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
</TabItem>
<TabItem value="yaml">
@@ -156,7 +156,7 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
</TabItem>
<TabItem value="yaml">
@@ -176,7 +176,7 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
</TabItem>
<TabItem value="yaml">
@@ -199,7 +199,7 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
</TabItem>
<TabItem value="yaml">
@@ -246,7 +246,7 @@ After placing the downloaded files for the tflite model and labels in your confi
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then on the same page, in the **Custom Model** tab, configure the model settings:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
| Field | Value |
| ---------------------------------------- | ----------------------------------------------------------------- |
@@ -309,7 +309,7 @@ Use this configuration for YOLO-based models. When no custom model path or URL i
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
| Field | Value |
| ---------------------------------------- | ----------------------- |
@@ -365,7 +365,7 @@ For SSD-based models, provide either a model path or URL to your compiled SSD mo
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
| Field | Value |
| --------------------------------------- | ------ |
@@ -410,7 +410,7 @@ The Hailo detector supports all YOLO models compiled for Hailo hardware that inc
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings to match your custom model dimensions and format.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings to match your custom model dimensions and format.
</TabItem>
<TabItem value="yaml">
@@ -465,7 +465,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add** to add multiple detectors, each targeting `GPU` or `NPU`.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add** to add multiple detectors, each targeting `GPU` or `NPU`.
</TabItem>
<TabItem value="yaml">
@@ -508,7 +508,7 @@ Use the model configuration shown below when using the OpenVINO detector with th
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------ |
@@ -558,7 +558,7 @@ After placing the downloaded onnx model in your config folder, use the following
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------- |
@@ -620,7 +620,7 @@ After placing the downloaded onnx model in your config folder, use the following
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@@ -676,7 +676,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| --------------------------------------- | --------------------------------- |
@@ -728,7 +728,7 @@ After placing the downloaded onnx model in your config/model_cache folder, use t
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------------- |
@@ -807,7 +807,7 @@ Using the detector config below will connect to the client:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`.
</TabItem>
<TabItem value="yaml">
@@ -841,7 +841,7 @@ When Frigate is started with the following config it will connect to the detecto
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@@ -1002,7 +1002,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add** to add multiple detectors.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add** to add multiple detectors.
</TabItem>
<TabItem value="yaml">
@@ -1050,7 +1050,7 @@ After placing the downloaded onnx model in your config folder, use the following
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------- |
@@ -1109,7 +1109,7 @@ After placing the downloaded onnx model in your config folder, use the following
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@@ -1158,7 +1158,7 @@ After placing the downloaded onnx model in your config folder, use the following
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@@ -1207,7 +1207,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| --------------------------------------- | --------------------------------- |
@@ -1252,7 +1252,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------- |
@@ -1328,7 +1328,7 @@ A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).
</TabItem>
<TabItem value="yaml">
@@ -1364,7 +1364,7 @@ To integrate CodeProject.AI into Frigate, configure the detector as follows:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
</TabItem>
<TabItem value="yaml">
@@ -1403,7 +1403,7 @@ To configure the MemryX detector, use the following example configuration:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`.
</TabItem>
<TabItem value="yaml">
@@ -1423,7 +1423,7 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add** to add multiple detectors, specifying `PCIe:0`, `PCIe:1`, `PCIe:2`, etc. as the device for each.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add** to add multiple detectors, specifying `PCIe:0`, `PCIe:1`, `PCIe:2`, etc. as the device for each.
</TabItem>
<TabItem value="yaml">
@@ -1467,7 +1467,7 @@ Below is the recommended configuration for using the **YOLO-NAS** (small) model
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------- |
@@ -1515,7 +1515,7 @@ Below is the recommended configuration for using the **YOLOv9** (small) model wi
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------- |
@@ -1562,7 +1562,7 @@ Below is the recommended configuration for using the **YOLOX** (small) model wit
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ----------------------- |
@@ -1609,7 +1609,7 @@ Below is the recommended configuration for using the **SSDLite MobileNet v2** mo
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ----------------------- |
@@ -1768,7 +1768,7 @@ Use the config below to work with generated TRT models:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------------------ |
@@ -1825,7 +1825,7 @@ Use the model configuration shown below when using the synaptics detector with t
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Synaptics** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------- |
@@ -1879,7 +1879,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **RKNN** from the detector type dropdown and click **Add** to add multiple detectors, each with `num_cores` set to `0` for automatic selection.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **RKNN** from the detector type dropdown and click **Add** to add multiple detectors, each with `num_cores` set to `0` for automatic selection.
</TabItem>
<TabItem value="yaml">
@@ -1921,7 +1921,7 @@ This `config.yml` shows all relevant options to configure the detector and expla
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **RKNN** from the detector type dropdown and click **Add**. Set `num_cores` to `0` for automatic selection (increase for better performance on multicore NPUs, e.g., set to `3` on rk3588).
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **RKNN** from the detector type dropdown and click **Add**. Set `num_cores` to `0` for automatic selection (increase for better performance on multicore NPUs, e.g., set to `3` on rk3588).
</TabItem>
<TabItem value="yaml">
@@ -1958,7 +1958,7 @@ The inference time was determined on a rk3588 with 3 NPU cores.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ----------------------------------------------------------------------- |
@@ -2004,7 +2004,7 @@ The pre-trained YOLO-NAS weights from DeciAI are subject to their license and ca
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------- |
@@ -2044,7 +2044,7 @@ model: # required
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------------------------- |
@@ -2138,7 +2138,7 @@ Once completed, configure the detector as follows:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to your AI server (e.g., service name, container name, or `host:port`), the zoo to `degirum/public`, and provide your authentication token if needed.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to your AI server (e.g., service name, container name, or `host:port`), the zoo to `degirum/public`, and provide your authentication token if needed.
</TabItem>
<TabItem value="yaml">
@@ -2181,7 +2181,7 @@ It is also possible to eliminate the need for an AI server and run the hardware
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@local`, the zoo to `degirum/public`, and provide your authentication token.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@local`, the zoo to `degirum/public`, and provide your authentication token.
</TabItem>
<TabItem value="yaml">
@@ -2218,7 +2218,7 @@ If you do not possess whatever hardware you want to run, there's also the option
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@cloud`, the zoo to `degirum/public`, and provide your authentication token.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@cloud`, the zoo to `degirum/public`, and provide your authentication token.
</TabItem>
<TabItem value="yaml">
@@ -2274,7 +2274,7 @@ Use the model configuration shown below when using the axengine detector with th
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
| Field | Value |
| ---------------------------------------- | ----------------------- |
+3 -3
View File
@@ -204,8 +204,8 @@ You need to refer to **Configure hardware acceleration** above to enable the con
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `OpenVINO` and **Device** `GPU`
2. On the same page, in the **Custom Model** tab, configure the model settings for OpenVINO:
1. Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `OpenVINO` and **Device** `GPU`
2. Navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings for OpenVINO:
| Field | Value |
| ---------------------------------------- | ------------------------------------------ |
@@ -273,7 +273,7 @@ services:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`.
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`.
</TabItem>
<TabItem value="yaml">
+1 -3
View File
@@ -3,8 +3,6 @@ id: plus
title: Frigate+
---
import NavPath from "@site/src/components/NavPath";
For more information about how to use Frigate+ to improve your model, see the [Frigate+ docs](/plus/).
:::info
@@ -59,7 +57,7 @@ You can view all of your submitted images at [https://plus.frigate.video](https:
Once you have [requested your first model](../plus/first_model.md) and gotten your own model ID, it can be used with a special model path. No other information needs to be configured for Frigate+ models because it fetches the remaining config from Frigate+ automatically.
You can either choose the new model from the <NavPath path="Settings > System > Detectors and model" /> pane in the Frigate UI (the **Frigate+ Model** tab), or manually set the model at the root level in your config:
You can either choose the new model from the Frigate+ pane in the Settings page of the Frigate UI, or manually set the model at the root level in your config:
```yaml
detectors: ...
@@ -37,8 +37,6 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real-time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature.
### When to use
- Reproducing a detection or tracking issue from a specific time range
+56 -159
View File
@@ -2072,43 +2072,10 @@
"integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==",
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.0.3",
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@colors/colors": {
@@ -4504,12 +4471,12 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz",
"integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
"license": "MIT",
"dependencies": {
"langium": "3.3.1"
"@chevrotain/types": "~11.1.1"
}
},
"node_modules/@nodelib/fs.scandir": {
@@ -5995,6 +5962,16 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
"integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
"license": "MIT",
"optionalDependencies": {
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1"
}
},
"node_modules/@vercel/oidc": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
@@ -7199,32 +7176,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chevrotain": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
"@chevrotain/regexp-to-ast": "11.0.3",
"@chevrotain/types": "11.0.3",
"@chevrotain/utils": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^11.0.0"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -8560,9 +8511,9 @@
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
@@ -8796,9 +8747,9 @@
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
"integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
"integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
@@ -8973,9 +8924,9 @@
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
@@ -10499,6 +10450,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/es6-promise": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
@@ -13058,22 +13019,6 @@
"node": ">=6"
}
},
"node_modules/langium": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
"integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.0.3",
"chevrotain-allstar": "~0.3.0",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.0.8"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/latest-version": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz",
@@ -13190,9 +13135,9 @@
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"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.debounce": {
@@ -13840,31 +13785,32 @@
}
},
"node_modules/mermaid": {
"version": "11.12.2",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz",
"integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.1",
"@mermaid-js/parser": "^0.6.3",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.1.1",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.13",
"dayjs": "^1.11.18",
"dompurify": "^3.2.5",
"katex": "^0.16.22",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"es-toolkit": "^1.45.1",
"katex": "^0.16.25",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^16.2.1",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/methods": {
@@ -20210,9 +20156,9 @@
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/roughjs": {
@@ -22561,55 +22507,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"license": "MIT"
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+2 -2
View File
@@ -63,8 +63,8 @@ SYSTEM_NAV: dict[str, tuple[str, str]] = {
"environment_vars": ("System", "Environment variables"),
"telemetry": ("System", "Telemetry"),
"birdseye": ("System", "Birdseye"),
"detectors": ("System", "Detectors and model"),
"model": ("System", "Detectors and model"),
"detectors": ("System", "Detector hardware"),
"model": ("System", "Detection model"),
}
# All known top-level config section keys
+8 -67
View File
@@ -96,46 +96,11 @@ def version():
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
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)
def stats(request: Request):
return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
@router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))])
@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())])
def stats_history(request: Request, keys: str = None):
if keys:
keys = keys.split(",")
@@ -774,8 +739,6 @@ def config_set(request: Request, body: AppConfigSetBody):
if request.app.dispatcher is not None:
request.app.dispatcher.config = config
for comm in request.app.dispatcher.comms:
comm.config = config
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
@@ -872,7 +835,7 @@ def nvinfo():
@router.get(
"/logs/{service}",
tags=[Tags.logs],
dependencies=[Depends(require_role(["admin"]))],
dependencies=[Depends(allow_any_authenticated())],
)
async def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
@@ -1077,27 +1040,12 @@ def get_media_sync_status(job_id: str):
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
def get_labels(
camera: str = "",
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
def get_labels(camera: str = ""):
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)
.where(Event.camera << allowed_cameras)
.distinct()
)
events = Event.select(Event.label).distinct()
except Exception as e:
logger.error(e)
return JSONResponse(
@@ -1110,16 +1058,9 @@ def get_labels(
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
def get_sub_labels(
split_joined: Optional[int] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
def get_sub_labels(split_joined: Optional[int] = None):
try:
events = (
Event.select(Event.sub_label)
.where(Event.camera << allowed_cameras)
.distinct()
)
events = Event.select(Event.sub_label).distinct()
except Exception:
return JSONResponse(
content=({"success": False, "message": "Failed to get sub_labels"}),
+5 -40
View File
@@ -19,9 +19,7 @@ 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,
)
@@ -33,12 +31,11 @@ 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, is_restricted_go2rtc_source
from frigate.util.services import ffprobe_stream
logger = logging.getLogger(__name__)
@@ -69,7 +66,7 @@ def _is_valid_host(host: str) -> bool:
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
async def go2rtc_streams(request: Request):
def go2rtc_streams():
r = requests.get("http://127.0.0.1:1984/api/streams")
if not r.ok:
logger.error("Failed to fetch streams from go2rtc")
@@ -78,24 +75,6 @@ async def go2rtc_streams(request: Request):
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", ""))
@@ -147,24 +126,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
params = {"name": stream_name}
if src:
try:
resolved_src = substitute_frigate_vars(src)
params["src"] = substitute_frigate_vars(src)
except KeyError:
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
params["src"] = src
r = requests.put(
"http://127.0.0.1:1984/api/streams",
@@ -1002,6 +966,7 @@ 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(
{
+395 -225
View File
@@ -10,7 +10,7 @@ from functools import reduce
from typing import Any, Dict, List, Optional
import cv2
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi import APIRouter, Body, Depends, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
@@ -35,13 +35,9 @@ from frigate.api.defs.response.chat_response import (
ToolCall,
)
from frigate.api.defs.tags import Tags
from frigate.api.event import _build_attribute_filter_clause, events
from frigate.api.event import events
from frigate.config import FrigateConfig
from frigate.genai.prompts import (
build_chat_system_prompt,
get_attribute_classifications,
get_tool_definitions,
)
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,
@@ -72,21 +68,338 @@ class VLMMonitorRequest(BaseModel):
zones: List[str] = []
def get_tool_definitions() -> 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.
"""
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."
),
"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,
},
},
},
"required": [],
},
},
{
"type": "function",
"function": {
"name": "find_similar_objects",
"description": (
"Find tracked objects that are visually and semantically similar "
"to a specific past event. Use this when the user references a "
"particular object they have seen and wants to find other "
"sightings of the same or similar one ('that green car', 'the "
"person in the red jacket', 'the package that was delivered'). "
"Prefer this over search_objects whenever the user's intent is "
"'find more like this specific one.' Use search_objects first "
"only if you need to locate the anchor event. Requires semantic "
"search to be enabled."
),
"parameters": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "The id of the anchor event to find similar objects to.",
},
"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').",
},
"cameras": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of cameras to restrict to. Defaults to all.",
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
},
"sub_labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of sub_labels (names) to restrict to.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of zones. An event matches if any of its zones overlap.",
},
"similarity_mode": {
"type": "string",
"enum": ["visual", "semantic", "fused"],
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
"default": "fused",
},
"min_score": {
"type": "number",
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default: 10).",
"default": 10,
},
},
"required": ["event_id"],
},
},
},
{
"type": "function",
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Requires admin privileges."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to target, or '*' to target all cameras.",
},
"feature": {
"type": "string",
"enum": [
"detect",
"record",
"snapshots",
"audio",
"motion",
"enabled",
"birdseye",
"birdseye_mode",
"improve_contrast",
"ptz_autotracker",
"motion_contour_area",
"motion_threshold",
"notifications",
"audio_transcription",
"review_alerts",
"review_detections",
"object_descriptions",
"review_descriptions",
"profile",
],
"description": (
"The feature to change. Most features accept ON or OFF. "
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
"motion_contour_area and motion_threshold accept a number. "
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
),
},
"value": {
"type": "string",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
},
},
"required": ["camera", "feature", "value"],
},
},
},
{
"type": "function",
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to get live context for.",
},
},
"required": ["camera"],
},
},
},
{
"type": "function",
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera ID to monitor.",
},
"condition": {
"type": "string",
"description": (
"Natural-language description of the condition to watch for, "
"e.g. 'a person arrives at the front door'."
),
},
"max_duration_minutes": {
"type": "integer",
"description": "Maximum time to watch before giving up (minutes, default 60).",
"default": 60,
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
},
},
"required": ["camera", "condition"],
},
},
},
{
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_profile_status",
"description": (
"Get the current profile status including the active profile and "
"timestamps of when each profile was last activated. Use this to "
"determine time periods for recap requests — e.g. when the user asks "
"'what happened while I was away?', call this first to find the relevant "
"time window based on profile activation history."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_recap",
"description": (
"Get a recap of all activity (alerts and detections) for a given time period. "
"Use this after calling get_profile_status to retrieve what happened during "
"a specific window — e.g. 'what happened while I was away?'. Returns a "
"chronological list of activity with camera, objects, zones, and GenAI-generated "
"descriptions when available. Summarize the results for the user."
),
"parameters": {
"type": "object",
"properties": {
"after": {
"type": "string",
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
},
"before": {
"type": "string",
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
},
"cameras": {
"type": "string",
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
},
"severity": {
"type": "string",
"enum": ["alert", "detection"],
"description": "Filter by severity level. Omit to include both alerts and detections.",
},
},
"required": ["after", "before"],
},
},
},
]
@router.get(
"/chat/tools",
dependencies=[Depends(allow_any_authenticated())],
summary="Get available tools",
description="Returns OpenAI-compatible tool definitions for function calling.",
)
def get_tools(request: Request) -> JSONResponse:
def get_tools() -> JSONResponse:
"""Get list of available tools for LLM function calling."""
config = request.app.frigate_config
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
attribute_classifications = get_attribute_classifications(config)
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
tools = get_tool_definitions()
return JSONResponse(content={"tools": tools})
@@ -119,29 +432,16 @@ def _resolve_zones(
async def _execute_search_objects(
request: Request,
arguments: Dict[str, Any],
allowed_cameras: List[str],
config: FrigateConfig,
) -> JSONResponse:
"""
Execute the search_objects tool.
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.
This searches for detected objects (events) in Frigate using the same
logic as the events API endpoint.
"""
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")
@@ -177,14 +477,11 @@ async def _execute_search_objects(
elif zones is None:
zones = "all"
attribute = arguments.get("attribute")
# Build query parameters compatible with EventsQueryParams
query_params = EventsQueryParams(
cameras=arguments.get("camera", "all"),
labels=arguments.get("label", "all"),
sub_labels=arguments.get("sub_label", "all"), # case-insensitive on the backend
attributes=attribute if attribute else "all",
zones=zones,
zone=zones,
after=after,
@@ -211,124 +508,6 @@ 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")
attribute = arguments.get("attribute")
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 attribute:
attribute_clause = _build_attribute_filter_clause(attribute)
if attribute_clause is not None:
clauses.append(attribute_clause)
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],
@@ -517,7 +696,9 @@ async def execute_tool(
logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}")
if tool_name == "search_objects":
return await _execute_search_objects(request, arguments, allowed_cameras)
return await _execute_search_objects(
arguments, allowed_cameras, request.app.frigate_config
)
if tool_name == "find_similar_objects":
result = await _execute_find_similar_objects(
@@ -697,7 +878,9 @@ 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(request, arguments, allowed_cameras)
response = await _execute_search_objects(
arguments, allowed_cameras, request.app.frigate_config
)
try:
if hasattr(response, "body"):
body_str = response.body.decode("utf-8")
@@ -1110,21 +1293,64 @@ async def chat_completion(
status_code=400,
)
config = request.app.frigate_config
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
attribute_classifications = get_attribute_classifications(config)
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
tools = get_tool_definitions()
conversation = []
system_prompt = build_chat_system_prompt(
config=config,
allowed_cameras=allowed_cameras,
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
current_datetime = datetime.now()
current_date_str = current_datetime.strftime("%Y-%m-%d")
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
camera_config = config.cameras[camera_id]
friendly_name = (
camera_config.friendly_name
if camera_config.friendly_name
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)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_section = ""
if cameras_info:
cameras_section = (
"\n\nAvailable cameras:\n"
+ "\n".join(cameras_info)
+ "\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}."
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}
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
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:<id>], 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}{speed_units_section}"""
conversation.append(
{
@@ -1185,18 +1411,6 @@ async def chat_completion(
)
+ b"\n"
)
elif kind == "reasoning_delta":
yield (
json.dumps({"type": "reasoning", "delta": value}).encode(
"utf-8"
)
+ 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":
@@ -1292,7 +1506,6 @@ async def chat_completion(
final_content = response.get("content") or ""
if body.stream:
final_reasoning = response.get("reasoning")
async def stream_body() -> Any:
if tool_calls:
@@ -1307,15 +1520,6 @@ async def chat_completion(
).encode("utf-8")
+ b"\n"
)
# Emit the full reasoning trace up front when the
# underlying client did not stream it
if final_reasoning:
yield (
json.dumps(
{"type": "reasoning", "delta": final_reasoning}
).encode("utf-8")
+ b"\n"
)
# Stream content in word-sized chunks for smooth UX
for part in chunk_content(final_content):
yield (
@@ -1336,7 +1540,6 @@ async def chat_completion(
message=ChatMessageResponse(
role="assistant",
content=final_content,
reasoning=response.get("reasoning"),
tool_calls=None,
),
finish_reason=response.get("finish_reason", "stop"),
@@ -1438,7 +1641,6 @@ 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)
@@ -1459,22 +1661,10 @@ 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(request: Request) -> JSONResponse:
async def get_vlm_monitor() -> 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)
@@ -1484,27 +1674,7 @@ async def get_vlm_monitor(request: Request) -> JSONResponse:
summary="Cancel the current VLM watch job",
description="Cancels the running watch job if one exists.",
)
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,
)
async def cancel_vlm_monitor() -> JSONResponse:
cancelled = stop_vlm_watch_job()
if not cancelled:
return JSONResponse(
+4 -99
View File
@@ -6,18 +6,11 @@ from datetime import datetime
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from peewee import DoesNotExist
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 (
ExportDebugReplaySource,
RecordingDebugReplaySource,
start_debug_replay_job,
)
from frigate.models import Export
from frigate.util.services import get_video_properties
from frigate.jobs.debug_replay import start_debug_replay_job
logger = logging.getLogger(__name__)
@@ -32,12 +25,6 @@ class DebugReplayStartBody(BaseModel):
end_time: float = Field(title="End timestamp")
class DebugReplayStartFromExportBody(BaseModel):
"""Request body for starting a debug replay session from an export."""
export_id: str = Field(title="Export id")
class DebugReplayStartResponse(BaseModel):
"""Response for starting a debug replay session."""
@@ -86,95 +73,13 @@ class DebugReplayStopResponse(BaseModel):
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session asynchronously."""
replay_manager = request.app.replay_manager
source = RecordingDebugReplaySource(
source_camera=body.camera,
start_ts=body.start_time,
end_ts=body.end_time,
)
try:
job_id = await asyncio.to_thread(
start_debug_replay_job,
source=source,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
replay_manager=replay_manager,
)
except RuntimeError:
return JSONResponse(
content={
"success": False,
"message": "A replay session is already active",
},
status_code=409,
)
except ValueError:
logger.exception("Rejected debug replay start request")
return JSONResponse(
content={
"success": False,
"message": "Invalid debug replay parameters",
},
status_code=400,
)
return JSONResponse(
content={
"success": True,
"replay_camera": replay_manager.replay_camera_name,
"job_id": job_id,
},
status_code=202,
)
@router.post(
"/debug_replay/start_from_export",
response_model=DebugReplayStartResponse,
status_code=202,
responses={
400: {"description": "Invalid export, time range, or no recordings"},
404: {"description": "Export not found"},
409: {"description": "A replay session is already active"},
},
dependencies=[Depends(require_role(["admin"]))],
summary="Start debug replay from an export",
description="Start a debug replay session covering an existing export's "
"time range. The end time is derived from the export's video duration.",
)
async def start_debug_replay_from_export(
request: Request, body: DebugReplayStartFromExportBody
):
"""Start a debug replay session from an existing export."""
try:
export: Export = Export.get(Export.id == body.export_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export not found"},
status_code=404,
)
properties = await get_video_properties(
request.app.frigate_config.ffmpeg, export.video_path, get_duration=True
)
duration = properties.get("duration", -1)
if duration is None or duration <= 0:
return JSONResponse(
content={
"success": False,
"message": "Could not determine export duration",
},
status_code=400,
)
replay_manager = request.app.replay_manager
source = ExportDebugReplaySource(export=export, duration=float(duration))
try:
job_id = await asyncio.to_thread(
start_debug_replay_job,
source=source,
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,
@@ -20,10 +20,6 @@ class ChatMessageResponse(BaseModel):
content: Optional[str] = Field(
default=None, description="Message content (None if tool calls present)"
)
reasoning: Optional[str] = Field(
default=None,
description="Separated reasoning/thinking trace if the model emitted one",
)
tool_calls: Optional[list[ToolCallInvocation]] = Field(
default=None, description="Tool calls if LLM wants to call tools"
)
+1 -1
View File
@@ -398,7 +398,7 @@ class _StreamingZipBuffer:
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(export.date)}"
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
candidate = f"{base}.mp4"
counter = 1
+13 -6
View File
@@ -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, exist_ok=True)
os.makedirs(d)
else:
logger.debug(f"Skipping directory: {d}")
@@ -428,11 +428,18 @@ class FrigateApp:
self.camera_maintainer.start()
def start_audio_processor(self) -> None:
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
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
def start_timeline_processor(self) -> None:
self.timeline_processor = TimelineProcessor(
+6 -356
View File
@@ -34,8 +34,6 @@ from frigate.const import (
UPDATE_REVIEW_DESCRIPTION,
UPSERT_REVIEW_SEGMENT,
)
from frigate.models import User
from frigate.output.ws_auth import ws_has_camera_access
logger = logging.getLogger(__name__)
@@ -68,7 +66,6 @@ _WS_VIEWER_TOPICS = frozenset(
"audioTranscriptionState",
"birdseyeLayout",
"embeddingsReindexProgress",
"jobState",
}
)
@@ -105,321 +102,6 @@ def _check_ws_authorization(
return topic in _WS_VIEWER_TOPICS
# ---- Outbound filtering ---------------------------------------------------
#
# Every WebSocket broadcast is classified into one of a small set of scopes,
# then materialized per recipient. Connections with restricted roles only see
# data for cameras they are authorized to access; admin and full-access roles
# behave as today.
# Topics that are safe to broadcast to every authenticated client.
_WS_GLOBAL_OUTBOUND_TOPICS = frozenset(
{
"model_state",
"embeddings_reindex_progress",
"audio_transcription_state",
"profile/state",
"notifications/state",
"notification_test",
}
)
# Topics that restricted roles must never receive. Birdseye composites span
# all cameras, so the existing JSMPEG policy already restricts birdseye access
# to unrestricted roles; the layout broadcast follows the same rule.
_WS_UNRESTRICTED_ONLY_TOPICS = frozenset(
{
"birdseye_layout",
}
)
# Topics whose payload (parsed as JSON) names a single owning camera at the
# given key path. Used to scope events, reviews, triggers, etc.
_WS_PAYLOAD_CAMERA_TOPICS: dict[str, tuple[str, ...]] = {
"events": ("after", "camera"),
"reviews": ("after", "camera"),
"tracked_object_update": ("camera",),
"triggers": ("camera",),
"camera_monitoring": ("camera",),
}
# Topics whose payload is a dict keyed by camera name; filter keys per
# recipient.
_WS_RESHAPE_BY_CAMERA_KEY_TOPICS = frozenset(
{
"camera_activity",
"audio_detections",
}
)
# Topics whose payload is a dict keyed by job_type, where each entry may
# contain a "camera" or "source_camera" field, or a nested ``results.jobs``
# list of per-camera sub-jobs (export broadcasts).
_WS_RESHAPE_JOB_STATE_TOPICS = frozenset(
{
"job_state",
}
)
# Topics whose payload mixes global aggregates with a ``cameras`` sub-dict
# keyed by camera name. Aggregates and detector data stay; per-camera entries
# are filtered.
_WS_RESHAPE_STATS_TOPICS = frozenset(
{
"stats",
}
)
def _collect_zone_names(config: FrigateConfig) -> set[str]:
"""Return the set of all zone names defined across cameras."""
names: set[str] = set()
for camera in config.cameras.values():
zones = getattr(camera, "zones", None) or {}
names.update(zones.keys())
return names
def _parse_json_payload(payload: Any) -> Any:
"""Return payload parsed as JSON if it is a string, else as-is."""
if isinstance(payload, str):
try:
return json.loads(payload)
except (ValueError, TypeError):
return None
return payload
def _scope_job_entry_to_allowed(entry: Any, allowed: set[str]) -> dict[str, Any] | None:
"""Filter a single job_state entry to the recipient's allowed cameras.
Returns the (possibly reshaped) entry, or None to drop it. Four shapes
are handled:
* Top-level ``camera`` or ``source_camera`` (motion_search, vlm_watch,
export sub-job dicts): drop the entry if not allowed.
* Nested ``results.jobs`` list of per-camera sub-jobs (the aggregated
export broadcast): filter the list; drop the entry if nothing remains.
* Nested ``results.camera`` or ``results.source_camera`` (debug_replay,
which puts replay-specific fields inside ``results``): drop the entry
if not allowed.
* No camera anywhere (e.g. ``media_sync``): treat as global and keep.
"""
if not isinstance(entry, dict):
return None
cam = entry.get("camera") or entry.get("source_camera")
if cam is None:
results = entry.get("results")
if isinstance(results, dict):
sub_jobs = results.get("jobs")
if isinstance(sub_jobs, list):
filtered_jobs = [
j
for j in sub_jobs
if isinstance(j, dict)
and (j.get("camera") or j.get("source_camera")) in allowed
]
if not filtered_jobs:
return None
reshaped = dict(entry)
reshaped["results"] = dict(results)
reshaped["results"]["jobs"] = filtered_jobs
return reshaped
cam = results.get("camera") or results.get("source_camera")
if cam is not None:
return entry if cam in allowed else None
return entry
def _extract_payload_camera(payload: Any, path: tuple[str, ...]) -> str | None:
"""Walk the dotted path through a (possibly JSON-encoded) payload."""
cur = _parse_json_payload(payload)
for key in path:
if not isinstance(cur, dict):
return None
cur = cur.get(key)
return cur if isinstance(cur, str) else None
def _classify_outbound(
topic: str, all_cameras: set[str], all_zones: set[str]
) -> tuple[str, Any]:
"""Classify an outbound topic into (kind, extra).
kind values:
- "global" : send to every authenticated client
- "drop" : send to nobody (fail-closed for unknowns)
- "unrestricted_only" : send only to admin/full-access roles
- "camera" : extra is the owning camera name
- "payload_camera" : extra is the JSON key path to the camera name
- "reshape_by_camera_key"
- "reshape_job_state"
- "reshape_stats"
"""
if topic in _WS_GLOBAL_OUTBOUND_TOPICS:
return ("global", None)
if topic in _WS_UNRESTRICTED_ONLY_TOPICS:
return ("unrestricted_only", None)
if topic in _WS_RESHAPE_BY_CAMERA_KEY_TOPICS:
return ("reshape_by_camera_key", None)
if topic in _WS_RESHAPE_JOB_STATE_TOPICS:
return ("reshape_job_state", None)
if topic in _WS_RESHAPE_STATS_TOPICS:
return ("reshape_stats", None)
if topic in _WS_PAYLOAD_CAMERA_TOPICS:
return ("payload_camera", _WS_PAYLOAD_CAMERA_TOPICS[topic])
# Topic-prefix based: first segment names the owning camera or zone.
first = topic.split("/", 1)[0]
if first in all_cameras:
return ("camera", first)
if first in all_zones:
# Zone aggregates span cameras; restricted users see nothing here.
return ("unrestricted_only", None)
return ("drop", None)
def _ws_role_header(ws: Any) -> str | None:
"""Return the HTTP_REMOTE_ROLE header value, if any."""
environ = getattr(ws, "environ", None)
if not environ:
return None
value = environ.get("HTTP_REMOTE_ROLE")
return value if isinstance(value, str) else None
def _ws_valid_roles(ws: Any, config: FrigateConfig) -> list[str]:
"""Return the list of recognized roles for this connection."""
header = _ws_role_header(ws)
if not header:
return []
roles = [r.strip() for r in header.split(config.proxy.separator) if r.strip()]
return [r for r in roles if r in config.auth.roles]
def _ws_is_unrestricted(ws: Any, config: FrigateConfig) -> bool:
"""True when the connection has unrestricted camera access.
Mirrors the policy in ``frigate.output.ws_auth``: admin or any role with
an empty allow-list grants full access.
"""
roles = _ws_valid_roles(ws, config)
if not roles:
return False
roles_dict = config.auth.roles
return any(r == "admin" or not roles_dict.get(r) for r in roles)
def _ws_allowed_cameras(ws: Any, config: FrigateConfig) -> set[str]:
"""Return the union of cameras this connection may access across its roles."""
roles = _ws_valid_roles(ws, config)
if not roles:
return set()
all_cameras = set(config.cameras.keys())
allowed: set[str] = set()
for role in roles:
if role == "admin" or not config.auth.roles.get(role):
return all_cameras
allowed.update(User.get_allowed_cameras(role, config.auth.roles, all_cameras))
return allowed
def _wrap_envelope(topic: str, inner_payload: Any) -> str:
"""Re-serialize a (topic, payload) message after payload reshaping.
Frigate's wire format keeps payloads as JSON-encoded strings inside the
outer envelope, mirroring what producers send today.
"""
return json.dumps({"topic": topic, "payload": json.dumps(inner_payload)})
def _materialize_for_ws(
ws: Any,
topic: str,
full_message: str,
scope: tuple[str, Any],
parsed_payload: Any,
config: FrigateConfig,
) -> str | None:
"""Return the JSON string to deliver to ``ws``, or None to skip it."""
kind, extra = scope
has_role = _ws_role_header(ws) is not None
if kind == "drop":
return None
if kind == "global":
# Globals still require an authenticated connection. Missing role
# falls back to viewer semantics (matching the inbound rule).
return full_message
# Beyond globals, an authenticated role header is required (fail-closed).
if not has_role:
return None
if kind == "unrestricted_only":
return full_message if _ws_is_unrestricted(ws, config) else None
if kind == "camera":
return full_message if ws_has_camera_access(ws, extra, config) else None
if kind == "payload_camera":
camera = _extract_payload_camera(parsed_payload, extra)
if camera is None:
return None
return full_message if ws_has_camera_access(ws, camera, config) else None
if kind == "reshape_by_camera_key":
if _ws_is_unrestricted(ws, config):
return full_message
if not isinstance(parsed_payload, dict):
return None
allowed = _ws_allowed_cameras(ws, config)
filtered = {cam: data for cam, data in parsed_payload.items() if cam in allowed}
if not filtered:
return None
return _wrap_envelope(topic, filtered)
if kind == "reshape_job_state":
if _ws_is_unrestricted(ws, config):
return full_message
if not isinstance(parsed_payload, dict):
return None
allowed = _ws_allowed_cameras(ws, config)
filtered_jobs: dict[str, Any] = {}
for job_type, job_payload in parsed_payload.items():
scoped = _scope_job_entry_to_allowed(job_payload, allowed)
if scoped is not None:
filtered_jobs[job_type] = scoped
if not filtered_jobs:
return None
return _wrap_envelope(topic, filtered_jobs)
if kind == "reshape_stats":
if _ws_is_unrestricted(ws, config):
return full_message
if not isinstance(parsed_payload, dict):
return None
allowed = _ws_allowed_cameras(ws, config)
cameras_block = parsed_payload.get("cameras")
if isinstance(cameras_block, dict):
filtered_cameras = {
name: data for name, data in cameras_block.items() if name in allowed
}
reshaped = dict(parsed_payload)
reshaped["cameras"] = filtered_cameras
return _wrap_envelope(topic, reshaped)
return full_message
return None
class WebSocket(WebSocket_): # type: ignore[misc]
def unhandled_error(self, error: Any) -> None:
"""
@@ -501,10 +183,6 @@ class WebSocketClient(Communicator):
self.websocket_thread.start()
def publish(self, topic: str, payload: Any, _: bool = False) -> None:
if self.websocket_server is None:
logger.debug("Skipping message, websocket not connected yet")
return
try:
ws_message = json.dumps(
{
@@ -517,42 +195,14 @@ class WebSocketClient(Communicator):
logger.debug(f"payload for {topic} wasn't text. Skipping...")
return
all_cameras = set(self.config.cameras.keys())
all_zones = _collect_zone_names(self.config)
scope = _classify_outbound(topic, all_cameras, all_zones)
if scope[0] == "drop":
if self.websocket_server is None:
logger.debug("Skipping message, websocket not connected yet")
return
# Pre-parse payload once for topics that need to read its contents.
parsed_payload: Any = None
if scope[0] in (
"payload_camera",
"reshape_by_camera_key",
"reshape_job_state",
"reshape_stats",
):
parsed_payload = _parse_json_payload(payload)
if parsed_payload is None:
# malformed payload — fail closed
return
manager = self.websocket_server.manager
with manager.lock:
websockets = list(manager.websockets.values())
for ws in websockets:
if getattr(ws, "terminated", False):
continue
message = _materialize_for_ws(
ws, topic, ws_message, scope, parsed_payload, self.config
)
if message is None:
continue
try:
ws.send(message)
except (ConnectionResetError, BrokenPipeError, ValueError):
pass
try:
self.websocket_server.manager.broadcast(ws_message)
except ConnectionResetError:
pass
def stop(self) -> None:
if self.websocket_server is not None:
+20 -18
View File
@@ -26,6 +26,7 @@ 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,12 +81,12 @@ logger = logging.getLogger(__name__)
yaml = YAML()
# Pydantic field default applied when an existing config omits `detectors:`.
# Kept as cpu tflite for backwards compatibility with 0.17 configs.
DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
# Used by the openvino branch below and rendered into the new-config YAML
# template so first-time setups default to openvino on CPU.
DEFAULT_DETECTORS = {
"ov": {
"type": "openvino",
"device": "CPU",
}
}
DEFAULT_MODEL = {
"width": 300,
"height": 300,
@@ -94,7 +95,6 @@ DEFAULT_MODEL = {
"path": "/openvino-model/ssdlite_mobilenet_v2.xml",
"labelmap_path": "/openvino-model/coco_91cl_bkgr.txt",
}
NEW_CONFIG_DETECTORS = {"ov": {"type": "openvino", "device": "CPU"}}
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
@@ -110,7 +110,7 @@ DEFAULT_CONFIG = f"""
mqtt:
enabled: False
{_render_default_yaml({"detectors": NEW_CONFIG_DETECTORS, "model": DEFAULT_MODEL})}
{_render_default_yaml({"detectors": DEFAULT_DETECTORS, "model": DEFAULT_MODEL})}
cameras: {{}} # No cameras defined, UI wizard should be used
version: {CURRENT_CONFIG_VERSION}
"""
@@ -629,22 +629,26 @@ class FrigateConfig(FrigateBaseModel):
# set default min_score for object attributes
for attribute in self.model.all_attributes:
existing = self.objects.filters.get(attribute)
if existing is None:
if not self.objects.filters.get(attribute):
self.objects.filters[attribute] = FilterConfig(min_score=0.7)
elif "min_score" not in existing.model_fields_set:
existing.min_score = 0.7
elif self.objects.filters[attribute].min_score == 0.5:
self.objects.filters[attribute].min_score = 0.7
# auto detect hwaccel args
if self.ffmpeg.hwaccel_args == "auto":
self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
# Populate global audio filters from listen. Existing user-defined
# entries for labels not in listen are preserved but unused at runtime.
# 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
}
if self.audio.filters is None:
self.audio.filters = {}
for key in sorted(set(self.audio.listen) - self.audio.filters.keys()):
for key in sorted(all_audio_labels - self.audio.filters.keys()):
self.audio.filters[key] = AudioFilterConfig()
self.audio.filters = dict(sorted(self.audio.filters.items()))
@@ -836,9 +840,7 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.audio.filters is None:
camera_config.audio.filters = {}
for key in sorted(
set(camera_config.audio.listen) - camera_config.audio.filters.keys()
):
for key in sorted(all_audio_labels - camera_config.audio.filters.keys()):
camera_config.audio.filters[key] = AudioFilterConfig()
camera_config.audio.filters = dict(
@@ -269,9 +269,7 @@ 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, []))
+1 -27
View File
@@ -9,7 +9,6 @@ import logging
import os
import shutil
import threading
import time
from ruamel.yaml import YAML
@@ -26,15 +25,7 @@ from frigate.const import (
REPLAY_DIR,
THUMB_DIR,
)
from frigate.jobs.debug_replay import (
JOB_TYPE as DEBUG_REPLAY_JOB_TYPE,
)
from frigate.jobs.debug_replay import (
cancel_debug_replay_job,
wait_for_runner,
)
from frigate.jobs.export import JobStatePublisher
from frigate.types import JobStatusTypesEnum
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
@@ -58,7 +49,6 @@ class DebugReplayManager:
self.clip_path: str | None = None
self.start_ts: float | None = None
self.end_ts: float | None = None
self._job_state_publisher = JobStatePublisher()
@property
def active(self) -> bool:
@@ -160,7 +150,6 @@ class DebugReplayManager:
return
replay_name = self.replay_camera_name
source_camera = self.source_camera
# Only publish remove if the camera was actually added to the live
# config (i.e. the runner reached the starting_camera phase).
@@ -174,21 +163,6 @@ class DebugReplayManager:
self._cleanup_db(replay_name)
self._cleanup_files(replay_name)
self._job_state_publisher.publish(
{
"id": "stopped",
"job_type": DEBUG_REPLAY_JOB_TYPE,
"status": JobStatusTypesEnum.cancelled,
"start_time": None,
"end_time": time.time(),
"error_message": None,
"results": {
"source_camera": source_camera,
"replay_camera_name": replay_name,
},
}
)
self._clear_locked()
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
+2 -19
View File
@@ -79,11 +79,7 @@ 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(
avail_dev == accel_dev or avail_dev.startswith(accel_dev + ".")
for avail_dev in available_devices
for accel_dev in acceleration_devices
)
return any(device in available_devices for device in acceleration_devices)
class BaseModelRunner(ABC):
@@ -282,13 +278,6 @@ class OpenVINOModelRunner(BaseModelRunner):
EnrichmentModelTypeEnum.arcface.value,
]
@staticmethod
def is_detection_model(model_type: str) -> bool:
# Import here to avoid circular imports
from frigate.detectors.detector_config import ModelTypeEnum
return model_type in [m.value for m in ModelTypeEnum]
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
self.model_path = model_path
self.device = device
@@ -317,15 +306,9 @@ class OpenVINOModelRunner(BaseModelRunner):
# Apply performance optimization
self.ov_core.set_property(device, {"PERF_COUNT": "NO"})
if device in ["GPU", "AUTO", "NPU"]:
if device in ["GPU", "AUTO"]:
self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"})
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
try:
self.ov_core.set_property(device, {"NPU_TURBO": "YES"})
except Exception as e:
logger.debug(f"NPU_TURBO not supported by driver: {e}")
# Compile model
self.compiled_model = self.ov_core.compile_model(
model=model_path, device_name=device
+3 -13
View File
@@ -60,11 +60,7 @@ 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 (
EventStateEnum,
EventTypeEnum,
RegenerateDescriptionEnum,
)
from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
from frigate.genai import GenAIClientManager
from frigate.models import Event, Recordings, ReviewSegment, Trigger
from frigate.types import TrackedObjectUpdateTypesEnum
@@ -232,7 +228,7 @@ class EmbeddingMaintainer(threading.Thread):
)
)
if any(
if self.config.audio_transcription.enabled and any(
c.enabled_in_config and c.audio_transcription.enabled
for c in self.config.cameras.values()
):
@@ -439,7 +435,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None:
return
source_type, event_type, camera, frame_name, data = update
source_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'}"
@@ -489,12 +485,6 @@ 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,
+15 -40
View File
@@ -84,6 +84,7 @@ class AudioProcessor(FrigateProcess):
def __init__(
self,
config: FrigateConfig,
cameras: list[CameraConfig],
camera_metrics: DictProxy,
stop_event: MpEvent,
):
@@ -92,18 +93,16 @@ 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: dict[str, AudioEventMaintainer] = {}
audio_threads: list[AudioEventMaintainer] = []
threading.current_thread().name = "process:audio_manager"
if any(
c.enabled_in_config and c.audio_transcription.enabled
for c in self.config.cameras.values()
):
if self.config.audio_transcription.enabled:
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
AudioTranscriptionModelRunner(
self.config.audio_transcription.device or "AUTO",
@@ -113,56 +112,32 @@ class AudioProcessor(FrigateProcess):
else:
self.transcription_model_runner = None
config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
[
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.audio,
CameraConfigUpdateEnum.ffmpeg,
],
)
if len(self.cameras) == 0:
return
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(
for camera in self.cameras:
audio_thread = AudioEventMaintainer(
camera,
self.config,
self.camera_metrics,
self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type]
)
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)
audio_threads.append(audio_thread)
audio_thread.start()
self.logger.info(f"Audio processor started (pid: {self.pid})")
# 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)
while not self.stop_event.wait():
pass
config_subscriber.stop()
for thread in audio_threads.values():
for thread in audio_threads:
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.values():
for thread in audio_threads:
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")
@@ -209,7 +184,7 @@ class AudioEventMaintainer(threading.Thread):
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
if (
self.camera_config.audio_transcription.enabled
self.config.audio_transcription.enabled
and self.audio_transcription_model_runner is not None
):
# init the transcription processor for this camera
+151 -34
View File
@@ -1,5 +1,6 @@
"""Generative AI module for Frigate."""
import datetime
import importlib
import json
import logging
@@ -8,18 +9,13 @@ import re
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
from frigate.data_processing.post.types import ReviewMetadata
from frigate.genai.manager import GenAIClientManager
from frigate.genai.prompts import (
build_object_description_prompt,
build_review_description_prompt,
build_review_description_response_format,
build_review_summary_prompt,
)
from frigate.models import Event
logger = logging.getLogger(__name__)
@@ -65,14 +61,75 @@ class GenAIClient:
activity_context_prompt: str,
) -> ReviewMetadata | None:
"""Generate a description for the review item activity."""
context_prompt = build_review_description_prompt(
review_data,
thumbnails,
concerns,
preferred_language,
activity_context_prompt,
)
def get_concern_prompt() -> str:
if concerns:
concern_list = "\n - ".join(concerns)
return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
- {concern_list}"""
else:
return ""
def get_language_prompt() -> str:
if preferred_language:
return f"Provide your answer in {preferred_language}"
else:
return ""
def get_objects_list() -> str:
if review_data["unified_objects"]:
return "\n- " + "\n- ".join(review_data["unified_objects"])
else:
return "\n- (No objects detected)"
context_prompt = f"""
Your task is to analyze a sequence of images taken in chronological order from a security camera.
## Normal Activity Patterns for This Property
{activity_context_prompt}
## Task Instructions
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
## Analysis Guidelines
When forming your description:
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
- **Use the actual timestamp provided in "Activity started at"** below for time of day context—do not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
## 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`: 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.
## Sequence Details
- Camera: {review_data["camera"]}
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.**
{get_objects_list()}
{get_language_prompt()}
"""
logger.debug(
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}"
)
@@ -86,7 +143,25 @@ class GenAIClient:
) as f:
f.write(context_prompt)
response_format = build_review_description_response_format(concerns)
# Build JSON schema for structured output from ReviewMetadata model
schema = ReviewMetadata.model_json_schema()
schema.get("properties", {}).pop("time", None)
if "time" in schema.get("required", []):
schema["required"].remove("time")
if not concerns:
schema.get("properties", {}).pop("other_concerns", None)
if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns")
response_format = {
"type": "json_schema",
"json_schema": {
"name": "review_metadata",
"strict": True,
"schema": schema,
},
}
response = self._send(context_prompt, thumbnails, response_format)
@@ -165,9 +240,61 @@ class GenAIClient:
debug_save: bool,
) -> str | None:
"""Generate a summary of review item descriptions over a period of time."""
timeline_summary_prompt = build_review_summary_prompt(
start_ts, end_ts, events, preferred_language
)
time_range = f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')} to {datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
timeline_summary_prompt = f"""
You are a security officer writing a concise security report.
Time range: {time_range}
Input format: Each event is a JSON object with:
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
- "context": array of related events from other cameras that occurred during overlapping time periods
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
Report Structure - Use this EXACT format:
# Security Summary - {time_range}
## Overview
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
---
## Timeline
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
### [Time Block Name]
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
- [Event title]: [Clear description incorporating contextual information from the "context" array]
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
[Repeat for each event in chronological order within the time block]
---
## Summary
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
Guidelines:
- List ALL events in chronological order, grouped by time blocks
- Threat level indicators: ✓ Normal, ⚠️ Needs review, 🔴 Security concern
- Integrate contextual information naturally - use the "context" array to enrich each event's description
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
- Be concise but informative - focus on what happened and what it means
- If contextual information makes an event clearly normal, reflect that in your assessment
- Only create time blocks that have events - don't create empty sections
"""
timeline_summary_prompt += "\n\nEvents:\n"
for event in events:
timeline_summary_prompt += f"\n{event}\n"
if preferred_language:
timeline_summary_prompt += f"\nProvide your answer in {preferred_language}"
if debug_save:
with open(
@@ -199,7 +326,10 @@ class GenAIClient:
) -> Optional[str]:
"""Generate a description for the frame."""
try:
prompt = build_object_description_prompt(camera_config, event)
prompt = camera_config.objects.genai.object_prompts.get(
str(event.label),
camera_config.objects.genai.prompt,
).format(**model_to_dict(event))
except KeyError as e:
logger.error(f"Invalid key in GenAI prompt: {e}")
return None
@@ -300,10 +430,6 @@ class GenAIClient:
Returns:
Dictionary with:
- 'content': Optional[str] - The text response from the LLM, None if tool calls
- 'reasoning': Optional[str] - The separated reasoning/thinking trace
if the model emitted one (e.g. via OpenAI-compatible
`reasoning_content`). None when the model does not surface a
trace or the provider does not parse it.
- 'tool_calls': Optional[List[Dict]] - List of tool calls if LLM wants to call tools.
Each tool call dict has:
- 'id': str - Unique identifier for this tool call
@@ -315,14 +441,6 @@ class GenAIClient:
- 'length': Hit token limit
- 'error': An error occurred
Streaming counterpart `chat_with_tools_stream` yields
``(kind, value)`` tuples where ``kind`` is one of:
- 'content_delta': value is a string fragment of the answer
- 'reasoning_delta': value is a string fragment of the reasoning
trace (emitted before content for thinking models)
- 'stats': value is a usage stats dict
- 'message': value is the final dict shape described above
Raises:
NotImplementedError: If the provider doesn't implement this method.
"""
@@ -333,15 +451,14 @@ class GenAIClient:
)
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
def load_providers() -> None:
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
for filename in os.listdir(plugins_dir):
package_dir = os.path.dirname(__file__)
for filename in os.listdir(package_dir):
if filename.endswith(".py") and filename != "__init__.py":
module_name = f"frigate.genai.plugins.{filename[:-3]}"
module_name = f"frigate.genai.{filename[:-3]}"
importlib.import_module(module_name)
+305
View File
@@ -0,0 +1,305 @@
"""Azure OpenAI Provider for Frigate AI."""
import base64
import json
import logging
from typing import Any, AsyncGenerator, Optional
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
logger = logging.getLogger(__name__)
@register_genai_provider(GenAIProviderEnum.azure_openai)
class OpenAIClient(GenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
provider: AzureOpenAI
def _init_provider(self) -> AzureOpenAI | None:
"""Initialize the client."""
try:
parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
if not api_version:
logger.warning("Azure OpenAI url is missing API version.")
return None
except Exception as e:
logger.warning("Error parsing Azure OpenAI url: %s", str(e))
return None
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
def _send(
self,
prompt: str,
images: list[bytes],
response_format: Optional[dict] = None,
) -> Optional[str]:
"""Submit a request to Azure OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
try:
request_params = {
"model": self.genai_config.model,
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": prompt}]
+ [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
for image in encoded_images
],
},
],
"timeout": self.timeout,
**self.genai_config.runtime_options,
}
if response_format:
request_params["response_format"] = response_format
result = self.provider.chat.completions.create(**request_params)
except Exception as e:
logger.warning("Azure OpenAI returned an error: %s", str(e))
return None
if len(result.choices) > 0:
return str(result.choices[0].message.content.strip())
return None
def list_models(self) -> list[str]:
"""Return available model IDs from Azure OpenAI."""
try:
return sorted(m.id for m in self.provider.models.list().data)
except Exception as e:
logger.warning("Failed to list Azure OpenAI models: %s", e)
return []
def get_context_size(self) -> int:
"""Get the context window size for Azure OpenAI."""
return 128000
def chat_with_tools(
self,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
) -> dict[str, Any]:
try:
openai_tool_choice = None
if tool_choice:
if tool_choice == "none":
openai_tool_choice = "none"
elif tool_choice == "auto":
openai_tool_choice = "auto"
elif tool_choice == "required":
openai_tool_choice = "required"
request_params = {
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
}
if tools:
request_params["tools"] = tools
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
if (
result is None
or not hasattr(result, "choices")
or len(result.choices) == 0
):
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
choice = result.choices[0]
message = choice.message
content = message.content.strip() if message.content else None
tool_calls = None
if message.tool_calls:
tool_calls = []
for tool_call in message.tool_calls:
try:
arguments = json.loads(tool_call.function.arguments)
except (json.JSONDecodeError, AttributeError) as e:
logger.warning(
f"Failed to parse tool call arguments: {e}, "
f"tool: {tool_call.function.name if hasattr(tool_call.function, 'name') else 'unknown'}"
)
arguments = {}
tool_calls.append(
{
"id": tool_call.id if hasattr(tool_call, "id") else "",
"name": tool_call.function.name
if hasattr(tool_call.function, "name")
else "",
"arguments": arguments,
}
)
finish_reason = "error"
if hasattr(choice, "finish_reason") and choice.finish_reason:
finish_reason = choice.finish_reason
elif tool_calls:
finish_reason = "tool_calls"
elif content:
finish_reason = "stop"
return {
"content": content,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
except Exception as e:
logger.warning("Azure OpenAI returned an error: %s", str(e))
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
async def chat_with_tools_stream(
self,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
) -> AsyncGenerator[tuple[str, Any], None]:
"""
Stream chat with tools; yields content deltas then final message.
Implements streaming function calling/tool usage for Azure OpenAI models.
"""
try:
openai_tool_choice = None
if tool_choice:
if tool_choice == "none":
openai_tool_choice = "none"
elif tool_choice == "auto":
openai_tool_choice = "auto"
elif tool_choice == "required":
openai_tool_choice = "required"
request_params = {
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
"stream": True,
}
if tools:
request_params["tools"] = tools
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
# Use streaming API
content_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
for chunk in stream:
if not chunk or not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
# Check for finish reason
if choice.finish_reason:
finish_reason = choice.finish_reason
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
yield ("content_delta", delta.content)
# Extract tool calls
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
fn = tc.function
if idx not in tool_calls_by_index:
tool_calls_by_index[idx] = {
"id": tc.id or "",
"name": fn.name if fn and fn.name else "",
"arguments": "",
}
t = tool_calls_by_index[idx]
if tc.id:
t["id"] = tc.id
if fn and fn.name:
t["name"] = fn.name
if fn and fn.arguments:
t["arguments"] += fn.arguments
# Build final message
full_content = "".join(content_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
if tool_calls_by_index:
tool_calls_list = []
for tc in tool_calls_by_index.values():
try:
# Parse accumulated arguments as JSON
parsed_args = json.loads(tc["arguments"])
except (json.JSONDecodeError, Exception):
parsed_args = tc["arguments"]
tool_calls_list.append(
{
"id": tc["id"],
"name": tc["name"],
"arguments": parsed_args,
}
)
finish_reason = "tool_calls"
yield (
"message",
{
"content": full_content,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
)
except Exception as e:
logger.warning("Azure OpenAI streaming returned an error: %s", str(e))
yield (
"message",
{
"content": None,
"tool_calls": None,
"finish_reason": "error",
},
)
@@ -14,20 +14,6 @@ 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."""
@@ -248,13 +234,6 @@ class GeminiClient(GenAIClient):
if tool_config:
config_params["tool_config"] = tool_config
# Ask thinking-capable models (Gemini 2.5+) to include their
# reasoning trace as separate `thought` parts so we can surface
# it on the reasoning channel. Older models ignore this field.
config_params["thinking_config"] = types.ThinkingConfig(
include_thoughts=True
)
# Merge runtime_options
if isinstance(self.genai_config.runtime_options, dict):
config_params.update(self.genai_config.runtime_options)
@@ -269,24 +248,19 @@ class GeminiClient(GenAIClient):
if not response or not response.candidates:
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
candidate = response.candidates[0]
content = None
reasoning_parts: list[str] = []
tool_calls = None
# Extract content, reasoning, and tool calls from response
# Extract content and tool calls from response
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text:
if getattr(part, "thought", False):
reasoning_parts.append(part.text)
else:
content = part.text.strip()
content = part.text.strip()
elif part.function_call:
# Handle function call
if tool_calls is None:
@@ -309,8 +283,6 @@ class GeminiClient(GenAIClient):
}
)
reasoning = "".join(reasoning_parts).strip() or None
# Determine finish reason
finish_reason = "error"
if hasattr(candidate, "finish_reason") and candidate.finish_reason:
@@ -336,7 +308,6 @@ class GeminiClient(GenAIClient):
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@@ -345,7 +316,6 @@ class GeminiClient(GenAIClient):
logger.warning("Gemini API error during chat_with_tools: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@@ -355,7 +325,6 @@ class GeminiClient(GenAIClient):
)
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@@ -494,22 +463,14 @@ class GeminiClient(GenAIClient):
if tool_config:
config_params["tool_config"] = tool_config
# Ask thinking-capable models to include their reasoning trace
# as separate `thought` parts (Gemini 2.5+; ignored elsewhere).
config_params["thinking_config"] = types.ThinkingConfig(
include_thoughts=True
)
# Merge runtime_options
if isinstance(self.genai_config.runtime_options, dict):
config_params.update(self.genai_config.runtime_options)
# Use streaming API
content_parts: list[str] = []
reasoning_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,
@@ -518,12 +479,6 @@ 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
@@ -543,16 +498,12 @@ class GeminiClient(GenAIClient):
]:
finish_reason = "error"
# Extract content, reasoning, and tool calls from chunk
# Extract content and tool calls from chunk
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text:
if getattr(part, "thought", False):
reasoning_parts.append(part.text)
yield ("reasoning_delta", part.text)
else:
content_parts.append(part.text)
yield ("content_delta", part.text)
content_parts.append(part.text)
yield ("content_delta", part.text)
elif part.function_call:
# Handle function call
try:
@@ -593,7 +544,6 @@ class GeminiClient(GenAIClient):
# Build final message
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
@@ -615,14 +565,10 @@ class GeminiClient(GenAIClient):
)
finish_reason = "tool_calls"
if usage_stats is not None:
yield ("stats", usage_stats)
yield (
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
@@ -634,7 +580,6 @@ class GeminiClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
@@ -647,7 +592,6 @@ class GeminiClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
@@ -4,7 +4,7 @@ import base64
import io
import json
import logging
from typing import Any, AsyncGenerator, Optional, cast
from typing import Any, AsyncGenerator, Optional
import httpx
import numpy as np
@@ -18,86 +18,6 @@ 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 _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]:
"""Fetch /props from a llama.cpp server, with llama-swap fallback.
Raises the underlying RequestException if both endpoints fail; callers
decide how to surface the failure.
"""
try:
response = requests.get(
f"{base_url}/props",
params={"model": model},
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
except Exception:
response = requests.get(
f"{base_url}/upstream/{model}/props",
timeout=10,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
try:
@@ -151,69 +71,26 @@ class LlamaCppClient(GenAIClient):
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)
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
# Query /v1/models to validate the configured model exists
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_entry = model
model_found = True
break
if model_entry is None:
if not model_found:
available = []
for m in models_data.get("data", []):
available.append(m.get("id", "unknown"))
@@ -232,64 +109,65 @@ class LlamaCppClient(GenAIClient):
e,
)
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
# Query /props for context size, modalities, and tool support.
# The standard /props?model=<name> 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.
try:
props = _fetch_llama_props(base_url, configured_model)
try:
response = requests.get(
f"{base_url}/props",
params={"model": configured_model},
timeout=10,
)
response.raise_for_status()
props = response.json()
except Exception:
response = requests.get(
f"{base_url}/upstream/{configured_model}/props",
timeout=10,
)
response.raise_for_status()
props = response.json()
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)
# 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 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))
# 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_tools"]:
chat_caps = props.get("chat_template_caps", {})
info["supports_tools"] = bool(chat_caps.get("supports_tools", False))
# Tool support from chat template capabilities
chat_caps = props.get("chat_template_caps", {})
self._supports_tools = chat_caps.get("supports_tools", False)
# Media marker for multimodal embeddings; the server randomizes this
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
# from /props rather than hardcoding "<__media__>".
media_marker = props.get("media_marker")
if isinstance(media_marker, str) and media_marker:
info["media_marker"] = media_marker
self._media_marker = 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,
)
except Exception as e:
logger.warning(
"Failed to query llama.cpp /props endpoint: %s. "
"Image embeddings may fail if the server randomized its media marker.",
"Using defaults for context size and capabilities.",
e,
)
return info
return base_url
def _send(
self,
@@ -517,8 +395,6 @@ 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:
@@ -527,28 +403,19 @@ class LlamaCppClient(GenAIClient):
k: v for k, v in self.provider_options.items() if k != "context_size"
}
payload.update(provider_opts)
payload.update(self.genai_config.runtime_options)
return payload
def _message_from_choice(self, choice: dict[str, Any]) -> dict[str, Any]:
"""Parse OpenAI-style choice into {content, reasoning, tool_calls, finish_reason}.
llama.cpp's `--reasoning-format` puts the trace in
`message.reasoning_content` (preferred) or `message.thinking`; both
keys are accepted so different builds work without configuration.
"""
"""Parse OpenAI-style choice into {content, tool_calls, finish_reason}."""
message = choice.get("message", {})
content = message.get("content")
content = content.strip() if content else None
reasoning = message.get("reasoning_content") or message.get("thinking")
reasoning = reasoning.strip() if reasoning else None
tool_calls = parse_tool_calls_from_message(message)
finish_reason = choice.get("finish_reason") or (
"tool_calls" if tool_calls else "stop" if content else "error"
)
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@@ -577,31 +444,6 @@ class LlamaCppClient(GenAIClient):
)
return result if result else None
def _refresh_media_marker(self) -> bool:
"""Re-fetch /props and update the cached media marker if it changed.
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
is set), so a stale marker indicates a restart. Returns True iff the
marker was updated to a new value used to gate a one-shot retry of
a failed embeddings request.
"""
if self.provider is None:
return False
try:
props = _fetch_llama_props(self.provider, self.genai_config.model)
except Exception as e:
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
return False
marker = props.get("media_marker")
if not isinstance(marker, str) or not marker or marker == self._media_marker:
return False
logger.info("llama.cpp media marker changed (server restart); refreshed")
self._media_marker = marker
return True
def embed(
self,
texts: list[str] | None = None,
@@ -626,46 +468,30 @@ class LlamaCppClient(GenAIClient):
EMBEDDING_DIM = 768
encoded_images: list[str] = []
content = []
for text in texts:
content.append({"prompt_string": text})
for img in images:
# llama.cpp uses STB which does not support WebP; convert to JPEG
jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
def build_content() -> list[dict[str, Any]]:
# prompt_string must contain the server's media marker placeholder
# for each image. The marker is randomized per server startup.
content: list[dict[str, Any]] = []
for text in texts:
content.append({"prompt_string": text})
for encoded in encoded_images:
content.append(
{
"prompt_string": f"{self._media_marker}\n",
"multimodal_data": [encoded],
}
)
return content
def post_embeddings() -> requests.Response:
return requests.post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": build_content()},
timeout=self.timeout,
encoded = base64.b64encode(to_encode).decode("utf-8")
# prompt_string must contain the server's media marker placeholder.
# The marker is randomized per server startup (read from /props).
content.append(
{
"prompt_string": f"{self._media_marker}\n",
"multimodal_data": [encoded], # type: ignore[dict-item]
}
)
try:
try:
response = post_embeddings()
response.raise_for_status()
except requests.exceptions.RequestException:
# The server may have restarted with a new media marker.
# Refresh from /props; only retry if the marker actually changed.
if not encoded_images or not self._refresh_media_marker():
raise
response = post_embeddings()
response.raise_for_status()
response = requests.post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": content},
timeout=self.timeout,
)
response.raise_for_status()
result = response.json()
items = result.get("data", result) if isinstance(result, dict) else result
@@ -811,7 +637,6 @@ class LlamaCppClient(GenAIClient):
try:
payload = self._build_payload(messages, tools, tool_choice, stream=True)
content_parts: list[str] = []
reasoning_parts: list[str] = []
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
@@ -832,24 +657,12 @@ 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
delta = choices[0].get("delta", {})
if choices[0].get("finish_reason"):
finish_reason = choices[0]["finish_reason"]
# llama.cpp emits separated thinking under
# reasoning_content (preferred) or thinking before any
# content tokens arrive
reasoning_delta = delta.get("reasoning_content") or delta.get(
"thinking"
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
if delta.get("content"):
content_parts.append(delta["content"])
yield ("content_delta", delta["content"])
@@ -875,7 +688,6 @@ class LlamaCppClient(GenAIClient):
)
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
tool_calls_list = self._streamed_tool_calls_to_list(tool_calls_by_index)
if tool_calls_list:
finish_reason = "tool_calls"
@@ -883,7 +695,6 @@ class LlamaCppClient(GenAIClient):
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
@@ -18,37 +18,6 @@ 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]]]:
@@ -309,7 +278,6 @@ class OllamaClient(GenAIClient):
"model": self.genai_config.model,
"messages": request_messages,
**self.provider_options,
**self.genai_config.runtime_options,
}
if stream:
request_params["stream"] = True
@@ -337,9 +305,6 @@ class OllamaClient(GenAIClient):
response.get("done"),
)
content = message.get("content", "").strip() if message.get("content") else None
reasoning = (
message.get("thinking", "").strip() if message.get("thinking") else None
)
tool_calls = parse_tool_calls_from_message(message)
finish_reason = "error"
if response.get("done"):
@@ -352,7 +317,6 @@ class OllamaClient(GenAIClient):
finish_reason = "stop"
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@@ -436,15 +400,9 @@ class OllamaClient(GenAIClient):
)
response = await async_client.chat(**request_params)
result = self._message_from_response(response)
reasoning = result.get("reasoning")
if reasoning:
yield ("reasoning_delta", reasoning)
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
@@ -457,38 +415,25 @@ class OllamaClient(GenAIClient):
headers=self._auth_headers(),
)
content_parts: list[str] = []
reasoning_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:
continue
msg = chunk.get("message", {})
reasoning_delta = msg.get("thinking") or ""
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
delta = msg.get("content") or ""
if delta:
content_parts.append(delta)
yield ("content_delta", delta)
if chunk.get("done"):
final_chunk = chunk
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
final_message = {
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": None,
"finish_reason": "stop",
}
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:
@@ -496,7 +441,6 @@ class OllamaClient(GenAIClient):
"message",
{
"content": "".join(content_parts).strip() or None,
"reasoning": "".join(reasoning_parts).strip() or None,
"tool_calls": None,
"finish_reason": "stop",
},
@@ -14,22 +14,6 @@ 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."""
@@ -38,11 +22,7 @@ class OpenAIClient(GenAIClient):
context_size: Optional[int] = None
def _init_provider(self) -> OpenAI:
"""Initialize the client.
Subclasses (e.g. Azure) should raise on configuration errors; the
manager catches construction failures and disables the provider.
"""
"""Initialize the client."""
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
# It will be used in get_context_size() instead
provider_opts = {
@@ -207,7 +187,6 @@ class OpenAIClient(GenAIClient):
"model": self.genai_config.model,
"messages": messages,
"timeout": self.timeout,
**self.genai_config.runtime_options,
}
if tools:
@@ -224,7 +203,7 @@ class OpenAIClient(GenAIClient):
}
request_params.update(provider_opts)
result = self.provider.chat.completions.create(**request_params)
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
if (
result is None
@@ -240,10 +219,6 @@ class OpenAIClient(GenAIClient):
choice = result.choices[0]
message = choice.message
content = message.content.strip() if message.content else None
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
message, "reasoning", None
)
reasoning = raw_reasoning.strip() if raw_reasoning else None
tool_calls = None
if message.tool_calls:
@@ -278,7 +253,6 @@ class OpenAIClient(GenAIClient):
return {
"content": content,
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
@@ -287,7 +261,6 @@ class OpenAIClient(GenAIClient):
logger.warning("OpenAI request timed out: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@@ -295,7 +268,6 @@ class OpenAIClient(GenAIClient):
logger.warning("OpenAI returned an error: %s", str(e))
return {
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
@@ -326,8 +298,6 @@ class OpenAIClient(GenAIClient):
"messages": messages,
"timeout": self.timeout,
"stream": True,
"stream_options": {"include_usage": True},
**self.genai_config.runtime_options,
}
if tools:
@@ -346,18 +316,12 @@ class OpenAIClient(GenAIClient):
# Use streaming API
content_parts: list[str] = []
reasoning_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)
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
@@ -368,15 +332,6 @@ class OpenAIClient(GenAIClient):
if choice.finish_reason:
finish_reason = choice.finish_reason
# Extract reasoning deltas (reasoning_content or reasoning,
# depending on the server)
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
delta, "reasoning", None
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
@@ -405,7 +360,6 @@ class OpenAIClient(GenAIClient):
# Build final message
full_content = "".join(content_parts).strip() or None
full_reasoning = "".join(reasoning_parts).strip() or None
# Convert tool calls to list format
tool_calls_list = None
@@ -427,14 +381,10 @@ class OpenAIClient(GenAIClient):
)
finish_reason = "tool_calls"
if usage_stats is not None:
yield ("stats", usage_stats)
yield (
"message",
{
"content": full_content,
"reasoning": full_reasoning,
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
@@ -446,7 +396,6 @@ class OpenAIClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
@@ -457,7 +406,6 @@ class OpenAIClient(GenAIClient):
"message",
{
"content": None,
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
},
-1
View File
@@ -1 +0,0 @@
"""GenAI provider plugins."""
-53
View File
@@ -1,53 +0,0 @@
"""Azure OpenAI Provider for Frigate AI.
Azure OpenAI exposes the same chat completions API as OpenAI once the
client is constructed, so this provider inherits all transport, streaming,
reasoning, and tool-calling logic from :class:`OpenAIClient` and only
overrides what is genuinely Azure-specific:
- Client construction: parses ``api-version`` out of the configured
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
malformed; :class:`GenAIClientManager` catches the exception and
disables the provider.
- Context size: Azure does not expose a per-model ``max_model_len`` field
reliably, so we keep the historical 128K default rather than the
model-name heuristic used by OpenAI.
"""
import logging
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import register_genai_provider
from frigate.genai.plugins.openai import OpenAIClient
logger = logging.getLogger(__name__)
@register_genai_provider(GenAIProviderEnum.azure_openai)
class AzureOpenAIClient(OpenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
def _init_provider(self) -> AzureOpenAI:
"""Initialize the AzureOpenAI client from the configured base_url."""
parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
if not api_version:
raise ValueError("Azure OpenAI base_url is missing api-version.")
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
def get_context_size(self) -> int:
"""Azure does not reliably surface per-model context size; use 128K."""
return 128000
-739
View File
@@ -1,739 +0,0 @@
"""Prompt and response-format builders for GenAI features.
Centralizes the per-feature prompt framing and structured-output schema
shaping so provider clients in :mod:`frigate.genai.plugins` only handle
transport.
"""
import datetime
from typing import Any, Dict, List, Optional
from playhouse.shortcuts import model_to_dict
from frigate.config import CameraConfig, FrigateConfig
from frigate.config.classification import ObjectClassificationType
from frigate.config.ui import UnitSystemEnum
from frigate.data_processing.post.types import ReviewMetadata
from frigate.models import Event
def build_review_description_prompt(
review_data: dict[str, Any],
thumbnails: list[bytes],
concerns: list[str],
preferred_language: str | None,
activity_context_prompt: str,
) -> str:
"""Build the prompt for review activity description generation."""
def get_concern_prompt() -> str:
if concerns:
concern_list = "\n - ".join(concerns)
return (
"\n- `other_concerns` (list of strings): Include a list of any of "
"the following concerns that are occurring:\n"
f" - {concern_list}"
)
else:
return ""
def get_language_prompt() -> str:
if preferred_language:
return f"Provide your answer in {preferred_language}"
else:
return ""
def get_objects_list() -> str:
if review_data["unified_objects"]:
return "\n- " + "\n- ".join(review_data["unified_objects"])
else:
return "\n- (No objects detected)"
return f"""
Your task is to analyze a sequence of images taken in chronological order from a security camera.
## Normal Activity Patterns for This Property
{activity_context_prompt}
## Task Instructions
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
## Analysis Guidelines
When forming your description:
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
- **Use the actual timestamp provided in "Activity started at"** below for time of day contextdo not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
## 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`: 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
- Camera: {review_data["camera"]}
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. The `` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
**Note: Unidentified objects (without names) are NOT indicators of suspicious activitythey simply mean the system hasn't identified that object.**
{get_objects_list()}
{get_language_prompt()}
"""
def build_review_description_response_format(concerns: list[str]) -> dict[str, Any]:
"""Build the structured-output JSON schema for review descriptions.
Strips the `time` field (populated server-side) and drops
`other_concerns` when no concerns are configured.
"""
schema = ReviewMetadata.model_json_schema()
schema.get("properties", {}).pop("time", None)
if "time" in schema.get("required", []):
schema["required"].remove("time")
if not concerns:
schema.get("properties", {}).pop("other_concerns", None)
if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns")
return {
"type": "json_schema",
"json_schema": {
"name": "review_metadata",
"strict": True,
"schema": schema,
},
}
def build_review_summary_prompt(
start_ts: float,
end_ts: float,
events: list[dict[str, Any]],
preferred_language: str | None,
) -> str:
"""Build the prompt for a multi-event review summary."""
time_range = (
f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')}"
f" to "
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
)
prompt = f"""
You are a security officer writing a concise security report.
Time range: {time_range}
Input format: Each event is a JSON object with:
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
- "context": array of related events from other cameras that occurred during overlapping time periods
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
Report Structure - Use this EXACT format:
# Security Summary - {time_range}
## Overview
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
---
## Timeline
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
### [Time Block Name]
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
- [Event title]: [Clear description incorporating contextual information from the "context" array]
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
[Repeat for each event in chronological order within the time block]
---
## Summary
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
Guidelines:
- List ALL events in chronological order, grouped by time blocks
- Threat level indicators: Normal, Needs review, 🔴 Security concern
- Integrate contextual information naturally - use the "context" array to enrich each event's description
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
- Be concise but informative - focus on what happened and what it means
- If contextual information makes an event clearly normal, reflect that in your assessment
- Only create time blocks that have events - don't create empty sections
"""
prompt += "\n\nEvents:\n"
for event in events:
prompt += f"\n{event}\n"
if preferred_language:
prompt += f"\nProvide your answer in {preferred_language}"
return prompt
def build_object_description_prompt(
camera_config: CameraConfig,
event: Event,
) -> str:
"""Build the prompt for a per-object description.
Pulls the per-label override from `objects.genai.object_prompts`, falling
back to the camera default, and interpolates event fields.
Raises:
KeyError: if the user-defined prompt template references an unknown
event field.
"""
template = camera_config.objects.genai.object_prompts.get(
str(event.label),
camera_config.objects.genai.prompt,
)
return template.format(**model_to_dict(event))
def get_attribute_classifications(config: FrigateConfig) -> List[Dict[str, Any]]:
"""Return enabled custom classification models of `attribute` type.
Each entry: {"name": <model name>, "objects": [<object label>, ...]}.
These models attach attribute metadata to events on the listed object
types, which can later be filtered via the search_objects `attribute`
field.
"""
result: List[Dict[str, Any]] = []
for model_key, model_config in config.classification.custom.items():
if not model_config.enabled or model_config.object_config is None:
continue
if (
model_config.object_config.classification_type
!= ObjectClassificationType.attribute
):
continue
result.append(
{
"name": model_config.name or model_key,
"objects": list(model_config.object_config.objects or []),
}
)
return result
def get_tool_definitions(
semantic_search_enabled: bool = False,
attribute_classifications: Optional[List[Dict[str, Any]]] = None,
) -> 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. 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. When attribute classification models are configured, an
`attribute` parameter is exposed for filtering by their labels.
"""
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 attribute_classifications:
model_outline = "; ".join(
f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})"
for m in attribute_classifications
)
search_objects_properties["attribute"] = {
"type": "string",
"description": (
"Filter by a classification attribute label produced by a "
"configured attribute classification model. Use this INSTEAD "
"of semantic_query when the user's request matches one of "
"these classifications. Configured models: "
f"{model_outline}. "
"Set the value to the attribute label that matches the user's "
"phrasing (case-sensitive)."
),
}
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_objects_description,
"parameters": {
"type": "object",
"properties": search_objects_properties,
},
"required": [],
},
},
{
"type": "function",
"function": {
"name": "find_similar_objects",
"description": (
"Find tracked objects that are visually and semantically similar "
"to a specific past event. Use this when the user references a "
"particular object they have seen and wants to find other "
"sightings of the same or similar one ('that green car', 'the "
"person in the red jacket', 'the package that was delivered'). "
"Prefer this over search_objects whenever the user's intent is "
"'find more like this specific one.' Use search_objects first "
"only if you need to locate the anchor event. Requires semantic "
"search to be enabled."
),
"parameters": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "The id of the anchor event to find similar objects to.",
},
"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').",
},
"cameras": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of cameras to restrict to. Defaults to all.",
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
},
"sub_labels": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of sub_labels (names) to restrict to.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of zones. An event matches if any of its zones overlap.",
},
"similarity_mode": {
"type": "string",
"enum": ["visual", "semantic", "fused"],
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
"default": "fused",
},
"min_score": {
"type": "number",
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
},
"limit": {
"type": "integer",
"description": "Maximum number of matches to return (default: 10).",
"default": 10,
},
},
"required": ["event_id"],
},
},
},
{
"type": "function",
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Requires admin privileges."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to target, or '*' to target all cameras.",
},
"feature": {
"type": "string",
"enum": [
"detect",
"record",
"snapshots",
"audio",
"motion",
"enabled",
"birdseye",
"birdseye_mode",
"improve_contrast",
"ptz_autotracker",
"motion_contour_area",
"motion_threshold",
"notifications",
"audio_transcription",
"review_alerts",
"review_detections",
"object_descriptions",
"review_descriptions",
"profile",
],
"description": (
"The feature to change. Most features accept ON or OFF. "
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
"motion_contour_area and motion_threshold accept a number. "
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
),
},
"value": {
"type": "string",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
},
},
"required": ["camera", "feature", "value"],
},
},
},
{
"type": "function",
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to get live context for.",
},
},
"required": ["camera"],
},
},
},
{
"type": "function",
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera ID to monitor.",
},
"condition": {
"type": "string",
"description": (
"Natural-language description of the condition to watch for, "
"e.g. 'a person arrives at the front door'."
),
},
"max_duration_minutes": {
"type": "integer",
"description": "Maximum time to watch before giving up (minutes, default 60).",
"default": 60,
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
},
"zones": {
"type": "array",
"items": {"type": "string"},
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
},
},
"required": ["camera", "condition"],
},
},
},
{
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_profile_status",
"description": (
"Get the current profile status including the active profile and "
"timestamps of when each profile was last activated. Use this to "
"determine time periods for recap requests — e.g. when the user asks "
"'what happened while I was away?', call this first to find the relevant "
"time window based on profile activation history."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_recap",
"description": (
"Get a recap of all activity (alerts and detections) for a given time period. "
"Use this after calling get_profile_status to retrieve what happened during "
"a specific window — e.g. 'what happened while I was away?'. Returns a "
"chronological list of activity with camera, objects, zones, and GenAI-generated "
"descriptions when available. Summarize the results for the user."
),
"parameters": {
"type": "object",
"properties": {
"after": {
"type": "string",
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
},
"before": {
"type": "string",
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
},
"cameras": {
"type": "string",
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
},
"severity": {
"type": "string",
"enum": ["alert", "detection"],
"description": "Filter by severity level. Omit to include both alerts and detections.",
},
},
"required": ["after", "before"],
},
},
},
]
def build_chat_system_prompt(
config: FrigateConfig,
allowed_cameras: List[str],
semantic_search_enabled: bool,
attribute_classifications: List[Dict[str, Any]],
) -> str:
"""Build the system prompt for the chat completion endpoint.
Composes the static framing with conditional sections describing the
available cameras, speed units, semantic-search routing guidance, and
configured attribute classifications.
"""
current_datetime = datetime.datetime.now()
current_date_str = current_datetime.strftime("%Y-%m-%d")
current_time_str = current_datetime.strftime("%I:%M:%S %p")
cameras_info: List[str] = []
has_speed_zone = False
for camera_id in allowed_cameras:
if camera_id not in config.cameras:
continue
camera_config = config.cameras[camera_id]
friendly_name = (
camera_config.friendly_name
if camera_config.friendly_name
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)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
cameras_section = ""
if cameras_info:
cameras_section = (
"\n\nAvailable cameras:\n"
+ "\n".join(cameras_info)
+ "\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`."
)
attribute_classification_section = ""
if attribute_classifications:
model_lines = "\n".join(
f"- {m['name']}: applies to {', '.join(m['objects']) or 'any object'}"
for m in attribute_classifications
)
attribute_classification_section = (
"\n\nAttribute classification models are configured for the following object types:\n"
f"{model_lines}\n"
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels."
)
return 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}
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
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:<id>], 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}{attribute_classification_section}{cameras_section}{speed_units_section}"""
+38 -140
View File
@@ -12,7 +12,6 @@ import os
import subprocess as sp
import threading
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional, cast
@@ -24,7 +23,7 @@ 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 Export, Recordings
from frigate.models import Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.ffmpeg import run_ffmpeg_with_progress
@@ -115,125 +114,6 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
return cast(ModelSelect, query)
class DebugReplaySource(ABC):
"""Abstract source for a debug replay session.
Provides the camera identity and time range the replay represents,
validates that usable content exists, and supplies the ffmpeg input
args used to build the replay clip.
"""
@property
@abstractmethod
def source_camera(self) -> str:
"""Camera name the replay is derived from."""
@property
@abstractmethod
def start_ts(self) -> float:
"""Unix timestamp marking the start of the replay range."""
@property
@abstractmethod
def end_ts(self) -> float:
"""Unix timestamp marking the end of the replay range."""
@abstractmethod
def validate(self) -> None:
"""Raise ValueError if the source has no usable content."""
@abstractmethod
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
"""Return ffmpeg input args (including -i). May write temp files in working_dir."""
def cleanup(self, working_dir: str) -> None:
"""Remove any temp files the source created in working_dir. Default no-op."""
class RecordingDebugReplaySource(DebugReplaySource):
"""Replay source backed by the Recordings table.
Builds a concat playlist of recording files covering the time range
and feeds it to ffmpeg's concat demuxer.
"""
def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None:
self._camera = source_camera
self._start_ts = start_ts
self._end_ts = end_ts
self._concat_file: Optional[str] = None
@property
def source_camera(self) -> str:
return self._camera
@property
def start_ts(self) -> float:
return self._start_ts
@property
def end_ts(self) -> float:
return self._end_ts
def validate(self) -> None:
if self._end_ts <= self._start_ts:
raise ValueError("End time must be after start time")
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
raise ValueError(
f"No recordings found for camera '{self._camera}' in the specified time range"
)
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}"
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt")
recordings = query_recordings(self._camera, self._start_ts, self._end_ts)
with open(concat_file, "w") as f:
for recording in recordings:
f.write(f"file '{recording.path}'\n")
self._concat_file = concat_file
return ["-f", "concat", "-safe", "0", "-i", concat_file]
def cleanup(self, working_dir: str) -> None:
if self._concat_file:
_remove_silent(self._concat_file)
class ExportDebugReplaySource(DebugReplaySource):
"""Replay source backed by an existing Export.
Uses the export's video file directly as the ffmpeg input — does not
require recordings to still exist for the time range.
"""
def __init__(self, export: Export, duration: float) -> None:
self._camera = cast(str, export.camera)
# Export.date is declared DateTimeField but Frigate writes raw unix
# timestamps to the column.
self._start_ts = float(cast(Any, export.date))
self._video_path = cast(str, export.video_path)
self._duration = duration
@property
def source_camera(self) -> str:
return self._camera
@property
def start_ts(self) -> float:
return self._start_ts
@property
def end_ts(self) -> float:
return self._start_ts + self._duration
def validate(self) -> None:
if not os.path.exists(self._video_path):
raise ValueError(f"Export video file not found: {self._video_path}")
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
return ["-i", self._video_path]
class DebugReplayJobRunner(threading.Thread):
"""Worker thread that drives the startup job to completion.
@@ -246,7 +126,6 @@ class DebugReplayJobRunner(threading.Thread):
def __init__(
self,
job: DebugReplayJob,
source: DebugReplaySource,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: "DebugReplayManager",
@@ -254,7 +133,6 @@ class DebugReplayJobRunner(threading.Thread):
) -> None:
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
self.job = job
self.source = source
self.frigate_config = frigate_config
self.config_publisher = config_publisher
self.replay_manager = replay_manager
@@ -305,6 +183,7 @@ class DebugReplayJobRunner(threading.Thread):
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
@@ -313,13 +192,23 @@ class DebugReplayJobRunner(threading.Thread):
self._broadcast(force=True)
try:
input_args = self.source.ffmpeg_input_args(REPLAY_DIR)
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",
*input_args,
"-f",
"concat",
"-safe",
"0",
"-i",
concat_file,
"-c",
"copy",
"-movflags",
@@ -396,7 +285,7 @@ class DebugReplayJobRunner(threading.Thread):
self.replay_manager.clear_session()
_remove_silent(clip_path)
finally:
self.source.cleanup(REPLAY_DIR)
_remove_silent(concat_file)
_set_active_runner(None)
def _finalize_cancelled(self, clip_path: str) -> None:
@@ -420,43 +309,52 @@ def _remove_silent(path: str) -> None:
def start_debug_replay_job(
*,
source: DebugReplaySource,
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 an invalid source (camera missing, source has
no usable content) and RuntimeError if a session is already active.
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.source_camera not in frigate_config.cameras:
raise ValueError(f"Camera '{source.source_camera}' not found")
if source_camera not in frigate_config.cameras:
raise ValueError(f"Camera '{source_camera}' not found")
source.validate()
if end_ts <= start_ts:
raise ValueError("End time must be after start time")
replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}"
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.source_camera,
source_camera=source_camera,
replay_camera_name=replay_name,
start_ts=source.start_ts,
end_ts=source.end_ts,
start_ts=start_ts,
end_ts=end_ts,
)
job = DebugReplayJob(
source_camera=source.source_camera,
source_camera=source_camera,
replay_camera_name=replay_name,
start_ts=source.start_ts,
end_ts=source.end_ts,
start_ts=start_ts,
end_ts=end_ts,
)
set_current_job(job)
runner = DebugReplayJobRunner(
job=job,
source=source,
frigate_config=frigate_config,
config_publisher=config_publisher,
replay_manager=replay_manager,
-3
View File
@@ -45,7 +45,6 @@ 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)
@@ -375,7 +374,6 @@ 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.
@@ -399,7 +397,6 @@ 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
+6 -11
View File
@@ -62,10 +62,8 @@ 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.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)"
logger.warning(
f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}"
)
return (canvas_width, canvas_height)
@@ -798,18 +796,15 @@ 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,
canvas_width,
canvas_height,
canvas_width,
canvas_height,
config.birdseye.width,
config.birdseye.height,
config.birdseye.width,
config.birdseye.height,
config.birdseye.quality,
config.birdseye.restream,
)
+2 -1
View File
@@ -610,7 +610,8 @@ class RecordingMaintainer(threading.Thread):
camera,
)
os.makedirs(directory, exist_ok=True)
if not os.path.exists(directory):
os.makedirs(directory)
# file will be in utc due to start_time being in utc
file_name = f"{start_time.strftime('%M.%S.mp4')}"
-109
View File
@@ -1,109 +0,0 @@
"""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()
+11 -16
View File
@@ -230,7 +230,6 @@ 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:
@@ -243,7 +242,6 @@ 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)) + "%",
@@ -252,34 +250,31 @@ async def set_gpu_stats(
}
else:
stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
stats["nvidia-gpu"] = {"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"] = {"vendor": "nvidia", **jetson_usage}
stats["jetson-gpu"] = jetson_usage
else:
stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
stats["jetson-gpu"] = {"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 not intel_gpu_collected:
if "intel-gpu" not in stats:
# 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:
for entry in intel_usage.values():
name = entry.pop("name")
stats[name] = entry
if intel_usage is not None:
stats["intel-gpu"] = intel_usage or {"gpu": "", "mem": ""}
else:
stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""}
stats["intel-gpu"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "vaapi" in args:
if not config.telemetry.stats.amd_gpu_stats:
@@ -289,18 +284,18 @@ async def set_gpu_stats(
amd_usage = get_amd_gpu_stats()
if amd_usage:
stats["amd-vaapi"] = {"vendor": "amd", **amd_usage}
stats["amd-vaapi"] = amd_usage
else:
stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""}
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "preset-rk" in args:
rga_usage = get_rockchip_gpu_stats()
if rga_usage:
stats["rockchip"] = {"vendor": "rockchip", **rga_usage}
stats["rockchip"] = rga_usage
elif "v4l2m2m" in args or "rpi" in args:
# RPi v4l2m2m is currently not able to get usage stats
stats["rpi-v4l2m2m"] = {"vendor": "rpi", "gpu": "", "mem": ""}
stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""}
if stats:
all_stats["gpu_usages"] = stats
@@ -15,12 +15,11 @@ class TestDebugReplayAPI(BaseTestHttp):
# 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):
source = kwargs["source"]
kwargs["replay_manager"].mark_starting(
source_camera=source.source_camera,
source_camera=kwargs["source_camera"],
replay_camera_name="_replay_front",
start_ts=source.start_ts,
end_ts=source.end_ts,
start_ts=kwargs["start_ts"],
end_ts=kwargs["end_ts"],
)
return "job-1234"
@@ -1,4 +1,3 @@
import os
from unittest.mock import patch
from fastapi import HTTPException, Request
@@ -358,51 +357,6 @@ 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."""
+13 -64
View File
@@ -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
from frigate.util.builtin import deep_merge, load_labels
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,11 +309,16 @@ class TestConfig(unittest.TestCase):
}
frigate_config = FrigateConfig(**config)
assert set(frigate_config.cameras["back"].audio.filters.keys()) == {
"speech",
"yell",
all_audio_labels = {
label
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
if label
}
assert all_audio_labels.issubset(
set(frigate_config.cameras["back"].audio.filters.keys())
)
def test_override_audio_filters(self):
config = {
"mqtt": {"host": "mqtt"},
@@ -340,8 +345,7 @@ 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 "yell" in frigate_config.cameras["back"].audio.filters
assert "babbling" not in frigate_config.cameras["back"].audio.filters
assert "babbling" in frigate_config.cameras["back"].audio.filters
def test_inherit_object_filters(self):
config = {
@@ -1673,60 +1677,5 @@ class TestConfig(unittest.TestCase):
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
class TestAttributeFilterDefaults(unittest.TestCase):
"""Verify attribute filter min_score handling at config load."""
def setUp(self):
self.minimal = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
def _build_config(self, object_filters: dict | None = None) -> FrigateConfig:
config = deep_merge({}, self.minimal)
if object_filters is not None:
config.setdefault("objects", {})["filters"] = object_filters
return FrigateConfig(**config)
def test_attribute_with_no_filter_gets_default_min_score(self):
"""Attribute with no user-provided filter gets created with min_score=0.7."""
config = self._build_config()
face_filter = config.objects.filters.get("face")
self.assertIsNotNone(face_filter)
self.assertEqual(face_filter.min_score, 0.7)
def test_attribute_filter_without_min_score_gets_bumped(self):
"""If user sets some FilterConfig field but not min_score, min_score is bumped to 0.7."""
config = self._build_config({"face": {"min_area": 500}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_area, 500)
self.assertEqual(face_filter.min_score, 0.7)
def test_attribute_filter_explicit_min_score_half_is_preserved(self):
"""User-provided min_score=0.5 must NOT be silently rewritten to 0.7."""
config = self._build_config({"face": {"min_score": 0.5}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_score, 0.5)
def test_attribute_filter_explicit_min_score_other_value_is_preserved(self):
"""Sanity: explicit non-0.5 values pass through unchanged."""
config = self._build_config({"face": {"min_score": 0.3}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_score, 0.3)
if __name__ == "__main__":
unittest.main(verbosity=2)
-8
View File
@@ -71,14 +71,6 @@ class TestDebugReplayManagerSession(unittest.TestCase):
class TestDebugReplayManagerStop(unittest.TestCase):
def setUp(self) -> None:
# stop() publishes a terminal job_state via a real JobStatePublisher,
# which opens a ZMQ REQ socket and blocks on REP. No dispatcher runs
# in unit tests, so substitute a no-op publisher.
patcher = patch("frigate.debug_replay.JobStatePublisher")
patcher.start()
self.addCleanup(patcher.stop)
def test_stop_when_inactive_is_a_noop(self) -> None:
from frigate.debug_replay import DebugReplayManager
+27 -28
View File
@@ -9,7 +9,6 @@ from unittest.mock import MagicMock, patch
from frigate.debug_replay import DebugReplayManager
from frigate.jobs.debug_replay import (
DebugReplayJob,
RecordingDebugReplaySource,
cancel_debug_replay_job,
get_active_runner,
start_debug_replay_job,
@@ -100,9 +99,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
def test_rejects_unknown_camera(self) -> None:
with self.assertRaises(ValueError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="missing", start_ts=100.0, end_ts=200.0
),
source_camera="missing",
start_ts=100.0,
end_ts=200.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -111,9 +110,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
def test_rejects_invalid_time_range(self) -> None:
with self.assertRaises(ValueError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=200.0, end_ts=100.0
),
source_camera="front",
start_ts=200.0,
end_ts=100.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -125,9 +124,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
with self.assertRaises(ValueError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
source_camera="front",
start_ts=100.0,
end_ts=200.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -155,9 +154,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
job_id = start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
source_camera="front",
start_ts=100.0,
end_ts=200.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -192,9 +191,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
source_camera="front",
start_ts=100.0,
end_ts=200.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -202,9 +201,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(RuntimeError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
source_camera="front",
start_ts=100.0,
end_ts=200.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -270,9 +269,9 @@ class TestRunnerHappyPath(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
source_camera="front",
start_ts=100.0,
end_ts=200.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -341,9 +340,9 @@ class TestRunnerFailurePath(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
source_camera="front",
start_ts=100.0,
end_ts=200.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -419,9 +418,9 @@ class TestRunnerCancellation(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
),
source_camera="front",
start_ts=100.0,
end_ts=200.0,
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
+6 -12
View File
@@ -17,14 +17,12 @@ class TestGpuStats(unittest.TestCase):
amd_stats = get_amd_gpu_stats()
assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"}
@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):
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep):
# 1 second of wall clock between snapshots
monotonic.side_effect = [0.0, 1.0]
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
# Two i915 clients on the same iGPU. Engine values are cumulative ns.
# Deltas over the 1s window:
@@ -81,15 +79,11 @@ class TestGpuStats(unittest.TestCase):
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%"},
},
"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")
+1 -1
View File
@@ -230,7 +230,7 @@ class TestExportResolution(unittest.TestCase):
id=export_id,
camera=camera,
name=f"export-{export_id}",
date=int(datetime.datetime.now().timestamp()),
date=datetime.datetime.now(),
video_path=f"/media/frigate/exports/{filename}",
thumb_path=f"/media/frigate/exports/{filename}.jpg",
in_progress=False,
-806
View File
@@ -1,806 +0,0 @@
"""Tests for outbound WebSocket broadcast filtering."""
import json
import threading
import unittest
from types import SimpleNamespace
from typing import Any
from frigate.comms.ws import (
WebSocketClient,
_classify_outbound,
_collect_zone_names,
_extract_payload_camera,
_materialize_for_ws,
_ws_allowed_cameras,
_ws_is_unrestricted,
)
from frigate.config import FrigateConfig
def _build_config(
*,
extra_roles: dict[str, list[str]] | None = None,
extra_cameras: dict[str, dict[str, Any]] | None = None,
extra_zones: dict[str, dict[str, dict[str, Any]]] | None = None,
) -> FrigateConfig:
"""Construct a FrigateConfig used by the outbound filter tests.
The default fixture has three cameras: front_door, back_door, garage.
Restricted role "house_only" sees front_door + back_door but not garage.
"""
cameras: dict[str, dict[str, Any]] = {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/v", "roles": ["detect"]}],
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.2:554/v", "roles": ["detect"]}],
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"garage": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.3:554/v", "roles": ["detect"]}],
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
}
if extra_cameras:
cameras.update(extra_cameras)
if extra_zones:
for cam_name, zones in extra_zones.items():
cameras[cam_name]["zones"] = zones
roles = {"house_only": ["front_door", "back_door"]}
if extra_roles:
roles.update(extra_roles)
return FrigateConfig(
mqtt={"host": "mqtt"},
auth={"roles": roles},
cameras=cameras,
)
def _ws(role: str | None) -> Any:
"""Build a fake ws4py-style websocket exposing ``environ``."""
environ = {} if role is None else {"HTTP_REMOTE_ROLE": role}
return SimpleNamespace(environ=environ, terminated=False, sent=[])
class TestClassifyOutbound(unittest.TestCase):
"""The pure classifier — bucket every topic into a scope."""
def setUp(self):
self.config = _build_config(
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
)
self.all_cameras = set(self.config.cameras.keys())
self.all_zones = _collect_zone_names(self.config)
def _classify(self, topic: str) -> tuple[str, Any]:
return _classify_outbound(topic, self.all_cameras, self.all_zones)
# --- Global allowlist ---
def test_model_state_is_global(self):
self.assertEqual(self._classify("model_state"), ("global", None))
def test_profile_state_is_global(self):
self.assertEqual(self._classify("profile/state"), ("global", None))
def test_bare_notifications_state_is_global(self):
"""The 2-segment ``notifications/state`` is global; the 3-segment
``<camera>/notifications/state`` is camera-scoped (see below)."""
self.assertEqual(self._classify("notifications/state"), ("global", None))
def test_notification_test_is_global(self):
self.assertEqual(self._classify("notification_test"), ("global", None))
# --- Unrestricted-only ---
def test_birdseye_layout_is_unrestricted_only(self):
self.assertEqual(self._classify("birdseye_layout"), ("unrestricted_only", None))
# --- Camera-prefixed ---
def test_camera_state_topic_resolves_to_camera(self):
self.assertEqual(
self._classify("front_door/detect/state"), ("camera", "front_door")
)
def test_camera_motion_topic_resolves_to_camera(self):
self.assertEqual(self._classify("back_door/motion"), ("camera", "back_door"))
def test_camera_per_notification_topic_resolves_to_camera(self):
self.assertEqual(
self._classify("front_door/notifications/state"),
("camera", "front_door"),
)
def test_camera_label_counter_resolves_to_camera(self):
self.assertEqual(self._classify("front_door/person"), ("camera", "front_door"))
def test_camera_object_mask_state_resolves_to_camera(self):
self.assertEqual(
self._classify("front_door/object_mask/zone_1/state"),
("camera", "front_door"),
)
# --- Zone-prefixed ---
def test_zone_aggregate_topic_is_unrestricted_only(self):
self.assertEqual(self._classify("driveway/person"), ("unrestricted_only", None))
def test_zone_all_topic_is_unrestricted_only(self):
self.assertEqual(self._classify("driveway/all"), ("unrestricted_only", None))
# --- Payload-camera ---
def test_events_topic_marks_payload_camera_path(self):
self.assertEqual(
self._classify("events"), ("payload_camera", ("after", "camera"))
)
def test_reviews_topic_marks_payload_camera_path(self):
self.assertEqual(
self._classify("reviews"), ("payload_camera", ("after", "camera"))
)
def test_triggers_topic_marks_payload_camera_path(self):
self.assertEqual(self._classify("triggers"), ("payload_camera", ("camera",)))
def test_tracked_object_update_marks_payload_camera_path(self):
self.assertEqual(
self._classify("tracked_object_update"), ("payload_camera", ("camera",))
)
# --- Reshape ---
def test_camera_activity_is_reshape_by_camera_key(self):
self.assertEqual(
self._classify("camera_activity"), ("reshape_by_camera_key", None)
)
def test_audio_detections_is_reshape_by_camera_key(self):
self.assertEqual(
self._classify("audio_detections"), ("reshape_by_camera_key", None)
)
def test_job_state_is_reshape_job_state(self):
self.assertEqual(self._classify("job_state"), ("reshape_job_state", None))
def test_stats_is_reshape_stats(self):
self.assertEqual(self._classify("stats"), ("reshape_stats", None))
# --- Fail-closed ---
def test_unknown_topic_is_dropped(self):
self.assertEqual(self._classify("some_random_topic"), ("drop", None))
def test_unknown_camera_prefix_is_dropped(self):
self.assertEqual(self._classify("ghost_camera/detect/state"), ("drop", None))
class TestCollectZoneNames(unittest.TestCase):
def test_zones_from_all_cameras(self):
config = _build_config(
extra_zones={
"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}},
"back_door": {"yard": {"coordinates": "0,0,1,0,1,1,0,1"}},
}
)
self.assertEqual(_collect_zone_names(config), {"driveway", "yard"})
def test_no_zones_returns_empty(self):
self.assertEqual(_collect_zone_names(_build_config()), set())
class TestExtractPayloadCamera(unittest.TestCase):
def test_extract_from_dict_path(self):
payload = {"after": {"camera": "front_door"}}
self.assertEqual(
_extract_payload_camera(payload, ("after", "camera")), "front_door"
)
def test_extract_from_json_string(self):
payload = json.dumps({"after": {"camera": "front_door"}})
self.assertEqual(
_extract_payload_camera(payload, ("after", "camera")), "front_door"
)
def test_extract_single_segment_path(self):
self.assertEqual(
_extract_payload_camera({"camera": "garage"}, ("camera",)), "garage"
)
def test_missing_key_returns_none(self):
self.assertIsNone(_extract_payload_camera({}, ("after", "camera")))
def test_malformed_json_returns_none(self):
self.assertIsNone(_extract_payload_camera("not-json", ("camera",)))
def test_non_string_camera_returns_none(self):
self.assertIsNone(_extract_payload_camera({"camera": 42}, ("camera",)))
class TestWsRoleHelpers(unittest.TestCase):
def setUp(self):
self.config = _build_config()
def test_admin_is_unrestricted(self):
self.assertTrue(_ws_is_unrestricted(_ws("admin"), self.config))
def test_viewer_is_unrestricted(self):
self.assertTrue(_ws_is_unrestricted(_ws("viewer"), self.config))
def test_restricted_role_is_not_unrestricted(self):
self.assertFalse(_ws_is_unrestricted(_ws("house_only"), self.config))
def test_missing_role_is_not_unrestricted(self):
self.assertFalse(_ws_is_unrestricted(_ws(None), self.config))
def test_unknown_role_is_not_unrestricted(self):
self.assertFalse(_ws_is_unrestricted(_ws("ghost"), self.config))
def test_admin_allowed_cameras_is_all(self):
self.assertEqual(
_ws_allowed_cameras(_ws("admin"), self.config),
{"front_door", "back_door", "garage"},
)
def test_restricted_role_allowed_cameras_is_subset(self):
self.assertEqual(
_ws_allowed_cameras(_ws("house_only"), self.config),
{"front_door", "back_door"},
)
def test_missing_role_allowed_cameras_is_empty(self):
self.assertEqual(_ws_allowed_cameras(_ws(None), self.config), set())
def test_multi_role_union_grants_widest(self):
self.assertEqual(
_ws_allowed_cameras(_ws("house_only,admin"), self.config),
{"front_door", "back_door", "garage"},
)
class TestMaterializeForWs(unittest.TestCase):
def setUp(self):
self.config = _build_config(
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
)
self.all_cameras = set(self.config.cameras.keys())
self.all_zones = _collect_zone_names(self.config)
def _materialize(self, ws: Any, topic: str, payload: Any) -> str | None:
scope = _classify_outbound(topic, self.all_cameras, self.all_zones)
from frigate.comms.ws import _parse_json_payload
parsed = (
_parse_json_payload(payload)
if scope[0]
in (
"payload_camera",
"reshape_by_camera_key",
"reshape_job_state",
"reshape_stats",
)
else None
)
full = json.dumps({"topic": topic, "payload": payload})
return _materialize_for_ws(ws, topic, full, scope, parsed, self.config)
# --- Globals: every authenticated client sees them ---
def test_globals_reach_admin(self):
self.assertIsNotNone(self._materialize(_ws("admin"), "model_state", "{}"))
def test_globals_reach_restricted(self):
self.assertIsNotNone(self._materialize(_ws("house_only"), "model_state", "{}"))
def test_globals_reach_no_role(self):
"""A missing role header still gets globals (matches viewer-default
for inbound)."""
self.assertIsNotNone(self._materialize(_ws(None), "model_state", "{}"))
# --- Unknown topic dropped for everyone ---
def test_unknown_topic_dropped_for_admin(self):
self.assertIsNone(self._materialize(_ws("admin"), "rogue_topic", "{}"))
# --- Non-global topics require a role (fail-closed) ---
def test_no_role_blocked_from_camera_topic(self):
self.assertIsNone(self._materialize(_ws(None), "front_door/detect/state", "ON"))
def test_no_role_blocked_from_events(self):
payload = json.dumps({"after": {"camera": "front_door"}})
self.assertIsNone(self._materialize(_ws(None), "events", payload))
# --- Camera-prefixed ---
def test_restricted_role_sees_allowed_camera(self):
self.assertIsNotNone(
self._materialize(_ws("house_only"), "front_door/detect/state", "ON")
)
def test_restricted_role_blocked_from_unallowed_camera(self):
self.assertIsNone(
self._materialize(_ws("house_only"), "garage/detect/state", "ON")
)
def test_admin_sees_all_camera_topics(self):
self.assertIsNotNone(
self._materialize(_ws("admin"), "garage/detect/state", "ON")
)
# --- Unrestricted-only (zones, birdseye_layout) ---
def test_zone_aggregate_blocked_for_restricted(self):
self.assertIsNone(self._materialize(_ws("house_only"), "driveway/person", 3))
def test_zone_aggregate_visible_to_admin(self):
self.assertIsNotNone(self._materialize(_ws("admin"), "driveway/person", 3))
def test_birdseye_layout_blocked_for_restricted(self):
payload = json.dumps(
{"front_door": {"x": 0, "y": 0, "width": 100, "height": 100}}
)
self.assertIsNone(
self._materialize(_ws("house_only"), "birdseye_layout", payload)
)
def test_birdseye_layout_visible_to_admin(self):
payload = json.dumps(
{"front_door": {"x": 0, "y": 0, "width": 100, "height": 100}}
)
self.assertIsNotNone(
self._materialize(_ws("admin"), "birdseye_layout", payload)
)
# --- Payload-camera ---
def test_events_filtered_by_payload_camera(self):
payload = json.dumps({"after": {"camera": "garage"}})
self.assertIsNone(self._materialize(_ws("house_only"), "events", payload))
payload = json.dumps({"after": {"camera": "front_door"}})
self.assertIsNotNone(self._materialize(_ws("house_only"), "events", payload))
def test_events_with_missing_camera_dropped(self):
payload = json.dumps({"after": {}})
self.assertIsNone(self._materialize(_ws("house_only"), "events", payload))
def test_triggers_filtered_by_payload_camera(self):
payload = json.dumps({"name": "t1", "camera": "garage"})
self.assertIsNone(self._materialize(_ws("house_only"), "triggers", payload))
# --- Reshape: dict keyed by camera ---
def test_camera_activity_filtered_to_allowed_keys(self):
payload = json.dumps(
{
"front_door": {"objects": 1},
"back_door": {"objects": 0},
"garage": {"objects": 2},
}
)
message = self._materialize(_ws("house_only"), "camera_activity", payload)
self.assertIsNotNone(message)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertEqual(set(inner.keys()), {"front_door", "back_door"})
self.assertNotIn("garage", inner)
def test_camera_activity_unchanged_for_admin(self):
payload = json.dumps({"front_door": {}, "back_door": {}, "garage": {}})
message = self._materialize(_ws("admin"), "camera_activity", payload)
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], payload)
def test_camera_activity_with_no_allowed_returns_none(self):
payload = json.dumps({"garage": {"objects": 2}})
self.assertIsNone(
self._materialize(_ws("house_only"), "camera_activity", payload)
)
def test_audio_detections_filtered_to_allowed_keys(self):
payload = json.dumps({"front_door": {"bark": {}}, "garage": {"speech": {}}})
message = self._materialize(_ws("house_only"), "audio_detections", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertEqual(set(inner.keys()), {"front_door"})
# --- Reshape: job_state ---
def test_job_state_admin_sees_full_payload(self):
payload = json.dumps(
{
"motion_search": {"job_type": "motion_search", "camera": "garage"},
"media_sync": {"job_type": "media_sync"},
}
)
message = self._materialize(_ws("admin"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], payload)
def test_job_state_restricted_keeps_allowed_camera_jobs(self):
"""Top-level camera field on a job entry: drop if not allowed."""
payload = json.dumps(
{
"motion_search": {"job_type": "motion_search", "camera": "front_door"},
"vlm_watch": {"job_type": "vlm_watch", "camera": "garage"},
}
)
message = self._materialize(_ws("house_only"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertIn("motion_search", inner)
self.assertNotIn("vlm_watch", inner)
def test_job_state_export_results_jobs_filtered_per_recipient(self):
"""The aggregated export broadcast nests per-camera sub-jobs under
``results.jobs``. Restricted users must only see allowed entries."""
payload = json.dumps(
{
"export": {
"job_type": "export",
"status": "running",
"results": {
"jobs": [
{"job_type": "export", "camera": "front_door", "id": "a"},
{"job_type": "export", "camera": "garage", "id": "b"},
{"job_type": "export", "camera": "back_door", "id": "c"},
]
},
}
}
)
message = self._materialize(_ws("house_only"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertIn("export", inner)
kept_cameras = [j["camera"] for j in inner["export"]["results"]["jobs"]]
self.assertEqual(kept_cameras, ["front_door", "back_door"])
# Sibling fields like ``status`` must survive reshaping.
self.assertEqual(inner["export"]["status"], "running")
def test_job_state_export_entry_dropped_when_no_jobs_allowed(self):
payload = json.dumps(
{
"export": {
"job_type": "export",
"status": "running",
"results": {
"jobs": [
{"job_type": "export", "camera": "garage", "id": "b"},
]
},
}
}
)
self.assertIsNone(self._materialize(_ws("house_only"), "job_state", payload))
# --- Reshape: stats ---
def _stats_payload(self) -> str:
return json.dumps(
{
"cameras": {
"front_door": {"camera_fps": 5.0, "pid": 1234},
"back_door": {"camera_fps": 5.0, "pid": 1235},
"garage": {"camera_fps": 5.0, "pid": 1236},
},
"detectors": {"cpu": {"detection_start": 0.0, "inference_speed": 10}},
"service": {"uptime": 12345, "version": "0.16.0"},
"camera_fps": 15.0,
"detection_fps": 6.0,
}
)
def test_stats_admin_sees_full_payload(self):
message = self._materialize(_ws("admin"), "stats", self._stats_payload())
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], self._stats_payload())
def test_stats_restricted_filters_camera_keys_but_keeps_aggregates(self):
message = self._materialize(_ws("house_only"), "stats", self._stats_payload())
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertEqual(set(inner["cameras"].keys()), {"front_door", "back_door"})
self.assertNotIn("garage", inner["cameras"])
# Aggregates, detectors, and service block must survive.
self.assertEqual(inner["camera_fps"], 15.0)
self.assertEqual(inner["detection_fps"], 6.0)
self.assertIn("detectors", inner)
self.assertIn("service", inner)
def test_stats_restricted_with_no_allowed_cameras_still_sends_aggregates(self):
"""A restricted role whose allow-list contains only nonexistent cameras
still gets the global aggregates and service block."""
config = _build_config(extra_roles={"empty_role": ["nonexistent"]})
from frigate.comms.ws import _parse_json_payload
payload = self._stats_payload()
all_cameras = set(config.cameras.keys())
scope = _classify_outbound("stats", all_cameras, _collect_zone_names(config))
full = json.dumps({"topic": "stats", "payload": payload})
message = _materialize_for_ws(
_ws("empty_role"),
"stats",
full,
scope,
_parse_json_payload(payload),
config,
)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertEqual(inner["cameras"], {})
self.assertEqual(inner["camera_fps"], 15.0)
self.assertIn("service", inner)
def test_stats_without_cameras_key_passes_through(self):
"""A malformed stats payload missing the cameras sub-dict shouldn't
break delivery for restricted users fall back to the full message."""
payload = json.dumps({"detectors": {}, "service": {}, "detection_fps": 0.0})
message = self._materialize(_ws("house_only"), "stats", payload)
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], payload)
def test_job_state_export_entry_unchanged_for_admin(self):
payload = json.dumps(
{
"export": {
"job_type": "export",
"status": "running",
"results": {
"jobs": [
{"job_type": "export", "camera": "garage", "id": "b"},
]
},
}
}
)
message = self._materialize(_ws("admin"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
self.assertEqual(envelope["payload"], payload)
def test_job_state_restricted_keeps_global_jobs(self):
"""media_sync has no camera field; restricted users still see it."""
payload = json.dumps(
{"media_sync": {"job_type": "media_sync", "status": "running"}}
)
message = self._materialize(_ws("house_only"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertIn("media_sync", inner)
def test_job_state_debug_replay_nested_source_camera_filtered(self):
"""debug_replay puts ``source_camera`` inside ``results`` (see
jobs/debug_replay.py:to_dict). Restricted users must not receive
entries whose nested source camera is unauthorized."""
payload = json.dumps(
{
"debug_replay": {
"id": "bd6dc99d-a7d",
"job_type": "debug_replay",
"status": "running",
"start_time": 1.0,
"end_time": None,
"error_message": None,
"results": {
"current_step": "preparing_clip",
"progress_percent": 0.0,
"source_camera": "garage",
"replay_camera_name": "_replay_garage",
"start_ts": 0.0,
"end_ts": 1.0,
},
}
}
)
self.assertIsNone(self._materialize(_ws("house_only"), "job_state", payload))
def test_job_state_debug_replay_nested_source_camera_allowed(self):
payload = json.dumps(
{
"debug_replay": {
"id": "bd6dc99d-a7d",
"job_type": "debug_replay",
"status": "running",
"results": {
"source_camera": "front_door",
"replay_camera_name": "_replay_front_door",
},
}
}
)
message = self._materialize(_ws("house_only"), "job_state", payload)
envelope = json.loads(message) # type: ignore[arg-type]
inner = json.loads(envelope["payload"])
self.assertIn("debug_replay", inner)
self.assertEqual(
inner["debug_replay"]["results"]["source_camera"], "front_door"
)
class _FakeManager:
"""Minimal ws4py manager: holds clients and exposes a lock."""
def __init__(self, clients: list[Any]) -> None:
self.lock = threading.Lock()
self.websockets = {id(c): c for c in clients}
class _FakeServer:
def __init__(self, manager: _FakeManager) -> None:
self.manager = manager
class _CapturingWs(SimpleNamespace):
"""Fake ws4py client that records what was sent."""
def __init__(self, role: str | None) -> None:
environ = {} if role is None else {"HTTP_REMOTE_ROLE": role}
super().__init__(environ=environ, terminated=False)
self.sent: list[str] = []
def send(self, message: str) -> None: # noqa: D401 - matches ws4py API
self.sent.append(message)
class TestPublishEndToEnd(unittest.TestCase):
"""Drive WebSocketClient.publish() against fake clients with different roles."""
def setUp(self):
self.config = _build_config(
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
)
self.admin = _CapturingWs("admin")
self.restricted = _CapturingWs("house_only")
self.anon = _CapturingWs(None)
self.client = WebSocketClient(self.config)
self.client.websocket_server = _FakeServer(
_FakeManager([self.admin, self.restricted, self.anon])
)
def _payloads(self, ws: _CapturingWs) -> list[Any]:
return [json.loads(m)["payload"] for m in ws.sent]
def test_global_topic_reaches_everyone(self):
self.client.publish("model_state", "{}")
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 1)
self.assertEqual(len(self.anon.sent), 1)
def test_camera_topic_filters_restricted_recipient(self):
self.client.publish("garage/detect/state", "ON")
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
self.assertEqual(len(self.anon.sent), 0)
def test_camera_topic_allows_restricted_recipient_for_allowed_camera(self):
self.client.publish("front_door/detect/state", "ON")
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 1)
self.assertEqual(len(self.anon.sent), 0)
def test_events_payload_filtered(self):
self.client.publish("events", json.dumps({"after": {"camera": "garage"}}))
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
def test_camera_activity_reshaped_per_recipient(self):
self.client.publish(
"camera_activity",
json.dumps(
{
"front_door": {"objects": 1},
"back_door": {"objects": 0},
"garage": {"objects": 2},
}
),
)
self.assertEqual(len(self.admin.sent), 1)
admin_inner = json.loads(self._payloads(self.admin)[0])
self.assertEqual(set(admin_inner.keys()), {"front_door", "back_door", "garage"})
self.assertEqual(len(self.restricted.sent), 1)
restricted_inner = json.loads(self._payloads(self.restricted)[0])
self.assertEqual(set(restricted_inner.keys()), {"front_door", "back_door"})
self.assertEqual(len(self.anon.sent), 0)
def test_birdseye_layout_blocked_for_restricted_and_anon(self):
self.client.publish(
"birdseye_layout",
json.dumps({"front_door": {"x": 0, "y": 0, "width": 1, "height": 1}}),
)
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
self.assertEqual(len(self.anon.sent), 0)
def test_zone_aggregate_blocked_for_restricted(self):
self.client.publish("driveway/person", 2)
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
def test_stats_reshaped_per_recipient(self):
self.client.publish(
"stats",
json.dumps(
{
"cameras": {
"front_door": {"camera_fps": 5.0},
"garage": {"camera_fps": 5.0},
},
"service": {"uptime": 1},
"camera_fps": 10.0,
}
),
)
self.assertEqual(len(self.admin.sent), 1)
admin_inner = json.loads(self._payloads(self.admin)[0])
self.assertEqual(set(admin_inner["cameras"].keys()), {"front_door", "garage"})
self.assertEqual(len(self.restricted.sent), 1)
restricted_inner = json.loads(self._payloads(self.restricted)[0])
self.assertEqual(set(restricted_inner["cameras"].keys()), {"front_door"})
self.assertEqual(restricted_inner["camera_fps"], 10.0)
self.assertIn("service", restricted_inner)
# Stats requires a role; anonymous gets nothing.
self.assertEqual(len(self.anon.sent), 0)
def test_export_job_state_filters_results_jobs_per_recipient(self):
self.client.publish(
"job_state",
json.dumps(
{
"export": {
"job_type": "export",
"status": "running",
"results": {
"jobs": [
{"camera": "front_door", "id": "a"},
{"camera": "garage", "id": "b"},
]
},
}
}
),
)
self.assertEqual(len(self.admin.sent), 1)
admin_inner = json.loads(self._payloads(self.admin)[0])
self.assertEqual(
[j["camera"] for j in admin_inner["export"]["results"]["jobs"]],
["front_door", "garage"],
)
self.assertEqual(len(self.restricted.sent), 1)
restricted_inner = json.loads(self._payloads(self.restricted)[0])
self.assertEqual(
[j["camera"] for j in restricted_inner["export"]["results"]["jobs"]],
["front_door"],
)
def test_unknown_topic_dropped_for_everyone(self):
self.client.publish("some_rogue_topic", "data")
self.assertEqual(self.admin.sent, [])
self.assertEqual(self.restricted.sent, [])
self.assertEqual(self.anon.sent, [])
def test_terminated_client_is_skipped(self):
self.restricted.terminated = True
self.client.publish("front_door/detect/state", "ON")
self.assertEqual(len(self.admin.sent), 1)
self.assertEqual(len(self.restricted.sent), 0)
if __name__ == "__main__":
unittest.main()
-3
View File
@@ -357,9 +357,6 @@ class TrackedObjectProcessor(threading.Thread):
def get_current_frame_time(self, camera: str) -> float:
"""Returns the latest frame time for a given camera."""
if camera not in self.camera_states:
return 0.0
return self.camera_states[camera].current_frame_time
def set_sub_label(
+2 -1
View File
@@ -531,7 +531,8 @@ class TrackedObject:
directory = os.path.join(THUMB_DIR, self.camera_config.name)
os.makedirs(directory, exist_ok=True)
if not os.path.exists(directory):
os.makedirs(directory)
thumb_bytes = self.get_thumbnail("webp")
+1 -1
View File
@@ -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", "descriptions", "chat"]
genai["roles"] = ["embeddings", "vision", "tools"]
new_config["genai"] = {"default": genai}
# Remove deprecated sync_recordings from global record config
+23 -81
View File
@@ -393,10 +393,8 @@ def _read_intel_drm_fdinfo(target_pdev: Optional[str]) -> dict:
return snapshot
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.
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, Any]]:
"""Get stats by reading DRM fdinfo files.
Each DRM client FD exposes monotonic per-engine busy counters via
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release).
@@ -404,14 +402,7 @@ def get_intel_gpu_stats(
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%).
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
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
@@ -426,21 +417,19 @@ def get_intel_gpu_stats(
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]] = {}
engine_pct: dict[str, float] = {
"render": 0.0,
"video": 0.0,
"video-enhance": 0.0,
"compute": 0.0,
}
pid_pct: 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:
@@ -463,37 +452,25 @@ def get_intel_gpu_stats(
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
if not per_pdev_engine_pct:
return None
for engine in engine_pct:
engine_pct[engine] = min(100.0, engine_pct[engine])
names = intel_gpu_name_resolver.get_names()
results: dict[str, dict[str, Any]] = {}
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)
for pdev, engine_pct in per_pdev_engine_pct.items():
for engine in engine_pct:
engine_pct[engine] = min(100.0, engine_pct[engine])
results: dict[str, Any] = {
"gpu": f"{round(overall_pct, 2)}%",
"mem": "-%",
"compute": f"{round(compute_pct, 2)}%",
"dec": f"{round(dec_pct, 2)}%",
}
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)}%",
if pid_pct:
results["clients"] = {
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
}
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
@@ -778,41 +755,6 @@ 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)
+15 -95
View File
@@ -150,51 +150,29 @@ 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
@@ -364,64 +342,6 @@ def main():
continue
section_data.pop(key, None)
if field_name == "objects":
# Produce a parallel `filters_attribute` block alongside `filters`,
# with object-wording rewritten for attribute filters (face,
# license_plate, courier logos). The frontend's
# buildTranslationPath routes `filters.<attr>.<field>` lookups to
# `filters_attribute.<field>` when `<attr>` is in
# `model.all_attributes`. Keep this rewrite list explicit rather
# than running a blanket s/object/attribute/ so unrelated
# descriptions (e.g. "JSON object") never accidentally flip.
filters_block = section_data.get("filters")
if isinstance(filters_block, dict):
attribute_rewrites = [
("Object filters", "Attribute filters"),
("detected objects", "detected attributes"),
("object area", "attribute area"),
("object type", "attribute"),
("the object", "the attribute"),
]
# Per-field overrides for cases where the generic rewrite
# doesn't capture the attribute-specific semantics. Keys
# match the FilterConfig field name; values are partial
# overrides applied AFTER the generic rewrites.
attribute_field_overrides: Dict[str, Dict[str, str]] = {
"min_score": {
"description": (
"Minimum single-frame detection confidence required "
"to associate this attribute with its parent object."
),
},
}
def rewrite(text: str) -> str:
for source, replacement in attribute_rewrites:
text = text.replace(source, replacement)
return text
attribute_variant: Dict[str, Any] = {}
for key, value in filters_block.items():
if key in ("label", "description"):
if isinstance(value, str):
attribute_variant[key] = rewrite(value)
continue
if not isinstance(value, dict):
continue
field_trans: Dict[str, str] = {}
if isinstance(value.get("label"), str):
field_trans["label"] = rewrite(value["label"])
if isinstance(value.get("description"), str):
field_trans["description"] = rewrite(value["description"])
overrides = attribute_field_overrides.get(key)
if overrides:
field_trans.update(overrides)
if field_trans:
attribute_variant[key] = field_trans
if attribute_variant:
section_data["filters_attribute"] = attribute_variant
if not section_data:
logger.warning(f"No translations found for section: {field_name}")
continue
+2 -8
View File
@@ -129,14 +129,8 @@ test.describe("Replay — active session @medium", () => {
);
await actionGroup.first().click();
// On mobile PlatformAwareSheet renders a MobilePage (full-screen panel)
// instead of a Radix Dialog, so assert the panel title heading is visible.
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /^Configuration$/i,
}),
).toBeVisible({ timeout: 5_000 });
const dialog = frigateApp.page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
});
test("Objects tab renders with the camera_activity objects list", async ({
@@ -1,55 +0,0 @@
/**
* Detectors and model settings page tests -- HIGH tier.
*
* Tests rendering of the merged page and navigation from the Frigate+ page.
*/
import { test, expect } from "../../fixtures/frigate-test";
test.describe("Detectors and model Settings @high", () => {
test("page renders with detector and model cards", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=systemDetectorsAndModel");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
const text = await frigateApp.page.textContent("#pageRoot");
expect(text).toContain("Detectors and model");
expect(text?.toLowerCase()).toContain("detector hardware");
expect(text?.toLowerCase()).toContain("detection model");
});
test("Frigate+ page links to the merged page", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=frigateplus");
await frigateApp.page.waitForTimeout(2000);
const button = frigateApp.page.getByRole("button", {
name: /Change in Detectors and model/,
});
// Button only appears when Frigate+ is enabled in the test config; skip
// the click assertion if it's not present.
if ((await button.count()) > 0) {
await button.first().click();
await frigateApp.page.waitForURL(/page=systemDetectorsAndModel/);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Detectors and model",
);
} else {
test.skip(
true,
"Frigate+ not enabled in this test config; skipping link assertion",
);
}
});
test("old systemDetectionModel deep-link no longer routes here", async ({
frigateApp,
}) => {
await frigateApp.goto("/settings?page=systemDetectionModel");
await frigateApp.page.waitForTimeout(2000);
// The old page key is no longer in allSettingsViews; the router
// falls back to its default settings page (uiSettings).
const text = await frigateApp.page.textContent("#pageRoot");
expect(text).not.toContain("Detection model");
});
});
@@ -1,235 +0,0 @@
/**
* go2rtc streams settings page tests -- MEDIUM tier.
*
* Regression coverage for the compat-mode (ffmpeg:) URL editor: unknown
* fragments like #timeout=10 must remain visible and editable when the
* stream is using compatibility mode.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
const STREAM_NAME = "dome_sub";
const FFMPEG_URL_WITH_TIMEOUT =
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10";
async function installRawPathsRoute(page: Page, streamUrl: string) {
let lastSavedConfig: unknown = null;
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
cameras: {},
go2rtc: { streams: { [STREAM_NAME]: [streamUrl] } },
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
return {
capturedConfig: () => lastSavedConfig,
};
}
async function expandStream(page: Page, streamName: string) {
// Each StreamCard renders the stream name as an h4 next to a rename
// button, with the chevron toggle as the last button in the header row.
// Scope to the header row (h4's grandparent) and click that last button.
const headerRow = page
.locator(`h4:text-is("${streamName}")`)
.locator("xpath=../..");
await headerRow.getByRole("button").last().click();
}
test.describe("go2rtc streams settings — ffmpeg compat mode @medium", () => {
test("preserves unknown fragments like #timeout= in the URL input", async ({
frigateApp,
}) => {
await installRawPathsRoute(frigateApp.page, FFMPEG_URL_WITH_TIMEOUT);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expect(
frigateApp.page.getByRole("heading", { name: STREAM_NAME }),
).toBeVisible();
await expandStream(frigateApp.page, STREAM_NAME);
const urlInput = frigateApp.page.getByPlaceholder(
"e.g., rtsp://user:pass@192.168.1.100/stream",
);
await expect(urlInput).toBeVisible();
// Focus the input so credential masking is bypassed and the raw value
// is rendered — this matches how a user would inspect the URL before
// editing it.
await urlInput.focus();
await expect(urlInput).toHaveValue(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10",
);
});
test("lets the user add an extra fragment in compat mode", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
FFMPEG_URL_WITH_TIMEOUT,
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const urlInput = frigateApp.page.getByPlaceholder(
"e.g., rtsp://user:pass@192.168.1.100/stream",
);
await urlInput.focus();
await urlInput.fill(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
);
await urlInput.blur();
// Reopen and re-focus to assert the new value round-tripped through
// parseFfmpegBaseAndExtras + buildFfmpegUrl back into the displayed text.
await urlInput.focus();
await expect(urlInput).toHaveValue(
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
);
// Save and verify the persisted URL includes both extras after the
// recognized video/audio directives.
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10#backchannel=0",
],
},
},
},
});
});
test("preserves repeatable #audio= fallback chain and lets the user add another codec", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
// Idiomatic go2rtc fallback: copy if source has the codec, else transcode
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
// Two pre-populated audio rows — one per #audio= fragment.
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
const audioRowsContainer = audioLabel.locator("xpath=../..");
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(2);
await expect(audioRowsContainer.getByRole("combobox").first()).toHaveText(
"Copy",
);
await expect(audioRowsContainer.getByRole("combobox").nth(1)).toHaveText(
"Transcode to Opus",
);
// Add a third audio codec via the LuPlus next to the "Audio" label.
await audioRowsContainer
.getByRole("button", { name: "Add audio codec" })
.click();
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(3);
// Change the newly-added entry to AAC.
await audioRowsContainer.getByRole("combobox").nth(2).click();
await frigateApp.page
.getByRole("option", { name: "Transcode to AAC" })
.click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus#audio=aac",
],
},
},
},
});
});
test("LuX is only shown on fallback rows and removes only that codec", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
const audioRowsContainer = audioLabel.locator("xpath=../..");
const removeButtons = audioRowsContainer.getByRole("button", {
name: "Remove codec",
});
// Primary (audio=copy) row is permanent and has no X; only the audio=opus
// fallback exposes a remove button.
await expect(removeButtons).toHaveCount(1);
await removeButtons.first().click();
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(1);
await expect(audioRowsContainer.getByRole("combobox")).toHaveText("Copy");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
],
},
},
},
});
});
test("picking Exclude on the primary row drops the #video= fragment entirely", async ({
frigateApp,
}) => {
const capture = await installRawPathsRoute(
frigateApp.page,
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
);
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
await expandStream(frigateApp.page, STREAM_NAME);
const videoLabel = frigateApp.page.locator(`label:text-is("Video")`);
const videoRowsContainer = videoLabel.locator("xpath=../..");
await videoRowsContainer.getByRole("combobox").first().click();
await frigateApp.page.getByRole("option", { name: "Exclude" }).click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
go2rtc: {
streams: {
[STREAM_NAME]: [
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#audio=copy",
],
},
},
},
});
});
});
+1 -1
View File
@@ -138,7 +138,7 @@
"plucked_string_instrument": "Instrument de corda pinçada",
"guitar": "Guitarra",
"electric_guitar": "Guitarra elèctrica",
"bass_guitar": "Guitarra baixa",
"bass_guitar": "Baix",
"acoustic_guitar": "Guitarra acústica",
"steel_guitar": "Guitarra steel",
"tapping": "Tapping",
+1 -2
View File
@@ -49,8 +49,7 @@
"gl": "Galego (Gallec)",
"id": "Bahasa Indonesia (Indonesi)",
"ur": "اردو (Urdú)",
"hr": "Hrvatski (croat)",
"bs": "Bosanski (Bosni)"
"hr": "Hrvatski (croat)"
},
"system": "Sistema",
"systemMetrics": "Mètriques del sistema",
+1 -5
View File
@@ -33,11 +33,7 @@
},
"filters": {
"label": "Filtres d'àudio",
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius.",
"threshold": {
"label": "Confiança mínima de l'àudio",
"description": "Llindar mínim de confiança per a l'esdeveniment d'àudio a comptar."
}
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius."
},
"enabled_in_config": {
"label": "Estat d'àudio original",
+2 -41
View File
@@ -258,41 +258,6 @@
},
"raw_mask": {
"label": "Màscara en brut"
},
"filters_attribute": {
"label": "Filtres d'atribut",
"description": "Filtres aplicats als atributs detectats per reduir falsos positius (àrea, relació, confiança).",
"min_area": {
"label": "Àrea mínima de l'atribut",
"description": "Es requereix una àrea de caixa contenidora mínima (píxels o percentatge) per a aquest atribut. Pot ser píxels (int) o percentatge (float entre 0,000001 i 0.99)."
},
"max_area": {
"label": "Àrea màxima de l'atribut",
"description": "Es permet l'àrea màxima del contenidor (píxels o percentatge) per a aquest atribut. Pot ser píxels (int) o percentatge (float entre 0,000001 i 0.99)."
},
"min_ratio": {
"label": "Relació mínima d'aspecte",
"description": "Relació mínima d'amplada/alçada requerida per a la casella contenidora a qualificar."
},
"max_ratio": {
"label": "Relació màxima d'aspecte",
"description": "Es permet la relació màxima d'amplada/alçada per a la casella contenidora a qualificar."
},
"threshold": {
"label": "Llindar de confiança",
"description": "Es requereix un llindar de confiança mitjà per a la detecció perquè l'atribut es consideri un veritable positiu."
},
"min_score": {
"label": "Confiança mínima",
"description": "Es requereix una confiança mínima de detecció d'un sol fotograma per a associar aquest atribut amb el seu objecte pare."
},
"mask": {
"label": "Màscara de filtre",
"description": "Coordenades de polígon que defineixen on s'aplica aquest filtre dins del marc."
},
"raw_mask": {
"label": "Màscara en brut"
}
}
},
"record": {
@@ -2022,11 +1987,7 @@
},
"filters": {
"label": "Filtres d'àudio",
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius.",
"threshold": {
"label": "Confiança mínima de l'àudio",
"description": "Llindar mínim de confiança per a l'esdeveniment d'àudio a comptar."
}
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius."
},
"enabled_in_config": {
"label": "Estat d'àudio original",
@@ -2246,7 +2207,7 @@
},
"match_distance": {
"label": "Distància de la coincidència",
"description": "Nombre de discrepàncies de caràcters permesos en comparar les plaques detectades amb les plaques conegudes."
"description": "Nombre de desajustos de caràcters permesos quan es comparen les plaques detectades amb les plaques conegudes."
},
"known_plates": {
"label": "Matricules conegudes",
+1 -6
View File
@@ -121,10 +121,5 @@
"royal_mail": "Royal Mail",
"school_bus": "Bus escolar",
"skunk": "Mofeta",
"kangaroo": "Cangur",
"baby": "Nadó",
"baby_stroller": "Cotxet",
"rickshaw": "Ricksaw",
"Rodent": "Rosegador",
"rodent": "Rosegador"
"kangaroo": "Cangur"
}
-23
View File
@@ -42,28 +42,5 @@
"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"
},
"new_chat": "Xat nou",
"settings": {
"title": "Configuració del xat",
"show_stats": {
"title": "Mostra les estadístiques",
"desc": "Mostra la velocitat de generació i la mida del context per a les respostes del xat.",
"while_generating": "En generar",
"always": "Sempre"
},
"auto_scroll": {
"title": "Desplaçament automàtic",
"desc": "Segueix els missatges nous a mesura que arriben."
}
},
"stats": {
"context": "{{tokens}} tokens",
"tokens_per_second": "{{rate}} t/s"
},
"reasoning": {
"active": "Raonant…",
"show": "Mostra el raonament",
"hide": "Amaga el raonament"
}
}
+1 -5
View File
@@ -14,11 +14,7 @@
"empty": "No hi ha intents recents de reconeixement de rostres",
"title": "Reconeixements recents",
"aria": "Selecciona els reconeixements recents",
"titleShort": "Recent",
"emptyNoLibrary": {
"title": "Puja una cara",
"description": "Heu d'afegir com a mínim una cara a la biblioteca perquè el reconeixement de la cara funcioni."
}
"titleShort": "Recent"
},
"description": {
"addFace": "Afegiu una col·lecció nova a la biblioteca de cares pujant la vostra primera imatge.",
+15 -158
View File
@@ -15,8 +15,7 @@
"globalConfig": "Configuració global - Frigate",
"cameraConfig": "Configuració de la càmera - Frigate",
"maintenance": "Manteniment - Frigate",
"profiles": "Perfils - Frigate",
"detectorsAndModel": "Detectors i model - Frigate"
"profiles": "Perfils - Frigate"
},
"menu": {
"ui": "Interfície d'usuari",
@@ -91,8 +90,7 @@
"regionGrid": "Quadrícula de la regió",
"uiSettings": "Paràmetres de la IU",
"profiles": "Perfils",
"systemGo2rtcStreams": "go2rtc streams",
"systemDetectorsAndModel": "Detectors i model"
"systemGo2rtcStreams": "go2rtc streams"
},
"dialog": {
"unsavedChanges": {
@@ -528,7 +526,7 @@
},
"title": "Afinador de detecció de moviment",
"toast": {
"success": "S'han desat els paràmetres del moviment."
"success": "Els ajustos de la detecció de moviment s'han desat."
},
"unsavedChanges": "Canvis no desats en l'ajust de moviment {{camera}}"
},
@@ -726,7 +724,7 @@
"trainDate": "Data d'entrenament",
"title": "Informació del model",
"supportedDetectors": "Detectors compatibles",
"availableModels": "Models Frigate+ disponibles",
"availableModels": "Models disponibles",
"cameras": "Càmeres",
"plusModelType": {
"userModel": "Afinat",
@@ -735,15 +733,7 @@
"loadingAvailableModels": "Carregant models disponibles…",
"loading": "Carregant informació del model…",
"error": "No s'ha pogut carregar la informació del model",
"modelSelect": "Els models disponibles a Frigate+ es poden seleccionar aquí. Tingues en compte que només es poden triar els models compatibles amb la configuració actual del detector.",
"noModelLoaded": "Actualment no s'ha carregat cap model Frigate+.",
"selectModel": "Selecciona un model",
"noModelsAvailable": "No hi ha models disponibles",
"filter": {
"ariaLabel": "Filtra els models per tipus",
"baseModels": "Models de base",
"fineTunedModels": "Models ajustats"
}
"modelSelect": "Els models disponibles a Frigate+ es poden seleccionar aquí. Tingues en compte que només es poden triar els models compatibles amb la configuració actual del detector."
},
"apiKey": {
"plusLink": "Llegeix més sobre Frigate+",
@@ -765,8 +755,7 @@
"currentModel": "Model actual",
"otherModels": "Altres models",
"configuration": "Configuració"
},
"changeInDetectorsAndModel": "Canviar model"
}
},
"enrichments": {
"semanticSearch": {
@@ -1306,7 +1295,7 @@
"title": "Habilita / Inhabilita les càmeres",
"desc": "Inhabilita temporalment una càmera fins que es reiniciï la fragata. 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.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
"enableLabel": "Càmeres habilitades",
"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.<br /> <em>Nota: això no inhabilita els restreams go2rtc.</em><br /><br />Drag el handle per reordenar les càmeres tal com apareixen a la interfície d'usuari. L'ordre de les càmeres habilitades es reflectirà en tota la interfície d'usuari, incloent el tauler en viu i els desplegables de selecció de càmeres.",
"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.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
"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.",
@@ -1315,10 +1304,7 @@
"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"
},
"reorderHandle": "Arrossega per reordenar",
"saving": "S'està desant…",
"saved": "Desat"
}
},
"cameraConfig": {
"add": "Afegeix una càmera",
@@ -1376,8 +1362,7 @@
"dedicatedLpr": "LPR dedicat",
"saveSuccess": "Tipus de càmera actualitzat per {{cameraName}}. Reinicia la fragata per aplicar els canvis.",
"normal": "Normal"
},
"description": "Afegiu, editeu i suprimiu les càmeres, controleu quines càmeres estan habilitades, i configureu les superposicions per perfil i tipus de càmera. Per a configurar fluxos, detecció, moviment i altres paràmetres específics de la càmera, trieu la secció específica a Configuració de la càmera."
}
},
"cameraReview": {
"object_descriptions": {
@@ -1676,9 +1661,7 @@
"options": {
"embeddings": "Incrustació",
"vision": "Visió",
"tools": "Eines",
"descriptions": "Descripcions",
"chat": "Xat"
"tools": "Eines"
}
},
"semanticSearchModel": {
@@ -1735,10 +1718,7 @@
"saveAllPartial_many": "{{successCount}} de {{totalCount}} seccions desades. {{failCount}} ha fallat.",
"saveAllPartial_other": "{{successCount}} de {{totalCount}} seccions desades. {{failCount}} ha fallat.",
"saveAllFailure": "Ha fallat en desar totes les seccions.",
"applied": "La configuració s'ha aplicat correctament",
"saveAllSuccessRestartRequired_one": "S'ha desat la secció {{count}} correctament. Reinicia la fragata per aplicar els canvis.",
"saveAllSuccessRestartRequired_many": "Totes les {{count}} seccions s'han desat correctament. Reinicia la fragata per aplicar els canvis.",
"saveAllSuccessRestartRequired_other": "Totes les {{count}} seccions s'han desat correctament. Reinicia la fragata per aplicar els canvis."
"applied": "La configuració s'ha aplicat correctament"
},
"unsavedChanges": "Teniu canvis sense desar",
"confirmReset": "Confirma el restabliment",
@@ -1763,15 +1743,7 @@
"othersField_many": "{{count}} altres",
"othersField_other": "{{count}} altres",
"profilePrefix": "Perfil {{profile}}: {{fields}}"
},
"overriddenGlobalHeading_one": "Aquesta càmera substitueix el camp {{count}} de la configuració global:",
"overriddenGlobalHeading_many": "Aquesta càmera anul·la {{count}} camps de la configuració global:",
"overriddenGlobalHeading_other": "Aquesta càmera anul·la {{count}} camps de la configuració global:",
"overriddenGlobalNoDeltas": "Aquesta càmera anul·la la configuració global, però no hi ha valors de camp diferents.",
"overriddenBaseConfigHeading_one": "El perfil {{profile}} substitueix el camp {{count}} de la configuració base:",
"overriddenBaseConfigHeading_many": "El perfil {{profile}} substitueix {{count}} camps de la configuració base:",
"overriddenBaseConfigHeading_other": "El perfil {{profile}} substitueix {{count}} camps de la configuració base:",
"overriddenBaseConfigNoDeltas": "El perfil {{profile}} substitueix aquesta secció, però no hi ha valors de camp diferents de la configuració base."
}
},
"profiles": {
"title": "Perfils",
@@ -1855,17 +1827,8 @@
"audioMp3": "Transcodifica a MP3",
"audioExclude": "Exclou",
"hardwareNone": "Sense acceleració de hardware",
"hardwareAuto": "Automàtic (recomanat)",
"addVideoCodec": "Afegeix un còdec de vídeo",
"addAudioCodec": "Afegeix un còdec d'àudio",
"removeCodec": "Elimina el còdec",
"hardwareVaapi": "VAAPI",
"hardwareCuda": "CUDA",
"hardwareV4l2m2m": "V4L2 M2M",
"hardwareDxva2": "DXVA2",
"hardwareVideotoolbox": "VideoToolbox"
},
"streamNumber": "Flux {{index}}"
"hardwareAuto": "Acceleració de hardware automàtica"
}
},
"timestampPosition": {
"tl": "A dalt a l'esquerra",
@@ -1875,14 +1838,7 @@
},
"onvif": {
"profileAuto": "Automàtic",
"profileLoading": "S'estan carregant perfils...",
"autotracking": {
"zooming": {
"disabled": "Desactivat",
"absolute": "Absolut",
"relative": "Relatiu"
}
}
"profileLoading": "S'estan carregant perfils..."
},
"configMessages": {
"review": {
@@ -1930,104 +1886,5 @@
"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."
}
},
"modelSize": {
"large": "Gran",
"small": "Petit"
},
"birdseye": {
"trackingMode": {
"objects": "Objectes",
"motion": "Moviment",
"continuous": "Continu"
},
"cameraOrder": {
"label": "Ordre de la càmera",
"description": "Arrossega les càmeres per establir el seu ordre en la disposició Birdseye.",
"reorderHandle": "Arrossega per reordenar",
"saving": "S'està desant…",
"saved": "Desat"
}
},
"snapshot": {
"retainMode": {
"all": "Tots",
"motion": "Moviment",
"active_objects": "Objectes Actius"
}
},
"ui": {
"timeFormat": {
"browser": "Visor",
"12hour": "12 hores",
"24hour": "24 hores"
},
"TimeOrDateStyle": {
"full": "Complet",
"long": "Llarg",
"medium": "Mitjà",
"short": "Curt"
},
"unitSystem": {
"metric": "Métric",
"imperial": "Imperial"
}
},
"review": {
"imageSource": {
"recordings": "Gravacions",
"previews": "Previsualitzacions"
}
},
"logger": {
"logLevel": {
"debug": "Depurar",
"info": "Informació",
"warning": "Avís",
"error": "Error",
"critical": "Crític"
}
},
"retainMode": {
"all": "Tots",
"motion": "Moviment",
"active_objects": "Objectes actius"
},
"previewQuality": {
"very_high": "Molt alta",
"high": "Alta",
"medium": "Mitja",
"low": "Baix",
"very_low": "Molt baix"
},
"detectorsAndModel": {
"restartRequired": "Reinici requerit (canvi en detector o model)",
"title": "Detectors i model",
"description": "Configuri el detector final que corre la detecció d'objectes i el model que usa. Els canvis es gravaràn junts i així el detector i el model estan sincronitzats.",
"cardTitles": {
"detector": "Detector Hardware",
"model": "Model de detecció"
},
"tabs": {
"plus": "Frigate+",
"custom": "Model personalitzat"
},
"mismatch": {
"warning": "El model actual de Frigate+ \"{{model}}\" requereix el detector {{required}}. Selecciona un model compatible a baix o canvía e model personalitzat abans de gravar."
},
"plusModel": {
"requiresDetector": "Requereix: {{detector}}",
"noModelSelected": "Selecciona un model Frigate+"
},
"toast": {
"saveSuccess": "Configuració de detectors i model guardats. Reinicia Frigate per aplicar els canvis.",
"saveError": "Fallo en gravar la configuració de detector i model"
},
"unsavedChanges": "Canvis de detector i model no gravats"
},
"menuDot": {
"overrideGlobal": "Aquesta secció substitueix la configuració global",
"overrideProfile": "Aquesta secció està substituïda pel perfil {{profile}}",
"unsaved": "Aquesta secció té canvis sense desar"
}
}
+1 -2
View File
@@ -192,8 +192,7 @@
"bg": "Български (bulgarisch)",
"gl": "Galego (Galicisch)",
"id": "Bahasa Indonesia (Indonesisch)",
"hr": "Hrvatski (Kroatisch)",
"bs": "Bosnisch"
"hr": "Hrvatski (Kroatisch)"
},
"appearance": "Erscheinung",
"theme": {
+1 -5
View File
@@ -25,11 +25,7 @@
},
"filters": {
"label": "Audiofilter",
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden.",
"threshold": {
"label": "Mindestvertrauensgrad für Audio",
"description": "Mindestschwellenwert für die Zuverlässigkeit, damit das Audioereignis gezählt wird."
}
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden."
},
"max_not_heard": {
"label": "Ende Timeout",
+1 -5
View File
@@ -23,11 +23,7 @@
},
"filters": {
"label": "Audiofilter",
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden.",
"threshold": {
"label": "Mindestvertrauensgrad für Audio",
"description": "Mindestschwellenwert für die Zuverlässigkeit, damit das Audioereignis gezählt wird."
}
"description": "Filtereinstellungen pro Audiotyp, wie z. B. Konfidenzschwellenwerte, die zur Reduzierung von Fehlalarmen verwendet werden."
},
"max_not_heard": {
"label": "Ende Timeout",
+1 -5
View File
@@ -121,9 +121,5 @@
"royal_mail": "Royal-Mail",
"school_bus": "Schulbus",
"skunk": "Stinktier",
"kangaroo": "Känguruh",
"baby": "Baby",
"baby_stroller": "Kinderwagen",
"rickshaw": "Rikscha",
"rodent": "Nagetier"
"kangaroo": "Känguruh"
}
-18
View File
@@ -42,23 +42,5 @@
"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"
},
"new_chat": "Neuer Chat",
"settings": {
"title": "Chat Einstellung",
"show_stats": {
"title": "Statistiken anzeigen",
"desc": "Generierungsrate und Kontextgröße für Chat-Antworten anzeigen.",
"while_generating": "Während der Erstellung",
"always": "Immer"
},
"auto_scroll": {
"title": "Auto scrollen",
"desc": "Verfolgen Sie neue Nachrichten, sobald sie eintreffen."
}
},
"stats": {
"context": "{{tokens}} tokens",
"tokens_per_second": "{{rate}} t/s"
}
}
+1 -5
View File
@@ -48,11 +48,7 @@
"title": "Neueste Erkennungen",
"aria": "Wähle aktuelle Erkennungen",
"empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung",
"titleShort": "frisch",
"emptyNoLibrary": {
"title": "Gesicht hinzufügen",
"description": "Sie müssen mindestens ein Gesicht zur Bibliothek hinzufügen, damit die Gesichtserkennung funktioniert."
}
"titleShort": "frisch"
},
"deleteFaceLibrary": {
"title": "Lösche Name",
+5 -84
View File
@@ -803,15 +803,7 @@
"availableModels": "Verfügbare Modelle",
"loadingAvailableModels": "Lade verfügbare Modelle…",
"baseModel": "Basis Model",
"title": "Model Informationen",
"noModelLoaded": "Derzeit ist kein „Frigate+“-Modell geladen.",
"selectModel": "Wählen Sie ein Modell aus",
"noModelsAvailable": "Keine Modelle verfügbar",
"filter": {
"ariaLabel": "Modelle nach Typ filtern",
"baseModels": "Basismodelle",
"fineTunedModels": "Optimierte Modelle"
}
"title": "Model Informationen"
},
"toast": {
"error": "Speichern der Konfigurationsänderungen fehlgeschlagen: {{errorMessage}}",
@@ -1423,8 +1415,7 @@
"normal": "Normal",
"dedicatedLpr": "Spezielles LPR-System",
"saveSuccess": "Der Kameratyp für {{cameraName}} wurde aktualisiert. Starte Frigate neu, um die Änderungen zu übernehmen."
},
"description": "Fügen Sie Kameras hinzu, bearbeiten und löschen Sie sie, legen Sie fest, welche Kameras aktiviert sind, und konfigurieren Sie profil- und kameratypabhängige Übersteuerungen. Um Streams, Erkennung, Bewegung und andere kameraspezifische Einstellungen zu konfigurieren, wählen Sie den entsprechenden Abschnitt unter „Kamerakonfiguration“ aus."
}
},
"cameraReview": {
"title": "Kamera-Einstellungen überprüfen",
@@ -1498,13 +1489,7 @@
"othersField_one": "{{count}} andere",
"othersField_other": "{{count}} weitere",
"profilePrefix": "{{profile}} Profile: {{fields}}"
},
"overriddenGlobalHeading_one": "Diese Kamera überschreibt das Feld {{count}} aus der globalen Konfiguration:",
"overriddenGlobalHeading_other": "Diese Kamera überschreibt alle Felder {{count}} aus der globalen Konfiguration:",
"overriddenGlobalNoDeltas": "Diese Kamera überschreibt die globale Konfiguration, es gibt jedoch keine Abweichungen bei den Feldwerten.",
"overriddenBaseConfigHeading_one": "Das Profil {{profile}} überschreibt das Feld {{count}} aus der Basiskonfiguration:",
"overriddenBaseConfigHeading_other": "Das Profil {{profile}} überschreibt di Felder {{count}} aus der Basiskonfiguration:",
"overriddenBaseConfigNoDeltas": "Das Profil {{profile}} überschreibt diesen Abschnitt, jedoch weichen keine Feldwerte von der Basiskonfiguration ab."
}
},
"timestampPosition": {
"tl": "Oben links",
@@ -1741,9 +1726,7 @@
"options": {
"embeddings": "Einbetten",
"vision": "Vision",
"tools": "Werkzeuge",
"descriptions": "Beschreibung",
"chat": "Chat"
"tools": "Werkzeuge"
}
},
"semanticSearchModel": {
@@ -1901,14 +1884,7 @@
},
"onvif": {
"profileAuto": "Auto",
"profileLoading": "Profile werden geladen...",
"autotracking": {
"zooming": {
"disabled": "deaktiviert",
"absolute": "Absolut",
"relative": "Verwandter"
}
}
"profileLoading": "Profile werden geladen..."
},
"configMessages": {
"review": {
@@ -1956,60 +1932,5 @@
"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."
}
},
"birdseye": {
"trackingMode": {
"objects": "Objekte",
"motion": "Bewegung",
"continuous": "Fortlaufend"
}
},
"retainMode": {
"all": "Alle",
"motion": "Bewegung",
"active_objects": "Aktive Objekte"
},
"previewQuality": {
"very_high": "sehr hoch",
"high": "hoch",
"medium": "Mittel",
"low": "niedrig",
"very_low": "sehr niedrig"
},
"ui": {
"timeFormat": {
"browser": "Browser",
"12hour": "12 Stunden",
"24hour": "24 Stunden"
},
"TimeOrDateStyle": {
"full": "vollständig",
"long": "lang",
"medium": "mittel",
"short": "kurz"
},
"unitSystem": {
"metric": "Metrik",
"imperial": "Imperial"
}
},
"review": {
"imageSource": {
"recordings": "Aufnahmen",
"previews": "Vorschau"
}
},
"logger": {
"logLevel": {
"debug": "Debug",
"info": "Info",
"warning": "Warnung",
"error": "Fehler",
"critical": "Kritisch"
}
},
"modelSize": {
"small": "klein",
"large": "groß"
}
}
+1 -5
View File
@@ -33,11 +33,7 @@
},
"filters": {
"label": "Audio filters",
"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."
}
"description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives."
},
"enabled_in_config": {
"label": "Original audio state",
+1 -40
View File
@@ -559,11 +559,7 @@
},
"filters": {
"label": "Audio filters",
"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."
}
"description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives."
},
"enabled_in_config": {
"label": "Original audio state",
@@ -921,41 +917,6 @@
"label": "Original GenAI state",
"description": "Indicates whether GenAI was enabled in the original static config."
}
},
"filters_attribute": {
"label": "Attribute filters",
"description": "Filters applied to detected attributes to reduce false positives (area, ratio, confidence).",
"min_area": {
"label": "Minimum attribute area",
"description": "Minimum bounding box area (pixels or percentage) required for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
},
"max_area": {
"label": "Maximum attribute area",
"description": "Maximum bounding box area (pixels or percentage) allowed for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
},
"min_ratio": {
"label": "Minimum aspect ratio",
"description": "Minimum width/height ratio required for the bounding box to qualify."
},
"max_ratio": {
"label": "Maximum aspect ratio",
"description": "Maximum width/height ratio allowed for the bounding box to qualify."
},
"threshold": {
"label": "Confidence threshold",
"description": "Average detection confidence threshold required for the attribute to be considered a true positive."
},
"min_score": {
"label": "Minimum confidence",
"description": "Minimum single-frame detection confidence required to associate this attribute with its parent object."
},
"mask": {
"label": "Filter mask",
"description": "Polygon coordinates defining where this filter applies within the frame."
},
"raw_mask": {
"label": "Raw Mask"
}
}
},
"record": {
+2 -2
View File
@@ -125,5 +125,5 @@
"baby": "Baby",
"baby_stroller": "Baby Stroller",
"rickshaw": "Rickshaw",
"rodent": "Rodent"
}
"Rodent": "Rodent"
}
-23
View File
@@ -42,28 +42,5 @@
"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"
},
"reasoning": {
"active": "Reasoning…",
"show": "Show reasoning",
"hide": "Hide reasoning"
}
}
+1 -1
View File
@@ -222,7 +222,7 @@
"label": "Hide object path"
},
"debugReplay": {
"label": "Debug Replay",
"label": "Debug replay",
"aria": "View this tracked object in the debug replay view"
},
"more": {
+1 -5
View File
@@ -32,11 +32,7 @@
"title": "Recent Recognitions",
"titleShort": "Recent",
"aria": "Select recent recognitions",
"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."
}
"empty": "There are no recent face recognition attempts"
},
"deleteFaceLibrary": {
"title": "Delete Name",
+21 -91
View File
@@ -12,7 +12,6 @@
"globalConfig": "Global Configuration - Frigate",
"cameraConfig": "Camera Configuration - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate",
"detectorsAndModel": "Detectors and model - Frigate",
"notifications": "Notification Settings - Frigate",
"maintenance": "Maintenance - Frigate",
"profiles": "Profiles - Frigate"
@@ -20,14 +19,8 @@
"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",
"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",
@@ -40,11 +33,6 @@
"profilePrefix": "{{profile}} profile: {{fields}}"
}
},
"menuDot": {
"overrideGlobal": "This section overrides the global configuration",
"overrideProfile": "This section is overridden by the {{profile}} profile",
"unsaved": "This section has unsaved changes"
},
"menu": {
"general": "General",
"globalConfig": "Global configuration",
@@ -75,7 +63,8 @@
"systemTelemetry": "Telemetry",
"systemBirdseye": "Birdseye",
"systemFfmpeg": "FFmpeg",
"systemDetectorsAndModel": "Detectors and model",
"systemDetectorHardware": "Detector hardware",
"systemDetectionModel": "Detection model",
"systemMqtt": "MQTT",
"systemGo2rtcStreams": "go2rtc streams",
"integrationSemanticSearch": "Semantic search",
@@ -457,7 +446,6 @@
},
"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": {
@@ -477,13 +465,10 @@
"streams": {
"title": "Enable / Disable Cameras",
"enableLabel": "Enabled cameras",
"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.<br /> <em>Note: This does not disable go2rtc restreams.</em><br /><br />Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.",
"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.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
"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.",
"reorderHandle": "Drag to reorder",
"saving": "Saving…",
"saved": "Saved",
"friendlyName": {
"edit": "Edit camera display name",
"title": "Edit Display Name",
@@ -1142,19 +1127,10 @@
"cameras": "Cameras",
"loading": "Loading model information…",
"error": "Failed to load model information",
"noModelLoaded": "No Frigate+ model is currently loaded.",
"availableModels": "Available Frigate+ models",
"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."
},
"changeInDetectorsAndModel": "Change model",
"unsavedChanges": "Unsaved Frigate+ settings changes",
"restart_required": "Restart required (Frigate+ model changed)",
"toast": {
@@ -1162,30 +1138,14 @@
"error": "Failed to save config changes: {{errorMessage}}"
}
},
"detectorsAndModel": {
"title": "Detectors and model",
"description": "Configure the detector backend that runs object detection and the model it uses. Changes are saved together so the detector and model stay in sync.",
"cardTitles": {
"detector": "Detector Hardware",
"model": "Detection Model"
},
"tabs": {
"plus": "Frigate+",
"custom": "Custom Model"
},
"mismatch": {
"warning": "The current Frigate+ model \"{{model}}\" requires the {{required}} detector. Pick a compatible model below or switch to Custom Model before saving."
},
"plusModel": {
"requiresDetector": "Requires: {{detector}}",
"noModelSelected": "Select a Frigate+ model"
},
"toast": {
"saveSuccess": "Detectors and model settings saved. Restart Frigate to apply changes.",
"saveError": "Failed to save detector and model settings"
},
"unsavedChanges": "Unsaved detector and model changes",
"restartRequired": "Restart required (detector or model changed)"
"detectionModel": {
"plusActive": {
"title": "Frigate+ model management",
"label": "Current model source",
"description": "This instance is running a Frigate+ model. Select or change your model in Frigate+ settings.",
"goToFrigatePlus": "Go to Frigate+ settings",
"showModelForm": "Manually configure a model"
}
},
"triggers": {
"documentTitle": "Triggers",
@@ -1591,8 +1551,6 @@
"resetError": "Failed to reset settings",
"saveAllSuccess_one": "Saved {{count}} section successfully.",
"saveAllSuccess_other": "All {{count}} sections saved successfully.",
"saveAllSuccessRestartRequired_one": "Saved {{count}} section successfully. Restart Frigate to apply your changes.",
"saveAllSuccessRestartRequired_other": "All {{count}} sections saved successfully. Restart Frigate to apply your changes.",
"saveAllPartial_one": "{{successCount}} of {{totalCount}} section saved. {{failCount}} failed.",
"saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.",
"saveAllFailure": "Failed to save all sections."
@@ -1649,7 +1607,6 @@
"addStream": "Add stream",
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
"addUrl": "Add URL",
"streamNumber": "Stream {{index}}",
"streamName": "Stream name",
"streamNamePlaceholder": "e.g., front_door",
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
@@ -1683,15 +1640,7 @@
"audioMp3": "Transcode to MP3",
"audioExclude": "Exclude",
"hardwareNone": "No hardware acceleration",
"hardwareAuto": "Automatic (recommended)",
"hardwareVaapi": "VAAPI",
"hardwareCuda": "CUDA",
"hardwareV4l2m2m": "V4L2 M2M",
"hardwareDxva2": "DXVA2",
"hardwareVideotoolbox": "VideoToolbox",
"addVideoCodec": "Add video codec",
"addAudioCodec": "Add audio codec",
"removeCodec": "Remove codec"
"hardwareAuto": "Automatic hardware acceleration"
}
},
"birdseye": {
@@ -1699,26 +1648,14 @@
"objects": "Objects",
"motion": "Motion",
"continuous": "Continuous"
},
"cameraOrder": {
"label": "Camera order",
"description": "Drag cameras to set their order in the Birdseye layout.",
"reorderHandle": "Drag to reorder",
"saving": "Saving…",
"saved": "Saved"
}
},
"retainMode": {
"all": "All",
"motion": "Motion",
"active_objects": "Active Objects"
},
"previewQuality": {
"very_high": "Very High",
"high": "High",
"medium": "Medium",
"low": "Low",
"very_low": "Very Low"
"snapshot": {
"retainMode": {
"all": "All",
"motion": "Motion",
"active_objects": "Active Objects"
}
},
"ui": {
"timeFormat": {
@@ -1754,14 +1691,7 @@
},
"onvif": {
"profileAuto": "Auto",
"profileLoading": "Loading profiles...",
"autotracking": {
"zooming": {
"disabled": "Disabled",
"absolute": "Absolute",
"relative": "Relative"
}
}
"profileLoading": "Loading profiles..."
},
"modelSize": {
"small": "Small",
@@ -1814,4 +1744,4 @@
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
}
}
}
}
+5 -24
View File
@@ -154,8 +154,7 @@
"gl": "Galego (Gallego)",
"id": "Bahasa Indonesia (Indonesio)",
"ur": "اردو (Urdu)",
"hr": "Hrvatski (Croata)",
"bs": "Bosanski (Bosnio)"
"hr": "Hrvatski (Croata)"
},
"appearance": "Apariencia",
"darkMode": {
@@ -197,10 +196,7 @@
"uiPlayground": "Zona de pruebas de la interfaz de usuario",
"faceLibrary": "Biblioteca de rostros",
"classification": "Clasificación",
"profiles": "Perfiles",
"actions": "Acciones",
"features": "Funciones",
"chat": "Chat"
"profiles": "Perfiles"
},
"unit": {
"speed": {
@@ -256,19 +252,7 @@
"saving": "Guardando…",
"exitFullscreen": "Salir de pantalla completa",
"on": "ENCENDIDO",
"continue": "Continuar",
"add": "Añadir",
"applying": "Aplicando…",
"undo": "Deshacer",
"copiedToClipboard": "Copiado al portapapeles",
"modified": "Modificado",
"overridden": "Sobrescrito",
"resetToGlobal": "Restablecer a global",
"resetToDefault": "Restablecer valores predeterminados",
"saveAll": "Guardar todo",
"savingAll": "Guardando todo…",
"undoAll": "Deshacer todo",
"retry": "Reintentar"
"continue": "Continuar"
},
"toast": {
"save": {
@@ -276,8 +260,7 @@
"noMessage": "No se pudieron guardar los cambios de configuración",
"title": "No se pudieron guardar los cambios de configuración: {{errorMessage}}"
},
"title": "Guardar",
"success": "Cambios de configuración guardados correctamente."
"title": "Guardar"
},
"copyUrlToClipboard": "URL copiada al portapapeles."
},
@@ -331,7 +314,5 @@
"field": {
"optional": "Opcional",
"internalID": "La ID interna que usa Frigate en la configuración y en la base de datos"
},
"no_items": "No hay elementos",
"validation_errors": "Errores de validación"
}
}
+4 -70
View File
@@ -71,77 +71,16 @@
"endTimeMustAfterStartTime": "La hora de finalización debe ser posterior a la hora de inicio"
},
"success": "Exportación iniciada con éxito. Ver el archivo en la página exportaciones.",
"view": "Ver",
"queued": "Exportación en cola. Consulta el progreso en la página de exportaciones.",
"batchSuccess_one": "Se inició 1 exportación. Abriendo el caso ahora.",
"batchSuccess_many": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
"batchSuccess_other": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
"batchPartial": "Se iniciaron {{successful}} de {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}",
"batchFailed": "No se pudieron iniciar {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}",
"batchQueuedSuccess_one": "1 exportación en cola. Abriendo el caso ahora.",
"batchQueuedSuccess_many": "{{count}} exportaciones en cola. Abriendo el caso ahora.",
"batchQueuedSuccess_other": "{{count}} exportaciones en cola. Abriendo el caso ahora.",
"batchQueuedPartial": "{{successful}} de {{total}} exportaciones en cola. Cámaras fallidas: {{failedCameras}}",
"batchQueueFailed": "No se pudieron poner en cola {{total}} exportaciones. Cámaras fallidas: {{failedCameras}}"
"view": "Ver"
},
"fromTimeline": {
"saveExport": "Guardar exportación",
"previewExport": "Vista previa de la exportación",
"queueingExport": "Poniendo exportación en cola...",
"useThisRange": "Usar este intervalo"
"previewExport": "Vista previa de la exportación"
},
"selectOrExport": "Seleccionar o exportar",
"case": {
"label": "Caso",
"newCaseDescriptionPlaceholder": "Descripción de caso",
"newCaseOption": "Crear nuevo caso",
"newCaseNamePlaceholder": "Nombre del nuevo caso",
"nonAdminHelp": "Se creará un nuevo caso para estas exportaciones.",
"placeholder": "Selecciona un caso"
},
"queueing": "Poniendo la exportación en cola…",
"tabs": {
"export": "Cámara única",
"multiCamera": "Multicámara"
},
"multiCamera": {
"timeRange": "Intervalo de tiempo",
"selectFromTimeline": "Seleccionar desde la línea de tiempo",
"cameraSelection": "Cámaras",
"cameraSelectionHelp": "Las cámaras con objetos detectados en este intervalo de tiempo están preseleccionadas",
"checkingActivity": "Comprobando actividad de las cámaras...",
"noCameras": "No hay cámaras disponibles",
"detectionCount_one": "1 objeto detectado",
"detectionCount_many": "{{count}} objetos detectados",
"detectionCount_other": "{{count}} objetos detectados",
"nameLabel": "Nombre de la exportación",
"namePlaceholder": "Nombre base opcional para estas exportaciones",
"queueingButton": "Poniendo exportaciones en cola...",
"exportButton_one": "Exportar 1 cámara",
"exportButton_many": "Exportar {{count}} cámaras",
"exportButton_other": "Exportar {{count}} cámaras"
},
"multi": {
"title_one": "Exportar 1 revisión",
"title_many": "Exportar {{count}} revisiones",
"title_other": "Exportar {{count}} revisiones",
"description": "Exportar cada revisión seleccionada. Todas las exportaciones se agruparán en un único caso.",
"descriptionNoCase": "Exportar cada revisión seleccionada.",
"caseNamePlaceholder": "Exportación de revisión - {{date}}",
"exportButton_one": "Exportar 1 revisión",
"exportButton_many": "Exportar {{count}} revisiones",
"exportButton_other": "Exportar {{count}} revisiones",
"exportingButton": "Exportando...",
"toast": {
"started_one": "Se inició 1 exportación. Abriendo el caso ahora.",
"started_many": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
"started_other": "Se iniciaron {{count}} exportaciones. Abriendo el caso ahora.",
"startedNoCase_one": "Se inició 1 exportación.",
"startedNoCase_many": "Se iniciaron {{count}} exportaciones.",
"startedNoCase_other": "Se iniciaron {{count}} exportaciones.",
"partial": "Se iniciaron {{successful}} de {{total}} exportaciones. Fallidas: {{failedItems}}",
"failed": "No se pudieron iniciar {{total}} exportaciones. Fallidas: {{failedItems}}"
}
"newCaseDescriptionPlaceholder": "Descripción de caso"
}
},
"streaming": {
@@ -191,12 +130,7 @@
"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.",
"label": "Compartir marca de tiempo",
"title": "Compartir marca de tiempo",
"custom": "Marca de tiempo personalizada",
"button": "Compartir URL de la marca de tiempo",
"shareTitle": "Marca de tiempo de revisión de Frigate: {{camera}}"
"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": {
+55 -719
View File
@@ -8,7 +8,7 @@
"description": "Habilitado"
},
"audio": {
"label": "Detección de audio",
"label": "Eventos de audio",
"description": "Configuración para la detección de eventos basada en audio para esta cámara.",
"enabled": {
"label": "Habilitar la detección de audio",
@@ -28,19 +28,14 @@
},
"filters": {
"label": "Filtros de audio",
"description": "Ajustes de filtrado por tipo de audio, como umbrales de confianza utilizados para reducir los falsos positivos.",
"threshold": {
"label": "Confianza mínima de audio",
"description": "Umbral mínimo de confianza para que se cuente el evento de audio."
}
"description": "Ajustes de filtrado por tipo de audio, como umbrales de confianza utilizados para reducir los falsos positivos."
},
"enabled_in_config": {
"description": "Indica si la detección de audio estaba habilitada originalmente en el archivo de configuración estática.",
"label": "Estado original del audio"
},
"num_threads": {
"label": "Hilos de detección",
"description": "Número de hilos que se utilizarán para el procesamiento de la detección de audio."
"label": "Hilos de detección"
}
},
"friendly_name": {
@@ -55,79 +50,29 @@
},
"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.",
"label": "Factor de zoom"
"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.",
"label": "Calibrar al iniciar"
"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).",
"label": "Modo de zoom"
"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.",
"label": "Preajuste de retorno"
"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.",
"label": "Tiempo de espera de retorno"
},
"label": "Seguimiento automático",
"enabled": {
"label": "Habilitar seguimiento automático",
"description": "Habilita o deshabilita el seguimiento automático con cámara PTZ de objetos detectados."
},
"track": {
"label": "Objetos rastreados",
"description": "Lista de tipos de objetos que deben activar el seguimiento automático."
},
"required_zones": {
"label": "Zonas requeridas",
"description": "Los objetos deben entrar en una de estas zonas antes de que comience el seguimiento automático."
},
"movement_weights": {
"label": "Pesos de movimiento",
"description": "Valores de calibración generados automáticamente por la calibración de la cámara. No los modifiques manualmente."
},
"enabled_in_config": {
"label": "Estado original de autoseguimiento",
"description": "Campo interno para rastrear si el seguimiento automático estaba habilitado en la configuración."
"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).",
"label": "Deshabilitar verificación TLS"
},
"label": "ONVIF",
"description": "Ajustes de conexión ONVIF y seguimiento automático PTZ para esta cámara.",
"host": {
"label": "Host ONVIF",
"description": "Host (y esquema opcional) para el servicio ONVIF de esta cámara."
},
"port": {
"label": "Puerto ONVIF",
"description": "Número de puerto del servicio ONVIF."
},
"user": {
"label": "Nombre de usuario ONVIF",
"description": "Nombre de usuario para la autenticación ONVIF; algunos dispositivos requieren un usuario administrador para ONVIF."
},
"password": {
"label": "Contraseña ONVIF",
"description": "Contraseña para la autenticación ONVIF."
},
"ignore_time_mismatch": {
"label": "Ignorar discrepancia horaria",
"description": "Ignora las diferencias de sincronización horaria entre la cámara y el servidor Frigate para la comunicación ONVIF."
"description": "Omitir la verificación TLS y deshabilitar la autenticación digest para ONVIF (no seguro; usar solo en redes seguras)."
}
},
"zones": {
"distances": {
"label": "Distancias reales",
"description": "Distancias reales opcionales para cada lado del cuadrilátero de la zona, usadas para cálculos de velocidad o distancia. Debe tener exactamente 4 valores si se establece."
"label": "Distancias reales"
},
"coordinates": {
"description": "Coordenadas del polígono que definen el área de la zona. Puede ser una cadena separada por comas o una lista de cadenas de coordenadas. Las coordenadas deben ser relativas (0-1) o absolutas (heredadas).",
@@ -161,41 +106,23 @@
"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.",
"label": "Filtros de zona",
"min_area": {
"label": "Área mínima de objeto",
"description": "Área mínima del cuadro delimitador (píxeles o porcentaje) necesaria para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
}
"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.",
"label": "Objetos activadores"
"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.",
"label": "Velocidad mínima"
"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.",
"label": "Nombre de zona"
"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.",
"label": "Fotogramas de inercia"
"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.",
"label": "Segundos de permanencia"
},
"label": "Zonas",
"enabled": {
"label": "Habilitado",
"description": "Habilita o deshabilita esta zona. Las zonas deshabilitadas se ignoran en tiempo de ejecución."
},
"enabled_in_config": {
"label": "Mantiene el registro del estado original de la zona."
"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": {
@@ -215,739 +142,148 @@
},
"send_triggers": {
"after_significant_updates": {
"description": "Envía una solicitud a GenAI tras un número especificado de actualizaciones significativas del objeto rastreado.",
"label": "Activador temprano de GenAI"
"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.).",
"label": "Activadores de GenAI",
"tracked_object_end": {
"label": "Enviar al finalizar",
"description": "Envía una solicitud a GenAI cuando finaliza el 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.",
"label": "Zonas requeridas"
},
"prompt": {
"label": "Prompt de descripción",
"description": "Plantilla de prompt predeterminada usada al generar descripciones con GenAI."
},
"object_prompts": {
"label": "Prompts de objetos",
"description": "Prompts por objeto para personalizar las salidas de GenAI para etiquetas concretas."
},
"objects": {
"label": "Objetos de GenAI",
"description": "Lista de etiquetas de objetos que se enviarán a GenAI de forma predeterminada."
},
"debug_save_thumbnails": {
"label": "Guardar miniaturas",
"description": "Guarda las miniaturas enviadas a GenAI para depuración y revisión."
},
"enabled_in_config": {
"label": "Estado original de GenAI",
"description": "Indica si GenAI estaba habilitado en la configuración estática original."
"description": "Zonas en las que deben ubicarse los objetos para ser elegibles para la generación de descripciones con GenAI."
}
},
"label": "Objetos",
"description": "Valores predeterminados de seguimiento de objetos, incluidas las etiquetas que se rastrean y los filtros por objeto.",
"track": {
"label": "Objetos a rastrear",
"description": "Lista de etiquetas de objetos a rastrear para esta cámara."
},
"filters": {
"label": "Filtros de objetos",
"description": "Filtros aplicados a los objetos detectados para reducir falsos positivos (área, relación, confianza).",
"min_area": {
"label": "Área mínima de objeto",
"description": "Área mínima del cuadro delimitador (píxeles o porcentaje) necesaria para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
},
"max_area": {
"label": "Área máxima de objeto",
"description": "Área máxima del cuadro delimitador (píxeles o porcentaje) permitida para este tipo de objeto. Puede ser píxeles (int) o porcentaje (float entre 0.000001 y 0.99)."
},
"min_ratio": {
"label": "Relación de aspecto mínima",
"description": "Relación mínima anchura/altura necesaria para que el cuadro delimitador sea válido."
},
"max_ratio": {
"label": "Relación de aspecto máxima",
"description": "Relación máxima anchura/altura permitida para que el cuadro delimitador sea válido."
},
"threshold": {
"label": "Umbral de confianza",
"description": "Umbral medio de confianza de detección necesario para que el objeto se considere un positivo verdadero."
},
"min_score": {
"label": "Confianza mínima",
"description": "Confianza mínima de detección en un único fotograma necesaria para que el objeto se contabilice."
},
"mask": {
"label": "Máscara de filtro",
"description": "Coordenadas del polígono que definen dónde se aplica este filtro dentro del fotograma."
},
"raw_mask": {
"label": "Máscara sin procesar"
}
},
"mask": {
"label": "Máscara de objeto",
"description": "Polígono de máscara usado para evitar la detección de objetos en áreas especificadas."
}
},
"mqtt": {
"label": "MQTT",
"required_zones": {
"description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT.",
"label": "Zonas requeridas"
},
"description": "Ajustes de publicación de imágenes MQTT.",
"enabled": {
"label": "Enviar imagen",
"description": "Habilita la publicación de instantáneas de objetos en temas MQTT para esta cámara."
},
"timestamp": {
"label": "Añadir marca de tiempo",
"description": "Superpone una marca de tiempo en las imágenes publicadas en MQTT."
},
"bounding_box": {
"label": "Añadir cuadro delimitador",
"description": "Dibuja cuadros delimitadores en las imágenes publicadas mediante MQTT."
},
"crop": {
"label": "Recortar imagen",
"description": "Recorta las imágenes publicadas en MQTT al cuadro delimitador del objeto detectado."
},
"height": {
"label": "Altura de imagen",
"description": "Altura (píxeles) a la que redimensionar las imágenes publicadas mediante MQTT."
},
"quality": {
"label": "Calidad JPEG",
"description": "Calidad JPEG de las imágenes publicadas en MQTT (0-100)."
"description": "Zonas en las que debe entrar un objeto para que se publique una imagen MQTT."
}
},
"notifications": {
"email": {
"label": "Email de notificacion",
"description": "Dirección de correo electrónico usada para notificaciones push o requerida por ciertos proveedores de notificaciones."
},
"label": "Notificaciones",
"description": "Ajustes para habilitar y controlar las notificaciones de esta cámara.",
"enabled": {
"label": "Habilitar notificaciones",
"description": "Habilita o deshabilita las notificaciones para esta cámara."
},
"cooldown": {
"label": "Periodo de enfriamiento",
"description": "Periodo de enfriamiento (segundos) entre notificaciones para evitar saturar a los destinatarios."
},
"enabled_in_config": {
"label": "Estado original de notificaciones",
"description": "Indica si las notificaciones estaban habilitadas en la configuración estática original."
"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",
"description": "Activar o desactivar la transcripción de eventos de audio activados manualmente."
},
"label": "Transcripción de audio",
"enabled_in_config": {
"label": "Estado original de la transcripción"
},
"live_enabled": {
"label": "Transcripción en directo",
"description": "Activar la transcripción en directo del audio a medida que se recibe."
"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.",
"label": "Omitir umbral de movimiento"
"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.",
"label": "Umbral de iluminación"
"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).",
"label": "Umbral de movimiento"
},
"label": "Detección de movimiento",
"description": "Ajustes predeterminados de detección de movimiento para esta cámara.",
"enabled": {
"label": "Habilitar detección de movimiento",
"description": "Habilita o deshabilita la detección de movimiento para esta cámara."
},
"improve_contrast": {
"label": "Mejorar contraste",
"description": "Aplica una mejora de contraste a los fotogramas antes del análisis de movimiento para ayudar a la detección."
},
"contour_area": {
"label": "Área de contorno",
"description": "Área mínima de contorno en píxeles necesaria para que se cuente un contorno de movimiento."
},
"delta_alpha": {
"label": "Delta alfa",
"description": "Factor de mezcla alfa usado en la diferencia entre fotogramas para calcular el movimiento."
},
"frame_alpha": {
"label": "Alfa del fotograma",
"description": "Valor alfa usado al mezclar fotogramas para el preprocesamiento de movimiento."
},
"frame_height": {
"label": "Altura del fotograma",
"description": "Altura en píxeles a la que escalar los fotogramas al calcular el movimiento."
},
"mask": {
"label": "Coordenadas de máscara",
"description": "Coordenadas x,y ordenadas que definen el polígono de máscara de movimiento usado para incluir/excluir áreas."
},
"mqtt_off_delay": {
"label": "Retraso de apagado MQTT",
"description": "Segundos a esperar tras el último movimiento antes de publicar un estado MQTT 'off'."
},
"enabled_in_config": {
"label": "Estado de movimiento original",
"description": "Indica si la detección de movimiento estaba habilitada en la configuración estática original."
},
"raw_mask": {
"label": "Máscara sin procesar"
"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.",
"label": "Nivel de mejora"
"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).",
"label": "Segundos hasta caducar"
},
"label": "Reconocimiento de matrículas",
"description": "Ajustes de reconocimiento de matrículas, incluidos umbrales de detección, formato y matrículas conocidas.",
"enabled": {
"label": "Habilitar LPR",
"description": "Habilita o deshabilita LPR en esta cámara."
},
"min_area": {
"label": "Área mínima de matrícula",
"description": "Área mínima de matrícula (píxeles) necesaria para intentar el reconocimiento."
"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).",
"label": "FPS de detección"
"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.",
"label": "Fotogramas mínimos de inicialización"
"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.",
"label": "Altura de detección"
"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.",
"label": "Anchura de detección"
"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.",
"label": "Configuración de objetos estacionarios",
"interval": {
"label": "Intervalo estacionario",
"description": "Frecuencia (en fotogramas) con la que se ejecuta una comprobación de detección para confirmar un objeto estacionario."
},
"threshold": {
"label": "Umbral estacionario",
"description": "Número de fotogramas sin cambio de posición necesarios para marcar un objeto como estacionario."
},
"max_frames": {
"label": "Fotogramas máximos",
"description": "Limita durante cuánto tiempo se rastrean los objetos estacionarios antes de descartarlos.",
"default": {
"label": "Fotogramas máximos predeterminados",
"description": "Número máximo predeterminado de fotogramas para rastrear un objeto estacionario antes de detenerse."
},
"objects": {
"label": "Fotogramas máximos por objeto",
"description": "Sobrescrituras por objeto para el número máximo de fotogramas en los que rastrear objetos estacionarios."
}
},
"classifier": {
"label": "Habilitar clasificador visual",
"description": "Usa un clasificador visual para detectar objetos realmente estacionarios incluso cuando los cuadros delimitadores oscilan."
}
},
"label": "Detección de objetos",
"description": "Ajustes del rol de detección/detect usado para ejecutar la detección de objetos e inicializar los rastreadores.",
"enabled": {
"label": "Habilitar detección de objetos",
"description": "Habilita o deshabilita la detección de objetos para esta cámara."
},
"max_disappeared": {
"label": "Fotogramas máximos desaparecido",
"description": "Número de fotogramas sin detección antes de que un objeto rastreado se considere desaparecido."
},
"annotation_offset": {
"label": "Desplazamiento de anotaciones",
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
"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.",
"label": "Retención de movimiento",
"days": {
"label": "Días de retención",
"description": "Días durante los que conservar las grabaciones."
}
"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.",
"label": "Retención continua",
"days": {
"label": "Días de retención",
"description": "Días durante los que conservar las grabaciones."
}
"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.",
"label": "Segundos de captura previa"
"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.",
"label": "Segundos de captura posterior"
},
"label": "Retención de detección",
"description": "Ajustes de retención de grabaciones para eventos de detección, incluidas las duraciones de captura previa/posterior.",
"retain": {
"label": "Retención de eventos",
"description": "Ajustes de retención para grabaciones de eventos de detección.",
"days": {
"label": "Días de retención",
"description": "Número de días durante los que conservar grabaciones de eventos de detección."
},
"mode": {
"label": "Modo de retención",
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
}
"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.",
"label": "Segundos de captura previa"
"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.",
"label": "Segundos de captura posterior"
},
"label": "Retención de alertas",
"description": "Ajustes de retención de grabaciones para eventos de alerta, incluidas las duraciones de captura previa/posterior.",
"retain": {
"label": "Retención de eventos",
"description": "Ajustes de retención para grabaciones de eventos de detección.",
"days": {
"label": "Días de retención",
"description": "Número de días durante los que conservar grabaciones de eventos de detección."
},
"mode": {
"label": "Modo de retención",
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
}
"description": "Número de segundos después del evento de detección que se incluirán en la grabación."
}
},
"label": "Grabación",
"description": "Ajustes de grabación y retención para esta cámara.",
"enabled": {
"label": "Habilitar grabación",
"description": "Habilita o deshabilita la grabación para esta cámara."
},
"expire_interval": {
"label": "Intervalo de limpieza de grabaciones",
"description": "Minutos entre pasadas de limpieza que eliminan segmentos de grabación caducados."
},
"export": {
"label": "Configuración de exportación",
"description": "Ajustes usados al exportar grabaciones, como timelapse y aceleración por hardware.",
"hwaccel_args": {
"label": "Argumentos hwaccel de exportación",
"description": "Argumentos de aceleración por hardware que se usarán en operaciones de exportación/transcodificación."
},
"max_concurrent": {
"label": "Exportaciones simultáneas máximas",
"description": "Número máximo de trabajos de exportación que se procesarán al mismo tiempo."
}
},
"preview": {
"label": "Configuración de vista previa",
"description": "Ajustes que controlan la calidad de las vistas previas de grabaciones mostradas en la interfaz.",
"quality": {
"label": "Calidad de vista previa",
"description": "Nivel de calidad de vista previa (very_low, low, medium, high, very_high)."
}
},
"enabled_in_config": {
"label": "Estado de grabación original",
"description": "Indica si la grabación estaba habilitada en la configuración estática original."
}
},
"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.",
"label": "Mostrar en la interfaz"
},
"label": "Interfaz de cámara",
"description": "Orden de visualización y visibilidad de esta cámara en la interfaz. El orden afecta al panel predeterminado. Para un control más granular, usa grupos de cámaras.",
"order": {
"label": "Orden en la interfaz",
"description": "Orden numérico usado para ordenar la cámara en la interfaz (panel predeterminado y listas); los números más altos aparecen más tarde."
"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.",
"label": "Altura en directo"
"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.",
"label": "Reproducción en directo",
"streams": {
"label": "Nombres de flujos en directo",
"description": "Asignación de nombres de flujos configurados a nombres de restream/go2rtc usados para la reproducción en directo."
},
"quality": {
"label": "Calidad en directo",
"description": "Calidad de codificación para el flujo jsmpeg (1 la más alta, 31 la más baja)."
}
"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.",
"label": "Zonas requeridas"
"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).",
"label": "Etiquetas de alerta"
},
"label": "Configuración de alertas",
"description": "Ajustes sobre qué objetos rastreados generan alertas y cómo se conservan las alertas.",
"enabled": {
"label": "Habilitar alertas",
"description": "Habilita o deshabilita la generación de alertas para esta cámara."
},
"enabled_in_config": {
"label": "Estado original de alertas",
"description": "Rastrea si las alertas estaban habilitadas originalmente en la configuración estática."
},
"cutoff_time": {
"label": "Tiempo de corte de alertas",
"description": "Segundos que se esperarán tras dejar de haber actividad causante de alerta antes de cortar una alerta."
"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.",
"label": "Zonas requeridas"
"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.",
"label": "Configuración de detecciones",
"enabled": {
"label": "Habilitar detecciones",
"description": "Habilita o deshabilita los eventos de detección para esta cámara."
},
"labels": {
"label": "Etiquetas de detección",
"description": "Lista de etiquetas de objetos que cuentan como eventos de detección."
},
"cutoff_time": {
"label": "Tiempo de corte de detecciones",
"description": "Segundos que se esperarán tras dejar de haber actividad causante de detección antes de cortar una detección."
},
"enabled_in_config": {
"label": "Estado original de detecciones",
"description": "Rastrea si las detecciones estaban habilitadas originalmente en la configuración estática."
}
"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.",
"label": "Origen de imagen de revisión"
"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.",
"label": "Consideraciones adicionales"
"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.",
"label": "Prompt de contexto de actividad"
"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.",
"label": "Guardar miniaturas"
},
"label": "Configuración de GenAI",
"enabled": {
"label": "Habilitar descripciones de GenAI",
"description": "Habilita o deshabilita las descripciones y resúmenes generados por GenAI para los elementos de revisión."
},
"alerts": {
"label": "Habilitar GenAI para alertas",
"description": "Usa GenAI para generar descripciones de elementos de alerta."
},
"detections": {
"label": "Habilitar GenAI para detecciones",
"description": "Usa GenAI para generar descripciones de elementos de detección."
},
"enabled_in_config": {
"label": "Estado original de GenAI",
"description": "Rastrea si la revisión de GenAI estaba habilitada originalmente en la configuración estática."
},
"preferred_language": {
"label": "Idioma preferido",
"description": "Idioma preferido que se solicitará al proveedor de GenAI para las respuestas generadas."
"description": "Guarde las miniaturas que se envían al proveedor de GenAI para su depuración y revisión."
}
},
"label": "Revisión"
}
},
"birdseye": {
"description": "Configuración para la vista compuesta Birdseye, que combina las transmisiones de múltiples cámaras en una sola vista.",
"label": "Vista general",
"enabled": {
"label": "Habilitar Birdseye",
"description": "Habilita o deshabilita la función de vista Birdseye."
},
"mode": {
"label": "Modo de seguimiento",
"description": "Modo para incluir cámaras en Birdseye: 'objects', 'motion' o 'continuous'."
},
"order": {
"label": "Posición",
"description": "Posición numérica que controla el orden de la cámara en el diseño de 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.",
"label": "Tiempo de reintento de FFmpeg"
"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\").",
"label": "Ruta de FFmpeg"
"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.",
"label": "Argumentos de salida",
"detect": {
"label": "Argumentos de salida de detección",
"description": "Argumentos de salida predeterminados para los flujos con rol de detección."
},
"record": {
"label": "Argumentos de salida de grabación",
"description": "Argumentos de salida predeterminados para los flujos con rol de grabación."
}
"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.",
"label": "FFmpeg",
"global_args": {
"label": "Argumentos globales de FFmpeg",
"description": "Argumentos globales pasados a los procesos de FFmpeg."
},
"hwaccel_args": {
"label": "Argumentos de aceleración por hardware",
"description": "Argumentos de aceleración por hardware para FFmpeg. Se recomiendan preajustes específicos del proveedor."
},
"input_args": {
"label": "Argumentos de entrada",
"description": "Argumentos de entrada aplicados a los flujos de entrada de FFmpeg."
},
"apple_compatibility": {
"label": "Compatibilidad con Apple",
"description": "Habilita el etiquetado HEVC para mejorar la compatibilidad con reproductores de Apple al grabar H.265."
},
"gpu": {
"label": "Índice de GPU",
"description": "Índice de GPU predeterminado usado para la aceleración por hardware si está disponible."
},
"inputs": {
"label": "Entradas de cámara",
"description": "Lista de definiciones de flujos de entrada (rutas y roles) para esta cámara.",
"path": {
"label": "Ruta de entrada",
"description": "URL o ruta del flujo de entrada de la cámara."
},
"roles": {
"label": "Roles de entrada",
"description": "Roles para este flujo de entrada."
},
"global_args": {
"label": "Argumentos globales de FFmpeg",
"description": "Argumentos globales de FFmpeg para este flujo de entrada."
},
"hwaccel_args": {
"label": "Argumentos de aceleración por hardware",
"description": "Argumentos de aceleración por hardware para este flujo de entrada."
},
"input_args": {
"label": "Argumentos de entrada",
"description": "Argumentos de entrada específicos para este flujo."
}
}
},
"face_recognition": {
"label": "Reconocimiento facial",
"description": "Ajustes de detección y reconocimiento facial para esta cámara.",
"enabled": {
"label": "Habilitar reconocimiento facial",
"description": "Habilita o deshabilita el reconocimiento facial."
},
"min_area": {
"label": "Área mínima de rostro",
"description": "Área mínima (píxeles) del cuadro de un rostro detectado necesaria para intentar el reconocimiento."
}
},
"semantic_search": {
"label": "Búsqueda semántica",
"description": "Ajustes de búsqueda semántica, que crea y consulta embeddings de objetos para encontrar elementos similares.",
"triggers": {
"label": "Activadores",
"description": "Acciones y criterios de coincidencia para activadores de búsqueda semántica específicos de la cámara.",
"friendly_name": {
"label": "Nombre descriptivo",
"description": "Nombre descriptivo opcional mostrado en la interfaz para este activador."
},
"enabled": {
"label": "Habilitar este activador",
"description": "Habilita o deshabilita este activador de búsqueda semántica."
},
"type": {
"label": "Tipo de activador",
"description": "Tipo de activador: 'thumbnail' (coincidir con imagen) o 'description' (coincidir con texto)."
},
"data": {
"label": "Contenido del activador",
"description": "Frase de texto o ID de miniatura que se comparará con objetos rastreados."
},
"threshold": {
"label": "Umbral del activador",
"description": "Puntuación mínima de similitud (0-1) necesaria para activar este activador."
},
"actions": {
"label": "Acciones del activador",
"description": "Lista de acciones que se ejecutarán cuando el activador coincida (notification, sub_label, attribute)."
}
}
},
"snapshots": {
"label": "Instantáneas",
"description": "Ajustes de instantáneas generadas por la API de objetos rastreados para esta cámara.",
"enabled": {
"label": "Habilitar instantáneas",
"description": "Habilita o deshabilita el guardado de instantáneas para esta cámara."
},
"timestamp": {
"label": "Superposición de marca de tiempo",
"description": "Superpone una marca de tiempo en las instantáneas de la API."
},
"bounding_box": {
"label": "Superposición de cuadro delimitador",
"description": "Dibuja cuadros delimitadores para los objetos rastreados en las instantáneas de la API."
},
"crop": {
"label": "Recortar instantánea",
"description": "Recorta las instantáneas de la API al cuadro delimitador del objeto detectado."
},
"required_zones": {
"label": "Zonas requeridas",
"description": "Zonas en las que debe entrar un objeto para que se guarde una instantánea."
},
"height": {
"label": "Altura de instantánea",
"description": "Altura (píxeles) a la que redimensionar las instantáneas de la API; déjalo vacío para conservar el tamaño original."
},
"retain": {
"label": "Retención de instantáneas",
"description": "Ajustes de retención de instantáneas, incluidos días predeterminados y sobrescrituras por objeto.",
"default": {
"label": "Retención predeterminada",
"description": "Número predeterminado de días durante los que conservar instantáneas."
},
"mode": {
"label": "Modo de retención",
"description": "Modo de retención: all (guarda todos los segmentos), motion (guarda segmentos con movimiento) o active_objects (guarda segmentos con objetos activos)."
},
"objects": {
"label": "Retención por objeto",
"description": "Sobrescrituras por objeto para los días de retención de instantáneas."
}
},
"quality": {
"label": "Calidad de instantánea",
"description": "Calidad de codificación de las instantáneas guardadas (0-100)."
}
},
"timestamp_style": {
"label": "Estilo de marca de tiempo",
"description": "Opciones de estilo para marcas de tiempo integradas aplicadas a grabaciones e instantáneas.",
"position": {
"label": "Posición de marca de tiempo",
"description": "Posición de la marca de tiempo en la imagen (tl/tr/bl/br)."
},
"format": {
"label": "Formato de marca de tiempo",
"description": "Cadena de formato de fecha y hora usada para las marcas de tiempo (códigos de formato datetime de Python)."
},
"color": {
"label": "Color de marca de tiempo",
"description": "Valores de color RGB para el texto de la marca de tiempo (todos los valores 0-255).",
"red": {
"label": "Rojo",
"description": "Componente rojo (0-255) para el color de la marca de tiempo."
},
"green": {
"label": "Verde",
"description": "Componente verde (0-255) para el color de la marca de tiempo."
},
"blue": {
"label": "Azul",
"description": "Componente azul (0-255) para el color de la marca de tiempo."
}
},
"thickness": {
"label": "Grosor de marca de tiempo",
"description": "Grosor de línea del texto de la marca de tiempo."
},
"effect": {
"label": "Efecto de marca de tiempo",
"description": "Efecto visual para el texto de la marca de tiempo (none, solid, shadow)."
}
},
"best_image_timeout": {
"label": "Tiempo de espera de mejor imagen",
"description": "Tiempo que se esperará la imagen con la puntuación de confianza más alta."
},
"type": {
"label": "Tipo de cámara",
"description": "Tipo de cámara"
},
"webui_url": {
"label": "URL de la cámara",
"description": "URL para visitar la cámara directamente desde la página del sistema"
},
"profiles": {
"label": "Perfiles",
"description": "Perfiles de configuración con nombre y sobrescrituras parciales que pueden activarse en tiempo de ejecución."
},
"enabled_in_config": {
"label": "Estado original de cámara",
"description": "Mantiene el registro del estado original de la cámara."
"description": "Configuración de FFmpeg, incluyendo la ruta del binario, argumentos, opciones de aceleración por hardware y argumentos de salida por rol."
}
}
File diff suppressed because it is too large Load Diff
+1 -11
View File
@@ -116,15 +116,5 @@
"animal": "Animal",
"postnord": "PostNord",
"usps": "USPS",
"gls": "GLS",
"canada_post": "Canada Post",
"royal_mail": "Royal Mail",
"school_bus": "Autobús escolar",
"skunk": "Mofeta",
"kangaroo": "Canguro",
"baby": "Bebé",
"baby_stroller": "Cochecito de bebé",
"rickshaw": "Rickshaw",
"Rodent": "Roedor",
"rodent": "Roedor"
"gls": "GLS"
}
+1 -69
View File
@@ -1,69 +1 @@
{
"documentTitle": "Chat - Frigate",
"title": "Frigate Chat",
"subtitle": "Tu asistente de IA para la gestión de cámaras y análisis",
"placeholder": "Pregunta cualquier cosa...",
"error": "Algo salió mal. Por favor, inténtalo de nuevo.",
"processing": "Procesando...",
"toolsUsed": "Usado: {{tools}}",
"showTools": "Mostrar herramientas ({{count}})",
"hideTools": "Ocultar herramientas",
"call": "Llamar",
"result": "Resultado",
"arguments": "Argumentos:",
"response": "Respuesta:",
"attachment_chip_label": "{{label}} en {{camera}}",
"attachment_chip_remove": "Eliminar adjunto",
"open_in_explore": "Abrir en Explorar",
"attach_event_aria": "Adjuntar evento {{eventId}}",
"attachment_picker_paste_label": "O pega el ID del evento",
"attachment_picker_attach": "Adjuntar",
"attachment_picker_placeholder": "Adjuntar un evento",
"quick_reply_find_similar": "Buscar avistamientos similares",
"quick_reply_tell_me_more": "Cuéntame más sobre esto",
"quick_reply_when_else": "¿Cuándo más se vio?",
"quick_reply_find_similar_text": "Buscar avistamientos similares a este.",
"quick_reply_tell_me_more_text": "Cuéntame más sobre este.",
"quick_reply_when_else_text": "¿Cuándo más se vio esto?",
"anchor": "Referencia",
"similarity_score": "Similitud",
"no_similar_objects_found": "No se encontraron objetos similares.",
"semantic_search_required": "La búsqueda semántica debe estar activada para encontrar objetos similares.",
"send": "Enviar",
"suggested_requests": "Prueba preguntando:",
"starting_requests": {
"show_recent_events": "Mostrar eventos recientes",
"show_camera_status": "Mostrar estado de la cámara",
"recap": "¿Qué ha pasado mientras estaba fuera?",
"watch_camera": "Vigilar una cámara en busca de actividad"
},
"starting_requests_prompts": {
"show_recent_events": "Muéstrame los eventos recientes de la última hora",
"show_camera_status": "¿Cuál es el estado actual de mis cámaras?",
"recap": "¿Qué ha pasado mientras estaba fuera?",
"watch_camera": "Vigila la puerta principal y avísame si aparece alguien"
},
"new_chat": "Nuevo chat",
"settings": {
"title": "Ajustes del chat",
"show_stats": {
"title": "Mostrar estadísticas",
"desc": "Mostrar la velocidad de generación y el tamaño del contexto en las respuestas del chat.",
"while_generating": "Durante la generación",
"always": "Siempre"
},
"auto_scroll": {
"title": "Desplazamiento automático",
"desc": "Seguir los mensajes nuevos a medida que llegan."
}
},
"stats": {
"context": "{{tokens}} tokens",
"tokens_per_second": "{{rate}} t/s"
},
"reasoning": {
"active": "Razonando…",
"show": "Mostrar razonamiento",
"hide": "Ocultar razonamiento"
}
}
{}
@@ -146,7 +146,7 @@
"generateSuccess": "Imágenes de ejemplo generadas correctamente",
"missingStatesWarning": {
"title": "Faltan Ejemplos de Estado",
"description": "No todas las clases tienen ejemplos. Prueba a generar nuevos ejemplos para encontrar la clase que falta, o continúa y usa la vista de Clasificaciones recientes para añadir imágenes más tarde."
"description": "Se recomienda seleccionar ejemplos para todos los estados para obtener mejores resultados. Puede continuar sin seleccionar todos los estados, pero el modelo no se entrenará hasta que todos los estados tengan imágenes. Después de continuar, use la vista \"Clasificaciones recientes\" para clasificar las imágenes de los estados faltantes y luego entrene el modelo."
},
"allImagesRequired_one": "Por favor clasifique todas las imágenes. Queda {{count}} imagen.",
"allImagesRequired_many": "Por favor clasifique todas las imágenes. Quedan {{count}} imágenes.",
+2 -27
View File
@@ -32,9 +32,7 @@
},
"camera": "Cámara",
"recordings": {
"documentTitle": "Grabaciones - Frigate",
"invalidSharedLink": "No se puede abrir el enlace de la grabación con marca de tiempo debido a un error de análisis.",
"invalidSharedCamera": "No se puede abrir el enlace de la grabación con marca de tiempo debido a una cámara desconocida o no autorizada."
"documentTitle": "Grabaciones - Frigate"
},
"calendarFilter": {
"last24Hours": "Últimas 24 horas"
@@ -68,28 +66,5 @@
"select_all": "Todas",
"normalActivity": "Normal",
"needsReview": "Necesita revisión",
"securityConcern": "Aviso de seguridad",
"motionSearch": {
"menuItem": "Búsqueda de movimiento",
"openMenu": "Opciones de cámara"
},
"motionPreviews": {
"menuItem": "Ver vistas previas de movimiento",
"title": "Vistas previas de movimiento: {{camera}}",
"mobileSettingsTitle": "Ajustes de vistas previas de movimiento",
"mobileSettingsDesc": "Ajusta la velocidad de reproducción y el atenuado, y elige una fecha para revisar clips solo de movimiento.",
"dim": "Atenuar",
"dimAria": "Ajustar intensidad de atenuado",
"dimDesc": "Aumenta el atenuado para mejorar la visibilidad de las áreas con movimiento.",
"speed": "Velocidad",
"speedAria": "Seleccionar velocidad de reproducción de las vistas previas",
"speedDesc": "Elige la velocidad a la que se reproducen los clips de vista previa.",
"back": "Atrás",
"empty": "No hay vistas previas disponibles",
"noPreview": "Vista previa no disponible",
"seekAria": "Mover el reproductor de {{camera}} a {{time}}",
"filter": "Filtrar",
"filterDesc": "Selecciona áreas para mostrar solo clips con movimiento en esas regiones.",
"filterClear": "Limpiar"
}
"securityConcern": "Aviso de seguridad"
}
+1 -8
View File
@@ -226,10 +226,6 @@
},
"more": {
"aria": "Más"
},
"debugReplay": {
"label": "Reproducción de depuración",
"aria": "Ver este objeto rastreado en la reproducción de depuración"
}
},
"dialog": {
@@ -286,10 +282,7 @@
"zones": "Zonas",
"area": "Área",
"score": "Puntuación",
"ratio": "Ratio(proporción)",
"computedScore": "Puntuación calculada",
"topScore": "Puntuación más alta",
"toggleAdvancedScores": "Alternar puntuaciones avanzadas"
"ratio": "Ratio(proporción)"
},
"entered_zone": "{{label}} ha entrado en {{zones}}"
},
+3 -84
View File
@@ -13,9 +13,7 @@
"toast": {
"error": {
"renameExportFailed": "No se pudo renombrar la exportación: {{errorMessage}}",
"assignCaseFailed": "Fallo en la actualización de la asignación de caso: {{errorMessage}}",
"caseSaveFailed": "No se pudo guardar el caso: {{errorMessage}}",
"caseDeleteFailed": "No se pudo eliminar el caso: {{errorMessage}}"
"assignCaseFailed": "Fallo en la actualización de la asignación de caso: {{errorMessage}}"
}
},
"deleteExport.desc": "¿Estás seguro de que quieres eliminar {{exportName}}?",
@@ -40,89 +38,10 @@
"descriptionLabel": "Descripción"
},
"toolbar": {
"addExport": "Añadir Exportación",
"newCase": "Nuevo caso",
"editCase": "Editar caso",
"deleteCase": "Eliminar caso"
"addExport": "Añadir Exportación"
},
"deleteCase": {
"label": "Eliminar caso",
"desc": "¿Estás seguro de que quieres eliminar {{caseName}}?",
"descKeepExports": "Las exportaciones seguirán disponibles como exportaciones sin categoría.",
"descDeleteExports": "Todas las exportaciones de este caso se eliminarán de forma permanente.",
"deleteExports": "Eliminar también las exportaciones"
},
"caseCard": {
"emptyCase": "Aún no hay exportaciones"
},
"jobCard": {
"defaultName": "Exportación de {{camera}}",
"queued": "En cola",
"running": "En ejecución",
"preparing": "Preparando",
"copying": "Copiando",
"encoding": "Codificando",
"encodingRetry": "Codificando (reintento)",
"finalizing": "Finalizando"
},
"caseView": {
"noDescription": "Sin descripción",
"createdAt": "Creado {{value}}",
"exportCount_one": "1 exportación",
"exportCount_other": "{{count}} exportaciones",
"cameraCount_one": "1 cámara",
"cameraCount_other": "{{count}} cámaras",
"showMore": "Mostrar más",
"showLess": "Mostrar menos",
"emptyTitle": "Este caso está vacío",
"emptyDescription": "Añade exportaciones existentes sin categorizar para mantener el caso organizado.",
"emptyDescriptionNoExports": "Todavía no hay exportaciones sin categorizar disponibles para añadir."
},
"caseEditor": {
"createTitle": "Crear caso",
"editTitle": "Editar caso",
"namePlaceholder": "Nombre del caso",
"descriptionPlaceholder": "Añade notas o contexto para este caso"
},
"addExportDialog": {
"title": "Añadir exportación a {{caseName}}",
"searchPlaceholder": "Buscar exportaciones sin categorizar",
"empty": "Ninguna exportación sin categorizar coincide con esta búsqueda.",
"addButton_one": "Añadir 1 exportación",
"addButton_other": "Añadir {{count}} exportaciones",
"adding": "Añadiendo..."
},
"selected_one": "{{count}} seleccionados",
"selected_other": "{{count}} seleccionados",
"bulkActions": {
"addToCase": "Añadir al caso",
"moveToCase": "Mover al caso",
"removeFromCase": "Eliminar del caso",
"delete": "Eliminar",
"deleteNow": "Eliminar ahora"
},
"bulkDelete": {
"title": "Eliminar exportaciones",
"desc_one": "¿Seguro que quieres eliminar {{count}} exportación?",
"desc_other": "¿Seguro que quieres eliminar {{count}} exportaciones?"
},
"bulkRemoveFromCase": {
"title": "Eliminar del caso",
"desc_one": "¿Eliminar {{count}} exportación de este caso?",
"desc_other": "¿Eliminar {{count}} exportaciones de este caso?",
"descKeepExports": "Las exportaciones se moverán a sin categorizar.",
"descDeleteExports": "Las exportaciones se eliminarán permanentemente.",
"deleteExports": "Eliminar exportaciones en su lugar"
},
"bulkToast": {
"success": {
"delete": "Exportaciones eliminadas correctamente",
"reassign": "Asignación de caso actualizada correctamente",
"remove": "Exportaciones eliminadas del caso correctamente"
},
"error": {
"deleteFailed": "No se pudieron eliminar las exportaciones: {{errorMessage}}",
"reassignFailed": "No se pudo actualizar la asignación del caso: {{errorMessage}}"
}
"desc": "¿Estás seguro de que quieres eliminar {{caseName}}?"
}
}
+1 -5
View File
@@ -30,11 +30,7 @@
"title": "Reconocimientos Recientes",
"aria": "Seleccionar reconocimientos recientes",
"empty": "No hay intentos recientes de reconocimiento facial",
"titleShort": "Reciente",
"emptyNoLibrary": {
"title": "Subir una cara",
"description": "Debes añadir al menos una cara a la biblioteca para que el reconocimiento facial funcione."
}
"titleShort": "Reciente"
},
"selectItem": "Seleccionar {{item}}",
"selectFace": "Seleccionar rostro",
+1 -2
View File
@@ -69,8 +69,7 @@
},
"recording": {
"enable": "Habilitar grabación",
"disable": "Deshabilitar grabación",
"disabledInConfig": "La grabación debe activarse primero en Ajustes para esta cámara."
"disable": "Deshabilitar grabación"
},
"snapshots": {
"enable": "Habilitar capturas de pantalla",
+1 -77
View File
@@ -1,77 +1 @@
{
"documentTitle": "Búsqueda por movimiento - Frigate",
"title": "Búsqueda por movimiento",
"description": "Dibuja un polígono para definir la región de interés y especifica un intervalo de tiempo para buscar cambios de movimiento dentro de esa región.",
"selectCamera": "Búsqueda por movimiento se está cargando",
"startSearch": "Iniciar búsqueda",
"searchStarted": "Búsqueda iniciada",
"searchCancelled": "Búsqueda cancelada",
"cancelSearch": "Cancelar",
"searching": "Búsqueda en progreso.",
"searchComplete": "Búsqueda completada",
"noResultsYet": "Ejecuta una búsqueda para encontrar cambios de movimiento en la región seleccionada",
"noChangesFound": "No se detectaron cambios de píxeles en la región seleccionada",
"changesFound_one": "Encontrado {{count}} cambio de movimiento",
"changesFound_many": "Encontrados {{count}} cambios de movimiento",
"changesFound_other": "Encontrados {{count}} cambios de movimiento",
"framesProcessed": "{{count}} fotogramas procesados",
"jumpToTime": "Saltar a este tiempo",
"results": "Resultados",
"showSegmentHeatmap": "Mapa de calor",
"newSearch": "Nueva búsqueda",
"clearResults": "Borrar resultados",
"clearROI": "Borrar polígono",
"polygonControls": {
"points_one": "{{count}} punto",
"points_many": "{{count}} puntos",
"points_other": "{{count}} puntos",
"undo": "Deshacer el último punto",
"reset": "Restablecer polígono"
},
"motionHeatmapLabel": "Mapa de calor de movimiento",
"dialog": {
"title": "Búsqueda de movimiento",
"cameraLabel": "Cámara",
"previewAlt": "Vista previa de la cámara {{camera}}"
},
"timeRange": {
"title": "Rango de búsqueda",
"start": "Hora de inicio",
"end": "Hora de finalización"
},
"settings": {
"title": "Ajustes de búsqueda",
"parallelMode": "Modo paralelo",
"parallelModeDesc": "Analiza varios segmentos de grabación al mismo tiempo (más rápido, pero consume significativamente más CPU)",
"threshold": "Umbral de sensibilidad",
"thresholdDesc": "Los valores más bajos detectan cambios más pequeños (1-255)",
"minArea": "Área mínima de cambio",
"minAreaDesc": "Porcentaje mínimo de la región de interés que debe cambiar para considerarse significativo",
"frameSkip": "Salto de fotogramas",
"frameSkipDesc": "Procesa cada N fotogramas. Establécelo según la tasa de FPS de tu cámara para procesar un fotograma por segundo (p. ej., 5 para una cámara de 5 FPS, 30 para una cámara de 30 FPS). Los valores más altos serán más rápidos, pero pueden omitir eventos de movimiento breves.",
"maxResults": "Resultados máximos",
"maxResultsDesc": "Detener después de esta cantidad de marcas de tiempo coincidentes"
},
"errors": {
"noCamera": "Selecciona una cámara",
"noROI": "Dibuja una región de interés",
"noTimeRange": "Selecciona un rango de tiempo",
"invalidTimeRange": "La hora de fin debe ser posterior a la hora de inicio",
"searchFailed": "La búsqueda falló: {{message}}",
"polygonTooSmall": "El polígono debe tener al menos 3 puntos",
"unknown": "Error desconocido"
},
"changePercentage": "{{percentage}}% cambiado",
"metrics": {
"title": "Métricas de búsqueda",
"segmentsScanned": "Segmentos analizados",
"segmentsProcessed": "Procesado",
"segmentsSkippedInactive": "Omitido (sin actividad)",
"segmentsSkippedHeatmap": "Omitido (sin superposición de ROI)",
"fallbackFullRange": "Análisis completo de respaldo",
"framesDecoded": "Fotogramas decodificados",
"wallTime": "Tiempo de búsqueda",
"segmentErrors": "Errores de segmento",
"seconds": "{{seconds}} s"
}
}
{}
+1 -59
View File
@@ -1,59 +1 @@
{
"title": "Depuración de reproducción",
"description": "Reproducir grabaciones de cámara para depuración. La lista de objetos muestra un resumen con retraso temporal de los objetos detectados y la pestaña Mensajes muestra un flujo de los mensajes internos de Frigate de la grabación reproducida.",
"websocket_messages": "Mensajes",
"dialog": {
"title": "Iniciar depuración de reproducción",
"description": "Crea una cámara de reproducción temporal que reproduzca en bucle imágenes históricas para depurar problemas de detección y seguimiento de objetos. La cámara de reproducción tendrá la misma configuración de detección que la cámara de origen. Elige un intervalo de tiempo para comenzar.",
"camera": "Cámara de origen",
"timeRange": "Intervalo de tiempo",
"preset": {
"1m": "Último 1 minuto",
"5m": "Últimos 5 minutos",
"timeline": "Desde la línea de tiempo",
"custom": "Personalizado"
},
"startButton": "Iniciar reproducción",
"selectFromTimeline": "Seleccionar",
"starting": "Iniciando reproducción...",
"startLabel": "Iniciar",
"endLabel": "Fin",
"toast": {
"error": "No se pudo iniciar la reproducción de depuración: {{error}}",
"alreadyActive": "Ya hay una sesión de reproducción activa",
"stopError": "No se pudo detener la reproducción de depuración: {{error}}",
"goToReplay": "Ir a la reproducción"
}
},
"page": {
"noSession": "No hay ninguna sesión activa de reproducción de depuración",
"noSessionDesc": "Inicia una reproducción de depuración desde la vista Historial haciendo clic en el botón Acciones de la barra de herramientas y seleccionando Reproducción de depuración.",
"goToRecordings": "Ir al historial",
"preparingClip": "Preparando clip…",
"preparingClipDesc": "Frigate está uniendo las grabaciones del intervalo de tiempo seleccionado. Esto puede tardar un minuto en intervalos más largos.",
"startingCamera": "Iniciando reproducción de depuración…",
"startError": {
"title": "No se pudo iniciar la reproducción de depuración",
"back": "Volver al historial"
},
"sourceCamera": "Cámara de origen",
"replayCamera": "Cámara de reproducción",
"initializingReplay": "Inicializando reproducción de depuración…",
"stoppingReplay": "Deteniendo repetición de depuración...",
"stopReplay": "Detener repetición",
"confirmStop": {
"title": "¿Detener repetición de depuración?",
"description": "Esto detendrá la sesión y eliminará todos los datos temporales. ¿Estás seguro?",
"confirm": "Detener repetición",
"cancel": "Cancelar"
},
"activity": "Actividad",
"objects": "Lista de objetos",
"audioDetections": "Detecciones de audio",
"noActivity": "No se detectó actividad",
"activeTracking": "Seguimiento activo",
"noActiveTracking": "No hay seguimiento activo",
"configuration": "Configuración",
"configurationDesc": "Ajusta con precisión la detección de movimiento y los ajustes de seguimiento de objetos para la cámara de repetición de depuración. No se guardará ningún cambio en el archivo de configuración de Frigate."
}
}
{}
+34 -634
View File
@@ -16,8 +16,7 @@
"globalConfig": "Configuración Global - Frigate",
"cameraConfig": "Configuración de Cámara - Frigate",
"maintenance": "Mantenimiento - Frigate",
"profiles": "Perfiles - Frigate",
"detectorsAndModel": "Detectores y modelo - Frigate"
"profiles": "Perfiles - Frigate"
},
"menu": {
"cameras": "Configuración de Cámara",
@@ -43,7 +42,7 @@
"globalDetect": "Detección de Objetos",
"globalRecording": "Grabación",
"globalSnapshots": "Instantáneas",
"globalFfmpeg": "arguments,Introduce",
"globalFfmpeg": "FFmpeg",
"globalMotion": "Detección de Movimiento",
"globalObjects": "Objetos",
"globalReview": "Revisión",
@@ -51,49 +50,7 @@
"globalLivePlayback": "Reproducción en Vivo",
"globalTimestampStyle": "Estilo de Marca de Tiempo",
"systemDatabase": "Base de Datos",
"systemAuthentication": "Autenticación",
"systemTls": "TLS",
"systemNetworking": "Red",
"systemProxy": "Proxy",
"systemUi": "Interfaz",
"systemLogging": "Registro",
"systemEnvironmentVariables": "Variables de entorno",
"systemTelemetry": "Telemetría",
"systemBirdseye": "Birdseye",
"systemFfmpeg": "FFmpeg",
"systemDetectorHardware": "Hardware del detector",
"systemDetectionModel": "Modelo de detección",
"systemMqtt": "MQTT",
"systemGo2rtcStreams": "Flujos go2rtc",
"integrationSemanticSearch": "Búsqueda semántica",
"integrationGenerativeAi": "IA generativa",
"integrationFaceRecognition": "Reconocimiento facial",
"integrationLpr": "Reconocimiento de matrículas",
"integrationObjectClassification": "Clasificación de objetos",
"integrationAudioTranscription": "Transcripción de audio",
"cameraDetect": "Detección de objetos",
"cameraFfmpeg": "FFmpeg",
"cameraRecording": "Grabación",
"cameraSnapshots": "Instantáneas",
"cameraMotion": "Detección de movimiento",
"cameraObjects": "Objetos",
"cameraConfigReview": "Revisión",
"cameraAudioEvents": "Detección de audio",
"cameraAudioTranscription": "Transcripción de audio",
"cameraNotifications": "Notificaciones",
"cameraLivePlayback": "Reproducción en directo",
"cameraBirdseye": "Birdseye",
"cameraFaceRecognition": "Reconocimiento facial",
"cameraLpr": "Reconocimiento de matrículas",
"cameraMqttConfig": "MQTT",
"cameraOnvif": "ONVIF",
"cameraUi": "Interfaz de cámara",
"cameraTimestampStyle": "Estilo de marca de tiempo",
"cameraMqtt": "MQTT de cámara",
"maintenance": "Mantenimiento",
"mediaSync": "Sincronización de medios",
"regionGrid": "Cuadrícula de regiones",
"systemDetectorsAndModel": "Detectores y modelo"
"systemAuthentication": "Autenticación"
},
"dialog": {
"unsavedChanges": {
@@ -102,7 +59,7 @@
}
},
"cameraSetting": {
"camera": "Overrides,Sobrescrituras",
"camera": "Cámara",
"noCamera": "Sin cámara"
},
"general": {
@@ -346,10 +303,6 @@
"zone": "zona",
"motion_mask": "máscara de movimiento",
"object_mask": "máscara de objeto"
},
"revertOverride": {
"title": "Revertir a la configuración base",
"desc": "Esto eliminará la sobrescritura del perfil para {{type}} <em>{{name}}</em> y revertirá a la configuración base."
}
},
"speed": {
@@ -361,12 +314,6 @@
"error": {
"mustNotBeEmpty": "El nombre no puede estar vacío."
}
},
"id": {
"error": {
"mustNotBeEmpty": "El ID no puede estar vacío.",
"alreadyExists": "Ya existe una máscara con este ID para esta cámara."
}
}
},
"zones": {
@@ -423,8 +370,7 @@
"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.",
"title": "Habilitado"
"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": {
@@ -465,13 +411,7 @@
"documentTitle": "Editar Máscara de Movimiento - Frigate",
"point_one": "{{count}} punto",
"point_many": "{{count}} puntos",
"point_other": "{{count}} puntos",
"defaultName": "Máscara de movimiento {{number}}",
"name": {
"title": "Nombre",
"description": "Un nombre descriptivo opcional para esta máscara de movimiento.",
"placeholder": "Introduce un nombre..."
}
"point_other": "{{count}} puntos"
},
"objectMasks": {
"label": "Máscaras de Objetos",
@@ -497,26 +437,11 @@
"point_one": "{{count}} punto",
"point_many": "{{count}} puntos",
"point_other": "{{count}} puntos",
"clickDrawPolygon": "Haz clic para dibujar un polígono en la imagen.",
"name": {
"title": "Nombre",
"description": "Un nombre descriptivo opcional para esta máscara de objeto.",
"placeholder": "Introduce un nombre..."
}
"clickDrawPolygon": "Haz clic para dibujar un polígono en la imagen."
},
"restart_required": "Es necesario reiniciar (se han cambiado las máscaras/zonas)",
"motionMaskLabel": "Máscara de movimiento {{number}}",
"objectMaskLabel": "Máscara de objeto {{number}}",
"disabledInConfig": "El elemento está deshabilitado en el archivo de configuración",
"addDisabledProfile": "Añádelo primero a la configuración base y luego sobrescríbelo en el perfil",
"profileBase": "(base)",
"profileOverride": "(sobrescritura)",
"masks": {
"enabled": {
"title": "Habilitado",
"description": "Indica si esta máscara está habilitada en el archivo de configuración. Si está deshabilitada, no se puede habilitar mediante MQTT. Las máscaras deshabilitadas se ignoran en tiempo de ejecución."
}
}
"objectMaskLabel": "Máscara de objeto {{number}}"
},
"motionDetectionTuner": {
"title": "Sintonizador de Detección de Movimiento",
@@ -789,7 +714,7 @@
"snapshots": "Instantáneas",
"cleanCopySnapshots": "<code>clean_copy</code> Instantáneas"
},
"desc": "Enviar a Frigate+ requiere que las instantáneas estén habilitadas en tu configuración.",
"desc": "Enviar a Frigate+ requiere que tanto las capturas instantáneas como las capturas <code>clean_copy</code> estén habilitadas en tu configuración.",
"cleanCopyWarning": "Algunas cámaras tienen las instantáneas deshabilitadas"
},
"modelInfo": {
@@ -801,21 +726,13 @@
"cameras": "Cámaras",
"loading": "Cargando información del modelo…",
"error": "No se pudo cargar la información del modelo",
"availableModels": "Modelos de Frigate+ disponibles",
"availableModels": "Modelos disponibles",
"loadingAvailableModels": "Cargando modelos disponibles…",
"modelSelect": "Tus modelos disponibles en Frigate+ se pueden seleccionar aquí. Ten en cuenta que solo se pueden seleccionar modelos compatibles con tu configuración actual de detectores.",
"trainDate": "Fecha de entrenamiento",
"plusModelType": {
"baseModel": "Modelo Base",
"userModel": "Ajustado Finamente"
},
"noModelLoaded": "Actualmente no hay ningún modelo de Frigate+ cargado.",
"selectModel": "Selecciona un modelo",
"noModelsAvailable": "No hay modelos disponibles",
"filter": {
"ariaLabel": "Filtrar modelos por tipo",
"baseModels": "Modelos base",
"fineTunedModels": "Modelos ajustados"
}
},
"toast": {
@@ -824,14 +741,7 @@
},
"restart_required": "Es necesario reiniciar (se ha cambiado el modelo Frigate+)",
"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í.",
"cardTitles": {
"api": "API",
"currentModel": "Modelo actual",
"otherModels": "Otros modelos",
"configuration": "Configuración"
},
"changeInDetectorsAndModel": "Cambiar modelo"
"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",
@@ -857,11 +767,11 @@
"modelSize": {
"label": "Tamaño del Modelo",
"small": {
"title": "size",
"title": "pequeño",
"desc": "Usar la opción <em>small</em> emplea una versión cuantizada del modelo que consume menos memoria RAM y se ejecuta más rápido en la CPU, con una diferencia muy pequeña o casi imperceptible en la calidad de las representaciones (embeddings)."
},
"large": {
"title": "model",
"title": "grande",
"desc": "Usar la opción <em>large</em> emplea el modelo completo de Jina y se ejecutará automáticamente en la GPU, si está disponible."
},
"desc": "Tamaño del modelo usado para la búsqueda semántica."
@@ -1247,8 +1157,7 @@
},
"hikvision": {
"substreamWarning": "La subtransmisión 1 está limitada a una resolución baja. Muchas cámaras Hikvision admiten subtransmisiones adicionales que deben habilitarse en la configuración de la cámara. Se recomienda comprobar y utilizar dichas transmisiones si están disponibles."
},
"resolutionUnknown": "No se pudo detectar la resolución de este flujo. Debes establecer manualmente la resolución de detección en Ajustes o en tu configuración."
}
}
},
"title": "Añadir cámara",
@@ -1283,20 +1192,7 @@
"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.<br /> <em>Nota: Esto no desactiva las retransmisiones de go2rtc.</em>",
"enableDesc": "Deshabilita temporalmente una cámara habilitada hasta que Frigate se reinicie. Deshabilitar una cámara detiene completamente el procesamiento de los flujos 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.Arrastra el controlador para reordenar las cámaras tal y como aparecen en la interfaz. El orden de las cámaras habilitadas se reflejará en toda la interfaz, incluido el panel en directo y los menús desplegables de selección de cámaras.",
"enableLabel": "Cámaras habilitadas",
"disableLabel": "Cámaras deshabilitadas",
"disableDesc": "Habilita una cámara que actualmente no está visible en la interfaz y está deshabilitada en la configuración. Es necesario reiniciar Frigate después de habilitarla.",
"enableSuccess": "{{cameraName}} se ha habilitado en la configuración. Reinicia Frigate para aplicar los cambios.",
"friendlyName": {
"edit": "Editar nombre visible de la cámara",
"title": "Editar nombre visible",
"description": "Establece el nombre descriptivo que se mostrará para esta cámara en toda la interfaz de Frigate. Déjalo en blanco para usar el ID de la cámara.",
"rename": "Renombrar"
},
"reorderHandle": "Arrastrar para reordenar",
"saving": "Guardando…",
"saved": "Guardado"
"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.<br /> <em>Nota: Esto no deshabilita las retransmisiones de go2rtc.</em>"
},
"cameraConfig": {
"add": "Añadir cámara",
@@ -1328,34 +1224,8 @@
}
},
"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.",
"title": "Eliminar cámara",
"selectPlaceholder": "Elegir cámara...",
"confirmTitle": "¿Estás seguro?",
"confirmWarning": "Eliminar <strong>{{cameraName}}</strong> no se puede deshacer.",
"deleteExports": "Eliminar también las exportaciones de esta cámara",
"confirmButton": "Eliminar permanentemente",
"success": "La cámara {{cameraName}} se ha eliminado correctamente",
"error": "No se pudo eliminar la cámara {{cameraName}}"
},
"deleteCamera": "Eliminar cámara",
"profiles": {
"title": "Sobrescrituras de cámaras del perfil",
"selectLabel": "Seleccionar perfil",
"description": "Configura qué cámaras se habilitan o deshabilitan cuando se activa un perfil. Las cámaras configuradas como \"Heredar\" conservan su estado base habilitado.",
"inherit": "Heredar",
"enabled": "Habilitado",
"disabled": "Deshabilitado"
},
"cameraType": {
"title": "Tipo de cámara",
"label": "Tipo de cámara",
"description": "Establece el tipo de cada cámara. Las cámaras LPR dedicadas son cámaras de un solo propósito con un zoom óptico potente para capturar matrículas de vehículos lejanos. La mayoría de cámaras deberían usar el tipo de cámara normal salvo que la cámara esté específicamente destinada a LPR y tenga una vista muy enfocada a matrículas.",
"normal": "Normal",
"dedicatedLpr": "LPR dedicada",
"saveSuccess": "Se ha actualizado el tipo de cámara de {{cameraName}}. Reinicia Frigate para aplicar los cambios."
},
"description": "Añade, edita y elimina cámaras, controla qué cámaras están habilitadas y configura sobrescrituras por perfil y tipo de cámara. Para configurar flujos, detección, movimiento y otros ajustes específicos de cámara, selecciona la sección correspondiente dentro de Configuración de cámara."
"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": {
"title": "Configuración de revisión de la cámara",
@@ -1398,295 +1268,34 @@
"overriddenGlobal": "Sobrescrito (Global)",
"overriddenBaseConfigTooltip": "El perfil {{profile}} sobrescribe los ajustes de configuración de esta sección",
"overriddenGlobalTooltip": "Esta cámara sobrescribe los ajustes de configuración global en esta sección",
"overriddenBaseConfig": "Sobrescrito (Configuración Base)",
"overriddenInCameras": {
"label_one": "Sobrescrito en {{count}} cámara",
"label_many": "Sobrescrito en {{count}} cámaras",
"label_other": "Sobrescrito en {{count}} cámaras",
"tooltip_one": "{{count}} cámaras sobrescriben los valores de esta sección. Haz clic para ver los detalles.",
"tooltip_many": "{{count}} cámaras sobrescriben los valores de esta sección. Haz clic para ver los detalles.",
"tooltip_other": "{{count}} cámaras sobrescriben los valores de esta sección. Haz clic para ver los detalles.",
"heading_one": "This global section has fields that are overridden in {{count}} camera.",
"heading_many": "Esta sección global tiene campos que están sobrescritos en {{count}} cámaras.",
"heading_other": "Esta sección global tiene campos que están sobrescritos en {{count}} cámaras.",
"othersField_one": "{{count}} más",
"othersField_many": "{{count}} más",
"othersField_other": "{{count}} más",
"profilePrefix": "Perfil {{profile}}: {{fields}}"
},
"overriddenGlobalHeading_one": "Esta cámara sobrescribe {{count}} campo de la configuración global:",
"overriddenGlobalHeading_many": "Esta cámara sobrescribe {{count}} campos de la configuración global:",
"overriddenGlobalHeading_other": "Esta cámara sobrescribe {{count}} campos de la configuración global:",
"overriddenGlobalNoDeltas": "Esta cámara sobrescribe la configuración global, pero no hay diferencias en los valores de los campos.",
"overriddenBaseConfigHeading_one": "El perfil {{profile}} sobrescribe {{count}} campo de la configuración base:",
"overriddenBaseConfigHeading_many": "El perfil {{profile}} sobrescribe {{count}} campos de la configuración base:",
"overriddenBaseConfigHeading_other": "El perfil {{profile}} sobrescribe {{count}} campos de la configuración base:",
"overriddenBaseConfigNoDeltas": "El perfil {{profile}} sobrescribe esta sección, pero no hay diferencias en los valores de los campos respecto a la configuración base."
"overriddenBaseConfig": "Sobrescrito (Configuración Base)"
},
"onvif": {
"profileLoading": "Cargando perfiles...",
"profileAuto": "Auto",
"autotracking": {
"zooming": {
"disabled": "Deshabilitado",
"absolute": "Absoluto",
"relative": "Relativo"
}
}
"profileLoading": "Cargando perfiles..."
},
"maintenance": {
"sync": {
"verboseDesc": "Escribe una lista completa de archivos huérfanos en el disco para su revisión.",
"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.",
"title": "Sincronización de medios",
"started": "Sincronización de medios iniciada.",
"alreadyRunning": "Ya hay una tarea de sincronización en ejecución",
"error": "No se pudo iniciar la sincronización",
"currentStatus": "Estado",
"jobId": "ID de tarea",
"startTime": "Hora de inicio",
"endTime": "Hora de finalización",
"statusLabel": "Estado",
"results": "Resultados",
"errorLabel": "Error",
"mediaTypes": "Tipos de medios",
"allMedia": "Todos los medios",
"dryRun": "Simulación",
"dryRunEnabled": "No se eliminará ningún archivo",
"dryRunDisabled": "Se eliminarán archivos",
"force": "Forzar",
"running": "Sincronización en curso...",
"start": "Iniciar sincronización",
"inProgress": "La sincronización está en curso. Esta página está deshabilitada.",
"status": {
"queued": "En cola",
"running": "En ejecución",
"completed": "Completado",
"failed": "Fallido",
"notRunning": "No está en ejecución"
},
"resultsFields": {
"filesChecked": "Archivos comprobados",
"orphansFound": "Huérfanos encontrados",
"orphansDeleted": "Huérfanos eliminados",
"aborted": "Abortado. La eliminación superaría el umbral de seguridad.",
"error": "Error",
"totals": "Totales"
},
"event_snapshots": "Instantáneas de objetos rastreados",
"event_thumbnails": "Miniaturas de objetos rastreados",
"review_thumbnails": "Miniaturas de revisión",
"previews": "Vistas previas",
"exports": "Exportaciones",
"recordings": "Grabaciones"
"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.",
"title": "Cuadrícula de regiones",
"clear": "Borrar cuadrícula de regiones",
"clearConfirmTitle": "Borrar cuadrícula de regiones",
"clearSuccess": "Cuadrícula de regiones borrada correctamente",
"clearError": "No se pudo borrar la cuadrícula de regiones",
"restartRequired": "Es necesario reiniciar para que los cambios de la cuadrícula de regiones surtan efecto"
},
"title": "Mantenimiento"
"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",
"description": "Estos ajustes se aplican únicamente a esta cámara y anulan los ajustes globales.",
"title": "Ajustes de cámara"
"description": "Estos ajustes se aplican únicamente a esta cámara y anulan los ajustes globales."
},
"genaiModel": {
"noModels": "No hay modelos disponibles",
"placeholder": "Seleccionar modelo…",
"search": "Buscar modelos…"
"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.",
"title": "Ajustes globales"
},
"sections": {
"go2rtc": "streams",
"detect": "Detección",
"record": "Grabación",
"snapshots": "Instantáneas",
"motion": "Movimiento",
"objects": "Objetos",
"review": "Revisión",
"audio": "Audio",
"notifications": "Notificaciones",
"live": "Vista en directo",
"timestamp_style": "Marcas de tiempo",
"mqtt": "MQTT",
"database": "Base de datos",
"telemetry": "Telemetría",
"auth": "Autenticación",
"tls": "TLS",
"proxy": "Proxy",
"ffmpeg": "FFmpeg",
"detectors": "Detectores",
"model": "Modelo",
"semantic_search": "Búsqueda semántica",
"genai": "GenAI",
"face_recognition": "Reconocimiento facial",
"lpr": "Reconocimiento de matrículas",
"birdseye": "Birdseye",
"masksAndZones": "Máscaras / zonas"
},
"advancedSettingsCount": "Ajustes avanzados ({{count}})",
"advancedCount": "Avanzado ({{count}})",
"showAdvanced": "Mostrar ajustes avanzados",
"tabs": {
"sharedDefaults": "Valores predeterminados compartidos",
"system": "Sistema",
"integrations": "Integraciones"
},
"additionalProperties": {
"keyLabel": "Clave",
"valueLabel": "Valor",
"keyPlaceholder": "Nueva clave",
"remove": "Eliminar"
},
"knownPlates": {
"namePlaceholder": "p. ej., Coche de mi mujer",
"platePlaceholder": "Número de matrícula o regex"
},
"timezone": {
"defaultOption": "Usar zona horaria del navegador"
},
"roleMap": {
"empty": "No hay asignaciones de roles",
"roleLabel": "Rol",
"groupsLabel": "Grupos",
"addMapping": "Añadir asignación de rol",
"remove": "Eliminar"
},
"ffmpegArgs": {
"preset": "Preajuste",
"manual": "Argumentos manuales",
"inherit": "Heredar del ajuste de cámara",
"none": "Ninguno",
"useGlobalSetting": "Heredar del ajuste global",
"selectPreset": "Seleccionar preajuste",
"manualPlaceholder": "Introduce argumentos de FFmpeg",
"presetLabels": {
"preset-rpi-64-h264": "Raspberry Pi (H.264)",
"preset-rpi-64-h265": "Raspberry Pi (H.265)",
"preset-vaapi": "VAAPI (GPU Intel/AMD)",
"preset-intel-qsv-h264": "Intel QuickSync (H.264)",
"preset-intel-qsv-h265": "Intel QuickSync (H.265)",
"preset-nvidia": "GPU NVIDIA",
"preset-jetson-h264": "NVIDIA Jetson (H.264)",
"preset-jetson-h265": "NVIDIA Jetson (H.265)",
"preset-rkmpp": "Rockchip RKMPP",
"preset-http-jpeg-generic": "HTTP JPEG (genérico)",
"preset-http-mjpeg-generic": "HTTP MJPEG (genérico)",
"preset-http-reolink": "HTTP - Cámaras Reolink",
"preset-rtmp-generic": "RTMP (genérico)",
"preset-rtsp-generic": "RTSP (genérico)",
"preset-rtsp-restream": "RTSP - Retransmisión desde go2rtc",
"preset-rtsp-restream-low-latency": "RTSP - Retransmisión desde go2rtc (baja latencia)",
"preset-rtsp-udp": "RTSP - UDP",
"preset-rtsp-blue-iris": "RTSP - Blue Iris",
"preset-record-generic": "Grabación (genérica, sin audio)",
"preset-record-generic-audio-copy": "Grabación (genérica + copiar audio)",
"preset-record-generic-audio-aac": "Grabación (genérica + audio a AAC)",
"preset-record-mjpeg": "Grabación - Cámaras MJPEG",
"preset-record-jpeg": "Grabación - Cámaras JPEG",
"preset-record-ubiquiti": "Grabación - Cámaras Ubiquiti"
}
},
"cameraInputs": {
"itemTitle": "Flujo {{index}}"
},
"restartRequiredField": "Reinicio necesario",
"restartRequiredFooter": "Configuración modificada - reinicio necesario",
"detect": {
"title": "Ajustes de detección"
},
"detectors": {
"title": "Ajustes de detector",
"singleType": "Solo se permite un detector {{type}}.",
"keyRequired": "El nombre del detector es obligatorio.",
"keyDuplicate": "El nombre del detector ya existe.",
"noSchema": "No hay esquemas de detector disponibles.",
"none": "No hay instancias de detector configuradas.",
"add": "Añadir detector",
"addCustomKey": "Añadir clave personalizada"
},
"record": {
"title": "Ajustes de grabación"
},
"snapshots": {
"title": "Ajustes de instantáneas"
},
"motion": {
"title": "Ajustes de movimiento"
},
"objects": {
"title": "Ajustes de objetos"
},
"audioLabels": {
"summary": "{{count}} etiquetas de audio seleccionadas",
"empty": "No hay etiquetas de audio disponibles"
},
"objectLabels": {
"summary": "{{count}} tipos de objeto seleccionados",
"empty": "No hay etiquetas de objeto disponibles"
},
"reviewLabels": {
"summary": "{{count}} etiquetas seleccionadas",
"empty": "No hay etiquetas disponibles"
},
"filters": {
"objectFieldLabel": "{{field}} para {{label}}"
},
"zoneNames": {
"summary": "{{count}} seleccionados",
"empty": "No hay zonas disponibles"
},
"inputRoles": {
"summary": "{{count}} roles seleccionados",
"empty": "No hay roles disponibles",
"options": {
"detect": "Detectar",
"record": "Grabar",
"audio": "Audio"
}
},
"genaiRoles": {
"options": {
"embeddings": "Embedding",
"descriptions": "Descripciones",
"chat": "Chat"
}
},
"semanticSearchModel": {
"placeholder": "Seleccionar modelo…",
"builtIn": "Modelos integrados",
"genaiProviders": "Proveedores de GenAI"
},
"review": {
"title": "Ajustes de revisión"
},
"audio": {
"title": "Ajustes de audio"
},
"notifications": {
"title": "Ajustes de notificaciones"
},
"live": {
"title": "Ajustes de vista en directo"
},
"timestamp_style": {
"title": "Ajustes de marcas de tiempo"
},
"searchPlaceholder": "Buscar...",
"addCustomLabel": "Añadir etiqueta personalizada..."
"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",
@@ -1721,10 +1330,7 @@
"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.",
"saveAllSuccessRestartRequired_one": "La sección {{count}} se ha guardado correctamente. Reinicia Frigate para aplicar los cambios.",
"saveAllSuccessRestartRequired_many": "Las {{count}} secciones se han guardado correctamente. Reinicia Frigate para aplicar los cambios.",
"saveAllSuccessRestartRequired_other": "Las {{count}} secciones se han guardado correctamente. Reinicia Frigate para aplicar los cambios."
"saveAllFailure": "Error al guardar todas las secciones."
},
"profiles": {
"title": "Perfiles",
@@ -1758,84 +1364,26 @@
"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.",
"deleteProfile": "Eliminar perfil",
"deleteProfileConfirm": "¿Eliminar el perfil \"{{profile}}\" de todas las cámaras? Esta acción no se puede deshacer.",
"deleteSuccess": "Perfil '{{profile}}' eliminado",
"createSuccess": "Perfil '{{profile}}' creado",
"removeOverride": "Eliminar sobrescritura de perfil",
"deleteSection": "Eliminar sobrescrituras de sección",
"deleteSectionConfirm": "¿Eliminar las sobrescrituras de {{section}} del perfil {{profile}} en {{camera}}?",
"deleteSectionSuccess": "Sobrescrituras de {{section}} eliminadas para {{profile}}",
"enableSwitch": "Habilitar perfiles"
"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.",
"title": "Flujos go2rtc",
"addStream": "Añadir flujo",
"addUrl": "Añadir URL",
"streamName": "Nombre del flujo",
"streamNamePlaceholder": "p. ej., puerta_principal",
"streamUrlPlaceholder": "p. ej., rtsp://usuario:contraseña@192.168.1.100/stream",
"deleteStream": "Eliminar flujo",
"noStreams": "No hay flujos go2rtc configurados. Añade un flujo para empezar.",
"validation": {
"nameRequired": "El nombre del flujo es obligatorio",
"nameDuplicate": "Ya existe un flujo con este nombre",
"nameInvalid": "El nombre del flujo solo puede contener letras, números, guiones bajos y guiones",
"urlRequired": "Se requiere al menos una URL"
},
"renameStream": "Renombrar flujo",
"newStreamName": "Nuevo nombre del flujo",
"ffmpeg": {
"useFfmpegModule": "Usar modo de compatibilidad (ffmpeg)",
"video": "Vídeo",
"audio": "Audio",
"hardware": "Aceleración por hardware",
"videoCopy": "Copiar",
"videoH264": "Transcodificar a H.264",
"videoH265": "Transcodificar a H.265",
"videoExclude": "Excluir",
"audioCopy": "Copiar",
"audioAac": "Transcodificar a AAC",
"audioOpus": "Transcodificar a Opus",
"audioPcmu": "Transcodificar a PCM μ-law",
"audioPcma": "Transcodificar a PCM A-law",
"audioPcm": "Transcodificar a PCM",
"audioMp3": "Transcodificar a MP3",
"audioExclude": "Excluir",
"hardwareNone": "Sin aceleración por hardware",
"hardwareAuto": "Automático (recomendado)",
"hardwareVaapi": "VAAPI",
"hardwareCuda": "CUDA",
"hardwareV4l2m2m": "V4L2 M2M",
"hardwareDxva2": "DXVA2",
"hardwareVideotoolbox": "VideoToolbox",
"addVideoCodec": "Añadir códec de vídeo",
"addAudioCodec": "Añadir códec de audio",
"removeCodec": "Eliminar códec"
},
"streamNumber": "Flujo {{index}}"
"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.",
"vehicleNotTracked": "El reconocimiento de matrículas requiere rastrear 'car' o 'motorcycle'. Habilita 'car' o 'motorcycle' en Objetos para esta cámara.",
"modelSizeLarge": "El modelo 'large' está optimizado para matrículas de varias líneas. El modelo 'small' ofrece mejor rendimiento que 'large' y debería usarse salvo que tu región use formatos de matrícula de varias líneas."
"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.",
"globalDisabled": "El enriquecimiento de reconocimiento facial debe estar habilitado para que las funciones de reconocimiento facial funcionen en esta cámara.",
"modelSizeLarge": "El modelo 'large' requiere una GPU o NPU para ofrecer un rendimiento razonable. Usa 'small' en sistemas solo con CPU."
"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."
@@ -1844,165 +1392,17 @@
"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.",
"mixedTypesSuggestion": "Todos los detectores deben usar el mismo tipo. Elimina los detectores existentes o selecciona {{type}}."
"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.",
"recordDisabled": "La grabación está deshabilitada; no se generarán elementos de revisión.",
"allNonAlertDetections": "Toda la actividad que no sea de alerta se incluirá como detecciones.",
"genaiImageSourceRecordingsRecordDisabled": "El origen de imagen está establecido en 'recordings', pero la grabación está deshabilitada. Frigate usará imágenes de vista previa como alternativa."
},
"detect": {
"fpsGreaterThanFive": "No se recomienda establecer los FPS de detección por encima de 5. Valores más altos pueden causar problemas de rendimiento y no aportarán ningún beneficio.",
"disabled": "La detección de objetos está deshabilitada. Las instantáneas, los elementos de revisión y enriquecimientos como el reconocimiento facial, el reconocimiento de matrículas y la IA generativa no funcionarán."
},
"objects": {
"genaiNoDescriptionsProvider": "Debes configurar un proveedor GenAI con el rol 'descriptions' para que se generen descripciones."
},
"record": {
"noRecordRole": "Ningún flujo tiene definido el rol de grabación. La grabación no funcionará."
},
"semanticSearch": {
"jinav2SmallModelSize": "El tamaño 'small' con el modelo Jina V2 tiene un alto consumo de RAM y coste de inferencia. Se recomienda el modelo 'large' con una GPU dedicada."
"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+.",
"title": "Gestión de modelos de Frigate+",
"label": "Origen del modelo actual",
"goToFrigatePlus": "Ir a los ajustes de Frigate+",
"showModelForm": "Configurar un modelo manualmente"
"description": "Esta instancia está ejecutando un modelo de Frigate+. Seleccione o cambie su modelo en la configuración de Frigate+."
}
},
"saveAllPreview": {
"profile": {
"label": "Override,Eliminar"
},
"title": "Cambios pendientes de guardar",
"triggerLabel": "Revisar cambios pendientes",
"empty": "No hay cambios pendientes.",
"scope": {
"label": "Ámbito",
"global": "Global",
"camera": "Cámara: {{cameraName}}"
},
"field": {
"label": "Campo"
},
"value": {
"label": "Nuevo valor",
"reset": "Restablecer"
}
},
"timestampPosition": {
"tl": "Arriba a la izquierda",
"tr": "Arriba a la derecha",
"bl": "Abajo a la izquierda",
"br": "Abajo a la derecha"
},
"unsavedChanges": "Tienes cambios sin guardar",
"confirmReset": "Confirmar restablecimiento",
"birdseye": {
"trackingMode": {
"objects": "Objetos",
"motion": "Movimiento",
"continuous": "Continuo"
},
"cameraOrder": {
"label": "Orden de cámaras",
"description": "Arrastra las cámaras para establecer su orden en el diseño de Birdseye.",
"reorderHandle": "Arrastrar para reordenar",
"saving": "Guardando…",
"saved": "Guardado"
}
},
"snapshot": {
"retainMode": {
"all": "Todo",
"motion": "Movimiento",
"active_objects": "Objetos activos"
}
},
"ui": {
"timeFormat": {
"browser": "Navegador",
"12hour": "12 horas",
"24hour": "24 horas"
},
"TimeOrDateStyle": {
"full": "Completo",
"long": "Largo",
"medium": "Medio",
"short": "Corto"
},
"unitSystem": {
"metric": "Métrico",
"imperial": "Imperial"
}
},
"review": {
"imageSource": {
"recordings": "Grabaciones",
"previews": "Vistas previas"
}
},
"logger": {
"logLevel": {
"debug": "Depuración",
"info": "Información",
"warning": "Advertencia",
"error": "Error",
"critical": "Crítico"
}
},
"modelSize": {
"small": "Pequeño",
"large": "Grande"
},
"retainMode": {
"all": "Todo",
"motion": "Movimiento",
"active_objects": "Objetos activos"
},
"previewQuality": {
"very_high": "Muy alto",
"high": "Alto",
"medium": "Medio",
"low": "Bajo",
"very_low": "Muy bajo"
},
"detectorsAndModel": {
"title": "Detectores y modelo",
"description": "Configura el backend del detector que ejecuta la detección de objetos y el modelo que utiliza. Los cambios se guardan juntos para que el detector y el modelo permanezcan sincronizados.",
"cardTitles": {
"detector": "Hardware del detector",
"model": "Modelo de detección"
},
"tabs": {
"plus": "Frigate+",
"custom": "Modelo personalizado"
},
"mismatch": {
"warning": "El modelo actual de Frigate+ “{{model}}” requiere el detector {{required}}. Selecciona un modelo compatible a continuación o cambia a Modelo personalizado antes de guardar."
},
"plusModel": {
"requiresDetector": "Requiere: {{detector}}",
"noModelSelected": "Selecciona un modelo de Frigate+"
},
"toast": {
"saveSuccess": "Los ajustes de detectores y modelo se han guardado. Reinicia Frigate para aplicar los cambios.",
"saveError": "No se pudieron guardar los ajustes del detector y del modelo"
},
"unsavedChanges": "Cambios sin guardar en el detector y el modelo",
"restartRequired": "Reinicio necesario (se ha cambiado el detector o el modelo)"
},
"menuDot": {
"overrideGlobal": "Esta sección sobrescribe la configuración global",
"overrideProfile": "Esta sección está sobrescrita por el perfil {{profile}}",
"unsaved": "Esta sección tiene cambios sin guardar"
}
}
+1 -7
View File
@@ -55,10 +55,7 @@
},
"count_other": "{{count}} mensajes",
"count_one": "{{count}} mensaje",
"empty": "No se han capturado mensaje aún",
"expanded": {
"payload": "Carga útil"
}
"empty": "No se han capturado mensaje aún"
}
},
"title": "Sistema",
@@ -212,9 +209,6 @@
"unusable": "No usable",
"fair": "Normal",
"stallsLastHour": "Bloqueos (última hora)"
},
"noCameras": {
"title": "No se han encontrado cámaras"
}
},
"lastRefreshed": "Última actualización: ",
+1 -2
View File
@@ -140,8 +140,7 @@
"gl": "Galego (galeegi keel)",
"id": "Bahasa Indonesia (indoneesia keel)",
"ur": "اردو (urdu keel)",
"hr": "Hrvatski (horvaadi keel)",
"bs": "Bosanski (bosnia keel)"
"hr": "Hrvatski (horvaadi keel)"
},
"system": "Süsteem",
"systemMetrics": "Süsteemi meetrika",
+1 -6
View File
@@ -121,10 +121,5 @@
"royal_mail": "Royal Mail",
"school_bus": "Koolibuss",
"skunk": "Vinukloom (skunk)",
"kangaroo": "Känguru",
"baby": "Väikelaps",
"baby_stroller": "Lapsevanker",
"rickshaw": "Rikša",
"Rodent": "Näriline",
"rodent": "Näriline"
"kangaroo": "Känguru"
}
+1 -5
View File
@@ -121,9 +121,5 @@
"royal_mail": "Poste du Royaume Uni",
"school_bus": "Bus scolaire",
"skunk": "Mouffette",
"kangaroo": "Kangourou",
"baby": "Bébé",
"baby_stroller": "Poussette",
"rickshaw": "Pousse-pousse",
"Rodent": "Rongeur"
"kangaroo": "Kangourou"
}
+1 -3
View File
@@ -16,7 +16,5 @@
"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",
"no_similar_objects_found": "Aucun objet similaire trouvé.",
"semantic_search_required": "La recherche sémantique doit être activée afin de trouver un objet similaire."
"quick_reply_find_similar": "Trouver des observations similaires"
}

Some files were not shown because too many files have changed in this diff Show More