Compare commits

..
79 changed files with 1053 additions and 1417 deletions
-3
View File
@@ -55,9 +55,6 @@ updates:
# Needs Python 3.12 and numpy 2 # Needs Python 3.12 and numpy 2
- dependency-name: "librosa" - dependency-name: "librosa"
versions: [">=1.0"] versions: [">=1.0"]
# pandas 3.1 requires numpy 2
- dependency-name: "pandas"
versions: [">=3.1"]
# Needs Python 3.12 # Needs Python 3.12
- dependency-name: "scipy" - dependency-name: "scipy"
versions: [">=1.18"] versions: [">=1.18"]
+1 -1
View File
@@ -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 frigate.test" run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
+4 -4
View File
@@ -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 frigate.test` to run the suite in parallel groups - **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
- **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 frigate.test` (parallel groups; plain `python3 -u -m unittest` runs serially) - **Run Command**: `python3 -u -m unittest`
- **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 in parallel groups (-j to set the number of jobs) # Run all tests
python3 -u -m frigate.test python3 -u -m unittest
# 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
View File
@@ -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 frigate.test` (runs the suite in parallel groups) - **Testing**: `python3 -u -m unittest`
- **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 frigate.test # Run all tests in parallel groups python3 -u -m unittest # Run all tests
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
+1 -1
View File
@@ -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 frigate.test python3 -u -m unittest
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,2 +1,2 @@
onnx == 1.14.0; platform_machine == 'aarch64' onnx == 1.14.0; platform_machine == 'aarch64'
protobuf == 5.29.6; platform_machine == 'aarch64' protobuf == 3.20.3; platform_machine == 'aarch64'
@@ -504,6 +504,9 @@ review:
# Presets adjust the tone and level of detail of the user-facing title, # Presets adjust the tone and level of detail of the user-facing title,
# summary, and scene description; "default" leaves the built-in prompt unchanged. # summary, and scene description; "default" leaves the built-in prompt unchanged.
response_style: default response_style: default
# Optional: Let the model think before writing the description (default: shown below)
# Only applies to models with a per-request thinking toggle (ollama and llamacpp providers).
enable_thinking: False
# Optional: Save thumbnails sent to the GenAI provider for review/debugging purposes (default: shown below) # Optional: Save thumbnails sent to the GenAI provider for review/debugging purposes (default: shown below)
debug_save_thumbnails: False debug_save_thumbnails: False
@@ -578,10 +581,6 @@ 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
+8 -9
View File
@@ -75,12 +75,11 @@ 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) | Review [response style](/configuration/genai/genai_review#response-style) | Notes | | Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | Notes |
| ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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-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.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. | | `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. |
| `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. | | `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. |
| `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
@@ -96,7 +95,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. |
@@ -120,7 +119,7 @@ Vision-language models come in **instruct** variants (fine-tuned to follow instr
Frigate manages reasoning per task automatically: Frigate manages reasoning per task automatically:
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle. - **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle. Review descriptions can opt back in with [`enable_thinking`](/configuration/genai/genai_review#thinking) for models trained to reason over video.
- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it. - **Chat** lets you toggle thinking on or off from the composer when the configured model supports it.
You can use a pure instruct, hybrid, or thinking-capable model with Frigate. No extra configuration is required to disable thinking for descriptions. You can use a pure instruct, hybrid, or thinking-capable model with Frigate. No extra configuration is required to disable thinking for descriptions.
@@ -511,7 +510,7 @@ Frigate's Generative AI features are configured and enabled separately. [Review
5. Save the exact images and prompts that were sent to your provider. 5. Save the exact images and prompts that were sent to your provider.
- Turn on **Save thumbnails** for the feature you are debugging (`review.genai.debug_save_thumbnails` or `objects.genai.debug_save_thumbnails`). Both features write to `/media/frigate/clips/genai-requests/`, and these files are admin-only. - Turn on **Save thumbnails** for the feature you are debugging (`review.genai.debug_save_thumbnails` or `objects.genai.debug_save_thumbnails`). Both features write to `/media/frigate/clips/genai-requests/`, and these files are admin-only.
- Review descriptions write `genai-requests/<review_id>/` containing the numbered frames that were sent, plus `prompt.txt` and `response.txt` with the exact prompt and the raw, unparsed model response. - Review descriptions write `genai-requests/<review_id>/` containing the numbered frames that were sent, plus `prompt.txt` and `response.txt` with the exact prompt and the raw, unparsed model response. When the provider returns a reasoning trace (for example with [thinking](/configuration/genai/genai_review#thinking) enabled), it is saved to `reasoning.txt`.
- Review summary reports write `genai-requests/<start_ts>-<end_ts>/prompt.txt` and `response.txt`. No images are involved, since a report summarizes existing review descriptions. - Review summary reports write `genai-requests/<start_ts>-<end_ts>/prompt.txt` and `response.txt`. No images are involved, since a report summarizes existing review descriptions.
- Object descriptions write `genai-requests/<event_id>/` containing the numbered thumbnails. The prompt for object descriptions is not written to a file, it is only visible in the debug logs from step 4. - Object descriptions write `genai-requests/<event_id>/` containing the numbered thumbnails. The prompt for object descriptions is not written to a file, it is only visible in the debug logs from step 4.
- Look at the saved images before blaming the model. If the object is small, blurry, or out of frame, no prompt will fix the result. For object descriptions, consider turning on **Use snapshots** (`objects.genai.use_snapshot`) to send a higher quality image. For review items, consider setting **Review image source** (`review.genai.image_source`) to `recordings` for 480p frames instead of the lower resolution preview frames. - Look at the saved images before blaming the model. If the object is small, blurry, or out of frame, no prompt will fix the result. For object descriptions, consider turning on **Use snapshots** (`objects.genai.use_snapshot`) to send a higher quality image. For review items, consider setting **Review image source** (`review.genai.image_source`) to `recordings` for 480p frames instead of the lower resolution preview frames.
@@ -264,6 +264,36 @@ review:
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
### Thinking
By default, Frigate turns thinking off for review descriptions, since most models write better descriptions without spending tokens on a reasoning trace. Some models are trained to reason over video, and may follow complex scenes more accurately when allowed to think first. The `enable_thinking` option lets you turn thinking on for review descriptions to evaluate this with your model.
`enable_thinking` only applies to models that expose a per-request thinking toggle with the `ollama` or `llamacpp` providers. For llama.cpp, the model's chat template must use the `enable_thinking` variable. Other models keep their default behavior. With `openai`, use `runtime_options` (for example `reasoning_effort`) to control reasoning instead.
Thinking makes each review description take longer and use more tokens. If responses come back empty, the model may be using its whole output budget on reasoning.
When **Save thumbnails** (`debug_save_thumbnails`) is enabled, the reasoning trace is saved to `reasoning.txt` alongside `prompt.txt` and `response.txt`, so you can see what the model considered before answering. The trace is saved even when the model returns no answer. To compare a saved example with thinking on and off, use `testing-scripts/genai_review_tester.py` in the Frigate repository.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Review" />.
- Enable **GenAI config > Enable thinking**. This option is only shown when the provider assigned the descriptions role is `ollama` or `llamacpp`.
</TabItem>
<TabItem value="yaml">
```yaml {4}
review:
genai:
enabled: true
enable_thinking: true
```
</TabItem>
</ConfigTabs>
## Review Reports ## Review Reports
Along with individual review item summaries, Generative AI can also produce a single report of review items from all cameras marked "suspicious" over a specified time period (for example, a daily summary of suspicious activity while you're on vacation). Along with individual review item summaries, Generative AI can also produce a single report of review items from all cameras marked "suspicious" over a specified time period (for example, a daily summary of suspicious activity while you're on vacation).
+1 -24
View File
@@ -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" />. 1. Navigate to <NavPath path="Settings > Notifications > 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,29 +108,6 @@ 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.
+2 -2
View File
@@ -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 frigate.test python3 -u -m unittest
``` ```
#### 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 frigate.test python3 -u -m unittest
``` ```
- 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
View File
@@ -6371,7 +6371,6 @@ paths:
required: false required: false
schema: schema:
type: integer type: integer
minimum: 0
description: Padding to apply to the vod. description: Padding to apply to the vod.
default: 0 default: 0
title: Padding title: Padding
@@ -7577,7 +7576,6 @@ paths:
required: false required: false
schema: schema:
type: integer type: integer
minimum: 1
default: 30 default: 30
title: Scale title: Scale
responses: responses:
+1 -6
View File
@@ -934,8 +934,7 @@ 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
base_path = request.headers.get("x-ingress-path", "").rstrip("/") response = RedirectResponse("/login", status_code=303)
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
@@ -966,10 +965,6 @@ def login(request: Request, body: AppPostLoginBody):
try: try:
db_user: User = User.get_by_id(user) db_user: User = User.get_by_id(user)
except DoesNotExist: except DoesNotExist:
# Hash anyway so response timing doesn't reveal which usernames exist
hash_password(
password, iterations=request.app.frigate_config.auth.hash_iterations
)
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}") logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
failed_logins.record(user, time.time()) failed_logins.record(user, time.time())
return JSONResponse(content={"message": "Login failed"}, status_code=401) return JSONResponse(content={"message": "Login failed"}, status_code=401)
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema from pydantic.json_schema import SkipJsonSchema
@@ -11,7 +11,7 @@ class MediaRecordingsAvailabilityQueryParams(BaseModel):
cameras: str = "all" cameras: str = "all"
before: float | SkipJsonSchema[None] = None before: float | SkipJsonSchema[None] = None
after: float | SkipJsonSchema[None] = None after: float | SkipJsonSchema[None] = None
scale: int = Field(default=30, ge=1) scale: int = 30
class RecordingsDeleteQueryParams(BaseModel): class RecordingsDeleteQueryParams(BaseModel):
+4 -4
View File
@@ -291,12 +291,12 @@ def events(
clauses.append( clauses.append(
reduce( reduce(
operator.or_, operator.or_,
[(start_hour_fun >= time_after), (start_hour_fun < time_before)], [(start_hour_fun > time_after), (start_hour_fun < time_before)],
) )
) )
# all other cases should be and operator # all other cases should be and operator
else: else:
clauses.append(start_hour_fun >= time_after) clauses.append(start_hour_fun > time_after)
clauses.append(start_hour_fun < time_before) clauses.append(start_hour_fun < time_before)
if has_clip is not None: if has_clip is not None:
@@ -749,12 +749,12 @@ def events_search(
event_filters.append( event_filters.append(
reduce( reduce(
operator.or_, operator.or_,
[(start_hour_fun >= time_after), (start_hour_fun < time_before)], [(start_hour_fun > time_after), (start_hour_fun < time_before)],
) )
) )
# all other cases should be and operator # all other cases should be and operator
else: else:
event_filters.append(start_hour_fun >= time_after) event_filters.append(start_hour_fun > time_after)
event_filters.append(start_hour_fun < time_before) event_filters.append(start_hour_fun < time_before)
# Perform semantic search # Perform semantic search
+1 -1
View File
@@ -1227,7 +1227,7 @@ def bulk_delete_exports(body: ExportBulkDeleteBody):
files_in_use = _get_files_in_use() files_in_use = _get_files_in_use()
for export in exports: for export in exports:
if export.in_progress or export.video_path.split("/")[-1] in files_in_use: if export.video_path.split("/")[-1] in files_in_use:
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
+5 -9
View File
@@ -862,7 +862,7 @@ async def vod_hour(
async def vod_event( async def vod_event(
request: Request, request: Request,
event_id: str, event_id: str,
padding: int = Query(0, ge=0, description="Padding to apply to the vod."), padding: int = Query(0, description="Padding to apply to the vod."),
): ):
try: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
@@ -1521,7 +1521,6 @@ async def preview_gif(
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
) )
.where(Previews.camera == camera_name) .where(Previews.camera == camera_name)
.order_by(Previews.start_time.asc())
.limit(1) .limit(1)
.get() .get()
) )
@@ -1531,8 +1530,7 @@ async def preview_gif(
status_code=404, status_code=404,
) )
clip_start = max(start_ts, preview.start_time) diff = start_ts - preview.start_time
diff = clip_start - preview.start_time
minutes = int(diff / 60) minutes = int(diff / 60)
seconds = int(diff % 60) seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config config: FrigateConfig = request.app.frigate_config
@@ -1544,7 +1542,7 @@ async def preview_gif(
"-ss", "-ss",
f"00:{minutes}:{seconds}", f"00:{minutes}:{seconds}",
"-t", "-t",
f"{end_ts - clip_start}", f"{end_ts - start_ts}",
"-i", "-i",
preview.path, preview.path,
"-r", "-r",
@@ -1711,7 +1709,6 @@ async def preview_mp4(
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
) )
.where(Previews.camera == camera_name) .where(Previews.camera == camera_name)
.order_by(Previews.start_time.asc())
.limit(1) .limit(1)
.get() .get()
) )
@@ -1724,8 +1721,7 @@ async def preview_mp4(
status_code=404, status_code=404,
) )
clip_start = max(start_ts, preview.start_time) diff = start_ts - preview.start_time
diff = clip_start - preview.start_time
minutes = int(diff / 60) minutes = int(diff / 60)
seconds = int(diff % 60) seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config config: FrigateConfig = request.app.frigate_config
@@ -1738,7 +1734,7 @@ async def preview_mp4(
"-ss", "-ss",
f"00:{minutes}:{seconds}", f"00:{minutes}:{seconds}",
"-t", "-t",
f"{end_ts - clip_start}", f"{end_ts - start_ts}",
"-i", "-i",
preview.path, preview.path,
"-r", "-r",
+2 -1
View File
@@ -163,7 +163,8 @@ async def review(
# Apply ordering and limit # Apply ordering and limit
review_query = ( review_query = (
review_query.order_by(ReviewSegment.start_time.desc()) review_query.order_by(ReviewSegment.severity.asc())
.order_by(ReviewSegment.start_time.desc())
.limit(limit) .limit(limit)
.dicts() .dicts()
.iterator() .iterator()
+1 -2
View File
@@ -22,7 +22,6 @@ 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,
@@ -624,7 +623,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 NOTIFICATION_TTL_SECONDS[self.config.notifications.ttl] ttl = 3600 if ended else 0
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}") logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
+1 -19
View File
@@ -1,23 +1,10 @@
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", "NotificationTTLEnum", "NOTIFICATION_TTL_SECONDS"] __all__ = ["NotificationConfig"]
class NotificationTTLEnum(str, Enum):
immediate = "immediate"
reliable = "reliable"
NOTIFICATION_TTL_SECONDS = {
NotificationTTLEnum.immediate: 0,
NotificationTTLEnum.reliable: 30,
}
class NotificationConfig(FrigateBaseModel): class NotificationConfig(FrigateBaseModel):
@@ -37,11 +24,6 @@ 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",
+5
View File
@@ -171,6 +171,11 @@ class GenAIReviewConfig(FrigateBaseModel):
title="Response style", title="Response style",
description="Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.", description="Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.",
) )
enable_thinking: bool = Field(
default=False,
title="Enable thinking",
description="Let the model reason before writing the review description. Only applies to models with a per-request thinking toggle (Ollama and llama.cpp providers); other models keep their default behavior. Thinking increases response time and token usage. When thumbnail saving is enabled, the reasoning is saved alongside the response.",
)
activity_context_prompt: str = Field( activity_context_prompt: str = Field(
default="""### Normal Activity Indicators (Level 0) default="""### Normal Activity Indicators (Level 0)
- Known/verified people in any zone at any time - Known/verified people in any zone at any time
@@ -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__)
@@ -78,7 +80,13 @@ def describe_heading(dx: float, dy: float) -> str:
def event_name(event: dict[str, Any]) -> str: def event_name(event: dict[str, Any]) -> str:
"""Name an object for the notes, e.g. 'a person' or 'waste bin "Compost"'. """Name an object for the notes, e.g. 'a person' or 'Nick'.
A recognized sub_label is used on its own, in the same title case as
"Objects in Scene", so the notes name the subject the way the response
should. The object type is left out: leading with it makes models copy
"person" and drop the name from titles. An unrecognized face is stored as
"unknown" and stays unnamed.
Objects are never numbered or given track identifiers. Frigate opens a new Objects are never numbered or given track identifiers. Frigate opens a new
tracked object whenever a subject is re-detected, so the tracking data tracked object whenever a subject is re-detected, so the tracking data
@@ -86,10 +94,10 @@ def event_name(event: dict[str, Any]) -> str:
ambiguous rather than implying either answer. ambiguous rather than implying either answer.
""" """
label = str(event["label"]).replace("_", " ").replace("-verified", "") label = str(event["label"]).replace("_", " ").replace("-verified", "")
sub_label = event.get("sub_label") sub_label = str(event.get("sub_label") or "").strip()
if sub_label: if sub_label and sub_label.lower() != "unknown":
return f'{label} "{sub_label}"' return titlecase(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}"
@@ -387,11 +395,10 @@ def build_frame_captions(
# audio and manual review items can have state changes but no tracked objects # audio and manual review items can have state changes but no tracked objects
if events: if events:
# No source prefix. A tag placed before the name is read as the
# subject's identity.
timeline.extend( timeline.extend(
(timestamp, f"[tracker] {note}") build_timeline(events, span_end, get_state_changes(detection_ids))
for timestamp, note in build_timeline(
events, span_end, get_state_changes(detection_ids)
)
) )
timeline.extend( timeline.extend(
@@ -813,6 +813,7 @@ def run_analysis(
genai_config.activity_context_prompt, genai_config.activity_context_prompt,
genai_config.response_style, genai_config.response_style,
frame_captions, frame_captions,
genai_config.enable_thinking,
) )
review_inference_speed.update(datetime.datetime.now().timestamp() - start) review_inference_speed.update(datetime.datetime.now().timestamp() - start)
+25 -315
View File
@@ -1,13 +1,8 @@
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:
@@ -17,324 +12,39 @@ except ModuleNotFoundError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TFLiteDetectionModel: def tflite_init(self, interpreter):
"""Object detection pre and post processing for a TFLite interpreter. self.interpreter = interpreter
Every TFLite detector shares this, differing only in how it builds the self.interpreter.allocate_tensors()
interpreter (on the CPU, or with the EdgeTPU or Teflon delegate).
"""
supported_models = [ self.tensor_input_details = self.interpreter.get_input_details()
ModelTypeEnum.ssd, self.tensor_output_details = self.interpreter.get_output_details()
ModelTypeEnum.yologeneric,
]
def __init__(
self, interpreter: Interpreter, model_config: ModelConfig, detector_key: str
):
"""Prepare the interpreter and work out the model's output layout.
Args: def tflite_detect_raw(self, tensor_input):
interpreter: The TFLite interpreter, with any delegate loaded self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
model_config: The model being run self.interpreter.invoke()
detector_key: The detector's type, for error messages
"""
self.model_type = model_config.model_type
if self.model_type not in self.supported_models: boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
raise ValueError( class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0]
f"{self.model_type.value} is currently not supported for {detector_key}. See the docs for more info on supported models." scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0]
) count = int(self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0])
self.interpreter = interpreter detections = np.zeros((20, 6), np.float32)
try: for i in range(count):
self.interpreter.allocate_tensors() if scores[i] < 0.4 or i == 20:
except RuntimeError as e: break
# an EdgeTPU compiled model wraps its graph in a custom op that only detections[i] = [
# the EdgeTPU delegate can run class_ids[i],
if "edgetpu-custom-op" in str(e): float(scores[i]),
logger.error( boxes[i][0],
"%s is compiled for the EdgeTPU and can only be run by an edgetpu detector", boxes[i][1],
model_config.path, boxes[i][2],
) boxes[i][3],
raise ]
self.tensor_input_details = self.interpreter.get_input_details() return detections
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(
+3 -6
View File
@@ -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 TFLiteDetectionModel from ..detector_utils import tflite_detect_raw, tflite_init
try: try:
from tflite_runtime.interpreter import Interpreter from tflite_runtime.interpreter import Interpreter
@@ -40,7 +40,6 @@ 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
@@ -50,9 +49,7 @@ class CpuTfl(DetectionApi):
num_threads=detector_config.num_threads or 3, num_threads=detector_config.num_threads or 3,
) )
self.tflite_model = TFLiteDetectionModel( tflite_init(self, interpreter)
interpreter, detector_config.model, DETECTOR_KEY
)
def detect_raw(self, tensor_input): def detect_raw(self, tensor_input):
return self.tflite_model.detect_raw(tensor_input) return tflite_detect_raw(self, tensor_input)
+303 -9
View File
@@ -1,13 +1,15 @@
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 from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
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
@@ -39,7 +41,10 @@ class EdgeTpuDetectorConfig(BaseDetectorConfig):
class EdgeTpuTfl(DetectionApi): class EdgeTpuTfl(DetectionApi):
type_key = DETECTOR_KEY type_key = DETECTOR_KEY
supported_models = TFLiteDetectionModel.supported_models supported_models = [
ModelTypeEnum.ssd,
ModelTypeEnum.yologeneric,
]
def __init__(self, detector_config: EdgeTpuDetectorConfig): def __init__(self, detector_config: EdgeTpuDetectorConfig):
device_config = {} device_config = {}
@@ -55,7 +60,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")
interpreter = Interpreter( self.interpreter = Interpreter(
model_path=detector_config.model.path, model_path=detector_config.model.path,
experimental_delegates=[edge_tpu_delegate], experimental_delegates=[edge_tpu_delegate],
) )
@@ -73,9 +78,298 @@ class EdgeTpuTfl(DetectionApi):
raise raise
self.tflite_model = TFLiteDetectionModel( self.interpreter.allocate_tensors()
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):
return self.tflite_model.detect_raw(tensor_input) 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:
# 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."
)
+4 -6
View File
@@ -7,7 +7,8 @@ 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 (
TFLiteDetectionModel, tflite_detect_raw,
tflite_init,
tflite_load_delegate_interpreter, tflite_load_delegate_interpreter,
) )
@@ -29,7 +30,6 @@ 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,9 +39,7 @@ 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
) )
self.tflite_model = TFLiteDetectionModel( tflite_init(self, interpreter)
interpreter, detector_config.model, DETECTOR_KEY
)
def detect_raw(self, tensor_input): def detect_raw(self, tensor_input):
return self.tflite_model.detect_raw(tensor_input) return tflite_detect_raw(self, tensor_input)
-3
View File
@@ -93,7 +93,6 @@ 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()
@@ -115,7 +114,6 @@ 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] = []
@@ -171,7 +169,6 @@ 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()
+49 -7
View File
@@ -7,6 +7,7 @@ import os
import re import re
import time import time
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass
from typing import Any from typing import Any
import numpy as np import numpy as np
@@ -33,6 +34,7 @@ __all__ = [
"GenAIClientManager", "GenAIClientManager",
"GenAIConfig", "GenAIConfig",
"GenAIProviderEnum", "GenAIProviderEnum",
"GenAIResponse",
"PROVIDERS", "PROVIDERS",
"load_providers", "load_providers",
"register_genai_provider", "register_genai_provider",
@@ -41,6 +43,18 @@ __all__ = [
PROVIDERS: dict[GenAIProviderEnum, type["GenAIClient"]] = {} PROVIDERS: dict[GenAIProviderEnum, type["GenAIClient"]] = {}
@dataclass
class GenAIResponse:
"""A provider's answer to a single request.
The reasoning trace is kept apart from the content so callers can save it
for debugging without it ending up in the parsed answer.
"""
content: str
reasoning: str | None = None
def register_genai_provider(key: GenAIProviderEnum) -> Callable: def register_genai_provider(key: GenAIProviderEnum) -> Callable:
"""Register a GenAI provider.""" """Register a GenAI provider."""
@@ -109,11 +123,14 @@ class GenAIClient:
activity_context_prompt: str, activity_context_prompt: str,
response_style: str = "default", response_style: str = "default",
frame_captions: list[str] | None = None, frame_captions: list[str] | None = None,
enable_thinking: bool = False,
) -> ReviewMetadata | None: ) -> ReviewMetadata | None:
"""Generate a description for the review item activity. """Generate a description for the review item activity.
`frame_captions` holds one caption per thumbnail for the annotated `frame_captions` holds one caption per thumbnail for the annotated
frame mode; each is sent directly before its frame. frame mode; each is sent directly before its frame. `enable_thinking`
lets the model reason before answering, when the provider exposes a
per-request toggle.
""" """
if frame_captions and len(frame_captions) != len(thumbnails): if frame_captions and len(frame_captions) != len(thumbnails):
logger.warning( logger.warning(
@@ -137,6 +154,13 @@ class GenAIClient:
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}" f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}"
) )
if enable_thinking and not self.supports_toggleable_thinking:
logger.debug(
"Thinking requested for review description but model %s does not "
"expose a per-request thinking toggle, using its default behavior",
self.genai_config.model,
)
if debug_save: if debug_save:
with open( with open(
os.path.join( os.path.join(
@@ -164,12 +188,14 @@ class GenAIClient:
response_format = build_review_description_response_format(concerns) response_format = build_review_description_response_format(concerns)
response = self._send( result = self._send(
context_prompt, context_prompt,
thumbnails, thumbnails,
response_format, response_format,
enable_thinking=enable_thinking,
image_captions=frame_captions, image_captions=frame_captions,
) )
response = result.content if result else None
if debug_save and response: if debug_save and response:
with open( with open(
@@ -180,6 +206,17 @@ class GenAIClient:
) as f: ) as f:
f.write(response) f.write(response)
# Saved even when the content is blank, since a model that spends its
# whole output budget thinking returns a trace but no answer.
if debug_save and result and result.reasoning:
with open(
os.path.join(
CLIPS_DIR, "genai-requests", review_data["id"], "reasoning.txt"
),
"w",
) as f:
f.write(result.reasoning)
if response: if response:
clean_json = re.sub( clean_json = re.sub(
r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response) r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response)
@@ -268,7 +305,8 @@ class GenAIClient:
) as f: ) as f:
f.write(timeline_summary_prompt) f.write(timeline_summary_prompt)
response = self._send(timeline_summary_prompt, []) result = self._send(timeline_summary_prompt, [])
response = result.content if result else None
if debug_save and response: if debug_save and response:
with open( with open(
@@ -295,7 +333,8 @@ class GenAIClient:
return None return None
logger.debug(f"Sending images to genai provider with prompt: {prompt}") logger.debug(f"Sending images to genai provider with prompt: {prompt}")
return self._send(prompt, thumbnails) result = self._send(prompt, thumbnails)
return result.content if result else None
def _init_provider(self) -> Any: def _init_provider(self) -> Any:
"""Initialize the client.""" """Initialize the client."""
@@ -308,13 +347,16 @@ class GenAIClient:
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to the provider. """Submit a request to the provider.
``enable_thinking`` is honored only by providers that report ``enable_thinking`` is honored only by providers that report
``supports_toggleable_thinking``. Description-style callers leave it ``supports_toggleable_thinking``. Description-style callers leave it
at the default (off) since synthesis tasks don't benefit from at the default (off) unless the user opts in, since most models write
reasoning traces. better descriptions without spending tokens on a reasoning trace.
Returns None when the request fails. Providers that surface a
reasoning trace separately return it in ``reasoning``.
``image_captions`` carries one caption per image, to be placed ``image_captions`` carries one caption per image, to be placed
immediately before its image so the model can tell the frames apart. immediately before its image so the model can tell the frames apart.
+9 -4
View File
@@ -12,7 +12,7 @@ from google.genai import errors, types
from google.genai.types import FunctionCallingConfigMode from google.genai.types import FunctionCallingConfigMode
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
from frigate.genai.utils import interleave_images from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -124,8 +124,13 @@ class GeminiClient(GenAIClient):
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to Gemini.""" """Submit a request to Gemini.
Thinking is configured at the model level for Gemini (or through
``thinking_config`` in ``runtime_options``), so ``enable_thinking`` is
ignored here.
"""
contents: list[Any] = [ contents: list[Any] = [
part part
if isinstance(part, str) if isinstance(part, str)
@@ -165,7 +170,7 @@ class GeminiClient(GenAIClient):
except (ValueError, AttributeError): except (ValueError, AttributeError):
# No description was generated # No description was generated
return None return None
return description return GenAIResponse(description)
@property @property
def supports_transcription(self) -> bool: def supports_transcription(self) -> bool:
+4 -3
View File
@@ -11,7 +11,7 @@ import numpy as np
import requests import requests
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
from frigate.genai.utils import ( from frigate.genai.utils import (
interleave_images, interleave_images,
parse_tool_calls_from_message, parse_tool_calls_from_message,
@@ -304,7 +304,7 @@ class LlamaCppClient(GenAIClient):
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to llama.cpp server.""" """Submit a request to llama.cpp server."""
if self.provider is None: if self.provider is None:
logger.warning( logger.warning(
@@ -362,7 +362,8 @@ class LlamaCppClient(GenAIClient):
): ):
choice = result["choices"][0] choice = result["choices"][0]
if "message" in choice and "content" in choice["message"]: if "message" in choice and "content" in choice["message"]:
return str(choice["message"]["content"].strip()) message = self._message_from_choice(choice)
return GenAIResponse(message["content"] or "", message["reasoning"])
return None return None
except Exception as e: except Exception as e:
logger.warning("llama.cpp returned an error: %s", str(e)) logger.warning("llama.cpp returned an error: %s", str(e))
+5 -4
View File
@@ -14,7 +14,7 @@ from ollama import Client as ApiClient
from ollama import ResponseError from ollama import ResponseError
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
from frigate.genai.utils import ( from frigate.genai.utils import (
interleave_images, interleave_images,
parse_tool_calls_from_message, parse_tool_calls_from_message,
@@ -243,7 +243,7 @@ class OllamaClient(GenAIClient):
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to Ollama through the chat API, the same path the """Submit a request to Ollama through the chat API, the same path the
tool-calling chat uses, with image placeholders keeping any captions tool-calling chat uses, with image placeholders keeping any captions
next to their frames.""" next to their frames."""
@@ -299,7 +299,8 @@ class OllamaClient(GenAIClient):
response.get("eval_count"), response.get("eval_count"),
response.get("prompt_eval_count"), response.get("prompt_eval_count"),
) )
response_text = self._message_from_response(response)["content"] or "" message_result = self._message_from_response(response)
response_text = message_result["content"] or ""
if not response_text: if not response_text:
logger.warning( logger.warning(
@@ -310,7 +311,7 @@ class OllamaClient(GenAIClient):
response.get("eval_count"), response.get("eval_count"),
) )
return response_text return GenAIResponse(response_text, message_result["reasoning"])
def list_models(self) -> list[str]: def list_models(self) -> list[str]:
"""Return available model names from the Ollama server.""" """Return available model names from the Ollama server."""
+16 -4
View File
@@ -10,7 +10,7 @@ from httpx import TimeoutException
from openai import OpenAI from openai import OpenAI
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
from frigate.genai.utils import interleave_images from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -71,8 +71,14 @@ class OpenAIClient(GenAIClient):
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to OpenAI.""" """Submit a request to OpenAI.
The chat completions API has no per-request thinking toggle, so
``enable_thinking`` is ignored here. Reasoning is controlled through
``runtime_options`` (e.g. ``reasoning_effort``), and any trace the
server returns separately is passed back as ``reasoning``.
"""
messages_content: list[dict] = [] messages_content: list[dict] = []
for part in interleave_images(prompt, images, image_captions): for part in interleave_images(prompt, images, image_captions):
if isinstance(part, str): if isinstance(part, str):
@@ -120,6 +126,10 @@ class OpenAIClient(GenAIClient):
): ):
message = result.choices[0].message message = result.choices[0].message
content = message.content content = message.content
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
message, "reasoning", None
)
reasoning = raw_reasoning.strip() if raw_reasoning else None
if not content: if not content:
# When reasoning is enabled for some OpenAI backends the actual response # When reasoning is enabled for some OpenAI backends the actual response
@@ -134,7 +144,9 @@ class OpenAIClient(GenAIClient):
) )
content = reasoning_content content = reasoning_content
return str(content.strip()) if content else None return (
GenAIResponse(str(content.strip()), reasoning) if content else None
)
return None return None
except (TimeoutException, Exception) as e: except (TimeoutException, Exception) as e:
logger.warning("OpenAI returned an error: %s", str(e)) logger.warning("OpenAI returned an error: %s", str(e))
+15 -15
View File
@@ -19,10 +19,10 @@ 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.
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": "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. Use every name from Objects in Scene.",
"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 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. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.',
"title": "Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.", "title": "Every name in Objects in Scene belongs in the 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 an unnamed object, refer to it naturally with an article.",
"shortSummary": "Briefly summarize the primary activity across the observations.", "shortSummary": "Briefly summarize the primary activity across the observations. Use every name from Objects in Scene.",
"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 +31,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. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign.',
"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": 'Every name in Objects in Scene belongs in the 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.',
"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. Use every name from Objects in Scene.",
}, },
"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.',
"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": 'Every name in Objects in Scene belongs in the title. Write the title as a terse label of two to four words naming the specific activity observed and where it happened, apart from those names. 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.',
"shortSummary": "Summarize the primary activity in one short sentence.", "shortSummary": "Summarize the primary activity in one short sentence. Use every name from Objects in Scene.",
}, },
"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. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
"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": 'Every name in Objects in Scene belongs in the 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.',
"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, including the most identifying visible detail, such as vehicle color or clothing. Use every name from Objects in Scene.",
}, },
} }
@@ -63,7 +63,7 @@ def get_review_field_guidelines(response_style: str = "default") -> dict[str, st
# 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.
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 a note of what changed at that moment: a subject being first detected, starting to move, reversing direction, stopping, or no longer being detected. When the note names the subject, use that name. 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."""
def build_review_description_prompt( def build_review_description_prompt(
@@ -164,9 +164,9 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
## Objects in Scene ## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times. The `←` symbol separates a recognized, known subject's name from their object type. Use each of these names. The same subject may appear on more than one line. A line with no `←` has no name; refer to it naturally with an article.
**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.** An object with no name is not suspicious just because it is unnamed.
{get_objects_list()} {get_objects_list()}
{get_language_prompt()} {get_language_prompt()}
+1 -6
View File
@@ -625,12 +625,7 @@ class OnvifController:
move_request.Speed = move_speed move_request.Speed = move_speed
try: await cam["ptz"].RelativeMove(move_request)
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
+1 -6
View File
@@ -1470,9 +1470,6 @@ class RecordingExporter(threading.Thread):
try: try:
self._run_export(video_path, thumb_path) self._run_export(video_path, thumb_path)
except Exception:
self._discard_failed_export(video_path, thumb_path)
raise
finally: finally:
# staged runs hold a full copy of the export, so they must not # staged runs hold a full copy of the export, so they must not
# survive a failure the way the small chapter file could # survive a failure the way the small chapter file could
@@ -1482,8 +1479,7 @@ class RecordingExporter(threading.Thread):
"""Drop the partial output and the row that promised it.""" """Drop the partial output and the row that promised it."""
Path(video_path).unlink(missing_ok=True) Path(video_path).unlink(missing_ok=True)
Export.delete().where(Export.id == self.export_id).execute() Export.delete().where(Export.id == self.export_id).execute()
if thumb_path: Path(thumb_path).unlink(missing_ok=True)
Path(thumb_path).unlink(missing_ok=True)
def _run_export(self, video_path: str, thumb_path: str) -> None: def _run_export(self, video_path: str, thumb_path: str) -> None:
try: try:
@@ -1502,7 +1498,6 @@ class RecordingExporter(threading.Thread):
else: else:
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path) ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
except DoesNotExist: except DoesNotExist:
self._discard_failed_export(video_path, thumb_path)
return return
# When neither custom ffmpeg arg is set the default path uses # When neither custom ffmpeg arg is set the default path uses
+10 -14
View File
@@ -100,14 +100,6 @@ 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,
@@ -491,6 +483,8 @@ 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
@@ -900,7 +894,9 @@ 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.add_manual_detection(manual_info) current_segment.detections[manual_info["event_id"]] = (
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"]
@@ -925,7 +921,9 @@ 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.add_manual_detection(manual_info) current_segment.detections[manual_info["event_id"]] = (
manual_info["label"]
)
if topic == DetectionTypeEnum.api: if topic == DetectionTypeEnum.api:
if ( if (
self.get_manual_event_severity( self.get_manual_event_severity(
@@ -1016,12 +1014,11 @@ 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:
@@ -1044,12 +1041,11 @@ 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:
-11
View File
@@ -1,11 +0,0 @@
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
-278
View File
@@ -1,278 +0,0 @@
"""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())
+2 -10
View File
@@ -1,12 +1,4 @@
"""Constants for testing.""" """Constants for testing."""
import os TEST_DB = "test.db"
import tempfile TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"]
# 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"}
+3 -3
View File
@@ -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, TEST_DB_PRAGMAS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
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, pragmas=TEST_DB_PRAGMAS) migrate_db = SqliteDatabase("test.db")
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, pragmas=TEST_DB_PRAGMAS) self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind(models) self.db.bind(models)
# The export job manager broadcasts via JobStatePublisher on # The export job manager broadcasts via JobStatePublisher on
+2 -8
View File
@@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp):
def test_event_time_filtering(self): def test_event_time_filtering(self):
morning_id = "123456.random" morning_id = "123456.random"
evening_id = "654321.random" evening_id = "654321.random"
morning = 1656568800 # 06/30/2022 6 am (GMT) morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656612000 # 06/30/2022 6 pm (GMT) evening = 1656633600 # 06/30/2022 6 pm (GMT)
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
super().insert_mock_event(morning_id, morning) super().insert_mock_event(morning_id, morning)
@@ -417,12 +417,6 @@ class TestHttpApp(BaseTestHttp):
).json() ).json()
assert events assert events
assert len(events) == 1 assert len(events) == 1
# an event in the first minute of the range is included
events = client.get(
"/events",
params={"time_range": "06:00,24:00"},
).json()
assert len(events) == 2
def test_set_delete_sub_label(self): def test_set_delete_sub_label(self):
mock_event_updater = Mock(spec=EventMetadataPublisher) mock_event_updater = Mock(spec=EventMetadataPublisher)
-24
View File
@@ -427,30 +427,6 @@ class TestHttpExport(BaseTestHttp):
assert response.status_code == 400 assert response.status_code == 400
assert Export.get(Export.id == "front_door_running").video_path == video assert Export.get(Export.id == "front_door_running").video_path == video
def test_delete_export_rejected_while_in_progress(self):
with tempfile.TemporaryDirectory() as tmpdir:
video = os.path.join(tmpdir, "front_door_abc123.mp4")
with open(video, "wb") as handle:
handle.write(b"video")
Export.create(
id="front_door_running",
camera="front_door",
name="front door export",
date=100,
video_path=video,
thumb_path=os.path.join(tmpdir, "t.webp"),
in_progress=True,
)
with AuthTestClient(self.app) as client:
response = client.post(
"/exports/delete", json={"ids": ["front_door_running"]}
)
assert response.status_code == 400
assert os.path.exists(video)
def test_rename_export_missing_file_leaves_the_row_alone(self): def test_rename_export_missing_file_leaves_the_row_alone(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
video = os.path.join(tmpdir, "front_door_gone_abc123.mp4") video = os.path.join(tmpdir, "front_door_gone_abc123.mp4")
+12 -14
View File
@@ -1,12 +1,11 @@
import os import os
import shutil import shutil
import tempfile from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import cv2 import cv2
import numpy as np import numpy as np
from frigate.output.preview import PREVIEW_FRAME_TYPE from frigate.output.preview import PREVIEW_CACHE_DIR, 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
@@ -16,15 +15,14 @@ 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()
# a directory of its own so test groups running in parallel can't if os.path.exists(PREVIEW_CACHE_DIR):
# delete each other's preview frames shutil.rmtree(PREVIEW_CACHE_DIR)
self.preview_dir = tempfile.mkdtemp() os.makedirs(PREVIEW_CACHE_DIR)
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
cache_dir_patch = patch( def tearDown(self):
"frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir if os.path.exists(PREVIEW_CACHE_DIR):
) shutil.rmtree(PREVIEW_CACHE_DIR)
cache_dir_patch.start() super().tearDown()
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"
@@ -47,7 +45,7 @@ class TestHttpLatestFrame(BaseTestHttp):
2, 2,
) )
preview_path = os.path.join( preview_path = os.path.join(
self.preview_dir, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}" PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
) )
cv2.imwrite(preview_path, dummy_frame) cv2.imwrite(preview_path, dummy_frame)
@@ -82,7 +80,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(
self.preview_dir, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}" PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
) )
cv2.imwrite(preview_path, dummy_frame) cv2.imwrite(preview_path, dummy_frame)
-4
View File
@@ -584,10 +584,6 @@ class TestHttpMedia(BaseTestHttp):
{"start_time": 1160, "end_time": 1300}, {"start_time": 1160, "end_time": 1300},
] ]
# a non-positive scale would never advance the gap scan
response = client.get("/recordings/unavailable", params={"scale": 0})
assert response.status_code == 422
def test_recordings_summary_day_attribution_by_start_time(self): def test_recordings_summary_day_attribution_by_start_time(self):
""" """
A recording spanning midnight marks only its start day. A recording spanning midnight marks only its start day.
@@ -1,87 +0,0 @@
"""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()
+3 -7
View File
@@ -617,13 +617,9 @@ class TestExportTimelineAlignment(unittest.TestCase):
): ):
windows = exporter._staged_chapter_windows() windows = exporter._staged_chapter_windows()
# the real cache dir only exists once the container has started path = exporter._build_recording_segment_chapter_metadata_file(windows)
with tempfile.TemporaryDirectory() as tmpdir: self.addCleanup(os.remove, path)
chapter_path = os.path.join(tmpdir, "chapters.txt") content = Path(path).read_text()
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)
+5
View File
@@ -83,6 +83,11 @@ class TestReviewResponseStyle(unittest.TestCase):
prompt, prompt,
) )
def test_named_subject_is_the_title_subject(self):
prompt = self._build_prompt()
self.assertIn("Every name in Objects in Scene belongs in the title.", prompt)
self.assertIn("recognized, known subject", prompt)
class TestClassificationStateChanges(unittest.TestCase): class TestClassificationStateChanges(unittest.TestCase):
def _build_prompt(self, **extra) -> str: def _build_prompt(self, **extra) -> str:
+169 -2
View File
@@ -15,6 +15,8 @@ crashing message conversion.
import asyncio import asyncio
import base64 import base64
import json import json
import os
import tempfile
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -22,7 +24,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import requests import requests
from frigate.config import GenAIConfig, GenAIProviderEnum from frigate.config import GenAIConfig, GenAIProviderEnum
from frigate.genai import PROVIDERS, load_providers from frigate.genai import PROVIDERS, GenAIClient, GenAIResponse, load_providers
load_providers() load_providers()
@@ -194,6 +196,19 @@ class TestOpenAIProvider(unittest.TestCase):
final = _final_message(_collect(client, MULTIMODAL_MESSAGES)) final = _final_message(_collect(client, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok") self.assertEqual(final["content"], "ok")
def test_send_returns_reasoning_content(self):
client = self._client()
message = SimpleNamespace(
content=" ok ", reasoning_content=" thought ", reasoning=None
)
client.provider.chat.completions.create = MagicMock(
return_value=SimpleNamespace(choices=[SimpleNamespace(message=message)])
)
result = client._send("prompt", [b"a"], enable_thinking=True)
self.assertEqual(result, GenAIResponse("ok", "thought"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Gemini # Gemini
@@ -399,7 +414,7 @@ class TestOllamaProvider(unittest.TestCase):
image_captions=["Frame 1 of 2", "Frame 2 of 2"], image_captions=["Frame 1 of 2", "Frame 2 of 2"],
) )
self.assertEqual(result, '{"ok": true}') self.assertEqual(result, GenAIResponse('{"ok": true}'))
client.provider.generate.assert_not_called() client.provider.generate.assert_not_called()
params = client.provider.chat.call_args.kwargs params = client.provider.chat.call_args.kwargs
self.assertEqual( self.assertEqual(
@@ -427,6 +442,30 @@ class TestOllamaProvider(unittest.TestCase):
self.assertEqual(message["content"], "prompt\n[img]") self.assertEqual(message["content"], "prompt\n[img]")
self.assertEqual(message["images"], [b"a"]) self.assertEqual(message["images"], [b"a"])
def test_send_with_thinking_returns_reasoning(self):
client = self._client()
client.provider = MagicMock()
client.provider.chat.return_value = {
"message": {"content": "ok", "thinking": " looked at each frame "},
"done": True,
}
client._supports_thinking_cache = True
result = client._send("prompt", [b"a"], enable_thinking=True)
self.assertTrue(client.provider.chat.call_args.kwargs["think"])
self.assertEqual(result, GenAIResponse("ok", "looked at each frame"))
def test_send_disables_thinking_by_default(self):
client = self._client()
client.provider = MagicMock()
client.provider.chat.return_value = {"message": {"content": "ok"}, "done": True}
client._supports_thinking_cache = True
client._send("prompt", [b"a"])
self.assertFalse(client.provider.chat.call_args.kwargs["think"])
def test_capabilities_drive_embeddings_and_thinking(self): def test_capabilities_drive_embeddings_and_thinking(self):
client = self._client() client = self._client()
client.provider = MagicMock() client.provider = MagicMock()
@@ -696,6 +735,42 @@ class TestLlamaCppProvider(unittest.TestCase):
final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES)) final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok") self.assertEqual(final["content"], "ok")
@staticmethod
def _chat_response(message):
response = MagicMock()
response.json.return_value = {"choices": [{"message": message}]}
return response
def test_send_with_thinking_returns_reasoning(self):
client = self._client()
client._supports_reasoning = True
response = self._chat_response(
{"content": " ok ", "reasoning_content": " looked at each frame "}
)
with patch.object(client, "_post", return_value=response) as post:
result = client._send("prompt", [b"a"], enable_thinking=True)
self.assertEqual(
post.call_args.kwargs["json"]["chat_template_kwargs"],
{"enable_thinking": True},
)
self.assertEqual(result, GenAIResponse("ok", "looked at each frame"))
def test_send_keeps_reasoning_when_content_is_null(self):
# A model that exhausts its output budget while thinking returns a
# trace with no content; the trace is what explains the blank answer.
client = self._client()
client._supports_reasoning = True
response = self._chat_response(
{"content": None, "reasoning_content": "still thinking"}
)
with patch.object(client, "_post", return_value=response):
result = client._send("prompt", [b"a"], enable_thinking=True)
self.assertEqual(result, GenAIResponse("", "still thinking"))
def _validated_client(self, server_context_size, provider_options=None): def _validated_client(self, server_context_size, provider_options=None):
"""Build a client as if the server reported the given context size.""" """Build a client as if the server reported the given context size."""
cfg = GenAIConfig( cfg = GenAIConfig(
@@ -1093,6 +1168,98 @@ class TestImageTokenEstimate(unittest.TestCase):
self.assertEqual(client._image_token_cache, {}) self.assertEqual(client._image_token_cache, {})
class _StubThinkingClient(GenAIClient):
def __init__(self, response: GenAIResponse | None) -> None:
super().__init__(GenAIConfig(provider="ollama", model="m"))
self.response = response
self.send_kwargs: dict = {}
@property
def supports_toggleable_thinking(self) -> bool:
return True
def _send(self, prompt, images, response_format=None, **kwargs):
self.send_kwargs = kwargs
return self.response
class TestReviewDescriptionThinking(unittest.TestCase):
REVIEW_ID = "1700000000.0-abc"
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.clips_dir = tmp.name
self.request_dir = os.path.join(
self.clips_dir, "genai-requests", self.REVIEW_ID
)
os.makedirs(self.request_dir)
patcher = patch("frigate.genai.CLIPS_DIR", self.clips_dir)
patcher.start()
self.addCleanup(patcher.stop)
def _generate(self, client, enable_thinking):
review_data = {
"id": self.REVIEW_ID,
"camera": "front",
"start": "Monday, 09:30 AM",
"duration": 10,
"zones": [],
"unified_objects": ["person"],
}
return client.generate_review_description(
review_data,
[b"a"],
[],
None,
True,
"activity context",
enable_thinking=enable_thinking,
)
def _saved(self, name):
path = os.path.join(self.request_dir, name)
if not os.path.exists(path):
return None
with open(path) as f:
return f.read()
def test_thinking_flag_reaches_provider_and_trace_is_saved(self):
answer = json.dumps(
{
"observations": [],
"scene": "A person walks up to the door.",
"title": "person at the door",
"shortSummary": "A person walks up to the door.",
"confidence": 0.9,
"potential_threat_level": 0,
}
)
client = _StubThinkingClient(GenAIResponse(answer, "frame 1 shows a person"))
metadata = self._generate(client, enable_thinking=True)
self.assertTrue(client.send_kwargs["enable_thinking"])
self.assertEqual(metadata.title, "Person at the door")
self.assertEqual(self._saved("response.txt"), answer)
self.assertEqual(self._saved("reasoning.txt"), "frame 1 shows a person")
def test_trace_is_saved_when_answer_is_blank(self):
client = _StubThinkingClient(GenAIResponse("", "ran out of tokens"))
self.assertIsNone(self._generate(client, enable_thinking=True))
self.assertIsNone(self._saved("response.txt"))
self.assertEqual(self._saved("reasoning.txt"), "ran out of tokens")
def test_thinking_off_by_default(self):
client = _StubThinkingClient(None)
self._generate(client, enable_thinking=False)
self.assertFalse(client.send_kwargs["enable_thinking"])
self.assertIsNone(self._saved("reasoning.txt"))
class TestBaseClientTranscribe(unittest.TestCase): class TestBaseClientTranscribe(unittest.TestCase):
"""Providers that don't implement the role must be inert, not broken.""" """Providers that don't implement the role must be inert, not broken."""
+3 -3
View File
@@ -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, TEST_DB_PRAGMAS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
_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, pragmas=TEST_DB_PRAGMAS) migrate_db = SqliteDatabase("test.db")
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, pragmas=TEST_DB_PRAGMAS) self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([Event, ReviewSegment, Recordings, Export]) self.db.bind([Event, ReviewSegment, Recordings, Export])
self.config = FrigateConfig(**_CONFIG) self.config = FrigateConfig(**_CONFIG)
+3 -3
View File
@@ -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, TEST_DB_PRAGMAS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
# 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, pragmas=TEST_DB_PRAGMAS) migrate_db = SqliteDatabase("test.db")
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, pragmas=TEST_DB_PRAGMAS) self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([Notice, NoticeStats]) self.db.bind([Notice, NoticeStats])
self.registry = NoticeRegistry() self.registry = NoticeRegistry()
self.listener = MagicMock() self.listener = MagicMock()
+20 -20
View File
@@ -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 PREVIEW_FRAME_TYPE, get_most_recent_preview_frame from frigate.output.preview import (
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):
# a directory of its own so test groups running in parallel can't if os.path.exists(PREVIEW_CACHE_DIR):
# delete each other's preview frames shutil.rmtree(PREVIEW_CACHE_DIR)
self.preview_dir = tempfile.mkdtemp() os.makedirs(PREVIEW_CACHE_DIR)
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
cache_dir_patch = patch( def tearDown(self):
"frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir if os.path.exists(PREVIEW_CACHE_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(
self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" PREVIEW_CACHE_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(
self.preview_dir, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}" PREVIEW_CACHE_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(
self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" PREVIEW_CACHE_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(
self.preview_dir, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}" PREVIEW_CACHE_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(
self.preview_dir, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}" PREVIEW_CACHE_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(self.preview_dir, f"{name}.{PREVIEW_FRAME_TYPE}"), "w" os.path.join(PREVIEW_CACHE_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(
self.preview_dir, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}" PREVIEW_CACHE_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(self.preview_dir) shutil.rmtree(PREVIEW_CACHE_DIR)
self.assertIsNone(get_most_recent_preview_frame("test_camera")) self.assertIsNone(get_most_recent_preview_frame("test_camera"))
-9
View File
@@ -195,15 +195,6 @@ 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
+27 -9
View File
@@ -79,8 +79,24 @@ class TestEventNames(unittest.TestCase):
self.assertEqual(event_name({"label": "animal"}), "an animal") self.assertEqual(event_name({"label": "animal"}), "an animal")
def test_sub_labeled_objects_use_their_name(self): def test_sub_labeled_objects_use_their_name(self):
event = {"label": "waste_bin", "sub_label": "Compost"} self.assertEqual(
self.assertEqual(event_name(event), 'waste bin "Compost"') event_name({"label": "waste_bin", "sub_label": "Compost"}),
"Compost",
)
self.assertEqual(
event_name({"label": "person", "sub_label": "nick"}),
"Nick",
)
self.assertEqual(
event_name({"label": "person", "sub_label": "nick_smith"}),
"Nick Smith",
)
def test_unknown_face_stays_unnamed(self):
self.assertEqual(
event_name({"label": "person", "sub_label": "unknown"}),
"a person",
)
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
@@ -184,8 +200,7 @@ class TestArrival(unittest.TestCase):
phrases, phrases,
[ [
"a person first detected at the right of the frame, moving up and left", "a person first detected at the right of the frame, moving up and left",
'waste bin "Compost" first detected at the right of the frame, ' "Compost first detected at the right of the frame, moving up and left",
"moving up and left",
], ],
) )
@@ -201,12 +216,12 @@ class TestArrival(unittest.TestCase):
timeline = build_timeline([bin_], 100.0) timeline = build_timeline([bin_], 100.0)
self.assertEqual( self.assertEqual(
timeline[0], timeline[0],
(0.3, 'waste bin "Compost" first detected at the right of the frame'), (0.3, "Compost first detected at the right of the frame"),
) )
self.assertAlmostEqual(timeline[1][0], 21.4) self.assertAlmostEqual(timeline[1][0], 21.4)
self.assertEqual( self.assertEqual(
timeline[1][1], timeline[1][1],
'waste bin "Compost" starts moving up and left from the right of the frame', "Compost starts moving up and left from the right of the frame",
) )
@@ -233,8 +248,8 @@ class TestStateChanges(unittest.TestCase):
self.assertEqual( self.assertEqual(
timeline[1:], timeline[1:],
[ [
(20.0, 'waste bin "Compost" has stopped moving'), (20.0, "Compost has stopped moving"),
(30.0, 'waste bin "Compost" starts moving again'), (30.0, "Compost starts moving again"),
], ],
) )
@@ -355,7 +370,10 @@ class TestClassificationChangeCaptions(unittest.TestCase):
captions[2], captions[2],
"Frame 3 of 3 (+20.0s):\n[state] front gate changed from closed to open", "Frame 3 of 3 (+20.0s):\n[state] front gate changed from closed to open",
) )
self.assertTrue(captions[0].splitlines()[1].startswith("[tracker] ")) self.assertTrue(
captions[0].splitlines()[1].startswith("a person first detected")
)
self.assertNotIn("[tracker]", captions[0])
def test_change_is_noted_without_tracked_objects(self): def test_change_is_noted_without_tracked_objects(self):
with patch( with patch(
+7 -55
View File
@@ -19,7 +19,6 @@ 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"
@@ -123,19 +122,13 @@ 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."""
self.feed_messages( queue = [
*[ (
( DetectionTypeEnum.video.value,
DetectionTypeEnum.video.value, (CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
(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:
@@ -860,46 +853,5 @@ 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,6 +100,24 @@ 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(
""" """
+3 -9
View File
@@ -13,28 +13,22 @@ 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, TEST_DB_PRAGMAS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
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, pragmas=TEST_DB_PRAGMAS) migrate_db = SqliteDatabase("test.db")
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, pragmas=TEST_DB_PRAGMAS) self.db = SqliteQueueDatabase(TEST_DB)
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": {
-229
View File
@@ -1,229 +0,0 @@
"""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)
+2 -4
View File
@@ -635,8 +635,7 @@ class TrackedObjectProcessor(threading.Thread):
if end_time if end_time
else ManualEventState.start else ManualEventState.start
), ),
"label": label, "label": f"{label}: {sub_label}" if sub_label else label,
"sub_label": sub_label,
"event_id": event_id, "event_id": event_id,
"end_time": end_time, "end_time": end_time,
}, },
@@ -697,8 +696,7 @@ class TrackedObjectProcessor(threading.Thread):
frame_time, frame_time,
{ {
"state": ManualEventState.start, "state": ManualEventState.start,
"label": label, "label": f"{label}: {sub_label}" if sub_label else label,
"sub_label": sub_label,
"event_id": event_id, "event_id": event_id,
"end_time": None, "end_time": None,
}, },
+23 -7
View File
@@ -15,8 +15,10 @@ Setup:
The script presents a menu to edit provider settings (provider, base URL, The script presents a menu to edit provider settings (provider, base URL,
optional API key, model) or run an example. Examples are selected with the optional API key, model) or run an example. Examples are selected with the
up/down arrow keys and launched with Enter. A writing style preset can be up/down arrow keys and launched with Enter. A writing style preset can be
applied on top of the saved prompt to compare tone between runs, and after a applied on top of the saved prompt to compare tone between runs, thinking can
response the model can be asked follow-up questions about the same frames. be turned on for models with a per-request toggle (the reasoning trace is
printed before the response), and after a response the model can be asked
follow-up questions about the same frames.
Provider settings are stored in Provider settings are stored in
testing-scripts/genai-review-examples/.settings.json (gitignored). testing-scripts/genai-review-examples/.settings.json (gitignored).
@@ -311,11 +313,12 @@ 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) result = client._send(prompt, frames)
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
if answer is None: if result is None:
print("No response from provider (see logs above).") print("No response from provider (see logs above).")
continue continue
answer = result.content
print(f"\n{answer}\n({elapsed:.1f}s)") print(f"\n{answer}\n({elapsed:.1f}s)")
transcript = f"{prompt}\n\nYour answer:\n{answer}" transcript = f"{prompt}\n\nYour answer:\n{answer}"
@@ -343,6 +346,11 @@ def run_example(settings: TesterSettings) -> None:
return return
style = styles[style_index] style = styles[style_index]
thinking_index = select_option("Thinking:", ["off", "on"])
if thinking_index is None:
return
enable_thinking = thinking_index == 1
prompt_file = example / "prompt.txt" prompt_file = example / "prompt.txt"
if not prompt_file.is_file(): if not prompt_file.is_file():
print(f"{example.name} has no prompt.txt, cannot run") print(f"{example.name} has no prompt.txt, cannot run")
@@ -363,18 +371,26 @@ def run_example(settings: TesterSettings) -> None:
concerns = ["_"] if "other_concerns" in prompt else [] concerns = ["_"] if "other_concerns" in prompt else []
response_format = build_review_description_response_format(concerns) response_format = build_review_description_response_format(concerns)
if enable_thinking and not client.supports_toggleable_thinking:
print(" Warning: model has no per-request thinking toggle, sending anyway")
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"thinking={'on' if enable_thinking else 'off'})..."
) )
start = time.monotonic() start = time.monotonic()
response = client._send(prompt, frames, response_format) result = client._send(prompt, frames, response_format, enable_thinking)
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
if response is None: if result is None:
print("No response from provider (see logs above).") print("No response from provider (see logs above).")
return return
if result.reasoning:
print(f"\nReasoning:\n\n{result.reasoning}")
response = result.content
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)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
/**
* Review GenAI thinking toggle tests -- MEDIUM tier.
*
* Thinking can only be switched per request by providers that expose a
* toggle, so the option is shown only when one of those providers holds the
* descriptions role.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const SETTINGS_URL = "/settings?page=globalReview";
async function installRoutes(
page: Page,
genai: Record<string, { provider: string; model: string; roles: string[] }>,
) {
const config = configFactory({ genai } as never);
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) =>
route.request().method() === "GET"
? route.fulfill({ json: config })
: route.fulfill({ json: { success: true } }),
);
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: {} }),
);
}
async function openGenAIConfig(page: Page) {
await page.getByText("GenAI config", { exact: true }).click();
await expect(page.locator("#pageRoot")).toContainText("Response style");
}
test.describe("review genai thinking @medium", () => {
test("is shown when the descriptions provider has a thinking toggle", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, {
local: { provider: "llamacpp", model: "m", roles: ["descriptions"] },
});
await frigateApp.goto(SETTINGS_URL);
await openGenAIConfig(frigateApp.page);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Enable thinking",
);
});
test("is hidden when the descriptions provider has no thinking toggle", async ({
frigateApp,
}) => {
// the chat provider supports the toggle, but descriptions go elsewhere
await installRoutes(frigateApp.page, {
local: { provider: "ollama", model: "m", roles: ["chat"] },
cloud: { provider: "gemini", model: "m", roles: ["descriptions"] },
});
await frigateApp.goto(SETTINGS_URL);
await openGenAIConfig(frigateApp.page);
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"Enable thinking",
);
});
test("is hidden when no provider is configured", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, {});
await frigateApp.goto(SETTINGS_URL);
await openGenAIConfig(frigateApp.page);
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"Enable thinking",
);
});
});
+9 -9
View File
@@ -2990,9 +2990,9 @@
} }
}, },
"node_modules/@rjsf/utils/node_modules/fast-uri": { "node_modules/@rjsf/utils/node_modules/fast-uri": {
"version": "4.2.3", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.3.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
"integrity": "sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==", "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5399,9 +5399,9 @@
"dev": true "dev": true
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.9", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -8669,9 +8669,9 @@
} }
}, },
"node_modules/postcss-selector-parser": { "node_modules/postcss-selector-parser": {
"version": "6.1.5", "version": "6.1.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
"integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
+4 -4
View File
@@ -689,6 +689,10 @@
"label": "Response style", "label": "Response style",
"description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged." "description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged."
}, },
"enable_thinking": {
"label": "Enable thinking",
"description": "Let the model reason before writing the review description. Only applies to models with a per-request thinking toggle (Ollama and llama.cpp providers); other models keep their default behavior. Thinking increases response time and token usage. When thumbnail saving is enabled, the reasoning is saved alongside the response."
},
"activity_context_prompt": { "activity_context_prompt": {
"label": "Activity context prompt", "label": "Activity context prompt",
"description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries." "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries."
@@ -858,10 +862,6 @@
"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."
+4 -4
View File
@@ -154,10 +154,6 @@
"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."
@@ -1064,6 +1060,10 @@
"label": "Response style", "label": "Response style",
"description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged." "description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged."
}, },
"enable_thinking": {
"label": "Enable thinking",
"description": "Let the model reason before writing the review description. Only applies to models with a per-request thinking toggle (Ollama and llama.cpp providers); other models keep their default behavior. Thinking increases response time and token usage. When thumbnail saving is enabled, the reasoning is saved alongside the response."
},
"activity_context_prompt": { "activity_context_prompt": {
"label": "Activity context prompt", "label": "Activity context prompt",
"description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries." "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries."
+1 -6
View File
@@ -1934,10 +1934,6 @@
"saved": "Saved" "saved": "Saved"
} }
}, },
"notificationTtl": {
"immediate": "Immediate",
"reliable": "Reliable"
},
"retainMode": { "retainMode": {
"all": "All", "all": "All",
"motion": "Motion", "motion": "Motion",
@@ -2055,8 +2051,7 @@
"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: ["ttl"], advancedFields: [],
fieldMessages: [ fieldMessages: [
{ {
key: "profile-base-notifications-disabled", key: "profile-base-notifications-disabled",
@@ -24,14 +24,6 @@ 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: {
@@ -39,13 +31,12 @@ 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", "ttl"], hiddenFields: ["enabled_in_config", "email"],
}, },
}; };
@@ -1,5 +1,19 @@
import type { HiddenFieldContext } from "@/types/configForm";
import type { SectionConfigOverrides } from "./types"; import type { SectionConfigOverrides } from "./types";
// Only these providers expose a per-request thinking toggle, so the option
// has no effect when any other provider holds the descriptions role
const THINKING_TOGGLE_PROVIDERS = new Set(["ollama", "llamacpp"]);
const hideThinkingWithoutToggle = (ctx: HiddenFieldContext): string[] => {
const provider = Object.values(ctx.fullConfig.genai ?? {}).find((agent) =>
agent.roles?.includes("descriptions"),
)?.provider;
return provider && THINKING_TOGGLE_PROVIDERS.has(provider)
? []
: ["genai.enable_thinking"];
};
const review: SectionConfigOverrides = { const review: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/review", sectionDocs: "/configuration/review",
@@ -98,6 +112,7 @@ const review: SectionConfigOverrides = {
"genai.frame_mode": "/configuration/genai/genai_review#frame-mode", "genai.frame_mode": "/configuration/genai/genai_review#frame-mode",
"genai.additional_concerns": "genai.additional_concerns":
"/configuration/genai/genai_review#additional-concerns", "/configuration/genai/genai_review#additional-concerns",
"genai.enable_thinking": "/configuration/genai/genai_review#thinking",
}, },
restartRequired: [], restartRequired: [],
fieldOrder: ["alerts", "detections", "genai", "genai.enabled"], fieldOrder: ["alerts", "detections", "genai", "genai.enabled"],
@@ -107,6 +122,7 @@ const review: SectionConfigOverrides = {
"alerts.enabled_in_config", "alerts.enabled_in_config",
"detections.enabled_in_config", "detections.enabled_in_config",
"genai.enabled_in_config", "genai.enabled_in_config",
hideThinkingWithoutToggle,
], ],
advancedFields: [], advancedFields: [],
uiSchema: { uiSchema: {
@@ -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", "ttl"], ["enabled_in_config", "email"],
); );
onPendingDataChange("notifications", camera.name, sanitizedNotifications); onPendingDataChange("notifications", camera.name, sanitizedNotifications);
pendingCameraOverridesRef.current.add(camera.name); pendingCameraOverridesRef.current.add(camera.name);
@@ -415,19 +415,10 @@ export default function NotificationsSettingsExtras({
pushSubscription.unsubscribe(); pushSubscription.unsubscribe();
workerRegistration.unregister(); workerRegistration.unregister();
setRegistration(null); setRegistration(null);
removeMessage("notification_settings", "registration");
}); });
})
.catch(() => {
toast.error(t("notification.toast.error.registerFailed"), {
position: "top-center",
});
workerRegistration.unregister();
setRegistration(null);
removeMessage("notification_settings", "registration");
}); });
}, },
[addMessage, removeMessage, publicKey, t], [addMessage, publicKey, t],
); );
useEffect(() => { useEffect(() => {
@@ -499,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 md:order-none"> <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={cn("w-full max-w-5xl space-y-6")}> <div className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && ( {isAdmin && (
<SettingsGroupCard <SettingsGroupCard
@@ -307,9 +307,6 @@ export function KnownPlatesField(props: FieldProps) {
); );
const handleAddEntry = useCallback(() => { const handleAddEntry = useCallback(() => {
if ("" in data) {
return;
}
const next = { ...data, "": [""] }; const next = { ...data, "": [""] };
onChange(next, fieldPath); onChange(next, fieldPath);
}, [data, fieldPath, onChange]); }, [data, fieldPath, onChange]);
+30 -2
View File
@@ -690,8 +690,36 @@ export function ExportContent({
(option: ExportOption) => { (option: ExportOption) => {
setSelectedOption(option); setSelectedOption(option);
const hours = Number(option); const now = new Date(latestTime * 1000);
const start = latestTime - (Number.isNaN(hours) ? 1 : hours) * 3600; let start: number;
switch (option) {
case "1":
now.setHours(now.getHours() - 1);
start = now.getTime() / 1000;
break;
case "4":
now.setHours(now.getHours() - 4);
start = now.getTime() / 1000;
break;
case "8":
now.setHours(now.getHours() - 8);
start = now.getTime() / 1000;
break;
case "12":
now.setHours(now.getHours() - 12);
start = now.getTime() / 1000;
break;
case "24":
now.setHours(now.getHours() - 24);
start = now.getTime() / 1000;
break;
case "custom":
start = latestTime - 3600;
break;
default:
start = latestTime - 3600;
}
setRange({ setRange({
before: latestTime, before: latestTime,
+1 -12
View File
@@ -147,18 +147,7 @@ export default function DetailStream({
// away instead of waiting for a future effectiveTime change. // away instead of waiting for a future effectiveTime change.
useEffect(() => { useEffect(() => {
if (!reviewItems || reviewItems.length === 0) return; if (!reviewItems || reviewItems.length === 0) return;
if (activeReviewId) return;
// an id left over from another camera's items counts as unset
if (
activeReviewId &&
reviewItems.some(
(r) =>
`review-${r.id ?? r.start_time ?? Math.floor(r.start_time ?? 0)}` ===
activeReviewId,
)
) {
return;
}
let target: ReviewSegment | undefined; let target: ReviewSegment | undefined;
let closest: { r: ReviewSegment; diff: number } | undefined; let closest: { r: ReviewSegment; diff: number } | undefined;
+2 -1
View File
@@ -45,7 +45,8 @@ export function createRecordingReviewUrl(
pathname: string, pathname: string,
state: RecordingReviewLinkState, state: RecordingReviewLinkState,
): string { ): string {
const url = new URL(pathname.replace(/^\//, ""), baseUrl); const url = new URL(baseUrl);
url.pathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
url.searchParams.set( url.searchParams.set(
RECORDING_REVIEW_LINK_PARAM, RECORDING_REVIEW_LINK_PARAM,
`${state.camera}_${Math.floor(state.timestamp)}`, `${state.camera}_${Math.floor(state.timestamp)}`,
+15 -9
View File
@@ -575,19 +575,25 @@ export default function DraggableGridLayout({
}; };
useEffect(() => { useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {}; const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {}; const initialVolumeStates: VolumeState = {};
Object.entries(currentGroupStreamingSettings ?? {}).forEach( Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
([camera, cameraSettings]) => { if (groupSettings) {
initialAudioStates[camera] = cameraSettings.playAudio ?? false; Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialVolumeStates[camera] = cameraSettings.volume ?? 1; initialAudioStates[camera] = cameraSettings.playAudio ?? false;
}, initialVolumeStates[camera] = cameraSettings.volume ?? 1;
); });
}
});
setAudioStates(initialAudioStates); setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates); setVolumeStates(initialVolumeStates);
}, [currentGroupStreamingSettings, cameraGroup]); }, [allGroupsStreamingSettings]);
const toggleAudio = (cameraName: string) => { const toggleAudio = (cameraName: string) => {
setAudioStates((prev) => ({ setAudioStates((prev) => ({
@@ -794,7 +800,7 @@ export default function DraggableGridLayout({
toggleAudio={() => toggleAudio(camera.name)} toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]} statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)} toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name] ?? 1} volumeState={volumeStates[camera.name]}
setVolumeState={(value) => setVolumeState={(value) =>
setVolumeStates((prev) => ({ setVolumeStates((prev) => ({
...prev, ...prev,
@@ -839,7 +845,7 @@ export default function DraggableGridLayout({
onError={(e) => handleError(camera.name, e)} onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)} onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]} playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name] ?? 1} volume={volumeStates[camera.name]}
onLiveAspectChange={liveAspectHandlers[camera.name]} onLiveAspectChange={liveAspectHandlers[camera.name]}
/> />
</GridLiveContextMenu> </GridLiveContextMenu>
+8 -10
View File
@@ -511,14 +511,7 @@ export default function LiveCameraView({
} }
}, [webRTCVerdictPending]); }, [webRTCVerdictPending]);
const toggleMic = () => { useKeyboardListener(["m", "Escape"], (key, modifiers) => {
setMic(!mic);
if (!mic && !audio) {
setAudio(true);
}
};
useKeyboardListener(["m", "t", "Escape"], (key, modifiers) => {
if (!modifiers.down) { if (!modifiers.down) {
return true; return true;
} }
@@ -532,7 +525,7 @@ export default function LiveCameraView({
break; break;
case "t": case "t":
if (supports2WayTalk && talkAvailable) { if (supports2WayTalk && talkAvailable) {
toggleMic(); setMic(!mic);
return true; return true;
} }
break; break;
@@ -799,7 +792,12 @@ export default function LiveCameraView({
? t("twoWayTalk.disable", { ns: "views/live" }) ? t("twoWayTalk.disable", { ns: "views/live" })
: t("twoWayTalk.enable", { ns: "views/live" }) : t("twoWayTalk.enable", { ns: "views/live" })
} }
onClick={toggleMic} onClick={() => {
setMic(!mic);
if (!mic && !audio) {
setAudio(true);
}
}}
disabled={!cameraEnabled || debug || !talkAvailable} disabled={!cameraEnabled || debug || !talkAvailable}
/> />
)} )}
+14 -8
View File
@@ -326,19 +326,25 @@ export default function LiveDashboardView({
}; };
useEffect(() => { useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {}; const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {}; const initialVolumeStates: VolumeState = {};
Object.entries(currentGroupStreamingSettings ?? {}).forEach( Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
([camera, cameraSettings]) => { if (groupSettings) {
initialAudioStates[camera] = cameraSettings.playAudio ?? false; Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialVolumeStates[camera] = cameraSettings.volume ?? 1; initialAudioStates[camera] = cameraSettings.playAudio ?? false;
}, initialVolumeStates[camera] = cameraSettings.volume ?? 1;
); });
}
});
setAudioStates(initialAudioStates); setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates); setVolumeStates(initialVolumeStates);
}, [currentGroupStreamingSettings, cameraGroup]); }, [allGroupsStreamingSettings]);
const toggleAudio = (cameraName: string): void => { const toggleAudio = (cameraName: string): void => {
setAudioStates((prev) => ({ setAudioStates((prev) => ({
@@ -629,7 +635,7 @@ export default function LiveDashboardView({
resetPreferredLiveMode(camera.name) resetPreferredLiveMode(camera.name)
} }
playAudio={audioStates[camera.name] ?? false} playAudio={audioStates[camera.name] ?? false}
volume={volumeStates[camera.name] ?? 1} volume={volumeStates[camera.name]}
/> />
</LiveContextMenu> </LiveContextMenu>
); );
@@ -86,21 +86,17 @@ export default function MediaSyncSettingsView() {
position: "top-center", position: "top-center",
closeButton: true, closeButton: true,
}); });
} } else if (response.status === 409) {
} catch (error) { toast.error(t("maintenance.sync.alreadyRunning"), {
const alreadyRunning =
axios.isAxiosError(error) && error.response?.status === 409;
toast.error(
t(
alreadyRunning
? "maintenance.sync.alreadyRunning"
: "maintenance.sync.error",
),
{
position: "top-center", position: "top-center",
closeButton: true, closeButton: true,
}, });
); }
} catch {
toast.error(t("maintenance.sync.error"), {
position: "top-center",
closeButton: true,
});
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
+5 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
@@ -133,9 +133,6 @@ export default function TriggerView({
// watch websocket for updates // watch websocket for updates
const { payload: triggers_status_ws } = useTriggers(); const { payload: triggers_status_ws } = useTriggers();
const highlightTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>(
{},
);
useEffect(() => { useEffect(() => {
if (!triggers_status_ws) return; if (!triggers_status_ws) return;
@@ -151,13 +148,13 @@ export default function TriggerView({
return current; return current;
}); });
const name = triggers_status_ws.name; const timeout = setTimeout(() => {
clearTimeout(highlightTimers.current[name]);
highlightTimers.current[name] = setTimeout(() => {
setTriggeredTrigger((prev) => setTriggeredTrigger((prev) =>
(prev || []).filter((triggered) => triggered !== name), (prev || []).filter((name) => name !== triggers_status_ws.name),
); );
}, 3000); }, 3000);
return () => clearTimeout(timeout);
}, [triggers_status_ws, selectedCamera, mutate]); }, [triggers_status_ws, selectedCamera, mutate]);
useEffect(() => { useEffect(() => {