Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub c172db9fa4 Bump the pip-main-minor-patch group in /docker/main with 3 updates
Bumps the pip-main-minor-patch group in /docker/main with 3 updates: [ruff](https://github.com/astral-sh/ruff), [mypy](https://github.com/python/mypy) and [tzdata](https://github.com/python/tzdata).


Updates `ruff` from 0.17.1 to 0.17.3
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.17.1...0.17.3)

Updates `mypy` from 2.4.0 to 2.4.1
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v2.4.0...v2.4.1)

Updates `tzdata` from 2026.5 to 2026.6
- [Release notes](https://github.com/python/tzdata/releases)
- [Changelog](https://github.com/python/tzdata/blob/master/NEWS.md)
- [Commits](https://github.com/python/tzdata/compare/2026.5...2026.6)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.17.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: pip-main-minor-patch
- dependency-name: mypy
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-main-minor-patch
- dependency-name: tzdata
  dependency-version: '2026.6'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-main-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-01 11:34:00 +00:00
44 changed files with 489 additions and 603 deletions
-1
View File
@@ -181,7 +181,6 @@ noninteractive
noprint
Norfair
nptype
NTFS
NTSC
numpy
nvenc
+1 -1
View File
@@ -42,7 +42,7 @@ runs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to the Container registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
-3
View File
@@ -55,9 +55,6 @@ updates:
# Needs Python 3.12 and numpy 2
- dependency-name: "librosa"
versions: [">=1.0"]
# pandas 3.1 requires numpy 2
- dependency-name: "pandas"
versions: [">=3.1"]
# Needs Python 3.12
- dependency-name: "scipy"
versions: [">=1.18"]
+1 -1
View File
@@ -590,7 +590,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+6 -6
View File
@@ -2,9 +2,9 @@ aiofiles == 25.1.*
click == 8.5.*
# FastAPI
aiohttp == 3.14.*
starlette == 1.10.*
starlette == 1.9.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.162.*
fastapi[standard-no-fastapi-cloud-cli] == 0.159.*
uvicorn == 0.54.*
slowapi == 0.1.*
joserfc == 1.7.*
@@ -45,11 +45,11 @@ scipy == 1.17.*
openvino == 2026.4.*
onnxruntime == 1.31.*
# Embeddings
transformers == 5.22.*
transformers == 5.20.*
# Generative AI
google-genai == 2.34.*
google-genai == 2.32.*
ollama == 0.6.*
openai == 3.41.*
openai == 3.36.*
# push notifications
py-vapid == 1.9.6
pywebpush == 2.5.*
@@ -68,7 +68,7 @@ prometheus-client == 0.26.*
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
# audio transcription
sherpa-onnx==2.0.*
sherpa-onnx==1.13.*
faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.14.*
+1 -1
View File
@@ -361,7 +361,7 @@ The NPU firmware is loaded by the host kernel and is not part of the Frigate ima
Frigate bundles a specific version of Intel's [linux-npu-driver](https://github.com/intel/linux-npu-driver/releases), and the host firmware must come from that release or a newer one. Firmware older than the bundled driver may fail with `MAPPED_INFERENCE_VERSION is NOT compatible with the ELF`, where `Expected` is the version the firmware supports and `received` is the version the bundled compiler produced. Distributions often package older firmware than the driver Frigate ships, so check the build date on the host with `sudo dmesg | grep -i vpu` and update it there if needed.
Intel NPUs can be used under Home Assistant OS 18.4 or newer.
Intel NPUs cannot be used under Home Assistant OS, which does not include the NPU firmware.
### Configuration {#configuration-openvino}
+5 -2
View File
@@ -763,17 +763,20 @@ The community supported docker image tags for the current stable version are:
:::warning
As of Home Assistant Operating System 10.2 and Home Assistant 2023.6 defining separate network storage for media is supported.
There are important limitations in HA OS to be aware of:
- Separate local storage for media is not yet supported by Home Assistant
- AMD GPUs are not supported because HA OS does not include the mesa driver.
- Intel NPUs require Home Assistant OS 18.4 or newer. Earlier releases do not include the NPU firmware.
- Intel NPUs are not supported because HA OS does not include the NPU firmware.
- Nvidia GPUs are not supported because HA Apps do not support the Nvidia runtime.
:::
:::tip
Frigate's media can be kept on separate storage, either a network share (Home Assistant 2023.6 or newer) or a local disk such as a second internal drive or a USB drive (Home Assistant 2026.11 or newer). See [the storage guide](/guides/ha_network_storage.md) for setup instructions.
See [the network storage guide](/guides/ha_network_storage.md) for instructions to setup network storage for frigate.
:::
+12 -20
View File
@@ -1,48 +1,40 @@
---
id: ha_network_storage
title: Home Assistant storage
title: Home Assistant network storage
---
Frigate's media (`/media/frigate`) can be stored on a network share or a separate local disk (internal or USB) instead of the Home Assistant system disk.
As of Home Assistant 2023.6, Network Mounted Storage is supported for Apps.
## Prerequisites
## Setting Up Remote Storage For Frigate
### Network storage
### Prerequisites
- Home Assistant 2023.6 or newer is installed
- Running Home Assistant Operating System 10.2 or newer OR Running Supervised with latest os-agent installed (this is required for supervised install)
### Local disk
- Home Assistant 2026.11 or newer is installed
- The disk is already formatted, ideally as ext4 (Home Assistant does not format disks). Avoid FAT32, exFAT, and NTFS, which do not support Linux file permissions.
## Setting Up Storage For Frigate
### Initial Setup
1. Stop the Frigate App
### Move current data
Keeping the current data is optional, but `/media/frigate` must be moved or deleted so Home Assistant can mount the storage there. The database and config are stored elsewhere and are not affected.
Keeping the current data is optional, but the data will need to be moved regardless so the share can be created successfully.
#### If you want to keep the current data
1. Rename the /media/frigate folder to /media/frigate_tmp
1. Move the frigate.db, frigate.db-shm, frigate.db-wal files to the /config directory
2. Rename the /media/frigate folder to /media/frigate_tmp
#### If you don't want to keep the current data
1. Delete the /media/frigate folder and all of its contents
### Add the storage
### Create the media share
1. Go to **Settings -> System -> Storage -> Add storage**
2. Name the storage `frigate` (this is required)
3. Choose usage `media`
4. Choose the type:
- For network storage, choose `Samba/Windows (CIFS)` or `Network File System (NFS)` and fill out the additional required info for your particular NAS
- For a local disk, choose `Local disk` and select the disk to use
1. Go to **Settings -> System -> Storage -> Add Network Storage**
2. Name the share `frigate` (this is required)
3. Choose type `media`
4. Fill out the additional required info for your particular NAS
5. Connect
6. Move files from `/media/frigate_tmp` to `/media/frigate` if they were kept in previous step
7. Start the Frigate App
-2
View File
@@ -6371,7 +6371,6 @@ paths:
required: false
schema:
type: integer
minimum: 0
description: Padding to apply to the vod.
default: 0
title: Padding
@@ -7577,7 +7576,6 @@ paths:
required: false
schema:
type: integer
minimum: 1
default: 30
title: Scale
responses:
-4
View File
@@ -966,10 +966,6 @@ def login(request: Request, body: AppPostLoginBody):
try:
db_user: User = User.get_by_id(user)
except DoesNotExist:
# Hash anyway so response timing doesn't reveal which usernames exist
hash_password(
password, iterations=request.app.frigate_config.auth.hash_iterations
)
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
failed_logins.record(user, time.time())
return JSONResponse(content={"message": "Login failed"}, status_code=401)
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema
@@ -11,7 +11,7 @@ class MediaRecordingsAvailabilityQueryParams(BaseModel):
cameras: str = "all"
before: float | SkipJsonSchema[None] = None
after: float | SkipJsonSchema[None] = None
scale: int = Field(default=30, ge=1)
scale: int = 30
class RecordingsDeleteQueryParams(BaseModel):
+4 -4
View File
@@ -291,12 +291,12 @@ def events(
clauses.append(
reduce(
operator.or_,
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
)
)
# all other cases should be and operator
else:
clauses.append(start_hour_fun >= time_after)
clauses.append(start_hour_fun > time_after)
clauses.append(start_hour_fun < time_before)
if has_clip is not None:
@@ -749,12 +749,12 @@ def events_search(
event_filters.append(
reduce(
operator.or_,
[(start_hour_fun >= time_after), (start_hour_fun < time_before)],
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
)
)
# all other cases should be and operator
else:
event_filters.append(start_hour_fun >= time_after)
event_filters.append(start_hour_fun > time_after)
event_filters.append(start_hour_fun < time_before)
# Perform semantic search
+1 -1
View File
@@ -1227,7 +1227,7 @@ def bulk_delete_exports(body: ExportBulkDeleteBody):
files_in_use = _get_files_in_use()
for export in exports:
if export.in_progress or export.video_path.split("/")[-1] in files_in_use:
if export.video_path.split("/")[-1] in files_in_use:
return JSONResponse(
content={
"success": False,
+5 -9
View File
@@ -862,7 +862,7 @@ async def vod_hour(
async def vod_event(
request: Request,
event_id: str,
padding: int = Query(0, ge=0, description="Padding to apply to the vod."),
padding: int = Query(0, description="Padding to apply to the vod."),
):
try:
event: Event = Event.get(Event.id == event_id)
@@ -1521,7 +1521,6 @@ async def preview_gif(
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
)
.where(Previews.camera == camera_name)
.order_by(Previews.start_time.asc())
.limit(1)
.get()
)
@@ -1531,8 +1530,7 @@ async def preview_gif(
status_code=404,
)
clip_start = max(start_ts, preview.start_time)
diff = clip_start - preview.start_time
diff = start_ts - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config
@@ -1544,7 +1542,7 @@ async def preview_gif(
"-ss",
f"00:{minutes}:{seconds}",
"-t",
f"{end_ts - clip_start}",
f"{end_ts - start_ts}",
"-i",
preview.path,
"-r",
@@ -1711,7 +1709,6 @@ async def preview_mp4(
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
)
.where(Previews.camera == camera_name)
.order_by(Previews.start_time.asc())
.limit(1)
.get()
)
@@ -1724,8 +1721,7 @@ async def preview_mp4(
status_code=404,
)
clip_start = max(start_ts, preview.start_time)
diff = clip_start - preview.start_time
diff = start_ts - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
config: FrigateConfig = request.app.frigate_config
@@ -1738,7 +1734,7 @@ async def preview_mp4(
"-ss",
f"00:{minutes}:{seconds}",
"-t",
f"{end_ts - clip_start}",
f"{end_ts - start_ts}",
"-i",
preview.path,
"-r",
+2 -1
View File
@@ -163,7 +163,8 @@ async def review(
# Apply ordering and limit
review_query = (
review_query.order_by(ReviewSegment.start_time.desc())
review_query.order_by(ReviewSegment.severity.asc())
.order_by(ReviewSegment.start_time.desc())
.limit(limit)
.dicts()
.iterator()
@@ -12,8 +12,6 @@ import math
from collections.abc import Sequence
from typing import Any
from titlecase import titlecase
from frigate.models import Event, Timeline
logger = logging.getLogger(__name__)
@@ -86,16 +84,12 @@ def event_name(event: dict[str, Any]) -> str:
tracked object whenever a subject is re-detected, so the tracking data
cannot say whether two entries are the same subject, and the notes stay
ambiguous rather than implying either answer.
Names are formatted the same way as in the prompt's "Objects in Scene"
list. Small models otherwise copy the raw lowercase sub label into their
response instead of the name they were told to use.
"""
label = str(event["label"]).replace("_", " ").replace("-verified", "")
sub_label = event.get("sub_label")
if sub_label:
return f'{label} "{titlecase(str(sub_label).replace("_", " "))}"'
return f'{label} "{sub_label}"'
article = "an" if label[:1].lower() in "aeiou" else "a"
return f"{article} {label}"
@@ -219,7 +213,6 @@ def build_timeline(
events: list[dict[str, Any]],
span_end: float,
state_changes: Sequence[dict[str, Any]] = (),
span_start: float | None = None,
) -> list[tuple[float, str]]:
"""All annotated moments across every event, in time order.
@@ -229,12 +222,6 @@ def build_timeline(
describe nothing the model can see. A track ending means the object
stopped being detected, which may or may not mean it left the frame.
`span_start` is the timestamp of the first sampled frame. An object
tracked since before it gets a single "already in view" note instead of
its earlier history: those moments would otherwise land on the first
frame and read as happening during the clip, turning a car that parked
minutes ago into one pulling in.
`state_changes` are timeline rows (timestamp, source_id, class_type); the
stationary and active ones become "has stopped moving" / "starts moving
again". Frigate only marks an object stationary after it has been still
@@ -262,13 +249,6 @@ def build_timeline(
if event["start_time"] > span_end:
continue
if (
span_start is not None
and event["end_time"]
and event["end_time"] < span_start
):
continue
points = path_points(event.get("path_data") or [])
legs = path_legs(points) if len(points) >= 2 else []
name = event_name(event)
@@ -279,33 +259,8 @@ def build_timeline(
)
remaining = list(enumerate(legs))
moments: list[tuple[float, str]] = []
event_changes = sorted(
(timestamp, phrase)
for timestamp, phrase in changes_by_event.get(event["id"], [])
if timestamp >= detected_at
)
if span_start is not None and detected_at < span_start:
# Only whether it had come to rest is noted; where it is and which
# way it is heading are visible in the first frame.
earlier = [phrase for t, phrase in event_changes if t < span_start]
not_moving = (
bool(earlier) and earlier[-1] == STATE_CHANGE_PHRASES["stationary"]
)
timeline.append(
(
span_start,
f"{name} already in view when the sequence begins"
+ (", not moving" if not_moving else ""),
)
)
remaining = [
(index, leg)
for index, leg in remaining
if leg_start_time(points, leg) >= span_start
]
event_changes = [(t, p) for t, p in event_changes if t >= span_start]
elif merges:
if merges:
heading = leg_heading(points, legs[0])
timeline.append(
(detected_at, f"{name} first detected at {where}, moving {heading}")
@@ -322,7 +277,11 @@ def build_timeline(
)
)
moments.extend(event_changes)
moments.extend(
(timestamp, phrase)
for timestamp, phrase in changes_by_event.get(event["id"], [])
if timestamp >= detected_at
)
for timestamp, phrase in moments:
if timestamp <= span_end:
@@ -431,10 +390,7 @@ def build_frame_captions(
timeline.extend(
(timestamp, f"[tracker] {note}")
for timestamp, note in build_timeline(
events,
span_end,
get_state_changes(detection_ids),
span_start=frame_times[0],
events, span_end, get_state_changes(detection_ids)
)
)
+2 -2
View File
@@ -386,7 +386,7 @@ class GeminiClient(GenAIClient):
response = self.provider.models.generate_content(
model=self.genai_config.model,
contents=gemini_messages,
contents=gemini_messages, # type: ignore[arg-type]
config=types.GenerateContentConfig(**config_params),
)
@@ -648,7 +648,7 @@ class GeminiClient(GenAIClient):
stream = await self.provider.aio.models.generate_content_stream(
model=self.genai_config.model,
contents=gemini_messages,
contents=gemini_messages, # type: ignore[arg-type]
config=types.GenerateContentConfig(**config_params),
)
+11 -20
View File
@@ -18,17 +18,11 @@ from frigate.models import Event
# Base guidance per response field. `observations` is a reasoning scaffold,
# not user-facing, so style presets never override it.
#
# Every scene and shortSummary guidance states its length. The response
# schema caps both fields, and grammar-constrained providers such as llama.cpp
# enforce the cap by cutting the text off, so a model that is never told the
# limit ends mid-sentence. Every user-facing field also repeats the name rule:
# models follow per-field guidance far more reliably than a single global rule.
REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
"observations": "List what happens in chronological order, starting from the first frame. Include each arrival, departure, object handled, and notable change in position or state, including brief moments at the start or end of the sequence that a longer activity could overshadow. Each item is a single concrete fact written as a complete sentence.",
"scene": 'Describe how the sequence begins, then the progression of events: all significant movements and actions in order. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name; do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign. Use no more than five sentences.',
"observations": "Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.",
"scene": 'Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.',
"title": "Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.",
"shortSummary": "Briefly summarize the primary activity across the observations in one sentence. For named subjects, always use their name.",
"shortSummary": "Briefly summarize the primary activity across the observations.",
"potential_threat_level": "Must be consistent with your scene description and the activity patterns above.",
}
@@ -37,19 +31,19 @@ REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
# competing style instructions.
REVIEW_RESPONSE_STYLES: dict[str, dict[str, str]] = {
"natural": {
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign. Use no more than five sentences.',
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign.',
"title": 'Write the title as a short, sentence-case headline in present tense: the subject, then what they do, phrased the way you would text it to the homeowner. Describe only the action you see; do not assign the person a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing. For named subjects, always use their name.",
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing.",
},
"concise": {
"scene": 'Cover each significant movement and action in order using as few short, direct sentences as possible, omitting environmental and cosmetic detail unless it affects the assessment. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign. Use no more than five sentences.',
"scene": 'Cover each significant movement and action in order using as few short, direct sentences as possible, omitting environmental and cosmetic detail unless it affects the assessment. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
"title": 'Write the title as a terse label of two to four words naming the specific activity observed and where it happened. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. For named subjects, always use their name.',
"shortSummary": "Summarize the primary activity in one short sentence. For named subjects, always use their name.",
"shortSummary": "Summarize the primary activity in one short sentence.",
},
"detailed": {
"scene": 'Describe how the sequence begins, then the progression of events: all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign. Use no more than five sentences.',
"scene": 'Describe how the sequence begins, then the progression of events — all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
"title": 'Write the title as a specific description of who did what and where, in under roughly twelve words, including the most distinguishing visible detail of the subject, such as clothing or vehicle color. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
"shortSummary": "Briefly summarize the primary activity across the observations in one sentence, including the most identifying visible detail, such as vehicle color or clothing. For named subjects, always use their name.",
"shortSummary": "Briefly summarize the primary activity across the observations, including the most identifying visible detail, such as vehicle color or clothing.",
},
}
@@ -67,12 +61,9 @@ def get_review_field_guidelines(response_style: str = "default") -> dict[str, st
# Explains the per-frame labels and tracker notes used by the annotated frame
# mode. Neither the notes nor this guidance say whether repeated detections are
# the same subject, since the tracking data cannot tell. The last line keeps
# "no longer detected" from being read as the subject leaving the frame, which
# is only one of the reasons the tracker loses an object.
# the same subject, since the tracking data cannot tell.
FRAME_ANNOTATION_GUIDANCE = """- Each image below is immediately preceded by a text label giving its frame number and how many seconds into the sequence it was captured. Use these labels to track the order of events and the time between them.
- Some images below are preceded by notes from the camera's object tracker recording what changed at that point: an object being first detected, starting to move, reversing direction, stopping, or no longer being detected. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them.
- The notes say when something changed, not why. In particular, an object is no longer detected whenever the tracker loses sight of it, which happens when it leaves the frame and also when it goes out of view behind or inside something. Look at the frames before and after each note to determine what actually happened."""
- Some images below are preceded by notes from the camera's object tracker recording what changed at that point: an object being first detected, starting to move, reversing direction, stopping, or no longer being detected. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them."""
def build_review_description_prompt(
+1 -6
View File
@@ -1470,9 +1470,6 @@ class RecordingExporter(threading.Thread):
try:
self._run_export(video_path, thumb_path)
except Exception:
self._discard_failed_export(video_path, thumb_path)
raise
finally:
# staged runs hold a full copy of the export, so they must not
# survive a failure the way the small chapter file could
@@ -1482,8 +1479,7 @@ class RecordingExporter(threading.Thread):
"""Drop the partial output and the row that promised it."""
Path(video_path).unlink(missing_ok=True)
Export.delete().where(Export.id == self.export_id).execute()
if thumb_path:
Path(thumb_path).unlink(missing_ok=True)
Path(thumb_path).unlink(missing_ok=True)
def _run_export(self, video_path: str, thumb_path: str) -> None:
try:
@@ -1502,7 +1498,6 @@ class RecordingExporter(threading.Thread):
else:
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
except DoesNotExist:
self._discard_failed_export(video_path, thumb_path)
return
# When neither custom ffmpeg arg is set the default path uses
+2 -8
View File
@@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp):
def test_event_time_filtering(self):
morning_id = "123456.random"
evening_id = "654321.random"
morning = 1656568800 # 06/30/2022 6 am (GMT)
evening = 1656612000 # 06/30/2022 6 pm (GMT)
morning = 1656590400 # 06/30/2022 6 am (GMT)
evening = 1656633600 # 06/30/2022 6 pm (GMT)
with AuthTestClient(self.app) as client:
super().insert_mock_event(morning_id, morning)
@@ -417,12 +417,6 @@ class TestHttpApp(BaseTestHttp):
).json()
assert events
assert len(events) == 1
# an event in the first minute of the range is included
events = client.get(
"/events",
params={"time_range": "06:00,24:00"},
).json()
assert len(events) == 2
def test_set_delete_sub_label(self):
mock_event_updater = Mock(spec=EventMetadataPublisher)
-24
View File
@@ -427,30 +427,6 @@ class TestHttpExport(BaseTestHttp):
assert response.status_code == 400
assert Export.get(Export.id == "front_door_running").video_path == video
def test_delete_export_rejected_while_in_progress(self):
with tempfile.TemporaryDirectory() as tmpdir:
video = os.path.join(tmpdir, "front_door_abc123.mp4")
with open(video, "wb") as handle:
handle.write(b"video")
Export.create(
id="front_door_running",
camera="front_door",
name="front door export",
date=100,
video_path=video,
thumb_path=os.path.join(tmpdir, "t.webp"),
in_progress=True,
)
with AuthTestClient(self.app) as client:
response = client.post(
"/exports/delete", json={"ids": ["front_door_running"]}
)
assert response.status_code == 400
assert os.path.exists(video)
def test_rename_export_missing_file_leaves_the_row_alone(self):
with tempfile.TemporaryDirectory() as tmpdir:
video = os.path.join(tmpdir, "front_door_gone_abc123.mp4")
-4
View File
@@ -584,10 +584,6 @@ class TestHttpMedia(BaseTestHttp):
{"start_time": 1160, "end_time": 1300},
]
# a non-positive scale would never advance the gap scan
response = client.get("/recordings/unavailable", params={"scale": 0})
assert response.status_code == 422
def test_recordings_summary_day_attribution_by_start_time(self):
"""
A recording spanning midnight marks only its start day.
-13
View File
@@ -72,19 +72,6 @@ class TestReviewResponseStyle(unittest.TestCase):
f"unexpected override fields: {set(overrides)}",
)
def test_every_style_states_length_and_name_rules(self):
# The schema caps these fields and grammar-constrained providers cut
# text off at the cap, and models only reliably use names when each
# field asks for them.
for style in ["default", *REVIEW_RESPONSE_STYLES]:
fields = get_review_field_guidelines(style)
self.assertIn("no more than five sentences", fields["scene"])
self.assertIn("one", fields["shortSummary"])
self.assertIn("sentence", fields["shortSummary"])
for field_name in ("scene", "title", "shortSummary"):
self.assertIn("always use their name", fields[field_name])
def test_config_enum_matches_style_presets(self):
enum_styles = {e.value for e in ReviewResponseStyleEnum}
self.assertEqual(enum_styles, {"default", *REVIEW_RESPONSE_STYLES})
-55
View File
@@ -82,10 +82,6 @@ class TestEventNames(unittest.TestCase):
event = {"label": "waste_bin", "sub_label": "Compost"}
self.assertEqual(event_name(event), 'waste bin "Compost"')
def test_names_match_the_objects_in_scene_list(self):
event = {"label": "person", "sub_label": "known_subject"}
self.assertEqual(event_name(event), 'person "Known Subject"')
def test_repeated_objects_are_never_numbered(self):
# Whether these are the same subject is unknown, so the notes must not
# imply either answer.
@@ -301,57 +297,6 @@ class TestLateEvents(unittest.TestCase):
)
class TestEarlyEvents(unittest.TestCase):
def setUp(self):
# A car that pulled in and parked well before the first sampled frame,
# then leaves during the clip.
self.car = track(
"1789481990.000000-parked",
"car",
0.0,
straight_path((0.1, 0.9), (0.4, 0.6), 6, 1.0),
)
self.changes = [
{
"timestamp": 12.0,
"source_id": self.car["id"],
"class_type": "stationary",
},
{"timestamp": 70.0, "source_id": self.car["id"], "class_type": "active"},
]
def test_history_before_the_first_frame_is_summarized(self):
# Arrival notes on the first frame were read as the car pulling in
# during the clip.
timeline = build_timeline(
[self.car], 90.0, state_changes=self.changes, span_start=50.0
)
self.assertEqual(
timeline,
[
(50.0, "a car already in view when the sequence begins, not moving"),
(70.0, "a car starts moving again"),
],
)
def test_object_still_moving_at_the_first_frame_is_not_called_stationary(self):
timeline = build_timeline([self.car], 90.0, span_start=3.0)
self.assertEqual(
timeline[0], (3.0, "a car already in view when the sequence begins")
)
self.assertFalse(any("first detected" in p for _, p in timeline))
def test_objects_gone_before_the_first_frame_are_skipped(self):
gone = dict(self.car, end_time=20.0)
self.assertEqual(build_timeline([gone], 90.0, span_start=50.0), [])
def test_objects_detected_inside_the_clip_are_unchanged(self):
self.assertEqual(
build_timeline([self.car], 90.0, self.changes, span_start=0.0),
build_timeline([self.car], 90.0, self.changes),
)
class TestFrameBucketing(unittest.TestCase):
def test_moment_attaches_to_the_following_frame(self):
frame_times = [0.0, 10.0, 20.0, 30.0]
+23 -38
View File
@@ -42,7 +42,6 @@ from frigate.config.camera.genai import GenAIConfig, GenAIProviderEnum # noqa:
from frigate.genai.prompts import ( # noqa: E402
REVIEW_RESPONSE_STYLES,
build_review_description_response_format,
get_review_field_guidelines,
)
EXAMPLES_DIR = Path(__file__).resolve().parent / "genai-review-examples"
@@ -228,14 +227,11 @@ def list_examples() -> list[Path]:
)
def load_frames(example: Path) -> tuple[list[bytes], list[str] | None]:
"""Load the example's frames as JPEG bytes in frame order, with captions.
def load_frames(example: Path) -> list[bytes]:
"""Load the example's frames as JPEG bytes in frame order.
Frames are saved by Frigate as <index>.jpg or <index>.webp. Non-JPEG
images are re-encoded to JPEG to match what Frigate sends to providers.
In annotated frame mode Frigate also saves <index>.txt, the caption sent
directly before that frame; captions are returned only when every frame
has one, matching Frigate's fallback to plain frames.
"""
import cv2
@@ -246,42 +242,34 @@ def load_frames(example: Path) -> tuple[list[bytes], list[str] | None]:
return (1 << 30, path.name)
frames: list[bytes] = []
captions: list[str] = []
files = sorted(
(f for f in example.iterdir() if f.suffix.lower() in IMAGE_EXTENSIONS),
key=frame_order,
)
for file in files:
if file.suffix.lower() in (".jpg", ".jpeg"):
frame = file.read_bytes()
else:
image = cv2.imread(str(file))
if image is None:
print(f" Skipping unreadable image {file.name}")
continue
ok, jpg = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
if not ok:
continue
frame = jpg.tobytes()
frames.append(file.read_bytes())
continue
frames.append(frame)
caption_file = file.with_suffix(".txt")
if caption_file.is_file():
captions.append(caption_file.read_text())
return frames, captions if captions and len(captions) == len(frames) else None
image = cv2.imread(str(file))
if image is None:
print(f" Skipping unreadable image {file.name}")
continue
ok, jpg = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
if ok:
frames.append(jpg.tobytes())
return frames
def apply_style(prompt: str, style: str) -> str:
"""Apply the current field guidance for a style preset to a saved prompt.
"""Apply a style preset to a saved prompt.
Every per-field response guidance line is replaced with what Frigate
builds at runtime for that style, so "default" restores the default
guidance on a prompt saved with another style, and older saves pick up
current guidance. Handles both the current guidance format
Presets replace the per-field response guidance lines, matching what
Frigate builds at runtime. Handles both the current guidance format
("- `scene`: ...") and the 0.17 format ("- `scene` (string): ...").
"""
for field_name, guidance in get_review_field_guidelines(style).items():
overrides = REVIEW_RESPONSE_STYLES.get(style, {})
for field_name, guidance in overrides.items():
pattern = rf"^- `{field_name}`(?: \(string\))?: .*$"
prompt, count = re.subn(
pattern,
@@ -304,9 +292,7 @@ def pretty_print_response(response: str) -> None:
print(json.dumps(parsed, indent=2, ensure_ascii=False))
def followup_loop(
client: Any, base_prompt: str, frames: list[bytes], captions: list[str] | None
) -> None:
def followup_loop(client: Any, base_prompt: str, frames: list[bytes]) -> None:
"""Let the user ask the model follow-up questions about the same frames."""
transcript = base_prompt
print("\nAsk follow-up questions about this sequence (blank line to finish).")
@@ -325,7 +311,7 @@ def followup_loop(
f"Question: {question}"
)
start = time.monotonic()
answer = client._send(prompt, frames, image_captions=captions)
answer = client._send(prompt, frames)
elapsed = time.monotonic() - start
if answer is None:
print("No response from provider (see logs above).")
@@ -363,7 +349,7 @@ def run_example(settings: TesterSettings) -> None:
return
prompt = apply_style(prompt_file.read_text(), style)
frames, captions = load_frames(example)
frames = load_frames(example)
if not frames:
print(f"{example.name} contains no frame images, cannot run")
return
@@ -379,11 +365,10 @@ def run_example(settings: TesterSettings) -> None:
print(
f"\nSending {len(frames)} frames to {settings.provider} "
f"(model={settings.model or '(none)'}, style={style}, "
f"captions={'yes' if captions else 'no'})..."
f"(model={settings.model or '(none)'}, style={style})..."
)
start = time.monotonic()
response = client._send(prompt, frames, response_format, image_captions=captions)
response = client._send(prompt, frames, response_format)
elapsed = time.monotonic() - start
if response is None:
@@ -392,7 +377,7 @@ def run_example(settings: TesterSettings) -> None:
print(f"\nResponse ({elapsed:.1f}s):\n")
pretty_print_response(response)
followup_loop(client, f"{prompt}\n\nYour analysis:\n{response}", frames, captions)
followup_loop(client, f"{prompt}\n\nYour analysis:\n{response}", frames)
def main() -> None:
+6 -7
View File
@@ -97,13 +97,12 @@ test.describe("setup wizard account @high @mobile", () => {
.fill("a-long-enough-password");
await page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => sent.find((call) => call.method === "POST")?.body)
.toEqual({
username: "family",
password: "a-long-enough-password",
role: "viewer",
});
const createCall = sent.find((call) => call.method === "POST");
expect(createCall?.body).toEqual({
username: "family",
password: "a-long-enough-password",
role: "viewer",
});
});
test("shows anonymous copy when nobody is signed in", async ({
+11 -23
View File
@@ -101,14 +101,10 @@ test.describe("setup wizard hardware @high @mobile", () => {
await expect(page.getByText("Will use VAAPI (Intel/AMD)")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
await expect
.poll(
() =>
saves.find((save) => save.config_data?.ffmpeg)?.config_data?.ffmpeg,
)
.toEqual({
hwaccel_args: "preset-vaapi",
});
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
hwaccel_args: "preset-vaapi",
});
// the saved steps only take effect after a restart
const restarts = await captureRestarts(page);
@@ -121,7 +117,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
await page.getByRole("button", { name: "Apply & Restart" }).click();
await expect(page.getByText("Starting Frigate...")).toBeVisible();
await expect.poll(() => restarts).toHaveLength(1);
expect(restarts).toHaveLength(1);
});
test("defers model setup for onnx hardware without Frigate+", async ({
@@ -232,14 +228,10 @@ test.describe("setup wizard hardware @high @mobile", () => {
await page.getByRole("button", { name: "Next" }).click();
// no camera was added, so there is no codec to match
await expect
.poll(
() =>
saves.find((save) => save.config_data?.ffmpeg)?.config_data?.ffmpeg,
)
.toEqual({
hwaccel_args: "preset-jetson-h264",
});
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
hwaccel_args: "preset-jetson-h264",
});
});
test("None writes an explicit empty hwaccel list", async ({
@@ -257,11 +249,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
await page.getByRole("radio", { name: "None (software decoding)" }).click();
await page.getByRole("button", { name: "Next" }).click();
await expect
.poll(
() =>
saves.find((save) => save.config_data?.ffmpeg)?.config_data?.ffmpeg,
)
.toEqual({ hwaccel_args: [] });
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({ hwaccel_args: [] });
});
});
+164 -164
View File
@@ -9,8 +9,8 @@
"version": "0.0.0",
"hasInstallScript": true,
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.8",
"@hookform/resolvers": "^6.0.2",
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^6.0.0",
"@radix-ui/react-alert-dialog": "^1.2.1",
"@radix-ui/react-aspect-ratio": "^1.2.0",
"@radix-ui/react-checkbox": "^1.4.1",
@@ -37,39 +37,39 @@
"@rjsf/shadcn": "^6.10.1",
"@rjsf/utils": "^6.10.1",
"@rjsf/validator-ajv8": "^6.10.1",
"apexcharts": "^8.6.0",
"axios": "^1.21.0",
"apexcharts": "^8.4.1",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^14.9.0",
"hls.js": "^1.7.4",
"i18next": "^26.5.12",
"i18next-http-backend": "^4.0.7",
"framer-motion": "^14.6.0",
"hls.js": "^1.7.3",
"i18next": "^26.5.5",
"i18next-http-backend": "^4.0.4",
"idb-keyval": "^6.3.0",
"js-yaml": "^5.5.0",
"konva": "^10.9.0",
"lodash": "^4.18.1",
"lucide-react": "^1.65.0",
"lucide-react": "^1.61.0",
"monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
"react": "^19.3.1",
"react-apexcharts": "^2.2.0",
"react-apexcharts": "^2.1.1",
"react-day-picker": "^10.0.2",
"react-device-detect": "^2.2.3",
"react-dom": "^19.3.1",
"react-dropzone": "^20.1.4",
"react-grid-layout": "^2.3.0",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.9",
"react-hook-form": "^7.90.0",
"react-i18next": "^17.1.1",
"react-icons": "^5.7.0",
"react-konva": "^19.3.2",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.5",
"react-router-dom": "^7.18.4",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
@@ -81,7 +81,7 @@
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.3.0",
"vaul": "^1.1.2",
"virtua": "^0.54.0",
"virtua": "^0.53.6",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^4.6.7"
},
@@ -90,13 +90,13 @@
"@playwright/test": "^1.64.1",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.27",
"@types/node": "^26.6.6",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"autoprefixer": "^10.6.2",
"autoprefixer": "^10.6.1",
"esbuild": "^0.28.2",
"eslint": "^10.14.0",
"eslint": "^10.13.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.7",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -105,12 +105,12 @@
"i18next-cli": "^1.5.11",
"monaco-editor": "^0.53.0",
"patch-package": "^8.0.1",
"postcss": "^8.5.32",
"postcss": "^8.5.31",
"prettier": "^3.9.10",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.72.1",
"typescript-eslint": "^8.72.0",
"vite": "^8.3.5"
}
},
@@ -428,9 +428,9 @@
}
},
"node_modules/@cycjimmy/jsmpeg-player": {
"version": "6.1.8",
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.8.tgz",
"integrity": "sha512-ccG7JvidQWWPvmmpu6Ob6TUnbyaz6WaTHl9V/5BD4vBVndxnxchLmVU3rpJguB2lWEoEAhFPBIU3GoeXtc3RaQ==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.2.tgz",
"integrity": "sha512-U9DBDe5fxHmbwQww9rFxMLNI2Wlg7DhPzI7AVFpq8GehiUP7+NwuMPXpP4zAd52sgkxtOqOeMjgE5g0ZLnQZ0w==",
"license": "MIT"
},
"node_modules/@date-fns/tz": {
@@ -1059,9 +1059,9 @@
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-6.0.2.tgz",
"integrity": "sha512-e2dqUOlaGuirKVOd/iZWk/Yg5zeaGw7t0hSc3LzxL3o7eZGwZkTyEbBL8H52m0wRKEsNLWoMGOiXGHbPHTXlEQ==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-6.0.0.tgz",
"integrity": "sha512-6iBzQnB+YwAveVc8HUi6QYLITn5X+xNKRMFIcNXHZlvp2aTI7yWbj4n/JodXxirMCcBc4VCjb2aYAJh4x07mVw==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
@@ -1069,6 +1069,7 @@
"peerDependencies": {
"@sinclair/typebox": ">=0.25.24",
"@standard-schema/spec": "^1.0.0",
"@typeschema/main": ">=0.13.7",
"@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
"ajv": "^8.12.0",
"ajv-errors": "^3.0.0",
@@ -1099,6 +1100,9 @@
"@standard-schema/spec": {
"optional": true
},
"@typeschema/main": {
"optional": true
},
"@vinejs/vine": {
"optional": true
},
@@ -3607,9 +3611,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3657,17 +3661,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz",
"integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
"integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/type-utils": "8.72.1",
"@typescript-eslint/utils": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/type-utils": "8.72.0",
"@typescript-eslint/utils": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -3680,15 +3684,15 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.72.1",
"@typescript-eslint/parser": "^8.72.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz",
"integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==",
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz",
"integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3696,16 +3700,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz",
"integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz",
"integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0",
"debug": "^4.4.3"
},
"engines": {
@@ -3721,14 +3725,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.72.1",
"@typescript-eslint/types": "^8.72.1",
"@typescript-eslint/tsconfig-utils": "^8.72.0",
"@typescript-eslint/types": "^8.72.0",
"debug": "^4.4.3"
},
"engines": {
@@ -3743,14 +3747,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1"
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3761,9 +3765,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3778,15 +3782,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz",
"integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz",
"integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1",
"@typescript-eslint/utils": "8.72.1",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0",
"@typescript-eslint/utils": "8.72.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -3803,9 +3807,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3817,16 +3821,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.72.1",
"@typescript-eslint/tsconfig-utils": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"@typescript-eslint/project-service": "8.72.0",
"@typescript-eslint/tsconfig-utils": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -3845,16 +3849,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1"
"@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3869,13 +3873,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/types": "8.72.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -4063,9 +4067,9 @@
"license": "SEE LICENSE IN LICENSE.md"
},
"node_modules/apexcharts": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.6.0.tgz",
"integrity": "sha512-5524MLoDei2n8EcVMK+u3eOZTWrTpT1BIPRWu4AK8kkJdkODWs1hp3IEax8pMz78sSNAtsD4Tp7tr0s0SW/npQ==",
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.4.1.tgz",
"integrity": "sha512-29IbZ1rn3NRkDvete5EzZXy6yDSW7mjNXkytLpvaYHk0XbfsR+FvC7U78L3wMvJa699NBmix2z6r3Qm5YDvw0A==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"apex-commons": "^0.8.3"
@@ -4122,9 +4126,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.2.tgz",
"integrity": "sha512-VOw9/yGTGwPPgzFcODtcv6zhEPIeIWkRSe10L2ri2f5vZ7ID7lCt6jZzRF5/ubgOZ+szYydmEF5AYsA5jxX1tA==",
"version": "10.6.1",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz",
"integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==",
"dev": true,
"funding": [
{
@@ -4159,9 +4163,9 @@
}
},
"node_modules/axios": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.21.0.tgz",
"integrity": "sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@@ -4968,9 +4972,9 @@
}
},
"node_modules/eslint": {
"version": "10.14.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
"integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
"version": "10.13.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
"integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -4983,7 +4987,7 @@
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.3",
"@humanfs/node": "^0.16.8",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
@@ -5004,7 +5008,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.6",
"minimatch": "^10.2.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -5511,9 +5515,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
@@ -5578,19 +5582,18 @@
}
},
"node_modules/framer-motion": {
"version": "14.9.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.9.0.tgz",
"integrity": "sha512-g1hZu8WoTwgsoVAaqEEcefhvR03cKGtkmrKaRrWpXhnM3LGR8jl1u2qyJ1PMVlO7LKQHE8TTRQYUlMmnrY4L+g==",
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.6.0.tgz",
"integrity": "sha512-MzBgZxGgoHPfSLEXmo4o5XlZvb+nVRFdHtGjVhm6LMIEJKTt/8hNO023euL5mYyzc8llV/hQTfjnN68tNRHalw==",
"license": "MIT",
"dependencies": {
"motion-dom": "14.9.0",
"motion-utils": "14.7.0",
"motion-dom": "14.6.0",
"motion-utils": "14.6.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"remix": "^3.1.0"
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
@@ -5598,9 +5601,6 @@
},
"react-dom": {
"optional": true
},
"remix": {
"optional": true
}
}
},
@@ -5949,9 +5949,9 @@
}
},
"node_modules/hls.js": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.4.tgz",
"integrity": "sha512-3GlxGdPvT2YHvQYOt+yOzT/DEior/qJVNIylHA77kgeoBTdQQqi70GwefNEUmHQeNGyjC8U62+bruqW/Tpm+tg==",
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
"node_modules/hookified": {
@@ -5991,9 +5991,9 @@
}
},
"node_modules/i18next": {
"version": "26.5.12",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.12.tgz",
"integrity": "sha512-vTt8tc3y5axoU7wwTYg8vjpILLyP33a5GyGJAVI7FDRx1XueYxZVIkcVeStoftrVnAM/o28hKvK0sslucDI9aQ==",
"version": "26.5.5",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.5.tgz",
"integrity": "sha512-txdaNOlnZCdiTMSlEzl5hUJU8TC9R4J/RCR1G7XrDPbWT2t3mR142QPz84ZuZeXDt3yq+9zmsdPiakxYtmp3fw==",
"funding": [
{
"type": "individual",
@@ -6106,9 +6106,9 @@
}
},
"node_modules/i18next-http-backend": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.7.tgz",
"integrity": "sha512-SQy3OPy66YzumNJ43boHDBtzzMISnoPhQ0Zqjetk+luW74Tok8Ez9ujJJbJHKSHkG5sjDGldHznjuPtSxBwvuw==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.4.tgz",
"integrity": "sha512-nG9OQKvUsPiTVUBg1rCGmvmWhzxC9GOxVbGdTYyejHVMSUjutt1hiBNVSzhB/ZE7keL9Xkb880kUfhDovV5T5g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -6978,9 +6978,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.65.0.tgz",
"integrity": "sha512-roSdtzudEY39i2hn5LLjA8toYfhF2z4S10WSqZF4ubXpHlOmNTvaxMxZLdJfQDi6pEifQWX+cRmYihKSsvAxfA==",
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
@@ -8055,18 +8055,18 @@
}
},
"node_modules/motion-dom": {
"version": "14.9.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.9.0.tgz",
"integrity": "sha512-wGjKbNDMHd75aLStkxLCKZ+T8/wusZHklmNLK1Ak68UrdBt2X8I4yweYR/0WonxGnBsw6oloDTIV8pN+UTvzzA==",
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.6.0.tgz",
"integrity": "sha512-lIojTl9aYSPIkeOOSzBliy1nNvpdO3JtVzW2WgTXQlOP4ZMuJhtWO3fP7OD4TVFwlRucdvxlDSMkL+fEQnQ52A==",
"license": "MIT",
"dependencies": {
"motion-utils": "14.7.0"
"motion-utils": "14.6.0"
}
},
"node_modules/motion-utils": {
"version": "14.7.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.7.0.tgz",
"integrity": "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw==",
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.6.0.tgz",
"integrity": "sha512-bZJo2AmQ2aOFkaato6VXrTbkPG0XhMzivWUWSaI59pxyllj8QMQUFZ84MYRQnPkK+7dr4KldyJwF2+RM0sdq/w==",
"license": "MIT"
},
"node_modules/ms": {
@@ -8548,9 +8548,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"funding": [
{
"type": "opencollective",
@@ -8920,10 +8920,10 @@
}
},
"node_modules/react-apexcharts": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-2.2.0.tgz",
"integrity": "sha512-MEiH/T65ooUNf8V7Cq7fCu1femP3+lN9Svzsv+jNNS6AcRSciyA1LLvc1VETWqAiqMxQyX8NYEhZxOYnMf262g==",
"license": "SEE LICENSE IN LICENSE.md",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-2.1.1.tgz",
"integrity": "sha512-aV25n+1ZdAzRG0KhODVC48K8WbhMKBAr3yWSeaaBqM1ySn22kSSKblsNCUvlwegsT+cdMLuOrK9xGZb+IK+OkQ==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"prop-types": "^15.8.1"
},
@@ -9037,9 +9037,9 @@
}
},
"node_modules/react-hook-form": {
"version": "7.91.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.91.0.tgz",
"integrity": "sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==",
"version": "7.90.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.90.0.tgz",
"integrity": "sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -9059,9 +9059,9 @@
}
},
"node_modules/react-i18next": {
"version": "17.1.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
"version": "17.1.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.1.tgz",
"integrity": "sha512-Q76xpzxrZNcqhB7M31Ns7isulGOlmhURqRsJOGlYgJJCyoy1pMq6tRl8KQycGNNY1vPaxbIdrogrqY9nkb+SEA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
@@ -9235,9 +9235,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.5.tgz",
"integrity": "sha512-dTgvcXDOrLPrlLHrcZAqWveiK1hMSqN+rGAJzegP8HtmNkfRxOoshGWBNX3ZJ0cZjOHkRdlUJtaIhZMLLyGyPw==",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -9257,12 +9257,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.5",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.5.tgz",
"integrity": "sha512-9HcfDMU6JJ/fp6gJZYGwNE6RknVGS4kBjH1HkNw959mCJi70TVwdipQWv2FEol9uGpi+SZTFtVpJPtEQfayeOw==",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.5"
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
@@ -9586,9 +9586,9 @@
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==",
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
@@ -10062,16 +10062,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz",
"integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.0.tgz",
"integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.72.1",
"@typescript-eslint/parser": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1",
"@typescript-eslint/utils": "8.72.1"
"@typescript-eslint/eslint-plugin": "8.72.0",
"@typescript-eslint/parser": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0",
"@typescript-eslint/utils": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -10364,9 +10364,9 @@
}
},
"node_modules/virtua": {
"version": "0.54.0",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.54.0.tgz",
"integrity": "sha512-8nGGrDhX61MDVeqgyIaWrJqhRm9z2Nqa1WDqt0tk1m+ZWrvLesMP0iL0vL6xNsLCF2jE2ohwY+PqufdbVmRgEw==",
"version": "0.53.6",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.53.6.tgz",
"integrity": "sha512-/vQtSFNDFcp3Yz4klbpe8Z88eQeThSw49cFlgJQdsqh3SUnRlW4jLi6ZYt4YzdwerYGlP6KCgAOXNVO+PmUg7w==",
"license": "MIT",
"peerDependencies": {
"@angular/common": ">=20.0.0",
+19 -19
View File
@@ -21,8 +21,8 @@
"i18n:status": "i18next-cli status"
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.8",
"@hookform/resolvers": "^6.0.2",
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^6.0.0",
"@radix-ui/react-alert-dialog": "^1.2.1",
"@radix-ui/react-aspect-ratio": "^1.2.0",
"@radix-ui/react-checkbox": "^1.4.1",
@@ -49,39 +49,39 @@
"@rjsf/shadcn": "^6.10.1",
"@rjsf/utils": "^6.10.1",
"@rjsf/validator-ajv8": "^6.10.1",
"apexcharts": "^8.6.0",
"axios": "^1.21.0",
"apexcharts": "^8.4.1",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^14.9.0",
"hls.js": "^1.7.4",
"i18next": "^26.5.12",
"i18next-http-backend": "^4.0.7",
"framer-motion": "^14.6.0",
"hls.js": "^1.7.3",
"i18next": "^26.5.5",
"i18next-http-backend": "^4.0.4",
"idb-keyval": "^6.3.0",
"js-yaml": "^5.5.0",
"konva": "^10.9.0",
"lodash": "^4.18.1",
"lucide-react": "^1.65.0",
"lucide-react": "^1.61.0",
"monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
"react": "^19.3.1",
"react-apexcharts": "^2.2.0",
"react-apexcharts": "^2.1.1",
"react-day-picker": "^10.0.2",
"react-device-detect": "^2.2.3",
"react-dom": "^19.3.1",
"react-dropzone": "^20.1.4",
"react-grid-layout": "^2.3.0",
"react-hook-form": "^7.91.0",
"react-i18next": "^17.1.9",
"react-hook-form": "^7.90.0",
"react-i18next": "^17.1.1",
"react-icons": "^5.7.0",
"react-konva": "^19.3.2",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.5",
"react-router-dom": "^7.18.4",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
@@ -93,7 +93,7 @@
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.3.0",
"vaul": "^1.1.2",
"virtua": "^0.54.0",
"virtua": "^0.53.6",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^4.6.7"
},
@@ -102,13 +102,13 @@
"@playwright/test": "^1.64.1",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.27",
"@types/node": "^26.6.6",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"autoprefixer": "^10.6.2",
"autoprefixer": "^10.6.1",
"esbuild": "^0.28.2",
"eslint": "^10.14.0",
"eslint": "^10.13.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.7",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -117,12 +117,12 @@
"i18next-cli": "^1.5.11",
"monaco-editor": "^0.53.0",
"patch-package": "^8.0.1",
"postcss": "^8.5.32",
"postcss": "^8.5.31",
"prettier": "^3.9.10",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.72.1",
"typescript-eslint": "^8.72.0",
"vite": "^8.3.5"
},
"overrides": {
@@ -1086,6 +1086,13 @@
"title": "Confirm Password",
"placeholder": "Confirm Password"
},
"strength": {
"title": "Password strength: ",
"weak": "Weak",
"medium": "Medium",
"strong": "Strong",
"veryStrong": "Very Strong"
},
"requirements": {
"title": "Password requirements:",
"length": "At least 12 characters"
@@ -415,19 +415,10 @@ export default function NotificationsSettingsExtras({
pushSubscription.unsubscribe();
workerRegistration.unregister();
setRegistration(null);
removeMessage("notification_settings", "registration");
});
})
.catch(() => {
toast.error(t("notification.toast.error.registerFailed"), {
position: "top-center",
});
workerRegistration.unregister();
setRegistration(null);
removeMessage("notification_settings", "registration");
});
},
[addMessage, removeMessage, publicKey, t],
[addMessage, publicKey, t],
);
useEffect(() => {
@@ -307,9 +307,6 @@ export function KnownPlatesField(props: FieldProps) {
);
const handleAddEntry = useCallback(() => {
if ("" in data) {
return;
}
const next = { ...data, "": [""] };
onChange(next, fieldPath);
}, [data, fieldPath, onChange]);
@@ -37,7 +37,12 @@ import { useTranslation } from "react-i18next";
import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { getPasswordRequirements } from "@/utils/passwordUtil";
import {
calculatePasswordStrength,
getPasswordRequirements,
getPasswordStrengthLabel,
getPasswordStrengthColor,
} from "@/utils/passwordUtil";
import {
MobilePage,
MobilePageContent,
@@ -118,6 +123,12 @@ export default function CreateUserDialog({
const passwordsMatch = password === confirmPassword;
const showMatchIndicator = password && confirmPassword;
// Password strength calculation
const passwordStrength = useMemo(
() => calculatePasswordStrength(password),
[password],
);
const requirements = useMemo(
() => getPasswordRequirements(password),
[password],
@@ -213,12 +224,6 @@ export default function CreateUserDialog({
type={showPasswordVisible ? "text" : "password"}
className="h-10 pr-10"
{...field}
onChange={(e) => {
field.onChange(e);
if (form.getValues("confirmPassword")) {
form.trigger("confirmPassword");
}
}}
/>
<Button
type="button"
@@ -250,6 +255,21 @@ export default function CreateUserDialog({
{password && (
<div className="mt-2 space-y-2">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
<div
className={`${getPasswordStrengthColor(
password,
)} transition-all duration-300`}
style={{ width: `${passwordStrength * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("users.dialog.form.password.strength.title")}
<span className="font-medium">
{getPasswordStrengthLabel(password, t)}
</span>
</p>
<div className="space-y-1 rounded-md bg-muted/50 p-2">
<p className="text-xs font-medium text-muted-foreground">
{t("users.dialog.form.password.requirements.title")}
@@ -277,6 +297,8 @@ export default function CreateUserDialog({
</div>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
@@ -344,6 +366,7 @@ export default function CreateUserDialog({
)}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
+30 -2
View File
@@ -690,8 +690,36 @@ export function ExportContent({
(option: ExportOption) => {
setSelectedOption(option);
const hours = Number(option);
const start = latestTime - (Number.isNaN(hours) ? 1 : hours) * 3600;
const now = new Date(latestTime * 1000);
let start: number;
switch (option) {
case "1":
now.setHours(now.getHours() - 1);
start = now.getTime() / 1000;
break;
case "4":
now.setHours(now.getHours() - 4);
start = now.getTime() / 1000;
break;
case "8":
now.setHours(now.getHours() - 8);
start = now.getTime() / 1000;
break;
case "12":
now.setHours(now.getHours() - 12);
start = now.getTime() / 1000;
break;
case "24":
now.setHours(now.getHours() - 24);
start = now.getTime() / 1000;
break;
case "custom":
start = latestTime - 3600;
break;
default:
start = latestTime - 3600;
}
setRange({
before: latestTime,
@@ -28,7 +28,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { getPasswordRequirements } from "@/utils/passwordUtil";
import {
calculatePasswordStrength,
getPasswordRequirements,
getPasswordStrengthLabel,
getPasswordStrengthColor,
} from "@/utils/passwordUtil";
type SetPasswordProps = {
show: boolean;
@@ -119,6 +124,12 @@ export default function SetPasswordDialog({
const password = form.watch("password");
const confirmPassword = form.watch("confirmPassword");
// Password strength calculation
const passwordStrength = useMemo(
() => calculatePasswordStrength(password),
[password],
);
const requirements = useMemo(
() => getPasswordRequirements(password),
[password],
@@ -292,12 +303,6 @@ export default function SetPasswordDialog({
)}
className="h-10 pr-10"
autoFocus
onChange={(e) => {
field.onChange(e);
if (form.getValues("confirmPassword")) {
form.trigger("confirmPassword");
}
}}
/>
<Button
type="button"
@@ -329,6 +334,21 @@ export default function SetPasswordDialog({
{password && (
<div className="mt-2 space-y-2">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
<div
className={`${getPasswordStrengthColor(
password,
)} transition-all duration-300`}
style={{ width: `${passwordStrength * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("users.dialog.form.password.strength.title")}
<span className="font-medium">
{getPasswordStrengthLabel(password, t)}
</span>
</p>
<div className="space-y-1 rounded-md bg-muted/50 p-2">
<p className="text-xs font-medium text-muted-foreground">
{t("users.dialog.form.password.requirements.title")}
@@ -356,6 +376,8 @@ export default function SetPasswordDialog({
</div>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
@@ -406,25 +428,18 @@ export default function SetPasswordDialog({
</div>
</FormControl>
{password && confirmPassword && (
<div className="mt-1 flex items-center gap-1.5 text-xs">
{password === confirmPassword ? (
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
{t("users.dialog.form.password.match")}
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">
{t("users.dialog.form.password.notMatch")}
</span>
</>
)}
</div>
)}
{password &&
confirmPassword &&
password === confirmPassword && (
<div className="mt-1 flex items-center gap-1.5 text-xs">
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
{t("users.dialog.form.password.match")}
</span>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
+1 -12
View File
@@ -147,18 +147,7 @@ export default function DetailStream({
// away instead of waiting for a future effectiveTime change.
useEffect(() => {
if (!reviewItems || reviewItems.length === 0) return;
// an id left over from another camera's items counts as unset
if (
activeReviewId &&
reviewItems.some(
(r) =>
`review-${r.id ?? r.start_time ?? Math.floor(r.start_time ?? 0)}` ===
activeReviewId,
)
) {
return;
}
if (activeReviewId) return;
let target: ReviewSegment | undefined;
let closest: { r: ReviewSegment; diff: number } | undefined;
+38
View File
@@ -1,3 +1,41 @@
import { TFunction } from "i18next";
export const calculatePasswordStrength = (password: string): number => {
if (!password) return 0;
let strength = 0;
if (password.length >= 8) strength += 1;
if (/\d/.test(password)) strength += 1;
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
if (/[A-Z]/.test(password)) strength += 1;
return strength;
};
export const getPasswordRequirements = (password: string) => ({
length: password?.length >= 12,
});
export const getPasswordStrengthLabel = (
password: string,
t: TFunction,
): string => {
const strength = calculatePasswordStrength(password);
if (!password) return "";
if (strength < 1)
return t("users.dialog.form.password.strength.weak", {
ns: "views/settings",
});
return t("users.dialog.form.password.strength.veryStrong", {
ns: "views/settings",
});
};
export const getPasswordStrengthColor = (password: string): string => {
const strength = calculatePasswordStrength(password);
if (!password) return "bg-gray-200";
if (strength === 0) return "bg-red-500";
return "bg-green-500";
};
+2 -1
View File
@@ -45,7 +45,8 @@ export function createRecordingReviewUrl(
pathname: string,
state: RecordingReviewLinkState,
): string {
const url = new URL(pathname.replace(/^\//, ""), baseUrl);
const url = new URL(baseUrl);
url.pathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
url.searchParams.set(
RECORDING_REVIEW_LINK_PARAM,
`${state.camera}_${Math.floor(state.timestamp)}`,
+15 -9
View File
@@ -575,19 +575,25 @@ export default function DraggableGridLayout({
};
useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {};
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
},
);
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
if (groupSettings) {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
});
setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates);
}, [currentGroupStreamingSettings, cameraGroup]);
}, [allGroupsStreamingSettings]);
const toggleAudio = (cameraName: string) => {
setAudioStates((prev) => ({
@@ -794,7 +800,7 @@ export default function DraggableGridLayout({
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name] ?? 1}
volumeState={volumeStates[camera.name]}
setVolumeState={(value) =>
setVolumeStates((prev) => ({
...prev,
@@ -839,7 +845,7 @@ export default function DraggableGridLayout({
onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name] ?? 1}
volume={volumeStates[camera.name]}
onLiveAspectChange={liveAspectHandlers[camera.name]}
/>
</GridLiveContextMenu>
+8 -10
View File
@@ -511,14 +511,7 @@ export default function LiveCameraView({
}
}, [webRTCVerdictPending]);
const toggleMic = () => {
setMic(!mic);
if (!mic && !audio) {
setAudio(true);
}
};
useKeyboardListener(["m", "t", "Escape"], (key, modifiers) => {
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
if (!modifiers.down) {
return true;
}
@@ -532,7 +525,7 @@ export default function LiveCameraView({
break;
case "t":
if (supports2WayTalk && talkAvailable) {
toggleMic();
setMic(!mic);
return true;
}
break;
@@ -799,7 +792,12 @@ export default function LiveCameraView({
? t("twoWayTalk.disable", { ns: "views/live" })
: t("twoWayTalk.enable", { ns: "views/live" })
}
onClick={toggleMic}
onClick={() => {
setMic(!mic);
if (!mic && !audio) {
setAudio(true);
}
}}
disabled={!cameraEnabled || debug || !talkAvailable}
/>
)}
+14 -8
View File
@@ -326,19 +326,25 @@ export default function LiveDashboardView({
};
useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {};
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
},
);
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
if (groupSettings) {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
});
setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates);
}, [currentGroupStreamingSettings, cameraGroup]);
}, [allGroupsStreamingSettings]);
const toggleAudio = (cameraName: string): void => {
setAudioStates((prev) => ({
@@ -629,7 +635,7 @@ export default function LiveDashboardView({
resetPreferredLiveMode(camera.name)
}
playAudio={audioStates[camera.name] ?? false}
volume={volumeStates[camera.name] ?? 1}
volume={volumeStates[camera.name]}
/>
</LiveContextMenu>
);
@@ -86,21 +86,17 @@ export default function MediaSyncSettingsView() {
position: "top-center",
closeButton: true,
});
}
} catch (error) {
const alreadyRunning =
axios.isAxiosError(error) && error.response?.status === 409;
toast.error(
t(
alreadyRunning
? "maintenance.sync.alreadyRunning"
: "maintenance.sync.error",
),
{
} else if (response.status === 409) {
toast.error(t("maintenance.sync.alreadyRunning"), {
position: "top-center",
closeButton: true,
},
);
});
}
} catch {
toast.error(t("maintenance.sync.error"), {
position: "top-center",
closeButton: true,
});
} finally {
setIsSubmitting(false);
}
+5 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import useSWR from "swr";
@@ -133,9 +133,6 @@ export default function TriggerView({
// watch websocket for updates
const { payload: triggers_status_ws } = useTriggers();
const highlightTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>(
{},
);
useEffect(() => {
if (!triggers_status_ws) return;
@@ -151,13 +148,13 @@ export default function TriggerView({
return current;
});
const name = triggers_status_ws.name;
clearTimeout(highlightTimers.current[name]);
highlightTimers.current[name] = setTimeout(() => {
const timeout = setTimeout(() => {
setTriggeredTrigger((prev) =>
(prev || []).filter((triggered) => triggered !== name),
(prev || []).filter((name) => name !== triggers_status_ws.name),
);
}, 3000);
return () => clearTimeout(timeout);
}, [triggers_status_ws, selectedCamera, mutate]);
useEffect(() => {