Compare commits

..

25 Commits

Author SHA1 Message Date
Hosted Weblate
9f631e6fa2
Added translation using Weblate (Zuni)
Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Added translation using Weblate (Zuni)

Co-authored-by: Firas <firas.amm@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
2026-06-01 21:52:07 +00:00
Hosted Weblate
7e1ad58a08
Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (1171 of 1171 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (53 of 53 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: OverTheHillsAndFarAway <prosjektx@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/nb_NO/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-settings
2026-06-01 21:52:06 +00:00
Hosted Weblate
516a68f103
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (807 of 807 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1268 of 1268 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (61 of 61 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 94.6% (1196 of 1263 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1195 of 1195 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1186 of 1186 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1183 of 1183 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1181 of 1181 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (53 of 53 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1176 of 1176 strings)

Co-authored-by: GuoQing Liu <842607283@qq.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/zh_Hans/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-settings
2026-06-01 21:52:06 +00:00
Hosted Weblate
2a4c5b9fe0
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 99.5% (237 of 238 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (26 of 26 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KelvinKueh <kelvin.kueh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/zh_Hant/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
2026-06-01 21:52:05 +00:00
Hosted Weblate
618cb76f3b
Translated using Weblate (Uzbek)
Currently translated at 0.3% (2 of 501 strings)

Co-authored-by: Hamza Foziljonov <hamza.uztranslator@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/uz/
Translation: Frigate NVR/audio
2026-06-01 21:52:05 +00:00
Hosted Weblate
0b74a93606
Translated using Weblate (Khmer (Central))
Currently translated at 0.9% (5 of 501 strings)

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: reanyouda <mr.reanyouda@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/km/
Translation: Frigate NVR/audio
2026-06-01 21:52:04 +00:00
Hosted Weblate
caf75a6edb
Translated using Weblate (French)
Currently translated at 67.0% (850 of 1268 strings)

Translated using Weblate (French)

Currently translated at 85.1% (86 of 101 strings)

Translated using Weblate (French)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (French)

Currently translated at 82.1% (83 of 101 strings)

Co-authored-by: Gloup <emeric.denis@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: LeBuzzy <bwinster2@outlook.com>
Co-authored-by: Lorent Felix <comloren@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/fr/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-settings
2026-06-01 21:52:03 +00:00
Hosted Weblate
b900701601
Translated using Weblate (Spanish)
Currently translated at 100.0% (807 of 807 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1268 of 1268 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (61 of 61 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1263 of 1263 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1263 of 1263 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Spanish)

Currently translated at 99.2% (1253 of 1263 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1186 of 1186 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1183 of 1183 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1181 of 1181 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1176 of 1176 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: jjavin <javiernovoa@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/es/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-settings
2026-06-01 21:52:03 +00:00
Hosted Weblate
1fb70c1d7d
Translated using Weblate (Dutch)
Currently translated at 83.9% (397 of 473 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (Dutch)

Currently translated at 97.9% (794 of 811 strings)

Translated using Weblate (Dutch)

Currently translated at 93.7% (224 of 239 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Dutch)

Currently translated at 92.8% (221 of 238 strings)

Translated using Weblate (Dutch)

Currently translated at 98.0% (1148 of 1171 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hosted Weblate user 151476 <marijndekker3@gmail.com>
Co-authored-by: Hosted Weblate user 151476 <micel@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/nl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/nl/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
2026-06-01 21:52:02 +00:00
Hosted Weblate
87fd03723d
Translated using Weblate (Indonesian)
Currently translated at 5.0% (24 of 473 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Indonesian)

Currently translated at 1.8% (15 of 811 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (64 of 64 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (59 of 59 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (Indonesian)

Currently translated at 86.6% (52 of 60 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (175 of 175 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Indonesian)

Currently translated at 59.3% (38 of 64 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (1176 of 1176 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Indonesian)

Currently translated at 30.7% (39 of 127 strings)

Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Joseph K <o.joseph.k@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/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/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/id/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-01 21:52:01 +00:00
Hosted Weblate
1697577fa1
Translated using Weblate (Arabic)
Currently translated at 28.3% (142 of 501 strings)

Translated using Weblate (Arabic)

Currently translated at 18.8% (24 of 127 strings)

Co-authored-by: Firas <firas.amm@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/ar/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/ar/
Translation: Frigate NVR/audio
Translation: Frigate NVR/objects
2026-06-01 21:52:00 +00:00
Hosted Weblate
ea02cb6fa0
Translated using Weblate (Italian)
Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Italian)

Currently translated at 26.4% (125 of 473 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (1195 of 1195 strings)

Translated using Weblate (Italian)

Currently translated at 28.3% (230 of 811 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Italian)

Currently translated at 26.2% (124 of 473 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Italian)

Currently translated at 28.2% (229 of 811 strings)

Translated using Weblate (Italian)

Currently translated at 28.1% (228 of 811 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (1183 of 1183 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (Italian)

Currently translated at 26.0% (123 of 473 strings)

Co-authored-by: Frank_ai <cyberpez.ai@gmail.com>
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-player/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/it/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
2026-06-01 21:52:00 +00:00
Hosted Weblate
112e6d94fb
Translated using Weblate (Polish)
Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (Polish)

Currently translated at 94.1% (224 of 238 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (501 of 501 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: magnumek <m4gnumek@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/pl/
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
2026-06-01 21:51:59 +00:00
Hosted Weblate
d209f88282
Translated using Weblate (Catalan)
Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1268 of 1268 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (807 of 807 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (61 of 61 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1263 of 1263 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (45 of 45 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1263 of 1263 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1195 of 1195 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1186 of 1186 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1183 of 1183 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (175 of 175 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1181 of 1181 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1176 of 1176 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Gerard Ricart Castells <gerard.ricart@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-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-explore/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/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-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-01 21:51:58 +00:00
Hosted Weblate
9b65e6c88b
Translated using Weblate (Japanese)
Currently translated at 100.0% (1263 of 1263 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (1263 of 1263 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Japanese)

Currently translated at 93.9% (1186 of 1263 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (10 of 10 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (501 of 501 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (175 of 175 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (59 of 59 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (101 of 101 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (1186 of 1186 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (64 of 64 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (74 of 74 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (45 of 45 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: alpha <etc@alpha-line.org>
Co-authored-by: yhi264 <yhiraki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-auth/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ja/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ja/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-auth
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/components-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-01 21:51:58 +00:00
Hosted Weblate
712cff33c5
Translated using Weblate (Ukrainian)
Currently translated at 93.0% (120 of 129 strings)

Translated using Weblate (Ukrainian)

Currently translated at 77.7% (136 of 175 strings)

Translated using Weblate (Ukrainian)

Currently translated at 54.9% (649 of 1181 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (101 of 101 strings)

Translated using Weblate (Ukrainian)

Currently translated at 92.2% (119 of 129 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.1% (25 of 26 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (Ukrainian)

Currently translated at 54.7% (644 of 1176 strings)

Translated using Weblate (Ukrainian)

Currently translated at 90.0% (91 of 101 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ivabil <ivanbilych@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/uk/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-01 21:51:57 +00:00
Hosted Weblate
70a2779eb2
Translated using Weblate (Romanian)
Currently translated at 100.0% (1263 of 1263 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1263 of 1263 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1186 of 1186 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1183 of 1183 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (53 of 53 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1176 of 1176 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (238 of 238 strings)

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-player/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-explore/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/
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
2026-06-01 21:51:56 +00:00
Hosted Weblate
ce4d22a3d6
Translated using Weblate (Estonian)
Currently translated at 35.0% (21 of 60 strings)

Translated using Weblate (Estonian)

Currently translated at 2.5% (12 of 473 strings)

Translated using Weblate (Estonian)

Currently translated at 13.9% (18 of 129 strings)

Translated using Weblate (Estonian)

Currently translated at 41.3% (60 of 145 strings)

Translated using Weblate (Estonian)

Currently translated at 18.5% (234 of 1263 strings)

Translated using Weblate (Estonian)

Currently translated at 1.8% (15 of 811 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Estonian)

Currently translated at 14.8% (8 of 54 strings)

Translated using Weblate (Estonian)

Currently translated at 11.4% (20 of 175 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Estonian)

Currently translated at 26.6% (12 of 45 strings)

Translated using Weblate (Estonian)

Currently translated at 3.3% (2 of 59 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (Estonian)

Currently translated at 1.6% (8 of 473 strings)

Translated using Weblate (Estonian)

Currently translated at 0.3% (3 of 811 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/et/
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-explore/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-replay/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/et/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/et/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-replay
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-01 21:51:55 +00:00
Hosted Weblate
c4606141ca
Translated using Weblate (German)
Currently translated at 100.0% (807 of 807 strings)

Translated using Weblate (German)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (German)

Currently translated at 100.0% (1268 of 1268 strings)

Translated using Weblate (German)

Currently translated at 100.0% (61 of 61 strings)

Translated using Weblate (German)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (German)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (German)

Currently translated at 95.6% (1208 of 1263 strings)

Translated using Weblate (German)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (German)

Currently translated at 100.0% (238 of 238 strings)

Translated using Weblate (German)

Currently translated at 100.0% (811 of 811 strings)

Translated using Weblate (German)

Currently translated at 100.0% (23 of 23 strings)

Translated using Weblate (German)

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (German)

Currently translated at 100.0% (473 of 473 strings)

Translated using Weblate (German)

Currently translated at 99.5% (1178 of 1183 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sebastian Sie <sebastian.neuplanitz@googlemail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/de/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/common
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-settings
2026-06-01 21:51:55 +00:00
Hosted Weblate
052f159ca4
Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.5% (238 of 239 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 80.1% (81 of 101 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (499 of 501 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.3% (234 of 238 strings)

Co-authored-by: AmilcarNetto <amilcar.netto@gmail.com>
Co-authored-by: Geraldo Fensterseifer Júnior <gerafenster@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
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-camera/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/pt_BR/
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
2026-06-01 21:51:54 +00:00
Hosted Weblate
46465cea63
Translated using Weblate (Turkish)
Currently translated at 88.1% (89 of 101 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Turhan Munis <turhan.munis@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/tr/
Translation: Frigate NVR/components-dialog
2026-06-01 21:51:54 +00:00
Josh Hawkins
db9e64c598
replace motion activity resample apply/agg lambdas with vectorized max() and first() (#23383)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / Assemble and push default build (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
2026-06-01 15:51:43 -06:00
Josh Hawkins
570e21340a
Miscellaneous fixes (#23373)
* republish MQTT switch states when a profile is activated or deactivated

* fix object mask default name when created from Explore tracking details

* tweak annotation offset max in UI

* optimize recordings/unavailable gap detection and drop empty motion activity buckets

* add tests
2026-06-01 13:55:52 -06:00
Josh Hawkins
8073174c20
Refactor motion search (#23378)
* refactor motion search

* cleanup dead code and tests

* tweaks

* fix multi-day seeking

* start playback a few seconds before the change so the motion is in view
2026-06-01 12:08:46 -05:00
Josh Hawkins
47a06c8b30
Tweaks (#23367)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
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
* add ptz presets and default role widgets

* language tweaks

* fix width in triggers view

* tweak iOS PWA message in notifications settings

* deprecate ui.date_style and ui.time_style

these have been unused since date/time formatting has been pushed to i18n

* add config migrator to remove date_style and time_style

* remove date_style and time_style from reference config

* fix camera list scrolling in state classification wizard on mobile
2026-05-31 15:09:10 -06:00
61 changed files with 2353 additions and 651 deletions

View File

@ -1083,22 +1083,6 @@ ui:
# Optional: Set the time format used.
# Options are browser, 12hour, or 24hour (default: shown below)
time_format: browser
# Optional: Set the date style for a specified length.
# Options are: full, long, medium, short
# Examples:
# short: 2/11/23
# medium: Feb 11, 2023
# full: Saturday, February 11, 2023
# (default: shown below).
date_style: short
# Optional: Set the time style for a specified length.
# Options are: full, long, medium, short
# Examples:
# short: 8:14 PM
# medium: 8:15:22 PM
# full: 8:15:22 PM Mountain Standard Time
# (default: shown below).
time_style: medium
# Optional: Set the unit system to either "imperial" or "metric" (default: metric)
# Used in the UI and in MQTT topics
unit_system: metric

View File

@ -155,21 +155,22 @@ Motion Search lets you scan recorded footage for changes inside a region of inte
To start a search, open the Actions menu in History or click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
1. Pick the camera and time range to scan.
1. Pick the camera and time range to scan. In the date pickers, days that have recordings available are underlined.
2. Draw a polygon on the camera frame to define the region of interest.
3. Adjust the search parameters if needed:
| Field | Description |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
| **Minimum Change Area** | Minimum percentage of the region of interest that must change for a frame to be considered significant. Raise it to ignore small movements (leaves, distant motion); lower it when the object you care about only covers a small slice of the ROI. |
| **Frame Skip** | Number of frames to skip between samples — at a camera recording 20 fps, a skip value of 20 takes motion samples roughly once per second. Higher values scan much faster and are usually the right choice; lower it only when you need to catch the exact appearance or disappearance of a fast-moving object. |
| **Maximum Results** | Maximum number of matching timestamps to return. |
| **Parallel mode** | Process multiple recording segments in parallel. Speeds up large time ranges at the cost of higher CPU usage. |
| Field | Description |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
| **Minimum Change Area** | Minimum size of a single moving region, as a percentage of the ROI, for a frame to count as significant. Raise it to ignore small movements (leaves, distant motion); lower it when your subject covers only a small slice of the ROI. Every result shows the percentage it scored, so you can use those values to tune this. |
| **Maximum Results** | Maximum number of matching timestamps to return. The search stops once it reaches this many results, so a lower value finishes sooner while a higher value scans further into the range. |
| **Parallel mode** | Decode multiple recording ranges at the same time. Speeds up large time ranges at the cost of higher decoding and CPU usage. |
Motion Search samples each recording's keyframes automatically, so there is no frame-rate or sampling setting to tune.
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
The results panel shows the time range being scanned, a live progress bar with the timestamp currently being analyzed, and the running result count. A collapsible **Search Metrics** section reports how many segments were scanned and processed, how many were skipped because no motion was recorded in the ROI (using the stored motion heatmap), how many frames were decoded, and the total search time. Skipping segments with no recorded motion in the selected ROI is what makes searching long time ranges practical.
#### Common use cases
@ -179,6 +180,15 @@ Frigate's main use case is to record and surface tracked objects, so Motion Sear
- **An object that was never detected.** Something Frigate doesn't have a model label for, an object too small or distant to be detected, or movement in a region where detection isn't running. The activity left no tracked object but did change the pixels, so a search can still find it.
- **Activity while detection was effectively paused.** Changes that occurred while object detection was disabled, motion was suppressed by `skip_motion_threshold`, or inside an area covered by a motion mask, won't appear as review items or tracked objects but can be recovered by searching the recordings directly.
#### Examples
These show how to choose the ROI and **Minimum Change Area** for two common goals. Minimum Change Area is the size of a single moving region as a percentage of the ROI you draw, so the right value depends on how much of the ROI your subject — and its movement between samples — covers.
Because samples are a second or more apart, a moving subject usually appears in two places at once in the comparison, so even ordinary motion often scores tens of percent and a low threshold lets in almost everything. The most reliable approach is to **run a search, look at the percentage each result scored, and set Minimum Change Area just below the values for the events you care about.** The default is 20%; the suggestions below are starting points.
- **When did this item first appear (or disappear)?** A package was dropped off, a car parked, or a trash can was moved, and you want the exact moment. Draw a **tight ROI** around the spot the item occupies and **raise Minimum Change Area** (start around 4060%). Because the item fills most of a tight ROI, its arrival or removal is a large change, while smaller nearby motion (shadows, a passing pedestrian) stays below the threshold. The **earliest result** is when it appeared; if you only care about that moment, a low Maximum Results finishes faster. If you get no hits, the ROI is probably looser than the item — lower the threshold or tighten the ROI.
- **What's been getting into the garden?** Something has been trampling a flower bed overnight and no object was ever tracked. Draw a **looser ROI** covering the whole bed and use a **lower Minimum Change Area than the case above** — start near the 20% default and lower it (toward 510%) only if a small or distant subject is missed, since it covers just a slice of a large region. Expect more results to scan through — step through the timestamps and jump to each to see what triggered it. If wind-blown plants add noise, raise Minimum Change Area or the Sensitivity Threshold.
#### Expected performance
Motion Search analyzes the saved recordings on demand rather than reading a pre-built index, so a search over a long range takes longer than browsing Motion Previews. Cost scales mainly with how much footage has to be examined: segments with no recorded motion in your ROI are skipped using the stored motion heatmap (shown as "segments skipped" in the status panel), so a quiet range finishes quickly while a busy one takes longer.
@ -186,5 +196,6 @@ Motion Search analyzes the saved recordings on demand rather than reading a pre-
To increase the speed of searches:
- Draw a tight ROI. Because **Minimum Change Area** is measured as a percentage of the region you draw, a tight ROI around where you expect the change makes the object fill a larger share of the area, so it clears the threshold more easily. A loose ROI makes the same object a small fraction of the region, so it can fall below the threshold and be missed — forcing you to lower Minimum Change Area, which lets in more noise.
- Keep Frame Skip high. A higher value samples fewer frames and speeds up the search considerably, while still landing within a few seconds of when the motion or object appeared — close enough to seek to in the recording. Only lower it when you need to pinpoint the exact frame something appears or disappears.
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher CPU usage while it runs.
- Narrow the time range to the window you care about, so there is less footage to examine.
- Lower **Maximum Results** when you only need the first few hits. Because the search stops once it reaches that many results, a smaller value lets a busy range finish early instead of scanning the whole window.
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher decoding and CPU usage while it runs.

View File

@ -7288,13 +7288,6 @@ components:
title: Min Area
description: Minimum change area as a percentage of the ROI
default: 5
frame_skip:
type: integer
maximum: 30
minimum: 1
title: Frame Skip
description: "Process every Nth frame (1=all frames, 5=every 5th frame)"
default: 5
parallel:
type: boolean
title: Parallel
@ -7380,6 +7373,16 @@ components:
anyOf:
- $ref: "#/components/schemas/MotionSearchMetricsResponse"
- type: "null"
scanning_timestamp:
anyOf:
- type: number
- type: "null"
title: Scanning Timestamp
progress:
anyOf:
- type: number
- type: "null"
title: Progress
type: object
required:
- success

View File

@ -41,12 +41,6 @@ class MotionSearchRequest(BaseModel):
le=100.0,
description="Minimum change area as a percentage of the ROI",
)
frame_skip: int = Field(
default=30,
ge=1,
le=120,
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
)
parallel: bool = Field(
default=False,
description="Enable parallel scanning across segments",
@ -97,6 +91,8 @@ class MotionSearchStatusResponse(BaseModel):
total_frames_processed: Optional[int] = None
error_message: Optional[str] = None
metrics: Optional[MotionSearchMetricsResponse] = None
scanning_timestamp: Optional[float] = None
progress: Optional[float] = None
@router.post(
@ -151,7 +147,6 @@ async def start_motion_search(
polygon_points=body.polygon_points,
threshold=body.threshold,
min_area=body.min_area,
frame_skip=body.frame_skip,
parallel=body.parallel,
max_results=body.max_results,
)
@ -231,6 +226,9 @@ async def get_motion_search_status_endpoint(
if job.metrics:
response_content["metrics"] = job.metrics.to_dict()
response_content["scanning_timestamp"] = job.scanning_timestamp
response_content["progress"] = job.progress
return JSONResponse(content=response_content)

View File

@ -299,22 +299,36 @@ async def no_recordings(
.iterator()
)
# Convert recordings to list of (start, end) tuples
# Convert recordings to list of (start, end) tuples, ordered by start_time
recordings = [(r["start_time"], r["end_time"]) for r in data]
# Merge overlapping/adjacent recordings into covered intervals. The query
# orders by start_time, so a single pass merges them
covered: list[tuple[float, float]] = []
for rec_start, rec_end in recordings:
if covered and rec_start <= covered[-1][1]:
covered[-1] = (covered[-1][0], max(covered[-1][1], rec_end))
else:
covered.append((rec_start, rec_end))
# Iterate through time segments and check if each has any recording
no_recording_segments = []
current = after
current_gap_start = None
idx = 0
covered_count = len(covered)
while current < before:
segment_end = min(current + scale, before)
# Check if this segment overlaps with any recording
has_recording = any(
rec_start < segment_end and rec_end > current
for rec_start, rec_end in recordings
)
# Advance past covered intervals that end before this segment begins;
# they cannot overlap this or any later segment.
while idx < covered_count and covered[idx][1] <= current:
idx += 1
# A covered interval overlaps the segment when it starts before the
# segment ends (its end is already known to be > current).
has_recording = idx < covered_count and covered[idx][0] < segment_end
if not has_recording:
# This segment has no recordings

View File

@ -605,9 +605,10 @@ def motion_activity(
if not filtered:
return JSONResponse(content=[])
camera_list = list(filtered)
clauses.append((Recordings.camera << camera_list))
else:
clauses.append((Recordings.camera << allowed_cameras))
camera_list = list(allowed_cameras)
clauses.append((Recordings.camera << camera_list))
data: list[Recordings] = (
Recordings.select(
@ -635,14 +636,12 @@ def motion_activity(
df.set_index(["start_time"], inplace=True)
# normalize data
motion = (
df["motion"]
.resample(f"{scale}s")
.apply(lambda x: max(x, key=abs, default=0.0))
.fillna(0.0)
.to_frame()
)
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
motion = df["motion"].resample(f"{scale}s").max().fillna(0.0).to_frame()
if len(camera_list) == 1:
cameras = df["camera"].resample(f"{scale}s").first().fillna("")
else:
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
df = motion.join(cameras)
length = df.shape[0]
@ -658,6 +657,11 @@ def motion_activity(
else:
df.iloc[i : i + chunk, 0] = 0.0
# Drop resample gap-fill buckets. The resample above emits a row for every
# {scale}s bucket spanning the range, and buckets with no recording get a
# motion of 0 (from fillna) and an empty camera (from joining an empty set).
df = df[df["camera"] != ""]
# change types for output
df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records")

View File

@ -146,7 +146,7 @@ class CameraConfig(FrigateBaseModel):
timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig,
title="Timestamp style",
description="Styling options for in-feed timestamps applied to recordings and snapshots.",
description="Styling options for timestamps applied to snapshots and Debug view.",
)
# Options without global fallback

View File

@ -5,7 +5,7 @@ import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from typing import Any, Callable, Optional
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
@ -34,6 +34,45 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
"zones": CameraConfigUpdateEnum.zones,
}
# Retained MQTT switch topics per profile section, with a payload getter.
# Republished on profile change so MQTT/HA don't show a stale toggle.
SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
"audio": [("audio", lambda c: "ON" if c.audio.enabled else "OFF")],
"birdseye": [
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
(
"birdseye_mode",
lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF",
),
],
"detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")],
"motion": [
("motion", lambda c: "ON" if c.motion.enabled else "OFF"),
("improve_contrast", lambda c: "ON" if c.motion.improve_contrast else "OFF"),
("motion_threshold", lambda c: c.motion.threshold),
("motion_contour_area", lambda c: c.motion.contour_area),
],
"notifications": [
("notifications", lambda c: "ON" if c.notifications.enabled else "OFF"),
],
"objects": [
("object_descriptions", lambda c: "ON" if c.objects.genai.enabled else "OFF"),
],
"record": [("recordings", lambda c: "ON" if c.record.enabled else "OFF")],
"review": [
("review_alerts", lambda c: "ON" if c.review.alerts.enabled else "OFF"),
(
"review_detections",
lambda c: "ON" if c.review.detections.enabled else "OFF",
),
(
"review_descriptions",
lambda c: "ON" if c.review.genai.enabled else "OFF",
),
],
"snapshots": [("snapshots", lambda c: "ON" if c.snapshots.enabled else "OFF")],
}
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles"
@ -310,6 +349,15 @@ class ProfileManager:
settings,
)
# republish MQTT switch states
if self.dispatcher is not None:
for suffix, get_payload in SECTION_STATE_TOPICS.get(section, ()):
self.dispatcher.publish(
f"{cam_name}/{suffix}/state",
get_payload(cam_config),
retain=True,
)
def _persist_active_profile(self, profile_name: Optional[str]) -> None:
"""Persist the active profile state to disk as JSON."""
try:

View File

@ -45,7 +45,7 @@ class ProxyConfig(FrigateBaseModel):
default_role: Optional[str] = Field(
default="viewer",
title="Default role",
description="Default role assigned to proxy-authenticated users when no role mapping applies (admin or viewer).",
description="Default role assigned to proxy-authenticated users when no role mapping applies.",
)
separator: Optional[str] = Field(
default=",",

View File

@ -5,7 +5,7 @@ from pydantic import Field
from .base import FrigateBaseModel
__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"]
__all__ = ["TimeFormatEnum", "UnitSystemEnum", "UIConfig"]
class TimeFormatEnum(str, Enum):
@ -14,13 +14,6 @@ class TimeFormatEnum(str, Enum):
hours24 = "24hour"
class DateTimeStyleEnum(str, Enum):
full = "full"
long = "long"
medium = "medium"
short = "short"
class UnitSystemEnum(str, Enum):
imperial = "imperial"
metric = "metric"
@ -37,16 +30,6 @@ class UIConfig(FrigateBaseModel):
title="Time format",
description="Time format to use in the UI (browser, 12hour, or 24hour).",
)
date_style: DateTimeStyleEnum = Field(
default=DateTimeStyleEnum.short,
title="Date style",
description="Date style to use in the UI (full, long, medium, short).",
)
time_style: DateTimeStyleEnum = Field(
default=DateTimeStyleEnum.medium,
title="Time style",
description="Time style to use in the UI (full, long, medium, short).",
)
unit_system: UnitSystemEnum = Field(
default=UnitSystemEnum.metric,
title="Unit system",

View File

@ -3,6 +3,8 @@
import logging
import os
import threading
import time
from collections.abc import Callable, Generator, Iterable
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass, field
from datetime import datetime
@ -19,6 +21,18 @@ from frigate.jobs.manager import (
get_job_by_id,
set_current_job,
)
from frigate.jobs.motion_search_batch import (
build_segment_time_map,
coalesce_runs,
stream_time_to_absolute,
)
from frigate.jobs.motion_search_decode import (
iter_vod_frames,
keyframe_sampling_eligible,
probe_video_dimensions,
probe_vod_keyframe_pts,
resolve_motion_decode_args,
)
from frigate.models import Recordings
from frigate.types import JobStatusTypesEnum
@ -26,6 +40,18 @@ logger = logging.getLogger(__name__)
# Constants
HEATMAP_GRID_SIZE = 16
# Max wall-clock span of one VOD run request (seconds). Bounds per-request size
# and gives streaming/cancel/early-exit granularity.
MAX_RUN_SECONDS = 600.0
# Treat segments within this many seconds end-to-start as time-contiguous.
RUN_GAP_EPSILON = 1.0
# Longest-side pixels for the ROI downscale before motion detection.
SCALE_TARGET = 400
# Minimum wall seconds between intra-run progress broadcasts.
PROGRESS_BROADCAST_INTERVAL = 1.0
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
FALLBACK_SAMPLE_FPS = 2.0
@dataclass
@ -69,13 +95,16 @@ class MotionSearchJob(Job):
polygon_points: list[list[float]] = field(default_factory=list)
threshold: int = 30
min_area: float = 5.0
frame_skip: int = 5
parallel: bool = False
max_results: int = 25
# Track progress
total_frames_processed: int = 0
# Live progress (ride the existing to_dict() websocket broadcast)
scanning_timestamp: Optional[float] = None
progress: float = 0.0
# Metrics for observability
metrics: Optional[MotionSearchMetrics] = None
@ -100,6 +129,113 @@ def create_polygon_mask(
return mask
def compute_roi_crop_and_scale(
polygon_points: list[list[float]],
frame_width: int,
frame_height: int,
scale_target: int,
) -> tuple[tuple[int, int, int, int], tuple[int, int]]:
"""Compute the ROI crop box and never-upscale scaled dimensions.
Returns ((crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)) in pixels.
The crop is the polygon's bounding box in frame pixels; the scaled size fits
the crop's longest side to ``scale_target`` without ever enlarging it.
"""
xs = [p[0] for p in polygon_points]
ys = [p[1] for p in polygon_points]
# nv12 (4:2:0) hwdownload requires even crop offsets and even crop/scale
# dimensions; otherwise ffmpeg rounds the chroma planes and the raw byte
# stream stops matching the expected frame size. Force even values, and the
# mask is built from these same values so the two stay aligned.
crop_x = int(min(xs) * frame_width)
crop_y = int(min(ys) * frame_height)
crop_x -= crop_x % 2
crop_y -= crop_y % 2
crop_w = max(2, int(max(xs) * frame_width) - crop_x)
crop_h = max(2, int(max(ys) * frame_height) - crop_y)
crop_w -= crop_w % 2
crop_h -= crop_h % 2
longest = max(crop_w, crop_h)
factor = min(1.0, scale_target / longest)
scaled_w = max(2, round(crop_w * factor))
scaled_h = max(2, round(crop_h * factor))
scaled_w -= scaled_w % 2
scaled_h -= scaled_h % 2
return (crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)
def build_scaled_roi_mask(
polygon_points: list[list[float]],
frame_width: int,
frame_height: int,
crop: tuple[int, int, int, int],
scaled: tuple[int, int],
) -> np.ndarray:
"""Rasterize the polygon mask at the scaled ROI size.
Builds the full-resolution mask, crops it to the ROI box, and nearest-
neighbor resizes it to the scaled dimensions so it lines up exactly with the
frames ffmpeg crops and scales.
"""
crop_w, crop_h, crop_x, crop_y = crop
scaled_w, scaled_h = scaled
full_mask = create_polygon_mask(polygon_points, frame_width, frame_height)
cropped = full_mask[crop_y : crop_y + crop_h, crop_x : crop_x + crop_w]
return cv2.resize(cropped, (scaled_w, scaled_h), interpolation=cv2.INTER_NEAREST)
def detect_motion_scaled(
frames: Iterable[tuple[int, np.ndarray]],
mask: np.ndarray,
threshold: int,
min_area: float,
timestamp_fn: Callable[[int], float],
) -> list[MotionSearchResult]:
"""Detect motion across pre-cropped, pre-scaled gray frames.
``frames`` yields (absolute_frame_index, gray_roi_frame); ``mask`` is the
scaled ROI mask. ``min_area`` is a percentage of the masked ROI. Mirrors the
full-res detection math (absdiff -> blur -> threshold -> dilate -> contours)
on the already-reduced frames.
"""
results: list[MotionSearchResult] = []
mask_area = np.count_nonzero(mask)
if mask_area == 0:
return results
min_area_pixels = int((min_area / 100.0) * mask_area)
prev: np.ndarray | None = None
for frame_idx, gray in frames:
masked = cv2.bitwise_and(gray, gray, mask=mask)
if prev is not None:
diff = cv2.absdiff(prev, masked)
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
_, thresh = cv2.threshold(diff_blurred, threshold, 255, cv2.THRESH_BINARY)
thresh_dilated = cv2.dilate(thresh, None, iterations=1) # type: ignore[call-overload]
thresh_masked = cv2.bitwise_and(thresh_dilated, thresh_dilated, mask=mask)
change_pixels = cv2.countNonZero(thresh_masked)
if change_pixels > min_area_pixels:
contours, _ = cv2.findContours(
thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
total_change_area = sum(
cv2.contourArea(c)
for c in contours
if cv2.contourArea(c) >= min_area_pixels
)
if total_change_area > 0:
change_percentage = (total_change_area / mask_area) * 100
results.append(
MotionSearchResult(
timestamp=timestamp_fn(frame_idx),
change_percentage=round(change_percentage, 2),
)
)
prev = masked
return results
def compute_roi_bbox_normalized(
polygon_points: list[list[float]],
) -> tuple[float, float, float, float]:
@ -184,6 +320,22 @@ def segment_passes_heatmap_gate(
return heatmap_overlaps_roi(heatmap, roi_bbox)
def resolve_internal_port(config: FrigateConfig) -> int:
"""Return the unauthenticated internal nginx port for VOD requests."""
listen = config.networking.listen.internal
if isinstance(listen, str):
return int(listen.split(":")[-1])
return int(listen)
def build_vod_url(internal_port: int, camera: str, start: float, end: float) -> str:
"""Build the internal VOD HLS URL for a camera time range."""
return (
f"http://127.0.0.1:{internal_port}/vod/{camera}"
f"/start/{start}/end/{end}/index.m3u8"
)
class MotionSearchRunner(threading.Thread):
"""Thread-based runner for motion search jobs with parallel verification."""
@ -206,6 +358,23 @@ class MotionSearchRunner(threading.Thread):
cpu_count = os.cpu_count() or 1
self.max_workers = min(4, cpu_count)
# Resolved once per job in _execute_search
self.ffmpeg_path: str = "ffmpeg"
self.ffprobe_path: str = "ffprobe"
self.decode_args: list[str] = []
# Keyframe sampling decision, decided once per job from the first run's
# GOP. The fallback cadence is a fixed rate (see FALLBACK_SAMPLE_FPS).
self.use_keyframe: bool = True
self.fps_rate: float = FALLBACK_SAMPLE_FPS
# ROI crop/scale + scaled mask, computed once from the VOD-stream
# dimensions (which can differ from the detect resolution).
self.crop: tuple[int, int, int, int] = (0, 0, 0, 0)
self.scaled: tuple[int, int] = (0, 0)
self.scaled_mask: np.ndarray = np.zeros((0, 0), dtype=np.uint8)
self.channels: int = 1
self.internal_port: int = 5000
self._last_progress_broadcast: float = 0.0
def run(self) -> None:
"""Execute the motion search job."""
try:
@ -281,6 +450,9 @@ class MotionSearchRunner(threading.Thread):
if frame_width is None or frame_height is None:
raise ValueError(f"Camera {camera_name} detect dimensions not configured")
self.ffmpeg_path = camera_config.ffmpeg.ffmpeg_path
self.ffprobe_path = camera_config.ffmpeg.ffprobe_path
# Create polygon mask
polygon_mask = create_polygon_mask(
self.job.polygon_points, frame_width, frame_height
@ -384,205 +556,274 @@ class MotionSearchRunner(threading.Thread):
self.metrics.heatmap_roi_skip_segments,
)
if self.job.parallel:
return self._search_motion_parallel(filtered_recordings, polygon_mask)
# Resolve decode backend (allowlisted hwaccel or software), coalesce the
# gate-passing segments into time-contiguous runs, and probe the first
# run's VOD stream once for dimensions + keyframe layout. VOD output is
# what we decode, so crop/scale/mask are computed against it.
self.internal_port = resolve_internal_port(self.config)
self.decode_args = resolve_motion_decode_args(camera_config)
ffprobe_path = self.ffprobe_path
return self._search_motion_sequential(filtered_recordings, polygon_mask)
runs = coalesce_runs(filtered_recordings, MAX_RUN_SECONDS, RUN_GAP_EPSILON)
if not runs:
return []
def _search_motion_parallel(
self,
recordings: list[Recordings],
polygon_mask: np.ndarray,
) -> list[MotionSearchResult]:
"""Search for motion in parallel across segments, streaming results."""
all_results: list[MotionSearchResult] = []
total_frames = 0
next_recording_idx_to_merge = 0
first_run = runs[0]
first_url = build_vod_url(
self.internal_port,
camera_name,
float(first_run[0].start_time),
float(first_run[-1].end_time),
)
dims = probe_video_dimensions(ffprobe_path, first_url)
if dims is None:
raise ValueError(f"Could not probe VOD dimensions for camera {camera_name}")
rec_width, rec_height, _rec_fps = dims
self.crop, self.scaled = compute_roi_crop_and_scale(
self.job.polygon_points, rec_width, rec_height, SCALE_TARGET
)
self.scaled_mask = build_scaled_roi_mask(
self.job.polygon_points, rec_width, rec_height, self.crop, self.scaled
)
self.channels = 1 # always gray output
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
# (keyframe structure is a per-camera constant).
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url)
self.use_keyframe = keyframe_sampling_eligible(first_pts)
logger.debug(
"Motion search job %s: starting motion search with %d workers "
"across %d segments",
"Motion search job %s: %d runs, sampling=%s, hwaccel=%s, vod=%dx%d",
self.job.id,
self.max_workers,
len(recordings),
len(runs),
"keyframe" if self.use_keyframe else "cadence",
bool(self.decode_args),
rec_width,
rec_height,
)
# Initialize partial results on the job so they stream to the frontend
return self._search_runs(runs)
def _emit_progress(self, abs_ts: float) -> None:
"""Throttled intra-run progress broadcast (scanning cursor)."""
now = time.monotonic()
if now - self._last_progress_broadcast < PROGRESS_BROADCAST_INTERVAL:
return
self._last_progress_broadcast = now
self.job.scanning_timestamp = abs_ts
self._broadcast_status()
def _detect_with_progress(
self,
indexed_frames: list[tuple[int, np.ndarray]],
timestamp_fn: Callable[[int], float],
) -> list[MotionSearchResult]:
"""Run detection while firing throttled progress as frames are scanned."""
def _gen() -> Generator[tuple[int, np.ndarray], None, None]:
for i, frame in indexed_frames:
if not self._should_stop():
self._emit_progress(timestamp_fn(i))
yield i, frame
return detect_motion_scaled(
_gen(),
self.scaled_mask,
self.job.threshold,
self.job.min_area,
timestamp_fn,
)
def _process_run(
self, run: list[Recordings]
) -> tuple[list[MotionSearchResult], int]:
"""Decode one run's VOD stream and detect motion.
Keyframe mode compares every decoded keyframe (free recall, since they
are all decoded anyway) paired with its probed PTS; if the decoded and
probed counts disagree (the decoder ignored ``-skip_frame nokey`` or the
stream is corrupt) this run re-runs in the fixed-cadence fallback.
Returns ``(results, frame_count)``.
"""
run_start: float = run[0].start_time # type: ignore[assignment]
run_end: float = run[-1].end_time # type: ignore[assignment]
vod_url = build_vod_url(self.internal_port, self.job.camera, run_start, run_end)
time_map = build_segment_time_map(run)
if self.use_keyframe:
kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url)
frames = list(
iter_vod_frames(
self.ffmpeg_path,
vod_url,
self.scaled[0],
self.scaled[1],
self.channels,
self.decode_args,
self.crop,
self.scaled,
True,
self._should_stop,
skip_nonkey=True,
fps_rate=None,
)
)
if kf_pts and len(frames) == len(kf_pts):
abs_times = [stream_time_to_absolute(time_map, p) for p in kf_pts]
indexed = list(enumerate(frames))
def _ts_kf(i: int) -> float:
return abs_times[i]
results = self._detect_with_progress(indexed, _ts_kf)
return results, len(frames)
logger.debug(
"Keyframe count mismatch (%d decoded vs %d probed), using cadence",
len(frames),
len(kf_pts),
)
return self._process_run_cadence(vod_url, time_map)
def _process_run_cadence(
self, vod_url: str, time_map: list[tuple[float, float, float]]
) -> tuple[list[MotionSearchResult], int]:
"""Fixed-cadence fallback: fps-filtered VOD decode, evenly spaced times."""
frames = list(
iter_vod_frames(
self.ffmpeg_path,
vod_url,
self.scaled[0],
self.scaled[1],
self.channels,
self.decode_args,
self.crop,
self.scaled,
True,
self._should_stop,
skip_nonkey=False,
fps_rate=self.fps_rate,
)
)
indexed = list(enumerate(frames))
def _ts_fps(i: int) -> float:
return stream_time_to_absolute(time_map, i / self.fps_rate)
results = self._detect_with_progress(indexed, _ts_fps)
return results, len(frames)
def _merge_run(
self,
run: list[Recordings],
run_results: list[MotionSearchResult],
frames: int,
state: dict[str, Any],
) -> bool:
"""Fold one run's output into the running results; stream + dedup.
Returns True once ``max_results`` deduped hits have accumulated.
"""
state["completed_runs"] += 1
state["all_results"].extend(run_results)
state["total_frames"] += frames
self.job.total_frames_processed = state["total_frames"]
self.metrics.frames_decoded = state["total_frames"]
self.metrics.segments_processed += len(run)
self.job.progress = state["completed_runs"] / state["total_runs"]
state["all_results"].sort(key=lambda r: r.timestamp)
deduped = self._deduplicate_results(state["all_results"])[
: self.job.max_results
]
self.job.results = {
"results": [r.to_dict() for r in deduped],
"total_frames_processed": state["total_frames"],
}
self._broadcast_status()
return len(deduped) >= self.job.max_results
def _search_runs(self, runs: list[list[Recordings]]) -> list[MotionSearchResult]:
"""Decode runs (parallel pool when enabled), merge in order, stream."""
state: dict[str, Any] = {
"all_results": [],
"total_frames": 0,
"completed_runs": 0,
"total_runs": len(runs),
}
self.job.results = {"results": [], "total_frames_processed": 0}
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures: dict[Future, int] = {}
completed_segments: dict[int, tuple[list[MotionSearchResult], int]] = {}
logger.debug(
"Motion search job %s: searching %d runs (parallel=%s, workers=%d)",
self.job.id,
len(runs),
self.job.parallel,
self.max_workers,
)
for idx, recording in enumerate(recordings):
if self._should_stop():
break
if self.job.parallel and len(runs) > 1:
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures: dict[Future, int] = {}
for idx, run in enumerate(runs):
if self._should_stop():
break
futures[executor.submit(self._process_run, run)] = idx
rec_start: float = recording.start_time # type: ignore[assignment]
rec_end: float = recording.end_time # type: ignore[assignment]
future = executor.submit(
self._process_recording_for_motion,
str(recording.path),
rec_start,
rec_end,
self.job.start_time_range,
self.job.end_time_range,
polygon_mask,
self.job.threshold,
self.job.min_area,
self.job.frame_skip,
)
futures[future] = idx
completed: dict[int, tuple[list[MotionSearchResult], int]] = {}
next_idx = 0
for future in as_completed(futures):
if self._should_stop():
break
run_idx = futures[future]
try:
completed[run_idx] = future.result()
except Exception as e:
self.metrics.segments_with_errors += 1
logger.warning("Error processing run %d: %s", run_idx, e)
completed[run_idx] = ([], 0)
for future in as_completed(futures):
if self._should_stop():
# Cancel remaining futures
for f in futures:
f.cancel()
break
recording_idx = futures[future]
recording = recordings[recording_idx]
try:
results, frames = future.result()
self.metrics.segments_processed += 1
completed_segments[recording_idx] = (results, frames)
while next_recording_idx_to_merge in completed_segments:
segment_results, segment_frames = completed_segments.pop(
next_recording_idx_to_merge
)
all_results.extend(segment_results)
total_frames += segment_frames
self.job.total_frames_processed = total_frames
self.metrics.frames_decoded = total_frames
if segment_results:
deduped = self._deduplicate_results(all_results)
self.job.results = {
"results": [
r.to_dict() for r in deduped[: self.job.max_results]
],
"total_frames_processed": total_frames,
}
self._broadcast_status()
if segment_results and len(deduped) >= self.job.max_results:
while next_idx in completed:
run_results, frames = completed.pop(next_idx)
if self._merge_run(runs[next_idx], run_results, frames, state):
self.internal_stop_event.set()
for pending_future in futures:
pending_future.cancel()
for pending in futures:
pending.cancel()
break
next_recording_idx_to_merge += 1
next_idx += 1
if self.internal_stop_event.is_set():
break
else:
for run in runs:
if self._should_stop():
break
try:
run_results, frames = self._process_run(run)
except Exception as e:
self.metrics.segments_processed += 1
self.metrics.segments_with_errors += 1
self.metrics.segments_processed += len(run)
self._broadcast_status()
logger.warning(
"Error processing segment %s: %s",
recording.path,
e,
)
self.job.total_frames_processed = total_frames
self.metrics.frames_decoded = total_frames
logger.debug(
"Motion search job %s: motion search complete, "
"found %d raw results, decoded %d frames, %d segment errors",
self.job.id,
len(all_results),
total_frames,
self.metrics.segments_with_errors,
)
# Sort and deduplicate results
all_results.sort(key=lambda x: x.timestamp)
return self._deduplicate_results(all_results)[: self.job.max_results]
def _search_motion_sequential(
self,
recordings: list[Recordings],
polygon_mask: np.ndarray,
) -> list[MotionSearchResult]:
"""Search for motion sequentially across segments, streaming results."""
all_results: list[MotionSearchResult] = []
total_frames = 0
logger.debug(
"Motion search job %s: starting sequential motion search across %d segments",
self.job.id,
len(recordings),
)
self.job.results = {"results": [], "total_frames_processed": 0}
for recording in recordings:
if self.cancel_event.is_set():
break
try:
rec_start: float = recording.start_time # type: ignore[assignment]
rec_end: float = recording.end_time # type: ignore[assignment]
results, frames = self._process_recording_for_motion(
str(recording.path),
rec_start,
rec_end,
self.job.start_time_range,
self.job.end_time_range,
polygon_mask,
self.job.threshold,
self.job.min_area,
self.job.frame_skip,
)
all_results.extend(results)
total_frames += frames
self.job.total_frames_processed = total_frames
self.metrics.frames_decoded = total_frames
self.metrics.segments_processed += 1
if results:
all_results.sort(key=lambda x: x.timestamp)
deduped = self._deduplicate_results(all_results)[
: self.job.max_results
]
self.job.results = {
"results": [r.to_dict() for r in deduped],
"total_frames_processed": total_frames,
}
self._broadcast_status()
if results and len(deduped) >= self.job.max_results:
logger.warning("Error processing run: %s", e)
continue
if self._merge_run(run, run_results, frames, state):
break
except Exception as e:
self.metrics.segments_processed += 1
self.metrics.segments_with_errors += 1
self._broadcast_status()
logger.warning("Error processing segment %s: %s", recording.path, e)
self.job.total_frames_processed = total_frames
self.metrics.frames_decoded = total_frames
all_results: list[MotionSearchResult] = state["all_results"]
self.job.total_frames_processed = state["total_frames"]
self.metrics.frames_decoded = state["total_frames"]
self.job.progress = 1.0
logger.debug(
"Motion search job %s: sequential motion search complete, "
"found %d raw results, decoded %d frames, %d segment errors",
"Motion search job %s: complete, %d raw results, %d frames, %d errors",
self.job.id,
len(all_results),
total_frames,
state["total_frames"],
self.metrics.segments_with_errors,
)
all_results.sort(key=lambda x: x.timestamp)
all_results.sort(key=lambda r: r.timestamp)
return self._deduplicate_results(all_results)[: self.job.max_results]
def _deduplicate_results(
@ -602,160 +843,6 @@ class MotionSearchRunner(threading.Thread):
return deduplicated
def _process_recording_for_motion(
self,
recording_path: str,
recording_start: float,
recording_end: float,
search_start: float,
search_end: float,
polygon_mask: np.ndarray,
threshold: int,
min_area: float,
frame_skip: int,
) -> tuple[list[MotionSearchResult], int]:
"""Process a single recording file for motion detection.
This method is designed to be called from a thread pool.
Args:
min_area: Minimum change area as a percentage of the ROI (0-100).
"""
results: list[MotionSearchResult] = []
frames_processed = 0
if not os.path.exists(recording_path):
logger.warning("Recording file not found: %s", recording_path)
return results, frames_processed
cap = cv2.VideoCapture(recording_path)
if not cap.isOpened():
logger.error("Could not open recording: %s", recording_path)
return results, frames_processed
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
recording_duration = recording_end - recording_start
# Calculate frame range
start_offset = max(0, search_start - recording_start)
end_offset = min(recording_duration, search_end - recording_start)
start_frame = int(start_offset * fps)
end_frame = int(end_offset * fps)
start_frame = max(0, min(start_frame, total_frames - 1))
end_frame = max(0, min(end_frame, total_frames))
if start_frame >= end_frame:
return results, frames_processed
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
# Get ROI bounding box
roi_bbox = cv2.boundingRect(polygon_mask)
roi_x, roi_y, roi_w, roi_h = roi_bbox
prev_frame_gray = None
frame_step = max(frame_skip, 1)
frame_idx = start_frame
while frame_idx < end_frame:
if self._should_stop():
break
ret, frame = cap.read()
if not ret:
frame_idx += 1
continue
if (frame_idx - start_frame) % frame_step != 0:
frame_idx += 1
continue
frames_processed += 1
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# Handle frame dimension changes
if gray.shape != polygon_mask.shape:
resized_mask = cv2.resize(
polygon_mask,
(gray.shape[1], gray.shape[0]),
interpolation=cv2.INTER_NEAREST,
)
current_bbox = cv2.boundingRect(resized_mask)
else:
resized_mask = polygon_mask
current_bbox = roi_bbox
roi_x, roi_y, roi_w, roi_h = current_bbox
cropped_gray = gray[roi_y : roi_y + roi_h, roi_x : roi_x + roi_w]
cropped_mask = resized_mask[
roi_y : roi_y + roi_h, roi_x : roi_x + roi_w
]
cropped_mask_area = np.count_nonzero(cropped_mask)
if cropped_mask_area == 0:
frame_idx += 1
continue
# Convert percentage to pixel count for this ROI
min_area_pixels = int((min_area / 100.0) * cropped_mask_area)
masked_gray = cv2.bitwise_and(
cropped_gray, cropped_gray, mask=cropped_mask
)
if prev_frame_gray is not None:
diff = cv2.absdiff(prev_frame_gray, masked_gray) # type: ignore[unreachable]
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
_, thresh = cv2.threshold(
diff_blurred, threshold, 255, cv2.THRESH_BINARY
)
thresh_dilated = cv2.dilate(thresh, None, iterations=1)
thresh_masked = cv2.bitwise_and(
thresh_dilated, thresh_dilated, mask=cropped_mask
)
change_pixels = cv2.countNonZero(thresh_masked)
if change_pixels > min_area_pixels:
contours, _ = cv2.findContours(
thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
total_change_area = sum(
cv2.contourArea(c)
for c in contours
if cv2.contourArea(c) >= min_area_pixels
)
if total_change_area > 0:
frame_time_offset = (frame_idx - start_frame) / fps
timestamp = (
recording_start + start_offset + frame_time_offset
)
change_percentage = (
total_change_area / cropped_mask_area
) * 100
results.append(
MotionSearchResult(
timestamp=timestamp,
change_percentage=round(change_percentage, 2),
)
)
prev_frame_gray = masked_gray
frame_idx += 1
finally:
cap.release()
logger.debug(
"Motion search segment complete: %s, %d frames processed, %d results found",
recording_path,
frames_processed,
len(results),
)
return results, frames_processed
# Module-level state for managing per-camera jobs
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
@ -779,7 +866,6 @@ def start_motion_search_job(
polygon_points: list[list[float]],
threshold: int = 30,
min_area: float = 5.0,
frame_skip: int = 5,
parallel: bool = False,
max_results: int = 25,
) -> str:
@ -794,7 +880,6 @@ def start_motion_search_job(
polygon_points=polygon_points,
threshold=threshold,
min_area=min_area,
frame_skip=frame_skip,
parallel=parallel,
max_results=max_results,
)
@ -812,14 +897,13 @@ def start_motion_search_job(
logger.debug(
"Started motion search job %s for camera %s: "
"time_range=%.1f-%.1f, threshold=%d, min_area=%.1f%%, "
"frame_skip=%d, parallel=%s, max_results=%d, polygon_points=%d vertices",
"parallel=%s, max_results=%d, polygon_points=%d vertices",
job.id,
camera_name,
start_time,
end_time,
threshold,
min_area,
frame_skip,
parallel,
max_results,
len(polygon_points),

View File

@ -0,0 +1,75 @@
"""Pure helpers for VOD-batched motion search.
Coalescing gate-passing segments into time-contiguous runs, mapping a frame's
VOD stream time back to an absolute timestamp, and thinning sample times to a
target interval. No I/O or ffmpeg here so the tricky math stays unit-testable.
"""
from bisect import bisect_right
from typing import Any
def coalesce_runs(
segments: list[Any], max_seconds: float, epsilon: float
) -> list[list[Any]]:
"""Group gate-passing segments into time-contiguous runs.
A run extends while each segment's ``start_time`` is within ``epsilon`` of
the previous segment's ``end_time`` (no recording gap) and the run's total
span stays at or below ``max_seconds``. A gap or the cap starts a new run.
Each segment must expose ``start_time`` / ``end_time``.
"""
runs: list[list[Any]] = []
current: list[Any] = []
for seg in segments:
if not current:
current = [seg]
continue
prev_end = float(current[-1].end_time)
run_start = float(current[0].start_time)
contiguous = abs(float(seg.start_time) - prev_end) <= epsilon
within_cap = (float(seg.end_time) - run_start) <= max_seconds
if contiguous and within_cap:
current.append(seg)
else:
runs.append(current)
current = [seg]
if current:
runs.append(current)
return runs
def build_segment_time_map(
run: list[Any],
) -> list[tuple[float, float, float]]:
"""Build a (stream_offset, abs_start, duration) row per segment in a run.
``stream_offset`` is the segment's start in continuous VOD stream time (the
cumulative sum of preceding segment durations); ``abs_start`` is its absolute
``start_time``. Built from each segment's own duration; for a gap-free run
this makes stream time equal ``run_start + offset``.
"""
rows: list[tuple[float, float, float]] = []
offset = 0.0
for seg in run:
duration = float(seg.end_time) - float(seg.start_time)
rows.append((offset, float(seg.start_time), duration))
offset += duration
return rows
def stream_time_to_absolute(
time_map: list[tuple[float, float, float]], stream_time: float
) -> float:
"""Map a VOD stream time to an absolute timestamp via the run's table.
Binary-searches the segment whose stream range contains ``stream_time`` and
returns ``abs_start + (stream_time - stream_offset)``. Times past the last
segment map into the last segment (clamped at the run edge).
"""
offsets = [row[0] for row in time_map]
idx = bisect_right(offsets, stream_time) - 1
if idx < 0:
idx = 0
stream_offset, abs_start, _duration = time_map[idx]
return abs_start + (stream_time - stream_offset)

View File

@ -0,0 +1,382 @@
"""Hardware-accelerated ffmpeg decode for motion search.
Decodes a recording run's VOD/HLS stream with an ffmpeg subprocess, optionally
selecting only keyframes, and streams raw frames over a pipe for the motion
math. Output is the requested ``pix_fmt`` (gray or ``bgr24``) with optional
crop/scale applied in the filter graph so downstream pixels are unchanged.
"""
import json
import logging
import subprocess as sp
import tempfile
from collections.abc import Callable, Generator
from typing import IO
import numpy as np
from frigate.config import CameraConfig
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_decode
from frigate.util.services import auto_detect_hwaccel
logger = logging.getLogger(__name__)
# Output-format surfaces that download cleanly to nv12 via the fixed
# ``hwdownload,format=nv12`` step the decode path appends. Other surfaces
# (drm_prime from rkmpp, vulkan, amf) need a different download step, so motion
# search decodes them in software to keep results byte-identical rather than risk
# a wrong-but-valid-sized frame the zero-frame fallback gate would not catch.
_NV12_OUTPUT_FORMATS = frozenset({"vaapi", "cuda", "qsv"})
def _hwaccel_output_format(decode_args: list[str]) -> str | None:
"""Return the ``-hwaccel_output_format`` value in ffmpeg args, or None."""
try:
idx = decode_args.index("-hwaccel_output_format")
except ValueError:
return None
return decode_args[idx + 1] if idx + 1 < len(decode_args) else None
def resolve_motion_decode_args(camera_config: CameraConfig) -> list[str]:
"""Resolve the ffmpeg hwaccel decode args for a camera's recordings.
``auto`` is resolved via ``auto_detect_hwaccel`` and the preset is expanded
by ``parse_preset_hardware_acceleration_decode`` (the same table the live
pipeline uses). Acceleration is kept only when the decoded surface downloads
cleanly to nv12 -- decided by reading ``-hwaccel_output_format`` back from the
resolved args rather than a separate preset allowlist that could drift from
``PRESETS_HW_ACCEL_DECODE``. Anything else (custom args, a software-only
preset, or an nv12-incompatible surface) returns an empty list, meaning
software decode, so results stay byte-identical.
"""
raw = camera_config.ffmpeg.hwaccel_args
preset = auto_detect_hwaccel() if raw == "auto" else raw
# Custom args (a list) decode in software so results stay byte-identical.
if not isinstance(preset, str):
return []
decode_args = parse_preset_hardware_acceleration_decode(
preset,
camera_config.detect.fps,
camera_config.detect.width or 0,
camera_config.detect.height or 0,
camera_config.ffmpeg.gpu,
)
if not decode_args:
return []
if _hwaccel_output_format(decode_args) not in _NV12_OUTPUT_FORMATS:
return []
return decode_args
def _read_exact(stream: IO[bytes], size: int) -> bytes | None:
"""Read exactly ``size`` bytes from a pipe, or None at clean EOF.
Pipe reads can return fewer bytes than requested, so loop until the frame
is complete. A short read at the start of a frame means end-of-stream.
"""
buf = bytearray()
while len(buf) < size:
chunk = stream.read(size - len(buf))
if not chunk:
return None
buf.extend(chunk)
return bytes(buf)
def _terminate(proc: sp.Popen[bytes]) -> None:
"""Stop an ffmpeg decode process promptly."""
# Close the read end first so a blocked ffmpeg write unblocks (ffmpeg then
# sees a broken pipe), then signal it. The resulting ffmpeg write error is
# harmless and goes to the captured stderr.
if proc.stdout is not None:
try:
proc.stdout.close()
except OSError:
pass
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except sp.TimeoutExpired:
proc.kill()
proc.wait()
KEYFRAME_MAX_GAP_SECONDS = 2.0
def keyframe_sampling_eligible(
keyframe_pts: list[float], max_gap: float = KEYFRAME_MAX_GAP_SECONDS
) -> bool:
"""True if keyframes are dense and regular enough for keyframe-only sampling.
Requires at least two keyframes and no gap longer than ``max_gap`` seconds, so
a multi-second motion event necessarily spans a sampled keyframe.
"""
if len(keyframe_pts) < 2:
return False
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
return max(gaps) <= max_gap
VOD_PROTOCOL_ARGS = ["-protocol_whitelist", "pipe,file,http,tcp"]
def build_vod_decode_command(
ffmpeg_path: str,
vod_url: str,
decode_args: list[str],
crop: tuple[int, int, int, int] | None,
scale: tuple[int, int] | None,
gray: bool,
*,
skip_nonkey: bool,
fps_rate: float | None,
) -> list[str]:
"""Build the ffmpeg argv to decode a VOD HLS URL.
``skip_nonkey`` adds ``-skip_frame nokey`` (keyframe-only). ``fps_rate`` adds
an ``fps`` filter for the fixed-cadence fallback. They are mutually
exclusive: keyframe mode passes ``skip_nonkey=True``/``fps_rate=None``; the
fallback passes ``skip_nonkey=False`` with a rate.
"""
filters: list[str] = []
# With hwaccel the decoded frames are GPU surfaces; pull them back to system
# memory before the CPU fps/crop/scale filters and the rawvideo encoder.
if decode_args:
filters.append("hwdownload")
filters.append("format=nv12")
if fps_rate is not None:
filters.append(f"fps={fps_rate}")
if crop is not None:
cw, ch, cx, cy = crop
filters.append(f"crop={cw}:{ch}:{cx}:{cy}")
if scale is not None:
sw, sh = scale
filters.append(f"scale={sw}:{sh}")
pix_fmt = "gray" if gray else "bgr24"
cmd = [ffmpeg_path, "-hide_banner", "-loglevel", "error"]
if skip_nonkey:
cmd += ["-skip_frame", "nokey"]
cmd += [*decode_args, *VOD_PROTOCOL_ARGS, "-i", vod_url, "-an"]
if filters:
cmd += ["-vf", ",".join(filters)]
cmd += ["-vsync", "0", "-f", "rawvideo", "-pix_fmt", pix_fmt, "pipe:"]
return cmd
def _run_vod_decode(
ffmpeg_path: str,
vod_url: str,
out_width: int,
out_height: int,
channels: int,
decode_args: list[str],
crop: tuple[int, int, int, int] | None,
scale: tuple[int, int] | None,
gray: bool,
should_stop: Callable[[], bool],
*,
skip_nonkey: bool,
fps_rate: float | None,
software_retry: bool,
) -> Generator[np.ndarray, None, None]:
"""Run one VOD decode, yielding raw frames; retry in software if empty."""
cmd = build_vod_decode_command(
ffmpeg_path,
vod_url,
decode_args,
crop,
scale,
gray,
skip_nonkey=skip_nonkey,
fps_rate=fps_rate,
)
frame_size = out_width * out_height * channels
stderr_file = tempfile.SpooledTemporaryFile(max_size=65536)
proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=stderr_file)
assert proc.stdout is not None
count = 0
try:
while True:
if should_stop():
break
buf = _read_exact(proc.stdout, frame_size)
if buf is None:
break
if channels == 1:
frame = np.frombuffer(buf, dtype=np.uint8).reshape(
(out_height, out_width)
)
else:
frame = np.frombuffer(buf, dtype=np.uint8).reshape(
(out_height, out_width, channels)
)
count += 1
yield frame
finally:
_terminate(proc)
stderr_file.close()
if count == 0 and software_retry and not should_stop():
logger.warning("Hardware VOD decode produced no frames, retrying in software")
yield from _run_vod_decode(
ffmpeg_path,
vod_url,
out_width,
out_height,
channels,
[],
crop,
scale,
gray,
should_stop,
skip_nonkey=skip_nonkey,
fps_rate=fps_rate,
software_retry=False,
)
def iter_vod_frames(
ffmpeg_path: str,
vod_url: str,
out_width: int,
out_height: int,
channels: int,
decode_args: list[str],
crop: tuple[int, int, int, int] | None,
scale: tuple[int, int] | None,
gray: bool,
should_stop: Callable[[], bool],
*,
skip_nonkey: bool,
fps_rate: float | None,
) -> Generator[np.ndarray, None, None]:
"""Decode a VOD HLS URL and yield raw frames in order.
Pair keyframe-mode output with probed keyframe PTS; pair fallback output with
a fixed cadence. Falls back once to software decode if a hwaccel decode yields
no frames.
"""
yield from _run_vod_decode(
ffmpeg_path,
vod_url,
out_width,
out_height,
channels,
decode_args,
crop,
scale,
gray,
should_stop,
skip_nonkey=skip_nonkey,
fps_rate=fps_rate,
software_retry=bool(decode_args),
)
def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]:
"""Return keyframe presentation timestamps (VOD stream time) in order.
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
any failure so the caller can fall back.
"""
cmd = [
ffprobe_path,
"-v",
"error",
*VOD_PROTOCOL_ARGS,
"-i",
vod_url,
"-select_streams",
"v:0",
"-show_packets",
"-show_entries",
"packet=pts_time,flags",
"-of",
"json",
]
try:
completed = sp.run(cmd, capture_output=True, text=True, timeout=120)
except (OSError, sp.SubprocessError):
logger.warning("ffprobe failed for VOD keyframe probe")
return []
if completed.returncode != 0 or not completed.stdout:
return []
try:
packets = json.loads(completed.stdout).get("packets", [])
except json.JSONDecodeError:
return []
pts: list[float] = []
for pkt in packets:
flags = pkt.get("flags", "")
pts_time = pkt.get("pts_time")
if flags.startswith("K") and pts_time is not None:
try:
pts.append(float(pts_time))
except ValueError:
continue
return sorted(pts)
def probe_video_dimensions(
ffprobe_path: str, recording_path: str
) -> tuple[int, int, float] | None:
"""Return (width, height, fps) for a recording's video stream, or None.
Reads stream metadata via ffprobe (no decode). The record stream resolution
can differ from the camera's detect resolution, so this is probed once per
job against a real segment.
"""
cmd = [
ffprobe_path,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,avg_frame_rate",
"-of",
"json",
recording_path,
]
try:
completed = sp.run(cmd, capture_output=True, text=True, timeout=30)
except (OSError, sp.SubprocessError):
return None
if completed.returncode != 0 or not completed.stdout:
return None
try:
streams = json.loads(completed.stdout).get("streams", [])
except json.JSONDecodeError:
return None
if not streams:
return None
stream = streams[0]
width = int(stream.get("width", 0) or 0)
height = int(stream.get("height", 0) or 0)
rate = stream.get("avg_frame_rate", "0/0") or "0/0"
try:
num, _, den = rate.partition("/")
fps = float(num) / float(den) if float(den) != 0 else 0.0
except (ValueError, ZeroDivisionError):
fps = 0.0
if width <= 0 or height <= 0:
return None
return width, height, fps

View File

@ -403,3 +403,75 @@ class TestHttpMedia(BaseTestHttp):
assert len(summary) == 1
assert "2024-03-10" in summary
assert summary["2024-03-10"] is True
def test_recordings_unavailable_reports_gap_between_recordings(self):
"""A gap between two recordings is reported as an unavailable segment."""
with AuthTestClient(self.app) as client:
# Two recordings with a 20s gap (1010-1030) between them.
Recordings.insert(
id="rec_a",
path="/media/recordings/a.mp4",
camera="front_door",
start_time=1000,
end_time=1010,
duration=10,
motion=0,
).execute()
Recordings.insert(
id="rec_b",
path="/media/recordings/b.mp4",
camera="front_door",
start_time=1030,
end_time=1040,
duration=10,
motion=0,
).execute()
response = client.get(
"/recordings/unavailable",
params={
"after": 1000,
"before": 1040,
"scale": 5,
"cameras": "front_door",
},
)
assert response.status_code == 200
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
def test_recordings_unavailable_merges_overlapping_recordings(self):
"""Overlapping recordings are merged so no false gap is reported."""
with AuthTestClient(self.app) as client:
# Overlapping recordings spanning the whole requested range.
Recordings.insert(
id="rec_a",
path="/media/recordings/a.mp4",
camera="front_door",
start_time=1000,
end_time=1020,
duration=20,
motion=0,
).execute()
Recordings.insert(
id="rec_b",
path="/media/recordings/b.mp4",
camera="front_door",
start_time=1010,
end_time=1030,
duration=20,
motion=0,
).execute()
response = client.get(
"/recordings/unavailable",
params={
"after": 1000,
"before": 1030,
"scale": 5,
"cameras": "front_door",
},
)
assert response.status_code == 200
assert response.json() == []

View File

@ -610,19 +610,16 @@ class TestHttpReview(BaseTestHttp):
response = client.get("/review/activity/motion", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 61
# Only buckets with an actual recording are returned. Empty
# gap-fill buckets between the two recordings are dropped.
assert len(response_json) == 2
self.assertDictEqual(
{"motion": 50.5, "camera": "front_door", "start_time": now + 1},
response_json[0],
)
for item in response_json[1:-1]:
self.assertDictEqual(
{"motion": 0.0, "camera": "", "start_time": item["start_time"]},
item,
)
self.assertDictEqual(
{"motion": 100.0, "camera": "front_door", "start_time": one_m + 1},
response_json[len(response_json) - 1],
response_json[1],
)
####################################################################################################################

View File

@ -0,0 +1,58 @@
"""Tests for motion search batch helpers (runs + timestamp mapping)."""
import unittest
from dataclasses import dataclass
from frigate.jobs.motion_search_batch import (
build_segment_time_map,
coalesce_runs,
stream_time_to_absolute,
)
@dataclass
class _Seg:
path: str
start_time: float
end_time: float
def _run_seconds(run):
return float(run[-1].end_time) - float(run[0].start_time)
class TestCoalesceRuns(unittest.TestCase):
def test_contiguous_segments_form_one_run(self):
segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 20.0, 30.0)]
runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5)
self.assertEqual(len(runs), 1)
self.assertEqual(len(runs[0]), 3)
def test_time_gap_splits_runs(self):
# b ends 20, c starts 25 -> 5s gap > epsilon -> two runs.
segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 25.0, 35.0)]
runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5)
self.assertEqual([len(r) for r in runs], [2, 1])
def test_max_duration_caps_a_run(self):
# Five contiguous 10s segments, cap 25s.
segs = [_Seg(str(i), i * 10.0, i * 10.0 + 10.0) for i in range(5)]
runs = coalesce_runs(segs, max_seconds=25.0, epsilon=0.5)
self.assertTrue(all(_run_seconds(r) <= 30.0 for r in runs))
self.assertEqual(sum(len(r) for r in runs), 5)
def test_empty(self):
self.assertEqual(coalesce_runs([], max_seconds=600.0, epsilon=0.5), [])
class TestTimestampMapping(unittest.TestCase):
def test_gapfree_run_maps_to_start_plus_pts(self):
run = [_Seg("a", 1000.0, 1010.0), _Seg("b", 1010.0, 1020.0)]
time_map = build_segment_time_map(run)
self.assertAlmostEqual(stream_time_to_absolute(time_map, 3.0), 1003.0)
self.assertAlmostEqual(stream_time_to_absolute(time_map, 12.0), 1012.0)
def test_past_end_clamps(self):
run = [_Seg("a", 1000.0, 1010.0)]
time_map = build_segment_time_map(run)
self.assertAlmostEqual(stream_time_to_absolute(time_map, 9.9), 1009.9)

View File

@ -0,0 +1,190 @@
"""Tests for the motion search hardware-accelerated decode helpers."""
import unittest
from types import SimpleNamespace
from unittest import mock
from frigate.jobs.motion_search_decode import (
KEYFRAME_MAX_GAP_SECONDS,
build_vod_decode_command,
keyframe_sampling_eligible,
probe_video_dimensions,
probe_vod_keyframe_pts,
resolve_motion_decode_args,
)
def _fake_camera_config(
hwaccel_args, gpu=0, fps=5, width=1280, height=720, ffmpeg_path="ffmpeg"
):
return SimpleNamespace(
ffmpeg=SimpleNamespace(
hwaccel_args=hwaccel_args, gpu=gpu, ffmpeg_path=ffmpeg_path
),
detect=SimpleNamespace(fps=fps, width=width, height=height),
)
class TestResolveMotionDecodeArgs(unittest.TestCase):
def test_vaapi_preset_is_accelerated(self):
args = resolve_motion_decode_args(_fake_camera_config("preset-vaapi"))
self.assertIn("-hwaccel", args)
self.assertIn("vaapi", args)
def test_non_nv12_preset_falls_back_to_software(self):
# rkmpp produces drm_prime surfaces that do not download to nv12, so it
# must resolve to software decode (empty args) rather than risk corrupt
# frames.
self.assertEqual(
resolve_motion_decode_args(_fake_camera_config("preset-rkmpp")), []
)
def test_custom_args_fall_back_to_software(self):
# Arbitrary custom hwaccel args (a list, not a preset) decode in software
# to preserve byte-identical results.
self.assertEqual(
resolve_motion_decode_args(_fake_camera_config(["-hwaccel", "vulkan"])),
[],
)
def test_nvidia_codec_preset_is_accelerated(self):
# Codec-specific nvidia presets resolve to the same cuda decode args as
# the bare preset, so eligibility is derived from -hwaccel_output_format
# rather than a hardcoded list that omitted these aliases.
args = resolve_motion_decode_args(_fake_camera_config("preset-nvidia-h264"))
self.assertIn("-hwaccel_output_format", args)
self.assertIn("cuda", args)
def test_software_only_preset_falls_back_to_software(self):
# A preset with no -hwaccel_output_format (decoder-based, no GPU surface)
# cannot use the nv12 download step, so it decodes in software.
self.assertEqual(
resolve_motion_decode_args(_fake_camera_config("preset-rpi-64-h264")), []
)
class TestKeyframeEligibility(unittest.TestCase):
def test_regular_short_gop_is_eligible(self):
pts = [0.0, 0.5, 1.0, 1.5, 2.0] # 0.5s gaps
self.assertTrue(keyframe_sampling_eligible(pts))
def test_long_gop_is_ineligible(self):
pts = [0.0, 5.0, 10.0] # 5s gaps
self.assertFalse(keyframe_sampling_eligible(pts))
def test_irregular_gop_ineligible_when_a_gap_is_long(self):
pts = [0.0, 0.5, 1.0, 8.0] # one 7s gap
self.assertFalse(keyframe_sampling_eligible(pts))
def test_too_few_keyframes_ineligible(self):
self.assertFalse(keyframe_sampling_eligible([1.0]))
self.assertFalse(keyframe_sampling_eligible([]))
def test_default_max_gap_constant(self):
self.assertEqual(KEYFRAME_MAX_GAP_SECONDS, 2.0)
class TestVodDecodeCommand(unittest.TestCase):
URL = "http://127.0.0.1:5000/vod/cam/start/1/end/2/index.m3u8"
def test_keyframe_command_shape(self):
cmd = build_vod_decode_command(
"ffmpeg",
self.URL,
decode_args=[],
crop=(100, 80, 10, 20),
scale=(50, 40),
gray=True,
skip_nonkey=True,
fps_rate=None,
)
joined = " ".join(cmd)
self.assertIn("-skip_frame nokey", joined)
self.assertIn("-protocol_whitelist pipe,file,http,tcp", joined)
self.assertIn(f"-i {self.URL}", joined)
self.assertIn("crop=100:80:10:20", joined)
self.assertIn("scale=50:40", joined)
self.assertIn("-pix_fmt gray", joined)
self.assertNotIn("fps=", joined)
def test_fps_command_uses_fps_filter_not_skip_frame(self):
cmd = build_vod_decode_command(
"ffmpeg",
self.URL,
decode_args=[],
crop=None,
scale=None,
gray=False,
skip_nonkey=False,
fps_rate=2.0,
)
joined = " ".join(cmd)
self.assertNotIn("skip_frame", joined)
self.assertIn("fps=2.0", joined)
self.assertIn("-pix_fmt bgr24", joined)
def test_hwaccel_inserts_hwdownload(self):
cmd = build_vod_decode_command(
"ffmpeg",
self.URL,
decode_args=["-hwaccel", "vaapi"],
crop=None,
scale=None,
gray=True,
skip_nonkey=True,
fps_rate=None,
)
joined = " ".join(cmd)
self.assertIn("hwdownload", joined)
self.assertIn("format=nv12", joined)
class TestProbeVodKeyframePts(unittest.TestCase):
def test_parses_keyframe_packets(self):
sample = (
'{"packets":['
'{"pts_time":"0.000000","flags":"K__"},'
'{"pts_time":"1.000000","flags":"___"},'
'{"pts_time":"2.000000","flags":"K__"}]}'
)
completed = mock.Mock(stdout=sample, returncode=0)
with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
):
pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8")
self.assertEqual(pts, [0.0, 2.0])
def test_returns_empty_on_failure(self):
with mock.patch(
"frigate.jobs.motion_search_decode.sp.run",
side_effect=OSError("boom"),
):
self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), [])
class TestProbeVideoDimensions(unittest.TestCase):
def test_parses_dimensions_and_fps(self):
sample = (
'{"streams":[{"width":1920,"height":1080,"avg_frame_rate":"30000/1001"}]}'
)
completed = mock.Mock(stdout=sample, returncode=0)
with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
):
dims = probe_video_dimensions("ffprobe", "/tmp/a.mp4")
assert dims is not None
width, height, fps = dims
self.assertEqual((width, height), (1920, 1080))
self.assertAlmostEqual(fps, 29.97, places=2)
def test_returns_none_on_zero_dimensions(self):
sample = '{"streams":[{"width":0,"height":0,"avg_frame_rate":"0/0"}]}'
completed = mock.Mock(stdout=sample, returncode=0)
with mock.patch(
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
):
self.assertIsNone(probe_video_dimensions("ffprobe", "/tmp/a.mp4"))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,87 @@
"""Tests for motion search spatial (crop/scale/mask) helpers."""
import unittest
import numpy as np
from frigate.jobs.motion_search import (
build_scaled_roi_mask,
compute_roi_crop_and_scale,
detect_motion_scaled,
)
class TestComputeRoiCropAndScale(unittest.TestCase):
def test_crop_box_in_record_pixels(self):
# ROI covering x [0.25, 0.75], y [0.5, 1.0] of a 1000x600 frame.
polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]]
crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125)
cw, ch, cx, cy = crop
self.assertEqual((cx, cy), (250, 300))
self.assertEqual((cw, ch), (500, 300))
# longest side 500 -> factor 0.25 -> (125, 75), rounded down to even.
self.assertEqual(scaled, (124, 74))
def test_never_upscales(self):
polygon = [[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.0, 0.1]]
crop, scaled = compute_roi_crop_and_scale(polygon, 200, 200, scale_target=400)
cw, ch, _, _ = crop
# crop is 20x20; target 400 would upscale, so scaled == crop size.
self.assertEqual(scaled, (cw, ch))
def test_scaled_dims_are_at_least_one(self):
polygon = [[0.0, 0.0], [0.02, 0.0], [0.02, 0.02], [0.0, 0.02]]
crop, scaled = compute_roi_crop_and_scale(polygon, 50, 50, scale_target=1)
self.assertGreaterEqual(scaled[0], 1)
self.assertGreaterEqual(scaled[1], 1)
def test_all_dims_are_even_for_nv12(self):
# Odd-aligned ROI on an odd-ish frame must still yield even crop/scale so
# the nv12 hwdownload byte stream matches the expected frame size.
polygon = [[0.123, 0.321], [0.777, 0.321], [0.777, 0.901], [0.123, 0.901]]
crop, scaled = compute_roi_crop_and_scale(polygon, 1377, 911, scale_target=257)
for value in (*crop, *scaled):
self.assertEqual(value % 2, 0, f"{value} is not even")
class TestBuildScaledRoiMask(unittest.TestCase):
def test_mask_matches_scaled_dims_and_has_coverage(self):
polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]]
crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125)
mask = build_scaled_roi_mask(polygon, 1000, 600, crop, scaled)
self.assertEqual(mask.shape, (scaled[1], scaled[0]))
self.assertEqual(mask.dtype, np.uint8)
# A full rectangle ROI fills its whole crop -> mask is all 255.
self.assertGreater(np.count_nonzero(mask), 0)
self.assertEqual(np.count_nonzero(mask), mask.size)
class TestDetectMotionScaled(unittest.TestCase):
def _ts(self, idx):
return float(idx)
def test_finds_change_between_frames(self):
mask = np.full((60, 80), 255, dtype=np.uint8)
f0 = np.zeros((60, 80), dtype=np.uint8)
f1 = np.zeros((60, 80), dtype=np.uint8)
f1[10:50, 20:60] = 255 # big bright block appears
frames = [(0, f0), (30, f1)]
results = detect_motion_scaled(
frames, mask, threshold=30, min_area=1.0, timestamp_fn=self._ts
)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].timestamp, 30.0)
self.assertGreater(results[0].change_percentage, 0.0)
def test_no_change_yields_nothing(self):
mask = np.full((60, 80), 255, dtype=np.uint8)
f0 = np.zeros((60, 80), dtype=np.uint8)
f1 = np.zeros((60, 80), dtype=np.uint8)
results = detect_motion_scaled(
[(0, f0), (30, f1)], mask, threshold=30, min_area=1.0, timestamp_fn=self._ts
)
self.assertEqual(results, [])
if __name__ == "__main__":
unittest.main()

View File

@ -1,5 +1,6 @@
"""Tests for the profiles system."""
import copy
import json
import os
import unittest
@ -746,6 +747,36 @@ class TestProfileManager(unittest.TestCase):
manager.activate_profile(None)
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_profile_change_republishes_switch_states(self, mock_persist):
"""Profile changes republish MQTT switch states so HA stays in sync.
Regression: activating/deactivating a profile updated the in-memory
config (and Frigate's behavior) but left the retained MQTT state
topics stale, so external integrations like Home Assistant kept
showing the pre-profile toggle position.
"""
config_data = copy.deepcopy(self.config_data)
config_data["cameras"]["front"]["profiles"]["disarmed"]["review"] = {
"alerts": {"enabled": False},
}
config = FrigateConfig(**config_data)
dispatcher = MagicMock()
manager = ProfileManager(config, self.mock_updater, dispatcher)
# Activating disarmed turns alerts off -> MQTT state must follow
manager.activate_profile("disarmed")
dispatcher.publish.assert_any_call(
"front/review_alerts/state", "OFF", retain=True
)
# Deactivating restores the base (alerts on) -> MQTT state must follow
dispatcher.publish.reset_mock()
manager.activate_profile(None)
dispatcher.publish.assert_any_call(
"front/review_alerts/state", "ON", retain=True
)
@patch.object(ProfileManager, "_persist_active_profile")
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
"""Startup callers pass clear_runtime_overrides=False to preserve state."""

View File

@ -618,6 +618,16 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
new_config["cameras"][name] = camera_config
# Remove deprecated date_style and time_style from global ui config
global_ui = new_config.get("ui", {})
if global_ui.get("date_style") is not None:
del new_config["ui"]["date_style"]
if global_ui.get("time_style") is not None:
del new_config["ui"]["time_style"]
# Remove ui section if empty
if "ui" in new_config and not new_config["ui"]:
del new_config["ui"]
new_config["version"] = "0.18-0"
return new_config

View File

@ -686,7 +686,7 @@
},
"timestamp_style": {
"label": "Estil de la marca horària",
"description": "Opcions d'estilització per a marques de temps d'alimentació aplicades a enregistraments i instantànies.",
"description": "Opcions d'estilització per a marques de temps aplicades instantànies i la vista de depuració.",
"position": {
"label": "Posició de la marca horària",
"description": "Posició de la marca horària a la imatge (tl/tr/bl/br)."

View File

@ -984,7 +984,7 @@
},
"default_role": {
"label": "Rol predeterminat",
"description": "Rol predeterminat assignat als usuaris intermediaris autenticats quan no s'aplica cap mapatge de rols (administrador o visor)."
"description": "Rol predeterminat assignat als usuaris intermediaris autenticats quan no s'aplica cap mapatge de rols."
},
"separator": {
"label": "Caràcter separador",

View File

@ -26,7 +26,9 @@
"points_many": "{{count}} punts",
"points_other": "{{count}} punts",
"undo": "Desfés l'últim punt",
"reset": "Restableix el polígon"
"reset": "Restableix el polígon",
"drawMode": "Dibuxa",
"moveMode": "Moure"
},
"motionHeatmapLabel": "Mapa de calor del moviment",
"dialog": {
@ -42,11 +44,11 @@
"settings": {
"title": "Configuració de la cerca",
"parallelMode": "Mode paral·lel",
"parallelModeDesc": "Escaneja múltiples segments d'enregistrament al mateix temps (més ràpid, però significativament més intensiu en CPU)",
"parallelModeDesc": "Escaneja múltiples intervals d'enregistrament al mateix temps (més ràpid; utilitza més recursos de descodificació)",
"threshold": "Llindar de la sensibilitat",
"thresholdDesc": "Els valors més baixos detecten canvis més petits (1-255)",
"minArea": "Àrea de canvi mínim",
"minAreaDesc": "Percentatge mínim de la regió d'interès que s'ha de canviar per considerar-se significatiu",
"minAreaDesc": "Mida mínima d'una sola regió en moviment, com a percentatge de la regió d'interès",
"frameSkip": "Omet el fotograma",
"frameSkipDesc": "Processa cada N fotograma. Establiu això a la velocitat de fotogrames de la càmera per processar un fotograma per segon (p. ex. 5 per a una càmera de 5 FPS, 30 per a una càmera de 30 FPS). Els valors més alts seran més ràpids, però poden perdre els esdeveniments de curt moviment.",
"maxResults": "Resultats màxims",
@ -72,6 +74,9 @@
"framesDecoded": "Fotogrames descodificats",
"wallTime": "Temps de cerca",
"segmentErrors": "Errors del segment",
"seconds": "{{seconds}}s"
}
"seconds": "{{seconds}}s",
"scanSummary": "{{segments}} segments · {{time}}",
"minutesSeconds": "{{minutes}}m {{seconds}}s"
},
"scanning": "S'està analitzant {{time}}"
}

View File

@ -426,7 +426,8 @@
"notificationUnavailable": {
"title": "Notificacions no disponibles",
"documentation": "Llegir la documentació",
"desc": "Les notificacions push web requereixen un context segur (<code>https://…</code>). Aquesta és una limitació del navegador. Accedeix a Frigate de manera segura per utilitzar les notificacions."
"desc": "Les notificacions push web requereixen un context segur (<code>https://…</code>). Aquesta és una limitació del navegador. Accedeix a Frigate de manera segura per utilitzar les notificacions.",
"descPwa": "A iOS, les notificacions push web només estàn disponibles quan Frigate està instalat a la pantalla principal. Obre el menú <strong>Compartir</strong> , selecciona <strong>Afegir a la pantalla</strong>, i obre Frigate des del nou icona per registrar les notificacions en aquest dispositiu."
},
"unsavedChanges": "Canvis de notificació no desats",
"globalSettings": {
@ -1828,6 +1829,17 @@
"availableStreams": "Fluxos disponibles",
"useCustom": "Utilitza \"{{value}}\"",
"addStream": "Afegeix un flux"
},
"ptzPresets": {
"placeholder": "Selecciona o entra una configuració...",
"search": "Busca o entra una configuració...",
"noPresets": "No hi ha configuracions disponibles",
"available": "Parámetres de Cámera",
"useCustom": "Usa \"{{value}}\""
},
"defaultRole": {
"admin": "Administrar",
"viewer": "Visor"
}
},
"globalConfig": {
@ -1993,7 +2005,8 @@
"hardwareDxva2": "DXVA2",
"hardwareVideotoolbox": "VideoToolbox"
},
"streamNumber": "Flux {{index}}"
"streamNumber": "Flux {{index}}",
"sourceNumber": "Font {{index}}"
},
"timestampPosition": {
"tl": "A dalt a l'esquerra",
@ -2060,6 +2073,9 @@
"semanticSearch": {
"jinav2SmallModelSize": "La mida 'petita' amb el model Jina V2 té un alt cost de RAM i d'inferència. Es recomana el model 'gran' amb una GPU discreta.",
"modelSizeIgnoredForProvider": "La mida del model només s'aplica als models de Jina incorporats. Aquest valor s'ignorarà quan s'utilitzi un proveïdor d'incrustació GenAI."
},
"onvif": {
"autotrackingNoZones": "Autotraquejar requereix al menys una zona. Defineix una zona per aquesta cámera a Mascares/Zones, després usa'l com a requerit a la part inferior."
}
},
"modelSize": {

View File

@ -193,7 +193,8 @@
"gl": "Galego (Galicisch)",
"id": "Bahasa Indonesia (Indonesisch)",
"hr": "Hrvatski (Kroatisch)",
"bs": "Bosnisch"
"bs": "Bosnisch",
"zhHant": "Traditional Chinese"
},
"appearance": "Erscheinung",
"theme": {

View File

@ -48,5 +48,6 @@
"submittedFrigatePlus": "Bild erfolgreich an Frigate+ gesendet"
}
},
"noPreviewFoundFor": "Keine Vorschau für {{cameraName}} gefunden"
"noPreviewFoundFor": "Keine Vorschau für {{cameraName}} gefunden",
"cameraOff": "Kamera ist ausgeschaltet"
}

View File

@ -826,7 +826,7 @@
},
"timestamp_style": {
"label": "Format für Zeitstempel",
"description": "Gestaltungsmöglichkeiten für Zeitstempel im Feed, die auf Aufzeichnungen und Momentaufnahmen angewendet werden.",
"description": "Gestaltungsoptionen für Zeitstempel, die auf Momentaufnahmen und die Debug-Ansicht angewendet werden.",
"position": {
"label": "Position des Zeitstempels",
"description": "Position des Zeitstempels auf dem Bild (tl/tr/bl/br)."

View File

@ -496,7 +496,7 @@
},
"default_role": {
"label": "Standardrolle",
"description": "Standardrolle, die proxy-authentifizierten Benutzern zugewiesen wird, wenn keine Rollenzuordnung gilt (Admin oder Betrachter)."
"description": "Standardrolle, die proxy-authentifizierten Benutzern zugewiesen wird, wenn keine Rollenzuordnung vorliegt."
},
"separator": {
"label": "Trennzeichen",

View File

@ -144,7 +144,9 @@
},
"camera": {
"enable": "Kamera aktivieren",
"disable": "Kamera deaktivieren"
"disable": "Kamera deaktivieren",
"turnOn": "Schalte die Kamera ein",
"turnOff": "Schalte die Kamera aus"
},
"audioDetect": {
"enable": "Audioerkennung aktivieren",
@ -162,7 +164,8 @@
"autotracking": "Autotracking",
"audioDetection": "Audioerkennung",
"title": "{{camera}} Einstellungen",
"transcription": "Audio Transkription"
"transcription": "Audio Transkription",
"camera": "Kamera"
},
"history": {
"label": "Historisches Filmmaterial zeigen"

View File

@ -24,7 +24,9 @@
"points_one": "{{count}} Punkt",
"points_other": "{{count}} Punkte",
"undo": "Letzten Schritt rückgängig machen",
"reset": "Polygon zurücksetzen"
"reset": "Polygon zurücksetzen",
"drawMode": "ziehen",
"moveMode": "bewegen"
},
"motionHeatmapLabel": "Bewegungs-Heatmap",
"dialog": {

View File

@ -32,7 +32,7 @@
"enrichments": "Erkennungsfunktionen",
"triggers": "Auslöser",
"roles": "Rollen",
"cameraManagement": "Verwaltung",
"cameraManagement": "Kamera Verwaltung",
"cameraReview": "Überprüfung",
"system": "System",
"general": "allgemein",
@ -730,7 +730,8 @@
"notificationUnavailable": {
"title": "Benachrichtigungen nicht verfügbar",
"desc": "Web Push Benachrichtigungen erfordern einen sicheren Kontext (<code>https://…</code>). Das ist eine Vorgabe des Browsers. Greife auf Frigate gesichert zu um Benachrichtigungen zu nutzen.",
"documentation": "Dokumentation lesen"
"documentation": "Dokumentation lesen",
"descPwa": "Unter iOS sind Web-Push-Benachrichtigungen nur verfügbar, wenn Frigate auf Ihrem Startbildschirm installiert ist. Öffnen Sie das Menü <strong>Teilen</strong>, wählen Sie <strong>Zum Startbildschirm hinzufügen</strong> und öffnen Sie Frigate über das neue Symbol, um dieses Gerät für Benachrichtigungen zu registrieren."
},
"cameras": {
"desc": "Wähle aus für welche Kameras Benachrichtigungen aktiviert werden sollen.",
@ -802,7 +803,7 @@
"cameras": "Kameras",
"loading": "Lade Model Informationen…",
"error": "Model Informationen laden fehlgeschlagen",
"availableModels": "Verfügbare Modelle",
"availableModels": "Verfügbare Frigate+ Modelle",
"loadingAvailableModels": "Lade verfügbare Modelle…",
"baseModel": "Basis Model",
"title": "Model Informationen",
@ -1136,7 +1137,7 @@
"brands": {
"reolink-rtsp": "Reolink RTSP wird nicht empfohlen. Es wird empfohlen, http in den Kameraeinstellungen zu aktivieren und den Kamera-Assistenten neu zu starten."
},
"customUrlRtspRequired": "Benutzerdefinierte URLs müssen mit „rtsp://“ beginnen. Für Nicht-RTSP-Kamerastreams ist eine manuelle Konfiguration erforderlich."
"customUrlRtspRequired": "Benutzerdefinierte URLs müssen mit „rtsp://“ oder \"rtsps://\" beginnen. Für Nicht-RTSP-Kamerastreams ist eine manuelle Konfiguration erforderlich."
},
"docs": {
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
@ -1356,13 +1357,13 @@
"selectCamera": "Wähle eine Kamera",
"backToSettings": "Zurück zu Kameraeinstellungen",
"streams": {
"title": "Kameras aktivieren / deaktivieren",
"title": "Kamerastatus und Details",
"desc": "Deaktiviere eine Kamera vorübergehend, bis Frigate neu gestartet wird. Deaktivierung einer Kamera stoppt die Verarbeitung der Streams dieser Kamera durch Frigate vollständig. Erkennung, Aufzeichnung und Debugging sind dann nicht mehr verfügbar. <br /> <em>Hinweis: Dies deaktiviert nicht die go2rtc restreams.</em>",
"enableLabel": "Aktivierte Kameras",
"enableDesc": "</em>Eine aktivierte Kamera vorübergehend deaktivieren, bis Frigate neu gestartet wird. Durch das Deaktivieren einer Kamera wird die Verarbeitung der Streams dieser Kamera durch Frigate vollständig unterbrochen. Erkennung, Aufzeichnung und Fehlerbehebung stehen dann nicht mehr zur Verfügung.<br /><em> Hinweis: go2rtc-Restreams werden dadurch nicht deaktiviert.</em>",
"disableLabel": "Deaktivierte Kameras",
"disableDesc": "Aktivieren Sie eine Kamera, die derzeit in der Benutzeroberfläche nicht sichtbar und in der Konfiguration deaktiviert ist. Nach der Aktivierung ist ein Neustart von Frigate erforderlich.",
"enableSuccess": "{{cameraName}} wurde in der Konfiguration aktiviert. Starte Frigate neu, um die Änderungen zu übernehmen.",
"enableSuccess": "{{cameraName}} wurde aktiviert. Starte Frigate neu, um die Änderung zu übernehmen.",
"friendlyName": {
"edit": "Anzeigenamen der Kamera bearbeiten",
"title": "Anzeigenamen bearbeiten",
@ -1381,7 +1382,16 @@
"webuiUrlLabel": "URL der Web-Benutzeroberfläche",
"webuiUrlHelp": "URL, um die Web-Benutzeroberfläche der Kamera direkt aus der Debug-Ansicht aufzurufen. Lassen Sie das Feld leer, um den Link zu deaktivieren.",
"webuiUrlInvalid": "Es muss sich um eine gültige URL handeln (z. B. https://example.com)."
}
},
"label": "Kamerazustand",
"description": "Legen Sie den Betriebszustand für jede Kamera fest.<br /><br /><strong>Ein</strong>: Streams werden normal verarbeitet.<br /><strong>Aus</strong>: Die Verarbeitung wird vorübergehend angehalten. Diese Einstellung bleibt bei einem Neustart von Frigate nicht erhalten.<br /><strong>Deaktiviert</strong>: Die Verarbeitung wird beendet und die Änderung in Ihrer Konfiguration gespeichert. Um eine deaktivierte Kamera wieder zu aktivieren, ist ein Neustart erforderlich.<br /><br /><em>Hinweis: Die Deaktivierung hat keine Auswirkungen auf go2rtc-Restreams.</em><br /><br />Ziehen Sie den Griff, um die Reihenfolge der aktiven Kameras in der Benutzeroberfläche anzupassen, einschließlich des Live-Dashboards und der Dropdown-Menüs zur Kameraauswahl.",
"disabledSubheading": "In der Konfiguration deaktiviert",
"status": {
"on": "Eingeschaltet",
"off": "Ausgeschaltet",
"disabled": "Deaktiviert"
},
"disableSuccess": "{{cameraName}} wurde deaktiviert und in der Konfiguration gespeichert."
},
"cameraConfig": {
"add": "Kamera hinzufügen",
@ -1427,10 +1437,12 @@
"profiles": {
"title": "Profilkameraumschaltungen",
"selectLabel": "Profil auswählen",
"description": "Legen Sie fest, welche Kameras bei der Aktivierung eines Profils aktiviert oder deaktiviert werden sollen. Kameras, für die „Übernehmen“ eingestellt ist, behalten ihren ursprünglichen Aktivierungsstatus bei.",
"description": "Legen Sie fest, welche Kameras bei der Aktivierung eines Profils ein- oder ausgeschaltet werden. Kameras, die auf „Übernehmen“ eingestellt sind, behalten ihren Standardzustand bei.",
"inherit": "Erben",
"enabled": "Aktiviert",
"disabled": "Deaktiviert"
"disabled": "Deaktiviert",
"on": "Eingeschaltet",
"off": "Ausgeschaltet"
},
"cameraType": {
"title": "Kamera Art",
@ -1440,7 +1452,92 @@
"dedicatedLpr": "Spezielles LPR-System",
"saveSuccess": "Der Kameratyp für {{cameraName}} wurde aktualisiert. Starte Frigate neu, um die Änderungen zu übernehmen."
},
"description": "Fügen Sie Kameras hinzu, bearbeiten und löschen Sie sie, legen Sie fest, welche Kameras aktiviert sind, und konfigurieren Sie profil- und kameratypabhängige Übersteuerungen. Um Streams, Erkennung, Bewegung und andere kameraspezifische Einstellungen zu konfigurieren, wählen Sie den entsprechenden Abschnitt unter „Kamerakonfiguration“ aus."
"description": "Fügen Sie Kameras hinzu, bearbeiten und löschen Sie sie, steuern Sie den Status jeder einzelnen Kamera und konfigurieren Sie profil- und kameratypabhängige Übersteuerungen. Um Streams, Erkennung, Bewegung und andere kameraspezifische Einstellungen zu konfigurieren, wählen Sie den entsprechenden Abschnitt unter „Kamerakonfiguration“ aus.",
"clone": {
"sectionTitle": "Einstellungen klonen",
"sectionDescription": "Konfiguration von einer Kamera auf eine andere oder eine neue Kamera kopieren.",
"button": "Einstellungen klonen",
"title": "Kameraeinstellungen kopieren",
"description": "Kopieren Sie die Konfiguration einer Kamera auf eine oder mehrere andere Kameras oder auf eine neue Kamera. Die Identitätsdaten (Name, Anzeigename, URL der Web-Benutzeroberfläche, Anzeigereihenfolge) werden dabei nicht kopiert.",
"source": {
"label": "Quellkamera",
"placeholder": "Wählen Sie eine Quellkamera aus",
"required": "Wählen Sie eine Quellkamera aus"
},
"target": {
"legend": "Ziel",
"newRadio": "Neue Kamera",
"newNameLabel": "Kamera Name",
"newNamePlaceholder": "z. B. back_door oder Back Door",
"newNameRequired": "Kamera Name ist erforderlich",
"newNameInvalid": "ungültiger Kamera Name",
"newNameCollision": "Eine Kamera mit diesem Namen gibt es bereits",
"newStreamsForced": "Streams werden bei einer neuen Kamera immer kopiert.",
"existingCamerasRadio": "Vorhandene Kameras",
"allCameras": "Alle Kameras",
"existingPlaceholder": "Wählen Sie mindestens eine Kamera aus",
"existingDisabled": "Es gibt keine weiteren Kameras, auf die kopiert werden kann"
},
"categories": {
"legend": "Zu klonende Einstellungen",
"description": "Wählen Sie aus, welche Einstellungen von der Quellkamera kopiert werden sollen.",
"selectAll": "Alle auswählen",
"selectNone": "Keine auswählen",
"resetDefaults": "Auf Standardwerte zurücksetzen",
"general": "Allgemeines",
"spatial": "Räumliche Rahmenbedingungen",
"streams": "Streams",
"spatialWarningTitle": "Auflösungsdiskrepanz",
"spatialWarning": "Die Quellkamera {{srcCamera}} hat eine andere Auflösung ({{srcWidth}}×{{srcHeight}}) als: {{cameras}}. Die Polygone sind möglicherweise nicht auf diese Kameras ausgerichtet. Diese Standardeinstellungen sind deaktiviert; aktivieren Sie sie, um die Daten unverändert zu kopieren.",
"restartHint": "Neustart erforderlich",
"items": {
"record": "Aufnahme",
"snapshots": "Momentaufnahmen",
"motion": "Bewegungserkennung",
"objects": "Objekte",
"audio": "Tonerkennung",
"audio_transcription": "Audio-Transkription",
"notifications": "Benachrichtigungen",
"birdseye": "Birdseye",
"mqtt": "MQTT",
"timestamp_style": "Format für Zeitstempel",
"onvif": "ONVIF",
"lpr": "Kennzeichenerkennung",
"face_recognition": "Gesichtserkennung",
"semantic_search": "Semantische Suche",
"genai": "Generative AI",
"type": "Kameratyp (Standard / speziell für Kennzeichenerkennung)",
"profiles": "Profile",
"detect": "Abmessungen ermitteln",
"zones": "Zonen",
"motion_mask": "Bewegungsmaske",
"object_masks": "Objektmaske",
"ffmpeg_live": "Stream-URLs und Rollen",
"review": "Rezension"
}
},
"footer": {
"restartNeeded": "Für einige Änderungen ist ein Neustart erforderlich.",
"liveOnly": "Alle Änderungen werden sofort wirksam, ohne dass ein Neustart erforderlich ist.",
"submit": "Klon",
"submitting": "Klonen…",
"changeCount_one": "Die Änderung von {{count}} wird übernommen",
"changeCount_other": "Die Änderungen von {{count}} werden übernommen"
},
"toast": {
"success": "Einstellungen wurden auf {{cameraName}} kopiert",
"successWithRestart": "Die Einstellungen wurden auf {{cameraName}} kopiert. Starte Frigate neu, um alle Änderungen zu übernehmen.",
"successMulti_one": "Einstellungen wurden auf {{count}} Kamera kopiert",
"successMulti_other": "Einstellungen wurden auf {{count}} Kameras kopiert",
"successMultiWithRestart_one": "Die Einstellungen wurden auf die Kamera {{count}} kopiert. Starte Frigate neu, um alle Änderungen zu übernehmen.",
"successMultiWithRestart_other": "Die Einstellungen wurden auf {{count}} Kameras kopiert. Starten Sie Frigate neu, um alle Änderungen zu übernehmen.",
"partialFailure": "{{successCount}} Abschnitte wurden angewendet; '{{failedSection}}' ist fehlgeschlagen: {{errorMessage}}",
"partialFailureMulti": "{{successCount}} Kamera(s) wurden kopiert; bei {{failed}} ist ein Fehler aufgetreten: {{errorMessage}}",
"newCameraPartialFailure": "Die Kamera {{cameraName}} wurde erstellt, einige Einstellungen konnten jedoch nicht kopiert werden: {{errorMessage}}",
"sourceMissing": "Die Quellkamera existiert nicht mehr",
"submitError": "Das Klonen der Kamera ist fehlgeschlagen: {{errorMessage}}"
}
}
},
"cameraReview": {
"title": "Kamera-Einstellungen überprüfen",
@ -1789,8 +1886,8 @@
"platePlaceholder": "Kennzeichen oder regulärer Ausdruck"
},
"genaiModel": {
"placeholder": "Modell auswählen…",
"search": "Modell suchen…",
"placeholder": "Modell auswählen oder eingeben…",
"search": "Modell suchen oder eingeben…",
"noModels": "Keine Modelle verfügbar",
"available": "Verfügbare Modelle",
"useCustom": "Verwende „{{value}}“",
@ -1800,6 +1897,28 @@
},
"semanticSearchModelSize": {
"notApplicable": "Gilt nicht für GenAI-Anbieter"
},
"liveStreams": {
"streamNameLabel": "Streamname",
"streamNamePlaceholder": "z. B. Haupt-HD-Stream",
"go2rtcStreamLabel": "go2rtc stream",
"go2rtcStreamPlaceholder": "Wählen Sie einen go2rtc-Stream aus",
"go2rtcStreamSearch": "Suchen Sie nach einem Streamnamen oder geben Sie ihn ein…",
"noGo2rtcStreams": "Es sind keine go2rtc-Streams konfiguriert",
"availableStreams": "Verfügbare Streams",
"useCustom": "Verwende „{{value}}“",
"addStream": "Stream hinzufügen"
},
"ptzPresets": {
"placeholder": "Wählen Sie eine Voreinstellung aus oder geben Sie eine ein...",
"search": "Suchen oder eine Voreinstellung eingeben...",
"noPresets": "Es sind keine Voreinstellungen verfügbar",
"available": "Kamera-Voreinstellungen",
"useCustom": "Verwende „{{value}}“"
},
"defaultRole": {
"admin": "Admin",
"viewer": "Betrachter"
}
},
"globalConfig": {
@ -1922,7 +2041,7 @@
"audioMp3": "Transcode zu MP3",
"audioExclude": "Ausschließen",
"hardwareNone": "Keine Hardwarebeschleunigung",
"hardwareAuto": "Automatische Hardwarebeschleunigung",
"hardwareAuto": "Automatisch (empfohlen)",
"hardwareVaapi": "VAAPI",
"hardwareCuda": "CUDA",
"hardwareV4l2m2m": "V4L2 M2M",
@ -1932,7 +2051,8 @@
"addAudioCodec": "Audio-Codec hinzufügen",
"removeCodec": "Codec entfernen"
},
"streamNumber": "Stream {{index}}"
"streamNumber": "Stream {{index}}",
"sourceNumber": "Quelle {{index}}"
},
"onvif": {
"profileAuto": "Auto",
@ -1992,6 +2112,9 @@
},
"semanticSearch": {
"jinav2SmallModelSize": "Die „kleine“ Variante des Jina V2-Modells verursacht hohe RAM- und Inferenzkosten. Es wird das „große“ Modell mit einer dedizierten GPU empfohlen."
},
"onvif": {
"autotrackingNoZones": "Für die automatische Verfolgung ist mindestens eine Zone erforderlich. Definieren Sie unter „Masken / Zonen“ eine Zone für diese Kamera und legen Sie diese anschließend unten als erforderliche Zone fest."
}
},
"birdseye": {

View File

@ -682,7 +682,7 @@
},
"timestamp_style": {
"label": "Timestamp style",
"description": "Styling options for in-feed timestamps applied to recordings and snapshots.",
"description": "Styling options for timestamps applied to snapshots and Debug view.",
"position": {
"label": "Timestamp position",
"description": "Position of the timestamp on the image (tl/tr/bl/br)."

View File

@ -212,7 +212,7 @@
},
"default_role": {
"label": "Default role",
"description": "Default role assigned to proxy-authenticated users when no role mapping applies (admin or viewer)."
"description": "Default role assigned to proxy-authenticated users when no role mapping applies."
},
"separator": {
"label": "Separator character",
@ -270,14 +270,6 @@
"label": "Time format",
"description": "Time format to use in the UI (browser, 12hour, or 24hour)."
},
"date_style": {
"label": "Date style",
"description": "Date style to use in the UI (full, long, medium, short)."
},
"time_style": {
"label": "Time style",
"description": "Time style to use in the UI (full, long, medium, short)."
},
"unit_system": {
"label": "Unit system",
"description": "Unit system for display (metric or imperial) used in the UI and MQTT."

View File

@ -8,6 +8,7 @@
"searchCancelled": "Search cancelled",
"cancelSearch": "Cancel",
"searching": "Search in progress.",
"scanning": "Scanning {{time}}",
"searchComplete": "Search complete",
"noResultsYet": "Run a search to find motion changes in the selected region",
"noChangesFound": "No pixel changes detected in the selected region",
@ -42,13 +43,11 @@
"settings": {
"title": "Search Settings",
"parallelMode": "Parallel mode",
"parallelModeDesc": "Scan multiple recording segments at the same time (faster, but significantly more CPU intensive)",
"parallelModeDesc": "Scan multiple recording ranges at the same time (faster; uses more decoding resources)",
"threshold": "Sensitivity Threshold",
"thresholdDesc": "Lower values detect smaller changes (1-255)",
"minArea": "Minimum Change Area",
"minAreaDesc": "Minimum percentage of the region of interest that must change to be considered significant",
"frameSkip": "Frame Skip",
"frameSkipDesc": "Process every Nth frame. Set this to your camera's frame rate to process one frame per second (e.g. 5 for a 5 FPS camera, 30 for a 30 FPS camera). Higher values will be faster, but may miss short motion events.",
"minAreaDesc": "Minimum size of a single moving region, as a percentage of the region of interest",
"maxResults": "Maximum Results",
"maxResultsDesc": "Stop after this many matching timestamps"
},
@ -72,6 +71,8 @@
"framesDecoded": "Frames decoded",
"wallTime": "Search time",
"segmentErrors": "Segment errors",
"seconds": "{{seconds}}s"
"seconds": "{{seconds}}s",
"minutesSeconds": "{{minutes}}m {{seconds}}s",
"scanSummary": "{{segments}} segments · {{time}}"
}
}

View File

@ -1154,7 +1154,8 @@
},
"notificationUnavailable": {
"title": "Notifications Unavailable",
"desc": "Web push notifications require a secure context (<code>https://…</code>). This is a browser limitation. Access Frigate securely to use notifications."
"desc": "Web push notifications require a secure context (<code>https://…</code>). This is a browser limitation. Access Frigate securely to use notifications.",
"descPwa": "On iOS, web push notifications are only available when Frigate is installed to your Home Screen. Open the <strong>Share</strong> menu, choose <strong>Add to Home Screen</strong>, then open Frigate from the new icon to register this device for notifications."
},
"globalSettings": {
"title": "Global Settings",
@ -1674,6 +1675,17 @@
"refresh": "Refresh models",
"probeFailed": "Failed to probe models",
"fetchedModels": "Successfully fetched model list"
},
"ptzPresets": {
"placeholder": "Select or enter a preset...",
"search": "Search or enter a preset...",
"noPresets": "No presets available",
"available": "Camera presets",
"useCustom": "Use \"{{value}}\""
},
"defaultRole": {
"admin": "Admin",
"viewer": "Viewer"
}
},
"globalConfig": {
@ -1763,7 +1775,7 @@
"addStream": "Add stream",
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
"addUrl": "Add URL",
"streamNumber": "Stream {{index}}",
"sourceNumber": "Source {{index}}",
"streamName": "Stream name",
"streamNamePlaceholder": "e.g., front_door",
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
@ -1840,12 +1852,6 @@
"12hour": "12 hour",
"24hour": "24 hour"
},
"TimeOrDateStyle": {
"full": "Full",
"long": "Long",
"medium": "Medium",
"short": "Short"
},
"unitSystem": {
"metric": "Metric",
"imperial": "Imperial"
@ -1928,6 +1934,9 @@
},
"semanticSearch": {
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
},
"onvif": {
"autotrackingNoZones": "Autotracking requires at least one zone. Define a zone for this camera in Masks / Zones, then set it as a required zone below."
}
}
}

View File

@ -896,7 +896,7 @@
},
"timestamp_style": {
"label": "Estilo de marca de tiempo",
"description": "Opciones de estilo para marcas de tiempo integradas aplicadas a grabaciones e instantáneas.",
"description": "Opciones de estilo para las marcas de tiempo aplicadas a las instantáneas y a la vista de depuración.",
"position": {
"label": "Posición de marca de tiempo",
"description": "Posición de la marca de tiempo en la imagen (tl/tr/bl/br)."

View File

@ -610,7 +610,7 @@
"description": "Carácter usado para separar varios valores proporcionados en las cabeceras del proxy."
},
"default_role": {
"description": "Rol predeterminado asignado a los usuarios autenticados por proxy cuando no se aplica ningún mapeo de roles (administrador o espectador).",
"description": "Rol predeterminado asignado a los usuarios autenticados mediante proxy cuando no se aplica ninguna asignación de roles.",
"label": "Rol predeterminado"
},
"description": "Configuración para integrar Frigate detrás de un proxy inverso que transmite encabezados de usuario autenticados.",

View File

@ -26,7 +26,9 @@
"points_many": "{{count}} puntos",
"points_other": "{{count}} puntos",
"undo": "Deshacer el último punto",
"reset": "Restablecer polígono"
"reset": "Restablecer polígono",
"drawMode": "Dibujar",
"moveMode": "Mover"
},
"motionHeatmapLabel": "Mapa de calor de movimiento",
"dialog": {

View File

@ -727,7 +727,8 @@
"notificationUnavailable": {
"title": "Notificaciones no disponibles",
"documentation": "Leer la documentación",
"desc": "Las notificaciones push web requieren un contexto seguro (<code>https://…</code>). Esto es una limitación del navegador. Accede a Frigate de forma segura para usar las notificaciones."
"desc": "Las notificaciones push web requieren un contexto seguro (<code>https://…</code>). Esto es una limitación del navegador. Accede a Frigate de forma segura para usar las notificaciones.",
"descPwa": "En iOS, las notificaciones push web solo están disponibles cuando Frigate está instalado en la pantalla de inicio. Abre el menú <strong>Compartir</strong>, selecciona <strong>Añadir a la pantalla de inicio</strong> y, a continuación, abre Frigate desde el nuevo icono para registrar este dispositivo para las notificaciones."
},
"globalSettings": {
"title": "Configuración global",
@ -1814,6 +1815,17 @@
"availableStreams": "Flujos disponibles",
"useCustom": "Usar “{{value}}”",
"addStream": "Añadir flujo"
},
"ptzPresets": {
"placeholder": "Selecciona o introduce un preajuste…",
"search": "Busca o introduce un preajuste…",
"noPresets": "No hay preajustes disponibles",
"available": "Preajustes de cámara",
"useCustom": "Usar “{{value}}”"
},
"defaultRole": {
"admin": "Admin",
"viewer": "Visualizador"
}
},
"globalConfig": {
@ -1946,7 +1958,8 @@
"addAudioCodec": "Añadir códec de audio",
"removeCodec": "Eliminar códec"
},
"streamNumber": "Flujo {{index}}"
"streamNumber": "Flujo {{index}}",
"sourceNumber": "Origen {{index}}"
},
"configMessages": {
"birdseye": {
@ -1996,6 +2009,9 @@
"semanticSearch": {
"jinav2SmallModelSize": "El tamaño 'small' con el modelo Jina V2 tiene un alto consumo de RAM y coste de inferencia. Se recomienda el modelo 'large' con una GPU dedicada.",
"modelSizeIgnoredForProvider": "El tamaño del modelo solo se aplica a los modelos Jina integrados. Este valor se ignorará al usar un proveedor de embeddings GenAI."
},
"onvif": {
"autotrackingNoZones": "El seguimiento automático requiere al menos una zona. Define una zona para esta cámara en Máscaras / Zonas y, a continuación, establécela como zona obligatoria a continuación."
}
},
"resetToDefaultDescription": "Esto restablecerá todos los ajustes de esta sección a sus valores predeterminados. Esta acción no se puede deshacer.",

View File

@ -1377,6 +1377,54 @@
"inherit": "Hériter",
"enabled": "Activé",
"disabled": "Désactivé"
},
"clone": {
"target": {
"newNameLabel": "Nom de la caméra",
"newNamePlaceholder": "p.ex., porte_arriere ou Porte arrière",
"newNameRequired": "Le nom de la caméra est requis",
"newNameInvalid": "Nom de caméra invalide",
"newNameCollision": "Une caméra avec ce nom existe déjà",
"newStreamsForced": "Les flux sont toujours copiés pour une nouvelle caméra.",
"existingCamerasRadio": "Caméras existantes",
"allCameras": "Toutes les caméras",
"existingPlaceholder": "Sélectionnez au moins une caméra",
"existingDisabled": "Aucune autre caméra à copier vers"
},
"categories": {
"legend": "Paramètres à cloner",
"description": "Choisissez quels paramètres copier depuis la caméra source.",
"selectAll": "Sélectionner tout",
"selectNone": "Sélectionner aucun",
"resetDefaults": "Rétablir à la configuration d'usine",
"general": "Général",
"spatial": "Paramètres spatiaux",
"streams": "Flux",
"spatialWarning": "La résolution détectée ({{srcWidth}}x{{srcHeight}}) de la caméra source {{srcCamera}} diffère de : {{cameras}}. Les polygones peuvent ne pas être alignés sur ces caméras. Ces paramètres sont désactivés; activer pour copier tel quel.",
"restartHint": "Redémarrage requis",
"items": {
"record": "En cours d'enregistrement",
"objects": "Objets",
"audio": "Détection audio",
"audio_transcription": "Transcription audio",
"notifications": "Notifications",
"mqtt": "MQTT",
"onvif": "ONVIF",
"face_recognition": "Reconnaissance faciale",
"semantic_search": "Recherche sémantique"
}
},
"footer": {
"submit": "Cloner",
"submitting": "Clonage…"
},
"toast": {
"success": "Paramètres copiés vers {{cameraName}}",
"successWithRestart": "Paramètres copiés vers {{cameraName}}. Redémarrez Frigate afin d'appliquer tous les changements.",
"successMulti_one": "Paramètres copiés vers {{count}} caméra",
"successMulti_many": "Paramètres copiés vers {{count}} caméras",
"successMulti_other": "Paramètres copiés vers {{count}} caméras"
}
}
},
"cameraReview": {

View File

@ -718,7 +718,7 @@
"label": "时间戳效果",
"description": "时间戳文本的视觉效果none、solid、shadow。"
},
"description": "应用于录像和快照的实时监控流中时间戳的样式选项。"
"description": "快照与调试视图的时间戳样式设置。"
},
"semantic_search": {
"label": "语义搜索",

View File

@ -496,7 +496,7 @@
},
"default_role": {
"label": "默认权限组",
"description": "当没有权限组映射适用时分配给代理认证用户的默认权限组admin 或 viewer。"
"description": "当没有权限组映射适用时分配给代理认证用户的默认权限组。"
},
"separator": {
"label": "分隔符",

View File

@ -22,7 +22,9 @@
"polygonControls": {
"points_other": "{{count}} 个点位",
"undo": "撤销上一个点位",
"reset": "重置多边形"
"reset": "重置多边形",
"drawMode": "绘制",
"moveMode": "移动"
},
"motionHeatmapLabel": "画面变动热力图",
"dialog": {

View File

@ -726,7 +726,8 @@
"notificationUnavailable": {
"title": "通知功能不可用",
"desc": "网页推送通知需要安全连接(<code>https://…</code>)。这是浏览器的限制。请通过安全方式访问 Frigate 以使用通知功能。",
"documentation": "阅读文档"
"documentation": "阅读文档",
"descPwa": "在 iOS 设备上,只有将 Frigate 安装到主屏幕后,才能使用网页推送通知。请打开<strong>分享</strong>菜单,选择<strong>添加到主屏幕</strong>,然后从新生成的图标打开 Frigate即可注册此设备以接收通知。"
},
"email": {
"title": "电子邮箱",
@ -857,7 +858,7 @@
"desc": "将使用 <strong>大</strong>模型。该选项使用了完整的 Jina 模型,条件允许的情况下将自动使用 GPU 运行。"
}
},
"title": "分类搜索",
"title": "语义搜索",
"desc": "Frigate 中的语义搜索功能将能够让你通过图片、用户自定义的文本描述,或自动生成的文本描述等方式在核查项目中查找目标/物体。",
"readTheDocumentation": "阅读文档"
},
@ -1133,7 +1134,7 @@
"brands": {
"reolink-rtsp": "不建议使用萤石 RTSP 协议。建议在摄像头设置中启用 HTTP 协议,并重新运行摄像头添加向导。"
},
"customUrlRtspRequired": "自定义 URL 必须以“rtsp://”开头;对于非 RTSP 协议的摄像头流,需手动添加至配置文件。"
"customUrlRtspRequired": "自定义 URL 必须以“rtsp://”或“rtsps://”开头;对于非 RTSP 协议的摄像头流,需手动添加至配置文件。"
},
"docs": {
"reolink": "https://docs.frigate-cn.video/configuration/camera_specific.html#reolink-cameras"
@ -1456,7 +1457,89 @@
"dedicatedLpr": "车牌识别专用",
"saveSuccess": "已更新 {{cameraName}} 的摄像头类型,请重启 Frigate 以使更改生效。"
},
"description": "添加、编辑和删除摄像头,控制每个摄像头的状态,并配置每个配置文件和摄像头类型的覆盖设置。要配置视频流、检测、画面变动和其他特定于摄像头的设置,请在“摄像头配置”下选择相关功能。"
"description": "添加、编辑和删除摄像头,控制每个摄像头的状态,并配置每个配置文件和摄像头类型的覆盖设置。要配置视频流、检测、画面变动和其他特定于摄像头的设置,请在“摄像头配置”下选择相关功能。",
"clone": {
"sectionTitle": "复制设置",
"sectionDescription": "将摄像头的配置复制到另一台摄像头或新摄像头上。",
"button": "复制设置",
"title": "复制摄像头设置",
"description": "将摄像头的配置复制到其他一个或多个摄像头或者一个新摄像头上。但摄像头标识包括名称、别名、Web UI 网址、显示顺序)不会被复制。",
"source": {
"label": "源摄像头",
"placeholder": "选择源摄像头",
"required": "选择源摄像头"
},
"target": {
"legend": "目标",
"newRadio": "新摄像头",
"newNameLabel": "摄像头名称",
"newNamePlaceholder": "例如:后门",
"newNameRequired": "摄像头名称为必填项",
"newNameInvalid": "摄像头名称无效",
"newNameCollision": "已存在同名摄像头",
"newStreamsForced": "在创建新摄像头时,始终会复制视频流配置。",
"existingCamerasRadio": "现有摄像头",
"allCameras": "所有摄像头",
"existingPlaceholder": "至少选择一个摄像头",
"existingDisabled": "没有其他摄像头可供复制"
},
"categories": {
"legend": "要复制的设置",
"description": "选择要从源摄像头复制哪些设置。",
"selectAll": "选择所有",
"selectNone": "取消全选",
"resetDefaults": "恢复默认设置",
"general": "常规",
"spatial": "空间设置",
"streams": "视频流",
"spatialWarningTitle": "分辨率不一致",
"spatialWarning": "源摄像头 {{srcCamera}} 的检测分辨率 ({{srcWidth}}×{{srcHeight}}) 与以下摄像头存在差异:{{cameras}}。在这些摄像头上,多边形(检测区域)可能无法准确对齐。这些默认选项当前处于关闭状态;如果启用,将按原样进行复制。",
"restartHint": "需要重启",
"items": {
"record": "录制",
"snapshots": "快照",
"review": "核查",
"motion": "画面变动检测",
"objects": "目标",
"audio": "音频检测",
"audio_transcription": "音频转录",
"notifications": "通知",
"birdseye": "鸟瞰图",
"mqtt": "MQTT",
"timestamp_style": "时间戳样式",
"onvif": "ONVIF",
"lpr": "车牌识别",
"face_recognition": "人脸识别",
"semantic_search": "语义搜索",
"genai": "生成式 AI",
"type": "摄像头类型(通用或车牌识别专用)",
"profiles": "配置模板",
"detect": "检测维度",
"zones": "区域",
"motion_mask": "画面变动遮罩",
"object_masks": "目标遮罩",
"ffmpeg_live": "视频流地址和功能"
}
},
"footer": {
"changeCount_other": "将应用 {{count}} 项更改",
"restartNeeded": "部分更改需要重启才能生效。",
"liveOnly": "所有更改将立即生效,无需重启。",
"submit": "复制",
"submitting": "复制中…"
},
"toast": {
"success": "设置已复制到 {{cameraName}}",
"successWithRestart": "设置已复制到 {{cameraName}}。请重启 Frigate 以应用所有更改。",
"successMulti_other": "设置已复制到 {{count}} 个摄像头",
"successMultiWithRestart_other": "设置已复制到 {{count}} 个摄像头。请重启 Frigate 以应用所有更改。",
"partialFailure": "{{successCount}} 个部分已应用;'{{failedSection}}' 失败:{{errorMessage}}",
"partialFailureMulti": "已复制到 {{successCount}} 个摄像头;{{failed}} 个失败:{{errorMessage}}",
"newCameraPartialFailure": "摄像头 {{cameraName}} 已创建,但部分设置复制失败:{{errorMessage}}",
"sourceMissing": "源摄像头已不存在",
"submitError": "复制摄像头失败:{{errorMessage}}"
}
}
},
"cameraReview": {
"title": "摄像头核查设置",
@ -1737,6 +1820,17 @@
"availableStreams": "可用的视频流",
"useCustom": "使用“{{value}}”",
"addStream": "添加视频流"
},
"ptzPresets": {
"placeholder": "选择或输入预设…",
"search": "搜索或输入预设…",
"noPresets": "没有可用的预设",
"available": "摄像头预设",
"useCustom": "使用 “{{value}}”"
},
"defaultRole": {
"admin": "管理员",
"viewer": "成员"
}
},
"cameraConfig": {
@ -1948,7 +2042,8 @@
"addAudioCodec": "添加音频编码器",
"removeCodec": "移除编码器"
},
"streamNumber": "视频流 {{index}}"
"streamNumber": "视频流 {{index}}",
"sourceNumber": "源 {{index}}"
},
"onvif": {
"profileAuto": "自动",
@ -2009,6 +2104,9 @@
"semanticSearch": {
"jinav2SmallModelSize": "Jina V2 的大型模型版本内存占用与推理开销较高,建议搭配独立显卡使用大型模型。",
"modelSizeIgnoredForProvider": "模型大小仅适用于内置的 Jina 模型。当使用生成式 AI 作为嵌入提供者时,此值将被忽略。"
},
"onvif": {
"autotrackingNoZones": "自动追踪至少需要一个区域。请先在“遮罩 / 区域”中为此摄像头定义一个区域,然后在下方将其设置为必需区域。"
}
},
"birdseye": {

View File

@ -51,6 +51,7 @@ export default function Step2StateArea({
const [imageLoaded, setImageLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const popoverContainerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const stageRef = useRef<Konva.Stage>(null);
const rectRef = useRef<Konva.Rect>(null);
@ -224,7 +225,7 @@ export default function Step2StateArea({
const canContinue = cameraAreas.length > 0;
return (
<div className="flex flex-col gap-4">
<div ref={popoverContainerRef} className="flex flex-col gap-4">
<div
className={cn(
"flex gap-4 overflow-hidden",
@ -255,6 +256,7 @@ export default function Step2StateArea({
className="scrollbar-container w-64 border bg-background p-3 shadow-lg"
align="start"
sideOffset={5}
container={popoverContainerRef.current}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2">

View File

@ -25,6 +25,24 @@ const onvif: SectionConfigOverrides = {
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
overrideFields: [],
restartRequired: ["autotracking.calibrate_on_startup"],
fieldMessages: [
{
key: "autotracking-no-zones",
field: "autotracking.required_zones",
messageKey: "configMessages.onvif.autotrackingNoZones",
severity: "error",
position: "before",
condition: (ctx) => {
if (ctx.level !== "camera") return false;
const zones = ctx.fullCameraConfig?.zones;
return (
!zones ||
typeof zones !== "object" ||
Object.keys(zones).length === 0
);
},
},
],
uiSchema: {
host: {
"ui:options": { size: "sm" },
@ -39,11 +57,16 @@ const onvif: SectionConfigOverrides = {
required_zones: {
"ui:widget": "zoneNames",
},
return_preset: {
"ui:options": { size: "sm" },
"ui:widget": "ptzPresets",
},
track: {
"ui:widget": "objectLabels",
},
zooming: {
"ui:options": {
size: "xs",
enumI18nPrefix: "onvif.autotracking.zooming",
},
},

View File

@ -21,6 +21,10 @@ const proxy: SectionConfigOverrides = {
"ui:widget": "password",
"ui:options": { size: "md" },
},
default_role: {
"ui:widget": "defaultRole",
"ui:options": { size: "sm" },
},
header_map: {
"ui:after": { render: "ProxyRoleMap" },
},

View File

@ -10,13 +10,7 @@ const ui: SectionConfigOverrides = {
overrideFields: [],
},
global: {
fieldOrder: [
"timezone",
"time_format",
"date_style",
"time_style",
"unit_system",
],
fieldOrder: ["timezone", "time_format", "unit_system"],
advancedFields: [],
restartRequired: ["unit_system"],
uiSchema: {
@ -26,12 +20,6 @@ const ui: SectionConfigOverrides = {
time_format: {
"ui:options": { enumI18nPrefix: "ui.timeFormat" },
},
date_style: {
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
},
time_style: {
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
},
unit_system: {
"ui:options": { enumI18nPrefix: "ui.unitSystem" },
},

View File

@ -48,6 +48,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { isPWA } from "@/utils/isPWA";
import { isIOS } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
@ -437,6 +439,12 @@ export default function NotificationsSettingsExtras({
}
if (!("Notification" in window) || !window.isSecureContext) {
// iOS only exposes web push to apps installed to the Home Screen, so a
// secure-context iOS browser tab that isn't an installed PWA has no
// Notification API. Android supports web push in a normal tab, so it never
// reaches this case and keeps the generic secure-context message.
const requiresPwaInstall = isIOS && window.isSecureContext && !isPWA;
return (
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="w-full max-w-5xl">
@ -465,12 +473,21 @@ export default function NotificationsSettingsExtras({
{t("notification.notificationUnavailable.title")}
</AlertTitle>
<AlertDescription>
<Trans ns="views/settings">
notification.notificationUnavailable.desc
</Trans>
<Trans
ns="views/settings"
i18nKey={
requiresPwaInstall
? "notification.notificationUnavailable.descPwa"
: "notification.notificationUnavailable.desc"
}
/>
<div className="mt-3 flex items-center">
<Link
to={getLocaleDocUrl("configuration/authentication")}
to={getLocaleDocUrl(
requiresPwaInstall
? "configuration/notifications"
: "configuration/authentication",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"

View File

@ -33,6 +33,8 @@ import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
import { PTZPresetsWidget } from "./widgets/PTZPresetsWidget";
import { DefaultRoleWidget } from "./widgets/DefaultRoleWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@ -90,6 +92,8 @@ export const frigateTheme: FrigateTheme = {
semanticSearchModel: SemanticSearchModelWidget,
semanticSearchModelSize: SemanticSearchModelSizeWidget,
onvifProfile: OnvifProfileWidget,
ptzPresets: PTZPresetsWidget,
defaultRole: DefaultRoleWidget,
},
templates: {
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,

View File

@ -0,0 +1,56 @@
import { useMemo } from "react";
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ConfigFormContext } from "@/types/configForm";
import { getSizedFieldClassName } from "../utils";
const BUILT_IN_ROLES = ["admin", "viewer"];
export function DefaultRoleWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options, registry } =
props;
const { t } = useTranslation(["views/settings"]);
const fieldClassName = getSizedFieldClassName(options, "sm");
const formContext = registry?.formContext as ConfigFormContext | undefined;
const roles = useMemo<string[]>(() => {
const configured = Object.keys(formContext?.fullConfig?.auth?.roles ?? {});
// Keep admin/viewer first, then any custom roles in config order.
const custom = configured.filter((r) => !BUILT_IN_ROLES.includes(r));
return [...BUILT_IN_ROLES, ...custom];
}, [formContext]);
const selectedValue = typeof value === "string" && value ? value : "viewer";
const getLabel = (role: string) =>
BUILT_IN_ROLES.includes(role) ? t(`configForm.defaultRole.${role}`) : role;
return (
<Select
value={selectedValue}
onValueChange={onChange}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className={fieldClassName}>
<SelectValue placeholder={schema.title} />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role}>
{getLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default DefaultRoleWidget;

View File

@ -0,0 +1,151 @@
// Combobox widget for ONVIF PTZ preset fields (e.g. autotracking.return_preset).
// Fetches the camera's PTZ presets and shows them in a dropdown, while still
// allowing a typed custom value so existing presets that the camera does not
// report (such as "home") are preserved.
import { useState, useMemo } from "react";
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ConfigFormContext } from "@/types/configForm";
import type { CameraPtzInfo } from "@/types/ptz";
import { getSizedFieldClassName } from "../utils";
export function PTZPresetsWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, options, registry } = props;
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const fieldClassName = getSizedFieldClassName(options, "md");
const formContext = registry?.formContext as ConfigFormContext | undefined;
const cameraName = formContext?.cameraName;
const isCameraLevel = formContext?.level === "camera";
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
const { data: ptzInfo } = useSWR<CameraPtzInfo>(
isCameraLevel && cameraName && hasOnvifHost
? `${cameraName}/ptz/info`
: null,
{
// ONVIF may not be initialized yet when the settings page loads,
// so retry until presets become available
refreshInterval: (data) =>
data?.presets && data.presets.length > 0 ? 0 : 5000,
},
);
const presets = useMemo<string[]>(() => ptzInfo?.presets ?? [], [ptzInfo]);
const trimmedSearch = searchValue.trim();
const matchesPreset = useMemo(
() => presets.some((p) => p.toLowerCase() === trimmedSearch.toLowerCase()),
[presets, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesPreset;
const commit = (next: string) => {
onChange(next);
setSearchValue("");
setOpen(false);
};
const currentLabel = typeof value === "string" && value ? value : undefined;
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
{currentLabel ?? t("configForm.ptzPresets.placeholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t("configForm.ptzPresets.search")}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === "Enter" && showCustomOption) {
e.preventDefault();
commit(trimmedSearch);
}
}}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.ptzPresets.useCustom", {
value: trimmedSearch,
})}
</CommandItem>
</CommandGroup>
)}
{presets.length > 0 ? (
<CommandGroup heading={t("configForm.ptzPresets.available")}>
{presets.map((preset) => (
<CommandItem
key={preset}
value={preset}
onSelect={() => commit(preset)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === preset ? "opacity-100" : "opacity-0",
)}
/>
{preset}
</CommandItem>
))}
</CommandGroup>
) : !showCustomOption ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.ptzPresets.noPresets")}
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -13,6 +13,13 @@ import { useTimezone } from "@/hooks/use-date-utils";
type WeekStartsOnType = 0 | 1 | 2 | 3 | 4 | 5 | 6;
function formatCalendarDay(day: Date): string {
const y = day.getFullYear();
const m = String(day.getMonth() + 1).padStart(2, "0");
const d = String(day.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
type ReviewActivityCalendarProps = {
reviewSummary?: ReviewSummary;
recordingsSummary?: RecordingsSummary;
@ -62,17 +69,10 @@ export default function ReviewActivityCalendar({
}
}
const formatDay = (day: Date) => {
const y = day.getFullYear();
const m = String(day.getMonth() + 1).padStart(2, "0");
const d = String(day.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
return {
recordings: (day: Date) => recordingsSet.has(formatDay(day)),
alerts: (day: Date) => alertsSet.has(formatDay(day)),
detections: (day: Date) => detectionsSet.has(formatDay(day)),
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
alerts: (day: Date) => alertsSet.has(formatCalendarDay(day)),
detections: (day: Date) => detectionsSet.has(formatCalendarDay(day)),
};
}, [reviewSummary, recordingsSummary]);
@ -156,14 +156,32 @@ type TimezoneAwareCalendarProps = {
timezone?: string;
selectedDay?: Date;
onSelect: (day?: Date) => void;
recordingsSummary?: RecordingsSummary;
};
export function TimezoneAwareCalendar({
timezone,
selectedDay,
onSelect,
recordingsSummary,
}: TimezoneAwareCalendarProps) {
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
// When a recordings summary is supplied, underline days that have footage
const recordingsModifier = useMemo(() => {
if (!recordingsSummary) {
return undefined;
}
const recordingsSet = new Set<string>();
for (const date of Object.keys(recordingsSummary)) {
if (date !== LAST_24_HOURS_KEY) {
recordingsSet.add(date);
}
}
return {
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
};
}, [recordingsSummary]);
const timezoneOffset = useMemo(
() =>
timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined,
@ -217,6 +235,10 @@ export function TimezoneAwareCalendar({
onSelect={onSelect}
defaultMonth={selectedDay ?? new Date()}
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
modifiers={recordingsModifier}
components={
recordingsModifier ? { DayButton: ReviewActivityDay } : undefined
}
/>
);
}

View File

@ -12,7 +12,7 @@ export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__";
export const ANNOTATION_OFFSET_MIN = -10000;
export const ANNOTATION_OFFSET_MAX = 5000;
export const ANNOTATION_OFFSET_MAX = 10000;
export const ANNOTATION_OFFSET_STEP = 50;
export const supportedLanguageKeys = [

View File

@ -4,8 +4,6 @@ import { TriggerAction, TriggerType } from "./trigger";
export interface UiConfig {
timezone?: string;
time_format?: "browser" | "12hour" | "24hour";
date_style?: "full" | "long" | "medium" | "short";
time_style?: "full" | "long" | "medium" | "short";
dashboard: boolean;
order: number;
unit_system?: "metric" | "imperial";

View File

@ -14,7 +14,6 @@ export interface MotionSearchRequest {
parallel?: boolean;
threshold?: number;
min_area?: number;
frame_skip?: number;
max_results?: number;
}
@ -43,4 +42,21 @@ export interface MotionSearchStatusResponse {
total_frames_processed?: number;
error_message?: string;
metrics?: MotionSearchMetrics;
scanning_timestamp?: number;
progress?: number;
}
export interface MotionSearchJobResults {
results: MotionSearchResult[];
total_frames_processed: number;
}
export interface MotionSearchJobPayload {
id?: string;
status: "queued" | "running" | "success" | "failed" | "cancelled";
results?: MotionSearchJobResults;
metrics?: MotionSearchMetrics;
scanning_timestamp?: number;
progress?: number;
error_message?: string;
}

View File

@ -7,6 +7,7 @@ import { LuHand, LuPencil } from "react-icons/lu";
import { FrigateConfig } from "@/types/frigateConfig";
import { TimeRange } from "@/types/timeline";
import { RecordingsSummary } from "@/types/review";
import { ASPECT_PORTRAIT_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Button } from "@/components/ui/button";
@ -44,7 +45,11 @@ import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar";
import { useApiHost } from "@/api";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
import {
useFormattedTimestamp,
use24HourTime,
useTimezone,
} from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil";
import useSWR from "swr";
import { cn } from "@/lib/utils";
@ -69,8 +74,6 @@ type MotionSearchDialogProps = {
setThreshold: React.Dispatch<React.SetStateAction<number>>;
minArea: number;
setMinArea: React.Dispatch<React.SetStateAction<number>>;
frameSkip: number;
setFrameSkip: React.Dispatch<React.SetStateAction<number>>;
maxResults: number;
setMaxResults: React.Dispatch<React.SetStateAction<number>>;
searchRange?: TimeRange;
@ -100,8 +103,6 @@ export default function MotionSearchDialog({
setThreshold,
minArea,
setMinArea,
frameSkip,
setFrameSkip,
maxResults,
setMaxResults,
searchRange,
@ -117,6 +118,16 @@ export default function MotionSearchDialog({
const [imageLoaded, setImageLoaded] = useState(false);
const [panMode, setPanMode] = useState(false);
const recordingsTimezone = useTimezone(config);
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
selectedCamera
? [
"recordings/summary",
{ timezone: recordingsTimezone, cameras: selectedCamera },
]
: null,
);
const cameraConfig = useMemo(() => {
if (!selectedCamera) return undefined;
return config.cameras[selectedCamera];
@ -437,23 +448,6 @@ export default function MotionSearchDialog({
{t("settings.minAreaDesc")}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="frameSkip">{t("settings.frameSkip")}</Label>
<div className="flex items-center gap-2">
<Slider
id="frameSkip"
min={1}
max={120}
step={1}
value={[frameSkip]}
onValueChange={([value]) => setFrameSkip(value)}
/>
<span className="w-12 text-sm">{frameSkip}</span>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.frameSkipDesc")}
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="parallelMode">
@ -496,6 +490,7 @@ export default function MotionSearchDialog({
setRange={setSearchRange}
defaultRange={defaultRange}
timezone={timezone}
recordingsSummary={recordingsSummary}
/>
<Button
@ -519,6 +514,7 @@ type SearchRangeSelectorProps = {
setRange: React.Dispatch<React.SetStateAction<TimeRange | undefined>>;
defaultRange: TimeRange;
timezone?: string;
recordingsSummary?: RecordingsSummary;
};
function SearchRangeSelector({
@ -526,6 +522,7 @@ function SearchRangeSelector({
setRange,
defaultRange,
timezone,
recordingsSummary,
}: SearchRangeSelectorProps) {
const { t } = useTranslation(["views/motionSearch", "common"]);
const [startOpen, setStartOpen] = useState(false);
@ -630,6 +627,7 @@ function SearchRangeSelector({
<PopoverContent className="flex flex-col items-center">
<TimezoneAwareCalendar
timezone={timezone}
recordingsSummary={recordingsSummary}
selectedDay={new Date(startTime * 1000)}
onSelect={(day) => {
if (!day) {
@ -696,6 +694,7 @@ function SearchRangeSelector({
<PopoverContent className="flex flex-col items-center">
<TimezoneAwareCalendar
timezone={timezone}
recordingsSummary={recordingsSummary}
selectedDay={new Date(endTime * 1000)}
onSelect={(day) => {
if (!day) {

View File

@ -12,10 +12,12 @@ import { ExportMode } from "@/types/filter";
import {
MotionSearchRequest,
MotionSearchStartResponse,
MotionSearchStatusResponse,
MotionSearchResult,
MotionSearchMetrics,
MotionSearchJobResults,
MotionSearchJobPayload,
} from "@/types/motionSearch";
import { useJobStatus } from "@/api/ws";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
@ -25,6 +27,11 @@ import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
@ -44,7 +51,7 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { MotionData, ZoomLevel } from "@/types/review";
import { MotionData, REVIEW_PADDING, ZoomLevel } from "@/types/review";
import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
@ -52,14 +59,17 @@ import {
RecordingSegment,
} from "@/types/record";
import { VideoResolutionType } from "@/types/live";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
import {
useFormattedTimestamp,
useFormattedRange,
use24HourTime,
} from "@/hooks/use-date-utils";
import MotionSearchROICanvas from "./MotionSearchROICanvas";
import MotionSearchDialog from "./MotionSearchDialog";
import { IoMdArrowRoundBack } from "react-icons/io";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFire } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
import { LuSearch } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { LuSearch, LuChevronRight, LuX } from "react-icons/lu";
type MotionSearchViewProps = {
config: FrigateConfig;
@ -118,10 +128,11 @@ export default function MotionSearchView({
"actions" | "calendar"
>("actions");
// Recordings summary for calendar defer until dialog is closed
// so the preview image in the dialog loads without competing requests
// Recordings summary for the calendars (main view + search dialog). Fetched
// whenever a camera is selected so it is cached and ready by the time the
// dialog's date pickers render, regardless of open/closed state.
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
selectedCamera && !isSearchDialogOpen
selectedCamera
? [
"recordings/summary",
{
@ -146,17 +157,18 @@ export default function MotionSearchView({
const [parallelMode, setParallelMode] = useState(false);
const [threshold, setThreshold] = useState(30);
const [minArea, setMinArea] = useState(20);
const [frameSkip, setFrameSkip] = useState(30);
const [maxResults, setMaxResults] = useState(25);
// Job state
const [jobId, setJobId] = useState<string | null>(null);
const [jobCamera, setJobCamera] = useState<string | null>(null);
// Job polling with SWR
const { data: jobStatus } = useSWR<MotionSearchStatusResponse>(
jobId && jobCamera ? [`${jobCamera}/search/motion/${jobId}`] : null,
{ refreshInterval: 1000 },
const { payload: jobPayload } =
useJobStatus<MotionSearchJobResults>("motion_search");
const jobStatus = jobPayload as MotionSearchJobPayload | null;
const formattedScanningTimestamp = useFormattedTimestamp(
jobStatus?.scanning_timestamp ?? 0,
resultTimestampFormat,
timezone,
);
// Search state
@ -170,6 +182,15 @@ export default function MotionSearchView({
undefined,
);
const [pendingSeekTime, setPendingSeekTime] = useState<number | null>(null);
const pendingSeekTimeRef = useRef<number | null>(null);
// Formatted search window shown above the results (same date+time convention).
const formattedSearchRange = useFormattedRange(
searchRange?.after ?? 0,
searchRange?.before ?? 0,
resultTimestampFormat,
timezone,
);
// Export state
const [exportMode, setExportMode] = useState<ExportMode>("none");
@ -622,7 +643,7 @@ export default function MotionSearchView({
]);
useEffect(() => {
if (pendingSeekTime != null) {
if (pendingSeekTimeRef.current != null) {
return;
}
@ -636,7 +657,7 @@ export default function MotionSearchView({
setPlaybackStart(nextTime);
setSelectedRangeIdx(index === -1 ? chunkedTimeRange.length - 1 : index);
mainControllerRef.current?.seekToTimestamp(nextTime, true);
}, [pendingSeekTime, timeRange, chunkedTimeRange]);
}, [timeRange, chunkedTimeRange]);
useEffect(() => {
if (!scrubbing) {
@ -733,10 +754,9 @@ export default function MotionSearchView({
useEffect(() => {
return () => {
cancelMotionSearchJobViaBeacon(jobIdRef.current, jobCameraRef.current);
void cancelMotionSearchJob(jobIdRef.current, jobCameraRef.current);
};
}, [cancelMotionSearchJob, cancelMotionSearchJobViaBeacon]);
}, [cancelMotionSearchJob]);
useEffect(() => {
const handleBeforeUnload = () => {
@ -802,7 +822,6 @@ export default function MotionSearchView({
parallel: parallelMode,
threshold,
min_area: minArea,
frame_skip: frameSkip,
max_results: maxResults,
};
@ -877,7 +896,6 @@ export default function MotionSearchView({
parallelMode,
threshold,
minArea,
frameSkip,
maxResults,
t,
]);
@ -888,23 +906,27 @@ export default function MotionSearchView({
return;
}
if (!jobId || jobStatus.id !== jobId) {
return;
}
const resultList = jobStatus.results?.results;
if (jobStatus.status === "success") {
setSearchResults(jobStatus.results ?? []);
setSearchResults(resultList ?? []);
setSearchMetrics(jobStatus.metrics ?? null);
setIsSearching(false);
setJobId(null);
setJobCamera(null);
toast.success(
t("changesFound", { count: jobStatus.results?.length ?? 0 }),
);
toast.success(t("changesFound", { count: resultList?.length ?? 0 }));
} else if (
jobStatus.status === "queued" ||
jobStatus.status === "running"
) {
setSearchMetrics(jobStatus.metrics ?? null);
// Stream partial results as they arrive
if (jobStatus.results && jobStatus.results.length > 0) {
setSearchResults(jobStatus.results);
if (resultList && resultList.length > 0) {
setSearchResults(resultList);
}
} else if (jobStatus.status === "failed") {
setIsSearching(false);
@ -912,7 +934,7 @@ export default function MotionSearchView({
setJobCamera(null);
toast.error(
t("errors.searchFailed", {
message: jobStatus.error_message || jobStatus.message,
message: jobStatus.error_message || t("errors.unknown"),
}),
);
} else if (jobStatus.status === "cancelled") {
@ -921,7 +943,7 @@ export default function MotionSearchView({
setJobCamera(null);
toast.message(t("searchCancelled"));
}
}, [jobStatus, t]);
}, [jobStatus, jobId, t]);
// Handle result click
const handleResultClick = useCallback(
@ -930,12 +952,14 @@ export default function MotionSearchView({
result.timestamp < timeRange.after ||
result.timestamp > timeRange.before
) {
pendingSeekTimeRef.current = result.timestamp;
setPendingSeekTime(result.timestamp);
onDaySelect(new Date(result.timestamp * 1000));
return;
}
manuallySetCurrentTime(result.timestamp, true);
// start playback a few seconds before the change so the motion is in view
manuallySetCurrentTime(result.timestamp - REVIEW_PADDING, true);
},
[manuallySetCurrentTime, onDaySelect, timeRange],
);
@ -949,8 +973,9 @@ export default function MotionSearchView({
pendingSeekTime >= timeRange.after &&
pendingSeekTime <= timeRange.before
) {
manuallySetCurrentTime(pendingSeekTime, true);
manuallySetCurrentTime(pendingSeekTime - REVIEW_PADDING, true);
setPendingSeekTime(null);
pendingSeekTimeRef.current = null;
}
}, [pendingSeekTime, timeRange, manuallySetCurrentTime]);
@ -1028,6 +1053,9 @@ export default function MotionSearchView({
const progressMetrics = jobStatus?.metrics ?? searchMetrics;
const progressValue = (() => {
if (jobStatus?.progress != null) {
return Math.min(100, Math.max(0, jobStatus.progress * 100));
}
if (!progressMetrics || progressMetrics.segments_scanned <= 0) {
return 0;
}
@ -1042,23 +1070,48 @@ export default function MotionSearchView({
return Math.min(100, Math.max(0, (doneWork / totalWork) * 100));
})();
const wallTimeLabel = searchMetrics
? searchMetrics.wall_time_seconds >= 60
? t("metrics.minutesSeconds", {
minutes: Math.floor(searchMetrics.wall_time_seconds / 60),
seconds: Math.round(searchMetrics.wall_time_seconds % 60),
})
: t("metrics.seconds", {
seconds: searchMetrics.wall_time_seconds.toFixed(1),
})
: "";
const resultsPanel = (
<>
<div className="p-2">
<h3 className="font-medium">{t("results")}</h3>
</div>
{(hasSearched || isSearching) && (
<div className="flex flex-col gap-1 px-3 py-2.5">
{searchRange && (
<div className="text-sm font-medium text-foreground">
{formattedSearchRange}
</div>
)}
{searchMetrics && (
<div className="text-xs text-muted-foreground">
{t("metrics.scanSummary", {
segments: searchMetrics.segments_scanned,
time: wallTimeLabel,
})}
</div>
)}
</div>
)}
<ScrollArea className="flex-1">
<ScrollArea className="flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block">
{isSearching && (
<div className="flex flex-col gap-2 border-b p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1 text-wrap">
<ActivityIndicator className="mr-2 size-4" />
<div>{t("searching")}</div>
</div>
<div className="flex flex-col gap-1.5 border-b p-3">
<div className="flex items-center gap-2">
<Progress className="h-1.5 flex-1" value={progressValue} />
<Button
variant="destructive"
size="sm"
variant="ghost"
size="icon"
className="size-6 shrink-0 text-muted-foreground"
aria-label={t("cancelSearch")}
title={t("cancelSearch")}
onClick={() => {
void cancelMotionSearchJob(jobId, jobCamera);
setIsSearching(false);
@ -1067,76 +1120,90 @@ export default function MotionSearchView({
toast.success(t("searchCancelled"));
}}
>
{t("cancelSearch")}
<LuX className="size-4" />
</Button>
</div>
<Progress className="h-1" value={progressValue} />
<div className="text-xs text-muted-foreground">
{jobStatus?.scanning_timestamp != null
? t("scanning", { time: formattedScanningTimestamp })
: t("searching")}
</div>
</div>
)}
{searchMetrics && (isSearching || searchResults.length > 0) && (
<div className="mx-2 my-3 rounded-lg border bg-secondary p-2">
<div className="space-y-0.5 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>{t("metrics.segmentsScanned")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_scanned}
</span>
</div>
{searchMetrics.segments_processed > 0 && (
<div className="flex justify-between font-medium">
<span>{t("metrics.segmentsProcessed")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_processed}
</span>
{searchMetrics &&
(isSearching || searchResults.length > 0 || hasSearched) && (
<Collapsible>
<CollapsibleTrigger className="group flex w-full items-center gap-1 px-3 py-2.5 text-left text-xs text-muted-foreground hover:bg-accent">
<LuChevronRight className="size-3 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
{t("metrics.title")}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-0.5 px-3 pb-3 text-xs text-muted-foreground">
{searchMetrics.segments_processed > 0 && (
<div className="flex justify-between font-medium">
<span>{t("metrics.segmentsProcessed")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_processed}
</span>
</div>
)}
{searchMetrics.metadata_inactive_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedInactive")}</span>
<span className="text-primary-variant">
{searchMetrics.metadata_inactive_segments}
</span>
</div>
)}
{searchMetrics.heatmap_roi_skip_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedHeatmap")}</span>
<span className="text-primary-variant">
{searchMetrics.heatmap_roi_skip_segments}
</span>
</div>
)}
{searchMetrics.fallback_full_range_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.fallbackFullRange")}</span>
<span className="text-primary-variant">
{searchMetrics.fallback_full_range_segments}
</span>
</div>
)}
<div className="flex justify-between">
<span>{t("metrics.framesDecoded")}</span>
<span className="text-primary-variant">
{searchMetrics.frames_decoded}
</span>
</div>
<div className="flex justify-between">
<span>{t("metrics.wallTime")}</span>
<span className="text-primary-variant">
{wallTimeLabel}
</span>
</div>
{searchMetrics.segments_with_errors > 0 && (
<div className="flex justify-between text-destructive">
<span>{t("metrics.segmentErrors")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_with_errors}
</span>
</div>
)}
</div>
)}
{searchMetrics.metadata_inactive_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedInactive")}</span>
<span className="text-primary-variant">
{searchMetrics.metadata_inactive_segments}
</span>
</div>
)}
{searchMetrics.heatmap_roi_skip_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.segmentsSkippedHeatmap")}</span>
<span className="text-primary-variant">
{searchMetrics.heatmap_roi_skip_segments}
</span>
</div>
)}
{searchMetrics.fallback_full_range_segments > 0 && (
<div className="flex justify-between">
<span>{t("metrics.fallbackFullRange")}</span>
<span className="text-primary-variant">
{searchMetrics.fallback_full_range_segments}
</span>
</div>
)}
<div className="flex justify-between">
<span>{t("metrics.framesDecoded")}</span>
<span className="text-primary-variant">
{searchMetrics.frames_decoded}
</span>
</div>
<div className="flex justify-between">
<span>{t("metrics.wallTime")}</span>
<span className="text-primary-variant">
{t("metrics.seconds", {
seconds: searchMetrics.wall_time_seconds.toFixed(1),
})}
</span>
</div>
{searchMetrics.segments_with_errors > 0 && (
<div className="flex justify-between text-destructive">
<span>{t("metrics.segmentErrors")}</span>
<span className="text-primary-variant">
{searchMetrics.segments_with_errors}
</span>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
{(searchResults.length > 0 || (hasSearched && !isSearching)) && (
<div className="border-t px-1.5 pb-1.5 pt-3">
<h3 className="text-sm font-medium tracking-wide text-muted-foreground">
{searchResults.length > 0 && (
<span className="ml-1.5">{searchResults.length}</span>
)}{" "}
{t("results")}
</h3>
</div>
)}
@ -1145,7 +1212,7 @@ export default function MotionSearchView({
{hasSearched ? t("noChangesFound") : t("noResultsYet")}
</div>
) : searchResults.length > 0 ? (
<div className="flex flex-col gap-1 p-2">
<div className="flex flex-col gap-1 px-1 pb-2">
{searchResults.map((result, index) => (
<SearchResultItem
key={index}
@ -1187,8 +1254,6 @@ export default function MotionSearchView({
setThreshold={setThreshold}
minArea={minArea}
setMinArea={setMinArea}
frameSkip={frameSkip}
setFrameSkip={setFrameSkip}
maxResults={maxResults}
setMaxResults={setMaxResults}
searchRange={searchRange}
@ -1516,15 +1581,20 @@ function SearchResultItem({
return (
<button
className="flex w-full flex-col rounded-md p-2 text-left hover:bg-accent"
className="flex w-full items-center justify-between gap-2 rounded-md p-2 text-left hover:bg-accent"
onClick={onClick}
title={t("jumpToTime")}
>
<span className="text-sm font-medium">{formattedTime}</span>
<span className="text-xs text-muted-foreground">
{t("changePercentage", {
<span className="min-w-0 truncate text-sm font-medium">
{formattedTime}
</span>
<span
className="shrink-0 text-xs tabular-nums text-muted-foreground"
title={t("changePercentage", {
percentage: result.change_percentage.toFixed(1),
})}
>
{result.change_percentage.toFixed(1)}%
</span>
</button>
);

View File

@ -902,7 +902,7 @@ function StreamUrlEntry({
return (
<div className="pb-4">
<div className="flex h-7 flex-row items-center justify-start gap-2 text-sm text-primary-variant">
{t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })}
{t("go2rtcStreams.sourceNumber", { index: urlIndex + 1 })}
{canRemove && (
<Button
variant="ghost"

View File

@ -53,6 +53,7 @@ export default function MasksAndZonesView({
const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [polygonsInitialized, setPolygonsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadingPolygonIndex, setLoadingPolygonIndex] = useState<
number | undefined
@ -609,6 +610,7 @@ export default function MasksAndZonesView({
...globalObjectMasks,
...objectMasks,
]);
setPolygonsInitialized(true);
// Don't overwrite editingPolygons during editing layout shifts
// from switching to the edit pane can trigger a resize which
// recalculates scaledWidth/scaledHeight and would discard the
@ -676,7 +678,7 @@ export default function MasksAndZonesView({
}, [currentEditingProfile]);
useSearchEffect("object_mask", (coordinates: string) => {
if (!scaledWidth || !scaledHeight || isLoading) {
if (!scaledWidth || !scaledHeight || isLoading || !polygonsInitialized) {
return false;
}
// convert box points string to points array

View File

@ -484,7 +484,7 @@ export default function TriggerView({
) : (
<>
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<div className="flex max-w-5xl flex-col items-start">
<Heading as="h4" className="mb-1">
{t("triggers.management.title")}
</Heading>