mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-01 19:30:33 +03:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff7b324f5c | ||
|
|
0f00cae567 | ||
|
|
63c12cf135 | ||
|
|
e82dc7ad13 | ||
|
|
689a5b59ac | ||
|
|
69061d17f1 | ||
|
|
efb890e6e0 | ||
|
|
ea7b3e2336 | ||
|
|
a0b8d40a66 | ||
|
|
9b0aa75312 | ||
|
|
a6b2a29c4c | ||
|
|
40aa2da22a | ||
|
|
3dd59d9a44 | ||
|
|
bbf23ccce7 | ||
|
|
4953c19a71 | ||
|
|
ff2cd6a417 | ||
|
|
4f03480afd |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||||
|
"version": "2.7.1",
|
||||||
|
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08",
|
||||||
|
"integrity": "sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ runs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -590,7 +590,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
string: ${{ github.repository }}
|
string: ${{ github.repository }}
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -125,4 +125,4 @@ jobs:
|
|||||||
- name: Check API spec is up to date
|
- name: Check API spec is up to date
|
||||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
|
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
|
||||||
- name: Run unit tests in devcontainer
|
- name: Run unit tests in devcontainer
|
||||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
|
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
string: ${{ github.repository }}
|
string: ${{ github.repository }}
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ When reviewing code, do NOT comment on:
|
|||||||
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
||||||
- **Linting**: Ruff with rules defined in project config
|
- **Linting**: Ruff with rules defined in project config
|
||||||
- **Type Checking**: Use type hints consistently
|
- **Type Checking**: Use type hints consistently
|
||||||
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
|
- **Testing**: unittest framework - use `python3 -u -m frigate.test` to run the suite in parallel groups
|
||||||
- **Language**: American English for all code, comments, and documentation
|
- **Language**: American English for all code, comments, and documentation
|
||||||
- **Punctuation**: Do not use em dashes in documentation, comments, or strings; reword with standard punctuation (commas, colons, parentheses, or separate sentences)
|
- **Punctuation**: Do not use em dashes in documentation, comments, or strings; reword with standard punctuation (commas, colons, parentheses, or separate sentences)
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ Key rules enforced:
|
|||||||
### Backend Testing
|
### Backend Testing
|
||||||
|
|
||||||
- **Framework**: Python unittest
|
- **Framework**: Python unittest
|
||||||
- **Run Command**: `python3 -u -m unittest`
|
- **Run Command**: `python3 -u -m frigate.test` (parallel groups; plain `python3 -u -m unittest` runs serially)
|
||||||
- **Location**: `frigate/test/`
|
- **Location**: `frigate/test/`
|
||||||
- **Coverage**: Aim for comprehensive test coverage of core functionality
|
- **Coverage**: Aim for comprehensive test coverage of core functionality
|
||||||
- **Pattern**: Use `TestCase` classes with descriptive test method names
|
- **Pattern**: Use `TestCase` classes with descriptive test method names
|
||||||
@@ -219,8 +219,8 @@ Key rules enforced:
|
|||||||
### Python Backend
|
### Python Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests in parallel groups (-j to set the number of jobs)
|
||||||
python3 -u -m unittest
|
python3 -u -m frigate.test
|
||||||
|
|
||||||
# Run specific test file
|
# Run specific test file
|
||||||
python3 -u -m unittest frigate.test.test_ffmpeg_presets
|
python3 -u -m unittest frigate.test.test_ffmpeg_presets
|
||||||
|
|||||||
+2
-2
@@ -64,7 +64,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi
|
|||||||
- **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses)
|
- **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses)
|
||||||
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
||||||
- **Linting**: Ruff
|
- **Linting**: Ruff
|
||||||
- **Testing**: `python3 -u -m unittest`
|
- **Testing**: `python3 -u -m frigate.test` (runs the suite in parallel groups)
|
||||||
- **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting
|
- **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting
|
||||||
- **Async**: All external I/O must be async. No blocking calls in async functions.
|
- **Async**: All external I/O must be async. No blocking calls in async functions.
|
||||||
- **Error handling**: Use specific exception types. Keep try blocks minimal.
|
- **Error handling**: Use specific exception types. Keep try blocks minimal.
|
||||||
@@ -82,7 +82,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Python
|
# Python
|
||||||
python3 -u -m unittest # Run all tests
|
python3 -u -m frigate.test # Run all tests in parallel groups
|
||||||
python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test
|
python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test
|
||||||
ruff format frigate # Format
|
ruff format frigate # Format
|
||||||
ruff check frigate # Lint
|
ruff check frigate # Lint
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ run: local
|
|||||||
|
|
||||||
run_tests: local
|
run_tests: local
|
||||||
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
|
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
|
||||||
python3 -u -m unittest
|
python3 -u -m frigate.test
|
||||||
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
|
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
|
||||||
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
ruff == 0.17.1
|
ruff == 0.17.3
|
||||||
|
|
||||||
# types
|
# types
|
||||||
types-peewee == 4.0.*
|
types-peewee == 4.0.*
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ python-multipart == 0.0.32
|
|||||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||||
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
||||||
# General
|
# General
|
||||||
mypy == 2.4.0
|
mypy == 2.4.1
|
||||||
onvif-zeep-async == 4.3.*
|
onvif-zeep-async == 4.3.*
|
||||||
paho-mqtt == 2.1.*
|
paho-mqtt == 2.1.*
|
||||||
pandas == 3.0.*
|
pandas == 3.0.*
|
||||||
@@ -25,7 +25,7 @@ peewee_migrate == 2.3.*
|
|||||||
psutil == 7.2.*
|
psutil == 7.2.*
|
||||||
pydantic == 2.14.*
|
pydantic == 2.14.*
|
||||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||||
tzdata == 2026.5
|
tzdata == 2026.6
|
||||||
pyzmq == 27.2.*
|
pyzmq == 27.2.*
|
||||||
ruamel.yaml == 0.19.*
|
ruamel.yaml == 0.19.*
|
||||||
tzlocal == 5.4.5
|
tzlocal == 5.4.5
|
||||||
@@ -45,9 +45,9 @@ scipy == 1.17.*
|
|||||||
openvino == 2026.4.*
|
openvino == 2026.4.*
|
||||||
onnxruntime == 1.31.*
|
onnxruntime == 1.31.*
|
||||||
# Embeddings
|
# Embeddings
|
||||||
transformers == 5.20.*
|
transformers == 5.21.*
|
||||||
# Generative AI
|
# Generative AI
|
||||||
google-genai == 2.32.*
|
google-genai == 2.34.*
|
||||||
ollama == 0.6.*
|
ollama == 0.6.*
|
||||||
openai == 3.36.*
|
openai == 3.36.*
|
||||||
# push notifications
|
# push notifications
|
||||||
@@ -68,7 +68,7 @@ prometheus-client == 0.26.*
|
|||||||
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
|
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
|
||||||
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
|
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||||
# audio transcription
|
# audio transcription
|
||||||
sherpa-onnx==1.13.*
|
sherpa-onnx==2.0.*
|
||||||
faster-whisper==1.2.*
|
faster-whisper==1.2.*
|
||||||
librosa==0.11.*
|
librosa==0.11.*
|
||||||
soundfile==0.14.*
|
soundfile==0.14.*
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
onnx == 1.14.0; platform_machine == 'aarch64'
|
onnx == 1.14.0; platform_machine == 'aarch64'
|
||||||
protobuf == 3.20.3; platform_machine == 'aarch64'
|
protobuf == 5.29.6; platform_machine == 'aarch64'
|
||||||
|
|||||||
@@ -578,6 +578,10 @@ notifications:
|
|||||||
email: "admin@example.com"
|
email: "admin@example.com"
|
||||||
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||||
cooldown: 0
|
cooldown: 0
|
||||||
|
# Optional: Delivery of notifications sent while an alert is active (default: shown below)
|
||||||
|
# Options are immediate (deliver right away or drop) and reliable (retry for up to 30 seconds)
|
||||||
|
# NOTE: reliable can deliver late notifications that still link to the live view
|
||||||
|
ttl: immediate
|
||||||
|
|
||||||
# Optional: Record configuration
|
# Optional: Record configuration
|
||||||
# NOTE: Can be overridden at the camera level
|
# NOTE: Can be overridden at the camera level
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ Running Generative AI models on CPU is not recommended, as high inference times
|
|||||||
|
|
||||||
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
|
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
|
||||||
|
|
||||||
| Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | Notes |
|
| Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | Review [response style](/configuration/genai/genai_review#response-style) | Notes |
|
||||||
| ------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `qwen3-vl` | `frames` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. Follows a sequence of frames on its own. |
|
| `qwen3-vl` | `frames` | `natural` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. Follows a sequence of frames on its own. |
|
||||||
| `qwen3.6`/`qwen3.8` | `frames` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
| `qwen3.6`/`qwen3.8` | `frames` | `natural` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||||
| `gemma4` | `annotated_frames` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. Loses track of activity that repeats or reverses, so it benefits from annotated frames. |
|
| `gemma4` | `annotated_frames` | `default` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. Loses track of activity that repeats or reverses, so it benefits from annotated frames. |
|
||||||
|
| `lfm3-vl` | `annotated_frames` | `concise` | Very efficient with fast inference, making it a good choice for less powerful hardware or high volumes of review items. |
|
||||||
|
|
||||||
#### Embedding models
|
#### Embedding models
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ The `embeddings` role needs a different kind of model. Text queries are matched
|
|||||||
The `transcribe` role needs a model that accepts audio input. A text-only or vision-only model cannot serve this role. The following are recommended for local deployment of the `transcribe` role:
|
The `transcribe` role needs a model that accepts audio input. A text-only or vision-only model cannot serve this role. The following are recommended for local deployment of the `transcribe` role:
|
||||||
|
|
||||||
| Model | Notes |
|
| Model | Notes |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `qwen3-asr` | Dedicated speech recognition model covering 30 languages, and the better choice for transcription quality. It only transcribes, so it cannot be shared with the `descriptions` or `chat` roles. |
|
| `qwen3-asr` | Dedicated speech recognition model covering 30 languages, and the better choice for transcription quality. It only transcribes, so it cannot be shared with the `descriptions` or `chat` roles. |
|
||||||
| `gemma4` | General multimodal model that accepts audio as well as images, so one served model can cover `transcribe` alongside the other roles. Transcript quality is below `qwen3-asr`, particularly on noisy audio. |
|
| `gemma4` | General multimodal model that accepts audio as well as images, so one served model can cover `transcribe` alongside the other roles. Transcript quality is below `qwen3-asr`, particularly on noisy audio. |
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Notifications will be prevented if either:
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
1. Navigate to <NavPath path="Settings > Notifications > Notifications" />.
|
1. Navigate to <NavPath path="Settings > Notifications" />.
|
||||||
- Set **Email** to your email address
|
- Set **Email** to your email address
|
||||||
- Enable notifications for the desired cameras
|
- Enable notifications for the desired cameras
|
||||||
|
|
||||||
@@ -108,6 +108,29 @@ Currently, only Chrome supports images in notifications. Safari and Firefox will
|
|||||||
|
|
||||||
Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery.
|
Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery.
|
||||||
|
|
||||||
|
### Notification TTL
|
||||||
|
|
||||||
|
By default, Frigate sends notifications for an active alert with a `ttl` of `immediate`, so the push service either delivers them immediately or drops them. If your device is briefly unreachable (asleep, switching from Wi-Fi to cellular, or on a weak signal), those notifications are lost and you only receive the notification sent when the alert ends, which is always kept for up to an hour.
|
||||||
|
|
||||||
|
Setting `ttl` to `reliable` tells the push service to keep retrying these notifications for up to 30 seconds. This makes delivery more reliable, but a notification delivered late will still say an object is detected, even if the object has already left.
|
||||||
|
|
||||||
|
<ConfigTabs>
|
||||||
|
<TabItem value="ui">
|
||||||
|
|
||||||
|
1. Navigate to <NavPath path="Settings > Notifications" />.
|
||||||
|
- Expand **Advanced Settings** and set **Notification TTL** to **Reliable**
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="yaml">
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notifications:
|
||||||
|
ttl: reliable # retry an undelivered notification for up to 30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</ConfigTabs>
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
|
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ After closing VS Code, you may still have containers running. To close everythin
|
|||||||
GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
|
GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
python3 -u -m unittest
|
python3 -u -m frigate.test
|
||||||
```
|
```
|
||||||
|
|
||||||
#### FFMPEG Hardware Acceleration
|
#### FFMPEG Hardware Acceleration
|
||||||
@@ -207,7 +207,7 @@ npm run lint
|
|||||||
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
|
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
python3 -u -m unittest
|
python3 -u -m frigate.test
|
||||||
```
|
```
|
||||||
|
|
||||||
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
|
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
|
||||||
|
|||||||
+2
-1
@@ -934,7 +934,8 @@ def profile(request: Request):
|
|||||||
)
|
)
|
||||||
def logout(request: Request):
|
def logout(request: Request):
|
||||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||||
response = RedirectResponse("/login", status_code=303)
|
base_path = request.headers.get("x-ingress-path", "").rstrip("/")
|
||||||
|
response = RedirectResponse(f"{base_path}/login", status_code=303)
|
||||||
response.delete_cookie(auth_config.cookie_name)
|
response.delete_cookie(auth_config.cookie_name)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from frigate.comms.base_communicator import Communicator
|
|||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.auth import AuthConfig
|
from frigate.config.auth import AuthConfig
|
||||||
|
from frigate.config.camera.notification import NOTIFICATION_TTL_SECONDS
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateSubscriber,
|
CameraConfigUpdateSubscriber,
|
||||||
@@ -623,7 +624,7 @@ class WebPushClient(Communicator):
|
|||||||
|
|
||||||
# if event is ongoing open to live view otherwise open to recordings view
|
# if event is ongoing open to live view otherwise open to recordings view
|
||||||
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
|
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
|
||||||
ttl = 3600 if ended else 0
|
ttl = 3600 if ended else NOTIFICATION_TTL_SECONDS[self.config.notifications.ttl]
|
||||||
|
|
||||||
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from frigate.util.env import EnvString
|
from frigate.util.env import EnvString
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
|
|
||||||
__all__ = ["NotificationConfig"]
|
__all__ = ["NotificationConfig", "NotificationTTLEnum", "NOTIFICATION_TTL_SECONDS"]
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTTLEnum(str, Enum):
|
||||||
|
immediate = "immediate"
|
||||||
|
reliable = "reliable"
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFICATION_TTL_SECONDS = {
|
||||||
|
NotificationTTLEnum.immediate: 0,
|
||||||
|
NotificationTTLEnum.reliable: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class NotificationConfig(FrigateBaseModel):
|
class NotificationConfig(FrigateBaseModel):
|
||||||
@@ -24,6 +37,11 @@ class NotificationConfig(FrigateBaseModel):
|
|||||||
title="Cooldown period",
|
title="Cooldown period",
|
||||||
description="Cooldown (seconds) between notifications to avoid spamming recipients.",
|
description="Cooldown (seconds) between notifications to avoid spamming recipients.",
|
||||||
)
|
)
|
||||||
|
ttl: NotificationTTLEnum = Field(
|
||||||
|
default=NotificationTTLEnum.immediate,
|
||||||
|
title="Notification TTL",
|
||||||
|
description="How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour.",
|
||||||
|
)
|
||||||
enabled_in_config: bool | None = Field(
|
enabled_in_config: bool | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
title="Original notifications state",
|
title="Original notifications state",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import math
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from titlecase import titlecase
|
||||||
|
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -84,12 +86,16 @@ def event_name(event: dict[str, Any]) -> str:
|
|||||||
tracked object whenever a subject is re-detected, so the tracking data
|
tracked object whenever a subject is re-detected, so the tracking data
|
||||||
cannot say whether two entries are the same subject, and the notes stay
|
cannot say whether two entries are the same subject, and the notes stay
|
||||||
ambiguous rather than implying either answer.
|
ambiguous rather than implying either answer.
|
||||||
|
|
||||||
|
Names are formatted the same way as in the prompt's "Objects in Scene"
|
||||||
|
list. Small models otherwise copy the raw lowercase sub label into their
|
||||||
|
response instead of the name they were told to use.
|
||||||
"""
|
"""
|
||||||
label = str(event["label"]).replace("_", " ").replace("-verified", "")
|
label = str(event["label"]).replace("_", " ").replace("-verified", "")
|
||||||
sub_label = event.get("sub_label")
|
sub_label = event.get("sub_label")
|
||||||
|
|
||||||
if sub_label:
|
if sub_label:
|
||||||
return f'{label} "{sub_label}"'
|
return f'{label} "{titlecase(str(sub_label).replace("_", " "))}"'
|
||||||
|
|
||||||
article = "an" if label[:1].lower() in "aeiou" else "a"
|
article = "an" if label[:1].lower() in "aeiou" else "a"
|
||||||
return f"{article} {label}"
|
return f"{article} {label}"
|
||||||
@@ -213,6 +219,7 @@ def build_timeline(
|
|||||||
events: list[dict[str, Any]],
|
events: list[dict[str, Any]],
|
||||||
span_end: float,
|
span_end: float,
|
||||||
state_changes: Sequence[dict[str, Any]] = (),
|
state_changes: Sequence[dict[str, Any]] = (),
|
||||||
|
span_start: float | None = None,
|
||||||
) -> list[tuple[float, str]]:
|
) -> list[tuple[float, str]]:
|
||||||
"""All annotated moments across every event, in time order.
|
"""All annotated moments across every event, in time order.
|
||||||
|
|
||||||
@@ -222,6 +229,12 @@ def build_timeline(
|
|||||||
describe nothing the model can see. A track ending means the object
|
describe nothing the model can see. A track ending means the object
|
||||||
stopped being detected, which may or may not mean it left the frame.
|
stopped being detected, which may or may not mean it left the frame.
|
||||||
|
|
||||||
|
`span_start` is the timestamp of the first sampled frame. An object
|
||||||
|
tracked since before it gets a single "already in view" note instead of
|
||||||
|
its earlier history: those moments would otherwise land on the first
|
||||||
|
frame and read as happening during the clip, turning a car that parked
|
||||||
|
minutes ago into one pulling in.
|
||||||
|
|
||||||
`state_changes` are timeline rows (timestamp, source_id, class_type); the
|
`state_changes` are timeline rows (timestamp, source_id, class_type); the
|
||||||
stationary and active ones become "has stopped moving" / "starts moving
|
stationary and active ones become "has stopped moving" / "starts moving
|
||||||
again". Frigate only marks an object stationary after it has been still
|
again". Frigate only marks an object stationary after it has been still
|
||||||
@@ -249,6 +262,13 @@ def build_timeline(
|
|||||||
if event["start_time"] > span_end:
|
if event["start_time"] > span_end:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
span_start is not None
|
||||||
|
and event["end_time"]
|
||||||
|
and event["end_time"] < span_start
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
points = path_points(event.get("path_data") or [])
|
points = path_points(event.get("path_data") or [])
|
||||||
legs = path_legs(points) if len(points) >= 2 else []
|
legs = path_legs(points) if len(points) >= 2 else []
|
||||||
name = event_name(event)
|
name = event_name(event)
|
||||||
@@ -259,8 +279,33 @@ def build_timeline(
|
|||||||
)
|
)
|
||||||
remaining = list(enumerate(legs))
|
remaining = list(enumerate(legs))
|
||||||
moments: list[tuple[float, str]] = []
|
moments: list[tuple[float, str]] = []
|
||||||
|
event_changes = sorted(
|
||||||
|
(timestamp, phrase)
|
||||||
|
for timestamp, phrase in changes_by_event.get(event["id"], [])
|
||||||
|
if timestamp >= detected_at
|
||||||
|
)
|
||||||
|
|
||||||
if merges:
|
if span_start is not None and detected_at < span_start:
|
||||||
|
# Only whether it had come to rest is noted; where it is and which
|
||||||
|
# way it is heading are visible in the first frame.
|
||||||
|
earlier = [phrase for t, phrase in event_changes if t < span_start]
|
||||||
|
not_moving = (
|
||||||
|
bool(earlier) and earlier[-1] == STATE_CHANGE_PHRASES["stationary"]
|
||||||
|
)
|
||||||
|
timeline.append(
|
||||||
|
(
|
||||||
|
span_start,
|
||||||
|
f"{name} already in view when the sequence begins"
|
||||||
|
+ (", not moving" if not_moving else ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
remaining = [
|
||||||
|
(index, leg)
|
||||||
|
for index, leg in remaining
|
||||||
|
if leg_start_time(points, leg) >= span_start
|
||||||
|
]
|
||||||
|
event_changes = [(t, p) for t, p in event_changes if t >= span_start]
|
||||||
|
elif merges:
|
||||||
heading = leg_heading(points, legs[0])
|
heading = leg_heading(points, legs[0])
|
||||||
timeline.append(
|
timeline.append(
|
||||||
(detected_at, f"{name} first detected at {where}, moving {heading}")
|
(detected_at, f"{name} first detected at {where}, moving {heading}")
|
||||||
@@ -277,11 +322,7 @@ def build_timeline(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
moments.extend(
|
moments.extend(event_changes)
|
||||||
(timestamp, phrase)
|
|
||||||
for timestamp, phrase in changes_by_event.get(event["id"], [])
|
|
||||||
if timestamp >= detected_at
|
|
||||||
)
|
|
||||||
|
|
||||||
for timestamp, phrase in moments:
|
for timestamp, phrase in moments:
|
||||||
if timestamp <= span_end:
|
if timestamp <= span_end:
|
||||||
@@ -390,7 +431,10 @@ def build_frame_captions(
|
|||||||
timeline.extend(
|
timeline.extend(
|
||||||
(timestamp, f"[tracker] {note}")
|
(timestamp, f"[tracker] {note}")
|
||||||
for timestamp, note in build_timeline(
|
for timestamp, note in build_timeline(
|
||||||
events, span_end, get_state_changes(detection_ids)
|
events,
|
||||||
|
span_end,
|
||||||
|
get_state_changes(detection_ids),
|
||||||
|
span_start=frame_times[0],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
|
||||||
|
from frigate.util.model import xyxy_to_xywh_for_nms
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tflite_runtime.interpreter import Interpreter, load_delegate
|
from tflite_runtime.interpreter import Interpreter, load_delegate
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
@@ -12,39 +17,324 @@ except ModuleNotFoundError:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def tflite_init(self, interpreter):
|
class TFLiteDetectionModel:
|
||||||
self.interpreter = interpreter
|
"""Object detection pre and post processing for a TFLite interpreter.
|
||||||
|
|
||||||
self.interpreter.allocate_tensors()
|
Every TFLite detector shares this, differing only in how it builds the
|
||||||
|
interpreter (on the CPU, or with the EdgeTPU or Teflon delegate).
|
||||||
|
"""
|
||||||
|
|
||||||
self.tensor_input_details = self.interpreter.get_input_details()
|
supported_models = [
|
||||||
self.tensor_output_details = self.interpreter.get_output_details()
|
ModelTypeEnum.ssd,
|
||||||
|
ModelTypeEnum.yologeneric,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, interpreter: Interpreter, model_config: ModelConfig, detector_key: str
|
||||||
|
):
|
||||||
|
"""Prepare the interpreter and work out the model's output layout.
|
||||||
|
|
||||||
def tflite_detect_raw(self, tensor_input):
|
Args:
|
||||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
interpreter: The TFLite interpreter, with any delegate loaded
|
||||||
self.interpreter.invoke()
|
model_config: The model being run
|
||||||
|
detector_key: The detector's type, for error messages
|
||||||
|
"""
|
||||||
|
self.model_type = model_config.model_type
|
||||||
|
|
||||||
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
|
if self.model_type not in self.supported_models:
|
||||||
class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0]
|
raise ValueError(
|
||||||
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0]
|
f"{self.model_type.value} is currently not supported for {detector_key}. See the docs for more info on supported models."
|
||||||
count = int(self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0])
|
)
|
||||||
|
|
||||||
detections = np.zeros((20, 6), np.float32)
|
self.interpreter = interpreter
|
||||||
|
|
||||||
for i in range(count):
|
try:
|
||||||
if scores[i] < 0.4 or i == 20:
|
self.interpreter.allocate_tensors()
|
||||||
break
|
except RuntimeError as e:
|
||||||
detections[i] = [
|
# an EdgeTPU compiled model wraps its graph in a custom op that only
|
||||||
class_ids[i],
|
# the EdgeTPU delegate can run
|
||||||
float(scores[i]),
|
if "edgetpu-custom-op" in str(e):
|
||||||
boxes[i][0],
|
logger.error(
|
||||||
boxes[i][1],
|
"%s is compiled for the EdgeTPU and can only be run by an edgetpu detector",
|
||||||
boxes[i][2],
|
model_config.path,
|
||||||
boxes[i][3],
|
)
|
||||||
]
|
raise
|
||||||
|
|
||||||
return detections
|
self.tensor_input_details = self.interpreter.get_input_details()
|
||||||
|
self.tensor_output_details = self.interpreter.get_output_details()
|
||||||
|
self.model_width = model_config.width
|
||||||
|
self.model_height = model_config.height
|
||||||
|
|
||||||
|
self.min_score = 0.4
|
||||||
|
self.max_detections = 20
|
||||||
|
|
||||||
|
self.model_requires_int8 = self.tensor_input_details[0]["dtype"] == np.int8
|
||||||
|
|
||||||
|
if self.model_type == ModelTypeEnum.yologeneric:
|
||||||
|
logger.debug("Using YOLO preprocessing/postprocessing")
|
||||||
|
|
||||||
|
if len(self.tensor_output_details) not in [2, 3]:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid count of output tensors in YOLO model. Found {len(self.tensor_output_details)}, expecting 2 or 3."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.reg_max = 16 # = 64 dfl_channels // 4 # YOLO standard
|
||||||
|
self.min_logit_value = np.log(
|
||||||
|
self.min_score / (1 - self.min_score)
|
||||||
|
) # for filtering
|
||||||
|
self._generate_anchors_and_strides() # decode bounding box DFL
|
||||||
|
self.project = np.arange(
|
||||||
|
self.reg_max, dtype=np.float32
|
||||||
|
) # for decoding bounding box DFL information
|
||||||
|
|
||||||
|
# Determine YOLO tensor indices and quantization scales for
|
||||||
|
# boxes and class_scores the tensor ordering and names are
|
||||||
|
# not reliable, so use tensor shape to detect which tensor
|
||||||
|
# holds boxes or class scores.
|
||||||
|
# The tensors have shapes (B, N, C)
|
||||||
|
# where N is the number of candidates (=2100 for 320x320)
|
||||||
|
# this may guess wrong if the number of classes is exactly 64
|
||||||
|
output_boxes_index = None
|
||||||
|
output_classes_index = None
|
||||||
|
for i, x in enumerate(self.tensor_output_details):
|
||||||
|
# the nominal index seems to start at 1 instead of 0
|
||||||
|
if len(x["shape"]) == 3 and x["shape"][2] == 64:
|
||||||
|
output_boxes_index = i
|
||||||
|
elif len(x["shape"]) == 3 and x["shape"][2] > 1:
|
||||||
|
# require the number of classes to be more than 1
|
||||||
|
# to differentiate from (not used) max score tensor
|
||||||
|
output_classes_index = i
|
||||||
|
if output_boxes_index is None or output_classes_index is None:
|
||||||
|
logger.warning("Unrecognized model output, unexpected tensor shapes.")
|
||||||
|
output_classes_index = (
|
||||||
|
0
|
||||||
|
if (output_boxes_index is None or output_classes_index == 1)
|
||||||
|
else 1
|
||||||
|
) # 0 is default guess
|
||||||
|
output_boxes_index = 1 if (output_boxes_index == 0) else 0
|
||||||
|
|
||||||
|
scores_details = self.tensor_output_details[output_classes_index]
|
||||||
|
boxes_details = self.tensor_output_details[output_boxes_index]
|
||||||
|
|
||||||
|
# post processing works on the raw quantized values, which a float
|
||||||
|
# model doesn't have
|
||||||
|
if np.issubdtype(scores_details["dtype"], np.floating) or np.issubdtype(
|
||||||
|
boxes_details["dtype"], np.floating
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{model_config.path} has float outputs. YOLO models for TFLite must be int8 quantized."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.scores_tensor_index = scores_details["index"]
|
||||||
|
self.scores_scale, self.scores_zero_point = scores_details["quantization"]
|
||||||
|
# calculate the quantized version of the min_score
|
||||||
|
self.min_score_quantized = int(
|
||||||
|
(self.min_logit_value / self.scores_scale) + self.scores_zero_point
|
||||||
|
)
|
||||||
|
self.logit_shift_to_positive_values = (
|
||||||
|
max(0, math.ceil((128 + self.scores_zero_point) * self.scores_scale))
|
||||||
|
+ 1
|
||||||
|
) # round up
|
||||||
|
|
||||||
|
self.boxes_tensor_index = boxes_details["index"]
|
||||||
|
self.boxes_scale, self.boxes_zero_point = boxes_details["quantization"]
|
||||||
|
|
||||||
|
elif self.model_type == ModelTypeEnum.ssd:
|
||||||
|
logger.debug("Using SSD preprocessing/postprocessing")
|
||||||
|
|
||||||
|
# SSD models end in TFLite_Detection_PostProcess, which outputs
|
||||||
|
# boxes, class ids, scores and count
|
||||||
|
if len(self.tensor_output_details) < 4:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid count of output tensors in SSD model. Found {len(self.tensor_output_details)}, expecting 4. YOLO models need a model_type of yolo-generic."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_anchors_and_strides(self):
|
||||||
|
# for decoding the bounding box DFL information into xy coordinates
|
||||||
|
all_anchors = []
|
||||||
|
all_strides = []
|
||||||
|
strides = (8, 16, 32) # YOLO's small, medium, large detection heads
|
||||||
|
|
||||||
|
for stride in strides:
|
||||||
|
feat_h, feat_w = self.model_height // stride, self.model_width // stride
|
||||||
|
|
||||||
|
grid_y, grid_x = np.meshgrid(
|
||||||
|
np.arange(feat_h, dtype=np.float32),
|
||||||
|
np.arange(feat_w, dtype=np.float32),
|
||||||
|
indexing="ij",
|
||||||
|
)
|
||||||
|
|
||||||
|
grid_coords = np.stack((grid_x.flatten(), grid_y.flatten()), axis=1)
|
||||||
|
anchor_points = grid_coords + 0.5
|
||||||
|
|
||||||
|
all_anchors.append(anchor_points)
|
||||||
|
all_strides.append(np.full((feat_h * feat_w, 1), stride, dtype=np.float32))
|
||||||
|
|
||||||
|
self.anchors = np.concatenate(all_anchors, axis=0)
|
||||||
|
self.anchor_strides = np.concatenate(all_strides, axis=0)
|
||||||
|
|
||||||
|
def pre_process(self, tensor_input):
|
||||||
|
if self.model_requires_int8:
|
||||||
|
tensor_input = np.bitwise_xor(tensor_input, 128).view(
|
||||||
|
np.int8
|
||||||
|
) # shift by -128
|
||||||
|
return tensor_input
|
||||||
|
|
||||||
|
def detect_raw(self, tensor_input):
|
||||||
|
"""Run the model on a frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tensor_input: The frame, already resized to the model's input
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Up to 20 detections as [class_id, score, ymin, xmin, ymax, xmax],
|
||||||
|
padded with zeros
|
||||||
|
"""
|
||||||
|
tensor_input = self.pre_process(tensor_input)
|
||||||
|
|
||||||
|
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
||||||
|
self.interpreter.invoke()
|
||||||
|
|
||||||
|
if self.model_type == ModelTypeEnum.yologeneric:
|
||||||
|
return self._post_process_yolo()
|
||||||
|
|
||||||
|
return self._post_process_ssd()
|
||||||
|
|
||||||
|
def _post_process_yolo(self):
|
||||||
|
# Multi-tensor YOLO model with (non-standard B(H*W)C output format).
|
||||||
|
# (the comments indicate the shape of tensors,
|
||||||
|
# using "2100" as the anchor count (for image size of 320x320),
|
||||||
|
# "NC" as number of classes,
|
||||||
|
# "N" as the count that survive after min-score filtering)
|
||||||
|
# TENSOR A) class scores (1, 2100, NC) with logit values
|
||||||
|
# TENSOR B) box coordinates (1, 2100, 64) encoded as dfl scores
|
||||||
|
# Recommend that the model clamp the logit values in tensor (A)
|
||||||
|
# to the range [-4,+4] to preserve precision from [2%,98%]
|
||||||
|
# and because NMS requires the min_score parameter to be >= 0
|
||||||
|
|
||||||
|
# don't dequantize scores data yet, wait until the low-confidence
|
||||||
|
# candidates are filtered out from the overall result set.
|
||||||
|
# This reduces the work and makes post-processing faster.
|
||||||
|
# this method works with raw quantized numbers when possible,
|
||||||
|
# which relies on the value of the scale factor to be >0.
|
||||||
|
# This speeds up max and argmax operations.
|
||||||
|
# Get max confidence for each detection and create the mask
|
||||||
|
detections = np.zeros(
|
||||||
|
(self.max_detections, 6), np.float32
|
||||||
|
) # initialize zero results
|
||||||
|
scores_output_quantized = self.interpreter.get_tensor(self.scores_tensor_index)[
|
||||||
|
0
|
||||||
|
] # (2100, NC)
|
||||||
|
max_scores_quantized = np.max(scores_output_quantized, axis=1) # (2100,)
|
||||||
|
mask = max_scores_quantized >= self.min_score_quantized # (2100,)
|
||||||
|
|
||||||
|
if not np.any(mask):
|
||||||
|
return detections # empty results
|
||||||
|
|
||||||
|
max_scores_filtered_shiftedpositive = (
|
||||||
|
(max_scores_quantized[mask] - self.scores_zero_point) * self.scores_scale
|
||||||
|
) + self.logit_shift_to_positive_values # (N,1) shifted logit values
|
||||||
|
scores_output_quantized_filtered = scores_output_quantized[mask]
|
||||||
|
|
||||||
|
# dequantize boxes. NMS needs them to be in float format
|
||||||
|
# remove candidates with probabilities < threshold
|
||||||
|
boxes_output_quantized_filtered = (
|
||||||
|
self.interpreter.get_tensor(self.boxes_tensor_index)[0]
|
||||||
|
)[mask] # (N, 64)
|
||||||
|
boxes_output_filtered = (
|
||||||
|
boxes_output_quantized_filtered.astype(np.float32) - self.boxes_zero_point
|
||||||
|
) * self.boxes_scale
|
||||||
|
|
||||||
|
# 2. Decode DFL to distances (ltrb)
|
||||||
|
dfl_distributions = boxes_output_filtered.reshape(
|
||||||
|
-1, 4, self.reg_max
|
||||||
|
) # (N, 4, 16)
|
||||||
|
|
||||||
|
# Softmax over the 16 bins
|
||||||
|
dfl_max = np.max(dfl_distributions, axis=2, keepdims=True)
|
||||||
|
dfl_exp = np.exp(dfl_distributions - dfl_max)
|
||||||
|
dfl_probs = dfl_exp / np.sum(dfl_exp, axis=2, keepdims=True) # (N, 4, 16)
|
||||||
|
|
||||||
|
# Weighted sum: (N, 4, 16) * (16,) -> (N, 4)
|
||||||
|
distances = np.einsum("pcr,r->pc", dfl_probs, self.project)
|
||||||
|
|
||||||
|
# Calculate box corners in pixel coordinates
|
||||||
|
anchors_filtered = self.anchors[mask]
|
||||||
|
anchor_strides_filtered = self.anchor_strides[mask]
|
||||||
|
x1y1 = (
|
||||||
|
anchors_filtered - distances[:, [0, 1]]
|
||||||
|
) * anchor_strides_filtered # (N, 2)
|
||||||
|
x2y2 = (
|
||||||
|
anchors_filtered + distances[:, [2, 3]]
|
||||||
|
) * anchor_strides_filtered # (N, 2)
|
||||||
|
boxes_filtered_decoded = np.concatenate((x1y1, x2y2), axis=-1) # (N, 4)
|
||||||
|
|
||||||
|
# 9. Apply NMS. Use logit scores here to defer sigmoid()
|
||||||
|
# until after filtering out redundant boxes
|
||||||
|
# Shift the logit scores to be non-negative (required by cv2)
|
||||||
|
indices = cv2.dnn.NMSBoxes(
|
||||||
|
bboxes=xyxy_to_xywh_for_nms(boxes_filtered_decoded),
|
||||||
|
scores=max_scores_filtered_shiftedpositive,
|
||||||
|
score_threshold=(
|
||||||
|
self.min_logit_value + self.logit_shift_to_positive_values
|
||||||
|
),
|
||||||
|
nms_threshold=0.4, # should this be a model config setting?
|
||||||
|
)
|
||||||
|
num_detections = len(indices)
|
||||||
|
if num_detections == 0:
|
||||||
|
return detections # empty results
|
||||||
|
|
||||||
|
nms_indices = np.array(indices, dtype=np.int32).ravel() # or .flatten()
|
||||||
|
if num_detections > self.max_detections:
|
||||||
|
nms_indices = nms_indices[: self.max_detections]
|
||||||
|
num_detections = self.max_detections
|
||||||
|
kept_logits_quantized = scores_output_quantized_filtered[nms_indices]
|
||||||
|
class_ids_post_nms = np.argmax(kept_logits_quantized, axis=1)
|
||||||
|
|
||||||
|
# Extract the final boxes and scores using fancy indexing
|
||||||
|
final_boxes = boxes_filtered_decoded[nms_indices]
|
||||||
|
final_scores_logits = (
|
||||||
|
max_scores_filtered_shiftedpositive[nms_indices]
|
||||||
|
- self.logit_shift_to_positive_values
|
||||||
|
) # Unshifted logits
|
||||||
|
|
||||||
|
# Detections array format: [class_id, score, ymin, xmin, ymax, xmax]
|
||||||
|
detections[:num_detections, 0] = class_ids_post_nms
|
||||||
|
detections[:num_detections, 1] = 1.0 / (
|
||||||
|
1.0 + np.exp(-final_scores_logits)
|
||||||
|
) # sigmoid
|
||||||
|
detections[:num_detections, 2] = final_boxes[:, 1] / self.model_height
|
||||||
|
detections[:num_detections, 3] = final_boxes[:, 0] / self.model_width
|
||||||
|
detections[:num_detections, 4] = final_boxes[:, 3] / self.model_height
|
||||||
|
detections[:num_detections, 5] = final_boxes[:, 2] / self.model_width
|
||||||
|
return detections
|
||||||
|
|
||||||
|
def _post_process_ssd(self):
|
||||||
|
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
|
||||||
|
class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0]
|
||||||
|
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0]
|
||||||
|
count = int(
|
||||||
|
self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = np.zeros((self.max_detections, 6), np.float32)
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
if scores[i] < self.min_score:
|
||||||
|
break
|
||||||
|
if i == self.max_detections:
|
||||||
|
logger.debug("Too many detections (%s)", count)
|
||||||
|
break
|
||||||
|
detections[i] = [
|
||||||
|
class_ids[i],
|
||||||
|
float(scores[i]),
|
||||||
|
boxes[i][0],
|
||||||
|
boxes[i][1],
|
||||||
|
boxes[i][2],
|
||||||
|
boxes[i][3],
|
||||||
|
]
|
||||||
|
|
||||||
|
return detections
|
||||||
|
|
||||||
|
|
||||||
def tflite_load_delegate_interpreter(
|
def tflite_load_delegate_interpreter(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from frigate.detectors.detection_api import DetectionApi
|
|||||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||||
from frigate.log import suppress_stderr_during
|
from frigate.log import suppress_stderr_during
|
||||||
|
|
||||||
from ..detector_utils import tflite_detect_raw, tflite_init
|
from ..detector_utils import TFLiteDetectionModel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tflite_runtime.interpreter import Interpreter
|
from tflite_runtime.interpreter import Interpreter
|
||||||
@@ -40,6 +40,7 @@ class CpuDetectorConfig(BaseDetectorConfig):
|
|||||||
|
|
||||||
class CpuTfl(DetectionApi):
|
class CpuTfl(DetectionApi):
|
||||||
type_key = DETECTOR_KEY
|
type_key = DETECTOR_KEY
|
||||||
|
supported_models = TFLiteDetectionModel.supported_models
|
||||||
|
|
||||||
def __init__(self, detector_config: CpuDetectorConfig):
|
def __init__(self, detector_config: CpuDetectorConfig):
|
||||||
# Suppress TFLite delegate creation messages that bypass Python logging
|
# Suppress TFLite delegate creation messages that bypass Python logging
|
||||||
@@ -49,7 +50,9 @@ class CpuTfl(DetectionApi):
|
|||||||
num_threads=detector_config.num_threads or 3,
|
num_threads=detector_config.num_threads or 3,
|
||||||
)
|
)
|
||||||
|
|
||||||
tflite_init(self, interpreter)
|
self.tflite_model = TFLiteDetectionModel(
|
||||||
|
interpreter, detector_config.model, DETECTOR_KEY
|
||||||
|
)
|
||||||
|
|
||||||
def detect_raw(self, tensor_input):
|
def detect_raw(self, tensor_input):
|
||||||
return tflite_detect_raw(self, tensor_input)
|
return self.tflite_model.detect_raw(tensor_input)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
from typing import ClassVar, Literal
|
from typing import ClassVar, Literal
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
from pydantic import ConfigDict, Field
|
from pydantic import ConfigDict, Field
|
||||||
|
|
||||||
from frigate.detectors.detection_api import DetectionApi
|
from frigate.detectors.detection_api import DetectionApi
|
||||||
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||||
from frigate.util.model import xyxy_to_xywh_for_nms
|
|
||||||
|
from ..detector_utils import TFLiteDetectionModel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tflite_runtime.interpreter import Interpreter, load_delegate
|
from tflite_runtime.interpreter import Interpreter, load_delegate
|
||||||
@@ -41,10 +39,7 @@ class EdgeTpuDetectorConfig(BaseDetectorConfig):
|
|||||||
|
|
||||||
class EdgeTpuTfl(DetectionApi):
|
class EdgeTpuTfl(DetectionApi):
|
||||||
type_key = DETECTOR_KEY
|
type_key = DETECTOR_KEY
|
||||||
supported_models = [
|
supported_models = TFLiteDetectionModel.supported_models
|
||||||
ModelTypeEnum.ssd,
|
|
||||||
ModelTypeEnum.yologeneric,
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, detector_config: EdgeTpuDetectorConfig):
|
def __init__(self, detector_config: EdgeTpuDetectorConfig):
|
||||||
device_config = {}
|
device_config = {}
|
||||||
@@ -60,7 +55,7 @@ class EdgeTpuTfl(DetectionApi):
|
|||||||
logger.info(f"Attempting to load TPU as {device_type}")
|
logger.info(f"Attempting to load TPU as {device_type}")
|
||||||
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
|
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
|
||||||
logger.info("TPU found")
|
logger.info("TPU found")
|
||||||
self.interpreter = Interpreter(
|
interpreter = Interpreter(
|
||||||
model_path=detector_config.model.path,
|
model_path=detector_config.model.path,
|
||||||
experimental_delegates=[edge_tpu_delegate],
|
experimental_delegates=[edge_tpu_delegate],
|
||||||
)
|
)
|
||||||
@@ -78,298 +73,9 @@ class EdgeTpuTfl(DetectionApi):
|
|||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self.interpreter.allocate_tensors()
|
self.tflite_model = TFLiteDetectionModel(
|
||||||
|
interpreter, detector_config.model, DETECTOR_KEY
|
||||||
self.tensor_input_details = self.interpreter.get_input_details()
|
)
|
||||||
self.tensor_output_details = self.interpreter.get_output_details()
|
|
||||||
self.model_width = detector_config.model.width
|
|
||||||
self.model_height = detector_config.model.height
|
|
||||||
|
|
||||||
self.min_score = 0.4
|
|
||||||
self.max_detections = 20
|
|
||||||
|
|
||||||
self.model_type = detector_config.model.model_type
|
|
||||||
self.model_requires_int8 = self.tensor_input_details[0]["dtype"] == np.int8
|
|
||||||
|
|
||||||
if self.model_type == ModelTypeEnum.yologeneric:
|
|
||||||
logger.debug("Using YOLO preprocessing/postprocessing")
|
|
||||||
|
|
||||||
if len(self.tensor_output_details) not in [2, 3]:
|
|
||||||
logger.error(
|
|
||||||
f"Invalid count of output tensors in YOLO model. Found {len(self.tensor_output_details)}, expecting 2 or 3."
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
self.reg_max = 16 # = 64 dfl_channels // 4 # YOLO standard
|
|
||||||
self.min_logit_value = np.log(
|
|
||||||
self.min_score / (1 - self.min_score)
|
|
||||||
) # for filtering
|
|
||||||
self._generate_anchors_and_strides() # decode bounding box DFL
|
|
||||||
self.project = np.arange(
|
|
||||||
self.reg_max, dtype=np.float32
|
|
||||||
) # for decoding bounding box DFL information
|
|
||||||
|
|
||||||
# Determine YOLO tensor indices and quantization scales for
|
|
||||||
# boxes and class_scores the tensor ordering and names are
|
|
||||||
# not reliable, so use tensor shape to detect which tensor
|
|
||||||
# holds boxes or class scores.
|
|
||||||
# The tensors have shapes (B, N, C)
|
|
||||||
# where N is the number of candidates (=2100 for 320x320)
|
|
||||||
# this may guess wrong if the number of classes is exactly 64
|
|
||||||
output_boxes_index = None
|
|
||||||
output_classes_index = None
|
|
||||||
for i, x in enumerate(self.tensor_output_details):
|
|
||||||
# the nominal index seems to start at 1 instead of 0
|
|
||||||
if len(x["shape"]) == 3 and x["shape"][2] == 64:
|
|
||||||
output_boxes_index = i
|
|
||||||
elif len(x["shape"]) == 3 and x["shape"][2] > 1:
|
|
||||||
# require the number of classes to be more than 1
|
|
||||||
# to differentiate from (not used) max score tensor
|
|
||||||
output_classes_index = i
|
|
||||||
if output_boxes_index is None or output_classes_index is None:
|
|
||||||
logger.warning("Unrecognized model output, unexpected tensor shapes.")
|
|
||||||
output_classes_index = (
|
|
||||||
0
|
|
||||||
if (output_boxes_index is None or output_classes_index == 1)
|
|
||||||
else 1
|
|
||||||
) # 0 is default guess
|
|
||||||
output_boxes_index = 1 if (output_boxes_index == 0) else 0
|
|
||||||
|
|
||||||
scores_details = self.tensor_output_details[output_classes_index]
|
|
||||||
self.scores_tensor_index = scores_details["index"]
|
|
||||||
self.scores_scale, self.scores_zero_point = scores_details["quantization"]
|
|
||||||
# calculate the quantized version of the min_score
|
|
||||||
self.min_score_quantized = int(
|
|
||||||
(self.min_logit_value / self.scores_scale) + self.scores_zero_point
|
|
||||||
)
|
|
||||||
self.logit_shift_to_positive_values = (
|
|
||||||
max(0, math.ceil((128 + self.scores_zero_point) * self.scores_scale))
|
|
||||||
+ 1
|
|
||||||
) # round up
|
|
||||||
|
|
||||||
boxes_details = self.tensor_output_details[output_boxes_index]
|
|
||||||
self.boxes_tensor_index = boxes_details["index"]
|
|
||||||
self.boxes_scale, self.boxes_zero_point = boxes_details["quantization"]
|
|
||||||
|
|
||||||
elif self.model_type == ModelTypeEnum.ssd:
|
|
||||||
logger.debug("Using SSD preprocessing/postprocessing")
|
|
||||||
|
|
||||||
# SSD model indices (4 outputs: boxes, class_ids, scores, count)
|
|
||||||
for x in self.tensor_output_details:
|
|
||||||
if len(x["shape"]) == 3:
|
|
||||||
self.output_boxes_index = x["index"]
|
|
||||||
elif len(x["shape"]) == 1:
|
|
||||||
self.output_count_index = x["index"]
|
|
||||||
|
|
||||||
self.output_class_ids_index = None
|
|
||||||
self.output_class_scores_index = None
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise Exception(
|
|
||||||
f"{self.model_type} is currently not supported for edgetpu. See the docs for more info on supported models."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _generate_anchors_and_strides(self):
|
|
||||||
# for decoding the bounding box DFL information into xy coordinates
|
|
||||||
all_anchors = []
|
|
||||||
all_strides = []
|
|
||||||
strides = (8, 16, 32) # YOLO's small, medium, large detection heads
|
|
||||||
|
|
||||||
for stride in strides:
|
|
||||||
feat_h, feat_w = self.model_height // stride, self.model_width // stride
|
|
||||||
|
|
||||||
grid_y, grid_x = np.meshgrid(
|
|
||||||
np.arange(feat_h, dtype=np.float32),
|
|
||||||
np.arange(feat_w, dtype=np.float32),
|
|
||||||
indexing="ij",
|
|
||||||
)
|
|
||||||
|
|
||||||
grid_coords = np.stack((grid_x.flatten(), grid_y.flatten()), axis=1)
|
|
||||||
anchor_points = grid_coords + 0.5
|
|
||||||
|
|
||||||
all_anchors.append(anchor_points)
|
|
||||||
all_strides.append(np.full((feat_h * feat_w, 1), stride, dtype=np.float32))
|
|
||||||
|
|
||||||
self.anchors = np.concatenate(all_anchors, axis=0)
|
|
||||||
self.anchor_strides = np.concatenate(all_strides, axis=0)
|
|
||||||
|
|
||||||
def determine_indexes_for_non_yolo_models(self):
|
|
||||||
"""Legacy method for SSD models."""
|
|
||||||
if (
|
|
||||||
self.output_class_ids_index is None
|
|
||||||
or self.output_class_scores_index is None
|
|
||||||
):
|
|
||||||
for i in range(4):
|
|
||||||
index = self.tensor_output_details[i]["index"]
|
|
||||||
if (
|
|
||||||
index != self.output_boxes_index
|
|
||||||
and index != self.output_count_index
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
np.mod(np.float32(self.interpreter.tensor(index)()[0][0]), 1)
|
|
||||||
== 0.0
|
|
||||||
):
|
|
||||||
self.output_class_ids_index = index
|
|
||||||
else:
|
|
||||||
self.output_scores_index = index
|
|
||||||
|
|
||||||
def pre_process(self, tensor_input):
|
|
||||||
if self.model_requires_int8:
|
|
||||||
tensor_input = np.bitwise_xor(tensor_input, 128).view(
|
|
||||||
np.int8
|
|
||||||
) # shift by -128
|
|
||||||
return tensor_input
|
|
||||||
|
|
||||||
def detect_raw(self, tensor_input):
|
def detect_raw(self, tensor_input):
|
||||||
tensor_input = self.pre_process(tensor_input)
|
return self.tflite_model.detect_raw(tensor_input)
|
||||||
|
|
||||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
|
||||||
self.interpreter.invoke()
|
|
||||||
|
|
||||||
if self.model_type == ModelTypeEnum.yologeneric:
|
|
||||||
# Multi-tensor YOLO model with (non-standard B(H*W)C output format).
|
|
||||||
# (the comments indicate the shape of tensors,
|
|
||||||
# using "2100" as the anchor count (for image size of 320x320),
|
|
||||||
# "NC" as number of classes,
|
|
||||||
# "N" as the count that survive after min-score filtering)
|
|
||||||
# TENSOR A) class scores (1, 2100, NC) with logit values
|
|
||||||
# TENSOR B) box coordinates (1, 2100, 64) encoded as dfl scores
|
|
||||||
# Recommend that the model clamp the logit values in tensor (A)
|
|
||||||
# to the range [-4,+4] to preserve precision from [2%,98%]
|
|
||||||
# and because NMS requires the min_score parameter to be >= 0
|
|
||||||
|
|
||||||
# don't dequantize scores data yet, wait until the low-confidence
|
|
||||||
# candidates are filtered out from the overall result set.
|
|
||||||
# This reduces the work and makes post-processing faster.
|
|
||||||
# this method works with raw quantized numbers when possible,
|
|
||||||
# which relies on the value of the scale factor to be >0.
|
|
||||||
# This speeds up max and argmax operations.
|
|
||||||
# Get max confidence for each detection and create the mask
|
|
||||||
detections = np.zeros(
|
|
||||||
(self.max_detections, 6), np.float32
|
|
||||||
) # initialize zero results
|
|
||||||
scores_output_quantized = self.interpreter.get_tensor(
|
|
||||||
self.scores_tensor_index
|
|
||||||
)[0] # (2100, NC)
|
|
||||||
max_scores_quantized = np.max(scores_output_quantized, axis=1) # (2100,)
|
|
||||||
mask = max_scores_quantized >= self.min_score_quantized # (2100,)
|
|
||||||
|
|
||||||
if not np.any(mask):
|
|
||||||
return detections # empty results
|
|
||||||
|
|
||||||
max_scores_filtered_shiftedpositive = (
|
|
||||||
(max_scores_quantized[mask] - self.scores_zero_point)
|
|
||||||
* self.scores_scale
|
|
||||||
) + self.logit_shift_to_positive_values # (N,1) shifted logit values
|
|
||||||
scores_output_quantized_filtered = scores_output_quantized[mask]
|
|
||||||
|
|
||||||
# dequantize boxes. NMS needs them to be in float format
|
|
||||||
# remove candidates with probabilities < threshold
|
|
||||||
boxes_output_quantized_filtered = (
|
|
||||||
self.interpreter.get_tensor(self.boxes_tensor_index)[0]
|
|
||||||
)[mask] # (N, 64)
|
|
||||||
boxes_output_filtered = (
|
|
||||||
boxes_output_quantized_filtered.astype(np.float32)
|
|
||||||
- self.boxes_zero_point
|
|
||||||
) * self.boxes_scale
|
|
||||||
|
|
||||||
# 2. Decode DFL to distances (ltrb)
|
|
||||||
dfl_distributions = boxes_output_filtered.reshape(
|
|
||||||
-1, 4, self.reg_max
|
|
||||||
) # (N, 4, 16)
|
|
||||||
|
|
||||||
# Softmax over the 16 bins
|
|
||||||
dfl_max = np.max(dfl_distributions, axis=2, keepdims=True)
|
|
||||||
dfl_exp = np.exp(dfl_distributions - dfl_max)
|
|
||||||
dfl_probs = dfl_exp / np.sum(dfl_exp, axis=2, keepdims=True) # (N, 4, 16)
|
|
||||||
|
|
||||||
# Weighted sum: (N, 4, 16) * (16,) -> (N, 4)
|
|
||||||
distances = np.einsum("pcr,r->pc", dfl_probs, self.project)
|
|
||||||
|
|
||||||
# Calculate box corners in pixel coordinates
|
|
||||||
anchors_filtered = self.anchors[mask]
|
|
||||||
anchor_strides_filtered = self.anchor_strides[mask]
|
|
||||||
x1y1 = (
|
|
||||||
anchors_filtered - distances[:, [0, 1]]
|
|
||||||
) * anchor_strides_filtered # (N, 2)
|
|
||||||
x2y2 = (
|
|
||||||
anchors_filtered + distances[:, [2, 3]]
|
|
||||||
) * anchor_strides_filtered # (N, 2)
|
|
||||||
boxes_filtered_decoded = np.concatenate((x1y1, x2y2), axis=-1) # (N, 4)
|
|
||||||
|
|
||||||
# 9. Apply NMS. Use logit scores here to defer sigmoid()
|
|
||||||
# until after filtering out redundant boxes
|
|
||||||
# Shift the logit scores to be non-negative (required by cv2)
|
|
||||||
indices = cv2.dnn.NMSBoxes(
|
|
||||||
bboxes=xyxy_to_xywh_for_nms(boxes_filtered_decoded),
|
|
||||||
scores=max_scores_filtered_shiftedpositive,
|
|
||||||
score_threshold=(
|
|
||||||
self.min_logit_value + self.logit_shift_to_positive_values
|
|
||||||
),
|
|
||||||
nms_threshold=0.4, # should this be a model config setting?
|
|
||||||
)
|
|
||||||
num_detections = len(indices)
|
|
||||||
if num_detections == 0:
|
|
||||||
return detections # empty results
|
|
||||||
|
|
||||||
nms_indices = np.array(indices, dtype=np.int32).ravel() # or .flatten()
|
|
||||||
if num_detections > self.max_detections:
|
|
||||||
nms_indices = nms_indices[: self.max_detections]
|
|
||||||
num_detections = self.max_detections
|
|
||||||
kept_logits_quantized = scores_output_quantized_filtered[nms_indices]
|
|
||||||
class_ids_post_nms = np.argmax(kept_logits_quantized, axis=1)
|
|
||||||
|
|
||||||
# Extract the final boxes and scores using fancy indexing
|
|
||||||
final_boxes = boxes_filtered_decoded[nms_indices]
|
|
||||||
final_scores_logits = (
|
|
||||||
max_scores_filtered_shiftedpositive[nms_indices]
|
|
||||||
- self.logit_shift_to_positive_values
|
|
||||||
) # Unshifted logits
|
|
||||||
|
|
||||||
# Detections array format: [class_id, score, ymin, xmin, ymax, xmax]
|
|
||||||
detections[:num_detections, 0] = class_ids_post_nms
|
|
||||||
detections[:num_detections, 1] = 1.0 / (
|
|
||||||
1.0 + np.exp(-final_scores_logits)
|
|
||||||
) # sigmoid
|
|
||||||
detections[:num_detections, 2] = final_boxes[:, 1] / self.model_height
|
|
||||||
detections[:num_detections, 3] = final_boxes[:, 0] / self.model_width
|
|
||||||
detections[:num_detections, 4] = final_boxes[:, 3] / self.model_height
|
|
||||||
detections[:num_detections, 5] = final_boxes[:, 2] / self.model_width
|
|
||||||
return detections
|
|
||||||
|
|
||||||
elif self.model_type == ModelTypeEnum.ssd:
|
|
||||||
self.determine_indexes_for_non_yolo_models()
|
|
||||||
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
|
|
||||||
class_ids = self.interpreter.tensor(
|
|
||||||
self.tensor_output_details[1]["index"]
|
|
||||||
)()[0]
|
|
||||||
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[
|
|
||||||
0
|
|
||||||
]
|
|
||||||
count = int(
|
|
||||||
self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
detections = np.zeros((self.max_detections, 6), np.float32)
|
|
||||||
|
|
||||||
for i in range(count):
|
|
||||||
if scores[i] < self.min_score:
|
|
||||||
break
|
|
||||||
if i == self.max_detections:
|
|
||||||
logger.debug(f"Too many detections ({count})!")
|
|
||||||
break
|
|
||||||
detections[i] = [
|
|
||||||
class_ids[i],
|
|
||||||
float(scores[i]),
|
|
||||||
boxes[i][0],
|
|
||||||
boxes[i][1],
|
|
||||||
boxes[i][2],
|
|
||||||
boxes[i][3],
|
|
||||||
]
|
|
||||||
|
|
||||||
return detections
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise Exception(
|
|
||||||
f"{self.model_type} is currently not supported for edgetpu. See the docs for more info on supported models."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ from frigate.detectors.detection_api import DetectionApi
|
|||||||
from frigate.detectors.detector_config import BaseDetectorConfig
|
from frigate.detectors.detector_config import BaseDetectorConfig
|
||||||
|
|
||||||
from ..detector_utils import (
|
from ..detector_utils import (
|
||||||
tflite_detect_raw,
|
TFLiteDetectionModel,
|
||||||
tflite_init,
|
|
||||||
tflite_load_delegate_interpreter,
|
tflite_load_delegate_interpreter,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +29,7 @@ class TeflonDetectorConfig(BaseDetectorConfig):
|
|||||||
|
|
||||||
class TeflonTfl(DetectionApi):
|
class TeflonTfl(DetectionApi):
|
||||||
type_key = DETECTOR_KEY
|
type_key = DETECTOR_KEY
|
||||||
|
supported_models = TFLiteDetectionModel.supported_models
|
||||||
|
|
||||||
def __init__(self, detector_config: TeflonDetectorConfig):
|
def __init__(self, detector_config: TeflonDetectorConfig):
|
||||||
# Location in Debian's mesa-teflon-delegate
|
# Location in Debian's mesa-teflon-delegate
|
||||||
@@ -39,7 +39,9 @@ class TeflonTfl(DetectionApi):
|
|||||||
interpreter = tflite_load_delegate_interpreter(
|
interpreter = tflite_load_delegate_interpreter(
|
||||||
delegate_library, detector_config, device_config
|
delegate_library, detector_config, device_config
|
||||||
)
|
)
|
||||||
tflite_init(self, interpreter)
|
self.tflite_model = TFLiteDetectionModel(
|
||||||
|
interpreter, detector_config.model, DETECTOR_KEY
|
||||||
|
)
|
||||||
|
|
||||||
def detect_raw(self, tensor_input):
|
def detect_raw(self, tensor_input):
|
||||||
return tflite_detect_raw(self, tensor_input)
|
return self.tflite_model.detect_raw(tensor_input)
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ class AudioProcessor(FrigateProcess):
|
|||||||
[
|
[
|
||||||
CameraConfigUpdateEnum.add,
|
CameraConfigUpdateEnum.add,
|
||||||
CameraConfigUpdateEnum.audio,
|
CameraConfigUpdateEnum.audio,
|
||||||
|
CameraConfigUpdateEnum.enabled,
|
||||||
CameraConfigUpdateEnum.ffmpeg,
|
CameraConfigUpdateEnum.ffmpeg,
|
||||||
CameraConfigUpdateEnum.remove,
|
CameraConfigUpdateEnum.remove,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class EventCleanup(threading.Thread):
|
|||||||
Event.start_time < expire_after,
|
Event.start_time < expire_after,
|
||||||
Event.label == event.label,
|
Event.label == event.label,
|
||||||
Event.retain_indefinitely == False,
|
Event.retain_indefinitely == False,
|
||||||
|
Event.has_snapshot == True,
|
||||||
)
|
)
|
||||||
.namedtuples()
|
.namedtuples()
|
||||||
.iterator()
|
.iterator()
|
||||||
@@ -114,6 +115,7 @@ class EventCleanup(threading.Thread):
|
|||||||
Event.start_time < expire_after,
|
Event.start_time < expire_after,
|
||||||
Event.label == event.label,
|
Event.label == event.label,
|
||||||
Event.retain_indefinitely == False,
|
Event.retain_indefinitely == False,
|
||||||
|
Event.has_snapshot == True,
|
||||||
)
|
)
|
||||||
|
|
||||||
events_to_update: list[str] = []
|
events_to_update: list[str] = []
|
||||||
@@ -169,6 +171,7 @@ class EventCleanup(threading.Thread):
|
|||||||
Event.start_time < expire_after,
|
Event.start_time < expire_after,
|
||||||
Event.label == event.label,
|
Event.label == event.label,
|
||||||
Event.retain_indefinitely == False,
|
Event.retain_indefinitely == False,
|
||||||
|
Event.has_snapshot == True,
|
||||||
)
|
)
|
||||||
.namedtuples()
|
.namedtuples()
|
||||||
.iterator()
|
.iterator()
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ class GeminiClient(GenAIClient):
|
|||||||
|
|
||||||
response = self.provider.models.generate_content(
|
response = self.provider.models.generate_content(
|
||||||
model=self.genai_config.model,
|
model=self.genai_config.model,
|
||||||
contents=gemini_messages, # type: ignore[arg-type]
|
contents=gemini_messages,
|
||||||
config=types.GenerateContentConfig(**config_params),
|
config=types.GenerateContentConfig(**config_params),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -648,7 +648,7 @@ class GeminiClient(GenAIClient):
|
|||||||
|
|
||||||
stream = await self.provider.aio.models.generate_content_stream(
|
stream = await self.provider.aio.models.generate_content_stream(
|
||||||
model=self.genai_config.model,
|
model=self.genai_config.model,
|
||||||
contents=gemini_messages, # type: ignore[arg-type]
|
contents=gemini_messages,
|
||||||
config=types.GenerateContentConfig(**config_params),
|
config=types.GenerateContentConfig(**config_params),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+20
-11
@@ -18,11 +18,17 @@ from frigate.models import Event
|
|||||||
|
|
||||||
# Base guidance per response field. `observations` is a reasoning scaffold,
|
# Base guidance per response field. `observations` is a reasoning scaffold,
|
||||||
# not user-facing, so style presets never override it.
|
# not user-facing, so style presets never override it.
|
||||||
|
#
|
||||||
|
# Every scene and shortSummary guidance states its length. The response
|
||||||
|
# schema caps both fields, and grammar-constrained providers such as llama.cpp
|
||||||
|
# enforce the cap by cutting the text off, so a model that is never told the
|
||||||
|
# limit ends mid-sentence. Every user-facing field also repeats the name rule:
|
||||||
|
# models follow per-field guidance far more reliably than a single global rule.
|
||||||
REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
|
REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
|
||||||
"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.",
|
"observations": "List what happens in chronological order, starting from the first frame. Include each arrival, departure, object handled, and notable change in position or state, including brief moments at the start or end of the sequence that a longer activity could overshadow. 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.',
|
"scene": 'Describe how the sequence begins, then the progression of events: all significant movements and actions in order. 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. Use no more than five sentences.',
|
||||||
"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.",
|
"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.",
|
"shortSummary": "Briefly summarize the primary activity across the observations in one sentence. For named subjects, always use their name.",
|
||||||
"potential_threat_level": "Must be consistent with your scene description and the activity patterns above.",
|
"potential_threat_level": "Must be consistent with your scene description and the activity patterns above.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +37,19 @@ REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
|
|||||||
# competing style instructions.
|
# competing style instructions.
|
||||||
REVIEW_RESPONSE_STYLES: dict[str, dict[str, str]] = {
|
REVIEW_RESPONSE_STYLES: dict[str, dict[str, str]] = {
|
||||||
"natural": {
|
"natural": {
|
||||||
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign.',
|
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign. Use no more than five sentences.',
|
||||||
"title": 'Write the title as a short, sentence-case headline in present tense: the subject, then what they do, phrased the way you would text it to the homeowner. Describe only the action you see; do not assign the person a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
|
"title": 'Write the title as a short, sentence-case headline in present tense: the subject, then what they do, phrased the way you would text it to the homeowner. Describe only the action you see; do not assign the person a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
|
||||||
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing.",
|
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing. For named subjects, always use their name.",
|
||||||
},
|
},
|
||||||
"concise": {
|
"concise": {
|
||||||
"scene": 'Cover each significant movement and action in order using as few short, direct sentences as possible, omitting environmental and cosmetic detail unless it affects the assessment. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
|
"scene": 'Cover each significant movement and action in order using as few short, direct sentences as possible, omitting environmental and cosmetic detail unless it affects the assessment. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign. Use no more than five sentences.',
|
||||||
"title": 'Write the title as a terse label of two to four words naming the specific activity observed and where it happened. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. For named subjects, always use their name.',
|
"title": 'Write the title as a terse label of two to four words naming the specific activity observed and where it happened. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. For named subjects, always use their name.',
|
||||||
"shortSummary": "Summarize the primary activity in one short sentence.",
|
"shortSummary": "Summarize the primary activity in one short sentence. For named subjects, always use their name.",
|
||||||
},
|
},
|
||||||
"detailed": {
|
"detailed": {
|
||||||
"scene": 'Describe how the sequence begins, then the progression of events — all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
|
"scene": 'Describe how the sequence begins, then the progression of events: all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign. Use no more than five sentences.',
|
||||||
"title": 'Write the title as a specific description of who did what and where, in under roughly twelve words, including the most distinguishing visible detail of the subject, such as clothing or vehicle color. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
|
"title": 'Write the title as a specific description of who did what and where, in under roughly twelve words, including the most distinguishing visible detail of the subject, such as clothing or vehicle color. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
|
||||||
"shortSummary": "Briefly summarize the primary activity across the observations, including the most identifying visible detail, such as vehicle color or clothing.",
|
"shortSummary": "Briefly summarize the primary activity across the observations in one sentence, including the most identifying visible detail, such as vehicle color or clothing. For named subjects, always use their name.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +67,12 @@ def get_review_field_guidelines(response_style: str = "default") -> dict[str, st
|
|||||||
|
|
||||||
# Explains the per-frame labels and tracker notes used by the annotated frame
|
# Explains the per-frame labels and tracker notes used by the annotated frame
|
||||||
# mode. Neither the notes nor this guidance say whether repeated detections are
|
# mode. Neither the notes nor this guidance say whether repeated detections are
|
||||||
# the same subject, since the tracking data cannot tell.
|
# the same subject, since the tracking data cannot tell. The last line keeps
|
||||||
|
# "no longer detected" from being read as the subject leaving the frame, which
|
||||||
|
# is only one of the reasons the tracker loses an object.
|
||||||
FRAME_ANNOTATION_GUIDANCE = """- Each image below is immediately preceded by a text label giving its frame number and how many seconds into the sequence it was captured. Use these labels to track the order of events and the time between them.
|
FRAME_ANNOTATION_GUIDANCE = """- Each image below is immediately preceded by a text label giving its frame number and how many seconds into the sequence it was captured. Use these labels to track the order of events and the time between them.
|
||||||
- Some images below are preceded by notes from the camera's object tracker recording what changed at that point: an object being first detected, starting to move, reversing direction, stopping, or no longer being detected. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them."""
|
- Some images below are preceded by notes from the camera's object tracker recording what changed at that point: an object being first detected, starting to move, reversing direction, stopping, or no longer being detected. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them.
|
||||||
|
- The notes say when something changed, not why. In particular, an object is no longer detected whenever the tracker loses sight of it, which happens when it leaves the frame and also when it goes out of view behind or inside something. Look at the frames before and after each note to determine what actually happened."""
|
||||||
|
|
||||||
|
|
||||||
def build_review_description_prompt(
|
def build_review_description_prompt(
|
||||||
|
|||||||
@@ -625,7 +625,12 @@ class OnvifController:
|
|||||||
|
|
||||||
move_request.Speed = move_speed
|
move_request.Speed = move_speed
|
||||||
|
|
||||||
await cam["ptz"].RelativeMove(move_request)
|
try:
|
||||||
|
await cam["ptz"].RelativeMove(move_request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Onvif sending relative move request to {camera_name} failed: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# reset after the move request
|
# reset after the move request
|
||||||
move_request.Translation.PanTilt.x = 0
|
move_request.Translation.PanTilt.x = 0
|
||||||
|
|||||||
@@ -100,6 +100,14 @@ class PendingReviewSegment:
|
|||||||
if zone not in self.zones:
|
if zone not in self.zones:
|
||||||
self.zones.append(zone)
|
self.zones.append(zone)
|
||||||
|
|
||||||
|
def add_manual_detection(self, info: dict[str, Any]) -> None:
|
||||||
|
"""Add a manual event's label and sub label to the segment."""
|
||||||
|
if info.get("sub_label"):
|
||||||
|
self.detections[info["event_id"]] = f"{info['label']}-verified"
|
||||||
|
self.sub_labels[info["event_id"]] = info["sub_label"]
|
||||||
|
else:
|
||||||
|
self.detections[info["event_id"]] = info["label"]
|
||||||
|
|
||||||
def update_frame(
|
def update_frame(
|
||||||
self,
|
self,
|
||||||
camera_config: CameraConfig,
|
camera_config: CameraConfig,
|
||||||
@@ -483,8 +491,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
alerts so manual events keep their historical severity.
|
alerts so manual events keep their historical severity.
|
||||||
"""
|
"""
|
||||||
review_config = self.config.cameras[camera].review
|
review_config = self.config.cameras[camera].review
|
||||||
# label contains 'label: sub_label', only the label is categorized
|
|
||||||
label = label.split(": ")[0]
|
|
||||||
|
|
||||||
if review_config.alerts.enabled and label in review_config.alerts.labels:
|
if review_config.alerts.enabled and label in review_config.alerts.labels:
|
||||||
return SeverityEnum.alert
|
return SeverityEnum.alert
|
||||||
@@ -894,9 +900,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
current_segment.last_detection_time = frame_time
|
current_segment.last_detection_time = frame_time
|
||||||
elif topic == DetectionTypeEnum.api or topic == DetectionTypeEnum.lpr:
|
elif topic == DetectionTypeEnum.api or topic == DetectionTypeEnum.lpr:
|
||||||
if manual_info["state"] == ManualEventState.complete:
|
if manual_info["state"] == ManualEventState.complete:
|
||||||
current_segment.detections[manual_info["event_id"]] = (
|
current_segment.add_manual_detection(manual_info)
|
||||||
manual_info["label"]
|
|
||||||
)
|
|
||||||
if topic == DetectionTypeEnum.api:
|
if topic == DetectionTypeEnum.api:
|
||||||
severity = self.get_manual_event_severity(
|
severity = self.get_manual_event_severity(
|
||||||
camera, manual_info["label"]
|
camera, manual_info["label"]
|
||||||
@@ -921,9 +925,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||||
manual_info["label"]
|
manual_info["label"]
|
||||||
)
|
)
|
||||||
current_segment.detections[manual_info["event_id"]] = (
|
current_segment.add_manual_detection(manual_info)
|
||||||
manual_info["label"]
|
|
||||||
)
|
|
||||||
if topic == DetectionTypeEnum.api:
|
if topic == DetectionTypeEnum.api:
|
||||||
if (
|
if (
|
||||||
self.get_manual_event_severity(
|
self.get_manual_event_severity(
|
||||||
@@ -1014,11 +1016,12 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
camera,
|
camera,
|
||||||
frame_time,
|
frame_time,
|
||||||
severity,
|
severity,
|
||||||
{manual_info["event_id"]: manual_info["label"]},
|
{},
|
||||||
{},
|
{},
|
||||||
[],
|
[],
|
||||||
set(),
|
set(),
|
||||||
)
|
)
|
||||||
|
api_segment.add_manual_detection(manual_info)
|
||||||
self._activate_segment(api_segment)
|
self._activate_segment(api_segment)
|
||||||
|
|
||||||
if manual_info["state"] == ManualEventState.start:
|
if manual_info["state"] == ManualEventState.start:
|
||||||
@@ -1041,11 +1044,12 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
camera,
|
camera,
|
||||||
frame_time,
|
frame_time,
|
||||||
SeverityEnum.detection,
|
SeverityEnum.detection,
|
||||||
{manual_info["event_id"]: manual_info["label"]},
|
{},
|
||||||
{},
|
{},
|
||||||
[],
|
[],
|
||||||
set(),
|
set(),
|
||||||
)
|
)
|
||||||
|
lpr_segment.add_manual_detection(manual_info)
|
||||||
self._activate_segment(lpr_segment)
|
self._activate_segment(lpr_segment)
|
||||||
|
|
||||||
if manual_info["state"] == ManualEventState.start:
|
if manual_info["state"] == ManualEventState.start:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from frigate.config.config import stream_info_retriever
|
||||||
|
|
||||||
|
|
||||||
|
def _unreachable_stream_info(ffmpeg, path: str) -> dict:
|
||||||
|
return {"width": 0, "height": 0, "fourcc": None}
|
||||||
|
|
||||||
|
|
||||||
|
# Validating a camera without detect dimensions probes its stream. Test streams
|
||||||
|
# are unreachable, so return the failed probe result instead of waiting ~12s per
|
||||||
|
# stream for ffprobe to time out
|
||||||
|
stream_info_retriever.get_stream_info = _unreachable_stream_info
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Run the unit test suite in parallel groups.
|
||||||
|
|
||||||
|
Test modules are split into groups balanced by test count, similar to Home
|
||||||
|
Assistant's split_tests.py, and each group runs in its own unittest process.
|
||||||
|
|
||||||
|
Usage (from the repo root):
|
||||||
|
python3 -u -m frigate.test [-j JOBS] [--groups GROUPS] [--timeout SECONDS]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
TEST_METHOD = re.compile(r"^\s+(?:async )?def test", re.MULTILINE)
|
||||||
|
RAN_TESTS = re.compile(r"^Ran (\d+) tests? in", re.MULTILINE)
|
||||||
|
|
||||||
|
# Each group pays for its own interpreter and imports, so smaller groups only
|
||||||
|
# help when there are more cores than groups
|
||||||
|
TESTS_PER_GROUP = 250
|
||||||
|
|
||||||
|
# Each group runs in its own process group, which ctrl+c in the terminal does
|
||||||
|
# not reach, so the runner passes the interrupt on to these
|
||||||
|
interrupted = threading.Event()
|
||||||
|
running_groups: set[int] = set()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestGroup:
|
||||||
|
index: int
|
||||||
|
modules: list[str] = field(default_factory=list)
|
||||||
|
weight: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GroupResult:
|
||||||
|
group: TestGroup
|
||||||
|
returncode: int
|
||||||
|
output: str
|
||||||
|
duration: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tests_run(self) -> int:
|
||||||
|
"""Number of tests unittest reported running, 0 if it never finished."""
|
||||||
|
match = RAN_TESTS.search(self.output)
|
||||||
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
|
||||||
|
def discover_modules() -> dict[str, int]:
|
||||||
|
"""Find test modules the same way unittest discovery does.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Module name mapped to the number of test methods it defines
|
||||||
|
"""
|
||||||
|
modules: dict[str, int] = {}
|
||||||
|
|
||||||
|
for path in sorted((REPO_ROOT / "frigate").rglob("test*.py")):
|
||||||
|
relative = path.relative_to(REPO_ROOT)
|
||||||
|
|
||||||
|
# discovery only descends into packages
|
||||||
|
if not all(
|
||||||
|
(REPO_ROOT / parent / "__init__.py").exists()
|
||||||
|
for parent in relative.parents
|
||||||
|
if parent != Path(".")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = ".".join(relative.with_suffix("").parts)
|
||||||
|
modules[name] = len(TEST_METHOD.findall(path.read_text()))
|
||||||
|
|
||||||
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
def split_groups(modules: dict[str, int], count: int) -> list[TestGroup]:
|
||||||
|
"""Split modules into groups with a similar number of tests.
|
||||||
|
|
||||||
|
Largest modules are placed first, each into the currently lightest group.
|
||||||
|
"""
|
||||||
|
groups = [TestGroup(index) for index in range(1, count + 1)]
|
||||||
|
|
||||||
|
for name, weight in sorted(modules.items(), key=lambda m: m[1], reverse=True):
|
||||||
|
group = min(groups, key=lambda g: g.weight)
|
||||||
|
group.modules.append(name)
|
||||||
|
group.weight += weight
|
||||||
|
|
||||||
|
return [group for group in groups if group.modules]
|
||||||
|
|
||||||
|
|
||||||
|
def signal_group(pgid: int, sig: signal.Signals) -> bool:
|
||||||
|
"""Send a signal to every process in a process group.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
False if no process is left in the group
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
os.killpg(pgid, sig)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def stop_group(pgid: int) -> None:
|
||||||
|
"""Stop the processes a group's tests left running.
|
||||||
|
|
||||||
|
They get a moment to exit before being killed, so the multiprocessing
|
||||||
|
resource tracker, which ignores SIGTERM, can remove leaked shared memory
|
||||||
|
once the processes holding it have exited.
|
||||||
|
"""
|
||||||
|
if signal_group(pgid, signal.SIGTERM):
|
||||||
|
time.sleep(2)
|
||||||
|
signal_group(pgid, signal.SIGKILL)
|
||||||
|
|
||||||
|
|
||||||
|
def run_group(group: TestGroup, timeout: float) -> GroupResult:
|
||||||
|
"""Run a group's modules in a unittest process of its own.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group: The modules to run
|
||||||
|
timeout: Seconds before the group is stopped
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The exit code and combined output of the unittest process
|
||||||
|
"""
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
# a file instead of a pipe so a process left behind by a test can't keep
|
||||||
|
# the group from finishing by holding the pipe open
|
||||||
|
with tempfile.TemporaryFile("w+") as output:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-u", "-X", "faulthandler", "-m", "unittest"]
|
||||||
|
+ group.modules,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
# a group in the background that read the terminal would be
|
||||||
|
# stopped, and a stopped process doesn't act on SIGABRT
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=output,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
# a process group of its own so the processes its tests start can
|
||||||
|
# be stopped with it
|
||||||
|
process_group=0,
|
||||||
|
)
|
||||||
|
running_groups.add(proc.pid)
|
||||||
|
|
||||||
|
# the runner may have been interrupted while this group was starting
|
||||||
|
if interrupted.is_set():
|
||||||
|
signal_group(proc.pid, signal.SIGINT)
|
||||||
|
|
||||||
|
timed_out = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.wait(timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
# faulthandler prints the stack of every thread on SIGABRT, which
|
||||||
|
# shows where the group is stuck
|
||||||
|
timed_out = True
|
||||||
|
proc.send_signal(signal.SIGABRT)
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
# aborting skips the cleanup of the running test, which would
|
||||||
|
# have stopped the processes it started
|
||||||
|
stop_group(proc.pid)
|
||||||
|
|
||||||
|
running_groups.discard(proc.pid)
|
||||||
|
output.seek(0)
|
||||||
|
text = output.read()
|
||||||
|
|
||||||
|
if timed_out:
|
||||||
|
text += f"\nGroup timed out after {timeout:.0f}s\n"
|
||||||
|
|
||||||
|
return GroupResult(group, proc.returncode, text, time.monotonic() - start)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Split the tests into groups, run them in parallel, and report the results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The exit code, 1 if any group failed
|
||||||
|
"""
|
||||||
|
cpus = os.cpu_count() or 1
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument(
|
||||||
|
"-j",
|
||||||
|
"--jobs",
|
||||||
|
type=int,
|
||||||
|
default=cpus,
|
||||||
|
help=f"number of groups to run at once (default: {cpus})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--groups",
|
||||||
|
type=int,
|
||||||
|
help=(
|
||||||
|
"number of groups to split the tests into (default: one per job, "
|
||||||
|
f"or more to keep groups under about {TESTS_PER_GROUP} tests)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=float,
|
||||||
|
default=600,
|
||||||
|
help="seconds before a group is stopped and reported as failed",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="print the output of passing groups as well as failing ones",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
modules = discover_modules()
|
||||||
|
group_count = args.groups or max(
|
||||||
|
args.jobs, math.ceil(sum(modules.values()) / TESTS_PER_GROUP)
|
||||||
|
)
|
||||||
|
groups = split_groups(modules, group_count)
|
||||||
|
print(f"Running {len(groups)} test groups with {args.jobs} jobs", flush=True)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
failed: list[GroupResult] = []
|
||||||
|
total_tests = 0
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=args.jobs) as executor:
|
||||||
|
futures = [executor.submit(run_group, group, args.timeout) for group in groups]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for future in as_completed(futures):
|
||||||
|
result = future.result()
|
||||||
|
total_tests += result.tests_run
|
||||||
|
status = "OK" if result.returncode == 0 else "FAILED"
|
||||||
|
print(
|
||||||
|
f"Group {result.group.index}/{len(groups)}: {status}, "
|
||||||
|
f"{result.tests_run} tests in {result.duration:.1f}s",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
failed.append(result)
|
||||||
|
|
||||||
|
if result.returncode != 0 or args.verbose:
|
||||||
|
print(result.output, flush=True)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
interrupted.set()
|
||||||
|
executor.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
for pgid in list(running_groups):
|
||||||
|
signal_group(pgid, signal.SIGINT)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
print(f"\nRan {total_tests} tests in {time.monotonic() - start:.1f}s")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f"{len(failed)} of {len(groups)} groups FAILED, rerun one with:")
|
||||||
|
|
||||||
|
for result in sorted(failed, key=lambda r: r.group.index):
|
||||||
|
print(
|
||||||
|
f" Group {result.group.index}: python3 -u -m unittest "
|
||||||
|
f"{' '.join(result.group.modules)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+10
-2
@@ -1,4 +1,12 @@
|
|||||||
"""Constants for testing."""
|
"""Constants for testing."""
|
||||||
|
|
||||||
TEST_DB = "test.db"
|
import os
|
||||||
TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"]
|
import tempfile
|
||||||
|
|
||||||
|
# Unique per process so test groups can run in parallel without sharing a database
|
||||||
|
TEST_DB = os.path.join(tempfile.gettempdir(), f"frigate_test_{os.getpid()}.db")
|
||||||
|
TEST_DB_CLEANUPS = [TEST_DB, f"{TEST_DB}-shm", f"{TEST_DB}-wal"]
|
||||||
|
|
||||||
|
# The database is thrown away after each test, so skip syncing every commit to
|
||||||
|
# disk, which is slow when several test processes are writing at once
|
||||||
|
TEST_DB_PRAGMAS = {"synchronous": "off"}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from frigate.debug_replay import DebugReplayManager
|
|||||||
from frigate.jobs.export import JobStatePublisher
|
from frigate.jobs.export import JobStatePublisher
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
|
||||||
|
|
||||||
|
|
||||||
class AuthTestClient(TestClient):
|
class AuthTestClient(TestClient):
|
||||||
@@ -38,12 +38,12 @@ class AuthTestClient(TestClient):
|
|||||||
class BaseTestHttp(unittest.TestCase):
|
class BaseTestHttp(unittest.TestCase):
|
||||||
def setUp(self, models):
|
def setUp(self, models):
|
||||||
# setup clean database for each test run
|
# setup clean database for each test run
|
||||||
migrate_db = SqliteDatabase("test.db")
|
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||||
del logging.getLogger("peewee_migrate").handlers[:]
|
del logging.getLogger("peewee_migrate").handlers[:]
|
||||||
router = Router(migrate_db)
|
router = Router(migrate_db)
|
||||||
router.run()
|
router.run()
|
||||||
migrate_db.close()
|
migrate_db.close()
|
||||||
self.db = SqliteQueueDatabase(TEST_DB)
|
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||||
self.db.bind(models)
|
self.db.bind(models)
|
||||||
|
|
||||||
# The export job manager broadcasts via JobStatePublisher on
|
# The export job manager broadcasts via JobStatePublisher on
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from unittest.mock import MagicMock
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from frigate.output.preview import PREVIEW_CACHE_DIR, PREVIEW_FRAME_TYPE
|
from frigate.output.preview import PREVIEW_FRAME_TYPE
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
@@ -15,14 +16,15 @@ class TestHttpLatestFrame(BaseTestHttp):
|
|||||||
self.app = super().create_app()
|
self.app = super().create_app()
|
||||||
self.app.detected_frames_processor = MagicMock()
|
self.app.detected_frames_processor = MagicMock()
|
||||||
|
|
||||||
if os.path.exists(PREVIEW_CACHE_DIR):
|
# a directory of its own so test groups running in parallel can't
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
# delete each other's preview frames
|
||||||
os.makedirs(PREVIEW_CACHE_DIR)
|
self.preview_dir = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
|
||||||
def tearDown(self):
|
cache_dir_patch = patch(
|
||||||
if os.path.exists(PREVIEW_CACHE_DIR):
|
"frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
)
|
||||||
super().tearDown()
|
cache_dir_patch.start()
|
||||||
|
self.addCleanup(cache_dir_patch.stop)
|
||||||
|
|
||||||
def test_latest_frame_fallback_to_preview(self):
|
def test_latest_frame_fallback_to_preview(self):
|
||||||
camera = "front_door"
|
camera = "front_door"
|
||||||
@@ -45,7 +47,7 @@ class TestHttpLatestFrame(BaseTestHttp):
|
|||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
preview_path = os.path.join(
|
preview_path = os.path.join(
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
|
self.preview_dir, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
|
||||||
)
|
)
|
||||||
cv2.imwrite(preview_path, dummy_frame)
|
cv2.imwrite(preview_path, dummy_frame)
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ class TestHttpLatestFrame(BaseTestHttp):
|
|||||||
|
|
||||||
# 2. Create a dummy preview file
|
# 2. Create a dummy preview file
|
||||||
preview_path = os.path.join(
|
preview_path = os.path.join(
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
|
self.preview_dir, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
|
||||||
)
|
)
|
||||||
cv2.imwrite(preview_path, dummy_frame)
|
cv2.imwrite(preview_path, dummy_frame)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for audio maintainer spawning on runtime camera adds."""
|
"""Tests for audio maintainer spawning on runtime camera updates."""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
@@ -42,6 +42,42 @@ class TestAudioSpawnIfNeeded(unittest.TestCase):
|
|||||||
processor.logger = MagicMock()
|
processor.logger = MagicMock()
|
||||||
return processor
|
return processor
|
||||||
|
|
||||||
|
def test_initially_disabled_camera_spawns_after_enabled_update(self):
|
||||||
|
"""Consume an enabled update through the manager's real subscriber."""
|
||||||
|
config = _build_config()
|
||||||
|
config.cameras["front_door"].enabled = False
|
||||||
|
stop = MagicMock()
|
||||||
|
stop.wait.side_effect = [False, True]
|
||||||
|
processor = AudioProcessor(
|
||||||
|
config, {"front_door": MagicMock()}, MagicMock(), stop
|
||||||
|
)
|
||||||
|
processor.logger = MagicMock()
|
||||||
|
updates = iter([("config/cameras/front_door/enabled", True), (None, None)])
|
||||||
|
self.addCleanup(
|
||||||
|
setattr, threading.current_thread(), "name", threading.current_thread().name
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(processor, "pre_run_setup"),
|
||||||
|
patch("frigate.config.camera.updater.ConfigSubscriber") as transport,
|
||||||
|
patch("frigate.events.audio.AudioEventMaintainer") as maintainer,
|
||||||
|
):
|
||||||
|
|
||||||
|
def receive_update():
|
||||||
|
self.assertEqual(processor.audio_threads, {})
|
||||||
|
maintainer.assert_not_called()
|
||||||
|
return next(updates)
|
||||||
|
|
||||||
|
transport.return_value.check_for_update.side_effect = receive_update
|
||||||
|
maintainer.return_value.is_alive.return_value = False
|
||||||
|
processor.run()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
processor.audio_threads, {"front_door": maintainer.return_value}
|
||||||
|
)
|
||||||
|
maintainer.assert_called_once()
|
||||||
|
maintainer.return_value.start.assert_called_once()
|
||||||
|
|
||||||
def test_skips_camera_whose_metrics_have_not_been_created_yet(self):
|
def test_skips_camera_whose_metrics_have_not_been_created_yet(self):
|
||||||
"""The camera maintainer creates metrics on its own poll of the add
|
"""The camera maintainer creates metrics on its own poll of the add
|
||||||
update, so the audio processor can see the camera first."""
|
update, so the audio processor can see the camera first."""
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Tests for snapshot expiration in event cleanup."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from peewee import SqliteDatabase
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.events.cleanup import EventCleanup
|
||||||
|
from frigate.models import Event, Timeline
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventCleanupSnapshots(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.db = SqliteDatabase(":memory:")
|
||||||
|
models = [Event, Timeline]
|
||||||
|
self.db.bind(models)
|
||||||
|
self.db.create_tables(models)
|
||||||
|
|
||||||
|
config = FrigateConfig(
|
||||||
|
**{
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"snapshots": {"enabled": True, "retain": {"default": 14}},
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.cleanup = EventCleanup(config, MagicMock(), MagicMock())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def _insert_event(self, id: str, camera: str, has_snapshot: bool) -> None:
|
||||||
|
end_time = (datetime.datetime.now() - datetime.timedelta(days=20)).timestamp()
|
||||||
|
Event.create(
|
||||||
|
id=id,
|
||||||
|
label="person",
|
||||||
|
camera=camera,
|
||||||
|
start_time=end_time - 10,
|
||||||
|
end_time=end_time,
|
||||||
|
top_score=0.9,
|
||||||
|
score=0.9,
|
||||||
|
false_positive=False,
|
||||||
|
zones=[],
|
||||||
|
thumbnail="",
|
||||||
|
has_clip=True,
|
||||||
|
has_snapshot=has_snapshot,
|
||||||
|
region=[],
|
||||||
|
box=[],
|
||||||
|
area=0,
|
||||||
|
retain_indefinitely=False,
|
||||||
|
plus_id="",
|
||||||
|
model_hash="",
|
||||||
|
detector_type="cpu",
|
||||||
|
model_type="ssd",
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("frigate.events.cleanup.delete_event_snapshot", return_value=True)
|
||||||
|
def test_expired_snapshots_are_only_deleted_once(self, delete_snapshot):
|
||||||
|
# events stay in the db while their clip is retained, so events whose
|
||||||
|
# snapshot already expired must not be selected again
|
||||||
|
for camera in ("front_door", "removed_camera"):
|
||||||
|
self._insert_event(f"{camera}-pending", camera, has_snapshot=True)
|
||||||
|
self._insert_event(f"{camera}-expired", camera, has_snapshot=False)
|
||||||
|
|
||||||
|
self.cleanup.expire_snapshots()
|
||||||
|
|
||||||
|
deleted = sorted(c.args[0].id for c in delete_snapshot.call_args_list)
|
||||||
|
assert deleted == ["front_door-pending", "removed_camera-pending"]
|
||||||
|
assert not Event.select().where(Event.has_snapshot == True).exists()
|
||||||
|
|
||||||
|
delete_snapshot.reset_mock()
|
||||||
|
self.cleanup.expire_snapshots()
|
||||||
|
|
||||||
|
delete_snapshot.assert_not_called()
|
||||||
@@ -617,9 +617,13 @@ class TestExportTimelineAlignment(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
windows = exporter._staged_chapter_windows()
|
windows = exporter._staged_chapter_windows()
|
||||||
|
|
||||||
path = exporter._build_recording_segment_chapter_metadata_file(windows)
|
# the real cache dir only exists once the container has started
|
||||||
self.addCleanup(os.remove, path)
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
content = Path(path).read_text()
|
chapter_path = os.path.join(tmpdir, "chapters.txt")
|
||||||
|
exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign]
|
||||||
|
path = exporter._build_recording_segment_chapter_metadata_file(windows)
|
||||||
|
self.assertEqual(path, chapter_path)
|
||||||
|
content = Path(chapter_path).read_text()
|
||||||
|
|
||||||
self.assertIn("START=0\nEND=21500", content)
|
self.assertIn("START=0\nEND=21500", content)
|
||||||
self.assertIn("START=21500\nEND=43000", content)
|
self.assertIn("START=21500\nEND=43000", content)
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ class TestReviewResponseStyle(unittest.TestCase):
|
|||||||
f"unexpected override fields: {set(overrides)}",
|
f"unexpected override fields: {set(overrides)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_every_style_states_length_and_name_rules(self):
|
||||||
|
# The schema caps these fields and grammar-constrained providers cut
|
||||||
|
# text off at the cap, and models only reliably use names when each
|
||||||
|
# field asks for them.
|
||||||
|
for style in ["default", *REVIEW_RESPONSE_STYLES]:
|
||||||
|
fields = get_review_field_guidelines(style)
|
||||||
|
self.assertIn("no more than five sentences", fields["scene"])
|
||||||
|
self.assertIn("one", fields["shortSummary"])
|
||||||
|
self.assertIn("sentence", fields["shortSummary"])
|
||||||
|
|
||||||
|
for field_name in ("scene", "title", "shortSummary"):
|
||||||
|
self.assertIn("always use their name", fields[field_name])
|
||||||
|
|
||||||
def test_config_enum_matches_style_presets(self):
|
def test_config_enum_matches_style_presets(self):
|
||||||
enum_styles = {e.value for e in ReviewResponseStyleEnum}
|
enum_styles = {e.value for e in ReviewResponseStyleEnum}
|
||||||
self.assertEqual(enum_styles, {"default", *REVIEW_RESPONSE_STYLES})
|
self.assertEqual(enum_styles, {"default", *REVIEW_RESPONSE_STYLES})
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from frigate.api.media_auth import (
|
|||||||
)
|
)
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.models import Event, Export, Recordings, ReviewSegment
|
from frigate.models import Event, Export, Recordings, ReviewSegment
|
||||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
|
||||||
|
|
||||||
_CONFIG = {
|
_CONFIG = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
@@ -222,11 +222,11 @@ class TestExportResolution(unittest.TestCase):
|
|||||||
"""Export resolution requires a DB lookup."""
|
"""Export resolution requires a DB lookup."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
migrate_db = SqliteDatabase("test.db")
|
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||||
del logging.getLogger("peewee_migrate").handlers[:]
|
del logging.getLogger("peewee_migrate").handlers[:]
|
||||||
Router(migrate_db).run()
|
Router(migrate_db).run()
|
||||||
migrate_db.close()
|
migrate_db.close()
|
||||||
self.db = SqliteQueueDatabase(TEST_DB)
|
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||||
self.db.bind([Event, ReviewSegment, Recordings, Export])
|
self.db.bind([Event, ReviewSegment, Recordings, Export])
|
||||||
self.config = FrigateConfig(**_CONFIG)
|
self.config = FrigateConfig(**_CONFIG)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
|
|||||||
from frigate.models import Notice, NoticeStats
|
from frigate.models import Notice, NoticeStats
|
||||||
from frigate.notices.registry import NoticeRegistry
|
from frigate.notices.registry import NoticeRegistry
|
||||||
from frigate.notices.types import NOTICE_KINDS, NoticeKind, NoticeSeverity, notice_id
|
from frigate.notices.types import NOTICE_KINDS, NoticeKind, NoticeSeverity, notice_id
|
||||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
|
||||||
|
|
||||||
# kinds that switch on the lifecycle knobs, so the tests do not depend on the
|
# kinds that switch on the lifecycle knobs, so the tests do not depend on the
|
||||||
# values the real catalog picks
|
# values the real catalog picks
|
||||||
@@ -49,12 +49,12 @@ class TestNoticeKinds(unittest.TestCase):
|
|||||||
|
|
||||||
class RegistryTestCase(unittest.TestCase):
|
class RegistryTestCase(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
migrate_db = SqliteDatabase("test.db")
|
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||||
del logging.getLogger("peewee_migrate").handlers[:]
|
del logging.getLogger("peewee_migrate").handlers[:]
|
||||||
router = Router(migrate_db)
|
router = Router(migrate_db)
|
||||||
router.run()
|
router.run()
|
||||||
migrate_db.close()
|
migrate_db.close()
|
||||||
self.db = SqliteQueueDatabase(TEST_DB)
|
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||||
self.db.bind([Notice, NoticeStats])
|
self.db.bind([Notice, NoticeStats])
|
||||||
self.registry = NoticeRegistry()
|
self.registry = NoticeRegistry()
|
||||||
self.listener = MagicMock()
|
self.listener = MagicMock()
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from frigate.output.preview import (
|
from frigate.output.preview import PREVIEW_FRAME_TYPE, get_most_recent_preview_frame
|
||||||
PREVIEW_CACHE_DIR,
|
|
||||||
PREVIEW_FRAME_TYPE,
|
|
||||||
get_most_recent_preview_frame,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreviewLoader(unittest.TestCase):
|
class TestPreviewLoader(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
if os.path.exists(PREVIEW_CACHE_DIR):
|
# a directory of its own so test groups running in parallel can't
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
# delete each other's preview frames
|
||||||
os.makedirs(PREVIEW_CACHE_DIR)
|
self.preview_dir = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
|
||||||
def tearDown(self):
|
cache_dir_patch = patch(
|
||||||
if os.path.exists(PREVIEW_CACHE_DIR):
|
"frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
)
|
||||||
|
cache_dir_patch.start()
|
||||||
|
self.addCleanup(cache_dir_patch.stop)
|
||||||
|
|
||||||
def test_get_most_recent_preview_frame_missing(self):
|
def test_get_most_recent_preview_frame_missing(self):
|
||||||
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
||||||
@@ -28,14 +28,14 @@ class TestPreviewLoader(unittest.TestCase):
|
|||||||
for ts in ["1000.0", "2000.0", "1500.0"]:
|
for ts in ["1000.0", "2000.0", "1500.0"]:
|
||||||
with open(
|
with open(
|
||||||
os.path.join(
|
os.path.join(
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
|
self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
),
|
),
|
||||||
"w",
|
"w",
|
||||||
) as f:
|
) as f:
|
||||||
f.write(f"test_{ts}")
|
f.write(f"test_{ts}")
|
||||||
|
|
||||||
expected_path = os.path.join(
|
expected_path = os.path.join(
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}"
|
self.preview_dir, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}"
|
||||||
)
|
)
|
||||||
self.assertEqual(get_most_recent_preview_frame(camera), expected_path)
|
self.assertEqual(get_most_recent_preview_frame(camera), expected_path)
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class TestPreviewLoader(unittest.TestCase):
|
|||||||
for ts in ["1000.0", "2000.0"]:
|
for ts in ["1000.0", "2000.0"]:
|
||||||
with open(
|
with open(
|
||||||
os.path.join(
|
os.path.join(
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
|
self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
),
|
),
|
||||||
"w",
|
"w",
|
||||||
) as f:
|
) as f:
|
||||||
@@ -53,7 +53,7 @@ class TestPreviewLoader(unittest.TestCase):
|
|||||||
|
|
||||||
# Test finding frame before or at 1500
|
# Test finding frame before or at 1500
|
||||||
expected_path = os.path.join(
|
expected_path = os.path.join(
|
||||||
PREVIEW_CACHE_DIR, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}"
|
self.preview_dir, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}"
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
get_most_recent_preview_frame(camera, before=1500.0), expected_path
|
get_most_recent_preview_frame(camera, before=1500.0), expected_path
|
||||||
@@ -67,7 +67,7 @@ class TestPreviewLoader(unittest.TestCase):
|
|||||||
other_camera = "other_camera"
|
other_camera = "other_camera"
|
||||||
with open(
|
with open(
|
||||||
os.path.join(
|
os.path.join(
|
||||||
PREVIEW_CACHE_DIR, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}"
|
self.preview_dir, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}"
|
||||||
),
|
),
|
||||||
"w",
|
"w",
|
||||||
) as f:
|
) as f:
|
||||||
@@ -78,12 +78,12 @@ class TestPreviewLoader(unittest.TestCase):
|
|||||||
def test_get_most_recent_preview_frame_hyphenated_camera(self):
|
def test_get_most_recent_preview_frame_hyphenated_camera(self):
|
||||||
for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
|
for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
|
||||||
with open(
|
with open(
|
||||||
os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
|
os.path.join(self.preview_dir, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
|
||||||
) as f:
|
) as f:
|
||||||
f.write("test")
|
f.write("test")
|
||||||
|
|
||||||
expected_path = os.path.join(
|
expected_path = os.path.join(
|
||||||
PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
|
self.preview_dir, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
|
||||||
)
|
)
|
||||||
self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
|
self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -91,5 +91,5 @@ class TestPreviewLoader(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_get_most_recent_preview_frame_no_directory(self):
|
def test_get_most_recent_preview_frame_no_directory(self):
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
shutil.rmtree(self.preview_dir)
|
||||||
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
||||||
|
|||||||
@@ -195,6 +195,15 @@ class TestManualRelativeMoveMetrics(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_failed_move_does_not_raise(self) -> None:
|
||||||
|
# an exception here ends the autotracker's move queue consumer for good
|
||||||
|
controller = _make_move_controller(autotracking_enabled=True)
|
||||||
|
controller.cams[CAMERA]["ptz"].RelativeMove.side_effect = TimeoutError
|
||||||
|
|
||||||
|
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
|
||||||
|
|
||||||
|
self.assertFalse(controller.cams[CAMERA]["active"])
|
||||||
|
|
||||||
async def test_metrics_written_when_autotracking_enabled(self) -> None:
|
async def test_metrics_written_when_autotracking_enabled(self) -> None:
|
||||||
# get_camera_status resets stop_time once the camera reports IDLE, so the
|
# get_camera_status resets stop_time once the camera reports IDLE, so the
|
||||||
# autotracking path keeps its motion estimation timestamps
|
# autotracking path keeps its motion estimation timestamps
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ class TestEventNames(unittest.TestCase):
|
|||||||
event = {"label": "waste_bin", "sub_label": "Compost"}
|
event = {"label": "waste_bin", "sub_label": "Compost"}
|
||||||
self.assertEqual(event_name(event), 'waste bin "Compost"')
|
self.assertEqual(event_name(event), 'waste bin "Compost"')
|
||||||
|
|
||||||
|
def test_names_match_the_objects_in_scene_list(self):
|
||||||
|
event = {"label": "person", "sub_label": "known_subject"}
|
||||||
|
self.assertEqual(event_name(event), 'person "Known Subject"')
|
||||||
|
|
||||||
def test_repeated_objects_are_never_numbered(self):
|
def test_repeated_objects_are_never_numbered(self):
|
||||||
# Whether these are the same subject is unknown, so the notes must not
|
# Whether these are the same subject is unknown, so the notes must not
|
||||||
# imply either answer.
|
# imply either answer.
|
||||||
@@ -297,6 +301,57 @@ class TestLateEvents(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEarlyEvents(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# A car that pulled in and parked well before the first sampled frame,
|
||||||
|
# then leaves during the clip.
|
||||||
|
self.car = track(
|
||||||
|
"1789481990.000000-parked",
|
||||||
|
"car",
|
||||||
|
0.0,
|
||||||
|
straight_path((0.1, 0.9), (0.4, 0.6), 6, 1.0),
|
||||||
|
)
|
||||||
|
self.changes = [
|
||||||
|
{
|
||||||
|
"timestamp": 12.0,
|
||||||
|
"source_id": self.car["id"],
|
||||||
|
"class_type": "stationary",
|
||||||
|
},
|
||||||
|
{"timestamp": 70.0, "source_id": self.car["id"], "class_type": "active"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_history_before_the_first_frame_is_summarized(self):
|
||||||
|
# Arrival notes on the first frame were read as the car pulling in
|
||||||
|
# during the clip.
|
||||||
|
timeline = build_timeline(
|
||||||
|
[self.car], 90.0, state_changes=self.changes, span_start=50.0
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
timeline,
|
||||||
|
[
|
||||||
|
(50.0, "a car already in view when the sequence begins, not moving"),
|
||||||
|
(70.0, "a car starts moving again"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_object_still_moving_at_the_first_frame_is_not_called_stationary(self):
|
||||||
|
timeline = build_timeline([self.car], 90.0, span_start=3.0)
|
||||||
|
self.assertEqual(
|
||||||
|
timeline[0], (3.0, "a car already in view when the sequence begins")
|
||||||
|
)
|
||||||
|
self.assertFalse(any("first detected" in p for _, p in timeline))
|
||||||
|
|
||||||
|
def test_objects_gone_before_the_first_frame_are_skipped(self):
|
||||||
|
gone = dict(self.car, end_time=20.0)
|
||||||
|
self.assertEqual(build_timeline([gone], 90.0, span_start=50.0), [])
|
||||||
|
|
||||||
|
def test_objects_detected_inside_the_clip_are_unchanged(self):
|
||||||
|
self.assertEqual(
|
||||||
|
build_timeline([self.car], 90.0, self.changes, span_start=0.0),
|
||||||
|
build_timeline([self.car], 90.0, self.changes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestFrameBucketing(unittest.TestCase):
|
class TestFrameBucketing(unittest.TestCase):
|
||||||
def test_moment_attaches_to_the_following_frame(self):
|
def test_moment_attaches_to_the_following_frame(self):
|
||||||
frame_times = [0.0, 10.0, 20.0, 30.0]
|
frame_times = [0.0, 10.0, 20.0, 30.0]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import numpy as np
|
|||||||
from frigate.comms.detections_updater import DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionTypeEnum
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.review.maintainer import ReviewSegmentMaintainer
|
from frigate.review.maintainer import ReviewSegmentMaintainer
|
||||||
|
from frigate.track.object_processing import ManualEventState
|
||||||
|
|
||||||
CAMERA = "front_door"
|
CAMERA = "front_door"
|
||||||
|
|
||||||
@@ -122,13 +123,19 @@ class ReviewFlowTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
|
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
|
||||||
"""Run the maintainer loop over the given (frame_time, objects) frames."""
|
"""Run the maintainer loop over the given (frame_time, objects) frames."""
|
||||||
queue = [
|
self.feed_messages(
|
||||||
(
|
*[
|
||||||
DetectionTypeEnum.video.value,
|
(
|
||||||
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
|
DetectionTypeEnum.video.value,
|
||||||
)
|
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
|
||||||
for frame_time, objects in frames
|
)
|
||||||
]
|
for frame_time, objects in frames
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def feed_messages(self, *messages: tuple[str, tuple[Any, ...]]) -> None:
|
||||||
|
"""Run the maintainer loop over the given (topic, data) messages."""
|
||||||
|
queue = list(messages)
|
||||||
self.maintainer.stop_event = threading.Event()
|
self.maintainer.stop_event = threading.Event()
|
||||||
|
|
||||||
def next_update(timeout: float) -> Any:
|
def next_update(timeout: float) -> Any:
|
||||||
@@ -853,5 +860,46 @@ class TestSplitDetectionLabels(ReviewFlowTestCase):
|
|||||||
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"])
|
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestManualEvents(ReviewFlowTestCase):
|
||||||
|
def manual_event(
|
||||||
|
self, event_id: str, frame_time: float, sub_label: str | None = None
|
||||||
|
) -> tuple[str, tuple[Any, ...]]:
|
||||||
|
"""Build a completed manual event as published by the object processor."""
|
||||||
|
return (
|
||||||
|
DetectionTypeEnum.api.value,
|
||||||
|
(
|
||||||
|
CAMERA,
|
||||||
|
frame_time,
|
||||||
|
{
|
||||||
|
"state": ManualEventState.complete,
|
||||||
|
"label": "person",
|
||||||
|
"sub_label": sub_label,
|
||||||
|
"event_id": event_id,
|
||||||
|
"end_time": frame_time + 5,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sub_label_is_verified_on_new_segment(self) -> None:
|
||||||
|
self.feed_messages(self.manual_event("m1", 1, "Bob"))
|
||||||
|
|
||||||
|
data = self.maintainer.active_review_segments[CAMERA].get_data(False)["data"]
|
||||||
|
self.assertEqual(data["objects"], ["person-verified"])
|
||||||
|
self.assertEqual(data["verified_objects"], ["person-verified"])
|
||||||
|
self.assertEqual(data["sub_labels"], ["Bob"])
|
||||||
|
|
||||||
|
def test_sub_label_is_verified_on_active_segment(self) -> None:
|
||||||
|
self.feed(
|
||||||
|
(1, [self.tracked("dog1", "dog", 1)]),
|
||||||
|
)
|
||||||
|
self.feed_messages(
|
||||||
|
self.manual_event("m1", 2, "Bob"), self.manual_event("m2", 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = self.maintainer.active_review_segments[CAMERA].get_data(False)["data"]
|
||||||
|
self.assertCountEqual(data["objects"], ["dog", "person-verified", "person"])
|
||||||
|
self.assertEqual(data["sub_labels"], ["Bob"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -100,24 +100,6 @@ class TestManualEventSeverity(unittest.TestCase):
|
|||||||
SeverityEnum.alert,
|
SeverityEnum.alert,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_sub_label_is_stripped_before_categorizing(self) -> None:
|
|
||||||
maintainer = self._make_maintainer(
|
|
||||||
"""
|
|
||||||
review:
|
|
||||||
alerts:
|
|
||||||
labels:
|
|
||||||
- person
|
|
||||||
detections:
|
|
||||||
labels:
|
|
||||||
- person
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
maintainer.get_manual_event_severity("front_door", "person: Bob"),
|
|
||||||
SeverityEnum.alert,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_alert_label_is_detection_when_alerts_disabled(self) -> None:
|
def test_alert_label_is_detection_when_alerts_disabled(self) -> None:
|
||||||
maintainer = self._make_maintainer(
|
maintainer = self._make_maintainer(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -13,22 +13,28 @@ from frigate.config import FrigateConfig
|
|||||||
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
|
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
from frigate.storage import MAX_CALCULATED_BANDWIDTH, StorageMaintainer
|
from frigate.storage import MAX_CALCULATED_BANDWIDTH, StorageMaintainer
|
||||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
|
||||||
|
|
||||||
|
|
||||||
class TestHttp(unittest.TestCase):
|
class TestHttp(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# setup clean database for each test run
|
# setup clean database for each test run
|
||||||
migrate_db = SqliteDatabase("test.db")
|
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||||
del logging.getLogger("peewee_migrate").handlers[:]
|
del logging.getLogger("peewee_migrate").handlers[:]
|
||||||
router = Router(migrate_db)
|
router = Router(migrate_db)
|
||||||
router.run()
|
router.run()
|
||||||
migrate_db.close()
|
migrate_db.close()
|
||||||
self.db = SqliteQueueDatabase(TEST_DB)
|
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
||||||
models = [Event, Recordings]
|
models = [Event, Recordings]
|
||||||
self.db.bind(models)
|
self.db.bind(models)
|
||||||
self.test_dir = tempfile.mkdtemp()
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
# The maintainers are never stopped, so a real subscriber leaks a zmq
|
||||||
|
# context whose garbage collection can hang a later test in term()
|
||||||
|
subscriber_patch = patch("frigate.storage.CameraConfigUpdateSubscriber")
|
||||||
|
subscriber_patch.start()
|
||||||
|
self.addCleanup(subscriber_patch.stop)
|
||||||
|
|
||||||
self.minimal_config = {
|
self.minimal_config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Tests for the TFLite detection model shared by the TFLite detectors."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
|
||||||
|
from frigate.detectors.detector_utils import TFLiteDetectionModel
|
||||||
|
|
||||||
|
|
||||||
|
class FakeInterpreter:
|
||||||
|
"""Stands in for a TFLite interpreter with fixed output tensors."""
|
||||||
|
|
||||||
|
def __init__(self, outputs, input_dtype=np.uint8, allocate_error=None):
|
||||||
|
self.outputs = {}
|
||||||
|
self.output_details = []
|
||||||
|
self.allocate_error = allocate_error
|
||||||
|
self.input_details = [
|
||||||
|
{"index": 0, "dtype": input_dtype, "shape": np.array([1, 320, 320, 3])}
|
||||||
|
]
|
||||||
|
self.input = None
|
||||||
|
|
||||||
|
for i, (tensor, quantization) in enumerate(outputs, start=1):
|
||||||
|
self.outputs[i] = tensor
|
||||||
|
self.output_details.append(
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"dtype": tensor.dtype,
|
||||||
|
"shape": np.array(tensor.shape),
|
||||||
|
"quantization": quantization,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def allocate_tensors(self):
|
||||||
|
if self.allocate_error:
|
||||||
|
raise self.allocate_error
|
||||||
|
|
||||||
|
def get_input_details(self):
|
||||||
|
return self.input_details
|
||||||
|
|
||||||
|
def get_output_details(self):
|
||||||
|
return self.output_details
|
||||||
|
|
||||||
|
def set_tensor(self, index, value):
|
||||||
|
self.input = value
|
||||||
|
|
||||||
|
def invoke(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def tensor(self, index):
|
||||||
|
return lambda: self.outputs[index]
|
||||||
|
|
||||||
|
def get_tensor(self, index):
|
||||||
|
return self.outputs[index]
|
||||||
|
|
||||||
|
|
||||||
|
def model_with_type(model_type) -> ModelConfig:
|
||||||
|
return ModelConfig(
|
||||||
|
path="/config/model.tflite",
|
||||||
|
model_type=model_type,
|
||||||
|
labelmap_path=None,
|
||||||
|
labelmap={0: "person"},
|
||||||
|
width=320,
|
||||||
|
height=320,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ssd_outputs(scores, count=None):
|
||||||
|
num = len(scores)
|
||||||
|
boxes = np.tile(np.array([0.1, 0.2, 0.3, 0.4], np.float32), (1, num, 1))
|
||||||
|
class_ids = np.arange(num, dtype=np.float32).reshape(1, num)
|
||||||
|
scores = np.array(scores, np.float32).reshape(1, num)
|
||||||
|
count = np.array([num if count is None else count], np.float32)
|
||||||
|
return [
|
||||||
|
(boxes, (0.0, 0)),
|
||||||
|
(class_ids, (0.0, 0)),
|
||||||
|
(scores, (0.0, 0)),
|
||||||
|
(count, (0.0, 0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# 320x320 YOLO heads at strides 8, 16 and 32 have 40*40 + 20*20 + 10*10 anchors
|
||||||
|
YOLO_ANCHORS = 2100
|
||||||
|
YOLO_CLASSES = 3
|
||||||
|
SCORES_SCALE = 1 / 32
|
||||||
|
BOXES_SCALE = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def yolo_outputs(candidates, dtype=np.int8):
|
||||||
|
"""Build quantized YOLO outputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
candidates: (anchor index, class id, logit, ltrb distances in strides)
|
||||||
|
dtype: The output tensors' type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The class score and DFL box tensors with their quantization
|
||||||
|
"""
|
||||||
|
scores = np.full((1, YOLO_ANCHORS, YOLO_CLASSES), -128, dtype)
|
||||||
|
boxes = np.full((1, YOLO_ANCHORS, 4, 16), -100, dtype)
|
||||||
|
|
||||||
|
for anchor, class_id, logit, distances in candidates:
|
||||||
|
scores[0, anchor, class_id] = round(logit / SCORES_SCALE)
|
||||||
|
|
||||||
|
# a single hot bin per side decodes to that bin's distance
|
||||||
|
for side, distance in enumerate(distances):
|
||||||
|
boxes[0, anchor, side, distance] = 100
|
||||||
|
|
||||||
|
boxes = boxes.reshape(1, YOLO_ANCHORS, 64)
|
||||||
|
|
||||||
|
if np.issubdtype(dtype, np.floating):
|
||||||
|
return [(scores, (0.0, 0)), (boxes, (0.0, 0))]
|
||||||
|
|
||||||
|
return [(scores, (SCORES_SCALE, 0)), (boxes, (BOXES_SCALE, 0))]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTFLiteDetectionModel(unittest.TestCase):
|
||||||
|
def test_ssd_returns_detections_above_min_score(self):
|
||||||
|
interpreter = FakeInterpreter(ssd_outputs([0.9, 0.5, 0.3, 0.8]))
|
||||||
|
model = TFLiteDetectionModel(
|
||||||
|
interpreter, model_with_type(ModelTypeEnum.ssd), "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = model.detect_raw(np.zeros((1, 320, 320, 3), np.uint8))
|
||||||
|
|
||||||
|
# stops at the first score under the minimum, as SSD scores are sorted
|
||||||
|
np.testing.assert_allclose(
|
||||||
|
detections[:2],
|
||||||
|
[[0, 0.9, 0.1, 0.2, 0.3, 0.4], [1, 0.5, 0.1, 0.2, 0.3, 0.4]],
|
||||||
|
rtol=1e-6,
|
||||||
|
)
|
||||||
|
self.assertFalse(detections[2:].any())
|
||||||
|
|
||||||
|
def test_ssd_caps_detections_at_twenty(self):
|
||||||
|
interpreter = FakeInterpreter(ssd_outputs([0.9] * 25))
|
||||||
|
model = TFLiteDetectionModel(
|
||||||
|
interpreter, model_with_type(ModelTypeEnum.ssd), "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = model.detect_raw(np.zeros((1, 320, 320, 3), np.uint8))
|
||||||
|
|
||||||
|
self.assertEqual(detections.shape, (20, 6))
|
||||||
|
self.assertTrue((detections[:, 1] == np.float32(0.9)).all())
|
||||||
|
|
||||||
|
def test_ssd_model_without_four_outputs_is_rejected(self):
|
||||||
|
interpreter = FakeInterpreter(yolo_outputs([]))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "yolo-generic"):
|
||||||
|
TFLiteDetectionModel(interpreter, model_with_type(ModelTypeEnum.ssd), "cpu")
|
||||||
|
|
||||||
|
def test_int8_input_is_shifted_from_uint8(self):
|
||||||
|
interpreter = FakeInterpreter(ssd_outputs([0.9], count=0), np.int8)
|
||||||
|
model = TFLiteDetectionModel(
|
||||||
|
interpreter, model_with_type(ModelTypeEnum.ssd), "cpu"
|
||||||
|
)
|
||||||
|
frame = np.zeros((1, 320, 320, 3), np.uint8)
|
||||||
|
frame[0, 0, 0] = [0, 128, 255]
|
||||||
|
|
||||||
|
model.detect_raw(frame)
|
||||||
|
|
||||||
|
self.assertEqual(interpreter.input.dtype, np.int8)
|
||||||
|
self.assertEqual(interpreter.input[0, 0, 0].tolist(), [-128, 0, 127])
|
||||||
|
|
||||||
|
def test_yolo_decodes_quantized_outputs(self):
|
||||||
|
# anchor 420 is row 10, column 20 of the stride 8 head, centered on
|
||||||
|
# (20.5, 10.5) strides. Its neighbor overlaps it and should be removed
|
||||||
|
# by NMS as it has a lower score.
|
||||||
|
interpreter = FakeInterpreter(
|
||||||
|
yolo_outputs([(420, 2, 2.0, (2, 3, 4, 5)), (421, 1, 1.0, (2, 3, 4, 5))])
|
||||||
|
)
|
||||||
|
model = TFLiteDetectionModel(
|
||||||
|
interpreter, model_with_type(ModelTypeEnum.yologeneric), "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = model.detect_raw(np.zeros((1, 320, 320, 3), np.uint8))
|
||||||
|
|
||||||
|
# box corners are (20.5 - 2, 10.5 - 3) and (20.5 + 4, 10.5 + 5) strides
|
||||||
|
np.testing.assert_allclose(
|
||||||
|
detections[0],
|
||||||
|
[2, 1 / (1 + np.exp(-2.0)), 60 / 320, 148 / 320, 124 / 320, 196 / 320],
|
||||||
|
rtol=1e-4,
|
||||||
|
)
|
||||||
|
self.assertFalse(detections[1:].any())
|
||||||
|
|
||||||
|
def test_yolo_without_candidates_returns_no_detections(self):
|
||||||
|
interpreter = FakeInterpreter(yolo_outputs([]))
|
||||||
|
model = TFLiteDetectionModel(
|
||||||
|
interpreter, model_with_type(ModelTypeEnum.yologeneric), "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = model.detect_raw(np.zeros((1, 320, 320, 3), np.uint8))
|
||||||
|
|
||||||
|
self.assertFalse(detections.any())
|
||||||
|
|
||||||
|
def test_yolo_model_with_float_outputs_is_rejected(self):
|
||||||
|
interpreter = FakeInterpreter(yolo_outputs([], np.float32))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "int8 quantized"):
|
||||||
|
TFLiteDetectionModel(
|
||||||
|
interpreter, model_with_type(ModelTypeEnum.yologeneric), "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unsupported_model_type_is_rejected(self):
|
||||||
|
interpreter = FakeInterpreter(ssd_outputs([0.9]))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "yolox is currently not supported"):
|
||||||
|
TFLiteDetectionModel(
|
||||||
|
interpreter, model_with_type(ModelTypeEnum.yolox), "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_edgetpu_compiled_model_is_explained(self):
|
||||||
|
interpreter = FakeInterpreter(
|
||||||
|
ssd_outputs([0.9]),
|
||||||
|
allocate_error=RuntimeError(
|
||||||
|
"Encountered unresolved custom op: edgetpu-custom-op."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
self.assertLogs("frigate.detectors.detector_utils", "ERROR") as logs,
|
||||||
|
self.assertRaises(RuntimeError),
|
||||||
|
):
|
||||||
|
TFLiteDetectionModel(interpreter, model_with_type(ModelTypeEnum.ssd), "cpu")
|
||||||
|
|
||||||
|
self.assertIn("compiled for the EdgeTPU", logs.output[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -635,7 +635,8 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
if end_time
|
if end_time
|
||||||
else ManualEventState.start
|
else ManualEventState.start
|
||||||
),
|
),
|
||||||
"label": f"{label}: {sub_label}" if sub_label else label,
|
"label": label,
|
||||||
|
"sub_label": sub_label,
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
"end_time": end_time,
|
"end_time": end_time,
|
||||||
},
|
},
|
||||||
@@ -696,7 +697,8 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
frame_time,
|
frame_time,
|
||||||
{
|
{
|
||||||
"state": ManualEventState.start,
|
"state": ManualEventState.start,
|
||||||
"label": f"{label}: {sub_label}" if sub_label else label,
|
"label": label,
|
||||||
|
"sub_label": sub_label,
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
"end_time": None,
|
"end_time": None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from frigate.config.camera.genai import GenAIConfig, GenAIProviderEnum # noqa:
|
|||||||
from frigate.genai.prompts import ( # noqa: E402
|
from frigate.genai.prompts import ( # noqa: E402
|
||||||
REVIEW_RESPONSE_STYLES,
|
REVIEW_RESPONSE_STYLES,
|
||||||
build_review_description_response_format,
|
build_review_description_response_format,
|
||||||
|
get_review_field_guidelines,
|
||||||
)
|
)
|
||||||
|
|
||||||
EXAMPLES_DIR = Path(__file__).resolve().parent / "genai-review-examples"
|
EXAMPLES_DIR = Path(__file__).resolve().parent / "genai-review-examples"
|
||||||
@@ -227,11 +228,14 @@ def list_examples() -> list[Path]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_frames(example: Path) -> list[bytes]:
|
def load_frames(example: Path) -> tuple[list[bytes], list[str] | None]:
|
||||||
"""Load the example's frames as JPEG bytes in frame order.
|
"""Load the example's frames as JPEG bytes in frame order, with captions.
|
||||||
|
|
||||||
Frames are saved by Frigate as <index>.jpg or <index>.webp. Non-JPEG
|
Frames are saved by Frigate as <index>.jpg or <index>.webp. Non-JPEG
|
||||||
images are re-encoded to JPEG to match what Frigate sends to providers.
|
images are re-encoded to JPEG to match what Frigate sends to providers.
|
||||||
|
In annotated frame mode Frigate also saves <index>.txt, the caption sent
|
||||||
|
directly before that frame; captions are returned only when every frame
|
||||||
|
has one, matching Frigate's fallback to plain frames.
|
||||||
"""
|
"""
|
||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
@@ -242,34 +246,42 @@ def load_frames(example: Path) -> list[bytes]:
|
|||||||
return (1 << 30, path.name)
|
return (1 << 30, path.name)
|
||||||
|
|
||||||
frames: list[bytes] = []
|
frames: list[bytes] = []
|
||||||
|
captions: list[str] = []
|
||||||
files = sorted(
|
files = sorted(
|
||||||
(f for f in example.iterdir() if f.suffix.lower() in IMAGE_EXTENSIONS),
|
(f for f in example.iterdir() if f.suffix.lower() in IMAGE_EXTENSIONS),
|
||||||
key=frame_order,
|
key=frame_order,
|
||||||
)
|
)
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.suffix.lower() in (".jpg", ".jpeg"):
|
if file.suffix.lower() in (".jpg", ".jpeg"):
|
||||||
frames.append(file.read_bytes())
|
frame = file.read_bytes()
|
||||||
continue
|
else:
|
||||||
|
image = cv2.imread(str(file))
|
||||||
|
if image is None:
|
||||||
|
print(f" Skipping unreadable image {file.name}")
|
||||||
|
continue
|
||||||
|
ok, jpg = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
frame = jpg.tobytes()
|
||||||
|
|
||||||
image = cv2.imread(str(file))
|
frames.append(frame)
|
||||||
if image is None:
|
caption_file = file.with_suffix(".txt")
|
||||||
print(f" Skipping unreadable image {file.name}")
|
if caption_file.is_file():
|
||||||
continue
|
captions.append(caption_file.read_text())
|
||||||
ok, jpg = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
|
|
||||||
if ok:
|
return frames, captions if captions and len(captions) == len(frames) else None
|
||||||
frames.append(jpg.tobytes())
|
|
||||||
return frames
|
|
||||||
|
|
||||||
|
|
||||||
def apply_style(prompt: str, style: str) -> str:
|
def apply_style(prompt: str, style: str) -> str:
|
||||||
"""Apply a style preset to a saved prompt.
|
"""Apply the current field guidance for a style preset to a saved prompt.
|
||||||
|
|
||||||
Presets replace the per-field response guidance lines, matching what
|
Every per-field response guidance line is replaced with what Frigate
|
||||||
Frigate builds at runtime. Handles both the current guidance format
|
builds at runtime for that style, so "default" restores the default
|
||||||
|
guidance on a prompt saved with another style, and older saves pick up
|
||||||
|
current guidance. Handles both the current guidance format
|
||||||
("- `scene`: ...") and the 0.17 format ("- `scene` (string): ...").
|
("- `scene`: ...") and the 0.17 format ("- `scene` (string): ...").
|
||||||
"""
|
"""
|
||||||
overrides = REVIEW_RESPONSE_STYLES.get(style, {})
|
for field_name, guidance in get_review_field_guidelines(style).items():
|
||||||
for field_name, guidance in overrides.items():
|
|
||||||
pattern = rf"^- `{field_name}`(?: \(string\))?: .*$"
|
pattern = rf"^- `{field_name}`(?: \(string\))?: .*$"
|
||||||
prompt, count = re.subn(
|
prompt, count = re.subn(
|
||||||
pattern,
|
pattern,
|
||||||
@@ -292,7 +304,9 @@ def pretty_print_response(response: str) -> None:
|
|||||||
print(json.dumps(parsed, indent=2, ensure_ascii=False))
|
print(json.dumps(parsed, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
def followup_loop(client: Any, base_prompt: str, frames: list[bytes]) -> None:
|
def followup_loop(
|
||||||
|
client: Any, base_prompt: str, frames: list[bytes], captions: list[str] | None
|
||||||
|
) -> None:
|
||||||
"""Let the user ask the model follow-up questions about the same frames."""
|
"""Let the user ask the model follow-up questions about the same frames."""
|
||||||
transcript = base_prompt
|
transcript = base_prompt
|
||||||
print("\nAsk follow-up questions about this sequence (blank line to finish).")
|
print("\nAsk follow-up questions about this sequence (blank line to finish).")
|
||||||
@@ -311,7 +325,7 @@ def followup_loop(client: Any, base_prompt: str, frames: list[bytes]) -> None:
|
|||||||
f"Question: {question}"
|
f"Question: {question}"
|
||||||
)
|
)
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
answer = client._send(prompt, frames)
|
answer = client._send(prompt, frames, image_captions=captions)
|
||||||
elapsed = time.monotonic() - start
|
elapsed = time.monotonic() - start
|
||||||
if answer is None:
|
if answer is None:
|
||||||
print("No response from provider (see logs above).")
|
print("No response from provider (see logs above).")
|
||||||
@@ -349,7 +363,7 @@ def run_example(settings: TesterSettings) -> None:
|
|||||||
return
|
return
|
||||||
prompt = apply_style(prompt_file.read_text(), style)
|
prompt = apply_style(prompt_file.read_text(), style)
|
||||||
|
|
||||||
frames = load_frames(example)
|
frames, captions = load_frames(example)
|
||||||
if not frames:
|
if not frames:
|
||||||
print(f"{example.name} contains no frame images, cannot run")
|
print(f"{example.name} contains no frame images, cannot run")
|
||||||
return
|
return
|
||||||
@@ -365,10 +379,11 @@ def run_example(settings: TesterSettings) -> None:
|
|||||||
|
|
||||||
print(
|
print(
|
||||||
f"\nSending {len(frames)} frames to {settings.provider} "
|
f"\nSending {len(frames)} frames to {settings.provider} "
|
||||||
f"(model={settings.model or '(none)'}, style={style})..."
|
f"(model={settings.model or '(none)'}, style={style}, "
|
||||||
|
f"captions={'yes' if captions else 'no'})..."
|
||||||
)
|
)
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
response = client._send(prompt, frames, response_format)
|
response = client._send(prompt, frames, response_format, image_captions=captions)
|
||||||
elapsed = time.monotonic() - start
|
elapsed = time.monotonic() - start
|
||||||
|
|
||||||
if response is None:
|
if response is None:
|
||||||
@@ -377,7 +392,7 @@ def run_example(settings: TesterSettings) -> None:
|
|||||||
|
|
||||||
print(f"\nResponse ({elapsed:.1f}s):\n")
|
print(f"\nResponse ({elapsed:.1f}s):\n")
|
||||||
pretty_print_response(response)
|
pretty_print_response(response)
|
||||||
followup_loop(client, f"{prompt}\n\nYour analysis:\n{response}", frames)
|
followup_loop(client, f"{prompt}\n\nYour analysis:\n{response}", frames, captions)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
@@ -97,12 +97,13 @@ test.describe("setup wizard account @high @mobile", () => {
|
|||||||
.fill("a-long-enough-password");
|
.fill("a-long-enough-password");
|
||||||
await page.getByRole("button", { name: "Save" }).click();
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
const createCall = sent.find((call) => call.method === "POST");
|
await expect
|
||||||
expect(createCall?.body).toEqual({
|
.poll(() => sent.find((call) => call.method === "POST")?.body)
|
||||||
username: "family",
|
.toEqual({
|
||||||
password: "a-long-enough-password",
|
username: "family",
|
||||||
role: "viewer",
|
password: "a-long-enough-password",
|
||||||
});
|
role: "viewer",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows anonymous copy when nobody is signed in", async ({
|
test("shows anonymous copy when nobody is signed in", async ({
|
||||||
|
|||||||
@@ -101,10 +101,14 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
await expect(page.getByText("Will use VAAPI (Intel/AMD)")).toBeVisible();
|
await expect(page.getByText("Will use VAAPI (Intel/AMD)")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
|
await expect
|
||||||
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
|
.poll(
|
||||||
hwaccel_args: "preset-vaapi",
|
() =>
|
||||||
});
|
saves.find((save) => save.config_data?.ffmpeg)?.config_data?.ffmpeg,
|
||||||
|
)
|
||||||
|
.toEqual({
|
||||||
|
hwaccel_args: "preset-vaapi",
|
||||||
|
});
|
||||||
|
|
||||||
// the saved steps only take effect after a restart
|
// the saved steps only take effect after a restart
|
||||||
const restarts = await captureRestarts(page);
|
const restarts = await captureRestarts(page);
|
||||||
@@ -117,7 +121,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
await page.getByRole("button", { name: "Apply & Restart" }).click();
|
await page.getByRole("button", { name: "Apply & Restart" }).click();
|
||||||
|
|
||||||
await expect(page.getByText("Starting Frigate...")).toBeVisible();
|
await expect(page.getByText("Starting Frigate...")).toBeVisible();
|
||||||
expect(restarts).toHaveLength(1);
|
await expect.poll(() => restarts).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defers model setup for onnx hardware without Frigate+", async ({
|
test("defers model setup for onnx hardware without Frigate+", async ({
|
||||||
@@ -228,10 +232,14 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
// no camera was added, so there is no codec to match
|
// no camera was added, so there is no codec to match
|
||||||
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
|
await expect
|
||||||
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
|
.poll(
|
||||||
hwaccel_args: "preset-jetson-h264",
|
() =>
|
||||||
});
|
saves.find((save) => save.config_data?.ffmpeg)?.config_data?.ffmpeg,
|
||||||
|
)
|
||||||
|
.toEqual({
|
||||||
|
hwaccel_args: "preset-jetson-h264",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("None writes an explicit empty hwaccel list", async ({
|
test("None writes an explicit empty hwaccel list", async ({
|
||||||
@@ -249,7 +257,11 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
|||||||
await page.getByRole("radio", { name: "None (software decoding)" }).click();
|
await page.getByRole("radio", { name: "None (software decoding)" }).click();
|
||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
|
await expect
|
||||||
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({ hwaccel_args: [] });
|
.poll(
|
||||||
|
() =>
|
||||||
|
saves.find((save) => save.config_data?.ffmpeg)?.config_data?.ffmpeg,
|
||||||
|
)
|
||||||
|
.toEqual({ hwaccel_args: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+162
-166
@@ -9,8 +9,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
"@cycjimmy/jsmpeg-player": "^6.1.6",
|
||||||
"@hookform/resolvers": "^6.0.0",
|
"@hookform/resolvers": "^6.0.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||||
"@radix-ui/react-checkbox": "^1.4.1",
|
"@radix-ui/react-checkbox": "^1.4.1",
|
||||||
@@ -37,39 +37,39 @@
|
|||||||
"@rjsf/shadcn": "^6.10.1",
|
"@rjsf/shadcn": "^6.10.1",
|
||||||
"@rjsf/utils": "^6.10.1",
|
"@rjsf/utils": "^6.10.1",
|
||||||
"@rjsf/validator-ajv8": "^6.10.1",
|
"@rjsf/validator-ajv8": "^6.10.1",
|
||||||
"apexcharts": "^8.4.1",
|
"apexcharts": "^8.5.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.21.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"framer-motion": "^14.6.0",
|
"framer-motion": "^14.7.1",
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.4",
|
||||||
"i18next": "^26.5.5",
|
"i18next": "^26.5.11",
|
||||||
"i18next-http-backend": "^4.0.4",
|
"i18next-http-backend": "^4.0.6",
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"js-yaml": "^5.5.0",
|
"js-yaml": "^5.5.0",
|
||||||
"konva": "^10.9.0",
|
"konva": "^10.9.0",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^1.61.0",
|
"lucide-react": "^1.64.0",
|
||||||
"monaco-yaml": "^5.5.1",
|
"monaco-yaml": "^5.5.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-apexcharts": "^2.1.1",
|
"react-apexcharts": "^2.1.2",
|
||||||
"react-day-picker": "^10.0.2",
|
"react-day-picker": "^10.0.2",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-dropzone": "^20.1.4",
|
"react-dropzone": "^20.1.4",
|
||||||
"react-grid-layout": "^2.3.0",
|
"react-grid-layout": "^2.3.0",
|
||||||
"react-hook-form": "^7.90.0",
|
"react-hook-form": "^7.91.0",
|
||||||
"react-i18next": "^17.1.1",
|
"react-i18next": "^17.1.9",
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-konva": "^19.3.2",
|
"react-konva": "^19.3.2",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.5",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
"react-zoom-pan-pinch": "3.4.4",
|
"react-zoom-pan-pinch": "3.4.4",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-long-press": "^3.3.0",
|
"use-long-press": "^3.3.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"virtua": "^0.53.6",
|
"virtua": "^0.54.0",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
@@ -90,11 +90,11 @@
|
|||||||
"@playwright/test": "^1.64.1",
|
"@playwright/test": "^1.64.1",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash": "^4.17.27",
|
"@types/lodash": "^4.17.27",
|
||||||
"@types/node": "^26.6.5",
|
"@types/node": "^26.6.6",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
"autoprefixer": "^10.6.1",
|
"autoprefixer": "^10.6.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^10.13.0",
|
"eslint": "^10.13.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -105,12 +105,12 @@
|
|||||||
"i18next-cli": "^1.5.11",
|
"i18next-cli": "^1.5.11",
|
||||||
"monaco-editor": "^0.53.0",
|
"monaco-editor": "^0.53.0",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"postcss": "^8.5.31",
|
"postcss": "^8.5.32",
|
||||||
"prettier": "^3.9.10",
|
"prettier": "^3.9.10",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.72.0",
|
"typescript-eslint": "^8.72.1",
|
||||||
"vite": "^8.3.5"
|
"vite": "^8.3.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -428,9 +428,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cycjimmy/jsmpeg-player": {
|
"node_modules/@cycjimmy/jsmpeg-player": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.6.tgz",
|
||||||
"integrity": "sha512-U9DBDe5fxHmbwQww9rFxMLNI2Wlg7DhPzI7AVFpq8GehiUP7+NwuMPXpP4zAd52sgkxtOqOeMjgE5g0ZLnQZ0w==",
|
"integrity": "sha512-D3ZfarlwDAltkBzmYaAAvARqRvgN4DmM70fbYwRECh9gpT8IRzLSxsXcR0uoPj/W3VtFeQjwA1QBj+rDpXiUzA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@date-fns/tz": {
|
"node_modules/@date-fns/tz": {
|
||||||
@@ -1059,9 +1059,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-6.0.2.tgz",
|
||||||
"integrity": "sha512-6iBzQnB+YwAveVc8HUi6QYLITn5X+xNKRMFIcNXHZlvp2aTI7yWbj4n/JodXxirMCcBc4VCjb2aYAJh4x07mVw==",
|
"integrity": "sha512-e2dqUOlaGuirKVOd/iZWk/Yg5zeaGw7t0hSc3LzxL3o7eZGwZkTyEbBL8H52m0wRKEsNLWoMGOiXGHbPHTXlEQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/utils": "^0.3.0"
|
"@standard-schema/utils": "^0.3.0"
|
||||||
@@ -1069,7 +1069,6 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@sinclair/typebox": ">=0.25.24",
|
"@sinclair/typebox": ">=0.25.24",
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@typeschema/main": ">=0.13.7",
|
|
||||||
"@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
"@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
"ajv-errors": "^3.0.0",
|
"ajv-errors": "^3.0.0",
|
||||||
@@ -1100,9 +1099,6 @@
|
|||||||
"@standard-schema/spec": {
|
"@standard-schema/spec": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@typeschema/main": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@vinejs/vine": {
|
"@vinejs/vine": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -2990,9 +2986,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rjsf/utils/node_modules/fast-uri": {
|
"node_modules/@rjsf/utils/node_modules/fast-uri": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.3.tgz",
|
||||||
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
|
"integrity": "sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3611,9 +3607,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.6.5",
|
"version": "26.6.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||||
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
|
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3661,17 +3657,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz",
|
||||||
"integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==",
|
"integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.72.0",
|
"@typescript-eslint/scope-manager": "8.72.1",
|
||||||
"@typescript-eslint/type-utils": "8.72.0",
|
"@typescript-eslint/type-utils": "8.72.1",
|
||||||
"@typescript-eslint/utils": "8.72.0",
|
"@typescript-eslint/utils": "8.72.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
@@ -3684,15 +3680,15 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.72.0",
|
"@typescript-eslint/parser": "^8.72.1",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
"version": "7.0.13",
|
"version": "7.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz",
|
||||||
"integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==",
|
"integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3700,16 +3696,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz",
|
||||||
"integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==",
|
"integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.72.0",
|
"@typescript-eslint/scope-manager": "8.72.1",
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
"@typescript-eslint/typescript-estree": "8.72.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3725,14 +3721,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
|
||||||
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
|
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.72.0",
|
"@typescript-eslint/tsconfig-utils": "^8.72.1",
|
||||||
"@typescript-eslint/types": "^8.72.0",
|
"@typescript-eslint/types": "^8.72.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3747,14 +3743,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
|
||||||
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
|
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.72.0"
|
"@typescript-eslint/visitor-keys": "8.72.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3765,9 +3761,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
|
||||||
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
|
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3782,15 +3778,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz",
|
||||||
"integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==",
|
"integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
"@typescript-eslint/typescript-estree": "8.72.1",
|
||||||
"@typescript-eslint/utils": "8.72.0",
|
"@typescript-eslint/utils": "8.72.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
},
|
},
|
||||||
@@ -3807,9 +3803,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
|
||||||
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
|
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3821,16 +3817,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
|
||||||
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
|
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.72.0",
|
"@typescript-eslint/project-service": "8.72.1",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.72.0",
|
"@typescript-eslint/tsconfig-utils": "8.72.1",
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -3849,16 +3845,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
|
||||||
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
|
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.72.0",
|
"@typescript-eslint/scope-manager": "8.72.1",
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.72.0"
|
"@typescript-eslint/typescript-estree": "8.72.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3873,13 +3869,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
|
||||||
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
|
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4067,9 +4063,9 @@
|
|||||||
"license": "SEE LICENSE IN LICENSE.md"
|
"license": "SEE LICENSE IN LICENSE.md"
|
||||||
},
|
},
|
||||||
"node_modules/apexcharts": {
|
"node_modules/apexcharts": {
|
||||||
"version": "8.4.1",
|
"version": "8.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.5.0.tgz",
|
||||||
"integrity": "sha512-29IbZ1rn3NRkDvete5EzZXy6yDSW7mjNXkytLpvaYHk0XbfsR+FvC7U78L3wMvJa699NBmix2z6r3Qm5YDvw0A==",
|
"integrity": "sha512-mH9QiOwIeRGg635OzAc2mznuwrK9XHr9S1RcC26GNNOU6fUPe64cMc7hXGcM9bXz92nO4Ax7P6uxSYaTA7gBDw==",
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"apex-commons": "^0.8.3"
|
"apex-commons": "^0.8.3"
|
||||||
@@ -4126,9 +4122,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.6.1",
|
"version": "10.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.2.tgz",
|
||||||
"integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==",
|
"integrity": "sha512-VOw9/yGTGwPPgzFcODtcv6zhEPIeIWkRSe10L2ri2f5vZ7ID7lCt6jZzRF5/ubgOZ+szYydmEF5AYsA5jxX1tA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4163,9 +4159,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.20.0",
|
"version": "1.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.21.0.tgz",
|
||||||
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
"integrity": "sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
@@ -5399,9 +5395,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5515,9 +5511,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
|
||||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -5582,13 +5578,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "14.6.0",
|
"version": "14.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.7.1.tgz",
|
||||||
"integrity": "sha512-MzBgZxGgoHPfSLEXmo4o5XlZvb+nVRFdHtGjVhm6LMIEJKTt/8hNO023euL5mYyzc8llV/hQTfjnN68tNRHalw==",
|
"integrity": "sha512-mFDYZxyfeZnpvL/zEQEqLZNYVA/ya47c9TBfZb/PEoNBBVSVvCLkMrCv9U38cuw72W161XENZwZB0+4nIBLEJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-dom": "14.6.0",
|
"motion-dom": "14.7.1",
|
||||||
"motion-utils": "14.6.0",
|
"motion-utils": "14.7.0",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -5949,9 +5945,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hls.js": {
|
"node_modules/hls.js": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.4.tgz",
|
||||||
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
|
"integrity": "sha512-3GlxGdPvT2YHvQYOt+yOzT/DEior/qJVNIylHA77kgeoBTdQQqi70GwefNEUmHQeNGyjC8U62+bruqW/Tpm+tg==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/hookified": {
|
"node_modules/hookified": {
|
||||||
@@ -5991,9 +5987,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.5.5",
|
"version": "26.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||||
"integrity": "sha512-txdaNOlnZCdiTMSlEzl5hUJU8TC9R4J/RCR1G7XrDPbWT2t3mR142QPz84ZuZeXDt3yq+9zmsdPiakxYtmp3fw==",
|
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6106,9 +6102,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-http-backend": {
|
"node_modules/i18next-http-backend": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.6.tgz",
|
||||||
"integrity": "sha512-nG9OQKvUsPiTVUBg1rCGmvmWhzxC9GOxVbGdTYyejHVMSUjutt1hiBNVSzhB/ZE7keL9Xkb880kUfhDovV5T5g==",
|
"integrity": "sha512-k5OPDXAQH9CZ2Ju2DDw1ZC4WiYmjKUjOE4lcd51tNQyWjVDfpuwPcE9Y5VzDpz1tR1Z4wxkkj4H+ImxgwbfpeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -6978,9 +6974,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.61.0",
|
"version": "1.64.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.64.0.tgz",
|
||||||
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
"integrity": "sha512-v626F/bcLNvFom6vzztHILtG9tUgXXbgA5AGkd82YkV401+Ko/NHyrWafbPwGofa+6p/EgDJcvEkIwsMH9098w==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -8055,18 +8051,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "14.6.0",
|
"version": "14.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.7.1.tgz",
|
||||||
"integrity": "sha512-lIojTl9aYSPIkeOOSzBliy1nNvpdO3JtVzW2WgTXQlOP4ZMuJhtWO3fP7OD4TVFwlRucdvxlDSMkL+fEQnQ52A==",
|
"integrity": "sha512-p9q9F7+HoijbQjUG/cSGNmWS2t1OaAKxkd2RG5gQJRoDwoTap/Khb7K1P5rLW90crnSmx0rcRXzh3Jd5UNrpEw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-utils": "14.6.0"
|
"motion-utils": "14.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-utils": {
|
"node_modules/motion-utils": {
|
||||||
"version": "14.6.0",
|
"version": "14.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.7.0.tgz",
|
||||||
"integrity": "sha512-bZJo2AmQ2aOFkaato6VXrTbkPG0XhMzivWUWSaI59pxyllj8QMQUFZ84MYRQnPkK+7dr4KldyJwF2+RM0sdq/w==",
|
"integrity": "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
@@ -8548,9 +8544,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.31",
|
"version": "8.5.32",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||||
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
|
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -8669,9 +8665,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss-selector-parser": {
|
"node_modules/postcss-selector-parser": {
|
||||||
"version": "6.1.4",
|
"version": "6.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz",
|
||||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
"integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
@@ -8920,10 +8916,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-apexcharts": {
|
"node_modules/react-apexcharts": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-2.1.2.tgz",
|
||||||
"integrity": "sha512-aV25n+1ZdAzRG0KhODVC48K8WbhMKBAr3yWSeaaBqM1ySn22kSSKblsNCUvlwegsT+cdMLuOrK9xGZb+IK+OkQ==",
|
"integrity": "sha512-vIoltFDOxy/EIdyWam833mgEfZiOQ+R6oqCvRgbGqNNTNNITCKL91CNeXThi2L8+YyxbamxyWY/6aZV66mx1fA==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
@@ -9037,9 +9033,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.90.0",
|
"version": "7.91.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.90.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.91.0.tgz",
|
||||||
"integrity": "sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==",
|
"integrity": "sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -9059,9 +9055,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.1.1",
|
"version": "17.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||||
"integrity": "sha512-Q76xpzxrZNcqhB7M31Ns7isulGOlmhURqRsJOGlYgJJCyoy1pMq6tRl8KQycGNNY1vPaxbIdrogrqY9nkb+SEA==",
|
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.7",
|
"@babel/runtime": "^7.29.7",
|
||||||
@@ -9235,9 +9231,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.4",
|
"version": "7.18.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.5.tgz",
|
||||||
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
"integrity": "sha512-dTgvcXDOrLPrlLHrcZAqWveiK1hMSqN+rGAJzegP8HtmNkfRxOoshGWBNX3ZJ0cZjOHkRdlUJtaIhZMLLyGyPw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -9257,12 +9253,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.18.4",
|
"version": "7.18.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.5.tgz",
|
||||||
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
"integrity": "sha512-9HcfDMU6JJ/fp6gJZYGwNE6RknVGS4kBjH1HkNw959mCJi70TVwdipQWv2FEol9uGpi+SZTFtVpJPtEQfayeOw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.18.4"
|
"react-router": "7.18.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -9586,9 +9582,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
@@ -10062,16 +10058,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz",
|
||||||
"integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==",
|
"integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.72.0",
|
"@typescript-eslint/eslint-plugin": "8.72.1",
|
||||||
"@typescript-eslint/parser": "8.72.0",
|
"@typescript-eslint/parser": "8.72.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
"@typescript-eslint/typescript-estree": "8.72.1",
|
||||||
"@typescript-eslint/utils": "8.72.0"
|
"@typescript-eslint/utils": "8.72.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -10364,9 +10360,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/virtua": {
|
"node_modules/virtua": {
|
||||||
"version": "0.53.6",
|
"version": "0.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.53.6.tgz",
|
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.54.0.tgz",
|
||||||
"integrity": "sha512-/vQtSFNDFcp3Yz4klbpe8Z88eQeThSw49cFlgJQdsqh3SUnRlW4jLi6ZYt4YzdwerYGlP6KCgAOXNVO+PmUg7w==",
|
"integrity": "sha512-8nGGrDhX61MDVeqgyIaWrJqhRm9z2Nqa1WDqt0tk1m+ZWrvLesMP0iL0vL6xNsLCF2jE2ohwY+PqufdbVmRgEw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": ">=20.0.0",
|
"@angular/common": ">=20.0.0",
|
||||||
|
|||||||
+18
-18
@@ -21,8 +21,8 @@
|
|||||||
"i18n:status": "i18next-cli status"
|
"i18n:status": "i18next-cli status"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
"@cycjimmy/jsmpeg-player": "^6.1.6",
|
||||||
"@hookform/resolvers": "^6.0.0",
|
"@hookform/resolvers": "^6.0.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||||
"@radix-ui/react-checkbox": "^1.4.1",
|
"@radix-ui/react-checkbox": "^1.4.1",
|
||||||
@@ -49,39 +49,39 @@
|
|||||||
"@rjsf/shadcn": "^6.10.1",
|
"@rjsf/shadcn": "^6.10.1",
|
||||||
"@rjsf/utils": "^6.10.1",
|
"@rjsf/utils": "^6.10.1",
|
||||||
"@rjsf/validator-ajv8": "^6.10.1",
|
"@rjsf/validator-ajv8": "^6.10.1",
|
||||||
"apexcharts": "^8.4.1",
|
"apexcharts": "^8.5.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.21.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"framer-motion": "^14.6.0",
|
"framer-motion": "^14.7.1",
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.4",
|
||||||
"i18next": "^26.5.5",
|
"i18next": "^26.5.11",
|
||||||
"i18next-http-backend": "^4.0.4",
|
"i18next-http-backend": "^4.0.6",
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"js-yaml": "^5.5.0",
|
"js-yaml": "^5.5.0",
|
||||||
"konva": "^10.9.0",
|
"konva": "^10.9.0",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^1.61.0",
|
"lucide-react": "^1.64.0",
|
||||||
"monaco-yaml": "^5.5.1",
|
"monaco-yaml": "^5.5.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-apexcharts": "^2.1.1",
|
"react-apexcharts": "^2.1.2",
|
||||||
"react-day-picker": "^10.0.2",
|
"react-day-picker": "^10.0.2",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-dropzone": "^20.1.4",
|
"react-dropzone": "^20.1.4",
|
||||||
"react-grid-layout": "^2.3.0",
|
"react-grid-layout": "^2.3.0",
|
||||||
"react-hook-form": "^7.90.0",
|
"react-hook-form": "^7.91.0",
|
||||||
"react-i18next": "^17.1.1",
|
"react-i18next": "^17.1.9",
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-konva": "^19.3.2",
|
"react-konva": "^19.3.2",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.5",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
"react-zoom-pan-pinch": "3.4.4",
|
"react-zoom-pan-pinch": "3.4.4",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-long-press": "^3.3.0",
|
"use-long-press": "^3.3.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"virtua": "^0.53.6",
|
"virtua": "^0.54.0",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
@@ -102,11 +102,11 @@
|
|||||||
"@playwright/test": "^1.64.1",
|
"@playwright/test": "^1.64.1",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash": "^4.17.27",
|
"@types/lodash": "^4.17.27",
|
||||||
"@types/node": "^26.6.5",
|
"@types/node": "^26.6.6",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
"autoprefixer": "^10.6.1",
|
"autoprefixer": "^10.6.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^10.13.0",
|
"eslint": "^10.13.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -117,12 +117,12 @@
|
|||||||
"i18next-cli": "^1.5.11",
|
"i18next-cli": "^1.5.11",
|
||||||
"monaco-editor": "^0.53.0",
|
"monaco-editor": "^0.53.0",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"postcss": "^8.5.31",
|
"postcss": "^8.5.32",
|
||||||
"prettier": "^3.9.10",
|
"prettier": "^3.9.10",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.72.0",
|
"typescript-eslint": "^8.72.1",
|
||||||
"vite": "^8.3.5"
|
"vite": "^8.3.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -858,6 +858,10 @@
|
|||||||
"label": "Cooldown period",
|
"label": "Cooldown period",
|
||||||
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
|
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
|
||||||
},
|
},
|
||||||
|
"ttl": {
|
||||||
|
"label": "Notification TTL",
|
||||||
|
"description": "How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour."
|
||||||
|
},
|
||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "Original notifications state",
|
"label": "Original notifications state",
|
||||||
"description": "Indicates whether notifications were enabled in the original static configuration."
|
"description": "Indicates whether notifications were enabled in the original static configuration."
|
||||||
|
|||||||
@@ -154,6 +154,10 @@
|
|||||||
"label": "Cooldown period",
|
"label": "Cooldown period",
|
||||||
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
|
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
|
||||||
},
|
},
|
||||||
|
"ttl": {
|
||||||
|
"label": "Notification TTL",
|
||||||
|
"description": "How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour."
|
||||||
|
},
|
||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "Original notifications state",
|
"label": "Original notifications state",
|
||||||
"description": "Indicates whether notifications were enabled in the original static configuration."
|
"description": "Indicates whether notifications were enabled in the original static configuration."
|
||||||
|
|||||||
@@ -1934,6 +1934,10 @@
|
|||||||
"saved": "Saved"
|
"saved": "Saved"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notificationTtl": {
|
||||||
|
"immediate": "Immediate",
|
||||||
|
"reliable": "Reliable"
|
||||||
|
},
|
||||||
"retainMode": {
|
"retainMode": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"motion": "Motion",
|
"motion": "Motion",
|
||||||
@@ -2051,7 +2055,8 @@
|
|||||||
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
|
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera."
|
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera.",
|
||||||
|
"ttlDelay": "Notifications can arrive up to 30 seconds late and still link to the live view after the review item has ended."
|
||||||
},
|
},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
|
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const notifications: SectionConfigOverrides = {
|
|||||||
fieldOrder: ["enabled", "email"],
|
fieldOrder: ["enabled", "email"],
|
||||||
fieldGroups: {},
|
fieldGroups: {},
|
||||||
hiddenFields: ["enabled_in_config"],
|
hiddenFields: ["enabled_in_config"],
|
||||||
advancedFields: [],
|
advancedFields: ["ttl"],
|
||||||
fieldMessages: [
|
fieldMessages: [
|
||||||
{
|
{
|
||||||
key: "profile-base-notifications-disabled",
|
key: "profile-base-notifications-disabled",
|
||||||
@@ -24,6 +24,14 @@ const notifications: SectionConfigOverrides = {
|
|||||||
camera.notifications.enabled_in_config,
|
camera.notifications.enabled_in_config,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "notifications-ttl-delay",
|
||||||
|
field: "ttl",
|
||||||
|
messageKey: "configMessages.notifications.ttlDelay",
|
||||||
|
severity: "warning",
|
||||||
|
position: "after",
|
||||||
|
condition: (ctx) => ctx.formData?.ttl === "reliable",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
@@ -31,12 +39,13 @@ const notifications: SectionConfigOverrides = {
|
|||||||
"ui:before": { render: "NotificationsSettingsExtras" },
|
"ui:before": { render: "NotificationsSettingsExtras" },
|
||||||
enabled: { "ui:widget": "hidden" },
|
enabled: { "ui:widget": "hidden" },
|
||||||
email: { "ui:widget": "hidden" },
|
email: { "ui:widget": "hidden" },
|
||||||
|
ttl: { "ui:options": { enumI18nPrefix: "notificationTtl" } },
|
||||||
cooldown: { "ui:widget": "hidden" },
|
cooldown: { "ui:widget": "hidden" },
|
||||||
enabled_in_config: { "ui:widget": "hidden" },
|
enabled_in_config: { "ui:widget": "hidden" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
hiddenFields: ["enabled_in_config", "email"],
|
hiddenFields: ["enabled_in_config", "email", "ttl"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export default function NotificationsSettingsExtras({
|
|||||||
set(nextNotifications, "enabled", desiredEnabled);
|
set(nextNotifications, "enabled", desiredEnabled);
|
||||||
const sanitizedNotifications = sanitizeSectionData(
|
const sanitizedNotifications = sanitizeSectionData(
|
||||||
nextNotifications as ConfigSectionData,
|
nextNotifications as ConfigSectionData,
|
||||||
["enabled_in_config", "email"],
|
["enabled_in_config", "email", "ttl"],
|
||||||
);
|
);
|
||||||
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
|
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
|
||||||
pendingCameraOverridesRef.current.add(camera.name);
|
pendingCameraOverridesRef.current.add(camera.name);
|
||||||
@@ -490,7 +490,7 @@ export default function NotificationsSettingsExtras({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col md:flex-row">
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto md:order-none">
|
||||||
<div className={cn("w-full max-w-5xl space-y-6")}>
|
<div className={cn("w-full max-w-5xl space-y-6")}>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<SettingsGroupCard
|
<SettingsGroupCard
|
||||||
|
|||||||
Reference in New Issue
Block a user