mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 09:02:15 +03:00
Miscellaneous fixes (0.18 beta) (#23763)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
|
|
||||||
SQLITE_VEC_VERSION="0.1.3"
|
SQLITE_VEC_VERSION="0.1.9"
|
||||||
|
|
||||||
source /etc/os-release
|
source /etc/os-release
|
||||||
|
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ If you have CUDA hardware, you can experiment with the `large` `whisper` model o
|
|||||||
|
|
||||||
#### Transcription and translation of `speech` audio events
|
#### Transcription and translation of `speech` audio events
|
||||||
|
|
||||||
Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button in the Tracked Object Details pane.
|
Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button (the microphone icon) in the Tracked Object Details pane.
|
||||||
|
|
||||||
In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10).
|
In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10).
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@ Recorded `speech` events will always use a `whisper` model, regardless of the `m
|
|||||||
|
|
||||||
Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware.
|
Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware.
|
||||||
|
|
||||||
If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control.
|
If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button (the microphone icon) in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control.
|
||||||
|
|
||||||
Other options are being considered for future versions of Frigate to add transcription options that support external `whisper` Docker containers. A single transcription service could then be shared by Frigate and other applications (for example, Home Assistant Voice), and run on more powerful machines when available.
|
Other options are being considered for future versions of Frigate to add transcription options that support external `whisper` Docker containers. A single transcription service could then be shared by Frigate and other applications (for example, Home Assistant Voice), and run on more powerful machines when available.
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,21 @@ Once front-facing images are performing well, start choosing slightly off-angle
|
|||||||
|
|
||||||
Start with the [Usage](#usage) section and re-read the [Model Requirements](#model-requirements) above.
|
Start with the [Usage](#usage) section and re-read the [Model Requirements](#model-requirements) above.
|
||||||
|
|
||||||
1. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library.
|
1. Enable debug logs to see exactly what Frigate is doing.
|
||||||
|
- Enable debug logs for face recognition by adding `frigate.data_processing.real_time.face: debug` to your `logger` configuration. Restart Frigate after this change.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
logger:
|
||||||
|
default: info
|
||||||
|
logs:
|
||||||
|
# highlight-next-line
|
||||||
|
frigate.data_processing.real_time.face: debug
|
||||||
|
```
|
||||||
|
|
||||||
|
- These logs report where the pipeline stopped for each `person` object, such as no face being found within the person's bounding box, the detected face being smaller than `min_area`, or a face being recognized but scoring too low.
|
||||||
|
- If you see no face-related messages at all, also add `frigate.embeddings.maintainer: debug` to confirm that the face processor was created at startup and that `person` updates are reaching it.
|
||||||
|
|
||||||
|
2. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library.
|
||||||
|
|
||||||
If you are using a Frigate+ or `face` detecting model:
|
If you are using a Frigate+ or `face` detecting model:
|
||||||
- Watch the [debug view](/usage/live#the-single-camera-view) to ensure that `face` is being detected along with `person`.
|
- Watch the [debug view](/usage/live#the-single-camera-view) to ensure that `face` is being detected along with `person`.
|
||||||
@@ -242,7 +256,7 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod
|
|||||||
- Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects.
|
- Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects.
|
||||||
- You may need to lower your `detection_threshold` if faces are not being detected.
|
- You may need to lower your `detection_threshold` if faces are not being detected.
|
||||||
|
|
||||||
2. Any detected faces will then be _recognized_.
|
3. Any detected faces will then be _recognized_.
|
||||||
- Make sure you have trained at least one face per the recommendations above.
|
- Make sure you have trained at least one face per the recommendations above.
|
||||||
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ All llama.cpp native options can be passed through `provider_options`, including
|
|||||||
- Set **Provider** to `llamacpp`
|
- Set **Provider** to `llamacpp`
|
||||||
- Set **Base URL** to your llama.cpp server address (e.g., `http://localhost:8080`)
|
- Set **Base URL** to your llama.cpp server address (e.g., `http://localhost:8080`)
|
||||||
- Set **Model** to the name of your model
|
- Set **Model** to the name of your model
|
||||||
- Under **Provider Options**, set `context_size` to tell Frigate your context size so it can send the appropriate amount of information
|
- Optionally, under **Provider Options**, set `context_size` to override the context size Frigate detects from the server
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
@@ -89,12 +89,14 @@ genai:
|
|||||||
base_url: http://localhost:8080
|
base_url: http://localhost:8080
|
||||||
model: your-model-name
|
model: your-model-name
|
||||||
provider_options:
|
provider_options:
|
||||||
context_size: 16000 # Tell Frigate your context size so it can send the appropriate amount of information.
|
context_size: 16000 # Optional, overrides the context size reported by the server.
|
||||||
```
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</ConfigTabs>
|
</ConfigTabs>
|
||||||
|
|
||||||
|
Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own.
|
||||||
|
|
||||||
### Ollama
|
### Ollama
|
||||||
|
|
||||||
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
|
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
|
||||||
|
|||||||
+15
-10
@@ -1538,15 +1538,18 @@ async def set_description(
|
|||||||
event.data["description"] = new_description
|
event.data["description"] = new_description
|
||||||
event.save()
|
event.save()
|
||||||
|
|
||||||
# If semantic search is enabled, update the index
|
context: EmbeddingsContext | None = request.app.embeddings
|
||||||
if request.app.frigate_config.semantic_search.enabled:
|
|
||||||
context: EmbeddingsContext = request.app.embeddings
|
if context is not None:
|
||||||
if len(new_description) > 0:
|
if len(new_description) > 0:
|
||||||
context.update_description(
|
# If semantic search is enabled, update the index
|
||||||
event_id,
|
if request.app.frigate_config.semantic_search.enabled:
|
||||||
new_description,
|
context.update_description(
|
||||||
)
|
event_id,
|
||||||
|
new_description,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
|
# embeddings are always cleaned up so they don't outlive their description
|
||||||
context.db.delete_embeddings_description(event_ids=[event_id])
|
context.db.delete_embeddings_description(event_ids=[event_id])
|
||||||
|
|
||||||
response_message = (
|
response_message = (
|
||||||
@@ -1675,9 +1678,11 @@ async def delete_single_event(event_id: str, request: Request) -> dict:
|
|||||||
event.delete_instance()
|
event.delete_instance()
|
||||||
Timeline.delete().where(Timeline.source_id == event_id).execute()
|
Timeline.delete().where(Timeline.source_id == event_id).execute()
|
||||||
|
|
||||||
# If semantic search is enabled, update the index
|
# embeddings are always cleaned up, even when semantic search is disabled,
|
||||||
if request.app.frigate_config.semantic_search.enabled:
|
# so that they don't outlive their events
|
||||||
context: EmbeddingsContext = request.app.embeddings
|
context: EmbeddingsContext | None = request.app.embeddings
|
||||||
|
|
||||||
|
if context is not None:
|
||||||
context.db.delete_embeddings_thumbnail(event_ids=[event_id])
|
context.db.delete_embeddings_thumbnail(event_ids=[event_id])
|
||||||
context.db.delete_embeddings_description(event_ids=[event_id])
|
context.db.delete_embeddings_description(event_ids=[event_id])
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -270,7 +270,7 @@ class FrigateApp:
|
|||||||
10
|
10
|
||||||
* len([c for c in self.config.cameras.values() if c.enabled_in_config]),
|
* len([c for c in self.config.cameras.values() if c.enabled_in_config]),
|
||||||
),
|
),
|
||||||
load_vec_extension=self.config.semantic_search.enabled,
|
load_vec_extension=True,
|
||||||
)
|
)
|
||||||
models = [
|
models = [
|
||||||
Event,
|
Event,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import regex
|
import regex
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REGEXP_TIMEOUT_SECONDS = 1.0
|
REGEXP_TIMEOUT_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
@@ -28,8 +31,14 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
|
|
||||||
def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
|
def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
|
||||||
conn.enable_load_extension(True)
|
conn.enable_load_extension(True)
|
||||||
conn.load_extension(self.sqlite_vec_path)
|
|
||||||
conn.enable_load_extension(False)
|
try:
|
||||||
|
conn.load_extension(self.sqlite_vec_path)
|
||||||
|
except conn.OperationalError:
|
||||||
|
logger.error("Unable to load the sqlite-vec extension")
|
||||||
|
self.load_vec_extension = False
|
||||||
|
finally:
|
||||||
|
conn.enable_load_extension(False)
|
||||||
|
|
||||||
def _register_regexp(self, conn: sqlite3.Connection) -> None:
|
def _register_regexp(self, conn: sqlite3.Connection) -> None:
|
||||||
def regexp(expr: str, item: str | None) -> bool:
|
def regexp(expr: str, item: str | None) -> bool:
|
||||||
@@ -44,13 +53,33 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
|
|
||||||
conn.create_function("REGEXP", 2, regexp)
|
conn.create_function("REGEXP", 2, regexp)
|
||||||
|
|
||||||
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
|
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
|
||||||
|
"""Delete embeddings for the given events, if the table exists.
|
||||||
|
|
||||||
|
Embeddings outlive the events they belong to when semantic search is
|
||||||
|
disabled, so deletes are attempted regardless of the current config.
|
||||||
|
"""
|
||||||
|
if not event_ids or not self.load_vec_extension:
|
||||||
|
return
|
||||||
|
|
||||||
|
# the embeddings tables are only created once semantic search has run
|
||||||
|
cursor = self.execute_sql(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||||
|
(table,),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
logger.debug("Skipping %s cleanup, table does not exist", table)
|
||||||
|
return
|
||||||
|
|
||||||
ids = ",".join(["?" for _ in event_ids])
|
ids = ",".join(["?" for _ in event_ids])
|
||||||
self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids)
|
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||||
|
|
||||||
|
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
|
||||||
|
self._delete_embeddings("vec_thumbnails", event_ids)
|
||||||
|
|
||||||
def delete_embeddings_description(self, event_ids: list[str]) -> None:
|
def delete_embeddings_description(self, event_ids: list[str]) -> None:
|
||||||
ids = ",".join(["?" for _ in event_ids])
|
self._delete_embeddings("vec_descriptions", event_ids)
|
||||||
self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids)
|
|
||||||
|
|
||||||
def drop_embeddings_tables(self) -> None:
|
def drop_embeddings_tables(self) -> None:
|
||||||
self.execute_sql("""
|
self.execute_sql("""
|
||||||
|
|||||||
@@ -366,9 +366,10 @@ class EventCleanup(threading.Thread):
|
|||||||
logger.debug(f"Deleting {len(chunk)} events from the database")
|
logger.debug(f"Deleting {len(chunk)} events from the database")
|
||||||
Event.delete().where(Event.id << chunk).execute()
|
Event.delete().where(Event.id << chunk).execute()
|
||||||
|
|
||||||
if self.config.semantic_search.enabled:
|
# embeddings are always cleaned up, even when semantic search
|
||||||
self.db.delete_embeddings_description(event_ids=chunk)
|
# is disabled, so that they don't outlive their events
|
||||||
self.db.delete_embeddings_thumbnail(event_ids=chunk)
|
self.db.delete_embeddings_description(event_ids=chunk)
|
||||||
logger.debug(f"Deleted {len(ids_to_delete)} embeddings")
|
self.db.delete_embeddings_thumbnail(event_ids=chunk)
|
||||||
|
logger.debug(f"Deleted {len(chunk)} embeddings")
|
||||||
|
|
||||||
logger.info("Exiting event cleanup...")
|
logger.info("Exiting event cleanup...")
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class LlamaCppClient(GenAIClient):
|
|||||||
logger.info(
|
logger.info(
|
||||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||||
configured_model,
|
configured_model,
|
||||||
self._context_size or "unknown",
|
self.get_context_size(),
|
||||||
self._supports_vision,
|
self._supports_vision,
|
||||||
self._supports_audio,
|
self._supports_audio,
|
||||||
self._supports_tools,
|
self._supports_tools,
|
||||||
|
|||||||
@@ -491,6 +491,34 @@ 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")
|
||||||
|
|
||||||
|
def _validated_client(self, server_context_size, provider_options=None):
|
||||||
|
"""Build a client as if the server reported the given context size."""
|
||||||
|
cfg = GenAIConfig(
|
||||||
|
provider="llamacpp",
|
||||||
|
model="m",
|
||||||
|
base_url="http://localhost:9999",
|
||||||
|
provider_options=provider_options or {},
|
||||||
|
)
|
||||||
|
info = {
|
||||||
|
"context_size": server_context_size,
|
||||||
|
"supports_vision": False,
|
||||||
|
"supports_audio": False,
|
||||||
|
"supports_tools": False,
|
||||||
|
"supports_reasoning": False,
|
||||||
|
"media_marker": "<__media__>",
|
||||||
|
}
|
||||||
|
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
||||||
|
with patch.object(cls, "_get_model_info", return_value=info):
|
||||||
|
return cls(cfg, timeout=5)
|
||||||
|
|
||||||
|
def test_server_context_size_used_without_override(self):
|
||||||
|
client = self._validated_client(4096)
|
||||||
|
self.assertEqual(client.get_context_size(), 4096)
|
||||||
|
|
||||||
|
def test_provider_options_context_size_overrides_server(self):
|
||||||
|
client = self._validated_client(4096, {"context_size": 32768})
|
||||||
|
self.assertEqual(client.get_context_size(), 32768)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Tests for embedding cleanup on the main Frigate database.
|
||||||
|
|
||||||
|
Embeddings are deleted whether or not semantic search is currently enabled, so
|
||||||
|
the delete path has to tolerate databases where the vec0 tables were never
|
||||||
|
created and installs where the sqlite-vec extension is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteEmbeddings(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.db = SqliteVecQueueDatabase(os.path.join(self.tmp_dir.name, "test.db"))
|
||||||
|
self.db.start()
|
||||||
|
# the extension is not available to tests, so stand in for a database
|
||||||
|
# that has it loaded and use a plain table for the deletes
|
||||||
|
self.db.load_vec_extension = True
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.db.stop()
|
||||||
|
self.db.close()
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def _flush_writes(self) -> None:
|
||||||
|
# writes are queued and applied by a worker thread, and the queue is
|
||||||
|
# FIFO, so awaiting a later write means the earlier ones are done
|
||||||
|
self.db.execute_sql("PRAGMA user_version = 0").fetchall()
|
||||||
|
|
||||||
|
def _create_thumbnails_table(self) -> None:
|
||||||
|
self.db.execute_sql("CREATE TABLE vec_thumbnails (id TEXT PRIMARY KEY)")
|
||||||
|
self.db.execute_sql("INSERT INTO vec_thumbnails (id) VALUES ('a'), ('b')")
|
||||||
|
self._flush_writes()
|
||||||
|
|
||||||
|
def _thumbnail_ids(self) -> list[str]:
|
||||||
|
return [row[0] for row in self.db.execute_sql("SELECT id FROM vec_thumbnails")]
|
||||||
|
|
||||||
|
def test_delete_without_tables_does_not_raise(self) -> None:
|
||||||
|
# semantic search was never enabled, so event cleanup has nothing to do
|
||||||
|
self.db.delete_embeddings_thumbnail(event_ids=["1700000000.0-abc"])
|
||||||
|
self.db.delete_embeddings_description(event_ids=["1700000000.0-abc"])
|
||||||
|
|
||||||
|
def test_delete_removes_embeddings(self) -> None:
|
||||||
|
self._create_thumbnails_table()
|
||||||
|
|
||||||
|
self.db.delete_embeddings_thumbnail(event_ids=["a"])
|
||||||
|
self._flush_writes()
|
||||||
|
|
||||||
|
self.assertEqual(self._thumbnail_ids(), ["b"])
|
||||||
|
|
||||||
|
def test_delete_skipped_without_extension(self) -> None:
|
||||||
|
self._create_thumbnails_table()
|
||||||
|
self.db.load_vec_extension = False
|
||||||
|
|
||||||
|
self.db.delete_embeddings_thumbnail(event_ids=["a"])
|
||||||
|
self._flush_writes()
|
||||||
|
|
||||||
|
# the vec0 tables cannot be written without the extension
|
||||||
|
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Semantic Search settings tests -- MEDIUM tier.
|
||||||
|
*
|
||||||
|
* Focuses on the model_size field, which is unused when a GenAI embeddings
|
||||||
|
* provider is selected as the semantic search model. The resolved config always
|
||||||
|
* reports model_size (it has a schema default of "small"), even when the YAML
|
||||||
|
* file has no such key. Clearing model_size for a provider used to run
|
||||||
|
* unconditionally, which falsely marked the section dirty on load and asked the
|
||||||
|
* backend to delete a key that wasn't in the config file (KeyError: 'model_size').
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 PROVIDER = "llama_cpp";
|
||||||
|
const SETTINGS_URL = "/settings?page=integrationSemanticSearch";
|
||||||
|
const NOT_APPLICABLE = "Not applicable for GenAI providers";
|
||||||
|
const UNSAVED = "You have unsaved changes";
|
||||||
|
|
||||||
|
type SemanticSearch = {
|
||||||
|
enabled?: boolean;
|
||||||
|
model?: string;
|
||||||
|
model_size?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function installRoutes(page: Page, semanticSearch: SemanticSearch) {
|
||||||
|
const config = configFactory({
|
||||||
|
genai: { [PROVIDER]: { provider: PROVIDER, roles: ["embeddings"] } },
|
||||||
|
semantic_search: semanticSearch,
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastSavedConfig: unknown = null;
|
||||||
|
|
||||||
|
await page.route("**/api/config/schema.json", (route) =>
|
||||||
|
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/config", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({ json: config });
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/config/set", async (route) => {
|
||||||
|
lastSavedConfig = route.request().postDataJSON();
|
||||||
|
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/config/raw_paths", (route) =>
|
||||||
|
route.fulfill({ json: { semantic_search: semanticSearch } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { capturedConfig: () => lastSavedConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("semantic search model_size @medium", () => {
|
||||||
|
test("a provider with a defaulted model_size is not dirty on load", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// model_size stays at its schema default ("small"), i.e. it is not present
|
||||||
|
// in the YAML. This mirrors the reported bug: selecting a GenAI provider and
|
||||||
|
// returning to the page.
|
||||||
|
await installRoutes(frigateApp.page, {
|
||||||
|
enabled: true,
|
||||||
|
model: PROVIDER,
|
||||||
|
});
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
// The provider path is active: model_size shows "Not applicable".
|
||||||
|
await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible();
|
||||||
|
|
||||||
|
// Give any clearing effect time to fire, then confirm the section stayed
|
||||||
|
// clean (no phantom unsaved-changes banner, Save disabled).
|
||||||
|
await frigateApp.page.waitForTimeout(1000);
|
||||||
|
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("button", { name: "Save", exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching from a configured non-default model_size clears it", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
// A genuinely configured non-default model_size ("large") can only come from
|
||||||
|
// the YAML, so switching to a provider must still remove it.
|
||||||
|
const capture = await installRoutes(frigateApp.page, {
|
||||||
|
enabled: true,
|
||||||
|
model: "jinav2",
|
||||||
|
model_size: "large",
|
||||||
|
});
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
// Starts clean on a Jina model.
|
||||||
|
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
|
||||||
|
|
||||||
|
// Switch the model to the GenAI provider.
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("combobox", { name: /Semantic search model/ })
|
||||||
|
.click();
|
||||||
|
await frigateApp.page.getByRole("option", { name: PROVIDER }).click();
|
||||||
|
|
||||||
|
// The change is now dirty and model_size is no longer applicable.
|
||||||
|
await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible();
|
||||||
|
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
|
||||||
|
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("button", { name: "Save", exact: true })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// The saved payload removes model_size (empty string = "remove" key).
|
||||||
|
await expect
|
||||||
|
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||||
|
.toMatchObject({
|
||||||
|
config_data: {
|
||||||
|
semantic_search: { model: PROVIDER, model_size: "" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1936,7 +1936,8 @@
|
|||||||
"inputDimensionsNotDetectResolution": "Model input width and height are the input dimensions of the object detection model, not your camera's detect resolution. They should match the dimensions of the model you're using — typically a square size like 320x320 or 640x640."
|
"inputDimensionsNotDetectResolution": "Model input width and height are the input dimensions of the object detection model, not your camera's detect resolution. They should match the dimensions of the model you're using — typically a square size like 320x320 or 640x640."
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware."
|
"hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware.",
|
||||||
|
"inputsMissingGo2rtcStream": "An input below points at a go2rtc restream that no longer exists. Select an existing restream or enter the camera's URL manually, otherwise this camera will fail to connect."
|
||||||
},
|
},
|
||||||
"objects": {
|
"objects": {
|
||||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { parseRestreamStreamName } from "../theme/fields/streamSource";
|
||||||
import type { SectionConfigOverrides } from "./types";
|
import type { SectionConfigOverrides } from "./types";
|
||||||
|
|
||||||
const arrayAsTextWidget = {
|
const arrayAsTextWidget = {
|
||||||
@@ -42,6 +43,29 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "inputs-missing-go2rtc-stream",
|
||||||
|
field: "inputs",
|
||||||
|
position: "before",
|
||||||
|
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
|
||||||
|
severity: "warning",
|
||||||
|
docLink: "/configuration/restream",
|
||||||
|
condition: (ctx) => {
|
||||||
|
const streams = ctx.fullConfig?.go2rtc?.streams;
|
||||||
|
const inputs = ctx.formData?.inputs;
|
||||||
|
if (!Array.isArray(inputs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs.some((input) => {
|
||||||
|
const path = (input as { path?: unknown } | null)?.path;
|
||||||
|
const streamName = parseRestreamStreamName(
|
||||||
|
typeof path === "string" ? path : undefined,
|
||||||
|
);
|
||||||
|
return streamName !== undefined && !(streamName in (streams ?? {}));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
fieldDocs: {
|
fieldDocs: {
|
||||||
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
|
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ export function CameraInputsField(props: FieldProps) {
|
|||||||
[go2rtcStreamNames],
|
[go2rtcStreamNames],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSourceModeByIndex((previous) =>
|
||||||
|
Object.keys(previous).length > 0 ? {} : previous,
|
||||||
|
);
|
||||||
|
}, [formContext?.cameraName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpenByIndex((previous) => {
|
setOpenByIndex((previous) => {
|
||||||
const next: Record<number, boolean> = {};
|
const next: Record<number, boolean> = {};
|
||||||
@@ -222,18 +228,12 @@ export function CameraInputsField(props: FieldProps) {
|
|||||||
const handleSourceModeChange = useCallback(
|
const handleSourceModeChange = useCallback(
|
||||||
(index: number, nextMode: StreamSourceMode) => {
|
(index: number, nextMode: StreamSourceMode) => {
|
||||||
const input = inputs[index];
|
const input = inputs[index];
|
||||||
const currentPath =
|
|
||||||
typeof input?.path === "string" ? input.path : undefined;
|
|
||||||
|
|
||||||
if (nextMode === "manual") {
|
// Only revert the preset we set ourselves; never clobber custom args.
|
||||||
// Only revert the preset we set ourselves; never clobber custom args.
|
// The path is left alone until a stream is picked, so switching modes
|
||||||
if (input?.input_args === RESTREAM_PRESET) {
|
// never discards a typed URL or empties a required field.
|
||||||
handleFieldValuesChange(index, { input_args: undefined });
|
if (nextMode === "manual" && input?.input_args === RESTREAM_PRESET) {
|
||||||
}
|
handleFieldValuesChange(index, { input_args: undefined });
|
||||||
} else if (!parseRestreamStreamName(currentPath)) {
|
|
||||||
// Entering restream with a non-restream path: clear it so the dropdown
|
|
||||||
// shows its placeholder until a stream is chosen.
|
|
||||||
handleFieldValuesChange(index, { path: undefined });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode }));
|
setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode }));
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ export function SemanticSearchModelSizeWidget(props: WidgetProps) {
|
|||||||
model !== "jinav1" &&
|
model !== "jinav1" &&
|
||||||
model !== "jinav2";
|
model !== "jinav2";
|
||||||
|
|
||||||
// Clear model_size while on a provider (buildOverrides converts to ""
|
// model_size is unused on a GenAI provider. Only clear it (which the backend
|
||||||
// which the backend treats as "remove"). Restore the schema default
|
// treats as "remove") for a non-default value, which can only come from the
|
||||||
// when returning to a Jina model so the field isn't left empty.
|
// config file. A defaulted value is indistinguishable from unset in the
|
||||||
|
// resolved config, so clearing it would falsely dirty the field and delete a
|
||||||
|
// YAML key that isn't there. Restore the default when returning to a Jina model.
|
||||||
const { value, onChange, schema } = props;
|
const { value, onChange, schema } = props;
|
||||||
const schemaDefault = schema?.default as string | undefined;
|
const schemaDefault = schema?.default as string | undefined;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isProvider && value !== undefined) {
|
if (isProvider) {
|
||||||
onChange(undefined);
|
if (value !== undefined && value !== schemaDefault) {
|
||||||
} else if (!isProvider && value === undefined && schemaDefault) {
|
onChange(undefined);
|
||||||
|
}
|
||||||
|
} else if (value === undefined && schemaDefault) {
|
||||||
onChange(schemaDefault);
|
onChange(schemaDefault);
|
||||||
}
|
}
|
||||||
}, [isProvider, value, onChange, schemaDefault]);
|
}, [isProvider, value, onChange, schemaDefault]);
|
||||||
|
|||||||
Reference in New Issue
Block a user