Compare commits

..
16 Commits
Author SHA1 Message Date
Nicolas MowenandGitHub dc97f294a0 Support newer and cleaner llama.cpp embeddings api (#24499)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-09-28 15:22:59 -05:00
Josh HawkinsandGitHub a1c8bf99a7 Miscellaneous fixes (#24498)
* fix auto quality recovery after a downswitch

The downswitch callback armed the upswitch probe, but `triggerDownswitch` reset the stall history right after the callback returned, which disarmed it again. Auto quality stayed on the sub stream until the next chunk boundary no matter how much the connection recovered. The governor now arms the probe itself after the reset.

The chunk boundary effect also ran on mount, so when coverage was already cached it immediately undid the low quality cold start the seed effect had just picked. It now only runs when the chunk actually changes.

* fix recording playback quality switches and silent codec failures

A quality switch changes `bufferLength` a commit before the new source arrives, and since it was a dependency of the hls.js setup effect, the player rebuilt once on the outgoing playlist (jumping back to its original `startPosition`) and again on the new one. The buffer length now updates the running instance's config instead.

A fatal codec error with no lower quality stream to fall back to did nothing at all, so playback just sat there with no message. It now shows the playback failure toast, which is also limited to once per source since hls.js and the video element can both report the same failure.

* retry failed WebRTC probes and skip offline streams

The connectivity probe only ever tried the first go2rtc stream and cached its result for the whole page session, so a single offline camera at the top of the go2rtc config marked WebRTC unreachable for every camera until a reload, as did any brief network hiccup. The probe now starts with the stream being viewed and moves on to the next one when go2rtc reports that it can't open the stream's source. A failed result is only reused for 30 seconds, and it's retried on the next mount or when the page becomes visible again.

* fix two-way talk on cameras with AAC audio

The mic button was enabled whenever WebRTC was globally available, but the live view only switched to the WebRTC player when the stream itself qualified for WebRTC, and AAC playback audio disqualifies it. On most cameras the mic showed as on while nothing was sent. Two-way talk only needs the stream's video to connect since the backchannel is sent, not received, so AAC playback audio no longer blocks it. The mic is also turned off when a stream switch makes talk unavailable.

* keep Frigate+ model references when saving the models section

`/api/config` served a Frigate+ model's path as the resolved `/config/model_cache/<id>` file, and since the models list is saved whole, editing any model in the settings UI wrote that cache path back to the config in place of `plus://<id>`. After a restart the model loaded as a custom model with the default labelmap. The config API now reports the `plus://<id>` reference the model was configured with, and the models section drops the fields the Frigate+ model info supplies (size, tensor, pixel format, dtype, and type) instead of pinning them in the config.

* allow models to share shareable detection hardware

The hardware picker treated every device another model listed as taken, so a second model couldn't pick an Intel GPU or the CPU that the first one already used. The backend only rejects reuse of devices that can't be shared (Coral, MemryX), so the picker now matches that and only marks exclusive units as claimed.

* fix model card state and camera counts in the models editor

Model cards were keyed by index, so deleting one handed its state (such as the selected model source tab) to the card after it. Cards are keyed by scene now, which is unique per model.

The camera count on each card also ignored the backend's fallback to the `all` model, so a camera whose detect scene had no model of its own wasn't counted anywhere. It's counted under `all` now, which also feeds the recommended detector count.

* share a unit's temperature across repeated detector devices

Detector temperatures were matched to units by counting detectors of each type, so a device listed twice to run a second inference process (`hailo:PCIe` and `hailo:PCIe#2`) showed the next unit's temperature, or none at all. Distinct devices are numbered now and repeats share their unit's reading.

* update monitored hardware after a runtime config swap

`swap_runtime_config` rebound the stats emitter to the new config but not its `HardwareStats`, which kept polling hardware for the old config and applied camera updates to the discarded object. It now follows the swap along with its camera update subscriber.

* time out model downloads that never respond

`download_from_url` had no timeout, so a proxy or server that accepted the connection and never answered hung the download forever, including runtime downloads during startup. Connect and read timeouts now fail it like any other download error. The read timeout applies per socket read, so large models still finish.

* resolve segment start times in segment order

A camera stream's cached segments are probed concurrently, and each one chained its start off `last_segment_end` as soon as its own probe finished. When segments backed up in the cache and a later probe finished first, it chained off the wrong segment and the earlier one then moved `last_segment_end` backwards, so rows lost their exact adjacency. Probes still run concurrently, but each segment now waits for the one before it to settle its start before resolving its own.

* plan exports from the same coverage the vod route serves

The vod manifest nulls video-only glitch rows on audio-bearing streams, but exports planned their stream runs from the raw coverage, so a glitch row could produce a mixed-stream file or a 404 that failed the export. `null_audio_glitches` now works out each stream's audio composition itself, and exports go through it like the manifest and its realized timelines do.

An unstaged auto export also paged its playlist and chapters over main whenever main had any rows in range, even when the manifest served the range from sub and main only contributed glitches or slivers at the edges. It now reads the rows of the stream its single run actually uses.

* keep staged export chapters aligned across stream hand-offs

Each staged run of a mixed-stream export is rendered from its own pinned vod playlist, and that playlist's first clip snaps back to the preceding keyframe, so every staged file runs up to a GOP longer than its slice of the merged timeline. Chapters were placed on the merged timeline, so they drifted further from the video at every hand-off. Chapter windows for staged exports are now planned the same way each run's playlist is, carrying that keyframe lead-in into the offsets.

* clarify which hardware units only one model can use

* add e2e tests for shareable hardware and Frigate+ model saves

* only show the path field for a Frigate+ model without an API key

Without `PLUS_API_KEY` the models editor has no Frigate+ tab, so a `plus://` model showed every custom model field. The size, format, type, and labelmap fields are all supplied by the Frigate+ model info and ignored for a Frigate+ model, so editing them did nothing. Only the path is shown now, which still lets the model be switched to a custom one.

* keep a configured input_dtype when saving a Frigate+ model

The backend only overwrites `input_dtype` when the Frigate+ model info supplies `inputDataType`, which older models don't, so a configured dtype still matters for them. Saving the models section was dropping it along with the fields the backend always overwrites.

* probe every go2rtc stream until one isn't refused

The probe stopped after three streams, so with three offline cameras ahead of a working one, WebRTC was marked unreachable everywhere. It only moves past a stream when go2rtc refuses it, which is quick, and a stream that hangs still ends the probe at its timeout, so the cap bought nothing.

* only reuse a failed WebRTC probe for the stream it started from

A failed probe was reused for 30 seconds by every caller, so a camera whose stream timed out kept WebRTC unavailable for the next camera viewed, including one opened while that probe was still running. A pass still counts for every stream since it proves the connection, but a failure is only reused by probes that start from the same stream.

* strip input_dtype from Frigate+ models again

A Frigate+ model's config comes entirely from its model info, and a missing `inputDataType` means the `int` default. Keeping `input_dtype` meant switching from a custom model with `input_dtype: float` to a Frigate+ model carried the stale dtype over, with the field hidden so it couldn't be corrected.
2026-09-28 13:45:05 -06:00
Josh HawkinsandGitHub f9a37d4f4f remember last selected history sidebar tab (#24488)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (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
The History view always opened on Timeline unless the link carried a tab. The selected tab is now saved with useUserPersistence and used as the default when no tab is passed. Notification and shared review links no longer default to "timeline", so they pick up the saved tab too, and `?tab=` still overrides it.
2026-09-27 17:21:46 -06:00
Josh HawkinsandGitHub 0977fa5e15 Improve camera group live view grids (#24461)
* rework live dashboard grid layout and add natural mode

* hide natural aspect and layout import on phones

* fix merge conflict

* add confirmation dialog for clearing groups and streaming settings

* add confirmation dialog for natural aspect switch

* clean up

* remove note

* wording tweak

* remove unused

* fixes
2026-09-27 17:09:31 -06:00
Nick RogersandGitHub 6791df7971 Fix ZMQ detector ignoring the endpoint in its device string (#24464)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (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
2026-09-24 15:44:07 -06:00
Josh HawkinsandGitHub 9664d9ceae Notices and status bar improvements (#24459)
* Notice and status bar improvements

Status bar problems added in the same pass got the same `Date.now()` id and overwrote each other, so usually only one showed. Messages now fall back to their text as the id. The desktop status bar shows the most severe message with a count of the rest that opens a popover listing all of them, and the mobile drawer stacks them vertically instead of placing them side by side.

Dismissing a notice hid it for good, so a detector that restarted again after a dismissal was never shown. Dismiss is replaced by acknowledge, which hides a notice until it happens again, and mute, which hides it permanently. Kinds that never repeat (config and stream checks, the update notice) can only be muted. `reopen_at_count` is removed since acknowledge covers the failed login case.

* move camera CPU warnings to notices

High ffmpeg and detect CPU warnings sat in the status bar with no way to dismiss them. They're now `ffmpeg_high_cpu` and `detect_high_cpu` notices, raised per episode by the same tracker as skipped detections. Also stop failed login attempts held from before an acknowledgement from reopening the notice.

* fix mypy and handle missing cpu stats in notices
2026-09-24 15:39:18 -06:00
Nick RogersandGitHub c959df32c9 Apple Silicon Macs via lighter: ONNX detector on the Neural Engine and media engine decode (#24453)
* Run ONNX models on a Mac's Neural Engine through lighter's plugin provider

lighter's lighter.sh/ane device places an ONNX Runtime plugin execution
provider in the container. When it is present, the ONNX session setup
registers it once and opens sessions on its Neural Engine device, the same
place CUDA, ROCm and OpenVINO are chosen, so the onnx detector (and any
model that is not pinned to the CPU) runs there with no configuration. The
hardware probe reports it as an onnx unit.

* docs: hardware decode on an Apple Silicon Mac under lighter

A community section on the video decoding page: lighter's lighter.sh/video
device, hwaccel_args -c:v h264_v4l2m2m, and why the Raspberry Pi presets
decode a single-stream camera in software. The detector docs link to it.

* docs: set the lighter decoder per camera when codecs are mixed

* docs: the ONNX detector on a Mac's Neural Engine under lighter

* Format the Neural Engine provider setup

* Fall back to the default providers when the Neural Engine cannot load a model

* Apple Silicon ffmpeg presets for lighter's media engine, recommended when it is present
2026-09-24 15:37:32 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5889ef35f Bump image-size from 2.0.2 to 2.0.4 in /docs (#24462)
Bumps [image-size](https://github.com/image-size/image-size) from 2.0.2 to 2.0.4.
- [Release notes](https://github.com/image-size/image-size/releases)
- [Commits](https://github.com/image-size/image-size/commits)

---
updated-dependencies:
- dependency-name: image-size
  dependency-version: 2.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 13:48:50 -05:00
Josh HawkinsandGitHub 9443289112 bump onnxruntime, ruamel, and argcomplete (#24460) 2026-09-24 13:42:58 -05:00
007hacky007andGitHub 40f8ba1f7f Offer the full playback rate list on Safari (#24444)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (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
2026-09-24 10:02:06 -05:00
markfrancisonlyandGitHub 397f5253a5 Rate events over at least one second (#24455)
* Rate events over at least one second

EventsPerSecond.eps() divided the event count by the time since start(),
which can be a few milliseconds right after a restart. Frames buffered
during an ffmpeg restart then report as 100+ fps, and the same happens to
the detector fps. Use a window of at least one second.

* Keep sub-second windows consistent

Floor the divisor at the window length when the window is shorter than a
second, so a caller with a sub-second window still gets its true rate.
2026-09-24 06:28:00 -06:00
A. AhmetGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
af0ba19196 Feat/deepx npu detector (#24336)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (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
* feat(deepx): add DEEPX NPU detector and runtime integration.

* feat(deepx): enforce model_format requirement when ppu is enabled and add integrity checks for driver installation

* Update frigate/detectors/plugins/deepx.py

Public method lacks docstring

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Refactor DEEPX detector tests, support SSD and DAMO-YOLO

* feat(deepx): add anchor-free output decoding and corresponding tests

* Add tests and updates for DEEPX detector and refactor DEEPX accelerator code structure.

* fix: enhance model type validation and update documentation for DEEPX detector

* fix: add support for customizable score and NMS thresholds

* refactor: infer YOLO layout from the model, drop per-detector options and the dxrtd placeholder

* fix: keep only the anchor-free PPU verdict, re-read anchor-based each frame

* Update latency data for DEEPX NPU

* Expanding PPU support for DEEPX and set yolo-generic as default.

* enhance scale count resolution logic

* Extend PPU layout handling and YOLOX support to DEEPX detector

* fix: assume the largest PPU anchor table when the .dxnn has no layout

* Improve PPU decoding and introduce strides handling

* Improve PPU scope and fix box format mismatch

* Fix unnamed node issue that breaks traversal

* fix: update object detection model type description to remove outdated architecture

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-09-21 07:50:44 -05:00
Josh HawkinsandGitHub 52f50a7396 Tweaks (#24418)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (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
* don't display audio transcription provider message as health notice

* show remote provider for audio transcription in health pane

* adjust trigger and notifications messages to be consistent with the rest of the settings UI

* disable save buttons when there are no changes in config editor

* fix audio manager crash when a camera is added at runtime

The audio processor and the camera maintainer both poll the same `add` config update on their own one second timers, and the maintainer is what creates `camera_metrics[name]`. When the audio processor got there first it looked the new camera up before that entry existed, and the `KeyError` took down the whole `frigate.audio_manager` process. Whether it happens depends purely on which poll fires first, so cloning a camera from the UI fails or succeeds at random. `spawn_if_needed` now skips a camera whose metrics aren't there yet and picks it up on the next poll, the same way it already waits on a late ffmpeg update.

`AudioEventMaintainer` holds the `CameraMetrics` object now instead of indexing the manager dict on every audio chunk, which drops the IPC round trips and means a removed camera can't `KeyError` out of `detect_audio` after the maintainer pops the entry. The audio process is also registered with the watchdog, since a crash there previously left audio detection dead for every camera until a full restart, and it now receives the shared `DataProcessorMetrics` so `AudioTranscriptionRealTimeProcessor` gets the same type as the other real time processors.

* fix stationary max_frames dropping other tracked objects

When `max_frames` was set for a label, deregistering one object rebuilt norfair's list with a filter that kept an object only if it was both not the target and already on its way out, so every other healthy object of that label was dropped along with it. Any car leaving the frame took the rest of the cars with it and they came back as new tracked objects a few frames later. The filter now removes only the target, and objects that are expiring are still reaped by norfair on the next update.

* fix test

* fix skip_motion_threshold permanently disabling motion detection

The skip check returned before the two `accumulateWeighted` calls at the end of `detect`, so a skipped frame never made it into the background and setting `calibrating` there only picked a faster alpha for calls that never ran. `avg_frame` starts as an all zero image and a normally lit scene differs from black across nearly the whole frame, so the cameras I tested measure 0.84 to 0.98 against it. Any `skip_motion_threshold` below that number skips the first frame, leaves the background black, and skips every frame after it. Motion detection is dead for that camera until the setting is removed or Frigate restarts, with no motion boxes, no motion recordings, and no regions for the tracker since the detector stays calibrating.

Startup isn't the only way in. `update_mask` zeroes the background on any motion config change, and once a camera has calibrated the first IR switch or PTZ move freezes the background on the old scene, so it can't transition to the new one, which is the case the option exists for. The frame is now blended in before the early return at the same 0.2 alpha the calibrating path uses elsewhere, so a large scene change is still suppressed while the background catches up, about a second on a 5 fps camera, and then motion comes back.

* dump ffmpeg logs on every restart

The record watchdog restarted ffmpeg without flushing its `LogPipe`, so a camera whose recording segments went stale never showed a single line of ffmpeg output. The dump now happens in `start_or_restart_ffmpeg` right after the stop, which covers the stale record path, the record crash path, and the audio restart. `reset_capture_thread` and the audio `log_and_restart` fallback keep their own dumps since both pass `ffmpeg_process=None`.

* dump ffmpeg logs once per restart

The audio restart path dumped the log pipe itself before calling the helper, so the restart dump printed a second "last 100 lines" heading over an already drained deque and split the tail that `stop_ffmpeg` flushed into its own section. The heading is now only printed when there's something under it, and the audio path leaves the dump to the restart so each failure produces one section.

* keep all logpipe dumps consistent
2026-09-20 12:45:24 -06:00
+11 285dd5a461 Translations update from Hosted Weblate (#24333)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (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
* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Thai)

Currently translated at 86.0% (430 of 500 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: วรรณรุจ บุญแสง <wan.ball@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/th/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/audio
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 57.7% (436 of 755 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 57.7% (436 of 755 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 58.8% (83 of 141 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 57.4% (434 of 755 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (506 of 506 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 49.6% (70 of 141 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 49.8% (134 of 269 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 38.4% (518 of 1348 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (39 of 39 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (500 of 500 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marco Cordeiro <marcoecordeiro@gmail.com>
Co-authored-by: webmaster mvfc <webmaster@mvfc.com.br>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/pt_BR/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Danish)

Currently translated at 69.4% (347 of 500 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Martin Grüner <mrenurg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/da/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/audio
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Translated using Weblate (Estonian)

Currently translated at 19.4% (148 of 759 strings)

Translated using Weblate (Estonian)

Currently translated at 14.7% (75 of 508 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Estonian)

Currently translated at 69.4% (347 of 500 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Estonian)

Currently translated at 14.1% (20 of 141 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Estonian)

Currently translated at 29.5% (399 of 1348 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Estonian)

Currently translated at 69.2% (27 of 39 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Estonian)

Currently translated at 90.9% (240 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kaius Karon <kaiuskaron@gmail.com>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/et/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-settings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Russian)

Currently translated at 90.9% (240 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Павел Фролов <armagedetz@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ru/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player

* Translated using Weblate (Romanian)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1366 of 1366 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (264 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ro/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (1348 of 1348 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (119 of 119 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (506 of 506 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (35 of 35 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (264 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Uladz Maltsau <wldyslw@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/be/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/be/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Ukrainian)

Currently translated at 90.5% (239 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Павел Фролов <armagedetz@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Translated using Weblate (Catalan)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1366 of 1366 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (757 of 757 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1350 of 1350 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (119 of 119 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Catalan)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (506 of 506 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1348 of 1348 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (39 of 39 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ca/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Translated using Weblate (Polish)

Currently translated at 92.4% (244 of 264 strings)

Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: An Pa <andrzej.pasterczyk@googlemail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Italian)

Currently translated at 92.1% (696 of 755 strings)

Translated using Weblate (Italian)

Currently translated at 90.9% (240 of 264 strings)

Translated using Weblate (Italian)

Currently translated at 92.0% (695 of 755 strings)

Translated using Weblate (Italian)

Currently translated at 94.1% (1269 of 1348 strings)

Translated using Weblate (Italian)

Currently translated at 91.5% (108 of 118 strings)

Co-authored-by: Gringo <ita.translations@tiscali.it>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/it/
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Indonesian)

Currently translated at 17.7% (134 of 755 strings)

Translated using Weblate (Indonesian)

Currently translated at 30.4% (154 of 506 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.4% (38 of 39 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Indonesian)

Currently translated at 99.1% (117 of 118 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Catto <sisharyadi@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/id/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Dutch)

Currently translated at 73.5% (64 of 87 strings)

Translated using Weblate (Dutch)

Currently translated at 98.3% (116 of 118 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Herik <wvdh2002@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Rémon <remon@megelink.net>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/nl/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-exports

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Spanish)

Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Santiago Burgues <santibur06@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Swedish)

Currently translated at 95.0% (251 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Simon <simon.lappas2000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Korean)

Currently translated at 100.0% (66 of 66 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (24 of 24 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (506 of 506 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (1348 of 1348 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (68 of 68 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (39 of 39 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (118 of 118 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (264 of 264 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sinfancy <yujsjs@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ko/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translation: Frigate NVR/components-player

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translated using Weblate (Cantonese (Traditional Han script))

Currently translated at 47.7% (643 of 1348 strings)

Translated using Weblate (Cantonese (Traditional Han script))

Currently translated at 92.0% (243 of 264 strings)

Co-authored-by: ERK <sunnytse1@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/yue_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/yue_Hant/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-settings

---------

Co-authored-by: วรรณรุจ บุญแสง <wan.ball@gmail.com>
Co-authored-by: Marco Cordeiro <marcoecordeiro@gmail.com>
Co-authored-by: webmaster mvfc <webmaster@mvfc.com.br>
Co-authored-by: Martin Grüner <mrenurg@gmail.com>
Co-authored-by: Kaius Karon <kaiuskaron@gmail.com>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: Павел Фролов <armagedetz@gmail.com>
Co-authored-by: lukasig <lukasig@hotmail.com>
Co-authored-by: Uladz Maltsau <wldyslw@icloud.com>
Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: An Pa <andrzej.pasterczyk@googlemail.com>
Co-authored-by: Gringo <ita.translations@tiscali.it>
Co-authored-by: Catto <sisharyadi@gmail.com>
Co-authored-by: Herik <wvdh2002@hotmail.com>
Co-authored-by: Rémon <remon@megelink.net>
Co-authored-by: Santiago Burgues <santibur06@gmail.com>
Co-authored-by: Simon <simon.lappas2000@gmail.com>
Co-authored-by: sinfancy <yujsjs@gmail.com>
Co-authored-by: ERK <sunnytse1@gmail.com>
2026-09-19 07:16:00 -05:00
Josh HawkinsandGitHub 3d08bbe520 Miscellaneous fixes (#24402)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (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
* check for a valid frame before using its shape

With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first.

* fix the has_clip self-heal for events with no recordings

`vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code.

* return 403 for a snapshot or thumbnail on another camera

The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates.

* find DST transitions to the second

`get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition.

* don't run page shortcuts for keys a dialog already handled

Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render.

* fix train image filtering for a class with a dash

The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now.

* don't edit a chat message while a reply streams

The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends.

* fix restart failing under non-root

restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it.

* show runtime overrides in the settings form

The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config.

* fix mobile overflowing icons in system due to new health pane

* fix genai settings keeping a stale model and dropping roles after save

Switching a GenAI entry's provider left the previous provider's model selected, so saving wrote a model the new provider doesn't serve. llama.cpp can't find that model in `/v1/models`, so the backend reported every capability as false for the entry, and once the save refetched `genai/models` the roles widget stripped `transcribe` from the form on its own. The section showed unsaved changes right after saving, and saving again would have dropped the role. Switching provider now clears the model, and the roles widget only strips a role for a model or provider picked in the form, since the entry-level capability flags only describe the saved model. A selected role stays visible when the provider can't confirm it, so it can still be switched off. The llama.cpp model list also no longer repeats a model whose alias matches its id, which is what `--alias` produces.

* close onvif sessions on shutdown

`OnvifController.close()` only stopped its event loop, so the aiohttp sessions each `ONVIFCamera` holds and the `_poll_config_updates` task were left to be garbage collected during interpreter shutdown, when their warnings can no longer be logged. Every restart ended with a run of `Unclosed client session` and `Task was destroyed but it is pending!` logging errors, which only became visible once restart started exiting the process itself under non-root. `close()` now closes each camera's client and cancels the tasks on the loop before stopping it.

* fixes

* fixes
2026-09-18 07:33:23 -06:00
Nick DaviesandGitHub 0ca5cbbb63 Fix --validate-config exit code (#24398)
Validate config exists 0 regardless of if the config is valid or not.
This makes it not very useful for CI

Tiny fix to bail non-zero if the config is invalid
2026-09-18 07:52:39 -05:00
256 changed files with 10721 additions and 2042 deletions
+1
View File
@@ -5,3 +5,4 @@
/docker/rockchip/ @MarcA711 /docker/rockchip/ @MarcA711
/docker/rocm/ @harakas /docker/rocm/ @harakas
/docker/hailo8l/ @spanner3003 /docker/hailo8l/ @spanner3003
/docker/deepx/ @sixfab
+131
View File
@@ -0,0 +1,131 @@
#!/bin/bash
# Installs the DEEPX NPU kernel driver and the DX-RT runtime on the Docker host,
# then enables the vendor's dxrt.service. A container cannot load kernel
# modules, so this runs outside the image; the driver creates the /dev/dxrt*
# nodes and the daemon multiplexes the NPU across host and container.
#
# Driver, runtime and firmware versions must agree or inference hangs instead
# of failing at startup. The set this script installs is pinned in
# driver_version, runtime_version and firmware_version below; move them
# together, never one at a time.
#
# DEEPX NPU support in Frigate is maintained by Sixfab (https://sixfab.com).
set -euo pipefail
driver_version="v2.6.0"
# the commit the tag resolves to, since DEEPX signs neither tags nor releases
# and this is compiled and installed as root. Update both together
driver_commit="7074748e7104f470b02f517583abba652b3f05fa"
firmware_version="v2.7.4"
sudo apt-get update
sudo apt-get install -y git build-essential "linux-headers-$(uname -r)" pciutils wget
if ! lspci -d 1ff4: | grep -q .; then
echo "No DEEPX device found on the PCIe bus (lspci -d 1ff4:)."
echo "Check that the module is seated correctly before continuing."
exit 1
fi
# fetch the pinned commit rather than cloning the tag, so a retag cannot swap
# in different source. The build directory is reused so a second run after a
# failure does not stop on the directory already being there
mkdir -p dx_rt_npu_linux_driver
cd dx_rt_npu_linux_driver
git init -q
git remote get-url origin > /dev/null 2>&1 ||
git remote add origin https://github.com/DEEPX-AI/dx_rt_npu_linux_driver.git
git fetch --depth 1 origin "${driver_commit}"
git checkout -q FETCH_HEAD
fetched_commit=$(git rev-parse HEAD)
if [[ "${fetched_commit}" != "${driver_commit}" ]]; then
echo "Fetched commit ${fetched_commit} does not match pinned driver_commit ${driver_commit}."
echo "Refusing to build unverified driver source."
exit 1
fi
cd modules
sudo ./build.sh -c install --reload
sudo depmod -A
# dx_dma is the PCIe transport, dxrt_driver the NPU driver on top of it
for module in dx_dma dxrt_driver; do
if ! sudo modprobe "${module}"; then
echo "Unable to load the ${module} kernel module, common reasons are:"
echo "- Secure Boot is enabled and is rejecting the unsigned module."
echo "- The running kernel does not match the installed linux-headers."
exit 1
fi
done
if ! compgen -G "/dev/dxrt*" > /dev/null; then
echo "Modules loaded but no /dev/dxrt* device node appeared."
echo "Run ./sanity_check.sh from the driver repo to diagnose."
exit 1
fi
runtime_version="v3.4.0"
declare -A runtime_sha256=(
[amd64]="736cfef009ce9e974ab1ab610d867239d19d72a426a53e367ddcbd53297b6e20"
[arm64]="eb6107f5f02f2ad76ae89f414e8b5f346f34fbc6f0888236136853a26be6f6a0"
)
runtime_release="${runtime_version#v}"
deb_arch=$(dpkg --print-architecture)
deb_file="/tmp/libdxrt-bin_${runtime_release}_${deb_arch}.deb"
wget -qO "${deb_file}" \
"https://raw.githubusercontent.com/DEEPX-AI/dx_rt/${runtime_version}/release/${runtime_release}/libdxrt-bin_${runtime_release}_${deb_arch}.deb"
expected_sha256="${runtime_sha256[${deb_arch}]:-}"
if [[ -z "${expected_sha256}" ]]; then
echo "No pinned SHA-256 for architecture ${deb_arch}; refusing to install."
exit 1
fi
if [[ "$(sha256sum "${deb_file}" | cut -d' ' -f1)" != "${expected_sha256}" ]]; then
echo "SHA-256 mismatch for ${deb_file}; refusing to install."
exit 1
fi
sudo dpkg -i "${deb_file}"
sudo ldconfig
rm -f "${deb_file}"
sudo cp /usr/share/libdxrt-bin/service/dxrt.service /etc/systemd/system/
# With an endpoint set, dxrtd binds that path only, so the socket goes in a
# directory Frigate can mount (kept across restarts so the mount stays valid)
# and a symlink at the default /tmp path keeps host tools that do not set the
# variable working through their own fallback.
sudo mkdir -p /etc/systemd/system/dxrt.service.d
sudo tee /etc/systemd/system/dxrt.service.d/frigate.conf > /dev/null <<'UNIT'
[Service]
RuntimeDirectory=dxrt
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
Environment=DXRT_DYNAMIC_IPC_ENDPOINT=/run/dxrt/dxrt_dynamic_ipc.sock
ExecStartPost=/bin/ln -sfn /run/dxrt/dxrt_dynamic_ipc.sock /tmp/dxrt_dynamic_ipc.sock
UNIT
sudo systemctl daemon-reload
sudo systemctl enable dxrt.service
sudo systemctl restart dxrt.service
if ! sudo systemctl is-active --quiet dxrt.service; then
echo "dxrt.service did not start. Check: sudo journalctl -u dxrt.service"
exit 1
fi
echo "DEEPX driver and runtime installation complete."
echo "Driver version: $(modinfo -F version dxrt_driver) (expected ${driver_version#v})"
echo "Runtime version: ${runtime_release}"
echo "Device node(s): $(echo /dev/dxrt*)"
echo
echo "This driver expects NPU firmware ${firmware_version}. Check it with:"
echo " dxrt-cli --status"
echo "Update the module if it does not match before starting Frigate."
+3 -3
View File
@@ -27,7 +27,7 @@ pydantic == 2.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2025.* pytz == 2025.*
pyzmq == 27.1.* pyzmq == 27.1.*
ruamel.yaml == 0.18.* ruamel.yaml == 0.19.*
tzlocal == 5.2 tzlocal == 5.2
requests == 2.33.* requests == 2.33.*
types-requests == 2.32.* types-requests == 2.32.*
@@ -43,7 +43,7 @@ opencv-contrib-python == 4.11.0.*
scipy == 1.16.* scipy == 1.16.*
# OpenVino & ONNX # OpenVino & ONNX
openvino == 2025.4.* openvino == 2025.4.*
onnxruntime == 1.22.* onnxruntime == 1.30.*
# Embeddings # Embeddings
transformers == 4.45.* transformers == 4.45.*
# Generative AI # Generative AI
@@ -58,7 +58,7 @@ pyclipper == 1.4.*
shapely == 2.0.* shapely == 2.0.*
rapidfuzz==3.12.* rapidfuzz==3.12.*
# HailoRT # HailoRT
argcomplete==2.0.* argcomplete==3.7.*
contextlib2==0.6.* contextlib2==0.6.*
future==0.18.* future==0.18.*
netaddr==1.3.* netaddr==1.3.*
@@ -37,6 +37,7 @@ device_globs=(
"/dev/nvmap" "/dev/nvmap"
"/dev/nvidia*" "/dev/nvidia*"
"/dev/memx*" "/dev/memx*"
"/dev/dxrt*"
) )
IFS=',' read -ra extra_globs <<< "${DEVICE_ACL_PATHS:-}" IFS=',' read -ra extra_globs <<< "${DEVICE_ACL_PATHS:-}"
+59
View File
@@ -967,6 +967,65 @@ memryx:
# The .zip file must contain: # The .zip file must contain:
# ├── ssdlite_mobilenet.dfp (a file ending with .dfp) # ├── ssdlite_mobilenet.dfp (a file ending with .dfp)
# └── ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network) # └── ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network)
deepx:
title: DEEPX NPU
models:
- key: yolo
label: YOLO
recommended: true
download: No model is bundled with Frigate. Download a pre-compiled YOLO `.dxnn` model from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo) or compile your own with DX-COM, then bind-mount it into the container and point the model's `path` at it. The recommended model is `yolox-s_640x640_ppu.dxnn`. Its Post-Processing Unit (PPU) compile moves candidate selection onto the NPU, which makes it the fastest ModelZoo model measured through Frigate (about 13 ms on a DX-M1). The output layout is read from the compiled model, so anchor-based, anchor-free, NMS-in-head and PPU models (anchor-based or anchor-free) all need no extra configuration; prefer a `PPU` variant whenever the ModelZoo offers one. PPU models must be compiled with DX-COM 2.4.0 or later, which writes the head layout Frigate reads into the file.
ui: |-
Navigate to **Settings > System > Detection models** and select **DEEPX NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------------------------------- |
| **Custom object detector model path** | `/config/model_cache/deepx/yolox-s_640x640_ppu.dxnn` |
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
| **Object detection model input width** | `640` |
| **Object detection model input height** | `640` |
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
| **Model Input Tensor Shape** | `nhwc` (Frigate's default value) |
| **Model Input D Type** | `int` (Frigate's default value) |
| **Object Detection Model Type** | `yolo-generic` |
Quantization is baked into the compiled model, so no normalization is applied on the host and the input defaults do not need to be overridden.
yaml: |-
models:
- devices:
- deepx:PCIe:0
path: /config/model_cache/deepx/yolox-s_640x640_ppu.dxnn
labelmap_path: /labelmap/coco-80.txt
model_type: yolo-generic
width: 640
height: 640
- key: yolox
label: YOLOX
recommended: false
download: No model is bundled with Frigate. Download a pre-compiled YOLOX `.dxnn` model from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo), then bind-mount it into the container and point the model's `path` at it. The `_ppu` variant is faster and also works with the `yolo-generic` model type; the plain export needs `yolox` so its raw head is decoded.
ui: |-
Navigate to **Settings > System > Detection models** and select **DEEPX NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------------------- |
| **Custom object detector model path** | `/config/model_cache/deepx/yolox-s_640x640.dxnn`|
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
| **Object detection model input width** | `640` |
| **Object detection model input height** | `640` |
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
| **Model Input Tensor Shape** | `nhwc` (Frigate's default value) |
| **Model Input D Type** | `int` (Frigate's default value) |
| **Object Detection Model Type** | `yolox` |
The width and height must match the resolution the `.dxnn` file was compiled for.
yaml: |-
models:
- devices:
- deepx:PCIe:0
path: /config/model_cache/deepx/yolox-s_640x640.dxnn
labelmap_path: /labelmap/coco-80.txt
model_type: yolox
width: 640
height: 640
tensorrt: tensorrt:
title: TensorRT title: TensorRT
models: models:
+13 -11
View File
@@ -17,17 +17,19 @@ Hardware acceleration arguments tell FFmpeg to decode your camera's video stream
See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware. See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware.
| Preset (YAML config) | UI Label | Usage | Notes | | Preset (YAML config) | UI Label | Usage | Notes |
| --------------------- | ----------------------- | --------------------------------- | --------------------------------------------------------------- | | ------------------------- | ----------------------- | --------------------------------------------- | --------------------------------------------------------------- |
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | | | preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | | | preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected | | preset-apple-silicon-h264 | Apple Silicon (H.264) | Apple Silicon Mac under lighter, H.264 stream | Needs the `lighter.sh/video` device |
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead | | preset-apple-silicon-h265 | Apple Silicon (H.265) | Apple Silicon Mac under lighter, H.265 stream | Needs the `lighter.sh/video` device |
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead | | preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | | | preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | | | preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | | | preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode | | preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
<ConfigTabs> <ConfigTabs>
<TabItem value="ui"> <TabItem value="ui">
@@ -43,6 +43,10 @@ Frigate supports presets for optimal hardware accelerated video decoding:
- [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding. - [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding.
**Apple Silicon Mac** <CommunityBadge />
- [lighter](#apple-silicon-mac-lighter): Frigate can utilize the media engine in Apple Silicon Macs to accelerate video decoding, when running under the lighter container runtime.
**Other Hardware** **Other Hardware**
Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro
@@ -533,3 +537,35 @@ output_args:
Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet. Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet.
::: :::
## Apple Silicon Mac (lighter)
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. It gives a container the Mac's media engine as a standard V4L2 decoder, backed by VideoToolbox, so Frigate decodes H.264 and H.265 streams in hardware with the ffmpeg it already ships. It works on M1 and newer Macs with lighter 0.9.2 or newer.
Give the container the video device. With Docker Compose:
```yaml {4-5}
services:
frigate:
...
devices:
- lighter.sh/video=all
```
Or with `docker run`, add `--device lighter.sh/video=all`.
Then set the preset for the codec your cameras stream. The decoder is specific to the codec, so if your cameras mix H.264 and H.265, set the preset for the most common codec globally and override it on the other cameras:
```yaml
ffmpeg:
hwaccel_args: preset-apple-silicon-h264
cameras:
garage: # an H.265 camera
ffmpeg:
hwaccel_args: preset-apple-silicon-h265
```
The presets decode on the media engine and encode the Birdseye restream and timelapses there too. Scaling to the detect resolution runs on the CPU, as ffmpeg's V4L2 decoders cannot scale.
lighter can also run object detection on the Mac's Neural Engine; see [Apple Neural Engine (lighter)](object_detectors.md#apple-neural-engine-lighter).
+79 -1
View File
@@ -24,6 +24,7 @@ Frigate supports multiple different detectors that work on different types of ha
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices. - [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices.
- [Hailo](#hailo): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices. - [Hailo](#hailo): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms. - <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms.
- <CommunityBadge /> [DEEPX](#deepx-npu): The DEEPX NPU is available in m.2 format and as a HAT+ for the Raspberry Pi 5, offering broad compatibility across various platforms.
**AMD** **AMD**
@@ -33,6 +34,7 @@ Frigate supports multiple different detectors that work on different types of ha
**Apple Silicon** **Apple Silicon**
- [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices. - [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices.
- <CommunityBadge /> [ONNX](#apple-neural-engine-lighter): the ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs under the lighter container runtime.
**Intel** **Intel**
@@ -483,7 +485,7 @@ See [ONNX supported models](#onnx) for supported models, there are some caveats:
## ONNX ## ONNX
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available. ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, TensorRT, and a Mac's Neural Engine. On startup Frigate will automatically try to use a GPU if one is available.
:::info :::info
@@ -499,6 +501,9 @@ If the correct build is used for your GPU then the GPU will be detected and used
- Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image. - Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image.
- Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image. - Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image.
- **Apple Silicon Mac** <CommunityBadge />
- The Neural Engine will automatically be detected and used with the ONNX detector when Frigate runs under lighter with its Neural Engine device. See [Apple Neural Engine (lighter)](#apple-neural-engine-lighter).
::: :::
:::tip :::tip
@@ -514,6 +519,22 @@ models:
::: :::
### Apple Neural Engine (lighter) {#apple-neural-engine-lighter}
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. A container started with its `lighter.sh/ane` device gets an ONNX Runtime execution provider that runs models on the Mac's Neural Engine, and the ONNX detector uses it automatically, with the same models and configuration as on any other hardware. It works on M1 and newer Macs with lighter 0.9.2 or newer.
Give the Frigate container the Neural Engine device. With Docker Compose:
```yaml
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64
devices:
- lighter.sh/ane=all
```
Or with `docker run`, add `--device lighter.sh/ane=all`. Frigate then reports the Neural Engine under **Settings > System > Detection models**, and the ONNX detector's model loads on it. lighter can also decode camera streams on the Mac's media engine; see [Video Decoding](hardware_acceleration_video.md#apple-silicon-mac-lighter).
### Configuration {#configuration-onnx} ### Configuration {#configuration-onnx}
<ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} /> <ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} />
@@ -612,6 +633,63 @@ For detailed instructions on compiling models, refer to the [MemryX Compiler](ht
--- ---
## DEEPX NPU
This detector is available for use with the DEEPX NPU, both the DX-M1 M.2 module and the DX-M1M on the Sixfab AI HAT+ for the Raspberry Pi 5. The configuration below applies unchanged to either form factor. DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
See the [installation docs](../frigate/installation.md#deepx-npu) for information on installing the DEEPX kernel driver and runtime on the host and passing the NPU through to the container.
To run a model on a DEEPX NPU, list a `deepx` device on that model.
:::info
The DX-RT Python bindings are not part of the Frigate image. They are downloaded and installed into `/config/.local` the first time a DEEPX device is configured, verified against pinned checksums, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the files yourself.
:::
### Configuration {#configuration-deepx}
<ModelConfigDropdown detectorTitle="DEEPX" models={objectDetectorsModels.deepx.models} />
Frigate does not bundle a model for this detector. Models must be compiled to DEEPX's `.dxnn` format. Two model types are supported:
- `yolo-generic` for YOLO object detection models, the recommended default. The detector reads the model's output layout from the compiled file, so anchor-based, anchor-free and NMS-in-head models all work with the same configuration, as do models compiled with DEEPX's Post-Processing Unit (PPU) support.
- `yolox` for YOLOX models compiled without PPU support, whose raw head needs Frigate's YOLOX decoder. A YOLOX model compiled with PPU support works under either `yolox` or `yolo-generic`.
The quickest way to get one is the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo), which publishes pre-compiled `.dxnn` files for a range of YOLO object detection models. Download the `.dxnn`, bind-mount it into the container, and point the model's `path` at it. Alternatively, compile your own model with the DX-COM compiler. The recommended starting point is `yolox-s_640x640_ppu.dxnn`, the fastest ModelZoo model measured through Frigate:
```yaml
models:
- devices:
- deepx:PCIe:0
path: /config/model_cache/deepx/yolox-s_640x640_ppu.dxnn
labelmap_path: /labelmap/coco-80.txt
model_type: yolo-generic
width: 640
height: 640
```
For PPU models, use a `.dxnn` compiled with DX-COM 2.4.0 or later. Frigate reads the PPU head layout the compiler writes into the file and refuses to load a PPU model without it.
`model_type` must be set to `yolo-generic` or `yolox` to match the model; `yolo-generic` is the recommended default unless the model is a raw YOLOX export. Frigate defaults it to `ssd`, which this detector does not support, so the detector refuses to start on a model that leaves it unset.
`width` and `height` must match the resolution the model was compiled for. Quantization parameters are baked into the `.dxnn` file at compile time, so no normalization is applied on the host and Frigate's default `input_tensor`, `input_pixel_format`, and `input_dtype` values do not need to be overridden.
A DEEPX device is `PCIe:<index>`, as reported on the detector settings page. The NPU daemon multiplexes across processes, so the same device may be listed more than once to run additional inference processes against it:
```yaml
models:
- devices:
- deepx:PCIe:0
- deepx:PCIe:0
```
#### Label maps
The object detection models in the DEEPX ModelZoo are trained on the standard 80-class COCO label set, so `labelmap_path` must be set to `/labelmap/coco-80.txt`. Frigate's default label map uses an extended 91-class COCO scheme, and leaving it in place will cause detections to be reported as the wrong object type. For `yolo-generic` models the label map is also what the detector uses to tell the output layout, so a label map with the wrong number of classes is reported as an error at startup.
---
## NVidia TensorRT Detector ## NVidia TensorRT Detector
Nvidia Jetson devices may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt-jp6` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt-jp6`. This detector is designed to work with Yolo models for object detection. Nvidia Jetson devices may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt-jp6` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt-jp6`. This detector is designed to work with Yolo models for object detection.
+42 -1
View File
@@ -65,6 +65,11 @@ Frigate supports multiple different detectors that work on different types of ha
- [Supports many model architectures](../../configuration/object_detectors#memryx-mx3) - [Supports many model architectures](../../configuration/object_detectors#memryx-mx3)
- Runs best with tiny, small, or medium-size models - Runs best with tiny, small, or medium-size models
- <CommunityBadge /> [DEEPX](#deepx-npu): The DEEPX NPU is available in m.2 format and as a HAT+ for the Raspberry Pi 5, allowing for a wide range of compatibility with devices.
- [Supports YOLO model architectures](../../configuration/object_detectors#deepx-npu)
- Runs best with tiny or small size models
- Runs efficiently on low power hardware
**AMD** **AMD**
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection - [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
@@ -73,6 +78,10 @@ Frigate supports multiple different detectors that work on different types of ha
**Apple Silicon** **Apple Silicon**
- [ONNX via lighter](#apple-silicon): The ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs in the lighter container runtime
- [Supports the same model architectures as the ONNX detector](../../configuration/object_detectors#apple-neural-engine-lighter)
- Runs inside the Frigate container, with no separate detector process to set up
- The recommended way to run Frigate on a Mac
- [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection - [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector) - [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector)
- Runs well with any size models including large - Runs well with any size models including large
@@ -206,7 +215,13 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
### Apple Silicon ### Apple Silicon
With the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon. Frigate on a Mac is best run in the [lighter](https://github.com/fieldwork-ai/lighter) container runtime, where the [ONNX detector](../configuration/object_detectors.md#apple-neural-engine-lighter) runs on the Neural Engine of M1 and newer Macs from inside the Frigate container. There is no separate detector process to install or keep running, and the same container can decode video on the Mac's media engine.
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
| ---- | -------------------------------------- | ----------------------- | ---------------------- |
| M1 | t-320: 3.3 ms s-320: 7 ms s-640: 13 ms | 320: 6.6 ms | Nano-320: 38 ms |
Alternatively, with the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
:::warning :::warning
@@ -257,6 +272,32 @@ The MX3 is a pipelined architecture, where the maximum frames per second support
Inference speeds may vary depending on the host platform. The above data was measured on an **Intel 13700 CPU**. Platforms like Raspberry Pi, Orange Pi, and other ARM-based SBCs have different levels of processing capability, which may limit total FPS. Inference speeds may vary depending on the host platform. The above data was measured on an **Intel 13700 CPU**. Platforms like Raspberry Pi, Orange Pi, and other ARM-based SBCs have different levels of processing capability, which may limit total FPS.
### DEEPX NPU
Frigate supports the DEEPX NPU in both of its form factors: the **DX-M1** M.2 module, which works on x86 (Intel/AMD) and ARM-based SBCs such as the Raspberry Pi 5, and the **DX-M1M** on the [Sixfab AI HAT+](https://docs.sixfab.com/docs/ai-hat-plus-raspberry-pi-5-quickstart) for the Raspberry Pi 5. Both use the same driver and runtime, so the configuration is identical for either one. DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
The DEEPX driver and runtime run on the Docker host rather than inside the Frigate container and must be installed before the NPU can be used. See the [installation docs](installation.md#deepx-npu) for the setup steps and [the detector docs](/configuration/object_detectors#deepx-npu) for the configuration.
Frigate does not bundle a model for this detector. Models use DEEPX's `.dxnn` format, and pre-compiled YOLO models can be downloaded from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo). Prefer a model with a `_ppu` suffix whenever one is available for the architecture you want: these run part of the post-processing on the NPU itself and are considerably faster, roughly 2.5x for the same architecture and input size. **YOLOX-S with PPU is the recommended starting point.**
Inference times for a few recommended models, measured through Frigate's own stats on a DX-M1:
| Model | Input Size | DX-M1 Inference Time |
| ----------------- | ---------- | -------------------- |
| YOLOX-S (PPU) | 640 | ~ 13 ms |
| YOLOv9-t (PPU) | 640 | ~ 18 ms |
| YOLOv4 (PPU) | 512 | ~ 20 ms |
| YOLOX-S | 640 | ~ 34 ms |
| YOLOv9-s | 640 | ~ 39 ms |
Other ModelZoo YOLO variants are also supported but have not been measured. Inference speeds vary with the host platform, so a slower host such as a Raspberry Pi 5 will report higher times than those above.
:::note
A few ModelZoo models can not be used with Frigate: SSD models (they are trained on Pascal VOC, so their labels do not match Frigate's), DAMO-YOLO models, face and pose models, and the PPU builds of YOLOv7.
:::
### Nvidia Jetson ### Nvidia Jetson
Jetson devices are supported via the TensorRT or ONNX detectors when running Jetpack 6. It will [make use of the Jetson's hardware media engine](/configuration/hardware_acceleration_video#nvidia-jetson) when configured with the [appropriate presets](/configuration/ffmpeg_presets#hwaccel-presets), and will make use of the Jetson's GPU and DLA for object detection when configured with the [TensorRT detector](/configuration/object_detectors#nvidia-tensorrt-detector). Jetson devices are supported via the TensorRT or ONNX detectors when running Jetpack 6. It will [make use of the Jetson's hardware media engine](/configuration/hardware_acceleration_video#nvidia-jetson) when configured with the [appropriate presets](/configuration/ffmpeg_presets#hwaccel-presets), and will make use of the Jetson's GPU and DLA for object detection when configured with the [TensorRT detector](/configuration/object_detectors#nvidia-tensorrt-detector).
+93
View File
@@ -381,6 +381,99 @@ If you can't use Docker Compose, you can run the container with something simila
Finally, configure [hardware object detection](/configuration/object_detectors#memryx-mx3) to complete the setup. Finally, configure [hardware object detection](/configuration/object_detectors#memryx-mx3) to complete the setup.
### DEEPX NPU
The DEEPX NPU is available in two form factors, and Frigate supports both:
- **DX-M1** in the M.2 2280 form factor (like an NVMe SSD), for x86 (Intel/AMD) PCs, the Raspberry Pi 5, and other ARM SBCs with an exposed PCIe M.2 slot.
- **DX-M1M** on the [Sixfab AI HAT+](https://docs.sixfab.com/docs/ai-hat-plus-raspberry-pi-5-quickstart), a HAT+ board that connects to the Raspberry Pi 5 over PCIe Gen 3 x1.
Both present the NPU through the same PCIe driver and DX-RT runtime, so the setup below and the detector configuration are identical for either one. Nothing needs to change when moving between them.
DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
#### Versions
A DEEPX install has several separately versioned pieces, and they all have to agree. The driver, the runtime, and the daemon live on the Docker host; Frigate itself carries only the Python bindings, which it downloads on first start:
| Component | Version | Installed on | Installed by |
| -------------- | -------- | ------------ | ------------------------- |
| Kernel driver | `v2.6.0` | Host | `user_installation.sh` |
| DX-RT runtime | `v3.4.0` | Host | `user_installation.sh` |
| NPU firmware | `v2.7.4` | The module | Flashed from the host |
| DX-RT bindings | `v3.4.0` | Frigate | Downloaded at first start |
:::warning
A version mismatch does not produce a startup error. It typically shows up as inference requests that are accepted but never return a result, so detections simply stop appearing while Frigate looks healthy. If that happens after a Frigate upgrade, check every version in the table before anything else.
:::
The installation script installs the DX-RT runtime on the host and enables `dxrt.service`, so the daemon starts at boot and any other program on the host can share the NPU with Frigate. Check the firmware version with `dxrt-cli --status` and update the module if it does not match the table above.
#### Installation
The DEEPX kernel driver must be installed on the host rather than in the container, because containers share the host kernel and cannot load kernel modules. Installing it creates the `/dev/dxrt*` device nodes that are passed through to Frigate. The same script installs the DX-RT runtime and enables `dxrt.service`, the daemon that owns the NPU and hands work to it on behalf of Frigate and anything else on the host.
1. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/deepx/user_installation.sh).
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
3. Run the script with `./user_installation.sh`
4. **Restart your computer** to complete driver installation.
Confirm the NPU is visible before continuing:
```bash
ls /dev/dxrt*
```
Then confirm the daemon is running and listening in `/run/dxrt`:
```bash
systemctl is-active dxrt.service
ls /run/dxrt/
```
#### Setup
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
#### Docker configuration
Frigate needs the NPU device node and the directory holding the daemon's socket:
```yaml
services:
frigate:
devices:
- /dev/dxrt0:/dev/dxrt0
volumes:
- /run/dxrt:/run/dxrt
```
If you can't use Docker Compose, add `--device /dev/dxrt0:/dev/dxrt0 -v /run/dxrt:/run/dxrt` to your `docker run` command.
Add one `--device` per NPU, contiguously from `/dev/dxrt0`, since the client stops enumerating at the first gap.
The installation script configures `dxrt.service` to place its socket in `/run/dxrt` through a systemd drop-in. Mounting the directory rather than the socket file means the container sees the new socket after `dxrt.service` is restarted, rather than holding on to a deleted one.
`dxrtd` listens on an abstract socket as well, but that one does not cross into a container, so Frigate names the filesystem socket through `DXRT_DYNAMIC_IPC_ENDPOINT` on your behalf. Set that variable on the container yourself only if the daemon listens somewhere else, which means you also set it for `dxrtd` through its own systemd drop-in. The script writes `/etc/systemd/system/dxrt.service.d/frigate.conf` for exactly that, and has `dxrt.service` link the socket to `/tmp/dxrt_dynamic_ipc.sock` when it starts, so the host's own `dxrt-cli` and `dxtop` keep finding it at the default path they fall back to.
:::note
The DX-RT client exits when `dxrt.service` stops, so restart the Frigate container after restarting `dxrt.service`.
:::
The device node is needed as well as the socket, because the client opens the NPU directly even though the daemon arbitrates access. Without it, inference fails with `Device not found`.
`/dev/shm` does not need sharing.
The DX-RT python bindings are not shipped in the Frigate image. Frigate downloads them on first start when a DEEPX detector is configured, and caches them under `/config`.
#### Configuration
Finally, configure [hardware object detection](/configuration/object_detectors#deepx-npu) to complete the setup.
### Rockchip platform ### Rockchip platform
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands: Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands:
+1 -1
View File
@@ -184,6 +184,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
### Where do I see problems Frigate has detected? ### Where do I see problems Frigate has detected?
Open System > Health. The Notices list keeps a record of problems Frigate has found, and you can dismiss any entry to acknowledge it. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has undismissed entries. On mobile, tap the warning icon in the bottom navigation bar to see them. Open System > Health. The Notices list keeps a record of problems Frigate has found. Acknowledge an entry to hide it until the problem happens again, or mute it to hide it for good. Hidden entries stay listed under Show hidden in the filter. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has entries showing. On mobile, tap the warning icon in the bottom navigation bar to see them.
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports. The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
+4 -4
View File
@@ -11375,15 +11375,15 @@
} }
}, },
"node_modules/image-size": { "node_modules/image-size": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", "integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"image-size": "bin/image-size.js" "image-size": "bin/image-size.js"
}, },
"engines": { "engines": {
"node": ">=16.x" "node": ">=18"
} }
}, },
"node_modules/immer": { "node_modules/immer": {
+82 -16
View File
@@ -4174,19 +4174,20 @@ paths:
Get notices, most severe first. Get notices, most severe first.
Args: Args:
include_dismissed: Also return dismissed notices, for the history view include_hidden: Also return acknowledged and muted notices, for the
hidden list
Returns: Returns:
The notices The notices
operationId: get_notices_notices_get operationId: get_notices_notices_get
parameters: parameters:
- name: include_dismissed - name: include_hidden
in: query in: query
required: false required: false
schema: schema:
type: boolean type: boolean
default: false default: false
title: Include Dismissed title: Include Hidden
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4221,16 +4222,16 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/dismissed_checks: /notices/muted_checks:
get: get:
tags: tags:
- Notices - Notices
summary: Get Dismissed Checks summary: Get Muted Checks
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Get the dismissed config and stream check rows, newest first. Get the muted config and stream check rows, newest first.
operationId: get_dismissed_checks_notices_dismissed_checks_get operationId: get_muted_checks_notices_muted_checks_get
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4240,16 +4241,16 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/dismissed: /notices/hidden:
delete: delete:
tags: tags:
- Notices - Notices
summary: Purge Dismissed summary: Unhide All Notices
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Delete every dismissed notice and check row so each can show again. Show every acknowledged and muted notice and check row again.
operationId: purge_dismissed_notices_dismissed_delete operationId: unhide_all_notices_notices_hidden_delete
responses: responses:
'200': '200':
description: Successful Response description: Successful Response
@@ -4259,18 +4260,83 @@ paths:
security: security:
- frigateAdminAuth: [] - frigateAdminAuth: []
x-required-role: admin x-required-role: admin
/notices/{notice_id}/dismiss: /notices/{notice_id}/acknowledge:
post: post:
tags: tags:
- Notices - Notices
summary: Dismiss Notice summary: Acknowledge Notice
description: |- description: |-
**Access:** Admin role required. **Access:** Admin role required.
Hide a notice or a config or stream check row. Hide a notice until it happens again.
It stays hidden if the same problem happens again. Config and stream check rows and the update notice never repeat, so they
operationId: dismiss_notice_notices__notice_id__dismiss_post can only be muted.
operationId: acknowledge_notice_notices__notice_id__acknowledge_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/mute:
post:
tags:
- Notices
summary: Mute Notice
description: |-
**Access:** Admin role required.
Hide a notice or a config or stream check row for good.
operationId: mute_notice_notices__notice_id__mute_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/hidden:
delete:
tags:
- Notices
summary: Unhide Notice
description: |-
**Access:** Admin role required.
Show an acknowledged or muted notice or check row again.
operationId: unhide_notice_notices__notice_id__hidden_delete
parameters: parameters:
- name: notice_id - name: notice_id
in: path in: path
+4
View File
@@ -99,6 +99,10 @@ def main() -> None:
print("*** End Config Validation Errors ***") print("*** End Config Validation Errors ***")
print("*************************************************************") print("*************************************************************")
# force a non-zero exit code for config failures
if args.validate_config:
sys.exit(1)
# attempt to start Frigate in recovery mode # attempt to start Frigate in recovery mode
try: try:
config = FrigateConfig.load(install=True, safe_load=True) config = FrigateConfig.load(install=True, safe_load=True)
+7
View File
@@ -393,6 +393,11 @@ def config(request: Request):
model_dict["non_logo_attributes"] = model.non_logo_attributes model_dict["non_logo_attributes"] = model.non_logo_attributes
model_dict["labelmap"] = model.merged_labelmap model_dict["labelmap"] = model.merged_labelmap
# report the configured reference rather than the resolved cache path,
# so saving the config back doesn't lose the Frigate+ model
if model.plus_id:
model_dict["path"] = f"plus://{model.plus_id}"
if not config["plus"]["enabled"]: if not config["plus"]["enabled"]:
continue continue
@@ -429,6 +434,8 @@ def ffmpeg_presets():
hwaccel_presets = [ hwaccel_presets = [
"preset-rpi-64-h264", "preset-rpi-64-h264",
"preset-rpi-64-h265", "preset-rpi-64-h265",
"preset-apple-silicon-h264",
"preset-apple-silicon-h265",
"preset-jetson-h264", "preset-jetson-h264",
"preset-jetson-h265", "preset-jetson-h265",
"preset-rkmpp", "preset-rkmpp",
+1
View File
@@ -51,6 +51,7 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
if app.stats_emitter is not None: if app.stats_emitter is not None:
app.stats_emitter.config = config app.stats_emitter.config = config
app.stats_emitter.hardware_stats.set_config(config)
if app.dispatcher is not None: if app.dispatcher is not None:
app.dispatcher.config = config app.dispatcher.config = config
+1 -7
View File
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
null_audio_glitches, null_audio_glitches,
plan_clip, plan_clip,
resolve_coverage, resolve_coverage,
stream_has_audio,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -681,15 +680,10 @@ async def _vod_response(
end_ts, end_ts,
force_discontinuity, force_discontinuity,
) )
intervals = resolve_coverage(camera_name, start_ts, end_ts)
# rows contradicting their stream's audio composition are # rows contradicting their stream's audio composition are
# truncated-shutdown glitches # truncated-shutdown glitches
main_audio = stream_has_audio(intervals, main=True)
sub_audio = stream_has_audio(intervals, main=False)
spans = build_spans( spans = build_spans(
null_audio_glitches(intervals, main_audio, sub_audio), null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
stream_preference, stream_preference,
) )
+50 -22
View File
@@ -14,17 +14,18 @@ router = APIRouter(tags=[Tags.notices])
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))]) @router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse: def get_notices(request: Request, include_hidden: bool = False) -> JSONResponse:
"""Get notices, most severe first. """Get notices, most severe first.
Args: Args:
include_dismissed: Also return dismissed notices, for the history view include_hidden: Also return acknowledged and muted notices, for the
hidden list
Returns: Returns:
The notices The notices
""" """
return JSONResponse( return JSONResponse(
content=request.app.notice_registry.active(include_dismissed=include_dismissed) content=request.app.notice_registry.active(include_hidden=include_hidden)
) )
@@ -34,37 +35,64 @@ def get_notice_stats(request: Request) -> JSONResponse:
return JSONResponse(content=request.app.notice_registry.stats()) return JSONResponse(content=request.app.notice_registry.stats())
@router.get( @router.get("/notices/muted_checks", dependencies=[Depends(require_role(["admin"]))])
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))] def get_muted_checks(request: Request) -> JSONResponse:
) """Get the muted config and stream check rows, newest first."""
def get_dismissed_checks(request: Request) -> JSONResponse: return JSONResponse(content=request.app.notice_registry.muted_checks())
"""Get the dismissed config and stream check rows, newest first."""
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
@router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))]) @router.delete("/notices/hidden", dependencies=[Depends(require_role(["admin"]))])
def purge_dismissed(request: Request) -> JSONResponse: def unhide_all_notices(request: Request) -> JSONResponse:
"""Delete every dismissed notice and check row so each can show again.""" """Show every acknowledged and muted notice and check row again."""
request.app.notice_registry.purge_dismissed() request.app.notice_registry.unhide_all()
return JSONResponse( return JSONResponse(content={"success": True, "message": "Notices shown again"})
content={"success": True, "message": "Dismissed notices cleared"}
)
# model notice ids contain a slash, so the id is a path parameter # model notice ids contain a slash, so the id is a path parameter
@router.post( @router.post(
"/notices/{notice_id:path}/dismiss", "/notices/{notice_id:path}/acknowledge",
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
) )
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse: def acknowledge_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice or a config or stream check row. """Hide a notice until it happens again.
It stays hidden if the same problem happens again. Config and stream check rows and the update notice never repeat, so they
can only be muted.
""" """
if not request.app.notice_registry.dismiss(notice_id): if not request.app.notice_registry.acknowledge(notice_id):
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Notice not found"}, content={"success": False, "message": "Notice not found"},
status_code=404, status_code=404,
) )
return JSONResponse(content={"success": True, "message": "Notice dismissed"}) return JSONResponse(content={"success": True, "message": "Notice acknowledged"})
@router.post(
"/notices/{notice_id:path}/mute",
dependencies=[Depends(require_role(["admin"]))],
)
def mute_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice or a config or stream check row for good."""
if not request.app.notice_registry.mute(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice muted"})
@router.delete(
"/notices/{notice_id:path}/hidden",
dependencies=[Depends(require_role(["admin"]))],
)
def unhide_notice(request: Request, notice_id: str) -> JSONResponse:
"""Show an acknowledged or muted notice or check row again."""
if not request.app.notice_registry.unhide(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice shown again"})
+12 -2
View File
@@ -483,7 +483,7 @@ class FrigateApp:
def start_audio_processor(self) -> None: def start_audio_processor(self) -> None:
self.audio_process = AudioProcessor( self.audio_process = AudioProcessor(
self.config, self.camera_metrics, self.stop_event self.config, self.camera_metrics, self.embeddings_metrics, self.stop_event
) )
self.audio_process.start() self.audio_process.start()
self.processes["audio_detector"] = self.audio_process.pid or 0 self.processes["audio_detector"] = self.audio_process.pid or 0
@@ -556,10 +556,20 @@ class FrigateApp:
"output", "output",
lambda: OutputProcess(self.config, self.stop_event), lambda: OutputProcess(self.config, self.stop_event),
), ),
(
"audio_process",
"audio_detector",
lambda: AudioProcessor(
self.config,
self.camera_metrics,
self.embeddings_metrics,
self.stop_event,
),
),
] ]
for attr, key, factory in specs: for attr, key, factory in specs:
if not hasattr(self, attr): if getattr(self, attr, None) is None:
continue continue
def on_restart( def on_restart(
+51
View File
@@ -46,8 +46,42 @@ _PROVIDER_LABELS = {
"MIGraphXExecutionProvider": "MIGraphX", "MIGraphXExecutionProvider": "MIGraphX",
"OpenVINOExecutionProvider": "OpenVINO", "OpenVINOExecutionProvider": "OpenVINO",
"CPUExecutionProvider": "CPU", "CPUExecutionProvider": "CPU",
"LighterANE": "Neural Engine",
} }
# lighter (https://github.com/fieldwork-ai/lighter) places an ONNX Runtime plugin
# execution provider in a container started with --device lighter.sh/ane=all,
# which runs models on a Mac's Neural Engine; LIGHTER_ANE_EP names where it is
LIGHTER_ANE_EP_NAME = "LighterANE"
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
def get_lighter_ane_devices() -> list[Any]:
"""Get the Neural Engine devices lighter's provider offers, registering it once.
Returns:
The provider's ONNX Runtime devices, or an empty list without lighter's device
"""
library = os.environ.get("LIGHTER_ANE_EP", LIGHTER_ANE_LIBRARY)
if not os.path.exists(library):
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
if not devices:
try:
ort.register_execution_provider_library(LIGHTER_ANE_EP_NAME, library)
except Exception as e:
logger.warning(
f"Failed to load the Neural Engine provider from {library}: {e}"
)
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
return devices
def is_arm64_platform() -> bool: def is_arm64_platform() -> bool:
"""Check if we're running on an ARM platform.""" """Check if we're running on an ARM platform."""
@@ -689,6 +723,23 @@ def get_optimized_runner(
if rknn_path: if rknn_path:
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path)) return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
if device != "CPU" and (ane_devices := get_lighter_ane_devices()):
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
sess_options.add_provider_for_devices(ane_devices, {})
try:
session = ort.InferenceSession(model_path, sess_options=sess_options)
except Exception as e:
logger.warning(
f"Failed to load {model_path} on the Neural Engine, using the default providers: {e}"
)
else:
return _record_runner(
model_path,
model_type,
ONNXModelRunner(session, model_type=model_type),
)
providers, options = get_ort_providers(device == "CPU", device, **kwargs) providers, options = get_ort_providers(device == "CPU", device, **kwargs)
if providers[0] == "CPUExecutionProvider": if providers[0] == "CPUExecutionProvider":
+7
View File
@@ -123,6 +123,7 @@ class ModelConfig(BaseModel):
_all_attributes: list[str] = PrivateAttr() _all_attributes: list[str] = PrivateAttr()
_all_attribute_logos: list[str] = PrivateAttr() _all_attribute_logos: list[str] = PrivateAttr()
_model_hash: str = PrivateAttr() _model_hash: str = PrivateAttr()
_plus_id: str | None = PrivateAttr(default=None)
@property @property
def merged_labelmap(self) -> dict[int, str]: def merged_labelmap(self) -> dict[int, str]:
@@ -148,6 +149,11 @@ class ModelConfig(BaseModel):
def model_hash(self) -> str: def model_hash(self) -> str:
return self._model_hash return self._model_hash
@property
def plus_id(self) -> str | None:
"""The Frigate+ model id, once a plus:// path has been resolved."""
return self._plus_id
def __init__(self, **config): def __init__(self, **config):
super().__init__(**config) super().__init__(**config)
@@ -178,6 +184,7 @@ class ModelConfig(BaseModel):
os.makedirs(MODEL_CACHE_DIR, exist_ok=True) os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
model_id = self.path[7:] model_id = self.path[7:]
self._plus_id = model_id
self.path = os.path.join(MODEL_CACHE_DIR, model_id) self.path = os.path.join(MODEL_CACHE_DIR, model_id)
model_info_path = f"{self.path}.json" model_info_path = f"{self.path}.json"
+26
View File
@@ -25,6 +25,7 @@ SYS_ROOT = "/sys"
DEV_ROOT = "/dev" DEV_ROOT = "/dev"
PROC_ROOT = "/proc" PROC_ROOT = "/proc"
ETC_ROOT = "/etc" ETC_ROOT = "/etc"
LIB_ROOT = "/usr/lib"
# a Coral reports as Global Unichip until its firmware is loaded, then as Google # a Coral reports as Global Unichip until its firmware is loaded, then as Google
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")} CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
@@ -273,6 +274,16 @@ def detect_memryx() -> DetectionHardware | None:
return _hardware("memryx", "memryx", "MemryX MX3", units) return _hardware("memryx", "memryx", "MemryX MX3", units)
def detect_deepx() -> DetectionHardware | None:
"""Find DEEPX NPUs by their device nodes."""
units = _dev_units("dxrt*", "deepx:PCIe:{index}", "PCIe")
if not units:
return None
return _hardware("deepx", "deepx", "DEEPX NPU", units)
def detect_rockchip() -> DetectionHardware | None: def detect_rockchip() -> DetectionHardware | None:
"""Find a Rockchip NPU by reading the SoC from the device tree.""" """Find a Rockchip NPU by reading the SoC from the device tree."""
compatible = _read(f"{PROC_ROOT}/device-tree/compatible") compatible = _read(f"{PROC_ROOT}/device-tree/compatible")
@@ -307,6 +318,19 @@ def detect_synaptics() -> DetectionHardware | None:
return _hardware("synaptics", "synaptics", "Synaptics NPU", units) return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
def detect_lighter_ane() -> DetectionHardware | None:
"""Find a Mac's Neural Engine by the provider library lighter's device places."""
library = os.environ.get(
"LIGHTER_ANE_EP", f"{LIB_ROOT}/lighter/liblighter_ane_ep.so"
)
if not os.path.exists(library):
return None
# runs through onnx, whose session picks lighter's provider when it is present
units = [HardwareUnit(device="onnx", label="Neural Engine")]
return _hardware("onnx:lighter", "onnx", "Apple Neural Engine", units)
def detect_cpu() -> DetectionHardware: def detect_cpu() -> DetectionHardware:
"""The CPU, which is always available.""" """The CPU, which is always available."""
units = [HardwareUnit(device="cpu", label="CPU")] units = [HardwareUnit(device="cpu", label="CPU")]
@@ -319,6 +343,7 @@ PROBES = (
detect_coral_usb, detect_coral_usb,
detect_hailo, detect_hailo,
detect_memryx, detect_memryx,
detect_deepx,
detect_intel_npu, detect_intel_npu,
detect_intel_gpu, detect_intel_gpu,
detect_nvidia_gpu, detect_nvidia_gpu,
@@ -327,6 +352,7 @@ PROBES = (
detect_rockchip, detect_rockchip,
detect_axengine, detect_axengine,
detect_synaptics, detect_synaptics,
detect_lighter_ane,
detect_cpu, detect_cpu,
) )
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,7 +1,7 @@
import json import json
import logging import logging
import os import os
from typing import Any, Literal from typing import Any, ClassVar, Literal
import numpy as np import numpy as np
import zmq import zmq
@@ -21,6 +21,7 @@ class ZmqDetectorConfig(BaseDetectorConfig):
model_config = ConfigDict( model_config = ConfigDict(
title="ZMQ IPC", title="ZMQ IPC",
) )
device_spec_field: ClassVar[str] = "endpoint"
type: Literal[DETECTOR_KEY] type: Literal[DETECTOR_KEY]
endpoint: str = Field( endpoint: str = Field(
+46 -31
View File
@@ -11,6 +11,7 @@ from typing import Any
import numpy as np import numpy as np
from frigate.camera import CameraMetrics
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import CameraConfig, CameraInput, FrigateConfig from frigate.config import CameraConfig, CameraInput, FrigateConfig
@@ -36,6 +37,7 @@ from frigate.data_processing.common.audio_transcription.model import (
from frigate.data_processing.real_time.audio_transcription import ( from frigate.data_processing.real_time.audio_transcription import (
AudioTranscriptionRealTimeProcessor, AudioTranscriptionRealTimeProcessor,
) )
from frigate.data_processing.types import DataProcessorMetrics
from frigate.ffmpeg_presets import parse_preset_input from frigate.ffmpeg_presets import parse_preset_input
from frigate.log import LogPipe, suppress_stderr_during from frigate.log import LogPipe, suppress_stderr_during
from frigate.util.builtin import get_ffmpeg_arg_list, load_labels from frigate.util.builtin import get_ffmpeg_arg_list, load_labels
@@ -86,6 +88,7 @@ class AudioProcessor(FrigateProcess):
self, self,
config: FrigateConfig, config: FrigateConfig,
camera_metrics: DictProxy, camera_metrics: DictProxy,
embeddings_metrics: DataProcessorMetrics,
stop_event: MpEvent, stop_event: MpEvent,
): ):
super().__init__( super().__init__(
@@ -93,8 +96,38 @@ class AudioProcessor(FrigateProcess):
) )
self.camera_metrics = camera_metrics self.camera_metrics = camera_metrics
self.embeddings_metrics = embeddings_metrics
self.config = config self.config = config
def spawn_if_needed(self, camera: CameraConfig) -> None:
"""Start an audio maintainer for the camera once everything it needs
has arrived. Returning early leaves the camera for the next poll."""
name = camera.name
if name is None or name in self.audio_threads:
return
if not camera.enabled or not camera.audio.enabled:
return
# ffmpeg update may not have arrived yet
if not any("audio" in i.roles for i in camera.ffmpeg.inputs):
return
# the camera maintainer creates metrics on its own poll of the same
# add update and may not have gotten there yet
metrics = self.camera_metrics.get(name)
if metrics is None:
return
thread = AudioEventMaintainer(
camera,
self.config,
metrics,
self.embeddings_metrics,
self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type]
self.genai_manager,
)
self.audio_threads[name] = thread
thread.start()
self.logger.info(f"Audio maintainer started for {name}")
def __stop_audio_thread(self, camera: str) -> None: def __stop_audio_thread(self, camera: str) -> None:
thread = self.audio_threads.pop(camera, None) thread = self.audio_threads.pop(camera, None)
if thread is None: if thread is None:
@@ -150,29 +183,8 @@ class AudioProcessor(FrigateProcess):
], ],
) )
def spawn_if_needed(camera: CameraConfig) -> None:
name = camera.name
if name is None or name in self.audio_threads:
return
if not camera.enabled or not camera.audio.enabled:
return
# ffmpeg update may not have arrived yet; wait for next poll
if not any("audio" in i.roles for i in camera.ffmpeg.inputs):
return
thread = AudioEventMaintainer(
camera,
self.config,
self.camera_metrics,
self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type]
self.genai_manager,
)
self.audio_threads[name] = thread
thread.start()
self.logger.info(f"Audio maintainer started for {name}")
for camera in self.config.cameras.values(): for camera in self.config.cameras.values():
spawn_if_needed(camera) self.spawn_if_needed(camera)
self.logger.info(f"Audio processor started (pid: {self.pid})") self.logger.info(f"Audio processor started (pid: {self.pid})")
@@ -182,15 +194,14 @@ class AudioProcessor(FrigateProcess):
updated_topics = config_subscriber.check_for_updates() updated_topics = config_subscriber.check_for_updates()
# stop maintainers for removed cameras so their ffmpeg process is # stop maintainers for removed cameras so their ffmpeg process is
# torn down and they stop touching camera_metrics (which the camera # torn down
# maintainer has already popped for the removed camera)
for removed_camera in updated_topics.get( for removed_camera in updated_topics.get(
CameraConfigUpdateEnum.remove.name, [] CameraConfigUpdateEnum.remove.name, []
): ):
self.__stop_audio_thread(removed_camera) self.__stop_audio_thread(removed_camera)
for camera in self.config.cameras.values(): for camera in self.config.cameras.values():
spawn_if_needed(camera) self.spawn_if_needed(camera)
config_subscriber.stop() config_subscriber.stop()
@@ -212,7 +223,8 @@ class AudioEventMaintainer(threading.Thread):
self, self,
camera: CameraConfig, camera: CameraConfig,
config: FrigateConfig, config: FrigateConfig,
camera_metrics: DictProxy, metrics: CameraMetrics,
embeddings_metrics: DataProcessorMetrics,
audio_transcription_model_runner: AudioTranscriptionModelRunner | None, audio_transcription_model_runner: AudioTranscriptionModelRunner | None,
stop_event: threading.Event, stop_event: threading.Event,
genai_manager: Any = None, genai_manager: Any = None,
@@ -221,7 +233,11 @@ class AudioEventMaintainer(threading.Thread):
self.config = config self.config = config
self.camera_config = camera self.camera_config = camera
self.camera_metrics = camera_metrics # hold the metrics object rather than indexing the manager dict per
# chunk, which costs an IPC round trip and breaks once the camera
# maintainer pops the entry on removal
self.metrics = metrics
self.embeddings_metrics = embeddings_metrics
self.stop_event = stop_event self.stop_event = stop_event
# per-camera stop signal so a single maintainer can be torn down at # per-camera stop signal so a single maintainer can be torn down at
# runtime (e.g. on camera removal) without stopping the whole process # runtime (e.g. on camera removal) without stopping the whole process
@@ -265,7 +281,7 @@ class AudioEventMaintainer(threading.Thread):
camera_config=self.camera_config, camera_config=self.camera_config,
requestor=self.requestor, requestor=self.requestor,
model_runner=self.audio_transcription_model_runner, model_runner=self.audio_transcription_model_runner,
metrics=self.camera_metrics[self.camera_config.name], metrics=self.embeddings_metrics,
stop_event=self.stop_event, stop_event=self.stop_event,
genai_manager=self.genai_manager, genai_manager=self.genai_manager,
) )
@@ -291,8 +307,8 @@ class AudioEventMaintainer(threading.Thread):
audio_as_float: np.ndarray = audio.astype(np.float32) audio_as_float: np.ndarray = audio.astype(np.float32)
rms, dBFS = self.calculate_audio_levels(audio_as_float) rms, dBFS = self.calculate_audio_levels(audio_as_float)
self.camera_metrics[self.camera_config.name].audio_rms.value = rms self.metrics.audio_rms.value = rms
self.camera_metrics[self.camera_config.name].audio_dBFS.value = dBFS self.metrics.audio_dBFS.value = dBFS
audio_detections: list[tuple[str, float]] = [] audio_detections: list[tuple[str, float]] = []
@@ -377,7 +393,6 @@ class AudioEventMaintainer(threading.Thread):
return return
time.sleep(self.camera_config.ffmpeg.retry_interval) time.sleep(self.camera_config.ffmpeg.retry_interval)
self.logpipe.dump()
self.start_or_restart_ffmpeg() self.start_or_restart_ffmpeg()
if self.audio_listener is None or self.audio_listener.stdout is None: if self.audio_listener is None or self.audio_listener.stdout is None:
+9
View File
@@ -84,6 +84,8 @@ _user_agent_args = [
PRESETS_HW_ACCEL_DECODE = { PRESETS_HW_ACCEL_DECODE = {
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m", "preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
"preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m", "preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m",
"preset-apple-silicon-h264": "-c:v h264_v4l2m2m",
"preset-apple-silicon-h265": "-c:v hevc_v4l2m2m",
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi", FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
"preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17 "preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
"preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17 "preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
@@ -120,6 +122,9 @@ PRESETS_HW_ACCEL_DECODE["preset-rk-h265"] = PRESETS_HW_ACCEL_DECODE[
PRESETS_HW_ACCEL_SCALE = { PRESETS_HW_ACCEL_SCALE = {
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}", "preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}", "preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
# ffmpeg's v4l2m2m decoders cannot scale, so frames are scaled on the CPU
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12", FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", "preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p", "preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
@@ -150,6 +155,8 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL
PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = { PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}", "preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}", "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}",
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
# -vaapi_device is required in addition to -hwaccel_device: this is the only # -vaapi_device is required in addition to -hwaccel_device: this is the only
# birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before # birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before
# the decoder creates a device, so hwupload cannot see an -hwaccel_device one. # the decoder creates a device, so hwupload cannot see an -hwaccel_device one.
@@ -184,6 +191,8 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["preset-rk-h264"] = PRESETS_HW_ACCEL_ENCODE_BIR
PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = { PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}", "preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}", "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}", FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}",
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}", "preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}",
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}", "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}",
+36 -89
View File
@@ -103,11 +103,10 @@ class LlamaCppClient(GenAIClient):
_supports_reasoning: bool _supports_reasoning: bool
_image_token_cache: dict[tuple[int, int], int] _image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None _text_baseline_tokens: int | None
_media_marker: str
@property @property
def supports_embeddings(self) -> bool: def supports_embeddings(self) -> bool:
"""llama.cpp exposes an /embeddings endpoint for any loaded model.""" """llama.cpp exposes a /v1/embeddings endpoint for any loaded model."""
return True return True
def _auth_headers(self) -> dict | None: def _auth_headers(self) -> dict | None:
@@ -159,7 +158,6 @@ class LlamaCppClient(GenAIClient):
self._supports_reasoning = False self._supports_reasoning = False
self._image_token_cache = {} self._image_token_cache = {}
self._text_baseline_tokens = None self._text_baseline_tokens = None
self._media_marker = "<__media__>"
base_url = ( base_url = (
self.genai_config.base_url.rstrip("/") self.genai_config.base_url.rstrip("/")
@@ -187,7 +185,6 @@ class LlamaCppClient(GenAIClient):
self._supports_audio = info["supports_audio"] self._supports_audio = info["supports_audio"]
self._supports_tools = info["supports_tools"] self._supports_tools = info["supports_tools"]
self._supports_reasoning = info["supports_reasoning"] self._supports_reasoning = info["supports_reasoning"]
self._media_marker = info["media_marker"]
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",
@@ -215,9 +212,7 @@ class LlamaCppClient(GenAIClient):
`architecture.input_modalities` (text/image/audio) — the primary `architecture.input_modalities` (text/image/audio) — the primary
source. When proxied through llama-swap, the same entry carries source. When proxied through llama-swap, the same entry carries
`status.args` (server launch argv) and, for the loaded model, `status.args` (server launch argv) and, for the loaded model,
`meta.n_ctx`. /props remains the only source for `media_marker`, `meta.n_ctx`.
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
is set.
""" """
info: dict[str, Any] = { info: dict[str, Any] = {
"context_size": None, "context_size": None,
@@ -225,7 +220,6 @@ class LlamaCppClient(GenAIClient):
"supports_audio": False, "supports_audio": False,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
model_entry: dict[str, Any] | None = None model_entry: dict[str, Any] | None = None
@@ -314,16 +308,8 @@ class LlamaCppClient(GenAIClient):
# in the Jinja chat template itself. # in the Jinja chat template itself.
chat_template = props.get("chat_template") or "" chat_template = props.get("chat_template") or ""
info["supports_reasoning"] = "enable_thinking" in chat_template info["supports_reasoning"] = "enable_thinking" in chat_template
media_marker = props.get("media_marker")
if isinstance(media_marker, str) and media_marker:
info["media_marker"] = media_marker
except Exception as e: except Exception as e:
logger.warning( logger.warning("Failed to query llama.cpp /props endpoint: %s", e)
"Failed to query llama.cpp /props endpoint: %s. "
"Image embeddings may fail if the server randomized its media marker.",
e,
)
return info return info
@@ -474,9 +460,6 @@ class LlamaCppClient(GenAIClient):
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None: def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
"""Transcribe through /v1/chat/completions, for servers without the """Transcribe through /v1/chat/completions, for servers without the
transcriptions route. transcriptions route.
The _media_marker / multimodal_data convention is an /embeddings-only
protocol, so no marker-refresh retry is needed here.
""" """
prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech." prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech."
@@ -794,41 +777,16 @@ class LlamaCppClient(GenAIClient):
) )
return result if result else None return result if result else None
def _refresh_media_marker(self) -> bool:
"""Re-fetch /props and update the cached media marker if it changed.
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
is set), so a stale marker indicates a restart. Returns True iff the
marker was updated to a new value — used to gate a one-shot retry of
a failed embeddings request.
"""
if self.provider is None:
return False
try:
props = self._fetch_llama_props(self.provider, self.genai_config.model)
except Exception as e:
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
return False
marker = props.get("media_marker")
if not isinstance(marker, str) or not marker or marker == self._media_marker:
return False
logger.info("llama.cpp media marker changed (server restart); refreshed")
self._media_marker = marker
return True
def embed( def embed(
self, self,
texts: list[str] | None = None, texts: list[str] | None = None,
images: list[bytes] | None = None, images: list[bytes] | None = None,
) -> list[np.ndarray]: ) -> list[np.ndarray]:
"""Generate embeddings via llama.cpp /embeddings endpoint. """Generate embeddings via llama.cpp /v1/embeddings endpoint.
Supports batch requests. Uses content format with prompt_string and Each text or image is one entry in `input`, using the chat-style
multimodal_data for images (PR #15108). Server must be started with content array from ggml-org/llama.cpp#29556. Server must be started
--embeddings and --mmproj for multimodal support. with --embeddings, and --mmproj for image support.
""" """
if self.provider is None: if self.provider is None:
logger.warning( logger.warning(
@@ -843,49 +801,42 @@ class LlamaCppClient(GenAIClient):
EMBEDDING_DIM = 768 EMBEDDING_DIM = 768
encoded_images: list[str] = [] inputs: list[dict[str, Any]] = [
{"content": [{"type": "text", "text": text}]} for text in texts
]
for img in images: for img in images:
# llama.cpp uses STB which does not support WebP; convert to JPEG # llama.cpp uses STB which does not support WebP; convert to JPEG
jpeg_bytes = _to_jpeg(img) jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded_images.append(base64.b64encode(to_encode).decode("utf-8")) encoded = base64.b64encode(to_encode).decode("utf-8")
# The trailing newline keeps tokenization identical to the older
def build_content() -> list[dict[str, Any]]: # "<__media__>\n" prompt_string format, so indexed vectors stay valid
# prompt_string must contain the server's media marker placeholder inputs.append(
# for each image. The marker is randomized per server startup. {
content: list[dict[str, Any]] = [] "content": [
for text in texts: {
content.append({"prompt_string": text}) "type": "image_url",
for encoded in encoded_images: "image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
content.append( },
{ {"type": "text", "text": "\n"},
"prompt_string": f"{self._media_marker}\n", ]
"multimodal_data": [encoded], }
}
)
return content
def post_embeddings() -> requests.Response:
return self._post(
f"{self.provider}/embeddings",
json={"model": self.genai_config.model, "content": build_content()},
timeout=self.timeout,
) )
try: try:
try: response = self._post(
response = post_embeddings() f"{self.provider}/v1/embeddings",
response.raise_for_status() json={
except requests.exceptions.RequestException: "model": self.genai_config.model,
# The server may have restarted with a new media marker. "input": inputs,
# Refresh from /props; only retry if the marker actually changed. "encoding_format": "float",
if not encoded_images or not self._refresh_media_marker(): },
raise timeout=self.timeout,
response = post_embeddings() )
response.raise_for_status() response.raise_for_status()
result = response.json() items = response.json().get("data")
items = result.get("data", result) if isinstance(result, dict) else result
if not isinstance(items, list): if not isinstance(items, list):
logger.warning("llama.cpp embeddings returned unexpected format") logger.warning("llama.cpp embeddings returned unexpected format")
return [] return []
@@ -896,11 +847,7 @@ class LlamaCppClient(GenAIClient):
if emb is None: if emb is None:
logger.warning("llama.cpp embeddings item missing embedding field") logger.warning("llama.cpp embeddings item missing embedding field")
continue continue
arr = np.array(emb, dtype=np.float32) arr = np.array(emb, dtype=np.float32).flatten()
if arr.ndim > 1:
# llama.cpp can return token-level embeddings; pool per item
arr = arr.mean(axis=0)
arr = arr.flatten()
orig_dim = arr.size orig_dim = arr.size
if orig_dim != EMBEDDING_DIM: if orig_dim != EMBEDDING_DIM:
if orig_dim > EMBEDDING_DIM: if orig_dim > EMBEDDING_DIM:
+8
View File
@@ -144,6 +144,14 @@ class LogPipe(threading.Thread):
self.pipeReader.close() self.pipeReader.close()
def dump(self) -> None: def dump(self) -> None:
if not self.deque:
return
self.logger.log(
self.level,
"The following ffmpeg logs include the last 100 lines prior to exit.",
)
while len(self.deque) > 0: while len(self.deque) > 0:
self.logger.log(self.level, self.deque.popleft()) self.logger.log(self.level, self.deque.popleft())
+8 -3
View File
@@ -195,15 +195,20 @@ class Notice(Model):
first_seen = DateTimeField() first_seen = DateTimeField()
last_seen = DateTimeField() last_seen = DateTimeField()
count = IntegerField(default=1) count = IntegerField(default=1)
dismissed_at = DateTimeField(null=True) # hidden until the next occurrence
acknowledged_at = DateTimeField(null=True)
# hidden for good
muted_at = DateTimeField(null=True)
class NoticeStats(Model): class NoticeStats(Model):
kind = CharField(null=False, primary_key=True, max_length=50) kind = CharField(null=False, primary_key=True, max_length=50)
occurrences = IntegerField(default=0) occurrences = IntegerField(default=0)
dismissals = IntegerField(default=0) acknowledgements = IntegerField(default=0)
mutes = IntegerField(default=0)
first_seen = DateTimeField() first_seen = DateTimeField()
last_seen = DateTimeField() last_seen = DateTimeField()
# watermarks for a future analytics reporter; unused until then # watermarks for a future analytics reporter; unused until then
reported_occurrences = IntegerField(default=0) reported_occurrences = IntegerField(default=0)
reported_dismissals = IntegerField(default=0) reported_acknowledgements = IntegerField(default=0)
reported_mutes = IntegerField(default=0)
+5 -1
View File
@@ -188,8 +188,12 @@ class ImprovedMotionDetector(MotionDetector):
self.config.skip_motion_threshold is not None self.config.skip_motion_threshold is not None
and pct_motion > self.config.skip_motion_threshold and pct_motion > self.config.skip_motion_threshold
): ):
# force a recalibration so we transition to the new background # recalibrate so we transition to the new background. the frame
# still has to be blended in here, otherwise the background stays
# frozen and every subsequent frame skips as well
self.calibrating = True self.calibrating = True
cv2.accumulateWeighted(resized_frame, self.avg_frame, 0.2)
self.motion_frame_count = 0
return [] return []
# once the motion is less than 5% and the number of contours is < 4, assume its calibrated # once the motion is less than 5% and the number of contours is < 4, assume its calibrated
+110 -43
View File
@@ -5,7 +5,7 @@ import threading
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, cast
from frigate.const import REPLAY_CAMERA_PREFIX from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Notice, NoticeStats from frigate.models import Notice, NoticeStats
@@ -77,9 +77,8 @@ class NoticeRegistry:
) -> None: ) -> None:
"""Insert a notice or count another occurrence of it. """Insert a notice or count another occurrence of it.
A dismissed notice stays dismissed when it is raised again, unless its Another occurrence shows an acknowledged notice again. A muted notice
kind sets reopen_at_count. A kind that should come back after a stays hidden.
dismissal gives each episode its own scope.
""" """
definition = NOTICE_KINDS.get(kind) definition = NOTICE_KINDS.get(kind)
@@ -112,7 +111,6 @@ class NoticeRegistry:
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
count=1, count=1,
dismissed_at=None,
) )
self._bump_occurrences(kind, 1, now) self._bump_occurrences(kind, 1, now)
@@ -175,7 +173,7 @@ class NoticeRegistry:
self._notify() self._notify()
def resolve_camera(self, camera: str) -> None: def resolve_camera(self, camera: str) -> None:
"""Drop the notices and check dismissals of a camera being deleted.""" """Drop the notices and check mutes of a camera being deleted."""
camera_kinds = [ camera_kinds = [
key key
for key, definition in NOTICE_KINDS.items() for key, definition in NOTICE_KINDS.items()
@@ -193,7 +191,7 @@ class NoticeRegistry:
) )
# a stream id names its camera first; a config id ends with camera.<name> # a stream id names its camera first; a config id ends with camera.<name>
for check in self.dismissed_checks(): for check in self.muted_checks():
check_id = check["id"] check_id = check["id"]
if check_id.startswith(f"stream:{camera}:") or ( if check_id.startswith(f"stream:{camera}:") or (
@@ -205,26 +203,50 @@ class NoticeRegistry:
if deleted: if deleted:
self._notify() self._notify()
def purge_dismissed(self) -> int: def acknowledge(self, row_id: str) -> bool:
"""Delete every dismissed row so each can show again. Returns how many.""" """Hide a notice until it happens again.
with self._lock:
return int(
Notice.delete().where(Notice.dismissed_at.is_null(False)).execute()
)
def dismiss(self, row_id: str) -> bool: Returns False for an unknown id or a kind that never repeats, such as
"""Hide a notice or check row for good. Returns False for an unknown id.""" a check row or the update notice.
"""
with self._lock: with self._lock:
existing = Notice.get_or_none(Notice.id == row_id) existing = Notice.get_or_none(Notice.id == row_id)
if existing is None: if existing is None:
# a check row gets a notice row only once it is dismissed return False
definition = NOTICE_KINDS.get(existing.kind)
if definition is None or not definition.counts_repeats:
return False
if existing.acknowledged_at is not None or existing.muted_at is not None:
return True
Notice.update(acknowledged_at=datetime.now().timestamp()).where(
Notice.id == row_id
).execute()
NoticeStats.update(acknowledgements=NoticeStats.acknowledgements + 1).where(
NoticeStats.kind == existing.kind
).execute()
self._notify()
return True
def mute(self, row_id: str) -> bool:
"""Hide a notice or check row for good. Returns False for an unknown id."""
now = datetime.now().timestamp()
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
# a check row gets a notice row only once it is muted
kind, _, scope = row_id.partition(":") kind, _, scope = row_id.partition(":")
if kind not in CHECK_KINDS or not scope: if kind not in CHECK_KINDS or not scope:
return False return False
now = datetime.now().timestamp()
Notice.create( Notice.create(
id=row_id, id=row_id,
kind=kind, kind=kind,
@@ -233,40 +255,78 @@ class NoticeRegistry:
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
count=1, count=1,
dismissed_at=now, muted_at=now,
) )
return True return True
if existing.dismissed_at is not None: if existing.muted_at is not None:
return True return True
if existing.kind not in NOTICE_KINDS: if existing.kind not in NOTICE_KINDS:
return False return False
Notice.update(dismissed_at=datetime.now().timestamp()).where( Notice.update(acknowledged_at=None, muted_at=now).where(
Notice.id == row_id Notice.id == row_id
).execute() ).execute()
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where( NoticeStats.update(mutes=NoticeStats.mutes + 1).where(
NoticeStats.kind == existing.kind NoticeStats.kind == existing.kind
).execute() ).execute()
self._notify() self._notify()
return True return True
def dismissed_checks(self) -> list[dict[str, Any]]: def unhide(self, row_id: str) -> bool:
"""Dismissed config and stream check rows, newest first.""" """Show an acknowledged or muted row again. Returns False for an unknown id."""
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
return False
if existing.kind in CHECK_KINDS:
Notice.delete_by_id(row_id)
return True
Notice.update(acknowledged_at=None, muted_at=None).where(
Notice.id == row_id
).execute()
self._notify()
return True
def unhide_all(self) -> None:
"""Show every acknowledged and muted row again."""
with self._lock:
for check in self.muted_checks():
Notice.delete_by_id(check["id"])
shown = (
Notice.update(acknowledged_at=None, muted_at=None)
.where(
Notice.acknowledged_at.is_null(False)
| Notice.muted_at.is_null(False)
)
.execute()
)
if shown:
self._notify()
def muted_checks(self) -> list[dict[str, Any]]:
"""Muted config and stream check rows, newest first."""
rows = ( rows = (
Notice.select() Notice.select()
.where(Notice.kind.in_(list(CHECK_KINDS))) .where(Notice.kind.in_(list(CHECK_KINDS)))
.order_by(Notice.dismissed_at.desc()) .order_by(Notice.muted_at.desc())
) )
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows] return [{"id": row.id, "muted_at": row.muted_at} for row in rows]
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]: def active(self, include_hidden: bool = False) -> list[dict[str, Any]]:
"""Notices most severe first, then most recent first. """Notices most severe first, then most recent first.
Args: Args:
include_dismissed: Also return dismissed notices, for the history view include_hidden: Also return acknowledged and muted notices, for the
hidden list
""" """
rows = [] rows = []
@@ -276,7 +336,9 @@ class NoticeRegistry:
if definition is None: if definition is None:
continue continue
if row.dismissed_at is not None and not include_dismissed: hidden = row.acknowledged_at is not None or row.muted_at is not None
if hidden and not include_hidden:
continue continue
rows.append( rows.append(
@@ -291,7 +353,9 @@ class NoticeRegistry:
"first_seen": row.first_seen, "first_seen": row.first_seen,
"last_seen": row.last_seen, "last_seen": row.last_seen,
"count": row.count, "count": row.count,
"dismissed_at": row.dismissed_at, "acknowledgeable": definition.counts_repeats,
"acknowledged_at": row.acknowledged_at,
"muted_at": row.muted_at,
} }
) )
@@ -309,11 +373,13 @@ class NoticeRegistry:
{ {
"kind": row.kind, "kind": row.kind,
"occurrences": row.occurrences, "occurrences": row.occurrences,
"dismissals": row.dismissals, "acknowledgements": row.acknowledgements,
"mutes": row.mutes,
"first_seen": row.first_seen, "first_seen": row.first_seen,
"last_seen": row.last_seen, "last_seen": row.last_seen,
"reported_occurrences": row.reported_occurrences, "reported_occurrences": row.reported_occurrences,
"reported_dismissals": row.reported_dismissals, "reported_acknowledgements": row.reported_acknowledgements,
"reported_mutes": row.reported_mutes,
} }
for row in NoticeStats.select() for row in NoticeStats.select()
if row.kind in NOTICE_KINDS if row.kind in NOTICE_KINDS
@@ -327,18 +393,18 @@ class NoticeRegistry:
last_seen: float, last_seen: float,
params: dict[str, Any], params: dict[str, Any],
) -> None: ) -> None:
# called with the lock held # called with the lock held; held repeats from before an acknowledgement
fields: dict[str, Any] = { # still count but leave the notice hidden
"count": row.count + count, still_acknowledged = row.acknowledged_at is not None and (
"last_seen": last_seen, last_seen <= cast(float, row.acknowledged_at)
"params": params, )
}
reopen_at = NOTICE_KINDS[kind].reopen_at_count
if reopen_at is not None and row.count < reopen_at <= row.count + count: Notice.update(
fields["dismissed_at"] = None count=row.count + count,
last_seen=last_seen,
Notice.update(**fields).where(Notice.id == row.id).execute() params=params,
acknowledged_at=row.acknowledged_at if still_acknowledged else None,
).where(Notice.id == row.id).execute()
self._bump_occurrences(kind, count, last_seen) self._bump_occurrences(kind, count, last_seen)
def _prune(self, kind: str, keep: int) -> None: def _prune(self, kind: str, keep: int) -> None:
@@ -362,7 +428,8 @@ class NoticeRegistry:
NoticeStats.create( NoticeStats.create(
kind=kind, kind=kind,
occurrences=count, occurrences=count,
dismissals=0, acknowledgements=0,
mutes=0,
first_seen=now, first_seen=now,
last_seen=now, last_seen=now,
) )
+10 -6
View File
@@ -28,9 +28,9 @@ class NoticeKind:
category: camera, detector, model, or system; a camera scope is a category: camera, detector, model, or system; a camera scope is a
camera name, and the UI shows it camera name, and the UI shows it
link: app route or absolute URL for the row, filled in from params link: app route or absolute URL for the row, filled in from params
counts_repeats: whether raising an existing notice counts another occurrence counts_repeats: whether raising an existing notice counts another
occurrence, which also shows an acknowledged notice again
batch_repeats: whether repeats wait in memory for the next flush batch_repeats: whether repeats wait in memory for the next flush
reopen_at_count: count at which a dismissed notice shows again
keep_latest: rows of this kind to keep; a new row drops the oldest keep_latest: rows of this kind to keep; a new row drops the oldest
reportable: whether a future analytics reporter may send this kind's counts reportable: whether a future analytics reporter may send this kind's counts
""" """
@@ -41,7 +41,6 @@ class NoticeKind:
link: str | None = None link: str | None = None
counts_repeats: bool = True counts_repeats: bool = True
batch_repeats: bool = False batch_repeats: bool = False
reopen_at_count: int | None = None
keep_latest: int | None = None keep_latest: int | None = None
reportable: bool = True reportable: bool = True
@@ -67,6 +66,12 @@ _KINDS = (
"camera", "camera",
link="/system#cameras", link="/system#cameras",
), ),
NoticeKind(
"ffmpeg_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind(
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"), NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
# one row per user per burst; the login log lines carry the address # one row per user per burst; the login log lines carry the address
NoticeKind( NoticeKind(
@@ -75,10 +80,9 @@ _KINDS = (
"system", "system",
link="/logs", link="/logs",
batch_repeats=True, batch_repeats=True,
reopen_at_count=5,
keep_latest=100, keep_latest=100,
), ),
# one row per release, so a dismissal lasts until the next release # one row per release, so muting it lasts until the next release
NoticeKind( NoticeKind(
"update_available", "update_available",
NoticeSeverity.info, NoticeSeverity.info,
@@ -92,7 +96,7 @@ _KINDS = (
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS} NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
# the Health tab builds config and stream check rows in the browser, so a notice # the Health tab builds config and stream check rows in the browser, so a notice
# row of these kinds only records a dismissal; its other fields are placeholders # row of these kinds only records a mute; its other fields are placeholders
CHECK_KINDS = frozenset({"config", "stream"}) CHECK_KINDS = frozenset({"config", "stream"})
+90 -22
View File
@@ -42,6 +42,8 @@ from frigate.util.ownership import chown_to_runtime
from frigate.util.recording_coverage import ( from frigate.util.recording_coverage import (
build_spans, build_spans,
known_video_codecs, known_video_codecs,
null_audio_glitches,
realized_timeline,
resolve_coverage, resolve_coverage,
stream_media_summary, stream_media_summary,
) )
@@ -84,10 +86,20 @@ class StreamRun:
@dataclass @dataclass
class _ChapterWindow: class _ChapterWindow:
"""A merged-timeline slice, shaped like the recording rows chapters read.""" """A merged-timeline slice, shaped like the recording rows chapters read.
lead_in is the output time the slice's vod clip plays before start_time,
from snapping its first frame back to a keyframe.
"""
start_time: float start_time: float
end_time: float end_time: float
lead_in: float = 0.0
def _lead_in(recording: Any) -> float:
"""Output seconds a chapter source plays before its first wall second."""
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS). # Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
@@ -376,15 +388,18 @@ class RecordingExporter(threading.Thread):
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]: def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
"""Resolve the export range into the spans the VOD manifest will serve. """Resolve the export range into the spans the VOD manifest will serve.
Delegates to the same coverage resolution the manifest builder Delegates to the same coverage resolution and glitch nulling the
uses, so what we plan around and what nginx-vod emits agree by manifest builder uses, so what we plan around and what nginx-vod
construction. Returns the spans (each [row, start, end, is_main]), emits agree by construction. Returns the spans (each [row, start,
the known video codecs, and whether audio survives the range. end, is_main]), the known video codecs, and whether audio survives
the range.
Memoized: several stages of the export ask the same question, and Memoized: several stages of the export ask the same question, and
the recordings backing a finished range do not change under us. the recordings backing a finished range do not change under us.
""" """
if self._coverage is None: if self._coverage is None:
intervals = resolve_coverage(self.camera, self.start_time, self.end_time) intervals = null_audio_glitches(
resolve_coverage(self.camera, self.start_time, self.end_time)
)
self._coverage = ( self._coverage = (
build_spans(intervals, self.pinned_stream), build_spans(intervals, self.pinned_stream),
known_video_codecs(intervals), known_video_codecs(intervals),
@@ -433,17 +448,57 @@ class RecordingExporter(threading.Thread):
# hand-off to stage around # hand-off to stage around
return True return True
spans, codecs, keep_audio = self._resolve_coverage() _spans, codecs, keep_audio = self._resolve_coverage()
runs = self._stream_runs(spans) runs = self._planned_stream_runs()
# a range one stream covers end to end has nothing to hand off, # a range one stream covers end to end has nothing to hand off,
# so it stays on the existing path however long it is # so it stays on the existing path however long it is
if len(runs) < 2: if len(runs) < 2:
return True return True
runs = [piece for run in runs for piece in self._split_long_run(run)]
return self._stage_stream_runs(runs, codecs, keep_audio) return self._stage_stream_runs(runs, codecs, keep_audio)
def _planned_stream_runs(self) -> list[StreamRun]:
"""The runs a mixed range is staged as, one pinned vod playlist each."""
runs = self._stream_runs(self._merged_spans())
if len(runs) < 2:
return runs
return [piece for run in runs for piece in self._split_long_run(run)]
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
"""Chapter windows for the staged files as they were rendered.
Each staged run comes from its own pinned vod playlist, whose first
clip snaps back to the preceding keyframe, so a staged file runs up
to a GOP longer than its slice of the merged timeline. Planning each
run the way its playlist does carries that lead-in into the chapter
offsets instead of letting it accumulate at every hand-off.
"""
windows: list[_ChapterWindow] = []
for run in self._planned_stream_runs():
intervals = null_audio_glitches(
resolve_coverage(self.camera, run.start_time, run.end_time)
)
for clip in realized_timeline(intervals, run.stream_type):
# a skipped clip is absent from the playlist and the file
if clip["duration"] <= 0:
continue
span = clip["end_time"] - clip["start_time"]
windows.append(
_ChapterWindow(
clip["start_time"],
clip["end_time"],
max(0.0, clip["duration"] / 1000 - span),
)
)
return windows
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]: def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
"""Collapse the merged spans into contiguous runs of one stream type. """Collapse the merged spans into contiguous runs of one stream type.
@@ -840,6 +895,8 @@ class RecordingExporter(threading.Thread):
clipped_end = min(float(rec.end_time), float(self.end_time)) clipped_end = min(float(rec.end_time), float(self.end_time))
if clipped_end <= clipped_start: if clipped_end <= clipped_start:
continue continue
# a staged window's keyframe lead-in plays before it
output_offset += _lead_in(rec)
windows.append((clipped_start, clipped_end, output_offset)) windows.append((clipped_start, clipped_end, output_offset))
output_offset += clipped_end - clipped_start output_offset += clipped_end - clipped_start
@@ -987,9 +1044,13 @@ class RecordingExporter(threading.Thread):
if duration_ms <= 0: if duration_ms <= 0:
continue continue
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat( # a staged window's keyframe lead-in opens its chapter, with
timespec="seconds" # frames captured that long before the window
) lead_in = _lead_in(rec)
duration_ms += int(round(lead_in * 1000))
title = datetime.datetime.fromtimestamp(
clipped_start - lead_in, tz=tz
).isoformat(timespec="seconds")
chapter_blocks.append( chapter_blocks.append(
"[CHAPTER]\n" "[CHAPTER]\n"
"TIMEBASE=1/1000\n" "TIMEBASE=1/1000\n"
@@ -1128,12 +1189,12 @@ class RecordingExporter(threading.Thread):
if self.staged_runs: if self.staged_runs:
# each run was already rendered to a temp file with a common # each run was already rendered to a temp file with a common
# track timescale, so the concat demuxer has nothing left to # track timescale, so the concat demuxer has nothing left to
# reconcile and every chapter offset lines up with the merged # reconcile
# timeline the staged files reproduce recordings = (
recordings = [ self._staged_chapter_windows()
_ChapterWindow(span_start, span_end) if self.chapters not in (None, ChaptersEnum.none)
for _row, span_start, span_end, _is_main in self._merged_spans() else []
] )
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs] playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
ffmpeg_input = ( ffmpeg_input = (
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin" "-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
@@ -1149,11 +1210,18 @@ class RecordingExporter(threading.Thread):
# its own rows are the ones the chapters describe # its own rows are the ones the chapters describe
recordings = self._get_recordings_for_range(pin) recordings = self._get_recordings_for_range(pin)
else: else:
# never mix streams in one playlist; use main when available # an unstaged auto range resolves to at most one stream run, and
# and fall back to sub for expired-main history # its rows are the ones the chapters describe. Main rows the
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN) # manifest drops (glitches, slivers at the edges of a sub range)
# must not stand in for it.
runs = self._stream_runs(self._merged_spans())
recordings = self._get_recordings_for_range(
runs[0].stream_type if runs else STREAM_TYPE_MAIN
)
if not recordings: # never mix streams in one playlist; fall back to sub for
# expired-main history
if not recordings and not runs:
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB) recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
playlist_lines = [] playlist_lines = []
+41 -4
View File
@@ -490,9 +490,17 @@ class RecordingMaintainer(threading.Thread):
) )
reviews = reviews_by_camera[camera] reviews = reviews_by_camera[camera]
tasks.extend( # probes run concurrently, but each segment's start chains off the
[self.validate_and_move_segment(camera, reviews, r) for r in recordings] # previous segment's end, so starts resolve in segment order
) previous: asyncio.Event | None = None
for recording in recordings:
resolved = asyncio.Event()
tasks.append(
self._validate_in_order(
camera, reviews, recording, previous, resolved
)
)
previous = resolved
# publish most recently available recording time and None if disabled # publish most recently available recording time and None if disabled
if stream_type == STREAM_TYPE_MAIN: if stream_type == STREAM_TYPE_MAIN:
@@ -550,12 +558,33 @@ class RecordingMaintainer(threading.Thread):
while info and info[0][0] < expire_before: while info and info[0][0] < expire_before:
info.pop(0) info.pop(0)
async def _validate_in_order(
self,
camera: str,
reviews: Any,
recording: dict[str, Any],
previous_start: asyncio.Event | None,
start_resolved: asyncio.Event,
) -> dict[str, Any] | None:
"""Validate a segment, always releasing the next one in its stream."""
try:
return await self.validate_and_move_segment(
camera, reviews, recording, previous_start, start_resolved
)
finally:
start_resolved.set()
def drop_segment(self, cache_path: str) -> None: def drop_segment(self, cache_path: str) -> None:
Path(cache_path).unlink(missing_ok=True) Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None) self.end_time_cache.pop(cache_path, None)
async def validate_and_move_segment( async def validate_and_move_segment(
self, camera: str, reviews: Any, recording: dict[str, Any] self,
camera: str,
reviews: Any,
recording: dict[str, Any],
previous_start: asyncio.Event | None = None,
start_resolved: asyncio.Event | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
cache_path: str = recording["cache_path"] cache_path: str = recording["cache_path"]
start_time: datetime.datetime = recording["start_time"] start_time: datetime.datetime = recording["start_time"]
@@ -617,6 +646,9 @@ class RecordingMaintainer(threading.Thread):
async with self.probe_semaphore: async with self.probe_semaphore:
keyframes = await get_keyframe_offsets(cache_path) keyframes = await get_keyframe_offsets(cache_path)
if previous_start is not None:
await previous_start.wait()
start_time = self._resolve_segment_start( start_time = self._resolve_segment_start(
camera, stream_type, start_time, duration, cache_path camera, stream_type, start_time, duration, cache_path
) )
@@ -654,6 +686,11 @@ class RecordingMaintainer(threading.Thread):
RecordingsDataTypeEnum.valid.value, RecordingsDataTypeEnum.valid.value,
) )
# the start is settled, so the next segment of the stream can chain
# off it while this one waits on retention and the move
if start_resolved is not None:
start_resolved.set()
record_config = self.config.cameras[camera].record record_config = self.config.cameras[camera].record
# sub's alerts/detections carry the retain mode directly, unlike # sub's alerts/detections carry the retain mode directly, unlike
+61 -15
View File
@@ -29,38 +29,59 @@ VERSION_REFRESH_S = 24 * 60 * 60
# a camera skipping at least this percent of its frames is falling behind # a camera skipping at least this percent of its frames is falling behind
SKIPPED_DETECTIONS_PCT = 5 SKIPPED_DETECTIONS_PCT = 5
# for at least this long before it becomes a notice # lifetime CPU averages at or above these are high; they match
SKIPPED_DETECTIONS_HOLD_S = 60 # CameraFfmpegThreshold.error and CameraDetectThreshold.error in the UI
FFMPEG_HIGH_CPU_PCT = 20
DETECT_HIGH_CPU_PCT = 40
# a camera stays over a threshold this long before it becomes a notice
EPISODE_HOLD_S = 60
# detectors warm up after a start; the status bar waits this long too # detectors warm up after a start; the status bar waits this long too
STARTUP_GRACE_S = 120 STARTUP_GRACE_S = 120
class SkippedDetectionsTracker: def cpu_average(cpu_usages: dict[str, Any], pid: int | None) -> float | None:
"""Finds cameras whose skipped share stays high long enough for a notice.""" """A process's CPU use averaged over its lifetime, or None if unknown."""
usage = cpu_usages.get(str(pid)) if pid else None
def __init__(self) -> None: try:
return float(usage["cpu_average"]) if usage else None
except (KeyError, TypeError, ValueError):
return None
class EpisodeTracker:
"""Finds cameras whose value stays over a threshold long enough for a notice."""
def __init__(self, threshold: float) -> None:
self._threshold = threshold
self._since: dict[str, float] = {} self._since: dict[str, float] = {}
self._raised: set[str] = set() self._raised: set[str] = set()
def update(self, cameras: dict[str, dict[str, Any]], now: float) -> list[str]: def update(self, values: dict[str, float | None], now: float) -> list[str]:
"""Return the cameras whose episode qualified on this sample.""" """Return the cameras whose episode qualified on this sample.
Args:
values: Each camera's current value, or None when it has none
now: Sample time in seconds
"""
qualified: list[str] = [] qualified: list[str] = []
# a removed camera that comes back starts a new episode # a removed camera that comes back starts a new episode
for camera in self._since.keys() - cameras.keys(): for camera in self._since.keys() - values.keys():
self._since.pop(camera) self._since.pop(camera)
self._raised.discard(camera) self._raised.discard(camera)
for camera, camera_stats in cameras.items(): for camera, value in values.items():
if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT: if value is None or value < self._threshold:
self._since.pop(camera, None) self._since.pop(camera, None)
self._raised.discard(camera) self._raised.discard(camera)
continue continue
since = self._since.setdefault(camera, now) since = self._since.setdefault(camera, now)
if camera not in self._raised and now - since >= SKIPPED_DETECTIONS_HOLD_S: if camera not in self._raised and now - since >= EPISODE_HOLD_S:
self._raised.add(camera) self._raised.add(camera)
qualified.append(camera) qualified.append(camera)
@@ -80,7 +101,9 @@ class StatsEmitter(threading.Thread):
self.stop_event = stop_event self.stop_event = stop_event
self.hardware_stats = HardwareStats(config) self.hardware_stats = HardwareStats(config)
self.stats_history: list[dict[str, Any]] = [] self.stats_history: list[dict[str, Any]] = []
self.skipped_detections = SkippedDetectionsTracker() self.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
self.ffmpeg_cpu = EpisodeTracker(FFMPEG_HIGH_CPU_PCT)
self.detect_cpu = EpisodeTracker(DETECT_HIGH_CPU_PCT)
# the shm notice's params as last sent, so only a change is written # the shm notice's params as last sent, so only a change is written
self._shm_checked = False self._shm_checked = False
@@ -227,15 +250,38 @@ class StatsEmitter(threading.Thread):
def _update_notices(self, stats: dict[str, Any], now: float) -> None: def _update_notices(self, stats: dict[str, Any], now: float) -> None:
"""Update notices based on current stats or time.""" """Update notices based on current stats or time."""
# skipped detections cameras = stats["cameras"]
# absent when CPU collection timed out or failed on this tick
cpu_usages = stats.get("cpu_usages", {})
if stats["service"]["uptime"] >= STARTUP_GRACE_S: if stats["service"]["uptime"] >= STARTUP_GRACE_S:
for camera in self.skipped_detections.update(stats["cameras"], now): # skipped detections
skipped = {
camera: camera_stats["skipped_pct"]
for camera, camera_stats in cameras.items()
}
for camera in self.skipped_detections.update(skipped, now):
raise_notice( raise_notice(
"skipped_detections", "skipped_detections",
scope=camera, scope=camera,
params={"pct": stats["cameras"][camera]["skipped_pct"]}, params={"pct": skipped[camera]},
) )
# high ffmpeg and detect CPU
for tracker, kind, pid_key in (
(self.ffmpeg_cpu, "ffmpeg_high_cpu", "ffmpeg_pid"),
(self.detect_cpu, "detect_high_cpu", "pid"),
):
averages = {
camera: cpu_average(cpu_usages, camera_stats.get(pid_key))
for camera, camera_stats in cameras.items()
}
for camera in tracker.update(averages, now):
raise_notice(kind, scope=camera, params={"cpu": averages[camera]})
# shm too small for the cameras # shm too small for the cameras
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"]) self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
+11
View File
@@ -256,6 +256,17 @@ class HardwareStats:
) )
self.update_config() self.update_config()
def set_config(self, config: FrigateConfig) -> None:
"""Follow a runtime config swap and recalculate the monitored hardware.
The camera update subscriber has to follow too, or later camera updates
would land on the discarded config.
"""
self.config = config
self._config_subscriber.config = config
self._config_subscriber.camera_configs = config.cameras
self.update_config()
def update_config(self) -> None: def update_config(self) -> None:
"""Recalculate all hardware that needs to be monitored from the config.""" """Recalculate all hardware that needs to be monitored from the config."""
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments() names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
+7 -4
View File
@@ -111,15 +111,18 @@ def get_detector_stats(
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Get stats for all detectors, including temperatures based on detector type.""" """Get stats for all detectors, including temperatures based on detector type."""
detector_stats: dict[str, dict[str, Any]] = {} detector_stats: dict[str, dict[str, Any]] = {}
detector_type_indices: dict[str, int] = {} # detector type -> device -> index into that type's temperatures
device_indices: dict[str, dict[str, int]] = {}
for name, detector in stats_tracking["detectors"].items(): for name, detector in stats_tracking["detectors"].items():
pid = detector.detect_process.pid if detector.detect_process else None pid = detector.detect_process.pid if detector.detect_process else None
detector_type = detector.detector_config.type detector_type = detector.detector_config.type
# Keep track of the index for each detector type to match temperatures correctly # temperatures are per physical unit, so a repeated device
current_index = detector_type_indices.get(detector_type, 0) # ("hailo:PCIe#2", see runner_names) shares its unit's reading
detector_type_indices[detector_type] = current_index + 1 device = name.partition("#")[0]
type_devices = device_indices.setdefault(detector_type, {})
current_index = type_devices.setdefault(device, len(type_devices))
detector_stat = { detector_stat = {
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined] "inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
+41 -1
View File
@@ -1,8 +1,10 @@
import json
import os
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import frigate.genai import frigate.genai
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.const import REDACTED_CREDENTIAL_SENTINEL from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
@@ -90,6 +92,44 @@ class TestHttpApp(BaseTestHttp):
mqtt = response.json()["mqtt"] mqtt = response.json()["mqtt"]
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
def test_config_response_keeps_plus_model_reference(self):
model_id = "test_plus_reference"
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
with open(model_path, "w") as f:
f.write("model")
with open(f"{model_path}.json", "w") as f:
json.dump(
{
"id": model_id,
"type": "ssd",
"supportedDetectors": ["cpu"],
"width": 320,
"height": 320,
"inputShape": "nhwc",
"pixelFormat": "rgb",
"labelMap": {"0": "person"},
},
f,
)
self.addCleanup(os.remove, model_path)
self.addCleanup(os.remove, f"{model_path}.json")
self.minimal_config["models"] = [
{"path": f"plus://{model_id}", "devices": ["cpu"]}
]
app = super().create_app()
with AuthTestClient(app) as client:
response = client.get("/config")
assert response.status_code == 200
assert response.json()["models"][0]["path"] == f"plus://{model_id}"
# detection still loads the resolved cache file
assert app.frigate_config.models[0].path == model_path
#################################################################################################################### ####################################################################################################################
################################### POST /genai/probe Endpoint ################################################## ################################### POST /genai/probe Endpoint ##################################################
#################################################################################################################### ####################################################################################################################
+61 -45
View File
@@ -53,92 +53,108 @@ class TestHttpNotices(BaseTestHttp):
self.assertEqual(response.json()[0]["kind"], "detector_stuck") self.assertEqual(response.json()[0]["kind"], "detector_stuck")
self.assertEqual(response.json()[0]["occurrences"], 1) self.assertEqual(response.json()[0]["occurrences"], 1)
def test_dismiss_hides_and_history_keeps_it(self): def test_acknowledge_hides_and_the_hidden_list_keeps_it(self):
self.registry.raise_notice( self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"} "detector_stuck", scope="ov", params={"detector": "ov"}
) )
with self.client() as client: with self.client() as client:
response = client.post("/notices/detector_stuck:ov/dismiss") response = client.post("/notices/detector_stuck:ov/acknowledge")
listed = client.get("/notices").json() listed = client.get("/notices").json()
history = client.get("/notices", params={"include_dismissed": True}).json() hidden = client.get("/notices", params={"include_hidden": True}).json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(listed, []) self.assertEqual(listed, [])
self.assertEqual([n["id"] for n in history], ["detector_stuck:ov"]) self.assertEqual([n["id"] for n in hidden], ["detector_stuck:ov"])
self.assertIsNotNone(history[0]["dismissed_at"]) self.assertIsNotNone(hidden[0]["acknowledged_at"])
def test_dismiss_an_id_with_a_slash(self): def test_acknowledging_a_check_is_404(self):
with self.client() as client:
response = client.post(f"/notices/{CONFIG_CHECK}/acknowledge")
self.assertEqual(response.status_code, 404)
def test_mute_an_id_with_a_slash(self):
self.registry.raise_notice( self.registry.raise_notice(
"model_download_failed", scope="yolo/model.onnx", params={} "model_download_failed", scope="yolo/model.onnx", params={}
) )
with self.client() as client: with self.client() as client:
response = client.post( response = client.post(
"/notices/model_download_failed:yolo/model.onnx/dismiss" "/notices/model_download_failed:yolo/model.onnx/mute"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
def test_dismiss_unknown_is_404(self): def test_unknown_ids_are_404(self):
with self.client() as client: with self.client() as client:
response = client.post("/notices/nope/dismiss") responses = [
client.post("/notices/nope/acknowledge"),
client.post("/notices/nope/mute"),
client.delete("/notices/nope/hidden"),
]
self.assertEqual(response.status_code, 404) self.assertEqual([r.status_code for r in responses], [404, 404, 404])
def test_requires_admin(self): def test_requires_admin(self):
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
with TestClient(self.app) as client: with TestClient(self.app) as client:
response = client.get( responses = [
"/notices", headers={"remote-user": "viewer", "remote-role": "viewer"} client.get("/notices", headers=viewer),
) client.get("/notices/muted_checks", headers=viewer),
client.post("/notices/detector_stuck:ov/acknowledge", headers=viewer),
client.post("/notices/detector_stuck:ov/mute", headers=viewer),
client.delete("/notices/detector_stuck:ov/hidden", headers=viewer),
client.delete("/notices/hidden", headers=viewer),
]
self.assertEqual(response.status_code, 403) self.assertEqual([r.status_code for r in responses], [403] * 6)
def test_dismissed_checks_are_listed_once(self): def test_muted_checks_are_listed_once(self):
with self.client() as client: with self.client() as client:
first = client.post(f"/notices/{CONFIG_CHECK}/dismiss") first = client.post(f"/notices/{CONFIG_CHECK}/mute")
again = client.post(f"/notices/{CONFIG_CHECK}/dismiss") again = client.post(f"/notices/{CONFIG_CHECK}/mute")
listed = client.get("/notices/dismissed_checks").json() listed = client.get("/notices/muted_checks").json()
history = client.get("/notices", params={"include_dismissed": True}).json() hidden = client.get("/notices", params={"include_hidden": True}).json()
self.assertEqual(first.status_code, 200) self.assertEqual(first.status_code, 200)
self.assertEqual(again.status_code, 200) self.assertEqual(again.status_code, 200)
self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK]) self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK])
self.assertIsNotNone(listed[0]["dismissed_at"]) self.assertIsNotNone(listed[0]["muted_at"])
# the Health tab builds the row itself, so it never comes back as a notice # the Health tab builds the row itself, so it never comes back as a notice
self.assertEqual(history, []) self.assertEqual(hidden, [])
def test_dismissed_checks_require_admin(self): def test_unhide_one_row(self):
with TestClient(self.app) as client:
response = client.get(
"/notices/dismissed_checks",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(response.status_code, 403)
def test_purge_dismissed_clears_notices_and_checks(self):
self.registry.raise_notice( self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"} "detector_stuck", scope="ov", params={"detector": "ov"}
) )
self.registry.dismiss("detector_stuck:ov") self.registry.mute("detector_stuck:ov")
self.registry.dismiss(CONFIG_CHECK) self.registry.mute(CONFIG_CHECK)
with self.client() as client: with self.client() as client:
response = client.delete("/notices/dismissed") notice = client.delete("/notices/detector_stuck:ov/hidden")
history = client.get("/notices", params={"include_dismissed": True}).json() check = client.delete(f"/notices/{CONFIG_CHECK}/hidden")
checks = client.get("/notices/dismissed_checks").json() listed = client.get("/notices").json()
checks = client.get("/notices/muted_checks").json()
self.assertEqual(response.status_code, 200) self.assertEqual([notice.status_code, check.status_code], [200, 200])
self.assertEqual(history, []) self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
self.assertEqual(checks, []) self.assertEqual(checks, [])
def test_purge_dismissed_requires_admin(self): def test_unhide_all_shows_notices_and_drops_check_mutes(self):
with TestClient(self.app) as client: self.registry.raise_notice(
response = client.delete( "detector_stuck", scope="ov", params={"detector": "ov"}
"/notices/dismissed", )
headers={"remote-user": "viewer", "remote-role": "viewer"}, self.registry.acknowledge("detector_stuck:ov")
) self.registry.mute(CONFIG_CHECK)
self.assertEqual(response.status_code, 403) with self.client() as client:
response = client.delete("/notices/hidden")
listed = client.get("/notices").json()
checks = client.get("/notices/muted_checks").json()
self.assertEqual(response.status_code, 200)
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
self.assertEqual(checks, [])
+90
View File
@@ -0,0 +1,90 @@
"""Tests for audio maintainer spawning on runtime camera adds."""
import threading
import unittest
from unittest.mock import MagicMock, patch
from frigate.config import FrigateConfig
from frigate.events.audio import AudioProcessor
def _build_config() -> FrigateConfig:
return FrigateConfig(
**{
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "audio"],
}
]
},
"audio": {"enabled": True},
}
},
}
)
class TestAudioSpawnIfNeeded(unittest.TestCase):
def _processor(self, config: FrigateConfig, camera_metrics: dict) -> AudioProcessor:
processor = AudioProcessor.__new__(AudioProcessor)
processor.config = config
processor.camera_metrics = camera_metrics
processor.embeddings_metrics = MagicMock()
processor.audio_threads = {}
processor.transcription_model_runner = None
processor.genai_manager = None
processor.stop_event = threading.Event()
processor.logger = MagicMock()
return processor
def test_skips_camera_whose_metrics_have_not_been_created_yet(self):
"""The camera maintainer creates metrics on its own poll of the add
update, so the audio processor can see the camera first."""
config = _build_config()
processor = self._processor(config, {})
with patch("frigate.events.audio.AudioEventMaintainer") as maintainer:
processor.spawn_if_needed(config.cameras["front_door"])
maintainer.assert_not_called()
assert processor.audio_threads == {}
def test_spawns_once_metrics_exist_and_passes_the_metrics_object(self):
config = _build_config()
metrics = object()
processor = self._processor(config, {"front_door": metrics})
with patch("frigate.events.audio.AudioEventMaintainer") as maintainer:
processor.spawn_if_needed(config.cameras["front_door"])
assert maintainer.call_args.args[2] is metrics
assert "front_door" in processor.audio_threads
maintainer.return_value.start.assert_called_once()
def test_does_not_respawn_for_a_camera_already_running(self):
config = _build_config()
processor = self._processor(config, {"front_door": object()})
processor.audio_threads["front_door"] = MagicMock()
with patch("frigate.events.audio.AudioEventMaintainer") as maintainer:
processor.spawn_if_needed(config.cameras["front_door"])
maintainer.assert_not_called()
def test_skips_camera_whose_ffmpeg_update_has_not_arrived_yet(self):
"""The add update carries the camera before its ffmpeg inputs are
applied, so the audio role can be briefly missing."""
config = _build_config()
camera = config.cameras["front_door"]
camera.ffmpeg.inputs[0].roles = ["detect"]
processor = self._processor(config, {"front_door": object()})
with patch("frigate.events.audio.AudioEventMaintainer") as maintainer:
processor.spawn_if_needed(camera)
maintainer.assert_not_called()
+25
View File
@@ -36,6 +36,31 @@ class TestEventsPerSecond(unittest.TestCase):
clock[0] += 100.0 clock[0] += 100.0
self.assertEqual(eps.eps(), 0.0) self.assertEqual(eps.eps(), 0.0)
def test_burst_after_start_is_not_divided_by_a_tiny_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# eleven buffered frames arrive within 100 ms of starting
for _ in range(11):
clock[0] += 0.01
eps.update()
# 11 events over less than a second is at most 11 per second
self.assertLessEqual(eps.eps(), 11.0)
def test_subsecond_window_keeps_its_rate(self) -> None:
eps = EventsPerSecond(last_n_seconds=0.5)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# twenty events per second for two seconds
for _ in range(40):
clock[0] += 0.05
eps.update()
# read between events, so none sits exactly on the window edge
clock[0] += 0.01
self.assertAlmostEqual(eps.eps(), 20.0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1
View File
@@ -26,6 +26,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
app.genai_manager.update_config.assert_called_once_with(config) app.genai_manager.update_config.assert_called_once_with(config)
app.profile_manager.update_config.assert_called_once_with(config) app.profile_manager.update_config.assert_called_once_with(config)
self.assertIs(app.stats_emitter.config, config) self.assertIs(app.stats_emitter.config, config)
app.stats_emitter.hardware_stats.set_config.assert_called_once_with(config)
self.assertIs(app.dispatcher.config, config) self.assertIs(app.dispatcher.config, config)
for comm in app.dispatcher.comms: for comm in app.dispatcher.comms:
self.assertIs(comm.config, config) self.assertIs(comm.config, config)
+896
View File
@@ -0,0 +1,896 @@
"""Tests for the DEEPX detector."""
import json
import os
import struct
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import numpy as np
from pydantic import ValidationError
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
from frigate.detectors.device import (
DeviceParseError,
build_detector_config,
parse_device,
)
from frigate.detectors.plugins.deepx import (
DEEPX_MANIFEST,
DXRT_VERSION,
PPU_RECORD_SIZE,
DeepxDetector,
DeepxDetectorConfig,
PpuLayout,
YoloLayout,
class_count,
decode_raw_anchor,
decode_raw_nms_in_head,
infer_yolo_layout,
read_ppu_layout,
resolve_device,
validate_yolox_outputs,
)
def model_with_type(model_type) -> ModelConfig:
return ModelConfig(
model_type=model_type,
labelmap_path=None,
labelmap={79: "toothbrush"},
width=640,
height=640,
)
def build_ppu_record(box, score=0.9, label=0, grid=(7, 9, 2, 2)) -> np.ndarray:
record = np.zeros(PPU_RECORD_SIZE, dtype=np.uint8)
record[0:16] = np.array(box, dtype=np.float32).view(np.uint8)
record[16:20] = grid
record[20:24] = np.array([score], dtype=np.float32).view(np.uint8)
record[24:28] = np.array([label], dtype=np.uint32).view(np.uint8)
return record.reshape(1, 1, PPU_RECORD_SIZE)
# compile_config.ppu as DX-COM writes it for the two head kinds
ANCHOR_BASED_PPU = {"type": 0, "num_classes": 80, "activation": "Sigmoid"}
ANCHOR_FREE_PPU = {"type": 1, "num_classes": 80}
PPU_BBOX_NODE = "/head/Mul_2"
# (grid_w, grid_h, entries) per scale, finest first; the grids give the
# strides at a 640 input
THREE_SCALE_ANCHORS = [(80, 80, 3), (40, 40, 3), (20, 20, 3)]
TWO_SCALE_ANCHORS = [(40, 40, 3), (20, 20, 3)]
FOUR_SCALE_ANCHORS = [(160, 160, 3), (80, 80, 3), (40, 40, 3), (20, 20, 3)]
THREE_SCALE_FREE = [(80, 80, 1), (40, 40, 1), (20, 20, 1)]
ONE_SCALE_FREE = [(100, 84, 1)]
def proto_varint(value: int) -> bytes:
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
out.append(byte | (0x80 if value else 0))
if not value:
return bytes(out)
def proto_bytes(field: int, payload: bytes) -> bytes:
return proto_varint(field << 3 | 2) + proto_varint(len(payload)) + payload
def proto_number(field: int, value: int) -> bytes:
return proto_varint(field << 3) + proto_varint(value)
def onnx_node(op_type: str, name: str, inputs: list, outputs: list) -> bytes:
body = b"".join(proto_bytes(1, tensor.encode()) for tensor in inputs)
body += b"".join(proto_bytes(2, tensor.encode()) for tensor in outputs)
return body + proto_bytes(3, name.encode()) + proto_bytes(4, op_type.encode())
def onnx_box_graph(box_format: str) -> bytes:
if box_format == "broken":
return proto_varint(1 << 3 | 3)
unnamed = box_format == "unnamed"
def graph_node(op_type: str, name: str, inputs: list, outputs: list) -> bytes:
return onnx_node(op_type, "" if unnamed else name, inputs, outputs)
nodes = [
graph_node("Split", "dfl_split", ["dfl", "sizes"], ["lt", "rb"]),
graph_node("Sub", "corner_min", ["anchors", "lt"], ["x1y1"]),
graph_node("Add", "corner_max", ["rb", "anchors"], ["x2y2"]),
]
if box_format in ("centre", "unnamed"):
nodes += [
graph_node("Add", "corner_sum", ["x1y1", "x2y2"], ["sum"]),
graph_node("Mul", "corner_mean", ["sum", "half"], ["cxy"]),
graph_node("Sub", "corner_span", ["x2y2", "x1y1"], ["wh"]),
graph_node("Concat", "box_concat", ["cxy", "wh"], ["box"]),
]
elif box_format == "corner":
nodes.append(graph_node("Concat", "box_concat", ["x1y1", "x2y2"], ["box"]))
else:
nodes.append(graph_node("Concat", "box_concat", ["x1y1", "x1y1"], ["box"]))
box = "box"
if unnamed:
box = "box_flat"
nodes.append(graph_node("Reshape", "box_reshape", ["shape", "box"], [box]))
nodes.append(onnx_node("Mul", PPU_BBOX_NODE, [box, "strides"], ["bbox_out"]))
graph = b"".join(proto_bytes(1, node) for node in nodes)
graph += proto_bytes(5, b"weights")
return (
proto_number(1, 10) # ir_version
+ proto_bytes(2, b"onnx_frontend_compiler") # producer_name
+ proto_bytes(7, graph)
)
def write_dxnn(
directory, ppu, layers, name="model.dxnn", table=True, box_format=None
) -> str:
"""A minimal .dxnn as DX-RT's parsers read it: the container header, a
compile_config carrying `ppu`, and either the PPU tensor table with one
entry per (layer, anchor) as a v8 file has, or with `table` False only
the rmap_info listing of the PPU output tensors, as a v7 file has.
`layers` is (grid_w, grid_h, entries) per scale, finest first; an
anchor-free scale has one entry, and grid_h 1 means a flattened
(1, cells, channels) tensor. `box_format`, "centre" or "corner", adds
the compiled graph that says how the head writes its boxes, along with
the compile_config layer naming the node the PPU reads them from."""
if box_format is not None and "layer" not in ppu:
ppu = dict(ppu, layer=[{"bbox": PPU_BBOX_NODE, "cls_conf": "/head/Sigmoid"}])
compile_config = json.dumps({"compile_version": "2.4.0", "ppu": ppu}).encode()
graph = onnx_box_graph(box_format) if box_format is not None else b""
if table:
part = bytearray(struct.pack("<BBBB", 1, sum(n for _, _, n in layers), 0, 0))
for conv, (grid_w, grid_h, entries) in enumerate(layers):
for anchor in range(entries):
part += struct.pack(
"<HHfBBBBBBBB",
128,
80,
0.001,
conv,
anchor,
0,
1,
0,
grid_w,
grid_h,
0,
)
else:
outputs = []
for conv, (grid_w, grid_h, entries) in enumerate(layers):
for anchor in range(entries):
name_ = f"PPU_Transpose_Output_{conv}"
if entries > 1:
name_ += f"_anchor_{anchor}"
shape = [1, grid_w, 127] if grid_h == 1 else [1, grid_h, grid_w, 128]
outputs.append({"name": name_, "shape": shape, "layout": "PPU_YOLO"})
part = json.dumps({"inputs": [], "outputs": outputs}).encode()
data = {
"compile_config": {
"type": "str",
"offset": 0,
"size": len(compile_config),
},
"compiled_data": {
"M1A_4K": {
"npu_0": {
"rmap": {"type": "bytes", "offset": 0, "size": 0},
"ppu" if table else "rmap_info": {
"type": "bytes" if table else "str",
"offset": len(compile_config),
"size": len(part),
},
}
}
},
}
if graph:
data["vis_npu_models"] = {
"npu_0": {
"type": "bytes",
"offset": len(compile_config) + len(part),
"size": len(graph),
}
}
index = json.dumps(
{
"version": 8 if table else 7,
"signature": "DXNN",
"size": 8192,
"data": data,
}
).encode()
path = os.path.join(directory, name)
with open(path, "wb") as model:
model.write(b"DXNN" + struct.pack("<I", 8))
model.write(index.ljust(8192 - 8, b"\0"))
model.write(compile_config + part + graph)
return path
def layout_of(shapes, num_classes, ppu=False, dynamic_output=False) -> YoloLayout:
return infer_yolo_layout(shapes, num_classes, ppu, dynamic_output).layout
class TestDeepxModelFile(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def layout(self, ppu, layers, **kwargs) -> PpuLayout | None:
return read_ppu_layout(write_dxnn(self.tmp.name, ppu, layers, **kwargs))
def test_the_head_kind_and_one_grid_per_scale_are_read(self):
cases = {
"anchor-based: three anchors per scale collapse to one grid each": (
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, {}),
PpuLayout(anchor_based=True, grids=((80, 80), (40, 40), (20, 20))),
),
"anchor-free, one scale flattened into a single tensor": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, {"box_format": "centre"}),
PpuLayout(anchor_based=False, grids=((100, 84),), centre_boxes=True),
),
"a head kind the compiler does not name still yields the scales": (
({"type": 7}, [(80, 80, 3), (40, 40, 3)], {}),
PpuLayout(anchor_based=None, grids=((80, 80), (40, 40))),
),
"no table: the per-anchor split says anchor-based": (
({"num_classes": 80}, THREE_SCALE_ANCHORS, {"table": False}),
PpuLayout(anchor_based=True, grids=((80, 80), (40, 40), (20, 20))),
),
"no table: compile_config places the scales ahead of the names": (
(
{
"type": 1,
"outputs": {
f"PPU_Transpose_Output_{i}": {"conv_idx": 2 - i}
for i in range(3)
},
},
[(20, 20, 1), (40, 40, 1), (80, 80, 1)],
{"table": False},
),
PpuLayout(anchor_based=False, grids=((80, 80), (40, 40), (20, 20))),
),
"no table: every scale in one (1, cells, channels) tensor": (
(ANCHOR_FREE_PPU, [(8400, 1, 1)], {"table": False}),
PpuLayout(anchor_based=False, grids=((8400, 1),)),
),
}
for head, ((ppu, layers, kwargs), expected) in cases.items():
with self.subTest(head=head):
self.assertEqual(self.layout(ppu, layers, **kwargs), expected)
def test_the_box_format_comes_from_the_compiled_graph(self):
for box_format, centre in (
("centre", True),
("corner", False),
("unnamed", True),
):
with self.subTest(box_format=box_format):
self.assertEqual(
self.layout(ANCHOR_FREE_PPU, ONE_SCALE_FREE, box_format=box_format),
PpuLayout(
anchor_based=False, grids=((100, 84),), centre_boxes=centre
),
)
def test_a_box_format_the_graph_does_not_answer_is_left_open(self):
cases = {
"no compiled graph at all": (ANCHOR_FREE_PPU, ONE_SCALE_FREE, {}),
"a graph without the node compile_config names": (
dict(ANCHOR_FREE_PPU, layer=[{"bbox": "/head/Missing"}]),
ONE_SCALE_FREE,
{"box_format": "centre"},
),
"a graph that builds its boxes from neither shape": (
ANCHOR_FREE_PPU,
ONE_SCALE_FREE,
{"box_format": "neither"},
),
"a graph section the reader cannot make sense of": (
ANCHOR_FREE_PPU,
ONE_SCALE_FREE,
{"box_format": "broken"},
),
"a grid-decoded head, where the question does not arise": (
ANCHOR_FREE_PPU,
THREE_SCALE_FREE,
{"box_format": "centre"},
),
}
for graph, (ppu, layers, kwargs) in cases.items():
with self.subTest(graph=graph):
self.assertIsNone(self.layout(ppu, layers, **kwargs).centre_boxes)
def test_nothing_is_read_from_a_model_without_ppu_metadata(self):
self.assertIsNone(read_ppu_layout(os.path.join(self.tmp.name, "missing")))
path = write_dxnn(self.tmp.name, None, [(80, 80, 3)])
self.assertIsNone(read_ppu_layout(path))
with open(path, "wb") as model:
model.write(b"ONNX" + b"\0" * 100)
self.assertIsNone(read_ppu_layout(path))
path = write_dxnn(self.tmp.name, ANCHOR_BASED_PPU, [(80, 80, 3), (40, 40, 3)])
os.truncate(path, os.path.getsize(path) - 40)
self.assertIsNone(read_ppu_layout(path))
class TestDeepxLayoutInference(unittest.TestCase):
def test_a_shape_and_class_count_pick_one_layout(self):
cases = {
"anchor-free, four columns ahead of the classes": (
[(1, 84, 8400)],
80,
YoloLayout.anchor_free,
84,
),
"anchor-free, row-major": ([(1, 8400, 84)], 80, YoloLayout.anchor_free, 84),
"anchor-based, an objectness column as well": (
[(1, 25200, 85)],
80,
YoloLayout.anchor,
85,
),
"anchor-based, channel-major": (
[(1, 85, 25200)],
80,
YoloLayout.anchor,
85,
),
"NMS in the head, a fixed run of corner records": (
[(1, 300, 6)],
80,
YoloLayout.nms_in_head,
None,
),
# only the label map can tell these two apart
"85 columns with 81 classes is anchor-free": (
[(1, 8400, 85)],
81,
YoloLayout.anchor_free,
85,
),
"85 columns with 80 classes is anchor-based": (
[(1, 8400, 85)],
80,
YoloLayout.anchor,
85,
),
# 6 columns is all three layouts, told apart by the row count
"6 columns and thousands of rows, one class": (
[(1, 25200, 6)],
1,
YoloLayout.anchor,
6,
),
"6 columns and thousands of rows, two classes": (
[(1, 8400, 6)],
2,
YoloLayout.anchor_free,
6,
),
"6 columns and a few hundred rows, one class": (
[(1, 300, 6)],
1,
YoloLayout.nms_in_head,
None,
),
"7 columns with two classes is only anchor-based": (
[(1, 8400, 7)],
2,
YoloLayout.anchor,
7,
),
# a square output matches the same width on both axes, which must
# not read as two candidate layouts
"a square output is not ambiguous with itself": (
[(1, 85, 85)],
80,
YoloLayout.anchor,
85,
),
"three NCHW maps with 255 channels are feature maps": (
[(1, 255, 80, 80), (1, 255, 40, 40), (1, 255, 20, 20)],
80,
YoloLayout.multipart,
None,
),
}
for head, (shapes, num_classes, layout, columns) in cases.items():
with self.subTest(head=head):
output = infer_yolo_layout(shapes, num_classes, False, False)
self.assertIs(output.layout, layout)
if columns is not None:
self.assertEqual(output.columns, columns)
def test_the_runtime_flags_outrank_the_shapes(self):
self.assertIs(layout_of([(8400,)], 80, ppu=True), YoloLayout.ppu)
self.assertIs(
layout_of([(1, -1, 6)], 80, dynamic_output=True), YoloLayout.nms_in_head
)
def test_a_shape_no_layout_fits_is_refused_with_the_reason(self):
cases = {
"fits two layouts": ([(1, 84, 6)], 80),
"labelmap_path": ([(1, 8400, 84)], 91),
"no output tensor": ([], 80),
"255 channels": (
[(1, 80, 80, 255), (1, 40, 40, 255), (1, 20, 20, 255)],
80,
),
"feature maps": ([(1, 8400, 80), (1, 8400, 4)], 80),
"80-class": ([(1, 24, 80, 80), (1, 24, 40, 40), (1, 24, 20, 20)], 3),
}
for reason, (shapes, num_classes) in cases.items():
with (
self.subTest(reason=reason),
self.assertRaisesRegex(ValueError, reason),
):
layout_of(shapes, num_classes)
class TestDeepxOutputValidation(unittest.TestCase):
def test_the_raw_yolox_head_is_read_for_the_configured_input(self):
for shapes, size in (
([(1, 8400, 85)], 640),
([(1, 85, 8400)], 640),
# 52*52 + 26*26 + 13*13 cells at 416
([(1, 3549, 85)], 416),
):
with self.subTest(shapes=shapes, size=size):
self.assertEqual(validate_yolox_outputs(shapes, 80, size, size), 85)
def test_another_head_under_yolox_is_refused_with_the_reason(self):
cases = {
"width and height": ([(1, 8400, 85)], 80, 416),
"labelmap_path": ([(1, 8400, 85)], 91, 640),
"yolo-generic": ([(1, 8400, 80), (1, 8400, 4)], 80, 640),
}
for reason, (shapes, num_classes, size) in cases.items():
with (
self.subTest(reason=reason),
self.assertRaisesRegex(ValueError, reason),
):
validate_yolox_outputs(shapes, num_classes, size, size)
def test_the_label_map_bounds_the_class_count(self):
self.assertEqual(class_count({0: "person", 79: "toothbrush"}), 80)
with self.assertRaisesRegex(ValueError, "labelmap_path"):
class_count({})
class TestDeepxRawDecode(unittest.TestCase):
def anchor_rows(self, rows) -> list:
out = np.zeros((1, len(rows), 85), dtype=np.float32)
for i, (cx, cy, w, h, obj, label, score) in enumerate(rows):
out[0, i, 0:4] = [cx, cy, w, h]
out[0, i, 4] = obj
out[0, i, 5 + label] = score
return [out]
def test_an_anchor_based_head_becomes_normalized_corners(self):
outputs = self.anchor_rows([(320.0, 160.0, 64.0, 32.0, 0.8, 3, 0.5)])
detections = decode_raw_anchor(outputs, 640, 640, 0.25, 0.45)
self.assertEqual(detections[0][0], 3)
self.assertAlmostEqual(detections[0][1], 0.4, places=5)
self.assertAlmostEqual(detections[0][2], 144 / 640, places=5)
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
self.assertAlmostEqual(detections[0][4], 176 / 640, places=5)
self.assertAlmostEqual(detections[0][5], 352 / 640, places=5)
def test_a_channel_major_export_is_read_by_column_count(self):
outputs = self.anchor_rows([(320.0, 160.0, 64.0, 32.0, 1.0, 3, 0.9)])
detections = decode_raw_anchor(
[np.swapaxes(outputs[0], 1, 2)], 640, 640, 0.25, 0.45, columns=85
)
self.assertEqual(detections[0][0], 3)
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
def test_rows_below_the_combined_threshold_are_dropped(self):
# 0.4 * 0.5 = 0.2, under the threshold both parts clear on their own
outputs = self.anchor_rows([(320.0, 320.0, 40.0, 80.0, 0.4, 3, 0.5)])
self.assertTrue(np.all(decode_raw_anchor(outputs, 640, 640, 0.25, 0.45) == 0))
def test_at_most_twenty_detections_are_returned(self):
rows = [(20.0 + 24 * i, 320.0, 16.0, 16.0, 1.0, i % 80, 0.9) for i in range(25)]
detections = decode_raw_anchor(self.anchor_rows(rows), 640, 640, 0.25, 0.45)
self.assertEqual(detections.shape, (20, 6))
self.assertEqual(int((detections[:, 1] > 0).sum()), 20)
def test_an_nms_in_head_output_is_read_without_running_nms(self):
out = np.array(
[
[
[100.0, 100.0, 200.0, 200.0, 0.9, 2.0],
[102.0, 102.0, 202.0, 202.0, 0.8, 2.0],
[300.0, 300.0, 400.0, 400.0, 0.1, 5.0],
]
],
dtype=np.float32,
)
detections = decode_raw_nms_in_head([out], 640, 640, 0.25)
self.assertEqual(detections[0][0], 2)
self.assertAlmostEqual(detections[0][1], 0.9, places=5)
self.assertAlmostEqual(detections[0][3], 100 / 640, places=5)
self.assertEqual(detections[1][0], 2)
self.assertAlmostEqual(detections[1][1], 0.8, places=5)
self.assertTrue(np.all(detections[2] == 0))
empty = np.zeros((1, 0, 6), dtype=np.float32)
self.assertTrue(np.all(decode_raw_nms_in_head([empty], 640, 640, 0.25) == 0))
class TestDeepxConfig(unittest.TestCase):
def test_a_device_string_resolves_to_an_npu_index(self):
for configured, index in (("PCIe:1", 1), ("2", 2), ("", 0)):
with self.subTest(device=configured):
self.assertEqual(resolve_device(configured), index)
def test_a_device_that_is_not_an_index_is_rejected(self):
"""The whole string has to be an index. Reading only the tail would
take the 1 out of "PCIe:0,PCIe:1" and bind to an NPU the config never
named, and several NPUs are configured as separate devices entries."""
for configured in (
"PCIe:the-fast-one",
"PCIe:0,PCIe:1",
"0,1",
"PCIe:0 PCIe:1",
"PCIe:-1",
"PCIe:",
):
with self.subTest(device=configured):
with self.assertRaises(ValueError):
resolve_device(configured)
with self.assertRaises(ValidationError):
DeepxDetectorConfig(type="deepx", device=configured)
def test_a_bad_device_is_refused_where_the_config_is_parsed(self):
"""parse_device builds the detector config to surface a bad device at
startup, so the comma-separated form fails there rather than binding a
detector process to the wrong NPU."""
self.assertEqual(parse_device("deepx:PCIe:1").device, "PCIe:1")
for raw in ("deepx:PCIe:0,PCIe:1", "deepx:the-fast-one"):
with self.subTest(raw=raw), self.assertRaises(DeviceParseError):
parse_device(raw)
def test_a_device_string_builds_this_detector_config(self):
"""Frigate turns a `deepx:PCIe:0` entry into the detector config with
the model already attached, which is the path app.py takes; a bare
constructor call does not exercise it."""
config = build_detector_config(
parse_device("deepx:PCIe:0"), model_with_type(ModelTypeEnum.yologeneric)
)
self.assertIsInstance(config, DeepxDetectorConfig)
self.assertEqual(config.device, "PCIe:0")
self.assertEqual(config.model.model_type, ModelTypeEnum.yologeneric)
def test_the_runtime_manifest_pins_its_wheels_to_the_version(self):
"""A PyPI path carries a per-file digest, so bumping DXRT_VERSION has
to rewrite the whole URL; a stale one installs the old wheel and fails
the sha256 on every user's first start."""
self.assertEqual(DEEPX_MANIFEST.version, DXRT_VERSION)
for artifact in DEEPX_MANIFEST.artifacts:
with self.subTest(url=artifact.url):
self.assertIn(f"dx_engine-{DXRT_VERSION}-", artifact.url)
def test_a_model_less_config_still_validates(self):
config = DeepxDetectorConfig(type="deepx")
self.assertIsNone(config.model)
class DeepxDetectorTestCase(unittest.TestCase):
def detector(
self,
model_type=ModelTypeEnum.yologeneric,
outputs_info=None,
ppu=False,
dynamic=False,
model_path="/nonexistent/model.dxnn",
) -> DeepxDetector:
dx_engine = MagicMock()
dx_engine.Configuration.ITEM.SERVICE = object()
session = dx_engine.InferenceEngine.return_value
session.get_output_tensors_info.return_value = (
[{"shape": [8400]}] if ppu else outputs_info or []
)
session.is_ppu.return_value = ppu
session.has_dynamic_output.return_value = dynamic
config = DeepxDetectorConfig(type="deepx")
config.model = model_with_type(model_type)
config.model.path = model_path
with (
# the detector writes the endpoint into the environment, which the
# rest of the suite shares when it runs in one process
patch.dict(os.environ),
patch.dict(sys.modules, {"dx_engine": dx_engine}),
patch.object(DeepxDetector, "activate_dependencies"),
patch("os.path.isfile", return_value=True),
):
return DeepxDetector(config)
def ppu_detector(
self, ppu, layers, model_type=ModelTypeEnum.yologeneric, box_format=None
) -> DeepxDetector:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
return self.detector(
model_type,
ppu=True,
model_path=write_dxnn(tmp.name, ppu, layers, box_format=box_format),
)
def detect(self, detector, outputs) -> np.ndarray:
detector.session.run.return_value = outputs
return detector.detect_raw(np.zeros((1, 640, 640, 3), np.uint8))
class TestDeepxModelType(DeepxDetectorTestCase):
def test_a_model_type_with_no_decoder_is_rejected(self):
for model_type in (ModelTypeEnum.ssd, ModelTypeEnum.dfine):
with (
self.subTest(model_type=model_type),
self.assertRaisesRegex(ValueError, model_type.value),
):
self.detector(model_type)
def test_supported_model_types_are_accepted(self):
for model_type, outputs_info in (
(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}]),
(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}]),
):
with self.subTest(model_type=model_type):
detector = self.detector(model_type, outputs_info)
self.assertEqual(detector.model_type, model_type)
class TestDeepxDetectorLoad(DeepxDetectorTestCase):
def test_the_layout_is_settled_at_load(self):
detector = self.detector(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}])
self.assertIs(detector.output.layout, YoloLayout.anchor_free)
self.assertEqual(detector.output.columns, 84)
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}])
self.assertIs(detector.output.layout, YoloLayout.yolox)
for model_type in (ModelTypeEnum.yologeneric, ModelTypeEnum.yolox):
with self.subTest(model_type=model_type):
detector = self.ppu_detector(
ANCHOR_FREE_PPU, THREE_SCALE_FREE, model_type=model_type
)
self.assertIs(detector.output.layout, YoloLayout.ppu)
self.assertEqual(detector.ppu_layout.scale_count, 3)
def test_a_model_the_detector_cannot_decode_is_refused_at_load(self):
cases = {
"Cannot decode DEEPX model": lambda: self.detector(
ModelTypeEnum.yologeneric, [{"shape": [1, 8400, 7]}]
),
"DX-COM 2.4.0": lambda: self.detector(ModelTypeEnum.yologeneric, ppu=True),
"face and pose": lambda: self.ppu_detector({"type": 2}, [(80, 80, 1)]),
"centre and size or as two corners": lambda: self.ppu_detector(
ANCHOR_FREE_PPU, ONE_SCALE_FREE
),
}
for reason, load in cases.items():
with (
self.subTest(reason=reason),
self.assertRaisesRegex(ValueError, reason),
):
load()
class TestDeepxDetectRaw(DeepxDetectorTestCase):
def test_a_ppu_record_decodes_by_the_head_in_the_model(self):
cases = {
"anchor-based, layer 2 of 3 at stride 32, the 373x326 anchor": (
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, None),
build_ppu_record((0.6, 0.4, 0.3, 0.7), label=5),
(5, (0.0, 0.380094, 0.864187, 0.589906)),
),
"anchor-based, layer 0 of 3 at stride 8, the 16x30 anchor": (
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, None),
build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0)),
(0, (None, 0.116750, None, None)),
),
"anchor-based, two scales: layer 0 is stride 16, not stride 8": (
(ANCHOR_BASED_PPU, TWO_SCALE_ANCHORS, None),
build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0)),
(0, (0.141156, 0.236031, 0.223844, 0.248969)),
),
"anchor-free, three scales: cell (10, 9) of stride 32": (
(ANCHOR_FREE_PPU, THREE_SCALE_FREE, None),
build_ppu_record((1.2, 0.5, 1.0, 0.5), grid=(9, 10, 0, 2), label=7),
(7, (0.433782, 0.492043, 0.516218, 0.627957)),
),
"anchor-free, one scale: the box fields are already pixels": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
build_ppu_record((320.0, 160.0, 64.0, 32.0), label=3),
(3, (144 / 640, 288 / 640, 176 / 640, 352 / 640)),
),
"anchor-free, one scale: a sub-pixel box stays sub-pixel": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
build_ppu_record((0.6, 0.4, 0.3, 0.7)),
(0, (None, 0.45 / 640, None, None)),
),
"a corner-format head reads the record as two corners": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "corner"),
build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2),
(2, (50 / 640, 100 / 640, 250 / 640, 300 / 640)),
),
"a centre-format head reads it as a centre and size": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2),
(2, (0.0, 0.0, 175 / 640, 250 / 640)),
),
}
for head, ((ppu, layers, box_format), record, (label, box)) in cases.items():
with self.subTest(head=head):
detector = self.ppu_detector(ppu, layers, box_format=box_format)
detections = self.detect(detector, [record])
self.assertEqual(detections[0][0], label)
for i, expected in enumerate(box, start=2):
if expected is not None:
self.assertAlmostEqual(detections[0][i], expected, places=5)
def test_the_strides_come_from_the_grids_in_the_model(self):
detector = self.ppu_detector(
ANCHOR_FREE_PPU, [(40, 40, 1), (20, 20, 1), (10, 10, 1)]
)
detections = self.detect(
detector,
[build_ppu_record((1.2, 0.5, 1.0, 0.5), grid=(9, 10, 0, 0), label=7)],
)
# layer 0 is stride 16: centre (11.2, 9.5) * 16, size e * 16 x sqrt(e) * 16
self.assertEqual(detections[0][0], 7)
self.assertAlmostEqual(detections[0][2], (152 - np.exp(0.5) * 8) / 640, 5)
self.assertAlmostEqual(detections[0][3], (179.2 - np.exp(1.0) * 8) / 640, 5)
def test_the_head_stays_what_the_model_said_across_frames(self):
detector = self.ppu_detector(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS)
first = self.detect(
detector, [build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0))]
)
# layer 0 of 3: stride 8, anchor 16x30
self.assertAlmostEqual(first[0][3], 0.116750, places=5)
second = self.detect(
detector, [build_ppu_record((0.5, 0.5, 0.4, 0.4), grid=(3, 4, 0, 1))]
)
# layer 1 of 3: stride 16, anchor 30x61, not layer 1 of 2
self.assertAlmostEqual(second[0][3], 0.0975, places=5)
detector = self.ppu_detector(
ANCHOR_FREE_PPU, ONE_SCALE_FREE, box_format="centre"
)
record = [build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2)]
# centre (100, 50), size 300 x 250: the right edge lands at 250
for frame in range(2):
with self.subTest(frame=frame):
self.assertAlmostEqual(
self.detect(detector, record)[0][5], 250 / 640, places=5
)
def test_records_the_head_cannot_place_come_back_empty(self):
cases = {
"a level or box the anchor table does not carry": (
THREE_SCALE_ANCHORS,
[build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 2, 5))],
),
"a scale count Frigate has no anchor table for": (
FOUR_SCALE_ANCHORS,
[build_ppu_record((0.6, 0.4, 0.3, 0.7))],
),
"a record below the score threshold": (
THREE_SCALE_ANCHORS,
[build_ppu_record((0.6, 0.4, 0.3, 0.7), score=0.1)],
),
"an unexpected record width": (
THREE_SCALE_ANCHORS,
[np.zeros((1, 3, 16), dtype=np.uint8)],
),
**{
f"no records at all, shaped {shape}": (
THREE_SCALE_ANCHORS,
[np.zeros(shape, dtype=np.uint8)],
)
for shape in ((1, 0, PPU_RECORD_SIZE), (0, PPU_RECORD_SIZE), (0,))
},
}
for output, (layers, outputs) in cases.items():
with self.subTest(output=output):
detector = self.ppu_detector(ANCHOR_BASED_PPU, layers)
self.assertTrue(np.all(self.detect(detector, outputs) == 0))
def test_a_yolox_raw_head_is_decoded_through_the_grid(self):
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}])
# cell (x 10, y 5) of the stride-8 grid is row 5 * 80 + 10
tensor = np.zeros((1, 8400, 85), np.float32)
tensor[0, 410, :5] = [0.5, 0.5, np.log(4.0), np.log(2.0), 0.9]
tensor[0, 410, 5 + 7] = 0.8
detections = self.detect(detector, [tensor])
# centre (84, 44), size 32 x 16
self.assertEqual(detections[0][0], 7)
self.assertAlmostEqual(detections[0][1], 0.72, places=5)
self.assertAlmostEqual(detections[0][2], 36 / 640, places=5)
self.assertAlmostEqual(detections[0][3], 68 / 640, places=5)
self.assertAlmostEqual(detections[0][4], 52 / 640, places=5)
self.assertAlmostEqual(detections[0][5], 100 / 640, places=5)
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 85, 8400]}])
detections = self.detect(detector, [np.swapaxes(tensor, 1, 2)])
self.assertAlmostEqual(detections[0][5], 100 / 640, places=5)
def test_a_raw_head_comes_back_as_frigates_detection_rows(self):
detector = self.detector(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}])
output = np.zeros((1, 84, 8400), dtype=np.float32)
output[0, 0:4, 0] = [320.0, 160.0, 64.0, 32.0]
output[0, 4 + 2, 0] = 0.9
detections = self.detect(detector, [output])
self.assertEqual(detections.shape, (20, 6))
self.assertEqual(detections[0][0], 2)
self.assertAlmostEqual(detections[0][1], 0.9, places=5)
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
+5
View File
@@ -59,6 +59,10 @@ class TestBuildDetectorConfig(unittest.TestCase):
def test_detectors_that_name_the_field_something_else(self): def test_detectors_that_name_the_field_something_else(self):
self.assertEqual(self._build("cpu:4").num_threads, 4) self.assertEqual(self._build("cpu:4").num_threads, 4)
self.assertEqual(self._build("rknn:2").num_cores, 2) self.assertEqual(self._build("rknn:2").num_cores, 2)
self.assertEqual(
self._build("zmq:tcp://host.docker.internal:5555").endpoint,
"tcp://host.docker.internal:5555",
)
def test_device_is_coerced_to_the_detector_field_type(self): def test_device_is_coerced_to_the_detector_field_type(self):
self.assertEqual(self._build("tensorrt:1").device, 1) self.assertEqual(self._build("tensorrt:1").device, 1)
@@ -66,6 +70,7 @@ class TestBuildDetectorConfig(unittest.TestCase):
def test_omitted_device_falls_back_to_the_detector_default(self): def test_omitted_device_falls_back_to_the_detector_default(self):
self.assertEqual(self._build("cpu").num_threads, 3) self.assertEqual(self._build("cpu").num_threads, 3)
self.assertEqual(self._build("rknn").num_cores, 0) self.assertEqual(self._build("rknn").num_cores, 0)
self.assertEqual(self._build("zmq").endpoint, "ipc:///tmp/cache/zmq_detector")
self.assertEqual(self._build("openvino").device, "AUTO") self.assertEqual(self._build("openvino").device, "AUTO")
self.assertIsNone(self._build("edgetpu").device) self.assertIsNone(self._build("edgetpu").device)
+44 -1
View File
@@ -25,7 +25,7 @@ class HardwareProbeTestCase(unittest.TestCase):
self.root = tempfile.TemporaryDirectory() self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup) self.addCleanup(self.root.cleanup)
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT"): for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT", "LIB_ROOT"):
sub = os.path.join(self.root.name, name.split("_")[0].lower()) sub = os.path.join(self.root.name, name.split("_")[0].lower())
os.makedirs(sub, exist_ok=True) os.makedirs(sub, exist_ok=True)
patcher = patch.object(hardware, name, sub) patcher = patch.object(hardware, name, sub)
@@ -167,6 +167,29 @@ class TestNvidia(HardwareProbeTestCase):
class TestAccelerators(HardwareProbeTestCase): class TestAccelerators(HardwareProbeTestCase):
def test_the_neural_engine_is_found_by_lighters_provider_library(self):
write(os.path.join(self.lib_root, "lighter", "liblighter_ane_ep.so"))
with patch.dict(os.environ, clear=False) as env:
env.pop("LIGHTER_ANE_EP", None)
ane = self.probe()["onnx:lighter"]
self.assertEqual(ane.detector, "onnx")
self.assertEqual(ane.units[0].device, "onnx")
self.assertEqual(ane.units[0].label, "Neural Engine")
def test_the_neural_engine_is_found_where_lighter_ane_ep_points(self):
library = os.path.join(self.root.name, "elsewhere", "liblighter_ane_ep.so")
write(library)
with patch.dict(os.environ, {"LIGHTER_ANE_EP": library}):
self.assertIn("onnx:lighter", self.probe())
def test_no_neural_engine_is_reported_without_the_library(self):
with patch.dict(os.environ, clear=False) as env:
env.pop("LIGHTER_ANE_EP", None)
self.assertNotIn("onnx:lighter", self.probe())
def test_hailo_is_found_by_its_device_node(self): def test_hailo_is_found_by_its_device_node(self):
write(os.path.join(self.dev_root, "hailo0")) write(os.path.join(self.dev_root, "hailo0"))
@@ -184,6 +207,26 @@ class TestAccelerators(HardwareProbeTestCase):
) )
self.assertFalse(memryx.unlimited) self.assertFalse(memryx.unlimited)
def test_each_deepx_node_is_a_unit(self):
write(os.path.join(self.dev_root, "dxrt0"))
write(os.path.join(self.dev_root, "dxrt1"))
deepx = self.probe()["deepx"]
self.assertEqual(
[unit.device for unit in deepx.units],
["deepx:PCIe:0", "deepx:PCIe:1"],
)
def test_a_deepx_npu_is_unlimited(self):
# the host daemon multiplexes, so one module takes several processes
write(os.path.join(self.dev_root, "dxrt0"))
self.assertTrue(self.probe()["deepx"].unlimited)
def test_no_deepx_is_reported_without_a_node(self):
self.assertNotIn("deepx", self.probe())
def test_a_supported_rockchip_soc_is_reported(self): def test_a_supported_rockchip_soc_is_reported(self):
write( write(
os.path.join(self.proc_root, "device-tree", "compatible"), os.path.join(self.proc_root, "device-tree", "compatible"),
+39
View File
@@ -0,0 +1,39 @@
"""Tests for per-detector stats."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.stats.util import get_detector_stats
def _detector(detector_type: str) -> MagicMock:
detector = MagicMock()
detector.detector_config.type = detector_type
detector.avg_inference_speed.value = 0.01
detector.detection_start.value = 0.0
detector.detect_process.pid = 1
return detector
class TestDetectorTemperatures(unittest.TestCase):
def test_repeated_device_shares_its_unit_temperature(self):
stats_tracking = {
"detectors": {
"hailo:PCIe": _detector("hailo8l"),
"hailo:PCIe#2": _detector("hailo8l"),
"hailo:PCIe:1": _detector("hailo8l"),
}
}
with patch(
"frigate.stats.util.get_hardware_temperatures", return_value=[50.0, 60.0]
):
stats = get_detector_stats(stats_tracking)
self.assertEqual(stats["hailo:PCIe"]["temperature"], 50.0)
self.assertEqual(stats["hailo:PCIe#2"]["temperature"], 50.0)
self.assertEqual(stats["hailo:PCIe:1"]["temperature"], 60.0)
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -543,6 +543,56 @@ class TestPinnedStream(unittest.TestCase):
self.assertFalse(any("/vod/front/main/" in token for token in cmd)) self.assertFalse(any("/vod/front/main/" in token for token in cmd))
class TestExportTimelineAlignment(unittest.TestCase):
def test_unstaged_auto_reads_the_stream_it_serves(self) -> None:
# a sub-only range whose main rows are glitches the manifest drops
exporter = _make_exporter([_span("/s1.mp4", 1_000, 1_040, False)], {"h264"})
streams: list[str] = []
def rows(stream: str) -> list:
streams.append(stream)
return [_FakeRow(f"/{stream}.mp4")]
exporter._get_recordings_for_range = rows # type: ignore[method-assign]
exporter.get_record_export_command("/exports/out.mp4")
self.assertEqual(streams, ["sub"])
def test_staged_chapters_carry_keyframe_lead_in(self) -> None:
exporter = _make_exporter(
[
_span("/m1.mp4", 1_000, 1_020, True),
_span("/s1.mp4", 1_020, 1_040, False),
],
{"h264"},
)
exporter.config.ui.timezone = None
# each run's vod clip snaps 1.5s back to a keyframe
def timeline(_intervals: list, stream: str) -> list[dict]:
start, end = (1_000, 1_020) if stream == "main" else (1_020, 1_040)
return [
{
"start_time": start,
"end_time": end,
"duration": (end - start + 1.5) * 1000,
}
]
with (
patch("frigate.record.export.resolve_coverage", return_value=[]),
patch("frigate.record.export.realized_timeline", side_effect=timeline),
):
windows = exporter._staged_chapter_windows()
path = exporter._build_recording_segment_chapter_metadata_file(windows)
self.addCleanup(os.remove, path)
content = Path(path).read_text()
self.assertIn("START=0\nEND=21500", content)
self.assertIn("START=21500\nEND=43000", content)
class TestStagedFileCleanup(unittest.TestCase): class TestStagedFileCleanup(unittest.TestCase):
"""A staged path must be tracked before ffmpeg can write to it.""" """A staged path must be tracked before ffmpeg can write to it."""
+10
View File
@@ -51,6 +51,16 @@ class TestFfmpegPresets(unittest.TestCase):
" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]) " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
) )
def test_ffmpeg_hwaccel_apple_preset_decodes_every_stream(self):
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
"preset-apple-silicon-h265"
)
frigate_config = FrigateConfig(**self.default_ffmpeg)
cmd = " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
assert "-c:v hevc_v4l2m2m" in cmd
assert "-c:v:1" not in cmd
assert "scale=1920:1080" in cmd
def test_ffmpeg_hwaccel_not_preset(self): def test_ffmpeg_hwaccel_not_preset(self):
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = ( self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
"-other-hwaccel args" "-other-hwaccel args"
+58 -2
View File
@@ -19,6 +19,8 @@ import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
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, load_providers
@@ -566,7 +568,6 @@ class TestLlamaCppProvider(unittest.TestCase):
"supports_audio": False, "supports_audio": False,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
cls = PROVIDERS[GenAIProviderEnum.llamacpp] cls = PROVIDERS[GenAIProviderEnum.llamacpp]
with patch.object(cls, "_get_model_info", return_value=info): with patch.object(cls, "_get_model_info", return_value=info):
@@ -589,6 +590,62 @@ class TestLlamaCppProvider(unittest.TestCase):
with patch.object(client, "_fetch_models_data", return_value=models_data): with patch.object(client, "_fetch_models_data", return_value=models_data):
self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"]) self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"])
@staticmethod
def _embeddings_response(vectors):
response = MagicMock()
response.status_code = 200
response.json.return_value = {
"object": "list",
"data": [
{"object": "embedding", "index": i, "embedding": v}
for i, v in enumerate(vectors)
],
}
return response
def test_embed_posts_content_arrays_to_v1_embeddings(self):
client = self._client()
response = self._embeddings_response([[0.1] * 768, [0.2] * 768])
with patch.object(client, "_post", return_value=response) as post:
result = client.embed(texts=["a person"], images=[b"not an image"])
url = post.call_args.args[0]
payload = post.call_args.kwargs["json"]
self.assertEqual(url, "http://localhost:9999/v1/embeddings")
self.assertEqual(payload["model"], "m")
self.assertEqual(payload["encoding_format"], "float")
self.assertEqual(
payload["input"][0], {"content": [{"type": "text", "text": "a person"}]}
)
image_parts = payload["input"][1]["content"]
self.assertEqual(image_parts[0]["type"], "image_url")
self.assertEqual(
image_parts[0]["image_url"]["url"],
"data:image/jpeg;base64," + base64.b64encode(b"not an image").decode(),
)
self.assertEqual(image_parts[1], {"type": "text", "text": "\n"})
self.assertEqual(len(result), 2)
self.assertAlmostEqual(float(result[1][0]), 0.2, places=5)
def test_embed_normalizes_dimension(self):
client = self._client()
response = self._embeddings_response([[1.0] * 1024, [1.0] * 512])
with patch.object(client, "_post", return_value=response):
result = client.embed(texts=["long", "short"])
self.assertEqual([r.shape for r in result], [(768,), (768,)])
self.assertEqual(float(result[1][-1]), 0.0)
def test_embed_request_error_returns_empty(self):
client = self._client()
response = MagicMock()
response.raise_for_status.side_effect = requests.exceptions.HTTPError("400")
with patch.object(client, "_post", return_value=response):
self.assertEqual(client.embed(texts=["a"]), [])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# transcribe role # transcribe role
@@ -762,7 +819,6 @@ class TestLlamaCppTranscribe(unittest.TestCase):
"supports_audio": supports_audio, "supports_audio": supports_audio,
"supports_tools": False, "supports_tools": False,
"supports_reasoning": False, "supports_reasoning": False,
"media_marker": "<__media__>",
} }
cls = PROVIDERS[GenAIProviderEnum.llamacpp] cls = PROVIDERS[GenAIProviderEnum.llamacpp]
with patch.object(cls, "_get_model_info", return_value=info): with patch.object(cls, "_get_model_info", return_value=info):
+11
View File
@@ -288,6 +288,17 @@ class TestUpdateConfig(HardwareStatsTestCase):
self.assertEqual(set(stats._monitored), {"rockchip"}) self.assertEqual(set(stats._monitored), {"rockchip"})
def test_follows_a_runtime_config_swap(self):
stats = self.make_stats(self.make_config())
self.assertEqual(set(stats._monitored), set())
swapped = self.make_config("preset-rk-h264")
stats.set_config(swapped)
self.assertEqual(set(stats._monitored), {"rockchip"})
self.assertIs(self.subscriber.return_value.config, swapped)
self.assertIs(self.subscriber.return_value.camera_configs, swapped.cameras)
class TestUpdateStats(HardwareStatsTestCase): class TestUpdateStats(HardwareStatsTestCase):
def run_stats(self, stats: HardwareStats) -> dict: def run_stats(self, stats: HardwareStats) -> dict:
@@ -34,6 +34,12 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
patcher.start() patcher.start()
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
self.sys_root = os.path.join(self.root.name, "sys")
os.makedirs(self.sys_root)
patcher = patch.object(hwaccel, "SYS_ROOT", self.sys_root)
patcher.start()
self.addCleanup(patcher.stop)
drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={}) drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={})
self.drm = drm.start() self.drm = drm.start()
self.addCleanup(drm.stop) self.addCleanup(drm.stop)
@@ -66,6 +72,12 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f: with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f:
f.write(f"processor\t: 0\nmodel name\t: {model_name}\n") f.write(f"processor\t: 0\nmodel name\t: {model_name}\n")
def write_video_device(self, vendor: str) -> None:
device = os.path.join(self.sys_root, "class", "video4linux", "video0", "device")
os.makedirs(device)
with open(os.path.join(device, "vendor"), "w") as f:
f.write(f"{vendor}\n")
def write_device_tree(self) -> None: def write_device_tree(self) -> None:
os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True) os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True)
with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f: with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f:
@@ -191,6 +203,22 @@ class TestAvailableFamilies(HwaccelRecommendationTestCase):
self.assertIn(recommended, [family.key for family in families]) self.assertIn(recommended, [family.key for family in families])
class TestLighter(HwaccelRecommendationTestCase):
def test_lighters_media_engine_is_recommended(self):
self.write_video_device(hwaccel.LIGHTER_VIRTIO_VENDOR)
self.assertEqual(self.recommend(codecs={"h264"}), "apple-silicon")
self.assertEqual(
self.presets()["apple-silicon"],
{"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
)
def test_another_virtio_media_device_is_not_lighters(self):
self.write_video_device("0x554d4551")
self.assertEqual(self.available(), [])
class TestCodecCoverage(HwaccelRecommendationTestCase): class TestCodecCoverage(HwaccelRecommendationTestCase):
def test_a_family_carries_a_preset_per_codec(self): def test_a_family_carries_a_preset_per_codec(self):
self.assertEqual( self.assertEqual(
+6 -3
View File
@@ -1,7 +1,7 @@
import datetime import datetime
import sys import sys
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
# Mock complex imports before importing maintainer, saving originals so we can # Mock complex imports before importing maintainer, saving originals so we can
# restore them after import and avoid polluting sys.modules for other tests. # restore them after import and avoid polluting sys.modules for other tests.
@@ -48,8 +48,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
"frigate.record.maintainer.psutil.process_iter", return_value=[] "frigate.record.maintainer.psutil.process_iter", return_value=[]
): ):
with patch("frigate.record.maintainer.logger.warning") as warn: with patch("frigate.record.maintainer.logger.warning") as warn:
# Mock validate_and_move_segment to avoid further logic # Mock validate_and_move_segment to avoid further logic.
maintainer.validate_and_move_segment = MagicMock() # The requestor is real when another test imported the
# maintainer first, and it would block on a reply.
maintainer.validate_and_move_segment = AsyncMock()
maintainer.requestor = MagicMock()
try: try:
await maintainer.move_files() await maintainer.move_files()
+29
View File
@@ -73,6 +73,35 @@ class TestImprovedMotionDetector(unittest.TestCase):
"Motion boxes should be empty when scene change exceeds skip threshold", "Motion boxes should be empty when scene change exceeds skip threshold",
) )
def _bright_frame(self, offset: int) -> np.ndarray:
"""Produce a bright frame with a small dark object that moves."""
frame = np.full((self.frame_shape[0], self.frame_shape[1]), 200, dtype=np.uint8)
x = 10 + (offset * 5) % 60
frame[40:60, x : x + 15] = 20
return frame
def test_skip_motion_threshold_recovers_after_skip(self):
"""Skipped frames must still be blended into the background.
A bright scene differs from the zeroed background across the whole
frame, so the first frames are skipped. The background has to catch up
anyway, otherwise motion detection never returns.
"""
self.config.skip_motion_threshold = 0.5
self.config.improve_contrast = False
self.detector.config = self.config
self.detector.update_mask()
boxes = [len(self.detector.detect(self._bright_frame(i))) for i in range(40)]
self.assertEqual(boxes[0], 0, "First frame should exceed the skip threshold")
self.assertGreater(
self.detector.avg_frame.max(),
0,
"Background was never updated while frames were skipped",
)
self.assertTrue(any(boxes), "Motion detection never recovered after a skip")
def test_skip_motion_threshold_does_not_affect_calibration(self): def test_skip_motion_threshold_does_not_affect_calibration(self):
"""Even when skipping, the detector should go into calibrating state.""" """Even when skipping, the detector should go into calibrating state."""
self.config.skip_motion_threshold = 0.4 self.config.skip_motion_threshold = 0.4
+120 -58
View File
@@ -16,9 +16,7 @@ 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
BATCHED = NoticeKind( BATCHED = NoticeKind("batched", NoticeSeverity.warning, "system", batch_repeats=True)
"batched", NoticeSeverity.warning, "system", batch_repeats=True, reopen_at_count=3
)
PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2) PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2)
TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)} TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)}
@@ -77,6 +75,11 @@ class RegistryTestCase(unittest.TestCase):
mock_datetime.now.return_value.timestamp.return_value = timestamp mock_datetime.now.return_value.timestamp.return_value = timestamp
self.registry.raise_notice(kind, **kwargs) self.registry.raise_notice(kind, **kwargs)
def _acknowledge_at(self, timestamp: float, row_id: str) -> None:
with patch("frigate.notices.registry.datetime") as mock_datetime:
mock_datetime.now.return_value.timestamp.return_value = timestamp
self.registry.acknowledge(row_id)
class TestNoticeRegistry(RegistryTestCase): class TestNoticeRegistry(RegistryTestCase):
def test_raise_inserts_and_counts_one_occurrence(self): def test_raise_inserts_and_counts_one_occurrence(self):
@@ -122,34 +125,98 @@ class TestNoticeRegistry(RegistryTestCase):
self.assertEqual(self.registry.stats()[0]["occurrences"], 1) self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_not_called() self.listener.assert_not_called()
def test_dismissal_survives_a_re_raise(self): def test_acknowledged_notice_returns_on_a_re_raise(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("detector_stuck", scope="ov", params={})
notice = self.registry.active()[0]
self.assertEqual(notice["count"], 2)
self.assertIsNone(notice["acknowledged_at"])
def test_muted_notice_stays_hidden_on_a_re_raise(self):
self.registry.raise_notice("skipped_detections", scope="front_door", params={}) self.registry.raise_notice("skipped_detections", scope="front_door", params={})
self.assertTrue(self.registry.dismiss("skipped_detections:front_door")) self.assertTrue(self.registry.mute("skipped_detections:front_door"))
self.registry.raise_notice("skipped_detections", scope="front_door", params={}) self.registry.raise_notice("skipped_detections", scope="front_door", params={})
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
history = self.registry.active(include_dismissed=True) hidden = self.registry.active(include_hidden=True)
self.assertEqual(history[0]["count"], 2) self.assertEqual(hidden[0]["count"], 2)
self.assertIsNotNone(history[0]["dismissed_at"]) self.assertIsNotNone(hidden[0]["muted_at"])
def test_dismiss_counts_once(self): def test_muting_an_acknowledged_notice_replaces_the_acknowledgement(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.acknowledge("detector_stuck:ov")
self.assertTrue(self.registry.dismiss("detector_stuck:ov")) self.assertTrue(self.registry.mute("detector_stuck:ov"))
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
self.assertEqual(self.registry.stats()[0]["dismissals"], 1) notice = self.registry.active(include_hidden=True)[0]
self.assertIsNone(notice["acknowledged_at"])
self.assertIsNotNone(notice["muted_at"])
def test_dismiss_unknown_id(self): def test_acknowledging_a_muted_notice_keeps_it_muted(self):
self.assertFalse(self.registry.dismiss("nope"))
def test_dismissed_hidden_unless_requested(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.dismiss("detector_stuck:ov") self.registry.mute("detector_stuck:ov")
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
notice = self.registry.active(include_hidden=True)[0]
self.assertIsNone(notice["acknowledged_at"])
self.assertIsNotNone(notice["muted_at"])
def test_acknowledge_and_mute_each_count_once(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.raise_notice("shm_too_low", params={})
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertTrue(self.registry.mute("shm_too_low"))
self.assertTrue(self.registry.mute("shm_too_low"))
stats = {s["kind"]: s for s in self.registry.stats()}
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
self.assertEqual(stats["detector_stuck"]["mutes"], 0)
self.assertEqual(stats["shm_too_low"]["mutes"], 1)
def test_unknown_ids_are_rejected(self):
self.assertFalse(self.registry.acknowledge("nope"))
self.assertFalse(self.registry.mute("nope"))
self.assertFalse(self.registry.unhide("nope"))
def test_kinds_that_never_repeat_cannot_be_acknowledged(self):
self.registry.raise_notice(
"update_available", scope="0.19.1", params={"version": "0.19.1"}
)
self.assertFalse(self.registry.acknowledge("update_available:0.19.1"))
self.assertFalse(
self.registry.acknowledge("config:detect:fps-greater-than-five:global")
)
self.assertEqual(self.registry.active()[0]["acknowledgeable"], False)
def test_hidden_notices_listed_only_when_requested(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.acknowledge("detector_stuck:ov")
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.assertEqual(len(self.registry.active(include_dismissed=True)), 1) self.assertEqual(len(self.registry.active(include_hidden=True)), 1)
def test_unhide_shows_a_notice_and_drops_a_check_mute(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.mute("detector_stuck:ov")
self.registry.mute("config:detect:fps-greater-than-five:global")
self.assertTrue(self.registry.unhide("detector_stuck:ov"))
self.assertTrue(
self.registry.unhide("config:detect:fps-greater-than-five:global")
)
notice = self.registry.active()[0]
self.assertIsNone(notice["muted_at"])
self.assertEqual(self.registry.muted_checks(), [])
def test_resolve_deletes_and_keeps_stats(self): def test_resolve_deletes_and_keeps_stats(self):
self.registry.raise_notice( self.registry.raise_notice(
@@ -160,7 +227,7 @@ class TestNoticeRegistry(RegistryTestCase):
self.registry.resolve("model_download_failed", "yolo/model.onnx") self.registry.resolve("model_download_failed", "yolo/model.onnx")
self.registry.resolve("model_download_failed", "yolo/model.onnx") self.registry.resolve("model_download_failed", "yolo/model.onnx")
self.assertEqual(self.registry.active(include_dismissed=True), []) self.assertEqual(self.registry.active(include_hidden=True), [])
self.assertEqual(self.registry.stats()[0]["occurrences"], 1) self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_called_once() self.listener.assert_called_once()
@@ -237,45 +304,47 @@ class TestNoticeRegistry(RegistryTestCase):
ids, ["model_download_failed:front_door", "skipped_detections:garage"] ids, ["model_download_failed:front_door", "skipped_detections:garage"]
) )
def test_resolve_camera_drops_that_cameras_check_dismissals(self): def test_resolve_camera_drops_that_cameras_check_mutes(self):
for check_id in ( for check_id in (
"stream:front_door:0:probe", "stream:front_door:0:probe",
"config:detect:fps-greater-than-five:camera.front_door", "config:detect:fps-greater-than-five:camera.front_door",
"config:detect:fps-greater-than-five:global", "config:detect:fps-greater-than-five:global",
"stream:garage:0:probe", "stream:garage:0:probe",
): ):
self.assertTrue(self.registry.dismiss(check_id)) self.assertTrue(self.registry.mute(check_id))
self.registry.resolve_camera("front_door") self.registry.resolve_camera("front_door")
ids = sorted(check["id"] for check in self.registry.dismissed_checks()) ids = sorted(check["id"] for check in self.registry.muted_checks())
self.assertEqual( self.assertEqual(
ids, ids,
["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"], ["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"],
) )
def test_camera_named_global_keeps_global_check_dismissals(self): def test_camera_named_global_keeps_global_check_mutes(self):
self.registry.dismiss("config:detect:fps-greater-than-five:global") self.registry.mute("config:detect:fps-greater-than-five:global")
self.registry.dismiss("config:detect:fps-greater-than-five:camera.global") self.registry.mute("config:detect:fps-greater-than-five:camera.global")
self.registry.resolve_camera("global") self.registry.resolve_camera("global")
ids = [check["id"] for check in self.registry.dismissed_checks()] ids = [check["id"] for check in self.registry.muted_checks()]
self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"]) self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"])
def test_purge_dismissed_keeps_active_notices_and_counts(self): def test_unhide_all_shows_every_notice_and_keeps_counts(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={}) self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.raise_notice("skipped_detections", scope="garage", params={}) self.registry.raise_notice("skipped_detections", scope="garage", params={})
self.registry.dismiss("detector_stuck:ov") self.registry.acknowledge("detector_stuck:ov")
self.registry.dismiss("config:detect:fps-greater-than-five:camera.garage") self.registry.mute("skipped_detections:garage")
self.registry.mute("config:detect:fps-greater-than-five:camera.garage")
self.assertEqual(self.registry.purge_dismissed(), 2) self.registry.unhide_all()
ids = [n["id"] for n in self.registry.active(include_dismissed=True)] ids = sorted(n["id"] for n in self.registry.active())
self.assertEqual(ids, ["skipped_detections:garage"]) self.assertEqual(ids, ["detector_stuck:ov", "skipped_detections:garage"])
self.assertEqual(self.registry.dismissed_checks(), []) self.assertEqual(self.registry.muted_checks(), [])
dismissals = {s["kind"]: s["dismissals"] for s in self.registry.stats()} stats = {s["kind"]: s for s in self.registry.stats()}
self.assertEqual(dismissals["detector_stuck"], 1) self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
class TestApply(RegistryTestCase): class TestApply(RegistryTestCase):
@@ -349,7 +418,7 @@ class TestLifecycleKnobs(RegistryTestCase):
self.registry.flush() self.registry.flush()
self.assertEqual(self.registry.active(include_dismissed=True), []) self.assertEqual(self.registry.active(include_hidden=True), [])
self.listener.assert_not_called() self.listener.assert_not_called()
def test_a_new_row_starts_without_stale_repeats(self): def test_a_new_row_starts_without_stale_repeats(self):
@@ -362,32 +431,26 @@ class TestLifecycleKnobs(RegistryTestCase):
self.assertEqual(self.registry.active()[0]["count"], 1) self.assertEqual(self.registry.active()[0]["count"], 1)
def test_a_dismissed_notice_reopens_at_the_count(self): def test_an_acknowledged_notice_returns_when_a_later_repeat_flushes(self):
self.registry.raise_notice("batched") self._raise_at(1000.0, "batched")
self.registry.dismiss("batched") self._acknowledge_at(1010.0, "batched")
self.registry.raise_notice("batched") self._raise_at(1020.0, "batched")
self.registry.flush()
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("batched")
self.registry.flush() self.registry.flush()
notice = self.registry.active()[0] self.assertEqual(self.registry.active()[0]["count"], 2)
self.assertEqual(notice["count"], BATCHED.reopen_at_count)
self.assertIsNone(notice["dismissed_at"])
def test_a_notice_dismissed_past_the_count_stays_dismissed(self): def test_repeats_held_from_before_an_acknowledgement_stay_hidden(self):
for _ in range(3): self._raise_at(1000.0, "batched")
self.registry.raise_notice("batched") self._raise_at(1005.0, "batched")
self.registry.flush() self._acknowledge_at(1010.0, "batched")
self.registry.dismiss("batched")
self.registry.raise_notice("batched")
self.registry.flush() self.registry.flush()
self.assertEqual(self.registry.active(), []) self.assertEqual(self.registry.active(), [])
self.assertEqual(self.registry.active(include_dismissed=True)[0]["count"], 4) self.assertEqual(self.registry.active(include_hidden=True)[0]["count"], 2)
def test_keep_latest_drops_the_oldest_rows(self): def test_keep_latest_drops_the_oldest_rows(self):
for index, scope in enumerate(("a", "b", "c")): for index, scope in enumerate(("a", "b", "c")):
@@ -410,14 +473,13 @@ class TestCatalogLifecycles(RegistryTestCase):
ids = [n["id"] for n in self.registry.active()] ids = [n["id"] for n in self.registry.active()]
self.assertEqual(ids, ["update_available:0.19.1"]) self.assertEqual(ids, ["update_available:0.19.1"])
def test_a_dismissed_failed_login_burst_reopens_at_five_attempts(self): def test_an_acknowledged_failed_login_burst_returns_on_the_next_attempt(self):
self.registry.raise_notice("failed_login", scope="1000") self.registry.raise_notice("failed_login", scope="1000")
self.registry.dismiss("failed_login:1000") self.registry.acknowledge("failed_login:1000")
for _ in range(4): self.registry.raise_notice("failed_login", scope="1000")
self.registry.raise_notice("failed_login", scope="1000")
self.registry.flush() self.registry.flush()
burst = self.registry.active()[0] burst = self.registry.active()[0]
self.assertEqual(burst["count"], 5) self.assertEqual(burst["count"], 2)
self.assertIsNone(burst["dismissed_at"]) self.assertIsNone(burst["acknowledged_at"])
@@ -1,5 +1,6 @@
"""Tests for sub stream cache segment handling in the recording maintainer.""" """Tests for sub stream cache segment handling in the recording maintainer."""
import asyncio
import datetime import datetime
import os import os
import tempfile import tempfile
@@ -532,6 +533,59 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3) self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3) self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
async def test_out_of_order_probes_chain_in_segment_order(self):
maintainer = _build_chaining_maintainer(self.T0)
async def probe(_ffmpeg, cache_path, get_duration=False):
# the earlier segment's probe finishes last
if "chain0" in cache_path:
await asyncio.sleep(0.05)
return {"has_valid_video": True, "duration": 10.4}
recordings = [
{
"start_time": datetime.datetime.fromtimestamp(
self.T0 + offset, tz=datetime.UTC
),
"cache_path": f"/tmp/cache/test_cam@chain{offset}.mp4",
"stream_type": "main",
}
for offset in (0, 10)
]
first_resolved = asyncio.Event()
with (
patch("frigate.record.maintainer.get_video_properties", probe),
patch(
"frigate.record.maintainer.get_keyframe_offsets",
AsyncMock(return_value=[0]),
),
patch(
"frigate.record.maintainer.os.path.getmtime",
MagicMock(side_effect=OSError("missing")),
),
):
await asyncio.gather(
maintainer._validate_in_order(
"test_cam", [], recordings[0], None, first_resolved
),
maintainer._validate_in_order(
"test_cam", [], recordings[1], first_resolved, asyncio.Event()
),
)
starts = sorted(
call.args[2].timestamp() for call in maintainer.move_segment.await_args_list
)
self.assertEqual(starts[0], self.T0)
self.assertAlmostEqual(starts[1], self.T0 + 10.4, places=3)
self.assertAlmostEqual(
maintainer.last_segment_end[("test_cam", "main")],
self.T0 + 20.8,
places=3,
)
async def test_genuine_gap_is_not_snapped(self): async def test_genuine_gap_is_not_snapped(self):
maintainer = _build_chaining_maintainer(self.T0) maintainer = _build_chaining_maintainer(self.T0)
+147
View File
@@ -1,5 +1,7 @@
"""Tests for the device each model runner reports after loading.""" """Tests for the device each model runner reports after loading."""
import os
import tempfile
import threading import threading
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -38,6 +40,7 @@ class TestRunnerDeviceName(unittest.TestCase):
self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO" self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO"
) )
self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU") self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU")
self.assertEqual(self._onnx(["LighterANE"]).device_name, "Neural Engine")
self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM") self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM")
self.assertEqual(self._onnx([]).device_name, "CPU") self.assertEqual(self._onnx([]).device_name, "CPU")
@@ -129,3 +132,147 @@ class TestLoadedDeviceSnapshot(unittest.TestCase):
finally: finally:
stop.set() stop.set()
thread.join(timeout=5) thread.join(timeout=5)
class TestLighterANE(unittest.TestCase):
"""lighter's plugin provider, picked up by the ONNX session setup."""
def setUp(self):
loaded_devices.clear()
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
self.library = os.path.join(self.root.name, "liblighter_ane_ep.so")
env = patch.dict(os.environ, {"LIGHTER_ANE_EP": self.library})
env.start()
self.addCleanup(env.stop)
def _device(self) -> MagicMock:
device = MagicMock()
device.ep_name = "LighterANE"
return device
def test_no_devices_without_the_library(self):
with patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices:
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
get_ep_devices.assert_not_called()
def test_the_provider_is_registered_once(self):
open(self.library, "w").close()
device = self._device()
other = MagicMock()
other.ep_name = "CPUExecutionProvider"
with (
patch.object(
detection_runners.ort,
"get_ep_devices",
side_effect=[[other], [other, device], [other, device]],
),
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
register.assert_called_once_with("LighterANE", self.library)
def test_a_provider_that_will_not_load_is_skipped(self):
open(self.library, "w").close()
with (
patch.object(detection_runners.ort, "get_ep_devices", return_value=[]),
patch.object(
detection_runners.ort,
"register_execution_provider_library",
side_effect=RuntimeError("not a provider"),
),
):
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
def test_the_session_runs_on_the_neural_engine(self):
device = self._device()
session = MagicMock()
session.get_providers.return_value = ["LighterANE", "CPUExecutionProvider"]
options = MagicMock()
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[device]
),
patch.object(
detection_runners, "get_ort_session_options", return_value=options
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
) as inference_session,
):
runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic")
options.add_provider_for_devices.assert_called_once_with([device], {})
inference_session.assert_called_once_with(
"/models/yolo.onnx", sess_options=options
)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(
loaded_devices["/models/yolo.onnx"], ("yolo-generic", "Neural Engine")
)
def test_a_model_the_neural_engine_cannot_load_uses_the_default_providers(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
),
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners, "is_openvino_gpu_npu_available", return_value=False
),
patch.object(
detection_runners.ort,
"InferenceSession",
side_effect=[RuntimeError("unsupported"), session],
) as inference_session,
patch.object(
detection_runners, "get_ort_session_options", return_value=MagicMock()
),
self.assertLogs(detection_runners.logger, level="WARNING"),
):
runner = get_optimized_runner("/models/jina.onnx", "AUTO", "jina-v2")
self.assertEqual(inference_session.call_count, 2)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/jina.onnx"], ("jina-v2", "CPU"))
def test_a_cpu_model_stays_on_the_cpu(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
) as ane,
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
),
patch.object(
detection_runners, "get_ort_session_options", return_value=None
),
):
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
ane.assert_not_called()
+86 -18
View File
@@ -1,10 +1,14 @@
"""Tests for the skipped detection rate and the notice it can raise.""" """Tests for the per-camera episode notices: skipped detections and high CPU."""
import unittest import unittest
from unittest.mock import patch from unittest.mock import call, patch
from frigate.stats import emitter from frigate.stats import emitter
from frigate.stats.emitter import SkippedDetectionsTracker from frigate.stats.emitter import (
SKIPPED_DETECTIONS_PCT,
EpisodeTracker,
cpu_average,
)
from frigate.stats.util import skipped_percent from frigate.stats.util import skipped_percent
@@ -19,18 +23,18 @@ class TestSkippedPercent(unittest.TestCase):
self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0) self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0)
class TestSkippedDetectionsTracker(unittest.TestCase): class TestEpisodeTracker(unittest.TestCase):
def _cameras(self, pct: float) -> dict: def _cameras(self, pct: float) -> dict:
return {"front_door": {"skipped_pct": pct}} return {"front_door": pct}
def test_below_threshold_never_qualifies(self): def test_below_threshold_never_qualifies(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
for tick in range(10): for tick in range(10):
self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), []) self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), [])
def test_qualifies_once_after_the_hold(self): def test_qualifies_once_after_the_hold(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
results = [ results = [
tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8) tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8)
@@ -42,7 +46,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(results[5:], [[], [], []]) self.assertEqual(results[5:], [[], [], []])
def test_a_dip_restarts_the_hold(self): def test_a_dip_restarts_the_hold(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
for now, pct in ((0.0, 10.0), (15.0, 10.0), (30.0, 10.0), (45.0, 2.0)): for now, pct in ((0.0, 10.0), (15.0, 10.0), (30.0, 10.0), (45.0, 2.0)):
self.assertEqual(tracker.update(self._cameras(pct), now), []) self.assertEqual(tracker.update(self._cameras(pct), now), [])
@@ -51,7 +55,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"])
def test_recovery_then_relapse_is_a_new_episode(self): def test_recovery_then_relapse_is_a_new_episode(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0) tracker.update(self._cameras(10.0), 0.0)
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"])
@@ -61,17 +65,15 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"])
def test_cameras_are_tracked_separately(self): def test_cameras_are_tracked_separately(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update({"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 0.0}}, 0.0) tracker.update({"a": 10.0, "b": 0.0}, 0.0)
qualified = tracker.update( qualified = tracker.update({"a": 10.0, "b": 10.0}, 60.0)
{"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 10.0}}, 60.0
)
self.assertEqual(qualified, ["a"]) self.assertEqual(qualified, ["a"])
def test_a_removed_camera_starts_a_new_episode(self): def test_a_removed_camera_starts_a_new_episode(self):
tracker = SkippedDetectionsTracker() tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0) tracker.update(self._cameras(10.0), 0.0)
tracker.update({}, 15.0) tracker.update({}, 15.0)
tracker.update(self._cameras(10.0), 30.0) tracker.update(self._cameras(10.0), 30.0)
@@ -79,6 +81,25 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), []) self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"]) self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"])
def test_a_missing_value_ends_the_episode(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0)
tracker.update({"front_door": None}, 30.0)
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
class TestCpuAverage(unittest.TestCase):
def test_reads_the_lifetime_average(self):
usages = {"42": {"cpu": "80.0", "cpu_average": "25.0"}}
self.assertEqual(cpu_average(usages, 42), 25.0)
def test_unknown_process_has_no_average(self):
self.assertIsNone(cpu_average({}, 42))
self.assertIsNone(cpu_average({"42": {"cpu_average": "25.0"}}, None))
self.assertIsNone(cpu_average({"42": {"cpu_average": ""}}, 42))
class TestEmitterNotices(unittest.TestCase): class TestEmitterNotices(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -94,15 +115,25 @@ class TestEmitterNotices(unittest.TestCase):
def _emitter(self) -> emitter.StatsEmitter: def _emitter(self) -> emitter.StatsEmitter:
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter) stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
stats_emitter.skipped_detections = SkippedDetectionsTracker() stats_emitter.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
stats_emitter.ffmpeg_cpu = EpisodeTracker(emitter.FFMPEG_HIGH_CPU_PCT)
stats_emitter.detect_cpu = EpisodeTracker(emitter.DETECT_HIGH_CPU_PCT)
stats_emitter._shm_checked = False stats_emitter._shm_checked = False
stats_emitter._shm_params = None stats_emitter._shm_params = None
return stats_emitter return stats_emitter
def _stats(self, uptime: int, pct: float) -> dict: def _stats(
self, uptime: int, pct: float, ffmpeg_cpu: str = "5.0", detect_cpu: str = "5.0"
) -> dict:
return { return {
"service": {"uptime": uptime, "storage": {"/dev/shm": {}}}, "service": {"uptime": uptime, "storage": {"/dev/shm": {}}},
"cameras": {"front_door": {"skipped_pct": pct}}, "cameras": {
"front_door": {"skipped_pct": pct, "ffmpeg_pid": 10, "pid": 11}
},
"cpu_usages": {
"10": {"cpu_average": ffmpeg_cpu},
"11": {"cpu_average": detect_cpu},
},
} }
def test_qualified_camera_raises_a_notice(self): def test_qualified_camera_raises_a_notice(self):
@@ -115,6 +146,43 @@ class TestEmitterNotices(unittest.TestCase):
"skipped_detections", scope="front_door", params={"pct": 12.5} "skipped_detections", scope="front_door", params={"pct": 12.5}
) )
def test_high_cpu_raises_a_notice_per_process(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats_emitter._update_notices(
self._stats(uptime, 0.0, ffmpeg_cpu="25.0", detect_cpu="45.0"), now
)
self.assertEqual(
self.raise_notice.call_args_list,
[
call("ffmpeg_high_cpu", scope="front_door", params={"cpu": 25.0}),
call("detect_high_cpu", scope="front_door", params={"cpu": 45.0}),
],
)
def test_cpu_below_each_threshold_raises_nothing(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats_emitter._update_notices(
self._stats(uptime, 0.0, ffmpeg_cpu="19.0", detect_cpu="39.0"), now
)
self.raise_notice.assert_not_called()
def test_missing_cpu_stats_raise_nothing(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats = self._stats(uptime, 0.0)
del stats["cpu_usages"]
stats_emitter._update_notices(stats, now)
self.raise_notice.assert_not_called()
self.assertEqual(self.flush_notices.call_count, 2)
def test_startup_window_is_ignored(self): def test_startup_window_is_ignored(self):
stats_emitter = self._emitter() stats_emitter = self._emitter()
+1 -3
View File
@@ -324,9 +324,7 @@ class NorfairTracker(ObjectTracker):
): ):
tracker = self.get_tracker(obj["label"]) tracker = self.get_tracker(obj["label"])
tracker.tracked_objects = [ tracker.tracked_objects = [
o o for o in tracker.tracked_objects if str(o.global_id) != track_id
for o in tracker.tracked_objects
if str(o.global_id) != track_id and o.hit_counter < 0
] ]
del self.track_id_map[track_id] del self.track_id_map[track_id]
+7 -4
View File
@@ -56,10 +56,13 @@ class EventsPerSecond:
self._start = now self._start = now
# compute the (approximate) events in the last n seconds # compute the (approximate) events in the last n seconds
self.expire_timestamps(now) self.expire_timestamps(now)
seconds = min(now - self._start, self._last_n_seconds) # rate over at least one second (or the whole window, if shorter),
# avoid divide by zero # so a burst of events right after start() is not divided by a
if seconds == 0: # tiny window
seconds = 1 seconds = max(
min(now - self._start, self._last_n_seconds),
min(1.0, self._last_n_seconds),
)
return len(self._timestamps) / seconds return len(self._timestamps) / seconds
# remove aged out timestamps # remove aged out timestamps
+7 -1
View File
@@ -14,6 +14,10 @@ from frigate.util.file import FileLock
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# (connect, read) seconds. The read timeout bounds each socket read rather than
# the whole download, so large models still finish.
DOWNLOAD_TIMEOUT = (15, 60)
# target path -> first line of the last download error for it; every existing # target path -> first line of the last download error for it; every existing
# download function swallows its exceptions, so this is how the downloader # download function swallows its exceptions, so this is how the downloader
# thread learns why a file is still missing # thread learns why a file is still missing
@@ -124,7 +128,9 @@ class ModelDownloader:
logger.info(f"Downloading model file from: {url}") logger.info(f"Downloading model file from: {url}")
try: try:
with requests.get(url, stream=True, allow_redirects=True) as r: with requests.get(
url, stream=True, allow_redirects=True, timeout=DOWNLOAD_TIMEOUT
) as r:
r.raise_for_status() r.raise_for_status()
with open(temporary_filename, "wb") as f: with open(temporary_filename, "wb") as f:
for chunk in r.iter_content(chunk_size=8192): for chunk in r.iter_content(chunk_size=8192):
+3
View File
@@ -49,6 +49,9 @@ def start_or_restart_ffmpeg(
if ffmpeg_process is not None: if ffmpeg_process is not None:
stop_ffmpeg(ffmpeg_process, logger) stop_ffmpeg(ffmpeg_process, logger)
# flush after the stop so the logs cover ffmpeg's output up to exit
logpipe.dump()
if frame_size is None: if frame_size is None:
process = sp.Popen( process = sp.Popen(
ffmpeg_cmd, ffmpeg_cmd,
+33 -1
View File
@@ -9,6 +9,7 @@ and resolve it per camera against that camera's detect stream.
""" """
import logging import logging
import os
import re import re
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -23,14 +24,20 @@ from frigate.util.services import enumerate_drm_devices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# root the /proc reads use, so tests can point them at a fixture tree # roots the /proc and /sys reads use, so tests can point them at a fixture tree
PROC_ROOT = "/proc" PROC_ROOT = "/proc"
SYS_ROOT = "/sys"
ANY_CODEC = "any" ANY_CODEC = "any"
# a Raspberry Pi has no detection hardware of its own, so it gets a key here # a Raspberry Pi has no detection hardware of its own, so it gets a key here
RASPBERRY_PI = "raspberrypi" RASPBERRY_PI = "raspberrypi"
# lighter (https://github.com/fieldwork-ai/lighter) decodes on a Mac's media
# engine through virtio-media devices, which carry its virtio vendor id "LGHT"
LIGHTER_MEDIA = "lighter"
LIGHTER_VIRTIO_VENDOR = "0x4c474854"
# ffprobe names h265 streams hevc # ffprobe names h265 streams hevc
CODEC_ALIASES = {"hevc": "h265"} CODEC_ALIASES = {"hevc": "h265"}
@@ -52,6 +59,7 @@ DECODE_HARDWARE = (
"rknn", "rknn",
"openvino:GPU", "openvino:GPU",
"onnx:amd", "onnx:amd",
LIGHTER_MEDIA,
RASPBERRY_PI, RASPBERRY_PI,
) )
@@ -98,6 +106,10 @@ FAMILY_RPI = HwaccelFamily(
key="rpi", key="rpi",
presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"}, presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"},
) )
FAMILY_APPLE = HwaccelFamily(
key="apple-silicon",
presets={"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
)
def _read(path: str) -> str | None: def _read(path: str) -> str | None:
@@ -139,6 +151,20 @@ def _is_raspberry_pi() -> bool:
return "raspberrypi" in compatible return "raspberrypi" in compatible
def _has_lighter_media() -> bool:
video = f"{SYS_ROOT}/class/video4linux"
try:
devices = os.listdir(video)
except OSError:
return False
return any(
_read(f"{video}/{device}/device/vendor") == LIGHTER_VIRTIO_VENDOR
for device in devices
)
def _intel_families(generation: int | None) -> list[HwaccelFamily]: def _intel_families(generation: int | None) -> list[HwaccelFamily]:
"""vaapi drives every Intel GPU, qsv only those from gen8 on.""" """vaapi drives every Intel GPU, qsv only those from gen8 on."""
if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN: if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN:
@@ -164,6 +190,9 @@ def _families(key: str, generation: int | None) -> list[HwaccelFamily]:
if key == "onnx:amd": if key == "onnx:amd":
return [FAMILY_VAAPI] return [FAMILY_VAAPI]
if key == LIGHTER_MEDIA:
return [FAMILY_APPLE]
if key == RASPBERRY_PI: if key == RASPBERRY_PI:
return [FAMILY_RPI] return [FAMILY_RPI]
@@ -193,6 +222,9 @@ def _decode_hardware(detector_key: str | None) -> list[str]:
""" """
present = {found.key for found in hardware_prober.probe()} present = {found.key for found in hardware_prober.probe()}
if _has_lighter_media():
present.add(LIGHTER_MEDIA)
if _is_raspberry_pi(): if _is_raspberry_pi():
present.add(RASPBERRY_PI) present.add(RASPBERRY_PI)
+7 -7
View File
@@ -213,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
) )
def null_audio_glitches( def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
) -> list[CoverageInterval]:
"""Treat video-only glitch rows on audio-bearing streams as no recording. """Treat video-only glitch rows on audio-bearing streams as no recording.
nginx-vod requires every clip in a sequence to carry the same track nginx-vod requires every clip in a sequence to carry the same track
count, so a truncated video-only segment (a backend restart can flush count, so a truncated video-only segment (a backend restart can flush
a sub-second file before any audio packet landed) poisons every a sub-second file before any audio packet landed) poisons every
manifest that includes it. Nulling the row turns the glitch into a manifest that includes it. Nulling the row turns the glitch into a
hole the span builder skips like any recording gap. hole the span builder skips like any recording gap. Every consumer of
a window's coverage (the vod manifest, its realized timelines, and
exports) goes through here, so they all agree on which rows exist.
""" """
main_audio = stream_has_audio(intervals, main=True)
sub_audio = stream_has_audio(intervals, main=False)
result: list[CoverageInterval] = [] result: list[CoverageInterval] = []
for interval in intervals: for interval in intervals:
main = interval.main main = interval.main
@@ -449,9 +451,7 @@ def realized_timelines(
assembles each variant's realized spans. Keyframe snapping reads the assembles each variant's realized spans. Keyframe snapping reads the
per-row index stored at record time, so no file is touched. per-row index stored at record time, so no file is touched.
""" """
main_audio = stream_has_audio(intervals, main=True) nulled = null_audio_glitches(intervals)
sub_audio = stream_has_audio(intervals, main=False)
nulled = null_audio_glitches(intervals, main_audio, sub_audio)
return { return {
"auto": realized_timeline(nulled, None), "auto": realized_timeline(nulled, None),
-4
View File
@@ -319,9 +319,6 @@ class CameraWatchdog(threading.Thread):
f"Capture thread for {self.config.name} did not exit in time" f"Capture thread for {self.config.name} did not exit in time"
) )
self.logger.error(
"The following ffmpeg logs include the last 100 lines prior to exit."
)
self.logpipe.dump() self.logpipe.dump()
self.logger.info("Restarting ffmpeg...") self.logger.info("Restarting ffmpeg...")
self.start_ffmpeg_detect() self.start_ffmpeg_detect()
@@ -543,7 +540,6 @@ class CameraWatchdog(threading.Thread):
f"{self.config.name}/status/{role.value}", "offline" f"{self.config.name}/status/{role.value}", "offline"
) )
p["logpipe"].dump()
p["process"] = start_or_restart_ffmpeg( p["process"] = start_or_restart_ffmpeg(
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
) )
+6 -3
View File
@@ -18,18 +18,21 @@ def migrate(migrator, database, fake=False, **kwargs):
'"first_seen" DATETIME NOT NULL, ' '"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, ' '"last_seen" DATETIME NOT NULL, '
'"count" INTEGER NOT NULL, ' '"count" INTEGER NOT NULL, '
'"dismissed_at" DATETIME)' '"acknowledged_at" DATETIME, '
'"muted_at" DATETIME)'
) )
migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")') migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")')
migrator.sql( migrator.sql(
'CREATE TABLE IF NOT EXISTS "noticestats" (' 'CREATE TABLE IF NOT EXISTS "noticestats" ('
'"kind" VARCHAR(50) NOT NULL PRIMARY KEY, ' '"kind" VARCHAR(50) NOT NULL PRIMARY KEY, '
'"occurrences" INTEGER NOT NULL, ' '"occurrences" INTEGER NOT NULL, '
'"dismissals" INTEGER NOT NULL, ' '"acknowledgements" INTEGER NOT NULL, '
'"mutes" INTEGER NOT NULL, '
'"first_seen" DATETIME NOT NULL, ' '"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, ' '"last_seen" DATETIME NOT NULL, '
'"reported_occurrences" INTEGER NOT NULL DEFAULT 0, ' '"reported_occurrences" INTEGER NOT NULL DEFAULT 0, '
'"reported_dismissals" INTEGER NOT NULL DEFAULT 0)' '"reported_acknowledgements" INTEGER NOT NULL DEFAULT 0, '
'"reported_mutes" INTEGER NOT NULL DEFAULT 0)'
) )
+3 -3
View File
@@ -49,7 +49,7 @@ export interface ApiMockOverrides {
}; };
users?: { username: string; role: string }[]; users?: { username: string; role: string }[];
notices?: unknown[]; notices?: unknown[];
dismissedChecks?: unknown[]; mutedChecks?: unknown[];
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */ /** camera name to the ffprobe entries returned for `paths=camera:<name>` */
ffprobe?: Record<string, unknown[]>; ffprobe?: Record<string, unknown[]>;
} }
@@ -238,8 +238,8 @@ export class ApiMocker {
await this.page.route("**/api/notices", (route) => await this.page.route("**/api/notices", (route) =>
route.fulfill({ json: overrides?.notices ?? [] }), route.fulfill({ json: overrides?.notices ?? [] }),
); );
await this.page.route("**/api/notices/dismissed_checks", (route) => await this.page.route("**/api/notices/muted_checks", (route) =>
route.fulfill({ json: overrides?.dismissedChecks ?? [] }), route.fulfill({ json: overrides?.mutedChecks ?? [] }),
); );
// Users. GET lists them; POST/PUT (create, password) just succeed, so // Users. GET lists them; POST/PUT (create, password) just succeed, so
+204
View File
@@ -0,0 +1,204 @@
/**
* Helpers for the live dashboard's draggable grid layout: reading and seeding
* the persisted layout, and measuring rendered tiles.
*
* DraggableGridLayout persists through useUserPersistence, which namespaces
* keys by username, and every write is an async idb put. A test that seeds the
* bare key, or seeds before the app's own first write has landed, silently
* asserts against a key the app never reads. persistedLayoutKey() closes both
* holes, so prefer it over building the key by hand.
*
* Geometry has its own trap: the grid first lays out against window.innerWidth,
* then reflows narrower once useResizeObserver reports the real container.
* Tiles measured in separate round-trips can straddle that reflow and disagree
* on scale, so cameraBoxes() takes every measurement in one evaluate.
*
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
*/
import { expect, type Page } from "@playwright/test";
export type LayoutItem = {
i: string;
x: number;
y: number;
w: number;
h: number;
};
export type PersistedLayout = {
version: number;
naturalAspect: boolean;
layout: LayoutItem[];
};
function layoutKeySuffix(group: string): string {
return `${group}-draggable-layout`;
}
/**
* The key the app has actually written an envelope to, or undefined while its
* first write is still in flight.
*/
function findWrittenKey(
page: Page,
group: string,
): Promise<string | undefined> {
return page.evaluate(
(suffix) =>
new Promise<string | undefined>((resolve) => {
const open = indexedDB.open("keyval-store");
open.onsuccess = () => {
const store = open.result
.transaction("keyval", "readonly")
.objectStore("keyval");
// getAllKeys and getAll both return in key order, so the indexes align
const keys = store.getAllKeys();
const values = store.getAll();
keys.transaction.oncomplete = () => {
open.result.close();
const names = keys.result as string[];
const stored = values.result as { version?: number }[];
const match = names.findIndex(
(name, index) =>
(name === suffix || name.startsWith(`${suffix}:`)) &&
typeof stored[index]?.version === "number",
);
resolve(match === -1 ? undefined : names[match]);
};
};
open.onerror = () => resolve(undefined);
}),
layoutKeySuffix(group),
);
}
/**
* Wait for the grid to persist its own layout, then return the key it used.
* Waiting for that write is what makes a later seed meaningful: it proves the
* key is live, and it rules out the app overwriting the seed a moment later.
*/
export async function persistedLayoutKey(
page: Page,
group: string,
): Promise<string> {
let key: string | undefined;
await expect
.poll(async () => (key = await findWrittenKey(page, group)), {
timeout: 10_000,
message: `grid never persisted a layout for group "${group}"`,
})
.not.toBeUndefined();
return key!;
}
/** Overwrite the stored layout, resolving only once the put has committed. */
export function seedLayout(
page: Page,
key: string,
value: unknown,
): Promise<void> {
return page.evaluate(
([key, value]) =>
new Promise<void>((resolve, reject) => {
const open = indexedDB.open("keyval-store");
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
open.onsuccess = () => {
const tx = open.result.transaction("keyval", "readwrite");
tx.objectStore("keyval").put(value, key as string);
tx.oncomplete = () => {
open.result.close();
resolve();
};
tx.onerror = () => reject(tx.error);
};
open.onerror = () => reject(open.error);
}),
[key, value] as const,
);
}
/** Read the stored layout back. Undefined until the app writes it. */
export function readLayout(
page: Page,
key: string,
): Promise<PersistedLayout | undefined> {
return page.evaluate(
(target) =>
new Promise((resolve) => {
const open = indexedDB.open("keyval-store");
open.onsuccess = () => {
const tx = open.result.transaction("keyval", "readonly");
const request = tx.objectStore("keyval").get(target);
tx.oncomplete = () => {
open.result.close();
resolve(request.result);
};
};
open.onerror = () => resolve(undefined);
}),
key,
) as Promise<PersistedLayout | undefined>;
}
export type Box = { w: number; h: number; x: number; y: number };
/** The card is the player root; the cell is the grid slot it sits in. */
export type BoxTarget = "card" | "cell";
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
function snapshotBoxes(
page: Page,
cameras: readonly string[],
target: BoxTarget,
): Promise<Record<string, Box> | null> {
return page.evaluate(
({ cams, target }) => {
const boxes: Record<string, Box> = {};
for (const cam of cams) {
const card = document.querySelector(`[data-camera='${cam}']`);
const el = target === "cell" ? card?.closest(".p-1") : card;
if (!el) {
return null;
}
const r = el.getBoundingClientRect();
// a re-rendering tile can briefly report no box at all
if (!r.width || !r.height) {
return null;
}
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
}
return boxes;
},
{ cams: cameras as readonly string[], target },
);
}
/**
* Measure the given cameras' tiles together, once they have all rendered.
* Measuring in one evaluate is what keeps the numbers mutually comparable.
*/
export async function cameraBoxes<T extends string>(
page: Page,
cameras: readonly T[],
target: BoxTarget = "cell",
): Promise<Record<T, Box>> {
let boxes: Record<string, Box> | null = null;
await expect
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
timeout: 10_000,
message: `${target}s never rendered for ${cameras.join(", ")}`,
})
.not.toBeNull();
return boxes as unknown as Record<T, Box>;
}
+11
View File
@@ -56,3 +56,14 @@ export async function waitForErrorMarker(
.poll(() => hasErrorMarkers(page), { timeout: timeoutMs }) .poll(() => hasErrorMarkers(page), { timeout: timeoutMs })
.toBe(true); .toBe(true);
} }
/**
* Append a trailing comment so the editor content differs from the
* loaded config. The save buttons are disabled until then.
*/
export async function makeMonacoEdit(page: Page): Promise<void> {
const editor = page.locator(".monaco-editor").first();
await editor.click();
await page.keyboard.press("ControlOrMeta+End");
await page.keyboard.type("# edited by e2e", { delay: 0 });
}
+5
View File
@@ -45,6 +45,11 @@ export class LivePage extends BasePage {
); );
} }
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
get editLayoutButton(): Locator {
return this.page.getByTestId("toggle-edit-layout");
}
/** Open the right-click context menu on a camera card (desktop only). */ /** Open the right-click context menu on a camera card (desktop only). */
async openContextMenuOn(cameraName: string): Promise<Locator> { async openContextMenuOn(cameraName: string): Promise<Locator> {
await this.cameraCard(cameraName).first().click({ button: "right" }); await this.cameraCard(cameraName).first().click({ button: "right" });
+5
View File
@@ -11,6 +11,7 @@ import { installWsFrameCapture, waitForWsFrame } from "../helpers/ws-frames";
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard"; import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
import { import {
getMonacoVisibleText, getMonacoVisibleText,
makeMonacoEdit,
replaceMonacoValue, replaceMonacoValue,
waitForErrorMarker, waitForErrorMarker,
} from "../helpers/monaco"; } from "../helpers/monaco";
@@ -71,6 +72,7 @@ test.describe("Config Editor — Save @medium", () => {
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await makeMonacoEdit(frigateApp.page);
await frigateApp.page.getByLabel("Save Only").click(); await frigateApp.page.getByLabel("Save Only").click();
await expect await expect
.poll(() => capture.capturedUrl(), { timeout: 5_000 }) .poll(() => capture.capturedUrl(), { timeout: 5_000 })
@@ -92,6 +94,7 @@ test.describe("Config Editor — Save @medium", () => {
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible( await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await makeMonacoEdit(frigateApp.page);
await frigateApp.page.getByLabel("Save Only").click(); await frigateApp.page.getByLabel("Save Only").click();
await expect(frigateApp.page.getByText(/Invalid field/i)).toBeVisible({ await expect(frigateApp.page.getByText(/Invalid field/i)).toBeVisible({
timeout: 5_000, timeout: 5_000,
@@ -117,6 +120,7 @@ test.describe("Config Editor — Save and Restart @medium", () => {
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await makeMonacoEdit(frigateApp.page);
await frigateApp.page.getByLabel("Save & Restart").click(); await frigateApp.page.getByLabel("Save & Restart").click();
const dialog = frigateApp.page.getByRole("alertdialog"); const dialog = frigateApp.page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toBeVisible({ timeout: 5_000 });
@@ -140,6 +144,7 @@ test.describe("Config Editor — Save and Restart @medium", () => {
{ timeout: 15_000 }, { timeout: 15_000 },
); );
await makeMonacoEdit(frigateApp.page);
await frigateApp.page.getByLabel("Save & Restart").click(); await frigateApp.page.getByLabel("Save & Restart").click();
const dialog = frigateApp.page.getByRole("alertdialog"); const dialog = frigateApp.page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toBeVisible({ timeout: 5_000 });
@@ -0,0 +1,185 @@
/**
* Live grid aspect modes.
*
* Bucketed mode (the default) snaps every camera to a wide, landscape or tall
* tile, and converts layouts saved by pre-masonry versions instead of
* discarding them. Natural mode sizes each tile to its own camera.
*/
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
import {
cameraBoxes,
persistedLayoutKey,
readLayout,
seedLayout,
type LayoutItem,
} from "../helpers/grid-layout";
const GROUP = "outdoor";
const GRID_COLS = 96;
test.describe("Live grid aspect modes @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Draggable grid is desktop-only",
);
test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 2560, height: 720 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
const { backyard: wide, front_door: normal } = await cameraBoxes(
frigateApp.page,
["backyard", "front_door"] as const,
);
expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1);
expect(wide.w / normal.w).toBeCloseTo(2, 1);
expect(wide.h).toBeCloseTo(normal.h, 0);
});
test("a letterboxed still image rounds its own corners", async ({
frigateApp,
}) => {
// A portrait camera pillarboxes inside its 8:9 bucket, so the card's
// overflow-hidden clip never reaches the picture's corners. The image has
// to carry the radius itself or it renders with square edges on the tile.
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
const radii = await frigateApp.page.evaluate(() => {
const card = document.querySelector("[data-camera='backyard']");
const img = card?.querySelector("img");
return {
card: card ? getComputedStyle(card).borderTopLeftRadius : null,
img: img ? getComputedStyle(img).borderTopLeftRadius : null,
};
});
expect(radii.card).not.toBe("0px");
expect(radii.img).toBe(radii.card);
});
test("a pre-masonry layout is converted, keeping resized tiles", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles.
// backyard was manually resized to 8x8 and sits beside front_door's column,
// front_door is a standard tile on the row below.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, [
{ i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false },
{ i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false },
]);
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// The conversion is written back on first load, replacing the legacy array
// with an envelope. Poll for it: that write is an async idb put.
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBe(2);
const stored = (await readLayout(frigateApp.page, key))!;
expect(stored).toMatchObject({ version: 2, naturalAspect: false });
// x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per
// standard tile), so the manual resize survives instead of snapping back.
expect(
stored.layout.find((i: LayoutItem) => i.i === "backyard"),
).toMatchObject({
x: 32,
y: 0,
w: 64,
h: 144,
});
expect(
stored.layout.find((i: LayoutItem) => i.i === "front_door"),
).toMatchObject({
x: 0,
y: 144,
w: 32,
h: 72,
});
// arrangement on screen: backyard indented, front_door below it
const { backyard, front_door: frontDoor } = await cameraBoxes(
frigateApp.page,
["backyard", "front_door"] as const,
);
expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2);
expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2);
});
test("conversion is a pure scale, so odd sizes and positions survive", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// The old grid exposed all four resize corners with no aspect constraint,
// so a stored tile can be any size. These two are adjacent and non-standard.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, [
{ i: "front_door", x: 0, y: 3, w: 5, h: 5 },
{ i: "backyard", x: 5, y: 3, w: 7, h: 5 },
]);
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBe(2);
const stored = (await readLayout(frigateApp.page, key))!;
const frontDoor = stored.layout.find(
(i: LayoutItem) => i.i === "front_door",
)!;
const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!;
expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 });
expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 });
// still adjacent, still inside the grid, still not overlapping
expect(frontDoor.x + frontDoor.w).toBe(backyard.x);
expect(backyard.x + backyard.w).toBe(GRID_COLS);
});
});
+298
View File
@@ -0,0 +1,298 @@
/**
* Masonry live grid -- custom-group draggable layout.
*
* Verifies natural-aspect tile sizing and that a saved layout the current
* version cannot read is regenerated cleanly. The grid renders only for a
* custom camera group (here: "outdoor") on desktop; mobile keeps the static
* grid, which the @mobile block below guards.
*/
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
import {
cameraBoxes,
persistedLayoutKey,
readLayout,
seedLayout,
} from "../helpers/grid-layout";
const GROUP = "outdoor"; // custom group: front_door + backyard
test.describe("Masonry live grid @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Draggable masonry grid is desktop-only",
);
test("custom group renders its cameras in the draggable grid", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
});
test("tiles render at their camera's natural aspect ratio", async ({
frigateApp,
}) => {
// backyard is 9:16, which bucketed mode would snap to an 8:9 tile
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await seedLayout(frigateApp.page, "naturalAspectLayout:admin", true);
await frigateApp.page.reload();
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
const { front_door: landscape, backyard: portrait } = await cameraBoxes(
frigateApp.page,
["front_door", "backyard"] as const,
"card",
);
expect(landscape.w / landscape.h).toBeCloseTo(16 / 9, 1);
expect(portrait.w / portrait.h).toBeCloseTo(9 / 16, 1);
});
test("dragging a tile does not shove other tiles far away", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await live.editLayoutButton.click();
const cameras = ["front_door", "backyard"] as const;
const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes(
frigateApp.page,
cameras,
"card",
);
// Drag backyard onto front_door's position (a deliberate collision). With
// free-placement + prevent-collision, front_door must NOT be shoved down.
const from = {
x: draggedBox.x + draggedBox.w / 2,
y: draggedBox.y + draggedBox.h / 2,
};
const to = {
x: fixedBefore.x + fixedBefore.w / 2,
y: fixedBefore.y + fixedBefore.h / 2,
};
await frigateApp.page.mouse.move(from.x, from.y);
await frigateApp.page.mouse.down();
await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 });
await frigateApp.page.mouse.up();
const { front_door: fixedAfter } = await cameraBoxes(
frigateApp.page,
cameras,
"card",
);
// Allow a few px of snap; a collision-push would move it a whole tile down.
expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40);
});
test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({
frigateApp,
}) => {
// A lone top tile has room to grow sideways, which is what exposed the bug:
// a top-edge handle let width grow while height stayed clamped at y=0.
await frigateApp.installDefaults({
config: { camera_groups: { outdoor: { cameras: ["front_door"] } } },
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await live.editLayoutButton.click();
const tile = frigateApp.page.locator(".react-grid-item", {
has: frigateApp.page.locator("[data-camera='front_door']"),
});
const only = ["front_door"] as const;
const { front_door: before } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
const aspect = before.w / before.h;
// Regression: if a top-edge handle is exposed, dragging it up/out must NOT
// distort the aspect (the old bug grew width while height stayed clamped).
const ne = tile.locator(".react-resizable-handle-ne");
if (await ne.count()) {
await ne.dragTo(tile, {
force: true,
targetPosition: { x: 1000, y: -160 },
});
const { front_door: afterNe } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
// It must actually resize (not a silent no-op) AND keep its aspect.
expect(afterNe.w).toBeGreaterThan(before.w);
expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2);
}
// Positive: growing from the bottom-right corner resizes and keeps aspect.
const se = tile.locator(".react-resizable-handle-se");
await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } });
const { front_door: grown } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
expect(grown.w).toBeGreaterThan(before.w);
expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2);
});
test("the grid keeps its measured width after a back navigation", async ({
frigateApp,
}) => {
// The grid sizes itself from window.innerWidth until its container is
// measured. On a warm back navigation nothing re-renders after that
// container mounts, so an observer that never attaches leaves every tile
// sized against the full window: the layout widens by the sidebar's width
// and the rightmost column clips on a full row.
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// total width the tiles span; tracks the width the grid laid out against
const span = () =>
frigateApp.page.evaluate(() => {
const tiles = [...document.querySelectorAll(".react-grid-item")];
if (!tiles.length) {
return null;
}
const rects = tiles.map((tile) => tile.getBoundingClientRect());
return +(
Math.max(...rects.map((r) => r.right)) -
Math.min(...rects.map((r) => r.left))
).toFixed(1);
});
let fresh: number | null = null;
await expect
.poll(async () => (fresh = await span()), { timeout: 10_000 })
.not.toBeNull();
await live.cameraCard("front_door").first().click();
await expect(frigateApp.page).toHaveURL(/#front_door/);
await frigateApp.page.goBack();
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// the layout must settle back to the measured width, not window.innerWidth
await expect
.poll(span, { timeout: 10_000 })
.toBeLessThanOrEqual(fresh! + 2);
});
test("a camera added to a saved layout fills an open column", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// one tall tile in the first column; backyard is missing from the layout
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, {
version: 2,
naturalAspect: false,
layout: [{ i: "front_door", x: 0, y: 0, w: 32, h: 400 }],
});
await frigateApp.page.reload();
await expect
.poll(async () => {
const stored = await readLayout(frigateApp.page, key);
return stored?.layout.find((item) => item.i === "backyard");
})
.toMatchObject({ x: 32, y: 0 });
});
test("saved layout from an unreadable version regenerates without error", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// A bare array is converted rather than discarded (covered in
// live-grid-aspect-modes), so use a version the current grid cannot read.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, {
version: 1,
naturalAspect: false,
layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }],
});
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
// Grid regenerated; both cameras still render and the error collector
// (frigate-test fixture) catches any crash.
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
// The app must have replaced the value it could not read. Without this the
// test would still pass against a key the app never touches.
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBeGreaterThan(1);
});
});
test.describe("Masonry live grid on mobile @critical @mobile", () => {
test("custom group keeps the static grid, with no draggable layout", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, false);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
// isMobileOnly routes around DraggableGridLayout entirely, so neither the
// grid items nor the edit-layout toggle may appear.
await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0);
await expect(live.editLayoutButton).toHaveCount(0);
});
});
@@ -217,6 +217,23 @@ test.describe("Detection models settings @high", () => {
).toBeDisabled(); ).toBeDisabled();
}); });
test("shareable hardware another model uses can still be picked", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] },
{ scene: "outdoor", devices: ["openvino:GPU.1"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").first(),
).toBeEnabled();
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"used by outdoor",
);
});
test("adding a model appends a card with an unused scene", async ({ test("adding a model appends a card with an unused scene", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -248,15 +265,13 @@ test.describe("Detection models settings @high", () => {
test("a saved Frigate+ model opens on the Frigate+ tab", async ({ test("a saved Frigate+ model opens on the Frigate+ tab", async ({
frigateApp, frigateApp,
}) => { }) => {
// the backend resolves plus:// to a cache path before serving the config
// back, so the plus metadata is the only signal the model is a Plus one
await installRoutes( await installRoutes(
frigateApp.page, frigateApp.page,
[ [
{ {
scene: "all", scene: "all",
devices: ["openvino:GPU.0"], devices: ["openvino:GPU.0"],
path: "/config/model_cache/abc123", path: "plus://abc123",
plus: PLUS_MODEL, plus: PLUS_MODEL,
}, },
], ],
@@ -302,6 +317,65 @@ test.describe("Detection models settings @high", () => {
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123"); expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
}); });
test("saving a Frigate+ model keeps its reference without the Frigate+ fields", async ({
frigateApp,
}) => {
// the backend fills these in from the Frigate+ model info when it loads
const saves = await installRoutes(
frigateApp.page,
[
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "plus://abc123",
plus: PLUS_MODEL,
width: 320,
height: 320,
input_tensor: "nchw",
input_dtype: "float",
model_type: "yolo-generic",
},
],
true,
);
await openPage(frigateApp);
await frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").click();
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
const model = saves.at(-1)?.config_data?.models?.[0];
expect(model?.path).toBe("plus://abc123");
expect(model?.devices).toEqual(["openvino:GPU.0", "openvino:GPU.1"]);
expect(model).not.toHaveProperty("width");
expect(model).not.toHaveProperty("input_tensor");
expect(model).not.toHaveProperty("model_type");
// a leftover dtype from a custom model must not override the int default
expect(model).not.toHaveProperty("input_dtype");
});
test("a Frigate+ model only shows its path without a Frigate+ API key", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "plus://abc123",
width: 320,
height: 320,
},
]);
await openPage(frigateApp);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("Custom object detector model path");
await expect(root).not.toContainText("Object detection model input width");
await expect(root).not.toContainText(
"Label map for custom object detector",
);
});
test("a Frigate+ Hailo model is listed by the device it was built for", async ({ test("a Frigate+ Hailo model is listed by the device it was built for", async ({
frigateApp, frigateApp,
}) => { }) => {
+59
View File
@@ -124,6 +124,65 @@ test.describe("genai provider settings @medium", () => {
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible(); await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
}); });
test("switching back to the saved provider drops the other provider's model", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
{
provider: "openai",
model: "gpt-4o",
roles: ["descriptions"],
},
{ models: ["gpt-4o", "qwen3"], supports_transcription: true },
);
await frigateApp.goto(SETTINGS_URL);
const model = frigateApp.page.locator(`#root_${ENTRY}_model`);
const provider = frigateApp.page.locator(`#root_${ENTRY}_provider`);
await expect(model).toHaveText("gpt-4o");
await provider.click();
await frigateApp.page.getByRole("option", { name: "llamacpp" }).click();
await model.click();
await frigateApp.page.getByRole("option", { name: "qwen3" }).click();
await expect(model).toHaveText("qwen3");
// the model picked for llamacpp must not carry over to openai, and the
// saved one isn't filled back in since the endpoint may have changed
await provider.click();
await frigateApp.page
.getByRole("option", { name: "openai", exact: true })
.click();
await expect(model).toHaveText(MODEL_PLACEHOLDER);
});
test("undo after switching provider brings back the saved model", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
{
provider: "openai",
model: "gpt-4o",
roles: ["descriptions"],
},
{ models: ["gpt-4o"], supports_transcription: true },
);
await frigateApp.goto(SETTINGS_URL);
const model = frigateApp.page.locator(`#root_${ENTRY}_model`);
await frigateApp.page.locator(`#root_${ENTRY}_provider`).click();
await frigateApp.page.getByRole("option", { name: "llamacpp" }).click();
await expect(model).toHaveText(MODEL_PLACEHOLDER);
await frigateApp.page.getByRole("button", { name: "Undo" }).click();
await expect(model).toHaveText("gpt-4o");
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
});
test("picking a model that cannot transcribe strips the role", async ({ test("picking a model that cannot transcribe strips the role", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -13,7 +13,26 @@ import type { Page } from "@playwright/test";
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin"; const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
const STREAMING_KEY = "streaming-settings:admin"; const STREAMING_KEY = "streaming-settings:admin";
const OUTDOOR_LAYOUT = [ // the shape DraggableGridLayout writes
const OUTDOOR_LAYOUT = {
version: 2,
naturalAspect: false,
layout: [
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
{ i: "backyard", x: 32, y: 0, w: 32, h: 72 },
],
};
const NATURAL_OUTDOOR_LAYOUT = {
version: 2,
naturalAspect: true,
layout: [
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
{ i: "backyard", x: 32, y: 0, w: 24, h: 96 },
],
};
const LEGACY_OUTDOOR_LAYOUT = [
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 }, { i: "front_door", x: 0, y: 0, w: 6, h: 4 },
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 }, { i: "backyard", x: 6, y: 0, w: 6, h: 4 },
]; ];
@@ -159,6 +178,160 @@ test.describe("UI settings import/export @medium", () => {
expect(payload.sections.preferences.playbackRate).toBe(2); expect(payload.sections.preferences.playbackRate).toBe(2);
}); });
test("exports only layouts built for the current tile sizing mode", async ({
frigateApp,
}) => {
// a group not opened since the mode changed still holds a layout from
// the other mode, which would import into a mode that cannot show it
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, {
[OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT,
"default-draggable-layout:admin": NATURAL_OUTDOOR_LAYOUT,
"naturalAspectLayout:admin": true,
});
const downloadPromise = frigateApp.page.waitForEvent("download");
await frigateApp.page
.getByRole("button", { name: "Export Settings" })
.click();
const download = await downloadPromise;
const payload = JSON.parse(readFileSync((await download.path())!, "utf-8"));
expect(payload.sections.layouts).toEqual({
default: NATURAL_OUTDOOR_LAYOUT,
});
});
test("toggling tile sizing mode clears stored layouts", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "The setting is hidden on phones");
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT });
await frigateApp.page.locator("#natural-aspect-desktop").click();
await frigateApp.page
.getByRole("alertdialog")
.getByRole("button", { name: "Enable" })
.click();
await expect
.poll(() => readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY))
.toBeNull();
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
true,
);
});
test("round-trips a layout left unconverted by an upgrade", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
// DraggableGridLayout rewrites a pre-0.19 layout only when that group's
// dashboard is opened, so exporting first carries the bare array into the
// file. Import must accept it back rather than rejecting the whole file.
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, {
[OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT,
"playbackRate:admin": 2,
});
const downloadPromise = frigateApp.page.waitForEvent("download");
await frigateApp.page
.getByRole("button", { name: "Export Settings" })
.click();
const download = await downloadPromise;
const contents = readFileSync((await download.path())!, "utf-8");
expect(JSON.parse(contents).sections.layouts.outdoor).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
await clearIdb(frigateApp.page);
await chooseImportText(frigateApp.page, contents);
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
// the rest of the file must survive alongside it
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
});
test("legacy layouts import turns natural aspect off so they display", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
// Bare-array layouts only render in bucketed mode; with natural aspect on
// they would be discarded and regenerated on the next dashboard visit. The
// import applies the mode the layouts were built for, and the file's own
// naturalAspectLayout preference must not override that.
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
await chooseImportFile(
frigateApp.page,
importPayload({
sections: {
layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT },
streaming: {},
preferences: { naturalAspectLayout: true },
},
}),
);
const note = frigateApp.page.getByText(/standard tile sizing/);
await expect(note).toBeVisible();
// the note is about the layouts section, so it follows its switch
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
await expect(note).toBeHidden();
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
await expect(note).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
false,
);
});
test("natural aspect layouts import turns the setting on", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
await frigateApp.goto("/settings?page=uiSettings");
await chooseImportFile(
frigateApp.page,
importPayload({
sections: {
layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT },
streaming: {},
preferences: {},
},
}),
);
await expect(
frigateApp.page.getByText(/camera aspect ratio tile sizing/),
).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
NATURAL_OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
true,
);
});
test("omits settings that were never stored", async ({ frigateApp }) => { test("omits settings that were never stored", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=uiSettings"); await frigateApp.goto("/settings?page=uiSettings");
@@ -190,12 +363,15 @@ test.describe("UI settings import/export @medium", () => {
await expect( await expect(
frigateApp.page.getByText("UI preferences (2 settings)"), frigateApp.page.getByText("UI preferences (2 settings)"),
).toBeVisible(); ).toBeVisible();
await expect(frigateApp.page.getByText(/patio/)).toBeVisible(); // patio is layout-only, so its warning follows the layouts section
await expect(frigateApp.page.getByText(/patio/)).toBeVisible({
visible: !frigateApp.isMobile,
});
await confirmImport(frigateApp.page); await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
); );
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS, STREAMING_SETTINGS,
@@ -206,6 +382,7 @@ test.describe("UI settings import/export @medium", () => {
test("hides the unknown-group warning when layouts are switched off", async ({ test("hides the unknown-group warning when layouts are switched off", async ({
frigateApp, frigateApp,
}) => { }) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
await frigateApp.goto("/settings?page=uiSettings"); await frigateApp.goto("/settings?page=uiSettings");
// patio is a layout-only group absent from this server, so the warning // patio is a layout-only group absent from this server, so the warning
@@ -235,7 +412,39 @@ test.describe("UI settings import/export @medium", () => {
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({}); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
);
});
test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Phone-only");
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false });
await chooseImportFile(frigateApp.page, importPayload());
await expect(
frigateApp.page.getByText(/aren't imported on phones/),
).toBeVisible();
// the section is still listed, but cannot be switched on
await expect(
frigateApp.page.getByText("Camera group layouts (2 groups)"),
).toBeVisible();
await expect(
frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'),
).toBeDisabled();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS,
);
// a layouts import is what flips this, so it must stay put
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
false,
); );
}); });
@@ -325,7 +534,7 @@ test.describe("UI settings import/export @medium", () => {
await confirmImport(frigateApp.page); await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
); );
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS, STREAMING_SETTINGS,
+258 -121
View File
@@ -1,7 +1,8 @@
/** /**
* Health tab tests -- MEDIUM tier. * Health tab tests -- MEDIUM tier.
* *
* Default tab, notice list rendering, dismiss, empty state, update notice. * Default tab, notice list rendering, acknowledge and mute, empty state,
* update notice.
*/ */
import { test, expect } from "../fixtures/frigate-test"; import { test, expect } from "../fixtures/frigate-test";
@@ -23,7 +24,9 @@ const ERROR_NOTICE = {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW, last_seen: NOW,
count: 2, count: 2,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}; };
const EVENT_NOTICE = { const EVENT_NOTICE = {
@@ -37,7 +40,9 @@ const EVENT_NOTICE = {
first_seen: NOW - 7200, first_seen: NOW - 7200,
last_seen: NOW - 60, last_seen: NOW - 60,
count: 3, count: 3,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}; };
test.describe("System — Health tab @medium", () => { test.describe("System — Health tab @medium", () => {
@@ -63,54 +68,61 @@ test.describe("System — Health tab @medium", () => {
"Downloading model.onnx for yolo failed: timeout", "Downloading model.onnx for yolo failed: timeout",
); );
await expect(rows.nth(0)).toContainText("2 times"); await expect(rows.nth(0)).toContainText("2 times");
await expect(
rows.nth(0).getByRole("button", { name: "Dismiss" }),
).toBeVisible();
await expect(rows.nth(1)).toContainText("Detector ov was restarted"); await expect(rows.nth(1)).toContainText("Detector ov was restarted");
await expect(rows.nth(1)).toContainText("3 times"); await expect(rows.nth(1)).toContainText("3 times");
await expect( for (const index of [0, 1]) {
rows.nth(1).getByRole("button", { name: "Dismiss" }), await expect(
).toBeVisible(); rows.nth(index).getByRole("button", { name: "Acknowledge" }),
).toBeVisible();
await expect(
rows.nth(index).getByRole("button", { name: "Mute", exact: true }),
).toBeVisible();
}
}); });
test("dismiss posts and removes the row", async ({ frigateApp }) => { for (const action of ["acknowledge", "mute"] as const) {
await frigateApp.installDefaults({ test(`${action} posts and removes the row`, async ({ frigateApp }) => {
stats: QUIET_STATS, await frigateApp.installDefaults({
notices: [EVENT_NOTICE], stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
// the list shrinks after the POST so the refetch shows the row gone
let hidden = false;
await frigateApp.page.route("**/api/notices", (route) =>
route.fulfill({ json: hidden ? [] : [EVENT_NOTICE] }),
);
await frigateApp.page.route(
`**/api/notices/detector_stuck/${action}`,
(route) => {
hidden = true;
return route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/system#health");
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes(`/api/notices/detector_stuck/${action}`) &&
req.method() === "POST",
);
await frigateApp.page
.getByTestId("health-problem-notice:detector_stuck")
.getByRole("button", {
name: action === "mute" ? "Mute" : "Acknowledge",
exact: true,
})
.click();
await request;
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible();
}); });
}
// the list shrinks after the dismiss so the refetch shows the row gone
let dismissed = false;
await frigateApp.page.route("**/api/notices", (route) =>
route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
);
await frigateApp.page.route(
"**/api/notices/detector_stuck/dismiss",
(route) => {
dismissed = true;
return route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/system#health");
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes("/api/notices/detector_stuck/dismiss") &&
req.method() === "POST",
);
await frigateApp.page
.getByTestId("health-problem-notice:detector_stuck")
.getByRole("button", { name: "Dismiss" })
.click();
await request;
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible();
});
test("empty state with no notices", async ({ frigateApp }) => { test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({ stats: QUIET_STATS });
@@ -140,7 +152,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 3600, first_seen: NOW - 3600,
last_seen: NOW, last_seen: NOW,
count: 1, count: 1,
dismissed_at: null, acknowledgeable: false,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -156,10 +170,23 @@ test.describe("System — Health tab @medium", () => {
"href", "href",
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0", "https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
); );
await expect(row.getByRole("button", { name: "Dismiss" })).toBeVisible(); await expect(
row.getByRole("button", { name: "Mute", exact: true }),
).toBeVisible();
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
0,
);
}); });
test("the filter shows dismissed notices without a Dismiss button", async ({ const ACKNOWLEDGED_NOTICE = {
...EVENT_NOTICE,
id: "detector_stuck:coral",
params: { detector: "coral" },
acknowledged_at: NOW - 60,
};
const MUTED_NOTICE = { ...ERROR_NOTICE, muted_at: NOW - 120 };
test("the filter shows hidden notices marked by how they were hidden", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
@@ -169,33 +196,85 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true", url.searchParams.get("include_hidden") === "true",
(route) => (route) =>
route.fulfill({ route.fulfill({
json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }], json: [EVENT_NOTICE, ACKNOWLEDGED_NOTICE, MUTED_NOTICE],
}), }),
); );
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
const showDismissed = frigateApp.page.getByRole("switch", { const showHidden = frigateApp.page.getByRole("switch", {
name: "Show dismissed", name: "Show hidden",
}); });
await expect(showDismissed).toHaveAttribute("aria-checked", "false"); await expect(showHidden).toHaveAttribute("aria-checked", "false");
await showDismissed.click(); await showHidden.click();
// mobile opens the filter as a modal drawer, which hides the rows' roles
await frigateApp.page.keyboard.press("Escape");
const row = frigateApp.page.getByTestId( const acknowledged = frigateApp.page.getByTestId(
"health-problem-notice:detector_stuck:coral",
);
await expect(acknowledged).toBeVisible({ timeout: 15_000 });
await expect(acknowledged).toContainText("Acknowledged");
await expect(
acknowledged.getByRole("button", { name: "Show again" }),
).toBeVisible();
await expect(
acknowledged.getByRole("button", { name: "Acknowledge" }),
).toHaveCount(0);
const muted = frigateApp.page.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx", "health-problem-notice:model_download_failed:yolo/model.onnx",
); );
await expect(row).toBeVisible({ timeout: 15_000 }); await expect(muted).toContainText("Muted");
await expect(row).toContainText("Dismissed"); await expect(muted.getByRole("button", { name: "Unmute" })).toBeVisible();
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0); await expect(
muted.getByRole("button", { name: "Mute", exact: true }),
).toHaveCount(0);
await showDismissed.click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await expect(row).toHaveCount(0); await showHidden.click();
await expect(muted).toHaveCount(0);
}); });
test("clear dismissed deletes the dismissed rows after confirming", async ({ test("unmute deletes the row's hidden state", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS, notices: [] });
await frigateApp.page.route(
(url) =>
url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_hidden") === "true",
(route) => route.fulfill({ json: [MUTED_NOTICE] }),
);
await frigateApp.page.route(
"**/api/notices/model_download_failed:yolo/model.onnx/hidden",
(route) => route.fulfill({ json: { success: true } }),
);
await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
await frigateApp.page.keyboard.press("Escape");
const request = frigateApp.page.waitForRequest(
(req) =>
req
.url()
.endsWith(
"/api/notices/model_download_failed:yolo/model.onnx/hidden",
) && req.method() === "DELETE",
);
await frigateApp.page
.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx",
)
.getByRole("button", { name: "Unmute" })
.click({ timeout: 15_000 });
await request;
});
test("show all again unhides every row after confirming", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
@@ -203,29 +282,25 @@ test.describe("System — Health tab @medium", () => {
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
// the history loses its dismissed row once the DELETE lands // the hidden list loses its muted row once the DELETE lands
let cleared = false; let cleared = false;
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true", url.searchParams.get("include_hidden") === "true",
(route) => (route) =>
route.fulfill({ route.fulfill({
json: cleared json: cleared ? [EVENT_NOTICE] : [EVENT_NOTICE, MUTED_NOTICE],
? [EVENT_NOTICE]
: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
}), }),
); );
await frigateApp.page.route("**/api/notices/dismissed", (route) => { await frigateApp.page.route("**/api/notices/hidden", (route) => {
cleared = true; cleared = true;
return route.fulfill({ json: { success: true } }); return route.fulfill({ json: { success: true } });
}); });
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
.getByRole("switch", { name: "Show dismissed" })
.click();
const row = frigateApp.page.getByTestId( const row = frigateApp.page.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx", "health-problem-notice:model_download_failed:yolo/model.onnx",
); );
@@ -233,23 +308,20 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.page.keyboard.press("Escape"); await frigateApp.page.keyboard.press("Escape");
await frigateApp.page await frigateApp.page
.getByRole("button", { name: "Clear dismissed" }) .getByRole("button", { name: "Show all again" })
.click(); .click();
const request = frigateApp.page.waitForRequest( const request = frigateApp.page.waitForRequest(
(req) => (req) =>
req.url().endsWith("/api/notices/dismissed") && req.url().endsWith("/api/notices/hidden") && req.method() === "DELETE",
req.method() === "DELETE",
); );
await frigateApp.page await frigateApp.page
.getByRole("alertdialog") .getByRole("alertdialog")
.getByRole("button", { name: "Clear dismissed" }) .getByRole("button", { name: "Show all again" })
.click(); .click();
await request; await request;
await expect(row).toHaveCount(0); await expect(row).toHaveCount(0);
await expect( await expect(frigateApp.page.getByText("No hidden notices")).toBeVisible();
frigateApp.page.getByText("No dismissed notices"),
).toBeVisible();
}); });
test("severity switches hide notices of that severity", async ({ test("severity switches hide notices of that severity", async ({
@@ -290,7 +362,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: start, first_seen: start,
last_seen: start + 60, last_seen: start + 60,
count, count,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}); });
await frigateApp.installDefaults({ await frigateApp.installDefaults({
stats: QUIET_STATS, stats: QUIET_STATS,
@@ -332,7 +406,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW - 600, last_seen: NOW - 600,
count: 1, count: 1,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -363,7 +439,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 600, first_seen: NOW - 600,
last_seen: NOW - 600, last_seen: NOW - 600,
count: 1, count: 1,
dismissed_at: null, acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
}, },
], ],
}); });
@@ -695,7 +773,7 @@ test.describe("System — Health notices sources @medium", () => {
// the status bar shows a problem, so stats have loaded // the status bar shows a problem, so stats have loaded
await expect( await expect(
frigateApp.page.getByText("Front Door is offline"), frigateApp.page.getByText("Recordings are being deleted"),
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
await expect( await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"), frigateApp.page.locator("[data-testid^='health-problem-']"),
@@ -1046,7 +1124,7 @@ test.describe("System — Health notices sources @medium", () => {
await expect(frigateApp.page).toHaveURL(/\/system#health/); await expect(frigateApp.page).toHaveURL(/\/system#health/);
}); });
test("status bar counts undismissed notices next to the health text", async ({ test("status bar counts shown notices next to the health text", async ({
frigateApp, frigateApp,
}) => { }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
@@ -1089,41 +1167,61 @@ test.describe("System — Health notices sources @medium", () => {
await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0); await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0);
}); });
test("a config row can be dismissed", async ({ frigateApp }) => { // the slow detector warning is added before the offline error
const id = "config:detect:fps-greater-than-five:camera.garage"; const TWO_PROBLEM_STATS = {
await frigateApp.installDefaults({ detectors: { cpu: { inference_speed: 60 } },
config: { cameras: { front_door: { camera_fps: 0 } },
cameras: { };
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
},
},
stats: QUIET_STATS,
});
// the list gains the dismissal after the POST so the refetch hides the row test("status bar collapses several problems behind the most severe", async ({
let dismissed = false; frigateApp,
await frigateApp.page.route(`**/api/notices/${id}/dismiss`, (route) => { }) => {
dismissed = true; test.skip(frigateApp.isMobile, "Status bar is desktop-only");
return route.fulfill({ json: { success: true } }); await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
}); await frigateApp.goto("/");
await frigateApp.page.route("**/api/notices/dismissed_checks", (route) =>
route.fulfill({ json: dismissed ? [{ id, dismissed_at: NOW }] : [] }),
);
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(`health-problem-${id}`); const summary = frigateApp.page.getByRole("button", {
await expect(row).toBeVisible({ timeout: 15_000 }); name: "Front Door is offline +1",
const request = frigateApp.page.waitForRequest( });
(req) => await expect(summary).toBeVisible({ timeout: 15_000 });
req.url().includes(`/api/notices/${id}/dismiss`) && await expect(frigateApp.page.getByText("Cpu is slow")).toHaveCount(0);
req.method() === "POST",
); await summary.click();
await row.getByRole("button", { name: "Dismiss" }).click(); const list = frigateApp.page.getByTestId("status-message-list");
await request; await expect(list.getByRole("link")).toHaveText([
await expect(row).toHaveCount(0); "Front Door is offline",
"Cpu is slow (60 ms)",
]);
await list.getByRole("link", { name: "Cpu is slow (60 ms)" }).click();
await expect(frigateApp.page).toHaveURL(/\/system#general/);
await expect(list).toHaveCount(0);
}); });
test("dismissed config rows move to the dismissed list", async ({ test("mobile status drawer stacks every problem", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
await frigateApp.goto("/");
await frigateApp.page
.getByTestId("status-alert-trigger")
.click({ timeout: 15_000 });
const items = frigateApp.page
.getByTestId("status-message-list")
.getByRole("link");
await expect(items).toHaveText([
"Front Door is offline",
"Cpu is slow (60 ms)",
]);
const [first, second] = await Promise.all([
items.nth(0).boundingBox(),
items.nth(1).boundingBox(),
]);
expect(second!.y).toBeGreaterThan(first!.y);
});
test("a config row can be muted but not acknowledged", async ({
frigateApp, frigateApp,
}) => { }) => {
const id = "config:detect:fps-greater-than-five:camera.garage"; const id = "config:detect:fps-greater-than-five:camera.garage";
@@ -1134,12 +1232,49 @@ test.describe("System — Health notices sources @medium", () => {
}, },
}, },
stats: QUIET_STATS, stats: QUIET_STATS,
dismissedChecks: [{ id, dismissed_at: NOW - 120 }], });
// the list gains the mute after the POST so the refetch hides the row
let muted = false;
await frigateApp.page.route(`**/api/notices/${id}/mute`, (route) => {
muted = true;
return route.fulfill({ json: { success: true } });
});
await frigateApp.page.route("**/api/notices/muted_checks", (route) =>
route.fulfill({ json: muted ? [{ id, muted_at: NOW }] : [] }),
);
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
0,
);
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes(`/api/notices/${id}/mute`) &&
req.method() === "POST",
);
await row.getByRole("button", { name: "Mute", exact: true }).click();
await request;
await expect(row).toHaveCount(0);
});
test("muted config rows move to the hidden list", async ({ frigateApp }) => {
const id = "config:detect:fps-greater-than-five:camera.garage";
await frigateApp.installDefaults({
config: {
cameras: {
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
},
},
stats: QUIET_STATS,
mutedChecks: [{ id, muted_at: NOW - 120 }],
}); });
await frigateApp.page.route( await frigateApp.page.route(
(url) => (url) =>
url.pathname.endsWith("/api/notices") && url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true", url.searchParams.get("include_hidden") === "true",
(route) => route.fulfill({ json: [] }), (route) => route.fulfill({ json: [] }),
); );
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
@@ -1153,12 +1288,14 @@ test.describe("System — Health notices sources @medium", () => {
await expect(row).toHaveCount(0); await expect(row).toHaveCount(0);
await frigateApp.page.getByRole("button", { name: "Filter" }).click(); await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
.getByRole("switch", { name: "Show dismissed" }) await frigateApp.page.keyboard.press("Escape");
.click();
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await expect(row).toContainText("Dismissed"); await expect(row).toContainText("Muted");
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0); await expect(row.getByRole("button", { name: "Unmute" })).toBeVisible();
await expect(
row.getByRole("button", { name: "Mute", exact: true }),
).toHaveCount(0);
}); });
}); });
@@ -15,10 +15,6 @@
"bandwidth": { "bandwidth": {
"title": "العرض الترددي:", "title": "العرض الترددي:",
"short": "العرض الترددي" "short": "العرض الترددي"
},
"latency": {
"title": "التأخير:",
"value": "{{seconds}} ثانية"
} }
}, },
"streamOffline": { "streamOffline": {
+40 -6
View File
@@ -21,8 +21,8 @@
"1hour": "1 гадзіна", "1hour": "1 гадзіна",
"12hours": "12 гадзін", "12hours": "12 гадзін",
"24hours": "24 гадзіны", "24hours": "24 гадзіны",
"pm": "вечара", "pm": "pm",
"am": "раніцы", "am": "am",
"yr": "{{time}} г", "yr": "{{time}} г",
"year_one": "{{time}} год", "year_one": "{{time}} год",
"year_few": "{{time}} гады", "year_few": "{{time}} гады",
@@ -168,13 +168,16 @@
"saveAll": "Захаваць усё", "saveAll": "Захаваць усё",
"savingAll": "Захаванне ўсяго…", "savingAll": "Захаванне ўсяго…",
"undoAll": "Адрабіць усё", "undoAll": "Адрабіць усё",
"retry": "Паўтарыць" "retry": "Паўтарыць",
"expand": "Разгарнуць",
"collapse": "Згарнуць",
"reload": "Перазагрузіць"
}, },
"menu": { "menu": {
"system": "Сістэма", "system": "Сістэма",
"systemMetrics": "Сістэмныя метрыкі", "systemMetrics": "Стан і метрыкі",
"configuration": "Канфігурацыя", "configuration": "Канфігурацыя",
"systemLogs": "Журналы сістэмы", "systemLogs": "Журналы",
"profiles": "Профілі", "profiles": "Профілі",
"settings": "Налады", "settings": "Налады",
"configurationEditor": "Рэдактар канфігурацыі", "configurationEditor": "Рэдактар канфігурацыі",
@@ -225,7 +228,7 @@
"withSystem": { "withSystem": {
"label": "Выкарыстоўваць мову з налад сістэмы" "label": "Выкарыстоўваць мову з налад сістэмы"
}, },
"be": "Беларуская" "be": "Беларуская (беларуская)"
}, },
"appearance": "Выгляд", "appearance": "Выгляд",
"darkMode": { "darkMode": {
@@ -328,5 +331,36 @@
"validation_errors": "Памылкі праверкі", "validation_errors": "Памылкі праверкі",
"credentialField": { "credentialField": {
"savedPlaceholder": "Захавана - пакіньце пустым, каб не мяняць" "savedPlaceholder": "Захавана - пакіньце пустым, каб не мяняць"
},
"error": {
"title": "Гэтая старонка перастала працаваць",
"desc": "На старонцы адбылася нечаканая памылка. Звычайна дапамагае перазагрузка. Калі гэта паўтараецца, скапіюйце падрабязнасці і дадайце іх да абмеркавання на GitHub.",
"staleTitle": "Патрэбна перазагрузка",
"staleDesc": "Частка інтэрфейсу не загрузілася - звычайна гэта значыць, што Frigate абнавіўся, пакуль гэтая ўкладка была адкрытая. Перазагрузіце, каб атрымаць новую версію.",
"partial": "Частка інтэрфейсу перастала працаваць.",
"copyDetails": "Скапіяваць падрабязнасці",
"copyFailed": "Не ўдалося скапіяваць падрабязнасці ў буфер абмену"
},
"commandMenu": {
"title": "Меню каманд",
"description": "Пошук старонак, камер, налад і дзеянняў",
"placeholder": "Пошук старонак, камер, налад і дзеянняў",
"empty": "Няма супадзенняў",
"hint": "{{key}} або / каб адкрыць, Enter каб выканаць",
"section": {
"recent": "Апошнія",
"pages": "Старонкі",
"cameras": "Камеры",
"cameraGroups": "Групы камер",
"settings": "Налады",
"actions": "Дзеянні"
},
"camera": {
"live": "Эфір",
"review": "Разгляд"
},
"action": {
"toggleTheme": "Пераключыць светлы і цёмны рэжым"
}
} }
} }
+17 -1
View File
@@ -137,6 +137,15 @@
"queueingExport": "Экспарт ставіцца ў чаргу…", "queueingExport": "Экспарт ставіцца ў чаргу…",
"previewExport": "Перадпрагляд экспарту", "previewExport": "Перадпрагляд экспарту",
"useThisRange": "Ужыць гэты прамежак" "useThisRange": "Ужыць гэты прамежак"
},
"stream": {
"label": "Якасць",
"auto": "Аўта",
"main": "Зыходная",
"sub": "Нізкая",
"autoDesc": "Выкарыстоўвае асноўную плынь, а дзе яна недаступная - пераходзіць на дадатковую плынь ніжэйшай якасці.",
"mainDesc": "Экспартуе толькі асноўную плынь зыходнай якасці. Прамежкі часу, дзе асноўная плынь недаступная, будуць адсутнічаць.",
"subDesc": "Экспартуе толькі дадатковую плынь ніжэйшай якасці. Прамежкі часу, дзе дадатковая плынь недаступная, будуць адсутнічаць."
} }
}, },
"streaming": { "streaming": {
@@ -190,7 +199,14 @@
"export": "Экспарт", "export": "Экспарт",
"markAsReviewed": "Пазначыць як разгледжанае", "markAsReviewed": "Пазначыць як разгледжанае",
"markAsUnreviewed": "Пазначыць як неразгледжанае", "markAsUnreviewed": "Пазначыць як неразгледжанае",
"deleteNow": "Выдаліць зараз" "deleteNow": "Выдаліць зараз",
"generateDescription": "Стварыць апісанне"
},
"genaiDescription": {
"toast": {
"success": "Апісанне GenAI запытана для гэтага элемента разгляду.",
"error": "Не ўдалося запытаць апісанне: {{error}}"
}
} }
}, },
"imagePicker": { "imagePicker": {
+16 -9
View File
@@ -22,14 +22,6 @@
"title": "Паласа прапускання:", "title": "Паласа прапускання:",
"short": "Паласа" "short": "Паласа"
}, },
"latency": {
"title": "Затрымка:",
"value": "{{seconds}} сек",
"short": {
"title": "Затрымка",
"value": "{{seconds}} с"
}
},
"totalFrames": "Усяго кадраў:", "totalFrames": "Усяго кадраў:",
"droppedFrames": { "droppedFrames": {
"title": "Страчаныя кадры:", "title": "Страчаныя кадры:",
@@ -46,7 +38,22 @@
"submittedFrigatePlus": "Кадр паспяхова адпраўлены ў Frigate+" "submittedFrigatePlus": "Кадр паспяхова адпраўлены ў Frigate+"
}, },
"error": { "error": {
"submitFrigatePlusFailed": "Не ўдалося адправіць кадр у Frigate+" "submitFrigatePlusFailed": "Не ўдалося адправіць кадр у Frigate+",
"playRecordingsFailed": "Не ўдалося прайграць запісы (памылка {{code}}): {{message}}"
} }
},
"quality": {
"auto": "Аўта",
"autoLow": "Прайграецца нізкая якасць (абмежаваная паласа прапускання)",
"autoLowCodec": "Прайграецца нізкая якасць (зыходная не падтрымліваецца гэтым браўзерам)",
"autoLowSaveData": "Прайграецца нізкая якасць (эканомія трафіку)",
"main": "Зыходная",
"sub": "Нізкая",
"notSupportedBrowser": "Не падтрымліваецца гэтым браўзерам",
"label": "Якасць",
"noAudio": "Без гуку",
"audioRate": "Гук {{rate}} кГц",
"audioCodecRate": "{{codec}} {{rate}} кГц",
"noRecordings": "Няма запісаў у гэтым прамежку часу"
} }
} }
+69 -1
View File
@@ -70,7 +70,7 @@
}, },
"objects": { "objects": {
"label": "Аб'екты-трыгеры", "label": "Аб'екты-трыгеры",
"description": "Спіс тыпаў аб'ектаў (з labelmap), якія могуць спрацаваць у гэтай зоне. Можа быць радком або спісам радкоў. Калі пуста, улічваюцца ўсе аб'екты." "description": "Спіс тыпаў аб'ектаў (з карты метак), якія могуць спрацаваць у гэтай зоне. Можа быць радком або спісам радкоў. Калі пуста, улічваюцца ўсе аб'екты."
} }
}, },
"label": "CameraConfig", "label": "CameraConfig",
@@ -120,6 +120,10 @@
"num_threads": { "num_threads": {
"label": "Патокі дэтэктавання", "label": "Патокі дэтэктавання",
"description": "Колькасць патокаў для апрацоўкі дэтэктавання гуку." "description": "Колькасць патокаў для апрацоўкі дэтэктавання гуку."
},
"labelmap": {
"label": "Змяненне карты метак гуку",
"description": "Перавызначэнні або пераназначэнні, якія аб'ядноўваюцца са стандартнай картай метак гуку."
} }
}, },
"audio_transcription": { "audio_transcription": {
@@ -147,6 +151,10 @@
"order": { "order": {
"label": "Пазіцыя", "label": "Пазіцыя",
"description": "Лічбавае становішча, якое вызначае парадак камеры ў раскладцы Birdseye." "description": "Лічбавае становішча, якое вызначае парадак камеры ў раскладцы Birdseye."
},
"modes": {
"label": "Тыпы актыўнасці",
"description": "Тыпы актыўнасці, пры якіх камеры паказваюцца ў Birdseye."
} }
}, },
"detect": { "detect": {
@@ -207,6 +215,10 @@
"annotation_offset": { "annotation_offset": {
"label": "Зрух анатацый", "label": "Зрух анатацый",
"description": "На колькі мілісекунд зрушыць прамавугольнікі аб'ектаў, каб яны супадалі з запісаным відэа: плыні «detect» і «record» рэдка ідэальна сінхронныя. Можа быць дадатным або адмоўным." "description": "На колькі мілісекунд зрушыць прамавугольнікі аб'ектаў, каб яны супадалі з запісаным відэа: плыні «detect» і «record» рэдка ідэальна сінхронныя. Можа быць дадатным або адмоўным."
},
"scene": {
"label": "Сцэна дэтэктавання",
"description": "Асяроддзе, на якое глядзіць гэтая камера; паводле яго выбіраецца, якая з наладжаных мадэлей на ёй працуе. Камеры са значэннем «all» выкарыстоўваюць мадэль, наладжаную са сцэнай «all»."
} }
}, },
"face_recognition": { "face_recognition": {
@@ -250,6 +262,10 @@
"record": { "record": {
"label": "Выходныя аргументы для record", "label": "Выходныя аргументы для record",
"description": "Прадвызначаныя выходныя аргументы для плыняў з роляй «record»." "description": "Прадвызначаныя выходныя аргументы для плыняў з роляй «record»."
},
"record_sub": {
"label": "Выходныя аргументы запісу дадатковай плыні",
"description": "Выходныя аргументы для плыняў з роляй «record_sub». Калі не зададзена, выкарыстоўваюцца выходныя аргументы запісу."
} }
}, },
"retry_interval": { "retry_interval": {
@@ -579,6 +595,54 @@
"enabled_in_config": { "enabled_in_config": {
"label": "Зыходны стан запісу", "label": "Зыходны стан запісу",
"description": "Паказвае, ці быў запіс уключаны ў зыходнай статычнай канфігурацыі." "description": "Паказвае, ці быў запіс уключаны ў зыходнай статычнай канфігурацыі."
},
"sub": {
"label": "Запіс дадатковай плыні",
"description": "Налады запісу другой плыні ніжэйшай якасці.",
"enabled": {
"label": "Уключыць запіс дадатковай плыні",
"description": "Уключыць запіс другой плыні ніжэйшай якасці для прайгравання з адаптыўнай якасцю і падоўжанага захоўвання."
},
"continuous": {
"label": "Бесперапыннае захоўванне дадатковай плыні",
"description": "Колькасць дзён захоўвання запісаў дадатковай плыні незалежна ад аб'ектаў пад адсочваннем або руху.",
"days": {
"label": "Дні захоўвання",
"description": "Дзён захоўвання запісаў."
}
},
"motion": {
"label": "Захоўванне дадатковай плыні па руху",
"description": "Колькасць дзён захоўвання запісаў дадатковай плыні, выкліканых рухам.",
"days": {
"label": "Дні захоўвання",
"description": "Дзён захоўвання запісаў."
}
},
"alerts": {
"label": "Захоўванне абвестак дадатковай плыні",
"description": "Налады захоўвання запісаў абвестак дадатковай плыні.",
"days": {
"label": "Дні захоўвання",
"description": "Колькасць дзён захоўвання запісаў падзей дэтэктавання."
},
"mode": {
"label": "Рэжым захоўвання",
"description": "Рэжым захоўвання: all (усе сегменты), motion (сегменты з рухам) або active_objects (сегменты з актыўнымі аб'ектамі)."
}
},
"detections": {
"label": "Захоўванне дэтэктаванняў дадатковай плыні",
"description": "Налады захоўвання запісаў дэтэктаванняў дадатковай плыні.",
"days": {
"label": "Дні захоўвання",
"description": "Колькасць дзён захоўвання запісаў падзей дэтэктавання."
},
"mode": {
"label": "Рэжым захоўвання",
"description": "Рэжым захоўвання: all (усе сегменты), motion (сегменты з рухам) або active_objects (сегменты з актыўнымі аб'ектамі)."
}
}
} }
}, },
"review": { "review": {
@@ -670,6 +734,10 @@
"activity_context_prompt": { "activity_context_prompt": {
"label": "Прампт кантэксту актыўнасці", "label": "Прампт кантэксту актыўнасці",
"description": "Уласны прампт, які апісвае, якая актыўнасць падазроная, а якая не, каб даць кантэкст для зводак GenAI." "description": "Уласны прампт, які апісвае, якая актыўнасць падазроная, а якая не, каб даць кантэкст для зводак GenAI."
},
"response_style": {
"label": "Стыль адказу",
"description": "Гатовы стыль пісьма для створаных апісанняў разгляду. Гатовыя стылі наладжваюць тон і падрабязнасць загалоўка, зводкі і апісання сцэны, якія бачыць карыстальнік; «default» пакідае ўбудаваны прампт без змен."
} }
} }
}, },
+123 -3
View File
@@ -33,7 +33,11 @@
"label": "Патокі дэтэктавання", "label": "Патокі дэтэктавання",
"description": "Колькасць патокаў для апрацоўкі дэтэктавання гуку." "description": "Колькасць патокаў для апрацоўкі дэтэктавання гуку."
}, },
"description": "Налады дэтэктавання падзей па гуку для ўсіх камер. Можна перавызначыць для кожнай камеры." "description": "Налады дэтэктавання падзей па гуку для ўсіх камер. Можна перавызначыць для кожнай камеры.",
"labelmap": {
"label": "Змяненне карты метак гуку",
"description": "Перавызначэнні або пераназначэнні, якія аб'ядноўваюцца са стандартнай картай метак гуку."
}
}, },
"audio_transcription": { "audio_transcription": {
"label": "Транскрыпцыя гуку", "label": "Транскрыпцыя гуку",
@@ -105,6 +109,10 @@
"idle_heartbeat_fps": { "idle_heartbeat_fps": {
"label": "FPS сігналу пры прастоі", "label": "FPS сігналу пры прастоі",
"description": "Колькі разоў на секунду паўтараць апошні кадр Birdseye пры прастоі. 0 адключае." "description": "Колькі разоў на секунду паўтараць апошні кадр Birdseye пры прастоі. 0 адключае."
},
"modes": {
"label": "Тыпы актыўнасці",
"description": "Тыпы актыўнасці, пры якіх камеры паказваюцца ў Birdseye."
} }
}, },
"detect": { "detect": {
@@ -165,6 +173,10 @@
"annotation_offset": { "annotation_offset": {
"label": "Зрух анатацый", "label": "Зрух анатацый",
"description": "На колькі мілісекунд зрушыць прамавугольнікі аб'ектаў, каб яны супадалі з запісаным відэа: плыні «detect» і «record» рэдка ідэальна сінхронныя. Можа быць дадатным або адмоўным." "description": "На колькі мілісекунд зрушыць прамавугольнікі аб'ектаў, каб яны супадалі з запісаным відэа: плыні «detect» і «record» рэдка ідэальна сінхронныя. Можа быць дадатным або адмоўным."
},
"scene": {
"label": "Сцэна дэтэктавання",
"description": "Асяроддзе, на якое глядзіць гэтая камера; паводле яго выбіраецца, якая з наладжаных мадэлей на ёй працуе. Камеры са значэннем «all» выкарыстоўваюць мадэль, наладжаную са сцэнай «all»."
} }
}, },
"face_recognition": { "face_recognition": {
@@ -238,6 +250,10 @@
"record": { "record": {
"label": "Выходныя аргументы для record", "label": "Выходныя аргументы для record",
"description": "Прадвызначаныя выходныя аргументы для плыняў з роляй «record»." "description": "Прадвызначаныя выходныя аргументы для плыняў з роляй «record»."
},
"record_sub": {
"label": "Выходныя аргументы запісу дадатковай плыні",
"description": "Выходныя аргументы для плыняў з роляй «record_sub». Калі не зададзена, выкарыстоўваюцца выходныя аргументы запісу."
} }
}, },
"retry_interval": { "retry_interval": {
@@ -650,7 +666,55 @@
"label": "Зыходны стан запісу", "label": "Зыходны стан запісу",
"description": "Паказвае, ці быў запіс уключаны ў зыходнай статычнай канфігурацыі." "description": "Паказвае, ці быў запіс уключаны ў зыходнай статычнай канфігурацыі."
}, },
"description": "Налады запісу і захоўвання для камер, калі яны не перавызначаны асобна." "description": "Налады запісу і захоўвання для камер, калі яны не перавызначаны асобна.",
"sub": {
"label": "Запіс дадатковай плыні",
"description": "Налады запісу другой плыні ніжэйшай якасці.",
"enabled": {
"label": "Уключыць запіс дадатковай плыні",
"description": "Уключыць запіс другой плыні ніжэйшай якасці для прайгравання з адаптыўнай якасцю і падоўжанага захоўвання."
},
"continuous": {
"label": "Бесперапыннае захоўванне дадатковай плыні",
"description": "Колькасць дзён захоўвання запісаў дадатковай плыні незалежна ад аб'ектаў пад адсочваннем або руху.",
"days": {
"label": "Дні захоўвання",
"description": "Дзён захоўвання запісаў."
}
},
"motion": {
"label": "Захоўванне дадатковай плыні па руху",
"description": "Колькасць дзён захоўвання запісаў дадатковай плыні, выкліканых рухам.",
"days": {
"label": "Дні захоўвання",
"description": "Дзён захоўвання запісаў."
}
},
"alerts": {
"label": "Захоўванне абвестак дадатковай плыні",
"description": "Налады захоўвання запісаў абвестак дадатковай плыні.",
"days": {
"label": "Дні захоўвання",
"description": "Колькасць дзён захоўвання запісаў падзей дэтэктавання."
},
"mode": {
"label": "Рэжым захоўвання",
"description": "Рэжым захоўвання: all (усе сегменты), motion (сегменты з рухам) або active_objects (сегменты з актыўнымі аб'ектамі)."
}
},
"detections": {
"label": "Захоўванне дэтэктаванняў дадатковай плыні",
"description": "Налады захоўвання запісаў дэтэктаванняў дадатковай плыні.",
"days": {
"label": "Дні захоўвання",
"description": "Колькасць дзён захоўвання запісаў падзей дэтэктавання."
},
"mode": {
"label": "Рэжым захоўвання",
"description": "Рэжым захоўвання: all (усе сегменты), motion (сегменты з рухам) або active_objects (сегменты з актыўнымі аб'ектамі)."
}
}
}
}, },
"review": { "review": {
"label": "Разгляд", "label": "Разгляд",
@@ -740,6 +804,10 @@
"activity_context_prompt": { "activity_context_prompt": {
"label": "Прампт кантэксту актыўнасці", "label": "Прампт кантэксту актыўнасці",
"description": "Уласны прампт, які апісвае, якая актыўнасць падазроная, а якая не, каб даць кантэкст для зводак GenAI." "description": "Уласны прампт, які апісвае, якая актыўнасць падазроная, а якая не, каб даць кантэкст для зводак GenAI."
},
"response_style": {
"label": "Стыль адказу",
"description": "Гатовы стыль пісьма для створаных апісанняў разгляду. Гатовыя стылі наладжваюць тон і падрабязнасць загалоўка, зводкі і апісання сцэны, якія бачыць карыстальнік; «default» пакідае ўбудаваны прампт без змен."
} }
}, },
"description": "Налады, якія кіруюць абвесткамі, дэтэктаваннямі і зводкамі разгляду GenAI для інтэрфейсу і сховішча." "description": "Налады, якія кіруюць абвесткамі, дэтэктаваннямі і зводкамі разгляду GenAI для інтэрфейсу і сховішча."
@@ -1173,7 +1241,7 @@
}, },
"default_role": { "default_role": {
"label": "Прадвызначаная роля", "label": "Прадвызначаная роля",
"description": "Роля, якая прызначаецца карыстальнікам, аўтарызаваным праз проксі, калі супастаўленне роляў не спрацавала." "description": "Роля, якая прызначаецца карыстальнікам, аўтарызаваным праз проксі, калі супастаўленне роляў не спрацавала. Задайце «none», каб адмовіць у доступе карыстальнікам без супастаўлення."
}, },
"separator": { "separator": {
"label": "Сімвал-раздзяляльнік", "label": "Сімвал-раздзяляльнік",
@@ -1399,5 +1467,57 @@
"label": "Паказваць у разглядзе", "label": "Паказваць у разглядзе",
"description": "Ці бачная гэта камера ў разглядзе (старонка разгляду і яе фільтр камер, разгляд руху і выгляд гісторыі)." "description": "Ці бачная гэта камера ў разглядзе (старонка разгляду і яе фільтр камер, разгляд руху і выгляд гісторыі)."
} }
},
"models": {
"label": "Мадэлі дэтэктавання",
"description": "Мадэлі дэтэктавання аб'ектаў і абсталяванне, на якім працуе кожная з іх. Камера выбірае мадэль, супастаўляючы свой detect.scene са сцэнай мадэлі.",
"scene": {
"label": "Сцэна мадэлі",
"description": "Асяроддзе камеры, для якога прызначана гэтая мадэль. Камера выбірае мадэль, задаючы detect.scene з адпаведным значэннем, а «all» выкарыстоўвае любая камера, якая нічога не задала."
},
"devices": {
"label": "Абсталяванне дэтэктавання",
"description": "Абсталяванне, на якім працуе гэтая мадэль, у выглядзе «<detector>» або «<detector>:<device>» (напрыклад «edgetpu:pci:0» ці «openvino:GPU»). Калі адна прылада пазначана некалькі разоў, на ёй запускаюцца дадатковыя працэсы вывядзення."
},
"path": {
"label": "Шлях да мадэлі свайго дэтэктара аб'ектаў",
"description": "Шлях да файла свае мадэлі дэтэктавання (або plus://<model_id> для мадэлей Frigate+)."
},
"labelmap_path": {
"label": "Карта метак для свайго дэтэктара аб'ектаў",
"description": "Шлях да файла карты метак, які супастаўляе лікавыя класы з тэкставымі меткамі для дэтэктара."
},
"width": {
"label": "Шырыня ўваходу мадэлі дэтэктавання аб'ектаў",
"description": "Шырыня ўваходнага тэнзара мадэлі ў пікселях."
},
"height": {
"label": "Вышыня ўваходу мадэлі дэтэктавання аб'ектаў",
"description": "Вышыня ўваходнага тэнзара мадэлі ў пікселях."
},
"labelmap": {
"label": "Змяненне карты метак",
"description": "Перавызначэнні або пераназначэнні, якія аб'ядноўваюцца са стандартнай картай метак."
},
"attributes_map": {
"label": "Карта метак аб'ектаў да іх метак атрыбутаў",
"description": "Супастаўленне метак аб'ектаў з меткамі атрыбутаў, якія дадаюць метаданыя (напрыклад «car» -> ['license_plate'])."
},
"input_tensor": {
"label": "Форма ўваходнага тэнзара мадэлі",
"description": "Фармат тэнзара, якога чакае мадэль: «nhwc» або «nchw»."
},
"input_pixel_format": {
"label": "Фармат колеру пікселяў на ўваходзе мадэлі",
"description": "Каляровая прастора пікселяў, якой чакае мадэль: «rgb», «bgr» або «yuv»."
},
"input_dtype": {
"label": "Тып даных на ўваходзе мадэлі",
"description": "Тып даных уваходнага тэнзара мадэлі (напрыклад «float32»)."
},
"model_type": {
"label": "Тып мадэлі дэтэктавання аб'ектаў",
"description": "Тып архітэктуры мадэлі дэтэктара (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine), які некаторыя дэтэктары выкарыстоўваюць для аптымізацыі."
}
} }
} }
@@ -31,5 +31,8 @@
}, },
"detect": { "detect": {
"dimensionMustBeEven": "Мусіць быць цотным лікам." "dimensionMustBeEven": "Мусіць быць цотным лікам."
},
"models": {
"defaultRequired": "Адна мадэль мусіць выкарыстоўваць сцэну «All cameras». Без гэтага камера, якая не выбрала сцэну, не мае мадэлі, да якой можна вярнуцца."
} }
} }
+16
View File
@@ -55,6 +55,12 @@
"auto_scroll": { "auto_scroll": {
"title": "Аўтапракрутка", "title": "Аўтапракрутка",
"desc": "Сачыць за новымі паведамленнямі па меры паступлення." "desc": "Сачыць за новымі паведамленнямі па меры паступлення."
},
"always_allow": {
"title": "Заўсёды дазволеныя дзеянні",
"desc": "Дзеянні, якія памочнік можа выконваць без папярэдняга запыту.",
"none": "Няма",
"reset": "Скінуць"
} }
}, },
"stats": { "stats": {
@@ -68,5 +74,15 @@
}, },
"thinking": { "thinking": {
"toggle": "Пераключыць разважанне" "toggle": "Пераключыць разважанне"
},
"approval": {
"title": "Ухваліць {{tool}}?",
"desc": "Гэтае дзеянне нешта змяняе ў Frigate. Праглядзіце падрабязнасці перад ухваленнем.",
"approve": "Ухваліць",
"always_allow": "Заўсёды дазваляць",
"reject": "Адхіліць",
"approved": "Ухвалена",
"rejected": "Адхілена",
"placeholder": "Ухваліце або адхіліце дзеянне ў чаканні, каб працягнуць"
} }
} }
+2 -1
View File
@@ -91,5 +91,6 @@
"crop": "Абрэзаць па фільтры", "crop": "Абрэзаць па фільтры",
"cropAria": "Пераключыць абрэзку перадпрагляду па выбраных вобласцях", "cropAria": "Пераключыць абрэзку перадпрагляду па выбраных вобласцях",
"cropDesc": "Набліжаць перадпрагляды да выбраных вобласцей фільтра замест паказу ўсяго кадра." "cropDesc": "Набліжаць перадпрагляды да выбраных вобласцей фільтра замест паказу ўсяго кадра."
} },
"subOnlyQuality": "У гэтым прамежку часу даступныя толькі запісы нізкай якасці"
} }
+2 -1
View File
@@ -63,7 +63,8 @@
"copying": "Капіяванне", "copying": "Капіяванне",
"encoding": "Кадаванне", "encoding": "Кадаванне",
"encodingRetry": "Кадаванне (паўтор)", "encodingRetry": "Кадаванне (паўтор)",
"finalizing": "Завяршэнне" "finalizing": "Завяршэнне",
"merging": "Аб'яднанне"
}, },
"caseView": { "caseView": {
"noDescription": "Без апісання", "noDescription": "Без апісання",
+3 -3
View File
@@ -80,9 +80,9 @@
"deletedFace_one": "Выдалены {{count}} твар.", "deletedFace_one": "Выдалены {{count}} твар.",
"deletedFace_few": "Выдалена {{count}} твары.", "deletedFace_few": "Выдалена {{count}} твары.",
"deletedFace_many": "Выдалена {{count}} твараў.", "deletedFace_many": "Выдалена {{count}} твараў.",
"deletedName_one": "Выдалены {{count}} твар.", "deletedName_one": "{{count}} твар паспяхова выдалены.",
"deletedName_few": "Выдалена {{count}} твары.", "deletedName_few": "{{count}} твары паспяхова выдалены.",
"deletedName_many": "Выдалена {{count}} твараў.", "deletedName_many": "{{count}} твараў паспяхова выдалена.",
"renamedFace": "Твар перайменаваны на {{name}}", "renamedFace": "Твар перайменаваны на {{name}}",
"trainedFace": "Твар навучаны.", "trainedFace": "Твар навучаны.",
"reclassifiedFace": "Твар перакласіфікаваны.", "reclassifiedFace": "Твар перакласіфікаваны.",
+34 -3
View File
@@ -6,7 +6,12 @@
"lowBandwidthMode": "Рэжым нізкай паласы прапускання", "lowBandwidthMode": "Рэжым нізкай паласы прапускання",
"twoWayTalk": { "twoWayTalk": {
"enable": "Уключыць двухбаковую размову", "enable": "Уключыць двухбаковую размову",
"disable": "Адключыць двухбаковую размову" "disable": "Адключыць двухбаковую размову",
"requiresWebRTC": "Двухбаковая размова патрабуе WebRTC, які недаступны",
"error": {
"microphone": "Двухбаковая размова не атрымала доступу да вашага мікрафона",
"refused": "Не ўдалося пачаць двухбаковую размову. Падрабязнасці глядзіце ў кансолі браўзера."
}
}, },
"cameraAudio": { "cameraAudio": {
"enable": "Уключыць гук камеры", "enable": "Уключыць гук камеры",
@@ -135,7 +140,8 @@
"unavailable": "Для гэтай плыні гук недаступны" "unavailable": "Для гэтай плыні гук недаступны"
}, },
"debug": { "debug": {
"picker": "Выбар плыні недаступны ў рэжыме адладкі. Адладачны выгляд заўсёды выкарыстоўвае плынь з роляй «detect»." "picker": "Выбар плыні недаступны ў рэжыме адладкі. Адладачны выгляд заўсёды выкарыстоўвае плынь з роляй «detect».",
"technology": "Выбар тэхналогіі трансляцыі недаступны ў рэжыме адладкі."
}, },
"twoWayTalk": { "twoWayTalk": {
"tips": "Ваша прылада мусіць падтрымліваць гэтую функцыю, а WebRTC мусіць быць наладжаны для двухбаковай размовы.", "tips": "Ваша прылада мусіць падтрымліваць гэтую функцыю, а WebRTC мусіць быць наладжаны для двухбаковай размовы.",
@@ -144,11 +150,36 @@
}, },
"lowBandwidth": { "lowBandwidth": {
"tips": "Жывы прагляд працуе ў рэжыме нізкай паласы прапускання праз буферызацыю або памылкі плыні.", "tips": "Жывы прагляд працуе ў рэжыме нізкай паласы прапускання праз буферызацыю або памылкі плыні.",
"resetStream": "Скінуць плынь" "resetStream": "Скінуць плынь",
"force": {
"label": "Прымусова ўключыць рэжым нізкай паласы прапускання",
"desc": "Заўсёды прайграваць убудаваную ў Frigate плынь нізкай паласы прапускання замест выбранай. Працуе пры любым злучэнні, але мае ніжэйшую якасць і без гуку."
}
}, },
"playInBackground": { "playInBackground": {
"label": "Прайграваць у фоне", "label": "Прайграваць у фоне",
"tips": "Уключыце гэты параметр, каб плынь працягваўся, калі плэер схаваны." "tips": "Уключыце гэты параметр, каб плынь працягваўся, калі плэер схаваны."
},
"mode": "Тэхналогія трансляцыі",
"technology": {
"description": "Выберыце пажаданую тэхналогію трансляцыі. Пры памылках прайгравання ці сеткі Frigate усё роўна можа перайсці ў рэжым нізкай паласы прапускання.",
"name": {
"mse": "MSE",
"webrtc": "WebRTC",
"jsmpeg": "JSMpeg"
},
"tips": {
"mse": "Рэкамендаваны прадвызначаны варыянт, шырокая сумяшчальнасць і плыўнае прайграванне",
"webrtc": "Патрабуе дадатковай наладкі і падтрымліваецца не на кожнай прыладзе"
},
"unavailable": {
"browser": "Ваш браўзер не падтрымлівае WebRTC.",
"not-configured": "WebRTC не наладжаны. Задайце ў go2rtc webrtc candidates або ice_servers.",
"unreachable": "WebRTC не змог злучыцца. Праверце, ці даступны порт 8555 і ці правільна зададзены серверы STUN/TURN.",
"video-codec": "Відэакодэк гэтай плыні не падтрымліваецца WebRTC у гэтым браўзеры.",
"audio-codec": "Аўдыякодэк гэтай плыні не падтрымліваецца WebRTC. Перакадуйце ў opus або G.711 праз go2rtc.",
"checking": "Праверка даступнасці WebRTC…"
}
} }
}, },
"cameraSettings": { "cameraSettings": {
+135 -13
View File
@@ -29,7 +29,7 @@
"systemTelemetry": "Тэлеметрыя", "systemTelemetry": "Тэлеметрыя",
"systemBirdseye": "Birdseye", "systemBirdseye": "Birdseye",
"systemFfmpeg": "FFMPEG", "systemFfmpeg": "FFMPEG",
"systemDetectorsAndModel": "Дэтэктары і мадэль", "systemDetectorsAndModel": "Мадэлі дэтэктавання",
"systemMqtt": "MQTT", "systemMqtt": "MQTT",
"systemGo2rtcStreams": "Плыні go2rtc", "systemGo2rtcStreams": "Плыні go2rtc",
"integrationSemanticSearch": "Семантычны пошук", "integrationSemanticSearch": "Семантычны пошук",
@@ -204,11 +204,50 @@
"toast": { "toast": {
"success": { "success": {
"clearStoredLayout": "Захаваная раскладка для {{cameraName}} ачышчана", "clearStoredLayout": "Захаваная раскладка для {{cameraName}} ачышчана",
"clearStreamingSettings": "Налады трансляцыі для ўсіх груп камер ачышчаны." "clearStreamingSettings": "Налады трансляцыі для ўсіх груп камер ачышчаны.",
"exportUiSettings": "Налады экспартаваны",
"importUiSettings": "Налады імпартаваны"
}, },
"error": { "error": {
"clearStoredLayoutFailed": "Не ўдалося ачысціць захаваную раскладку: {{errorMessage}}", "clearStoredLayoutFailed": "Не ўдалося ачысціць захаваную раскладку: {{errorMessage}}",
"clearStreamingSettingsFailed": "Не ўдалося ачысціць налады трансляцыі: {{errorMessage}}" "clearStreamingSettingsFailed": "Не ўдалося ачысціць налады трансляцыі: {{errorMessage}}",
"exportUiSettingsFailed": "Не ўдалося экспартаваць налады",
"importNothingToApply": "Не ўдалося імпартаваць налады: файл не змяшчае налад",
"importInvalidJson": "Не ўдалося імпартаваць налады: файл не з'яўляецца сапраўдным JSON",
"importWrongType": "Не ўдалося імпартаваць налады: файл не з'яўляецца экспартам налад Frigate",
"importUnsupportedVersion": "Не ўдалося імпартаваць налады: файлу патрэбна навейшая версія Frigate",
"importInvalidSchema": "Не ўдалося імпартаваць налады: файл пашкоджаны",
"importUiSettingsFailed": "Не ўдалося імпартаваць налады"
}
},
"backupRestore": {
"title": "Рэзервовая копія і аднаўленне",
"transfer": {
"label": "Файл налад прылады",
"desc": "Раскладкі груп камер, налады трансляцыі і параметры інтэрфейсу захоўваюцца ў вашым браўзеры. Экспартуйце іх у файл, каб зрабіць рэзервовую копію або перанесці на іншую прыладу.",
"export": "Экспартаваць налады",
"import": "Імпартаваць налады"
},
"importDialog": {
"title": "Імпартаваць налады",
"desc": "Выберыце, што прымяніць з гэтага файла. Пасля імпарту Frigate перазагрузіцца.",
"exportedFrom": "Экспартавана {{date}} з канфігурацыі Frigate версіі {{version}}",
"layouts_one": "Раскладкі груп камер ({{count}} група)",
"layouts_few": "Раскладкі груп камер ({{count}} групы)",
"layouts_many": "Раскладкі груп камер ({{count}} груп)",
"streaming_one": "Налады трансляцыі ({{count}} камера)",
"streaming_few": "Налады трансляцыі ({{count}} камеры)",
"streaming_many": "Налады трансляцыі ({{count}} камер)",
"preferences_one": "Параметры інтэрфейсу ({{count}} налада)",
"preferences_few": "Параметры інтэрфейсу ({{count}} налады)",
"preferences_many": "Параметры інтэрфейсу ({{count}} налад)",
"unknownGroups_one": "Групы камер {{groups}} няма на гэтым серверы. Яе налады захаваюцца, але не будуць выкарыстоўвацца.",
"unknownGroups_few": "Груп камер {{groups}} няма на гэтым серверы. Іх налады захаваюцца, але не будуць выкарыстоўвацца.",
"unknownGroups_many": "Груп камер {{groups}} няма на гэтым серверы. Іх налады захаваюцца, але не будуць выкарыстоўвацца.",
"unknownCameras_one": "Камеры {{cameras}} няма на гэтым серверы. Яе налады трансляцыі будуць ігнаравацца.",
"unknownCameras_few": "Камер {{cameras}} няма на гэтым серверы. Іх налады трансляцыі будуць ігнаравацца.",
"unknownCameras_many": "Камер {{cameras}} няма на гэтым серверы. Іх налады трансляцыі будуць ігнаравацца.",
"confirm": "Імпартаваць"
} }
} }
}, },
@@ -370,7 +409,7 @@
} }
}, },
"step3": { "step3": {
"description": "Наладзьце ролі плыняў і дадайце дадатковыя плыні для вашай камеры.", "description": "Наладзьце магчымасці і ролі плыняў, дадайце дадатковыя плыні для вашай камеры.",
"streamsTitle": "Плыні камеры", "streamsTitle": "Плыні камеры",
"addStream": "Дадаць плынь", "addStream": "Дадаць плынь",
"addAnotherStream": "Дадаць яшчэ плынь", "addAnotherStream": "Дадаць яшчэ плынь",
@@ -404,11 +443,26 @@
"title": "Ролі плыняў", "title": "Ролі плыняў",
"detect": "Асноўная плынь для дэтэктавання аб'ектаў.", "detect": "Асноўная плынь для дэтэктавання аб'ектаў.",
"record": "Захоўвае сегменты відэаплыні паводле налад канфігурацыі.", "record": "Захоўвае сегменты відэаплыні паводле налад канфігурацыі.",
"audio": "Плынь для дэтэктавання па гуку." "audio": "Плынь для дэтэктавання па гуку.",
"record_sub": "Запісы ніжэйшай якасці для адаптыўнага прайгравання і падоўжанага захоўвання (звычайна дадатковая плынь камеры)."
}, },
"featuresPopover": { "featuresPopover": {
"title": "Магчымасці плыні", "title": "Магчымасці плыні",
"description": "Скарыстайцеся рэтрансляцыяй go2rtc, каб паменшыць колькасць падлучэнняў да камеры." "description": "Скарыстайцеся рэтрансляцыяй go2rtc, каб паменшыць колькасць падлучэнняў да камеры."
},
"appleCompatibility": {
"title": "Палепшыць прайграванне на прыладах Apple",
"description": "Уключыце, калі глядзіце запісы ў Safari або на iPhone, iPad ці Mac."
},
"ptz": {
"title": "Уключыць кіраванне PTZ",
"detectedNote": "Праз ONVIF выяўлена падтрымка PTZ. Калі гэта камера PTZ, з гэтым параметрам вы зможаце кіраваць паваротам, нахілам і маштабаваннем камеры з інтэрфейсу.",
"connectionDetails": "Падрабязнасці злучэння ONVIF",
"host": "Хост ONVIF",
"port": "Порт ONVIF",
"username": "Імя карыстальніка ONVIF",
"password": "Пароль ONVIF",
"hostRequiredWarning": "Калі ўключана кіраванне PTZ, патрэбны хост і порт ONVIF."
} }
}, },
"step4": { "step4": {
@@ -1435,7 +1489,9 @@
"orphansDeleted": "Выдалена асірацелых", "orphansDeleted": "Выдалена асірацелых",
"aborted": "Перарвана. Выдаленне перавысіла б парог бяспекі.", "aborted": "Перарвана. Выдаленне перавысіла б парог бяспекі.",
"error": "Памылка", "error": "Памылка",
"totals": "Усяго" "totals": "Усяго",
"spaceToReclaim": "Месца для вызвалення",
"spaceReclaimed": "Вызвалена месца"
}, },
"event_snapshots": "Здымкі аб'ектаў пад адсочваннем", "event_snapshots": "Здымкі аб'ектаў пад адсочваннем",
"event_thumbnails": "Паменшаныя выявы аб'ектаў пад адсочваннем", "event_thumbnails": "Паменшаныя выявы аб'ектаў пад адсочваннем",
@@ -1488,7 +1544,12 @@
}, },
"knownPlates": { "knownPlates": {
"namePlaceholder": "напрыклад Аўто жонкі", "namePlaceholder": "напрыклад Аўто жонкі",
"platePlaceholder": "Нумар знака або рэгулярны выраз" "platePlaceholder": "Нумар знака або рэгулярны выраз",
"assignedTo": "Прызначана: {{name}}",
"detected": "Выяўленыя нумарныя знакі",
"noneDetected": "Нумарных знакаў пакуль не выяўлена",
"search": "Знайдзіце або ўвядзіце нумарны знак",
"useCustom": "Выкарыстаць «{{value}}»"
}, },
"liveStreams": { "liveStreams": {
"streamNameLabel": "Назва плыні", "streamNameLabel": "Назва плыні",
@@ -1544,7 +1605,8 @@
"preset-record-mjpeg": "Запіс - камеры MJPEG", "preset-record-mjpeg": "Запіс - камеры MJPEG",
"preset-record-jpeg": "Запіс - камеры JPEG", "preset-record-jpeg": "Запіс - камеры JPEG",
"preset-record-ubiquiti": "Запіс - камеры Ubiquiti" "preset-record-ubiquiti": "Запіс - камеры Ubiquiti"
} },
"sameAsRecord": "Тое самае, што выходныя аргументы запісу"
}, },
"cameraInputs": { "cameraInputs": {
"itemTitle": "Плынь {{index}}", "itemTitle": "Плынь {{index}}",
@@ -1629,8 +1691,11 @@
"options": { "options": {
"detect": "Дэтэктаваць", "detect": "Дэтэктаваць",
"record": "Запіс", "record": "Запіс",
"audio": "Аўдыя" "audio": "Аўдыя",
} "record_sub": "Запіс (дадатковая плынь)"
},
"roleInUse": "Ужо прызначана іншай плыні",
"recordSubConflict": "Плынь не можа мець адначасова ролі record і record_sub"
}, },
"genaiRoles": { "genaiRoles": {
"options": { "options": {
@@ -1683,7 +1748,18 @@
}, },
"defaultRole": { "defaultRole": {
"admin": "Адміністратар", "admin": "Адміністратар",
"viewer": "Назіральнік" "viewer": "Назіральнік",
"none": "Няма (адмовіць у доступе)"
},
"birdseyeModes": {
"summary": "Выбрана: {{count}}",
"options": {
"continuous": "Бесперапынна",
"motion": "Рух",
"all_objects": "Усе аб'екты",
"alerts": "Абвесткі",
"detections": "Дэтэктаванні"
}
} }
}, },
"globalConfig": { "globalConfig": {
@@ -1905,7 +1981,8 @@
"maxFramesSet": "Задаванне максімуму кадраў перавызначае прадвызначаныя паводзіны і адключае адсочванне нерухомых аб'ектаў. Гэта патрэбна вельмі рэдка, карыстайцеся асцярожна.", "maxFramesSet": "Задаванне максімуму кадраў перавызначае прадвызначаныя паводзіны і адключае адсочванне нерухомых аб'ектаў. Гэта патрэбна вельмі рэдка, карыстайцеся асцярожна.",
"squareResolution": "Квадратнае разрозненне дэтэктавання - незвычайны выбар. Шырыня і вышыня дэтэктавання мусяць адпавядаць фарматным суадносінам камеры (напрыклад 16:9), а не памерам мадэлі дэтэктавання аб'ектаў. Неадпаведнасць можа расцягнуць відарыс і знізіць дакладнасць дэтэктавання.", "squareResolution": "Квадратнае разрозненне дэтэктавання - незвычайны выбар. Шырыня і вышыня дэтэктавання мусяць адпавядаць фарматным суадносінам камеры (напрыклад 16:9), а не памерам мадэлі дэтэктавання аб'ектаў. Неадпаведнасць можа расцягнуць відарыс і знізіць дакладнасць дэтэктавання.",
"resolutionHigh": "Гэта разрозненне дэтэктавання вышэйшае за рэкамендаванае і можа павялічыць спажыванне рэсурсаў без павышэння дакладнасці. Для большасці камер рэкамендуецца разрозненне не вышэй за 1080p.", "resolutionHigh": "Гэта разрозненне дэтэктавання вышэйшае за рэкамендаванае і можа павялічыць спажыванне рэсурсаў без павышэння дакладнасці. Для большасці камер рэкамендуецца разрозненне не вышэй за 1080p.",
"globalResolutionMultipleCameras": "Зададзена глабальнае разрозненне дэтэктавання, хоць наладжана некалькі камер. Калі камеры не маюць аднолькавых разрознення і фарматных суадносін, шырыню і вышыню дэтэктавання варта задаваць для кожнай камеры асобна." "globalResolutionMultipleCameras": "Зададзена глабальнае разрозненне дэтэктавання, хоць наладжана некалькі камер. Калі камеры не маюць аднолькавых разрознення і фарматных суадносін, шырыню і вышыню дэтэктавання варта задаваць для кожнай камеры асобна.",
"sceneWithoutModel": "Для гэтай сцэны не наладжана мадэль дэтэктавання, таму камера вяртаецца да мадэлі са сцэнай «All cameras». Дадайце мадэль для гэтай сцэны, каб камера мела сваю."
}, },
"model": { "model": {
"optimizedFor320": "Frigate аптымізаваны пад мадэль 320x320, і для большасці канфігурацый гэта найлепшы выбар. Мадэль 640x640 працуе павольней і дапамагае толькі ў асобных выпадках.", "optimizedFor320": "Frigate аптымізаваны пад мадэль 320x320, і для большасці канфігурацый гэта найлепшы выбар. Мадэль 640x640 працуе павольней і дапамагае толькі ў асобных выпадках.",
@@ -1929,7 +2006,8 @@
"modelSizeLarge": "Мадэль «large» аптымізавана пад шматрадковыя нумарныя знакі. Мадэль «small» працуе хутчэй, і карыстацца варта ёй, калі ў вашым рэгіёне не ўжываюцца шматрадковыя знакі." "modelSizeLarge": "Мадэль «large» аптымізавана пад шматрадковыя нумарныя знакі. Мадэль «small» працуе хутчэй, і карыстацца варта ёй, калі ў вашым рэгіёне не ўжываюцца шматрадковыя знакі."
}, },
"record": { "record": {
"noRecordRole": "Ніводнай плыні не прызначана роля «record». Запіс працаваць не будзе." "noRecordRole": "Ніводнай плыні не прызначана роля «record». Запіс працаваць не будзе.",
"noRecordSubRole": "Ні ў адной плыні не зададзена роля record_sub. Запіс дадатковай плыні не будзе працаваць."
}, },
"snapshots": { "snapshots": {
"detectDisabled": "Дэтэктаванне аб'ектаў адключана. Здымкі ствараюцца з аб'ектаў пад адсочваннем і стварацца не будуць." "detectDisabled": "Дэтэктаванне аб'ектаў адключана. Здымкі ствараюцца з аб'ектаў пад адсочваннем і стварацца не будуць."
@@ -1939,6 +2017,50 @@
}, },
"onvif": { "onvif": {
"autotrackingNoZones": "Аўтаадсочванне патрабуе хаця б адной зоны. Вызначце зону для гэтай камеры ў раздзеле «Маскі і зоны», а потым пазначце яе як патрэбную ніжэй." "autotrackingNoZones": "Аўтаадсочванне патрабуе хаця б адной зоны. Вызначце зону для гэтай камеры ў раздзеле «Маскі і зоны», а потым пазначце яе як патрэбную ніжэй."
},
"birdseye": {
"objectTrackingDetectDisabled": "Birdseye паказвае аб'екты пад адсочваннем, але дэтэктаванне аб'ектаў для гэтай камеры адключана. Камера не з'явіцца ў Birdseye."
}
},
"detectionModels": {
"title": "Мадэлі дэтэктавання",
"description": "Наладзьце мадэлі дэтэктавання аб'ектаў і абсталяванне, на якім працуе кожная з іх. Камера выбірае мадэль паводле сваёй сцэны ў наладах дэтэктавання.",
"addModel": "Дадаць мадэль",
"cameras_one": "{{count}} камера",
"cameras_few": "{{count}} камеры",
"cameras_many": "{{count}} камер",
"scene": {
"label": "Сцэна",
"description": "Асяроддзе, для якога прызначана гэтая мадэль. Камера выбірае мадэль, задаючы тую самую сцэну ў сваіх наладах дэтэктавання, а мадэль са сцэнай all выкарыстоўвае любая камера, якая нічога не задала."
},
"scenes": {
"all": "Усе камеры",
"indoor": "У памяшканні",
"outdoor": "На вуліцы",
"indoor_thermal": "У памяшканні, цеплавізійная",
"outdoor_thermal": "На вуліцы, цеплавізійная"
},
"hardware": {
"label": "Абсталяванне",
"placeholder": "Выберыце абсталяванне",
"loading": "Пошук абсталявання для дэтэктавання…",
"none": "Абсталяванне не выбрана",
"claimedBy": "выкарыстоўвае {{scene}}",
"detectorCount": "Дэтэктары",
"countRecommended_one": "Рэкамендуецца для {{count}} камеры",
"countRecommended_few": "Рэкамендуецца для {{count}} камер",
"countRecommended_many": "Рэкамендуецца для {{count}} камер",
"unrecognized": "Гэтая мадэль наладжана для абсталявання, якога няма ў сістэме: {{devices}}",
"description": "Абсталяванне, на якім гэтая мадэль выконвае дэтэктаванне.",
"detectorCountDescription": "Колькі працэсаў дэтэктавання запускаць на гэтым абсталяванні. Большая колькасць дэтэктараў спраўляецца з большай колькасцю камер, але займае больш памяці прылады.",
"unitsDescription": "Кожны блок выконвае свой працэс дэтэктавання. Блок, які ўжо выкарыстоўвае іншая мадэль, выбраць нельга."
},
"tabs": {
"plus": "Frigate+",
"custom": "Свая мадэль"
},
"plusModel": {
"noModelSelected": "Выберыце мадэль Frigate+"
} }
} }
} }
+104 -3
View File
@@ -9,7 +9,8 @@
"go2rtc": "Журналы go2rtc - Frigate", "go2rtc": "Журналы go2rtc - Frigate",
"nginx": "Журналы Nginx - Frigate", "nginx": "Журналы Nginx - Frigate",
"websocket": "Журналы паведамленняў - Frigate" "websocket": "Журналы паведамленняў - Frigate"
} },
"health": "Стан - Frigate"
}, },
"title": "Сістэма", "title": "Сістэма",
"metrics": "Сістэмныя метрыкі", "metrics": "Сістэмныя метрыкі",
@@ -148,6 +149,10 @@
"unused": { "unused": {
"title": "Нявыкарыстана", "title": "Нявыкарыстана",
"tips": "Гэта значэнне можа недакладна адлюстроўваць вольнае месца, даступнае Frigate, калі на дыску ёсць іншыя файлы акрамя запісаў Frigate. Frigate не адсочвае месца па-за сваімі запісамі." "tips": "Гэта значэнне можа недакладна адлюстроўваць вольнае месца, даступнае Frigate, калі на дыску ёсць іншыя файлы акрамя запісаў Frigate. Frigate не адсочвае месца па-за сваімі запісамі."
},
"subStream": {
"information": "Звесткі пра сховішча дадатковай плыні",
"tips": "Камеры, якія пішуць дадатковую плынь, паказваюць выкарыстанне зыходнай і нізкай якасці асобна, тымі ж назвамі, што і параметры якасці пры прайграванні запісаў. Запісы нізкай якасці застаюцца на дыску да заканчэння тэрміну захоўвання, таму гэты падзел можа паказвацца і пасля таго, як запіс дадатковай плыні быў адключаны."
} }
} }
}, },
@@ -181,7 +186,11 @@
"segmentLength": "Даўжыня сегмента запісу:", "segmentLength": "Даўжыня сегмента запісу:",
"ok": "Ключавыя кадры прыблізна кожныя {{seconds}} с, гэта добра для запісу і прайгравання.", "ok": "Ключавыя кадры прыблізна кожныя {{seconds}} с, гэта добра для запісу і прайгравання.",
"unknown": "Не ўдалося вызначыць інтэрвал ключавых кадраў.", "unknown": "Не ўдалося вызначыць інтэрвал ключавых кадраў.",
"recordDisabled": "Для гэтай камеры запіс адключаны." "recordDisabled": "Для гэтай камеры запіс адключаны.",
"warningFixed": "Ключавыя кадры ідуць раўнамерна, але рэдка (прыкладна кожныя {{seconds}} с). Запіс працуе, але жывое прайграванне і перамотка пачынаюцца павольней. Задайце інтэрвал I-кадраў (ключавых кадраў) камеры роўным яе частаце кадраў.",
"warningVariable": "Інтэрвал ключавых кадраў нераўнамерны (ад {{minSeconds}} с да {{maxSeconds}} с), што звычайна значыць уключаны разумны кодэк (H.264+/H.265+). Так рабіць не рэкамендуецца.",
"errorFixed": "Ключавыя кадры кожныя прыкладна {{seconds}} с - гэта даўжэй за сегмент запісу ({{segmentTime}} с), таму некаторыя сегменты застаюцца без ключавога кадра і не будуць прайгравацца. Паменшыце інтэрвал I-кадраў (ключавых кадраў) камеры да яе частаты кадраў.",
"errorVariable": "Прамежкі паміж ключавымі кадрамі дасягаюць прыкладна {{seconds}} с - гэта даўжэй за сегмент запісу ({{segmentTime}} с). Некаторыя сегменты застануцца без ключавога кадра, што ламае прайграванне. Адключыце разумны кодэк (+) на камеры або паменшыце інтэрвал ключавых кадраў."
} }
}, },
"framesAndDetections": "Кадры / дэтэктаванні", "framesAndDetections": "Кадры / дэтэктаванні",
@@ -234,7 +243,11 @@
"cameraIsOffline": "{{camera}} па-за сеткай", "cameraIsOffline": "{{camera}} па-за сеткай",
"detectIsSlow": "{{detect}} працуе павольна ({{speed}} мс)", "detectIsSlow": "{{detect}} працуе павольна ({{speed}} мс)",
"detectIsVerySlow": "{{detect}} працуе вельмі павольна ({{speed}} мс)", "detectIsVerySlow": "{{detect}} працуе вельмі павольна ({{speed}} мс)",
"debugReplayActive": "Сеанс адладачнага паўтору актыўны" "debugReplayActive": "Сеанс адладачнага паўтору актыўны",
"cameraSkippedDetections": "{{camera}} прапускае дэтэктаванне на {{pct}}% кадраў",
"systemNotices_one": "{{count}} сістэмная заўвага",
"systemNotices_other": "{{count}} сістэмнай заўвагі",
"retentionUnmet": "Запісы выдаляюцца да заканчэння тэрміну захоўвання, каб вызваліць месца"
}, },
"enrichments": { "enrichments": {
"title": "Узбагачэнні", "title": "Узбагачэнні",
@@ -262,5 +275,93 @@
"classification_speed": "Хуткасць класіфікацыі {{name}}", "classification_speed": "Хуткасць класіфікацыі {{name}}",
"classification_events_per_second": "Падзей класіфікацыі {{name}} на секунду" "classification_events_per_second": "Падзей класіфікацыі {{name}} на секунду"
} }
},
"health": {
"title": "Стан",
"notices": {
"title": "Заўвагі",
"empty": "Ваша ўсталёўка Frigate у парадку",
"dismiss": "Схаваць",
"openSettings": "Адкрыць налады",
"openLink": "Адкрыць спасылку",
"noMatches": "Няма заўваг, якія адпавядаюць фільтру",
"dismissedTitle": "Схаваныя",
"noneDismissed": "Няма схаваных заўваг",
"clearDismissed": "Ачысціць схаваныя",
"clearDismissedTitle": "Ачысціць схаваныя заўвагі?",
"clearDismissedDesc": "Усе схаваныя заўвагі будуць выдалены. Праверкі канфігурацыі і плыняў пакажуцца адразу, а іншыя заўвагі вернуцца пры наступным узнікненні.",
"firstSeen_one": "Упершыню {{time}}",
"firstSeen_other": "Упершыню {{time}} · {{count}} разу",
"dismissedAt": "Схавана {{time}}",
"filter": {
"showDismissed": "Паказаць схаваныя",
"severity": "Узровень важнасці",
"error": "Памылка",
"warning": "Папярэджанне",
"info": "Звесткі"
},
"kinds": {
"detector_stuck": "Дэтэктар {{detector}} перазапушчаны пасля таго, як перастаў адказваць",
"model_download_failed": "Не ўдалося спампаваць {{file}} для {{model}}: {{error}}",
"skipped_detections": "Дэтэктаванне не паспявала і больш за хвіліну прапускала {{pct}}% кадраў",
"shm_too_low": "Выдзеленую памяць /dev/shm ({{total}} МБ) трэба павялічыць прынамсі да {{min}} МБ",
"failed_login_one": "Няўдалая спроба ўваходу для {{user}}",
"failed_login_other": "Няўдалыя спробы ўваходу для {{user}}",
"update_available": "Даступная Frigate {{version}}"
},
"streamPrefix": "Плынь {{index}}: {{message}}",
"streamPrefixRestream": "Плынь {{index}} (праз go2rtc): {{message}}",
"streamProbeFailed": "Не ўдалося зандзіраваць плынь {{index}}: {{error}}",
"cameraProbeFailed": "Не ўдалося зандзіраваць плыні: {{error}}"
},
"hardware": {
"title": "Абсталяванне",
"objectDetection": "Дэтэктаванне аб'ектаў",
"hardwareAcceleration": "Апаратнае паскарэнне",
"enrichments": {
"title": "Узбагачэнні",
"semantic_search": "Семантычны пошук",
"face_recognition": "Распазнаванне асобы",
"lpr": "Распазнаванне нумарных знакаў",
"audio_transcription": "Транскрыпцыя гуку"
},
"cameraConnections": "Злучэнні камер",
"allCamerasExcellent": "Усе камеры маюць выдатнае злучэнне.",
"fps": "{{camera}} / {{expected}} fps",
"nothingConfigured": "Нічога не наладжана",
"allCameras": "Усе камеры",
"probeUnavailable": "Выяўленне абсталявання недаступнае",
"justStarted": "Frigate толькі што запусціўся",
"deviceNotFound": "{{devices}} не знойдзена ў гэтай сістэме",
"detectorNotRunning": "Працэс дэтэктара не запушчаны",
"inferenceVerySlow": "Вывядзенне вельмі павольнае ({{speed}} мс)",
"inferenceSlow": "Вывядзенне павольнае ({{speed}} мс)",
"inferenceMs": "{{speed}} мс",
"customArgs": "Свае аргументы ffmpeg",
"customArgsNotVerified": "Свае аргументы ffmpeg, не правераныя",
"hwaccelNotConfigured": "Апаратнае дэкадаванне не выкарыстоўваецца, хоць {{family}} даступнае. Выберыце яго ў наладах ffmpeg",
"hwaccelHardwareMissing": "{{family}} наладжана, але апаратнае зандзіраванне яго не выявіла",
"unrecognizedDevice": "Нераспазнанае перавызначэнне прылады, не праверана",
"decoderUsage": "дэкадавальнік {{usage}}",
"remoteProvider": "Аддалены правайдар",
"acceleratorMissing": "Наладжана для {{device}}, але сумяшчальнага паскаральніка не знойдзена",
"modelNotRunYet": "Мадэль яшчэ не запускалася, прылада правяраецца пасля першага выкарыстання",
"fellBackToCpu": "Наладжана для {{device}}, але працуе на CPU",
"cpuDespiteAccelerator": "Працуе на CPU, хоць паскаральнік даступны",
"probed": "Зандзіравана",
"probing": "Зандзіраванне…",
"recheck": "Праверыць абсталяванне зноў",
"cameraStreams": "Плыні камер",
"streamsNotChecked": "Яшчэ не правяралася",
"streamsChecking": "Правярае {{done}} з {{total}}",
"streamsClean": "Праблем з плынямі няма",
"checked": "Праверана",
"runAgain": "Запусціць зноў",
"runStreamChecks": "Запусціць праверку плыняў",
"streamsChecked_one": "{{count}} камера правераная",
"streamsChecked_other": "{{count}} камеры правераных",
"streamsFlagged_one": "{{count}} з праблемамі, пералічана ў Заўвагах",
"streamsFlagged_other": "{{count}} з праблемамі, пералічана ў Заўвагах"
}
} }
} }
@@ -1,14 +1,6 @@
{ {
"noPreviewFoundFor": "Не е намерен предварителен преглед за {{cameraName}}", "noPreviewFoundFor": "Не е намерен предварителен преглед за {{cameraName}}",
"stats": { "stats": {
"latency": {
"short": {
"value": "{{seconds}} сек",
"title": "Закъснение"
},
"title": "Закъснение:",
"value": "{{seconds}} секунди"
},
"streamType": { "streamType": {
"title": "Тип поток:", "title": "Тип поток:",
"short": "Тип" "short": "Тип"
@@ -21,14 +21,6 @@
"title": "Širina pojasa:", "title": "Širina pojasa:",
"short": "Širina pojasa" "short": "Širina pojasa"
}, },
"latency": {
"title": "Kasnjenje:",
"value": "{{seconds}} sekundi",
"short": {
"title": "Kasnjenje",
"value": "{{seconds}} sek"
}
},
"totalFrames": "Ukupno okvira:", "totalFrames": "Ukupno okvira:",
"droppedFrames": { "droppedFrames": {
"title": "Izgubljeni okviri:", "title": "Izgubljeni okviri:",
+37 -3
View File
@@ -55,9 +55,9 @@
"be": "Беларуская (belarús)" "be": "Беларуская (belarús)"
}, },
"system": "Sistema", "system": "Sistema",
"systemMetrics": "Mètriques del sistema", "systemMetrics": "Salut i Mètriques",
"configuration": "Configuració", "configuration": "Configuració",
"systemLogs": "Registres del sistema", "systemLogs": "Registres",
"configurationEditor": "Editor de configuració", "configurationEditor": "Editor de configuració",
"languages": "Idiomes", "languages": "Idiomes",
"settings": "Opcions", "settings": "Opcions",
@@ -287,7 +287,10 @@
"savingAll": "S'està desant tot…", "savingAll": "S'està desant tot…",
"undoAll": "Desfés-ho tot", "undoAll": "Desfés-ho tot",
"applying": "S'està aplicant…", "applying": "S'està aplicant…",
"retry": "Torna a intentar" "retry": "Torna a intentar",
"expand": "Expandeix",
"collapse": "Redueix",
"reload": "Torna a carregar"
}, },
"toast": { "toast": {
"copyUrlToClipboard": "URL copiada al porta-retalls.", "copyUrlToClipboard": "URL copiada al porta-retalls.",
@@ -328,5 +331,36 @@
"validation_errors": "Errors de validació", "validation_errors": "Errors de validació",
"credentialField": { "credentialField": {
"savedPlaceholder": "Desat — deixa en blanc per mantenir l'actual" "savedPlaceholder": "Desat — deixa en blanc per mantenir l'actual"
},
"error": {
"title": "Aquesta pàgina ha deixat de funcionar",
"desc": "La pàgina ha tingut un error inesperat. La recàrrega normalment la neteja. Si continua passant, copieu els detalls i adjunteu-los a una discussió sobre GitHub.",
"staleTitle": "Recàrrega requerida",
"staleDesc": "No s'ha pogut carregar part de la interfície, cosa que normalment significa que Frigate s'ha actualitzat mentre aquesta pestanya estava oberta. Torna a carregar per a recollir la nova versió.",
"partial": "Part de la interfície va deixar de funcionar.",
"copyDetails": "Copia els detalls",
"copyFailed": "No s'han pogut copiar els detalls al porta-retalls"
},
"commandMenu": {
"title": "Menú d'ordres",
"description": "Cerca pàgines, càmeres, paràmetres i accions",
"placeholder": "Cerca pàgines, càmeres, paràmetres i accions",
"empty": "Sense coincidències",
"hint": "{{key}} o / per obrir, Intro per executar",
"section": {
"recent": "Recent",
"pages": "Pàg",
"cameras": "Càmeres",
"cameraGroups": "Grups de càmera",
"settings": "Configuració",
"actions": "Accions"
},
"camera": {
"live": "Directe",
"review": "Revisió"
},
"action": {
"toggleTheme": "Commuta el mode clar i fosc"
}
} }
} }
+17 -1
View File
@@ -137,6 +137,15 @@
"partial": "S'han iniciat {{successful}} de {{total}} exportacions. Ha fallat: {{failedItems}}", "partial": "S'han iniciat {{successful}} de {{total}} exportacions. Ha fallat: {{failedItems}}",
"failed": "No s'han pogut iniciar {{total}} exportacions. Ha fallat: {{failedItems}}" "failed": "No s'han pogut iniciar {{total}} exportacions. Ha fallat: {{failedItems}}"
} }
},
"stream": {
"label": "Qualitat",
"auto": "Automàtic",
"main": "Original",
"sub": "Baix",
"autoDesc": "Utilitza el flux principal i torna al flux de menor qualitat quan el principal no està disponible.",
"mainDesc": "Exporta només el flux principal de qualitat original. Faltaran els intervals de temps on el flux principal no està disponible.",
"subDesc": "Exporta només el subflux de menor qualitat. Faltaran els intervals de temps on el subflux no està disponible."
} }
}, },
"streaming": { "streaming": {
@@ -172,7 +181,8 @@
"deleteNow": "Suprimir ara", "deleteNow": "Suprimir ara",
"export": "Exportar", "export": "Exportar",
"markAsReviewed": "Marcar com a revisat", "markAsReviewed": "Marcar com a revisat",
"markAsUnreviewed": "Marcar com no revisat" "markAsUnreviewed": "Marcar com no revisat",
"generateDescription": "Genera la descripció"
}, },
"confirmDelete": { "confirmDelete": {
"title": "Confirmar la supressió", "title": "Confirmar la supressió",
@@ -191,6 +201,12 @@
"custom": "Marca horària personalitzada", "custom": "Marca horària personalitzada",
"button": "Comparteix l'URL de la marca horària", "button": "Comparteix l'URL de la marca horària",
"shareTitle": "Marca de temps de revisió de Frigate: {{camera}}" "shareTitle": "Marca de temps de revisió de Frigate: {{camera}}"
},
"genaiDescription": {
"toast": {
"success": "S'ha sol·licitat una descripció de IA Generativa per a aquest element de revisió.",
"error": "No s'ha pogut sol·licitar la descripció: {{error}}"
}
} }
}, },
"imagePicker": { "imagePicker": {
+17 -10
View File
@@ -1,13 +1,5 @@
{ {
"stats": { "stats": {
"latency": {
"short": {
"title": "Latència",
"value": "{{seconds}} s"
},
"title": "Latència:",
"value": "{{seconds}} segons"
},
"streamType": { "streamType": {
"title": "Tipus de transmissió:", "title": "Tipus de transmissió:",
"short": "Tipus" "short": "Tipus"
@@ -45,8 +37,23 @@
"submittedFrigatePlus": "Fotograma enviat correctament a Frigate+" "submittedFrigatePlus": "Fotograma enviat correctament a Frigate+"
}, },
"error": { "error": {
"submitFrigatePlusFailed": "Error al enviar fotograma a Frigate+" "submitFrigatePlusFailed": "Error al enviar fotograma a Frigate+",
"playRecordingsFailed": "No s'han pogut reproduir els enregistraments (error {{code}}): {{message}}"
} }
}, },
"cameraOff": "La càmera està apagada" "cameraOff": "La càmera està apagada",
"quality": {
"auto": "Automàtic",
"autoLow": "Reproduint de baixa qualitat (ample de banda limitat)",
"autoLowCodec": "Reproducció de baixa qualitat (Original no suportat per aquest navegador)",
"autoLowSaveData": "Reproducció de baixa qualitat (desador de dades)",
"main": "Original",
"sub": "Baixa",
"notSupportedBrowser": "No és compatible amb aquest navegador",
"label": "Qualitat",
"noAudio": "Sense àudio",
"audioRate": "{{rate}} àudio kHz",
"audioCodecRate": "{{codec}} {{rate}} kHz",
"noRecordings": "No hi ha enregistraments en aquest interval de temps"
}
} }
+105 -33
View File
@@ -46,6 +46,10 @@
"num_threads": { "num_threads": {
"label": "Fils de detecció", "label": "Fils de detecció",
"description": "Nombre de fils a utilitzar per al processament de detecció d'àudio." "description": "Nombre de fils a utilitzar per al processament de detecció d'àudio."
},
"labelmap": {
"label": "Personalització del mapa d'etiquetes d'àudio",
"description": "Sobreescriu o reassigna les entrades per a fusionar-se en el mapa d'etiquetes d'àudio estàndard."
} }
}, },
"audio_transcription": { "audio_transcription": {
@@ -73,6 +77,10 @@
"order": { "order": {
"label": "Posició", "label": "Posició",
"description": "Posició numèrica que controla l'ordenació de la càmera en la disposició Birdseye." "description": "Posició numèrica que controla l'ordenació de la càmera en la disposició Birdseye."
},
"modes": {
"label": "Tipus d'activitat",
"description": "Tipus d'activitat que inclouen càmeres a Birdseye."
} }
}, },
"detect": { "detect": {
@@ -133,6 +141,10 @@
"annotation_offset": { "annotation_offset": {
"label": "Desplaçament de l'anotació", "label": "Desplaçament de l'anotació",
"description": "Mil·lisegons per a desplaçar detecta anotacions per a alinear millor els límits de la línia de temps amb els enregistraments; pot ser positiu o negatiu." "description": "Mil·lisegons per a desplaçar detecta anotacions per a alinear millor els límits de la línia de temps amb els enregistraments; pot ser positiu o negatiu."
},
"scene": {
"label": "Detecta l'escena",
"description": "L'entorn que mira aquesta càmera, utilitzat per triar quin dels models configurats s'executa en ell. Les càmeres que queden a 'totes' executen el model configurat amb una escena de 'totes'."
} }
}, },
"face_recognition": { "face_recognition": {
@@ -176,6 +188,10 @@
"record": { "record": {
"label": "Registra els arguments de sortida", "label": "Registra els arguments de sortida",
"description": "Arguments de sortida predeterminats per a enregistrar fluxos de rols." "description": "Arguments de sortida predeterminats per a enregistrar fluxos de rols."
},
"record_sub": {
"label": "Arguments de sortida del registre de subflux",
"description": "Arguments de sortida per als fluxos de rols record.sub. Els arguments de sortida del registre s'utilitzen quan no s'estableix."
} }
}, },
"retry_interval": { "retry_interval": {
@@ -356,51 +372,51 @@
"label": "Màscara en brut" "label": "Màscara en brut"
}, },
"genai": { "genai": {
"label": "Configuració de l'objecte GenAI", "label": "Configuració de l'objecte IA Generativa",
"description": "Opcions de GenAI per descriure objectes rastrejats i enviar fotogrames per a la generació.", "description": "Opcions d'IA Generativa per descriure objectes rastrejats i enviar fotogrames per a la generació.",
"enabled": { "enabled": {
"label": "Habilita el GenAI", "label": "Habilita l'IA Generativa",
"description": "Habilita la generació de descripcions de GenAI per als objectes rastrejats de manera predeterminada." "description": "Habilita la generació de descripcions d'IA Generativa per als objectes rastrejats de manera predeterminada."
}, },
"use_snapshot": { "use_snapshot": {
"label": "Utilitza instantànies", "label": "Utilitza instantànies",
"description": "Usa instantànies d'objecte en lloc de miniatures per a la generació de descripcions de GenAI." "description": "Usa instantànies d'objecte en lloc de miniatures per a la generació de descripcions d'IA Generativa."
}, },
"prompt": { "prompt": {
"label": "Indicació de la llegenda", "label": "Indicació de la llegenda",
"description": "Plantilla de pregunta predeterminada utilitzada en generar descripcions amb GenAI." "description": "Plantilla de pregunta predeterminada utilitzada en generar descripcions amb IA Generativa."
}, },
"object_prompts": { "object_prompts": {
"label": "Peticions d'objecte", "label": "Peticions d'objecte",
"description": "Per objecte demana personalitzar les sortides de GenAI per a etiquetes específiques." "description": "Per objecte demana personalitzar les sortides d'IA Generativa per a etiquetes específiques."
}, },
"objects": { "objects": {
"label": "Objectes GenAI", "label": "Objectes d'IA Generativa",
"description": "Llista d'etiquetes d'objectes a enviar a GenAI per defecte." "description": "Llista d'etiquetes d'objectes a enviar a IA Generativa per defecte."
}, },
"required_zones": { "required_zones": {
"label": "Zones requerides", "label": "Zones requerides",
"description": "Zones que s'han d'introduir perquè els objectes es puguin classificar per a la generació de descripcions de GenAI." "description": "Zones que s'han d'introduir perquè els objectes es puguin classificar per a la generació de descripcions d'IA Generativa."
}, },
"debug_save_thumbnails": { "debug_save_thumbnails": {
"label": "Desa les miniatures", "label": "Desa les miniatures",
"description": "Desa les miniatures enviades a GenAI per a la depuració i la revisió." "description": "Desa les miniatures enviades a IA Generativa per a la depuració i la revisió."
}, },
"send_triggers": { "send_triggers": {
"label": "Activadors de GenAI", "label": "Activadors d'IA Generativa",
"description": "Defineix quan s'han d'enviar fotogrames a GenAI (al final, després de les actualitzacions, etc.).", "description": "Defineix quan s'han d'enviar fotogrames a IA Generativa (al final, després de les actualitzacions, etc.).",
"tracked_object_end": { "tracked_object_end": {
"label": "Envia al final", "label": "Envia al final",
"description": "Envia una sol·licitud a GenAI quan acabi l'objecte rastrejat." "description": "Envia una sol·licitud a IA Generativa quan acabi l'objecte rastrejat."
}, },
"after_significant_updates": { "after_significant_updates": {
"label": "Activador de GenAI primerenc", "label": "Activador d'IA Generativa primerenc",
"description": "Envia una sol·licitud a GenAI després d'un nombre especificat d'actualitzacions significatives per a l'objecte rastrejat." "description": "Envia una sol·licitud a IA Generativa després d'un nombre especificat d'actualitzacions significatives per a l'objecte rastrejat."
} }
}, },
"enabled_in_config": { "enabled_in_config": {
"label": "Estat original de GenAI", "label": "Estat original d'IA Generativa",
"description": "Indica si el GenAI s'ha activat a la configuració estàtica original." "description": "Indica si l'IA Generativa s'ha activat a la configuració estàtica original."
} }
} }
}, },
@@ -505,11 +521,59 @@
"enabled_in_config": { "enabled_in_config": {
"label": "Estat de l'enregistrament original", "label": "Estat de l'enregistrament original",
"description": "Indica si l'enregistrament s'ha activat en la configuració estàtica original." "description": "Indica si l'enregistrament s'ha activat en la configuració estàtica original."
},
"sub": {
"label": "Enregistrament de subflux",
"description": "Paràmetres per a enregistrar un segon flux de menor qualitat.",
"enabled": {
"label": "Habilita l'enregistrament de subflux",
"description": "Permet l'enregistrament d'un segon flux de menor qualitat per a la reproducció de qualitat adaptativa i la retenció estesa."
},
"continuous": {
"label": "Retenció contínua del subflux",
"description": "Nombre de dies per a retenir els enregistraments de sub flux independentment dels objectes o el moviment seguits.",
"days": {
"label": "Dies de retenció",
"description": "Dies per retenir enregistraments."
}
},
"motion": {
"label": "Retenció del moviment del subflux",
"description": "Nombre de dies per a retenir els enregistraments de substream activats pel moviment.",
"days": {
"label": "Dies de retenció",
"description": "Dies per retenir enregistraments."
}
},
"alerts": {
"label": "Retenció d'alertes del subflux",
"description": "Paràmetres de retenció per als enregistraments de substream d'alertes.",
"days": {
"label": "Dies de retenció",
"description": "Nombre de dies per a retenir enregistraments d'esdeveniments de detecció."
},
"mode": {
"label": "Mode de retenció",
"description": "Mode de retenció: tot (desa tots els segments), moviment (desa els segments amb moviment), o actiuobobjectes (desa els segments amb objectes actius)."
}
},
"detections": {
"label": "Retenció de la detecció de subflux",
"description": "Paràmetres de retenció per als enregistraments de substream de les deteccions.",
"days": {
"label": "Dies de retenció",
"description": "Nombre de dies per a retenir enregistraments d'esdeveniments de detecció."
},
"mode": {
"label": "Mode de retenció",
"description": "Mode de retenció: tot (desa tots els segments), moviment (desa els segments amb moviment), o actiuobobjectes (desa els segments amb objectes actius)."
}
}
} }
}, },
"review": { "review": {
"label": "Revisió", "label": "Revisió",
"description": "Configuració que controla les alertes, les deteccions i els resums de revisió de GenAI utilitzats per la interfície d'usuari i l'emmagatzematge d'aquesta càmera.", "description": "Configuració que controla les alertes, les deteccions i els resums de revisió d'IA Generativa utilitzats per la interfície d'usuari i l'emmagatzematge d'aquesta càmera.",
"alerts": { "alerts": {
"label": "Configuració d'alertes", "label": "Configuració d'alertes",
"description": "Paràmetres per als quals els objectes rastrejats generen alertes i com es mantenen les alertes", "description": "Paràmetres per als quals els objectes rastrejats generen alertes i com es mantenen les alertes",
@@ -559,43 +623,51 @@
} }
}, },
"genai": { "genai": {
"label": "Configuració del GenAI", "label": "Configuració de l'IA Generativa",
"description": "Controla l'ús de la IA generativa per a la producció de descripcions i resums d'elements de revisió.", "description": "Controla l'ús de l'IA generativa per a la producció de descripcions i resums d'elements de revisió.",
"enabled": { "enabled": {
"label": "Habilita les descripcions del GenAI", "label": "Habilita les descripcions de l'IA Generativa",
"description": "Activa o desactiva les descripcions i resums generats per GenAI per als elements de revisió." "description": "Activa o desactiva les descripcions i resums generats per IA Generativa per als elements de revisió."
}, },
"alerts": { "alerts": {
"label": "Habilita el GenAI per a alertes", "label": "Habilita l'IA Generativa per a alertes",
"description": "Utilitzeu GenAI per a generar descripcions per als elements d'alerta." "description": "Utilitzeu IA Generativa per a generar descripcions per als elements d'alerta."
}, },
"detections": { "detections": {
"label": "Habilita el GenAI per a les deteccions", "label": "Habilita l'IA Generativa per a les deteccions",
"description": "Utilitzeu GenAI per generar descripcions per als elements de detecció." "description": "Utilitzeu IA Generativa per generar descripcions per als elements de detecció."
}, },
"image_source": { "image_source": {
"label": "Revisa l'origen de la imatge", "label": "Revisa l'origen de la imatge",
"description": "Font d'imatges enviades a GenAI ('previsualització' o 'enregistraments'); 'enregistraments' utilitza Fotogrames de més qualitat però més tokens." "description": "Font d'imatges enviades a IA Generativa ('previsualització' o 'enregistraments'); 'enregistraments' utilitza Fotogrames de més qualitat però més tokens."
}, },
"additional_concerns": { "additional_concerns": {
"label": "Altres preocupacions", "label": "Altres preocupacions",
"description": "Una llista de preocupacions o notes addicionals que el GenAI ha de tenir en compte a l'hora d'avaluar l'activitat en aquesta càmera." "description": "Una llista de preocupacions o notes addicionals que l¡IA Generativa ha de tenir en compte a l'hora d'avaluar l'activitat en aquesta càmera."
}, },
"debug_save_thumbnails": { "debug_save_thumbnails": {
"label": "Desa les miniatures", "label": "Desa les miniatures",
"description": "Desa les miniatures que s'envien al proveïdor GenAI per a la depuració i la revisió." "description": "Desa les miniatures que s'envien al proveïdor IA Generativa per a la depuració i la revisió."
}, },
"enabled_in_config": { "enabled_in_config": {
"label": "Estat original de GenAI", "label": "Estat original d'IA Generativa",
"description": "Fa un seguiment de si la revisió de GenAI es va habilitar originalment a la configuració estàtica." "description": "Fa un seguiment de si la revisió d'IA Generativa es va habilitar originalment a la configuració estàtica."
}, },
"preferred_language": { "preferred_language": {
"label": "Idioma preferit", "label": "Idioma preferit",
"description": "Idioma preferit per sol·licitar al proveïdor GenAI respostes generades." "description": "Idioma preferit per sol·licitar al proveïdor d'IA Generativa respostes generades."
}, },
"activity_context_prompt": { "activity_context_prompt": {
"label": "Indicador de context de l'activitat", "label": "Indicador de context de l'activitat",
"description": "Pregunta personalitzada que descriu el que és i no és una activitat sospitosa per proporcionar context per als resums de GenAI." "description": "Pregunta personalitzada que descriu el que és i no és una activitat sospitosa per proporcionar context per als resums de GenAI."
},
"response_style": {
"label": "Estil de resposta",
"description": "Escriviu un predefinit d'estil per a les descripcions generades de les revisions. Els valors predefinits ajusten el to i el nivell de detall del títol orientat a l'usuari, el resum i la descripció de l'escena; «predeterminat» deixa l'indicatiu incorporat sense canvis."
},
"frame_mode": {
"label": "Mode de fotograma",
"description": "Com es presenten els marcs al model. Els 'frames' envien l'indicatiu seguit pels fotogrames, que s'adapta als models que segueixen una seqüència per si sols. 'annotated.frames' etiqueta cada marc i entrellaça notes derivades del seguiment d'objectes, el que ajuda als models que perden el rastre de l'activitat que es repeteix o reverteix."
} }
} }
}, },
+167 -39
View File
@@ -31,6 +31,10 @@
"record": { "record": {
"label": "Registra els arguments de sortida", "label": "Registra els arguments de sortida",
"description": "Arguments de sortida predeterminats per a enregistrar fluxos de rols." "description": "Arguments de sortida predeterminats per a enregistrar fluxos de rols."
},
"record_sub": {
"label": "Arguments de sortida del registre de subflux",
"description": "Arguments de sortida per als fluxos de rols record.sub. Els arguments de sortida del registre s'utilitzen quan no s'estableix."
} }
}, },
"retry_interval": { "retry_interval": {
@@ -193,49 +197,49 @@
"genai": { "genai": {
"required_zones": { "required_zones": {
"label": "Zones requerides", "label": "Zones requerides",
"description": "Zones que s'han d'introduir perquè els objectes es puguin classificar per a la generació de descripcions de GenAI." "description": "Zones que s'han d'introduir perquè els objectes es puguin classificar per a la generació de descripcions d'IA Generativa."
}, },
"label": "Configuració de l'objecte GenAI", "label": "Configuració de l'objecte IA Generativa",
"description": "Opcions de GenAI per descriure objectes rastrejats i enviar fotogrames per a la generació.", "description": "Opcions d'IA Generativa per descriure objectes rastrejats i enviar fotogrames per a la generació.",
"enabled": { "enabled": {
"label": "Habilita el GenAI", "label": "Habilita l'IA Generativa",
"description": "Habilita la generació de descripcions de GenAI per als objectes rastrejats de manera predeterminada." "description": "Habilita la generació de descripcions d'IA Generativa per als objectes rastrejats de manera predeterminada."
}, },
"use_snapshot": { "use_snapshot": {
"label": "Utilitza instantànies", "label": "Utilitza instantànies",
"description": "Usa instantànies d'objecte en lloc de miniatures per a la generació de descripcions de GenAI." "description": "Usa instantànies d'objecte en lloc de miniatures per a la generació de descripcions d'IA Generativa."
}, },
"prompt": { "prompt": {
"label": "Indicació de la llegenda", "label": "Indicació de la llegenda",
"description": "Plantilla de pregunta predeterminada utilitzada en generar descripcions amb GenAI." "description": "Plantilla de pregunta predeterminada utilitzada en generar descripcions amb IA Generativa."
}, },
"object_prompts": { "object_prompts": {
"label": "Peticions d'objecte", "label": "Peticions d'objecte",
"description": "Per objecte demana personalitzar les sortides de GenAI per a etiquetes específiques." "description": "Per objecte demana personalitzar les sortides d'IA Generativa per a etiquetes específiques."
}, },
"objects": { "objects": {
"label": "Objectes GenAI", "label": "Objectes d'IA Generativa",
"description": "Llista d'etiquetes d'objectes a enviar a GenAI per defecte." "description": "Llista d'etiquetes d'objectes a enviar a IA Generativa per defecte."
}, },
"debug_save_thumbnails": { "debug_save_thumbnails": {
"label": "Desa les miniatures", "label": "Desa les miniatures",
"description": "Desa les miniatures enviades a GenAI per a la depuració i la revisió." "description": "Desa les miniatures enviades a IA Generativa per a la depuració i la revisió."
}, },
"send_triggers": { "send_triggers": {
"label": "Activadors de GenAI", "label": "Activadors d'IA Generativa",
"description": "Defineix quan s'han d'enviar fotogrames a GenAI (al final, després de les actualitzacions, etc.).", "description": "Defineix quan s'han d'enviar fotogrames a IA Generativa (al final, després de les actualitzacions, etc.).",
"tracked_object_end": { "tracked_object_end": {
"label": "Envia al final", "label": "Envia al final",
"description": "Envia una sol·licitud a GenAI quan acabi l'objecte rastrejat." "description": "Envia una sol·licitud a IA Generativa quan acabi l'objecte rastrejat."
}, },
"after_significant_updates": { "after_significant_updates": {
"label": "Activador de GenAI primerenc", "label": "Activador d'IA Generativa primerenc",
"description": "Envia una sol·licitud a GenAI després d'un nombre especificat d'actualitzacions significatives per a l'objecte rastrejat." "description": "Envia una sol·licitud a IA Generativa després d'un nombre especificat d'actualitzacions significatives per a l'objecte rastrejat."
} }
}, },
"enabled_in_config": { "enabled_in_config": {
"label": "Estat original de GenAI", "label": "Estat original d'IA Generativa",
"description": "Indica si el GenAI s'ha activat a la configuració estàtica original." "description": "Indica si l'IA Generativa s'ha activat a la configuració estàtica original."
} }
}, },
"label": "Objectes", "label": "Objectes",
@@ -388,6 +392,54 @@
"enabled_in_config": { "enabled_in_config": {
"label": "Estat de l'enregistrament original", "label": "Estat de l'enregistrament original",
"description": "Indica si l'enregistrament s'ha activat en la configuració estàtica original." "description": "Indica si l'enregistrament s'ha activat en la configuració estàtica original."
},
"sub": {
"label": "Enregistrament de subflux",
"description": "Paràmetres per a enregistrar un segon flux de menor qualitat.",
"enabled": {
"label": "Habilita l'enregistrament de subflux",
"description": "Permet l'enregistrament d'un segon flux de menor qualitat per a la reproducció de qualitat adaptativa i la retenció estesa."
},
"continuous": {
"label": "Retenció contínua del subflux",
"description": "Nombre de dies per a retenir els enregistraments de sub flux independentment dels objectes o el moviment seguits.",
"days": {
"label": "Dies de retenció",
"description": "Dies per retenir enregistraments."
}
},
"motion": {
"label": "Retenció del moviment del subflux",
"description": "Nombre de dies per a retenir els enregistraments de substream activats pel moviment.",
"days": {
"label": "Dies de retenció",
"description": "Dies per retenir enregistraments."
}
},
"alerts": {
"label": "Retenció d'alertes del subflux",
"description": "Paràmetres de retenció per als enregistraments de substream d'alertes.",
"days": {
"label": "Dies de retenció",
"description": "Nombre de dies per a retenir enregistraments d'esdeveniments de detecció."
},
"mode": {
"label": "Mode de retenció",
"description": "Mode de retenció: tot (desa tots els segments), moviment (desa els segments amb moviment), o actiuobobjectes (desa els segments amb objectes actius)."
}
},
"detections": {
"label": "Retenció de la detecció de subflux",
"description": "Paràmetres de retenció per als enregistraments de substream de les deteccions.",
"days": {
"label": "Dies de retenció",
"description": "Nombre de dies per a retenir enregistraments d'esdeveniments de detecció."
},
"mode": {
"label": "Mode de retenció",
"description": "Mode de retenció: tot (desa tots els segments), moviment (desa els segments amb moviment), o actiuobobjectes (desa els segments amb objectes actius)."
}
}
} }
}, },
"review": { "review": {
@@ -417,42 +469,50 @@
}, },
"genai": { "genai": {
"image_source": { "image_source": {
"description": "Font d'imatges enviades a GenAI ('previsualització' o 'enregistraments'); 'enregistraments' utilitza Fotogrames de més qualitat però més tokens.", "description": "Font d'imatges enviades a IA Generativa ('previsualització' o 'enregistraments'); 'enregistraments' utilitza Fotogrames de més qualitat però més tokens.",
"label": "Revisa l'origen de la imatge" "label": "Revisa l'origen de la imatge"
}, },
"enabled_in_config": { "enabled_in_config": {
"description": "Fa un seguiment de si la revisió de GenAI es va habilitar originalment a la configuració estàtica.", "description": "Fa un seguiment de si la revisió d'IA Generativa es va habilitar originalment a la configuració estàtica.",
"label": "Estat original de GenAI" "label": "Estat original d'IA Generativa"
}, },
"label": "Configuració del GenAI", "label": "Configuració de l'IA Generativa",
"description": "Controla l'ús de la IA generativa per a la producció de descripcions i resums d'elements de revisió.", "description": "Controla l'ús de l'IA generativa per a la producció de descripcions i resums d'elements de revisió.",
"enabled": { "enabled": {
"label": "Habilita les descripcions del GenAI", "label": "Habilita les descripcions de l'IA Generativa",
"description": "Activa o desactiva les descripcions i resums generats per GenAI per als elements de revisió." "description": "Activa o desactiva les descripcions i resums generats per IA Generativa per als elements de revisió."
}, },
"alerts": { "alerts": {
"label": "Habilita el GenAI per a alertes", "label": "Habilita l'IA Generativa per a alertes",
"description": "Utilitzeu GenAI per a generar descripcions per als elements d'alerta." "description": "Utilitzeu IA Generativa per a generar descripcions per als elements d'alerta."
}, },
"detections": { "detections": {
"label": "Habilita el GenAI per a les deteccions", "label": "Habilita l'IA Generativa per a les deteccions",
"description": "Utilitzeu GenAI per generar descripcions per als elements de detecció." "description": "Utilitzeu IA Generativa per generar descripcions per als elements de detecció."
}, },
"additional_concerns": { "additional_concerns": {
"label": "Altres preocupacions", "label": "Altres preocupacions",
"description": "Una llista de preocupacions o notes addicionals que el GenAI ha de tenir en compte a l'hora d'avaluar l'activitat en aquesta càmera." "description": "Una llista de preocupacions o notes addicionals que l¡IA Generativa ha de tenir en compte a l'hora d'avaluar l'activitat en aquesta càmera."
}, },
"debug_save_thumbnails": { "debug_save_thumbnails": {
"label": "Desa les miniatures", "label": "Desa les miniatures",
"description": "Desa les miniatures que s'envien al proveïdor GenAI per a la depuració i la revisió." "description": "Desa les miniatures que s'envien al proveïdor IA Generativa per a la depuració i la revisió."
}, },
"preferred_language": { "preferred_language": {
"label": "Idioma preferit", "label": "Idioma preferit",
"description": "Idioma preferit per sol·licitar al proveïdor GenAI respostes generades." "description": "Idioma preferit per sol·licitar al proveïdor d'IA Generativa respostes generades."
}, },
"activity_context_prompt": { "activity_context_prompt": {
"label": "Indicador de context de l'activitat", "label": "Indicador de context de l'activitat",
"description": "Pregunta personalitzada que descriu el que és i no és una activitat sospitosa per proporcionar context per als resums de GenAI." "description": "Pregunta personalitzada que descriu el que és i no és una activitat sospitosa per proporcionar context per als resums de GenAI."
},
"response_style": {
"label": "Estil de resposta",
"description": "Escriviu un predefinit d'estil per a les descripcions generades de les revisions. Els valors predefinits ajusten el to i el nivell de detall del títol orientat a l'usuari, el resum i la descripció de l'escena; «predeterminat» deixa l'indicatiu incorporat sense canvis."
},
"frame_mode": {
"label": "Mode de fotograma",
"description": "Com es presenten els marcs al model. Els 'frames' envien l'indicatiu seguit pels fotogrames, que s'adapta als models que segueixen una seqüència per si sols. 'annotated.frames' etiqueta cada marc i entrellaça notes derivades del seguiment d'objectes, el que ajuda als models que perden el rastre de l'activitat que es repeteix o reverteix."
} }
}, },
"label": "Revisió", "label": "Revisió",
@@ -699,7 +759,7 @@
"description": "Configuració per a la transcripció d'àudio en viu i de veu utilitzada per a esdeveniments i llegendes en directe.", "description": "Configuració per a la transcripció d'àudio en viu i de veu utilitzada per a esdeveniments i llegendes en directe.",
"language": { "language": {
"label": "Idioma de transcripció", "label": "Idioma de transcripció",
"description": "Codi d'idioma utilitzat per a la transcripció/traducció (per exemple 'en' per a l'anglès). Vegeu https://whisper-api.com/docs/languages/ per als codis de llengua compatibles." "description": "Codi d'idioma utilitzat per a la transcripció/traducció (per exemple 'en' per a l'anglès), o 'auto' per permetre que el model el detecti. Vegeu https://whisper-api.com/docs/languages/ per als codis de llengua compatibles."
}, },
"device": { "device": {
"label": "Dispositiu de transcripció", "label": "Dispositiu de transcripció",
@@ -712,6 +772,10 @@
"live_enabled": { "live_enabled": {
"label": "Transcripció en viu", "label": "Transcripció en viu",
"description": "Habilita la transcripció en directe per a l'àudio a mesura que es rep." "description": "Habilita la transcripció en directe per a l'àudio a mesura que es rep."
},
"model": {
"label": "Model de transcripció d'àudio o nom del proveïdor GenAI",
"description": "El dorsal de transcripció: 'whisper' per als models locals integrats de Frigate, o el nom d'un proveïdor de GenAI amb el rol de transcriure."
} }
}, },
"version": { "version": {
@@ -971,7 +1035,7 @@
}, },
"default_role": { "default_role": {
"label": "Rol predeterminat", "label": "Rol predeterminat",
"description": "Rol predeterminat assignat als usuaris intermediaris autenticats quan no s'aplica cap mapatge de rols." "description": "Rol predeterminat assignat als usuaris intermediaris autenticats quan no s'aplica cap mapatge de rols. Establiu-ho a «none» per a denegar l'accés als usuaris sense mapar."
}, },
"separator": { "separator": {
"label": "Caràcter separador", "label": "Caràcter separador",
@@ -1019,7 +1083,7 @@
} }
}, },
"genai": { "genai": {
"label": "Configuració de la IA generada", "label": "Configuració de l'IA Generativa",
"description": "Paràmetres per als proveïdors integrats generatius d'IA utilitzats per generar descripcions d'objectes i resums de revisions.", "description": "Paràmetres per als proveïdors integrats generatius d'IA utilitzats per generar descripcions d'objectes i resums de revisions.",
"api_key": { "api_key": {
"label": "Clau API", "label": "Clau API",
@@ -1035,15 +1099,15 @@
}, },
"provider": { "provider": {
"label": "Proveïdor", "label": "Proveïdor",
"description": "El proveïdor GenAI a utilitzar (per exemple: ollama, gemini, openai)." "description": "El proveïdor IA Generativa a utilitzar (per exemple: ollama, gemini, openai)."
}, },
"roles": { "roles": {
"label": "Rols", "label": "Rols",
"description": "Rols de GenAI (xat, descripcions, incrustacions); un proveïdor per rol." "description": "Rols d'IA Generativa (xat, descripcions, incrustacions, transcriure); un proveïdor per rol. Per defecte només es concedeixen xats, descripcions i incrustacions; la transcripció s'ha de llistar explícitament."
}, },
"provider_options": { "provider_options": {
"label": "Opcions del proveïdor", "label": "Opcions del proveïdor",
"description": "Opcions addicionals específiques del proveïdor per passar al client GenAI." "description": "Opcions addicionals específiques del proveïdor per passar al client d'IA Generativa."
}, },
"runtime_options": { "runtime_options": {
"label": "Opcions de temps d'execució", "label": "Opcions de temps d'execució",
@@ -1084,6 +1148,10 @@
"num_threads": { "num_threads": {
"label": "Fils de detecció", "label": "Fils de detecció",
"description": "Nombre de fils a utilitzar per al processament de detecció d'àudio." "description": "Nombre de fils a utilitzar per al processament de detecció d'àudio."
},
"labelmap": {
"label": "Personalització del mapa d'etiquetes d'àudio",
"description": "Sobreescriu o reassigna les entrades per a fusionar-se en el mapa d'etiquetes d'àudio estàndard."
} }
}, },
"birdseye": { "birdseye": {
@@ -1132,7 +1200,11 @@
"label": "Posició", "label": "Posició",
"description": "Posició numèrica que controla l'ordenació de la càmera en la disposició Birdseye." "description": "Posició numèrica que controla l'ordenació de la càmera en la disposició Birdseye."
}, },
"label": "Birdseye" "label": "Birdseye",
"modes": {
"label": "Tipus d'activitat",
"description": "Tipus d'activitat que inclouen càmeres a Birdseye."
}
}, },
"detect": { "detect": {
"label": "Detecció d'objectes", "label": "Detecció d'objectes",
@@ -1192,6 +1264,10 @@
"annotation_offset": { "annotation_offset": {
"label": "Desplaçament de l'anotació", "label": "Desplaçament de l'anotació",
"description": "Mil·lisegons per a desplaçar detecta anotacions per a alinear millor els límits de la línia de temps amb els enregistraments; pot ser positiu o negatiu." "description": "Mil·lisegons per a desplaçar detecta anotacions per a alinear millor els límits de la línia de temps amb els enregistraments; pot ser positiu o negatiu."
},
"scene": {
"label": "Detecta l'escena",
"description": "L'entorn que mira aquesta càmera, utilitzat per triar quin dels models configurats s'executa en ell. Les càmeres que queden a 'totes' executen el model configurat amb una escena de 'totes'."
} }
}, },
"classification": { "classification": {
@@ -1399,5 +1475,57 @@
"active_profile": { "active_profile": {
"label": "Perfil actiu", "label": "Perfil actiu",
"description": "Nom de perfil actualment actiu. Només en temps d'execució, no ha persistit en YAML." "description": "Nom de perfil actualment actiu. Només en temps d'execució, no ha persistit en YAML."
},
"models": {
"label": "Models de detecció",
"description": "Models de detecció d'objectes i el maquinari en què s'executa. Les càmeres trien un model fent coincidir el seu detect.scene amb l'escena d'un model.",
"scene": {
"label": "Escena del model",
"description": "L'entorn de la càmera per al qual s'utilitza aquest model. Les càmeres seleccionen un model establint detect.scene a un valor coincident, i «all» és utilitzat per qualsevol càmera que no n'estableixi un."
},
"devices": {
"label": "hardware de detecció",
"description": "Aquest model funciona com a '<detector>' o '<detector>:<device>' (per exemple 'edgetpu:pci:0' o 'openvino:GPU'). La llista del mateix dispositiu més d'una vegada executa processos d'inferència addicionals."
},
"path": {
"label": "Ruta del model de detector d'objectes personalitzat",
"description": "Ruta a un fitxer de model de detecció personalitzat (o plus://<model_id> per a models Frigate+)."
},
"labelmap_path": {
"label": "Mapa d'etiquetes per al detector d'objectes personalitzat",
"description": "Ruta a un fitxer de mapa d'etiquetes que assigna classes numèriques a etiquetes de cadena per al detector."
},
"width": {
"label": "Amplada d'entrada del model de detecció d'objectes",
"description": "Amplada del tensor d'entrada del model en píxels."
},
"height": {
"label": "Alçada d'entrada del model de detecció d'objectes",
"description": "Alçada del tensor d'entrada del model en píxels."
},
"labelmap": {
"label": "Personalització del mapa d'etiquetes",
"description": "Sobreescriu o reassigna les entrades per a fusionar-se en el mapa d'etiquetes estàndard."
},
"attributes_map": {
"label": "Mapa d'etiquetes d'objectes a les seves etiquetes d'atribut",
"description": "Assignació des d'etiquetes d'objectes a etiquetes d'atribut utilitzades per adjuntar metadades (per exemple 'cotxe' -). ['matricula'])."
},
"input_tensor": {
"label": "Forma del sensor d'entrada del model",
"description": "Format del sensor esperat pel model: 'nhwc' o 'nchw'."
},
"input_pixel_format": {
"label": "Format de color del píxel d'entrada del model",
"description": "Espai de color del píxel esperat pel model: 'rgb', 'bgr' o 'yuv'."
},
"input_dtype": {
"label": "Tipus D d'entrada del model",
"description": "Tipus de dades del tensor d'entrada del model (per exemple «float32»)."
},
"model_type": {
"label": "Tipus de model de detecció d'objectes",
"description": "Tipus d'arquitectura del model de detector (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) utilitzat per alguns detectors per a l'optimització."
}
} }
} }
@@ -31,5 +31,8 @@
}, },
"detect": { "detect": {
"dimensionMustBeEven": "Ha de ser un nombre parell." "dimensionMustBeEven": "Ha de ser un nombre parell."
},
"models": {
"defaultRequired": "Un model ha d'utilitzar una escena de \"Totes les càmeres\". Sense ella, qualsevol càmera que no esculli una escena no té cap model al qual tornar."
} }
} }
+16
View File
@@ -55,6 +55,12 @@
"auto_scroll": { "auto_scroll": {
"title": "Desplaçament automàtic", "title": "Desplaçament automàtic",
"desc": "Segueix els missatges nous a mesura que arriben." "desc": "Segueix els missatges nous a mesura que arriben."
},
"always_allow": {
"title": "Accions sempre permeses",
"desc": "Accions que l'assistent pot executar sense preguntar primer.",
"none": "Cap",
"reset": "Restableix"
} }
}, },
"stats": { "stats": {
@@ -68,5 +74,15 @@
}, },
"thinking": { "thinking": {
"toggle": "Commuta el pensament" "toggle": "Commuta el pensament"
},
"approval": {
"title": "Aprova {{tool}}?",
"desc": "Aquesta acció canvia alguna cosa a Frigate. Revisa els detalls abans d'aprovar.",
"approve": "Aprova",
"always_allow": "Permet sempre",
"reject": "Rebutja",
"approved": "Aprovat",
"rejected": "Rebutjat",
"placeholder": "Aprova o rebutja l'acció pendent de continuar"
} }
} }
+2 -1
View File
@@ -91,5 +91,6 @@
"crop": "Escapça per filtrar", "crop": "Escapça per filtrar",
"cropAria": "Commuta la vista prèvia d'escapçament a les àrees filtrades", "cropAria": "Commuta la vista prèvia d'escapçament a les àrees filtrades",
"cropDesc": "Amplia les vistes prèvies a les àrees de filtre seleccionades en lloc de mostrar el fotograma complet." "cropDesc": "Amplia les vistes prèvies a les àrees de filtre seleccionades en lloc de mostrar el fotograma complet."
} },
"subOnlyQuality": "Només els enregistraments de baixa qualitat estan disponibles en aquest interval de temps"
} }

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