mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-29 19:36:57 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cddd17f36e | ||
|
|
4838ee1322 |
@@ -15,6 +15,7 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||||
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
||||||
|
from frigate.util.live_streams import raw_transcode_streams
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
is_go2rtc_arbitrary_exec_allowed,
|
is_go2rtc_arbitrary_exec_allowed,
|
||||||
is_restricted_go2rtc_source,
|
is_restricted_go2rtc_source,
|
||||||
@@ -174,6 +175,17 @@ for name in list(go2rtc_config.get("streams", {})):
|
|||||||
del go2rtc_config["streams"][name]
|
del go2rtc_config["streams"][name]
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# add transcoded live streams; a user stream with the same name wins here and
|
||||||
|
# fails Frigate's config validation
|
||||||
|
transcoded_streams = raw_transcode_streams(config)
|
||||||
|
|
||||||
|
if transcoded_streams:
|
||||||
|
if go2rtc_config.get("streams") is None:
|
||||||
|
go2rtc_config["streams"] = {}
|
||||||
|
|
||||||
|
for name, source in transcoded_streams.items():
|
||||||
|
go2rtc_config["streams"].setdefault(name, source)
|
||||||
|
|
||||||
# add birdseye restream stream if enabled
|
# add birdseye restream stream if enabled
|
||||||
if config.get("birdseye", {}).get("restream", False):
|
if config.get("birdseye", {}).get("restream", False):
|
||||||
birdseye: dict[str, Any] = config.get("birdseye")
|
birdseye: dict[str, Any] = config.get("birdseye")
|
||||||
|
|||||||
@@ -900,6 +900,21 @@ live:
|
|||||||
streams:
|
streams:
|
||||||
main_stream: main_stream_name
|
main_stream: main_stream_name
|
||||||
sub_stream: sub_stream_name
|
sub_stream: sub_stream_name
|
||||||
|
# Optional: Lower-quality live streams transcoded by go2rtc while someone is watching.
|
||||||
|
# NOTE: Set at the camera level only.
|
||||||
|
transcode:
|
||||||
|
# Optional: Enable transcoded streams (default: shown below)
|
||||||
|
enabled: False
|
||||||
|
# Optional: go2rtc stream to transcode (default: the first live stream)
|
||||||
|
source: main_stream_name
|
||||||
|
# Optional: One transcoded stream per quality (default: shown below)
|
||||||
|
qualities:
|
||||||
|
- height: 720
|
||||||
|
bitrate: 1200
|
||||||
|
- height: 480
|
||||||
|
bitrate: 500
|
||||||
|
- height: 360
|
||||||
|
bitrate: 250
|
||||||
# Optional: Set the height of the jsmpeg stream. (default: 720)
|
# Optional: Set the height of the jsmpeg stream. (default: 720)
|
||||||
# This must be less than or equal to the height of the detect stream. Lower resolutions
|
# This must be less than or equal to the height of the detect stream. Lower resolutions
|
||||||
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
|
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
|
||||||
|
|||||||
@@ -85,14 +85,6 @@ An optional config, `save_attempts`, can be set as a key under the model name. T
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</ConfigTabs>
|
</ConfigTabs>
|
||||||
|
|
||||||
## Review items
|
|
||||||
|
|
||||||
When a model's state changes while its camera has an active review item, the change is recorded on that review item. This includes changes in the few seconds before the item starts, such as a garage door opening just before the car is detected. State changes never create or extend review items on their own, and the first state reported after Frigate starts is not recorded as a change.
|
|
||||||
|
|
||||||
Recorded changes appear in the review item's data as `classification_state_changes` (see the [`frigate/reviews`](/integrations/mqtt#frigatereviews) MQTT topic) and are passed to [GenAI review summaries](/configuration/genai/genai_review) as facts, so a description can note that a gate was opened during the activity.
|
|
||||||
|
|
||||||
Change times are most accurate with `motion: true`. A model that only runs on an `interval` notices a change at its next run, so the change may be recorded late or attached to a later review item.
|
|
||||||
|
|
||||||
## Training the model
|
## Training the model
|
||||||
|
|
||||||
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
|
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
|
||||||
|
|||||||
@@ -201,8 +201,6 @@ Review items are sent to the model as a sequence of still frames. Some models fo
|
|||||||
|
|
||||||
The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`.
|
The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`.
|
||||||
|
|
||||||
Changes reported by [state classification](/configuration/custom_classification/state_classification#review-items) models during the review item are listed in the prompt in both modes. `annotated_frames` also notes each change before the frame where it happened.
|
|
||||||
|
|
||||||
Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item.
|
Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ go2rtc:
|
|||||||
|
|
||||||
### Setting Streams For Live UI
|
### Setting Streams For Live UI
|
||||||
|
|
||||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage.
|
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. When a camera has more than one stream, the dropdown also offers **Auto**, which is used until you pick a specific stream. Auto starts on the first stream, steps down the list when your connection can't keep up, and steps back up when it recovers. To retry the top stream right away, select **Try highest quality** under the stream picker. List streams from highest to lowest quality, and avoid names that are plain numbers (such as `720`), which the browser sorts ahead of the others. In the UI, drag streams to reorder them, or use **Auto order** to sort them by measured bitrate.
|
||||||
|
|
||||||
Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard.
|
Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard.
|
||||||
|
|
||||||
@@ -158,6 +158,26 @@ cameras:
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</ConfigTabs>
|
</ConfigTabs>
|
||||||
|
|
||||||
|
### Transcoded streams
|
||||||
|
|
||||||
|
When a camera has no suitable sub stream, Frigate can add lower-quality streams that go2rtc transcodes to H.264 while someone is watching. They appear in the stream list like any other stream, so Auto mode can step down to them. Enable them under <NavPath path="Settings > Camera configuration > Live playback" />, or in YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
test_cam:
|
||||||
|
live:
|
||||||
|
transcode:
|
||||||
|
enabled: true
|
||||||
|
source: test_cam # optional, defaults to the first live stream
|
||||||
|
qualities:
|
||||||
|
- height: 720
|
||||||
|
bitrate: 1200 # kbps
|
||||||
|
- height: 480
|
||||||
|
bitrate: 500
|
||||||
|
```
|
||||||
|
|
||||||
|
Each quality becomes a go2rtc stream named `<camera>_transcode_<height>p`. go2rtc picks a hardware encoder automatically and falls back to the CPU, which costs CPU for each transcode while it is being watched. Check go2rtc's `api/ffmpeg/hardware` page to see which encoder it found. Using a sub stream as the `source` lowers the cost.
|
||||||
|
|
||||||
### WebRTC extra configuration:
|
### WebRTC extra configuration:
|
||||||
|
|
||||||
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
|
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
|
||||||
@@ -363,6 +383,13 @@ When your browser runs into problems playing back your camera streams, it will l
|
|||||||
- `Safari reported InvalidStateError.`
|
- `Safari reported InvalidStateError.`
|
||||||
- `Safari reported decoding errors.`
|
- `Safari reported decoding errors.`
|
||||||
|
|
||||||
|
- **mse-codec**
|
||||||
|
- What it means: go2rtc has no codec for this stream that the browser can play.
|
||||||
|
- What to try: Pick a stream with a codec the browser supports (H.264 is the most compatible), or use a browser that supports the stream's codec. In Auto, Frigate skips this stream for the rest of the session.
|
||||||
|
|
||||||
|
- Possible console messages from the player code:
|
||||||
|
- `mse: streams: codecs not matched: ...`
|
||||||
|
|
||||||
- **stalled**
|
- **stalled**
|
||||||
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
|
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
|
||||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ An `update` with the same ID will be published when:
|
|||||||
- The severity changes from `detection` to `alert`
|
- The severity changes from `detection` to `alert`
|
||||||
- Additional objects are detected
|
- Additional objects are detected
|
||||||
- An object is recognized via face, lpr, etc.
|
- An object is recognized via face, lpr, etc.
|
||||||
- A [state classification](/configuration/custom_classification/state_classification#review-items) model changes state
|
|
||||||
|
|
||||||
When the review activity has ended a final `end` message is published.
|
When the review activity has ended a final `end` message is published.
|
||||||
|
|
||||||
@@ -236,8 +235,7 @@ When the review activity has ended a final `end` message is published.
|
|||||||
"objects": ["person", "car"],
|
"objects": ["person", "car"],
|
||||||
"sub_labels": [],
|
"sub_labels": [],
|
||||||
"zones": [],
|
"zones": [],
|
||||||
"audio": [],
|
"audio": []
|
||||||
"classification_state_changes": []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"after": {
|
"after": {
|
||||||
@@ -256,16 +254,7 @@ When the review activity has ended a final `end` message is published.
|
|||||||
"objects": ["person", "car"],
|
"objects": ["person", "car"],
|
||||||
"sub_labels": ["Bob"],
|
"sub_labels": ["Bob"],
|
||||||
"zones": ["front_yard"],
|
"zones": ["front_yard"],
|
||||||
"audio": [],
|
"audio": []
|
||||||
"classification_state_changes": [
|
|
||||||
// verified changes of state classification models on this camera
|
|
||||||
{
|
|
||||||
"model": "front_gate",
|
|
||||||
"from": "closed",
|
|
||||||
"to": "open",
|
|
||||||
"timestamp": 1718987131.52
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+38
@@ -412,6 +412,39 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- frigateAdminAuth: []
|
- frigateAdminAuth: []
|
||||||
x-required-role: admin
|
x-required-role: admin
|
||||||
|
/go2rtc/streams/{stream_name}/bitrate:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Camera
|
||||||
|
summary: Go2Rtc Stream Bitrate
|
||||||
|
description: |-
|
||||||
|
**Access:** Admin role required.
|
||||||
|
|
||||||
|
Measure a go2rtc stream's bitrate over a few seconds.
|
||||||
|
operationId:
|
||||||
|
go2rtc_stream_bitrate_go2rtc_streams__stream_name__bitrate_get
|
||||||
|
parameters:
|
||||||
|
- name: stream_name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
title: Stream Name
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
security:
|
||||||
|
- frigateAdminAuth: []
|
||||||
|
x-required-role: admin
|
||||||
/ffprobe:
|
/ffprobe:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -7731,6 +7764,11 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
title: Skip Save
|
title: Skip Save
|
||||||
default: false
|
default: false
|
||||||
|
replace_paths:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
title: Replace Paths
|
||||||
type: object
|
type: object
|
||||||
title: AppConfigSetBody
|
title: AppConfigSetBody
|
||||||
AppPostLoginBody:
|
AppPostLoginBody:
|
||||||
|
|||||||
+36
-5
@@ -63,6 +63,7 @@ from frigate.util.builtin import (
|
|||||||
flatten_config_data,
|
flatten_config_data,
|
||||||
load_labels,
|
load_labels,
|
||||||
process_config_query_string,
|
process_config_query_string,
|
||||||
|
split_config_key_path,
|
||||||
update_yaml_file_bulk,
|
update_yaml_file_bulk,
|
||||||
)
|
)
|
||||||
from frigate.util.config import (
|
from frigate.util.config import (
|
||||||
@@ -70,6 +71,10 @@ from frigate.util.config import (
|
|||||||
find_config_file,
|
find_config_file,
|
||||||
redact_credential,
|
redact_credential,
|
||||||
)
|
)
|
||||||
|
from frigate.util.live_streams import (
|
||||||
|
generated_transcode_streams,
|
||||||
|
sync_transcode_streams,
|
||||||
|
)
|
||||||
from frigate.util.object_names import get_categorized_object_names
|
from frigate.util.object_names import get_categorized_object_names
|
||||||
from frigate.util.schema import get_config_schema
|
from frigate.util.schema import get_config_schema
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
@@ -393,11 +398,6 @@ def config(request: Request):
|
|||||||
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
||||||
model_dict["labelmap"] = model.merged_labelmap
|
model_dict["labelmap"] = model.merged_labelmap
|
||||||
|
|
||||||
# report the configured reference rather than the resolved cache path,
|
|
||||||
# so saving the config back doesn't lose the Frigate+ model
|
|
||||||
if model.plus_id:
|
|
||||||
model_dict["path"] = f"plus://{model.plus_id}"
|
|
||||||
|
|
||||||
if not config["plus"]["enabled"]:
|
if not config["plus"]["enabled"]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -815,6 +815,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path_exists(data: Any, key_path: str) -> bool:
|
||||||
|
"""Return whether a dotted config path is present in parsed yaml."""
|
||||||
|
for key in split_config_key_path(key_path):
|
||||||
|
if not isinstance(data, dict) or key not in data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = data[key]
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def config_set(request: Request, body: AppConfigSetBody):
|
def config_set(request: Request, body: AppConfigSetBody):
|
||||||
config_file = find_config_file()
|
config_file = find_config_file()
|
||||||
@@ -869,6 +880,19 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# delete replaced paths first so their maps are rewritten in
|
||||||
|
# the order sent; update_yaml would otherwise keep old order
|
||||||
|
if body.replace_paths:
|
||||||
|
old_yaml = ruamel.yaml.YAML(typ="safe").load(old_raw_config) or {}
|
||||||
|
updates = {
|
||||||
|
**{
|
||||||
|
path: ""
|
||||||
|
for path in body.replace_paths
|
||||||
|
if _config_path_exists(old_yaml, path)
|
||||||
|
},
|
||||||
|
**updates,
|
||||||
|
}
|
||||||
|
|
||||||
# apply all updates in a single operation
|
# apply all updates in a single operation
|
||||||
update_yaml_file_bulk(config_file, updates)
|
update_yaml_file_bulk(config_file, updates)
|
||||||
|
|
||||||
@@ -932,9 +956,15 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
if request.app.dispatcher is not None:
|
if request.app.dispatcher is not None:
|
||||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||||
|
|
||||||
|
go2rtc_synced = True
|
||||||
|
|
||||||
if body.requires_restart == 0 or body.update_topic:
|
if body.requires_restart == 0 or body.update_topic:
|
||||||
old_config: FrigateConfig = request.app.frigate_config
|
old_config: FrigateConfig = request.app.frigate_config
|
||||||
swap_runtime_config(request.app, config)
|
swap_runtime_config(request.app, config)
|
||||||
|
go2rtc_synced = sync_transcode_streams(
|
||||||
|
generated_transcode_streams(old_config),
|
||||||
|
generated_transcode_streams(config),
|
||||||
|
)
|
||||||
|
|
||||||
if body.update_topic:
|
if body.update_topic:
|
||||||
if body.update_topic.startswith("config/cameras/"):
|
if body.update_topic.startswith("config/cameras/"):
|
||||||
@@ -994,6 +1024,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
if body.requires_restart == 0
|
if body.requires_restart == 0
|
||||||
else "Config successfully updated, restart to apply"
|
else "Config successfully updated, restart to apply"
|
||||||
),
|
),
|
||||||
|
"go2rtc_synced": go2rtc_synced,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
|||||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
from frigate.util.image import run_ffmpeg_snapshot
|
from frigate.util.image import run_ffmpeg_snapshot
|
||||||
|
from frigate.util.live_streams import (
|
||||||
|
generated_transcode_streams,
|
||||||
|
measure_stream_bitrate,
|
||||||
|
sync_transcode_streams,
|
||||||
|
)
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
analyze_record_keyframes,
|
analyze_record_keyframes,
|
||||||
ffprobe_stream,
|
ffprobe_stream,
|
||||||
@@ -248,6 +253,34 @@ def go2rtc_delete_stream(stream_name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/go2rtc/streams/{stream_name}/bitrate",
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
|
async def go2rtc_stream_bitrate(request: Request, stream_name: str):
|
||||||
|
"""Measure a go2rtc stream's bitrate over a few seconds."""
|
||||||
|
config: FrigateConfig = request.app.frigate_config
|
||||||
|
known = set(config.go2rtc.model_dump().get("streams") or {}) | set(
|
||||||
|
generated_transcode_streams(config)
|
||||||
|
)
|
||||||
|
|
||||||
|
if stream_name not in known:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Unknown stream"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
kbps = await asyncio.to_thread(measure_stream_bitrate, stream_name)
|
||||||
|
|
||||||
|
if kbps is None:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Stream sent no data"},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(content={"success": True, "kbps": round(kbps)})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
|
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||||
path_param = paths
|
path_param = paths
|
||||||
@@ -1342,6 +1375,12 @@ async def delete_camera(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
sync_transcode_streams,
|
||||||
|
generated_transcode_streams(frigate_config),
|
||||||
|
generated_transcode_streams(request.app.frigate_config),
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
|||||||
|
|
||||||
if app.stats_emitter is not None:
|
if app.stats_emitter is not None:
|
||||||
app.stats_emitter.config = config
|
app.stats_emitter.config = config
|
||||||
app.stats_emitter.hardware_stats.set_config(config)
|
|
||||||
|
|
||||||
if app.dispatcher is not None:
|
if app.dispatcher is not None:
|
||||||
app.dispatcher.config = config
|
app.dispatcher.config = config
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class AppConfigSetBody(BaseModel):
|
|||||||
update_topic: str | None = None
|
update_topic: str | None = None
|
||||||
config_data: dict[str, Any] | None = None
|
config_data: dict[str, Any] | None = None
|
||||||
skip_save: bool = False
|
skip_save: bool = False
|
||||||
|
# paths rewritten whole, so a map saves in the order sent
|
||||||
|
replace_paths: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class GenAIProbeBody(BaseModel):
|
class GenAIProbeBody(BaseModel):
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ from frigate.util.recording_coverage import (
|
|||||||
null_audio_glitches,
|
null_audio_glitches,
|
||||||
plan_clip,
|
plan_clip,
|
||||||
resolve_coverage,
|
resolve_coverage,
|
||||||
|
stream_has_audio,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -680,10 +681,15 @@ async def _vod_response(
|
|||||||
end_ts,
|
end_ts,
|
||||||
force_discontinuity,
|
force_discontinuity,
|
||||||
)
|
)
|
||||||
|
intervals = resolve_coverage(camera_name, start_ts, end_ts)
|
||||||
|
|
||||||
# rows contradicting their stream's audio composition are
|
# rows contradicting their stream's audio composition are
|
||||||
# truncated-shutdown glitches
|
# truncated-shutdown glitches
|
||||||
|
main_audio = stream_has_audio(intervals, main=True)
|
||||||
|
sub_audio = stream_has_audio(intervals, main=False)
|
||||||
|
|
||||||
spans = build_spans(
|
spans = build_spans(
|
||||||
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
|
null_audio_glitches(intervals, main_audio, sub_audio),
|
||||||
stream_preference,
|
stream_preference,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ class DetectionTypeEnum(str, Enum):
|
|||||||
video = "video"
|
video = "video"
|
||||||
audio = "audio"
|
audio = "audio"
|
||||||
lpr = "lpr"
|
lpr = "lpr"
|
||||||
classification_state = "classification_state"
|
|
||||||
|
|
||||||
|
|
||||||
class DetectionPublisher(Publisher):
|
class DetectionPublisher(Publisher):
|
||||||
|
|||||||
@@ -1,8 +1,57 @@
|
|||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
from frigate.util.live_streams import DEFAULT_TRANSCODE_QUALITIES
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
|
|
||||||
__all__ = ["CameraLiveConfig"]
|
__all__ = ["CameraLiveConfig", "LiveTranscodeConfig", "LiveTranscodeQualityConfig"]
|
||||||
|
|
||||||
|
|
||||||
|
class LiveTranscodeQualityConfig(FrigateBaseModel):
|
||||||
|
height: int = Field(
|
||||||
|
ge=144,
|
||||||
|
le=2160,
|
||||||
|
title="Height",
|
||||||
|
description="Output height in pixels; width follows the source aspect ratio.",
|
||||||
|
)
|
||||||
|
bitrate: int = Field(
|
||||||
|
ge=64,
|
||||||
|
title="Bitrate",
|
||||||
|
description="Target and maximum video bitrate in kbps.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LiveTranscodeConfig(FrigateBaseModel):
|
||||||
|
enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
title="Enable transcoded streams",
|
||||||
|
description="Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.",
|
||||||
|
)
|
||||||
|
source: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="Source stream",
|
||||||
|
description="go2rtc stream to transcode. Defaults to the first live stream.",
|
||||||
|
)
|
||||||
|
qualities: list[LiveTranscodeQualityConfig] = Field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
LiveTranscodeQualityConfig(**quality)
|
||||||
|
for quality in DEFAULT_TRANSCODE_QUALITIES
|
||||||
|
],
|
||||||
|
title="Qualities",
|
||||||
|
description="One transcoded stream is added per quality.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("qualities")
|
||||||
|
@classmethod
|
||||||
|
def validate_unique_heights(
|
||||||
|
cls, qualities: list[LiveTranscodeQualityConfig]
|
||||||
|
) -> list[LiveTranscodeQualityConfig]:
|
||||||
|
heights = [quality.height for quality in qualities]
|
||||||
|
|
||||||
|
if len(heights) != len(set(heights)):
|
||||||
|
raise ValueError("Transcoded stream heights must be unique.")
|
||||||
|
|
||||||
|
return qualities
|
||||||
|
|
||||||
|
|
||||||
class CameraLiveConfig(FrigateBaseModel):
|
class CameraLiveConfig(FrigateBaseModel):
|
||||||
@@ -11,6 +60,11 @@ class CameraLiveConfig(FrigateBaseModel):
|
|||||||
title="Live stream names",
|
title="Live stream names",
|
||||||
description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
|
description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
|
||||||
)
|
)
|
||||||
|
transcode: LiveTranscodeConfig = Field(
|
||||||
|
default_factory=LiveTranscodeConfig,
|
||||||
|
title="Transcoded streams",
|
||||||
|
description="Lower-quality live streams transcoded on demand by go2rtc.",
|
||||||
|
)
|
||||||
height: int = Field(
|
height: int = Field(
|
||||||
default=720,
|
default=720,
|
||||||
title="Live height",
|
title="Live height",
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ from frigate.util.config import (
|
|||||||
migrate_frigate_config,
|
migrate_frigate_config,
|
||||||
)
|
)
|
||||||
from frigate.util.image import create_mask
|
from frigate.util.image import create_mask
|
||||||
|
from frigate.util.live_streams import (
|
||||||
|
default_transcode_source,
|
||||||
|
is_transcode_stream_name,
|
||||||
|
transcode_stream_name,
|
||||||
|
transcode_streams,
|
||||||
|
)
|
||||||
from frigate.util.services import auto_detect_hwaccel
|
from frigate.util.services import auto_detect_hwaccel
|
||||||
|
|
||||||
from .auth import AuthConfig
|
from .auth import AuthConfig
|
||||||
@@ -282,11 +288,78 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_live_transcode_streams(
|
||||||
|
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||||
|
) -> None:
|
||||||
|
"""Fold a camera's transcoded streams into its live stream list.
|
||||||
|
|
||||||
|
Enabled qualities missing from live.streams are appended, and entries the
|
||||||
|
user placed keep their position. Transcoded names that are no longer
|
||||||
|
generated are dropped unless they name a real go2rtc stream.
|
||||||
|
"""
|
||||||
|
live = camera_config.live
|
||||||
|
transcode = live.transcode
|
||||||
|
go2rtc_streams = frigate_config.go2rtc.model_dump().get("streams") or {}
|
||||||
|
generated: dict[str, str] = {}
|
||||||
|
|
||||||
|
if transcode.enabled:
|
||||||
|
if transcode.source is None:
|
||||||
|
transcode.source = default_transcode_source(
|
||||||
|
camera_config.name, live.streams
|
||||||
|
)
|
||||||
|
|
||||||
|
if transcode.source not in go2rtc_streams:
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = transcode_streams(
|
||||||
|
camera_config.name,
|
||||||
|
transcode.source,
|
||||||
|
[quality.model_dump() for quality in transcode.qualities],
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in generated:
|
||||||
|
if name in go2rtc_streams:
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera {camera_config.name} generates transcoded stream {name}, which collides with a go2rtc stream of the same name."
|
||||||
|
)
|
||||||
|
|
||||||
|
streams = {
|
||||||
|
label: name
|
||||||
|
for label, name in live.streams.items()
|
||||||
|
if name in generated
|
||||||
|
or name in go2rtc_streams
|
||||||
|
or not is_transcode_stream_name(camera_config.name, name)
|
||||||
|
}
|
||||||
|
placed = set(streams.values())
|
||||||
|
|
||||||
|
for quality in transcode.qualities if transcode.enabled else []:
|
||||||
|
name = transcode_stream_name(camera_config.name, quality.height)
|
||||||
|
|
||||||
|
if name in placed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = f"{quality.height}p"
|
||||||
|
|
||||||
|
if label in streams:
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera {camera_config.name} already has a live stream named {label}; rename it or place the transcoded stream under another name."
|
||||||
|
)
|
||||||
|
|
||||||
|
streams[label] = name
|
||||||
|
|
||||||
|
live.streams = streams
|
||||||
|
|
||||||
|
|
||||||
def verify_valid_live_stream_names(
|
def verify_valid_live_stream_names(
|
||||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||||
) -> ValueError | None:
|
) -> ValueError | None:
|
||||||
"""Verify that a restream exists to use for live view."""
|
"""Verify that a restream exists to use for live view."""
|
||||||
for _, stream_name in camera_config.live.streams.items():
|
for _, stream_name in camera_config.live.streams.items():
|
||||||
|
if is_transcode_stream_name(camera_config.name, stream_name):
|
||||||
|
continue
|
||||||
|
|
||||||
if (
|
if (
|
||||||
stream_name
|
stream_name
|
||||||
not in frigate_config.go2rtc.model_dump().get("streams", {}).keys()
|
not in frigate_config.go2rtc.model_dump().get("streams", {}).keys()
|
||||||
@@ -978,6 +1051,8 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
"face_recognition": ["enabled", "min_area"],
|
"face_recognition": ["enabled", "min_area"],
|
||||||
"lpr": ["enabled", "expire_time", "min_area", "enhancement"],
|
"lpr": ["enabled", "expire_time", "min_area", "enhancement"],
|
||||||
"audio_transcription": ["enabled", "live_enabled"],
|
"audio_transcription": ["enabled", "live_enabled"],
|
||||||
|
# transcode is camera-level only
|
||||||
|
"live": ["streams", "height", "quality"],
|
||||||
}
|
}
|
||||||
|
|
||||||
for section in allowed_fields_map:
|
for section in allowed_fields_map:
|
||||||
@@ -1206,6 +1281,8 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
if not camera_config.live.streams:
|
if not camera_config.live.streams:
|
||||||
camera_config.live.streams = {name: name}
|
camera_config.live.streams = {name: name}
|
||||||
|
|
||||||
|
apply_live_transcode_streams(self, camera_config)
|
||||||
|
|
||||||
# generate the ffmpeg commands
|
# generate the ffmpeg commands
|
||||||
camera_config.create_ffmpeg_cmds()
|
camera_config.create_ffmpeg_cmds()
|
||||||
self.cameras[name] = camera_config
|
self.cameras[name] = camera_config
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Frame annotations derived from object tracking data.
|
"""Frame annotations derived from object tracking data.
|
||||||
|
|
||||||
Builds short notes describing what changed during a review item, keyed to the
|
Builds short notes describing what changed during a review item, keyed to the
|
||||||
frames sampled from it. Everything here comes from data already recorded
|
frames sampled from it. Everything here comes from tracked object data already
|
||||||
(each event's `path_data` trajectory, the timeline's stationary/active
|
in the database (each event's `path_data` trajectory and the timeline's
|
||||||
changes, and the review item's state classification changes), so the notes
|
stationary/active changes), so the notes can be stated to the model as fact
|
||||||
can be stated to the model as fact rather than as something it must perceive.
|
rather than as something it must perceive.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -95,15 +95,6 @@ def event_name(event: dict[str, Any]) -> str:
|
|||||||
return f"{article} {label}"
|
return f"{article} {label}"
|
||||||
|
|
||||||
|
|
||||||
def describe_classification_change(change: dict[str, Any]) -> str:
|
|
||||||
"""Phrase a state classification change, e.g. 'front gate changed from
|
|
||||||
closed to open'."""
|
|
||||||
model = str(change["model"]).replace("_", " ")
|
|
||||||
before = str(change["from"]).replace("_", " ")
|
|
||||||
after = str(change["to"]).replace("_", " ")
|
|
||||||
return f"{model} changed from {before} to {after}"
|
|
||||||
|
|
||||||
|
|
||||||
def path_legs(points: list[Point]) -> list[Leg]:
|
def path_legs(points: list[Point]) -> list[Leg]:
|
||||||
"""Split a trajectory into runs of travel in a consistent direction.
|
"""Split a trajectory into runs of travel in a consistent direction.
|
||||||
|
|
||||||
@@ -368,38 +359,25 @@ def get_state_changes(detection_ids: list[str]) -> list[dict[str, Any]]:
|
|||||||
def build_frame_captions(
|
def build_frame_captions(
|
||||||
detection_ids: list[str],
|
detection_ids: list[str],
|
||||||
frame_times: list[float],
|
frame_times: list[float],
|
||||||
classification_changes: Sequence[dict[str, Any]] = (),
|
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""A caption for each sampled frame, in frame order.
|
"""A caption for each sampled frame, in frame order.
|
||||||
|
|
||||||
Every frame gets its index and elapsed time so the model can tell them
|
Every frame gets its index and elapsed time so the model can tell them
|
||||||
apart; frames where something changed also carry the tracker and state
|
apart; frames where something changed also carry the tracker notes for
|
||||||
classification notes for that moment. Returns an empty list when there is
|
that moment. Returns an empty list when there is nothing to say, which
|
||||||
nothing to say, which callers treat as a reason to fall back to sending
|
callers treat as a reason to fall back to sending plain frames.
|
||||||
plain frames.
|
|
||||||
"""
|
"""
|
||||||
if not frame_times:
|
if not frame_times:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
span_end = frame_times[-1]
|
|
||||||
timeline: list[tuple[float, str]] = []
|
|
||||||
events = get_tracked_events(detection_ids)
|
events = get_tracked_events(detection_ids)
|
||||||
|
|
||||||
# audio and manual review items can have state changes but no tracked objects
|
if not events:
|
||||||
if events:
|
logger.debug("No tracked events found for review item, skipping annotations")
|
||||||
timeline.extend(
|
return []
|
||||||
(timestamp, f"[tracker] {note}")
|
|
||||||
for timestamp, note in build_timeline(
|
|
||||||
events, span_end, get_state_changes(detection_ids)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
timeline.extend(
|
timeline = build_timeline(events, frame_times[-1], get_state_changes(detection_ids))
|
||||||
(change["timestamp"], f"[state] {describe_classification_change(change)}")
|
buckets = annotations_by_frame(timeline, frame_times)
|
||||||
for change in classification_changes
|
|
||||||
if change["timestamp"] <= span_end
|
|
||||||
)
|
|
||||||
buckets = annotations_by_frame(sorted(timeline, key=lambda m: m[0]), frame_times)
|
|
||||||
|
|
||||||
if not buckets:
|
if not buckets:
|
||||||
return []
|
return []
|
||||||
@@ -410,7 +388,7 @@ def build_frame_captions(
|
|||||||
|
|
||||||
for index, timestamp in enumerate(frame_times):
|
for index, timestamp in enumerate(frame_times):
|
||||||
lines = [f"Frame {index + 1} of {total} (+{timestamp - origin:.1f}s):"]
|
lines = [f"Frame {index + 1} of {total} (+{timestamp - origin:.1f}s):"]
|
||||||
lines.extend(buckets.get(index, []))
|
lines.extend(f"[tracker] {note}" for note in buckets.get(index, []))
|
||||||
captions.append("\n".join(lines))
|
captions.append("\n".join(lines))
|
||||||
|
|
||||||
return captions
|
return captions
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from frigate.util.image import get_image_from_recording
|
|||||||
|
|
||||||
from ..post.api import PostProcessorApi
|
from ..post.api import PostProcessorApi
|
||||||
from ..types import DataProcessorMetrics
|
from ..types import DataProcessorMetrics
|
||||||
from .review_annotations import build_frame_captions, describe_classification_change
|
from .review_annotations import build_frame_captions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -254,10 +254,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
"start_time": r["start_time"],
|
"start_time": r["start_time"],
|
||||||
"end_time": r["end_time"],
|
"end_time": r["end_time"],
|
||||||
"metadata": r["data"]["metadata"],
|
"metadata": r["data"]["metadata"],
|
||||||
"state_changes": [
|
|
||||||
describe_classification_change(change)
|
|
||||||
for change in sorted_classification_state_changes(r["data"])
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
for r in (
|
for r in (
|
||||||
ReviewSegment.select(
|
ReviewSegment.select(
|
||||||
@@ -302,9 +298,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
primary_item["start_time"] = primary_seg["start_time"]
|
primary_item["start_time"] = primary_seg["start_time"]
|
||||||
primary_item["end_time"] = primary_seg["end_time"]
|
primary_item["end_time"] = primary_seg["end_time"]
|
||||||
|
|
||||||
if primary_seg["state_changes"]:
|
|
||||||
primary_item["state_changes"] = primary_seg["state_changes"]
|
|
||||||
|
|
||||||
# Find overlapping contextual items from other cameras
|
# Find overlapping contextual items from other cameras
|
||||||
primary_start = primary_seg["start_time"]
|
primary_start = primary_seg["start_time"]
|
||||||
primary_end = primary_seg["end_time"]
|
primary_end = primary_seg["end_time"]
|
||||||
@@ -325,25 +318,14 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
seg_end = seg["end_time"]
|
seg_end = seg["end_time"]
|
||||||
|
|
||||||
if seg_start < primary_end and primary_start < seg_end:
|
if seg_start < primary_end and primary_start < seg_end:
|
||||||
# Avoid duplicates if same camera has multiple overlapping
|
# Avoid duplicates if same camera has multiple overlapping segments
|
||||||
# segments. One with state changes is kept as its own item
|
if seg_camera not in seen_contextual_cameras:
|
||||||
# so each change stays within its item's time range.
|
contextual_item = copy.deepcopy(seg["metadata"])
|
||||||
if (
|
contextual_item["camera"] = seg_camera
|
||||||
seg_camera in seen_contextual_cameras
|
contextual_item["start_time"] = seg_start
|
||||||
and not seg["state_changes"]
|
contextual_item["end_time"] = seg_end
|
||||||
):
|
contextual_items.append(contextual_item)
|
||||||
continue
|
seen_contextual_cameras.add(seg_camera)
|
||||||
|
|
||||||
contextual_item = copy.deepcopy(seg["metadata"])
|
|
||||||
contextual_item["camera"] = seg_camera
|
|
||||||
contextual_item["start_time"] = seg_start
|
|
||||||
contextual_item["end_time"] = seg_end
|
|
||||||
|
|
||||||
if seg["state_changes"]:
|
|
||||||
contextual_item["state_changes"] = seg["state_changes"]
|
|
||||||
|
|
||||||
contextual_items.append(contextual_item)
|
|
||||||
seen_contextual_cameras.add(seg_camera)
|
|
||||||
|
|
||||||
# Add context array to primary item
|
# Add context array to primary item
|
||||||
primary_item["context"] = contextual_items
|
primary_item["context"] = contextual_items
|
||||||
@@ -457,7 +439,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
captions = build_frame_captions(
|
captions = build_frame_captions(
|
||||||
final_data["data"].get("detections") or [],
|
final_data["data"].get("detections") or [],
|
||||||
[timestamp for _, timestamp in frames],
|
[timestamp for _, timestamp in frames],
|
||||||
sorted_classification_state_changes(final_data["data"]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not captions:
|
if not captions:
|
||||||
@@ -707,39 +688,6 @@ def get_recording_buffer_extension(duration: float) -> float:
|
|||||||
return buffer_extension
|
return buffer_extension
|
||||||
|
|
||||||
|
|
||||||
def sorted_classification_state_changes(
|
|
||||||
review_data: dict[str, Any],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""A review item's state classification changes in time order."""
|
|
||||||
return sorted(
|
|
||||||
review_data.get("classification_state_changes") or [],
|
|
||||||
key=lambda change: change["timestamp"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_classification_state_changes(
|
|
||||||
changes: list[dict[str, Any]], start_time: float, end_time: float
|
|
||||||
) -> list[str]:
|
|
||||||
"""Phrase state classification changes with their timing in the activity.
|
|
||||||
|
|
||||||
Changes are attached while the review item is active, which runs past its
|
|
||||||
end_time by the review cutoff, and a few seconds before its start.
|
|
||||||
"""
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
for change in changes:
|
|
||||||
if change["timestamp"] < start_time:
|
|
||||||
when = "just before the activity started"
|
|
||||||
elif change["timestamp"] > end_time:
|
|
||||||
when = "after the activity ended"
|
|
||||||
else:
|
|
||||||
when = f"{round(change['timestamp'] - start_time)}s into the activity"
|
|
||||||
|
|
||||||
lines.append(f"{describe_classification_change(change)}, {when}")
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def run_analysis(
|
def run_analysis(
|
||||||
requestor: InterProcessRequestor,
|
requestor: InterProcessRequestor,
|
||||||
genai_client: GenAIClient,
|
genai_client: GenAIClient,
|
||||||
@@ -795,13 +743,6 @@ def run_analysis(
|
|||||||
unified_objects.append(object_type)
|
unified_objects.append(object_type)
|
||||||
|
|
||||||
analytics_data["unified_objects"] = unified_objects
|
analytics_data["unified_objects"] = unified_objects
|
||||||
analytics_data["classification_state_changes"] = (
|
|
||||||
format_classification_state_changes(
|
|
||||||
sorted_classification_state_changes(final_data["data"]),
|
|
||||||
final_data["start_time"],
|
|
||||||
final_data["end_time"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
metadata = genai_client.generate_review_description(
|
metadata = genai_client.generate_review_description(
|
||||||
analytics_data,
|
analytics_data,
|
||||||
|
|||||||
@@ -91,23 +91,8 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
|||||||
self.tensor_input_details = self.interpreter.get_input_details()
|
self.tensor_input_details = self.interpreter.get_input_details()
|
||||||
self.tensor_output_details = self.interpreter.get_output_details()
|
self.tensor_output_details = self.interpreter.get_output_details()
|
||||||
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
|
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
|
||||||
self._forget_unknown_states()
|
|
||||||
self.classifications_per_second.start()
|
self.classifications_per_second.start()
|
||||||
|
|
||||||
def _forget_unknown_states(self) -> None:
|
|
||||||
"""Drop verified states that are not labels of the loaded model.
|
|
||||||
|
|
||||||
A retrained model can rename or remove labels. Keeping a state it can
|
|
||||||
no longer produce would report its first verified state as a change
|
|
||||||
from that obsolete label.
|
|
||||||
"""
|
|
||||||
labels = set(self.labelmap.values())
|
|
||||||
self.state_history = {
|
|
||||||
camera: history
|
|
||||||
for camera, history in self.state_history.items()
|
|
||||||
if history["current_state"] in labels
|
|
||||||
}
|
|
||||||
|
|
||||||
def __update_metrics(self, duration: float) -> None:
|
def __update_metrics(self, duration: float) -> None:
|
||||||
self.classifications_per_second.update()
|
self.classifications_per_second.update()
|
||||||
if self.inference_speed:
|
if self.inference_speed:
|
||||||
@@ -149,20 +134,15 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
|||||||
# Don't save if state is stable (detected_state == current_state) AND score is 100%
|
# Don't save if state is stable (detected_state == current_state) AND score is 100%
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def verify_state_change(
|
def verify_state_change(self, camera: str, detected_state: str) -> str | None:
|
||||||
self, camera: str, detected_state: str, timestamp: float
|
|
||||||
) -> tuple[str | None, float] | None:
|
|
||||||
"""
|
"""
|
||||||
Verify state change requires 3 consecutive identical states before publishing.
|
Verify state change requires 3 consecutive identical states before publishing.
|
||||||
Returns (previous state, time the new state was first seen) once verified,
|
Returns state to publish or None if verification not complete.
|
||||||
or None if verification not complete. The previous state is None for the
|
|
||||||
first state verified on a camera.
|
|
||||||
"""
|
"""
|
||||||
if camera not in self.state_history:
|
if camera not in self.state_history:
|
||||||
self.state_history[camera] = {
|
self.state_history[camera] = {
|
||||||
"current_state": None,
|
"current_state": None,
|
||||||
"pending_state": None,
|
"pending_state": None,
|
||||||
"pending_since": 0.0,
|
|
||||||
"consecutive_count": 0,
|
"consecutive_count": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,14 +157,12 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
|||||||
verification["consecutive_count"] += 1
|
verification["consecutive_count"] += 1
|
||||||
|
|
||||||
if verification["consecutive_count"] >= 3:
|
if verification["consecutive_count"] >= 3:
|
||||||
previous_state = verification["current_state"]
|
|
||||||
verification["current_state"] = detected_state
|
verification["current_state"] = detected_state
|
||||||
verification["pending_state"] = None
|
verification["pending_state"] = None
|
||||||
verification["consecutive_count"] = 0
|
verification["consecutive_count"] = 0
|
||||||
return previous_state, verification["pending_since"]
|
return detected_state
|
||||||
else:
|
else:
|
||||||
verification["pending_state"] = detected_state
|
verification["pending_state"] = detected_state
|
||||||
verification["pending_since"] = timestamp
|
|
||||||
verification["consecutive_count"] = 1
|
verification["consecutive_count"] = 1
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections"
|
f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections"
|
||||||
@@ -362,19 +340,16 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
verified = self.verify_state_change(camera, detected_state, timestamp)
|
verified_state = self.verify_state_change(camera, detected_state)
|
||||||
|
|
||||||
if verified is not None:
|
if verified_state is not None:
|
||||||
previous_state, changed_at = verified
|
|
||||||
self._emit_result(
|
self._emit_result(
|
||||||
{
|
{
|
||||||
"type": "classification",
|
"type": "classification",
|
||||||
"processor": "state",
|
"processor": "state",
|
||||||
"model_name": self.model_config.name,
|
"model_name": self.model_config.name,
|
||||||
"camera": camera,
|
"camera": camera,
|
||||||
"state": detected_state,
|
"state": verified_state,
|
||||||
"previous_state": previous_state,
|
|
||||||
"timestamp": changed_at,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ class ModelConfig(BaseModel):
|
|||||||
_all_attributes: list[str] = PrivateAttr()
|
_all_attributes: list[str] = PrivateAttr()
|
||||||
_all_attribute_logos: list[str] = PrivateAttr()
|
_all_attribute_logos: list[str] = PrivateAttr()
|
||||||
_model_hash: str = PrivateAttr()
|
_model_hash: str = PrivateAttr()
|
||||||
_plus_id: str | None = PrivateAttr(default=None)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def merged_labelmap(self) -> dict[int, str]:
|
def merged_labelmap(self) -> dict[int, str]:
|
||||||
@@ -149,11 +148,6 @@ class ModelConfig(BaseModel):
|
|||||||
def model_hash(self) -> str:
|
def model_hash(self) -> str:
|
||||||
return self._model_hash
|
return self._model_hash
|
||||||
|
|
||||||
@property
|
|
||||||
def plus_id(self) -> str | None:
|
|
||||||
"""The Frigate+ model id, once a plus:// path has been resolved."""
|
|
||||||
return self._plus_id
|
|
||||||
|
|
||||||
def __init__(self, **config):
|
def __init__(self, **config):
|
||||||
super().__init__(**config)
|
super().__init__(**config)
|
||||||
|
|
||||||
@@ -184,7 +178,6 @@ class ModelConfig(BaseModel):
|
|||||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
model_id = self.path[7:]
|
model_id = self.path[7:]
|
||||||
self._plus_id = model_id
|
|
||||||
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||||
model_info_path = f"{self.path}.json"
|
model_info_path = f"{self.path}.json"
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ from typing import Any
|
|||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.detections_updater import (
|
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||||
DetectionPublisher,
|
|
||||||
DetectionSubscriber,
|
|
||||||
DetectionTypeEnum,
|
|
||||||
)
|
|
||||||
from frigate.comms.embeddings_updater import (
|
from frigate.comms.embeddings_updater import (
|
||||||
EmbeddingsRequestEnum,
|
EmbeddingsRequestEnum,
|
||||||
EmbeddingsResponder,
|
EmbeddingsResponder,
|
||||||
@@ -172,7 +168,6 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
self.review_subscriber = ReviewDataSubscriber("")
|
self.review_subscriber = ReviewDataSubscriber("")
|
||||||
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value)
|
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value)
|
||||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all.value)
|
|
||||||
self.embeddings_responder = EmbeddingsResponder()
|
self.embeddings_responder = EmbeddingsResponder()
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
|
|
||||||
@@ -361,7 +356,6 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
self.recordings_subscriber.stop()
|
self.recordings_subscriber.stop()
|
||||||
self.detection_subscriber.stop()
|
self.detection_subscriber.stop()
|
||||||
self.detection_publisher.stop()
|
|
||||||
self.event_metadata_publisher.stop()
|
self.event_metadata_publisher.stop()
|
||||||
self.event_metadata_subscriber.stop()
|
self.event_metadata_subscriber.stop()
|
||||||
self.embeddings_responder.stop()
|
self.embeddings_responder.stop()
|
||||||
@@ -857,21 +851,6 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
f"{result['camera']}/classification/{result['model_name']}",
|
f"{result['camera']}/classification/{result['model_name']}",
|
||||||
result["state"],
|
result["state"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# the first state verified after startup is not a change
|
|
||||||
if result["previous_state"] is not None:
|
|
||||||
self.detection_publisher.publish(
|
|
||||||
(
|
|
||||||
result["camera"],
|
|
||||||
{
|
|
||||||
"model": result["model_name"],
|
|
||||||
"from": result["previous_state"],
|
|
||||||
"to": result["state"],
|
|
||||||
"timestamp": result["timestamp"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
DetectionTypeEnum.classification_state.value,
|
|
||||||
)
|
|
||||||
elif result["processor"] == "object":
|
elif result["processor"] == "object":
|
||||||
object_id = result["object_id"]
|
object_id = result["object_id"]
|
||||||
camera = result["camera"]
|
camera = result["camera"]
|
||||||
|
|||||||
@@ -103,10 +103,11 @@ class LlamaCppClient(GenAIClient):
|
|||||||
_supports_reasoning: bool
|
_supports_reasoning: bool
|
||||||
_image_token_cache: dict[tuple[int, int], int]
|
_image_token_cache: dict[tuple[int, int], int]
|
||||||
_text_baseline_tokens: int | None
|
_text_baseline_tokens: int | None
|
||||||
|
_media_marker: str
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_embeddings(self) -> bool:
|
def supports_embeddings(self) -> bool:
|
||||||
"""llama.cpp exposes a /v1/embeddings endpoint for any loaded model."""
|
"""llama.cpp exposes an /embeddings endpoint for any loaded model."""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _auth_headers(self) -> dict | None:
|
def _auth_headers(self) -> dict | None:
|
||||||
@@ -158,6 +159,7 @@ class LlamaCppClient(GenAIClient):
|
|||||||
self._supports_reasoning = False
|
self._supports_reasoning = False
|
||||||
self._image_token_cache = {}
|
self._image_token_cache = {}
|
||||||
self._text_baseline_tokens = None
|
self._text_baseline_tokens = None
|
||||||
|
self._media_marker = "<__media__>"
|
||||||
|
|
||||||
base_url = (
|
base_url = (
|
||||||
self.genai_config.base_url.rstrip("/")
|
self.genai_config.base_url.rstrip("/")
|
||||||
@@ -185,6 +187,7 @@ class LlamaCppClient(GenAIClient):
|
|||||||
self._supports_audio = info["supports_audio"]
|
self._supports_audio = info["supports_audio"]
|
||||||
self._supports_tools = info["supports_tools"]
|
self._supports_tools = info["supports_tools"]
|
||||||
self._supports_reasoning = info["supports_reasoning"]
|
self._supports_reasoning = info["supports_reasoning"]
|
||||||
|
self._media_marker = info["media_marker"]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||||
@@ -212,7 +215,9 @@ class LlamaCppClient(GenAIClient):
|
|||||||
`architecture.input_modalities` (text/image/audio) — the primary
|
`architecture.input_modalities` (text/image/audio) — the primary
|
||||||
source. When proxied through llama-swap, the same entry carries
|
source. When proxied through llama-swap, the same entry carries
|
||||||
`status.args` (server launch argv) and, for the loaded model,
|
`status.args` (server launch argv) and, for the loaded model,
|
||||||
`meta.n_ctx`.
|
`meta.n_ctx`. /props remains the only source for `media_marker`,
|
||||||
|
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
|
||||||
|
is set.
|
||||||
"""
|
"""
|
||||||
info: dict[str, Any] = {
|
info: dict[str, Any] = {
|
||||||
"context_size": None,
|
"context_size": None,
|
||||||
@@ -220,6 +225,7 @@ class LlamaCppClient(GenAIClient):
|
|||||||
"supports_audio": False,
|
"supports_audio": False,
|
||||||
"supports_tools": False,
|
"supports_tools": False,
|
||||||
"supports_reasoning": False,
|
"supports_reasoning": False,
|
||||||
|
"media_marker": "<__media__>",
|
||||||
}
|
}
|
||||||
|
|
||||||
model_entry: dict[str, Any] | None = None
|
model_entry: dict[str, Any] | None = None
|
||||||
@@ -308,8 +314,16 @@ class LlamaCppClient(GenAIClient):
|
|||||||
# in the Jinja chat template itself.
|
# in the Jinja chat template itself.
|
||||||
chat_template = props.get("chat_template") or ""
|
chat_template = props.get("chat_template") or ""
|
||||||
info["supports_reasoning"] = "enable_thinking" in chat_template
|
info["supports_reasoning"] = "enable_thinking" in chat_template
|
||||||
|
|
||||||
|
media_marker = props.get("media_marker")
|
||||||
|
if isinstance(media_marker, str) and media_marker:
|
||||||
|
info["media_marker"] = media_marker
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to query llama.cpp /props endpoint: %s", e)
|
logger.warning(
|
||||||
|
"Failed to query llama.cpp /props endpoint: %s. "
|
||||||
|
"Image embeddings may fail if the server randomized its media marker.",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -460,6 +474,9 @@ class LlamaCppClient(GenAIClient):
|
|||||||
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
|
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
|
||||||
"""Transcribe through /v1/chat/completions, for servers without the
|
"""Transcribe through /v1/chat/completions, for servers without the
|
||||||
transcriptions route.
|
transcriptions route.
|
||||||
|
|
||||||
|
The _media_marker / multimodal_data convention is an /embeddings-only
|
||||||
|
protocol, so no marker-refresh retry is needed here.
|
||||||
"""
|
"""
|
||||||
prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech."
|
prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech."
|
||||||
|
|
||||||
@@ -777,16 +794,41 @@ class LlamaCppClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
return result if result else None
|
return result if result else None
|
||||||
|
|
||||||
|
def _refresh_media_marker(self) -> bool:
|
||||||
|
"""Re-fetch /props and update the cached media marker if it changed.
|
||||||
|
|
||||||
|
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
|
||||||
|
is set), so a stale marker indicates a restart. Returns True iff the
|
||||||
|
marker was updated to a new value — used to gate a one-shot retry of
|
||||||
|
a failed embeddings request.
|
||||||
|
"""
|
||||||
|
if self.provider is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
props = self._fetch_llama_props(self.provider, self.genai_config.model)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
marker = props.get("media_marker")
|
||||||
|
|
||||||
|
if not isinstance(marker, str) or not marker or marker == self._media_marker:
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info("llama.cpp media marker changed (server restart); refreshed")
|
||||||
|
self._media_marker = marker
|
||||||
|
return True
|
||||||
|
|
||||||
def embed(
|
def embed(
|
||||||
self,
|
self,
|
||||||
texts: list[str] | None = None,
|
texts: list[str] | None = None,
|
||||||
images: list[bytes] | None = None,
|
images: list[bytes] | None = None,
|
||||||
) -> list[np.ndarray]:
|
) -> list[np.ndarray]:
|
||||||
"""Generate embeddings via llama.cpp /v1/embeddings endpoint.
|
"""Generate embeddings via llama.cpp /embeddings endpoint.
|
||||||
|
|
||||||
Each text or image is one entry in `input`, using the chat-style
|
Supports batch requests. Uses content format with prompt_string and
|
||||||
content array from ggml-org/llama.cpp#29556. Server must be started
|
multimodal_data for images (PR #15108). Server must be started with
|
||||||
with --embeddings, and --mmproj for image support.
|
--embeddings and --mmproj for multimodal support.
|
||||||
"""
|
"""
|
||||||
if self.provider is None:
|
if self.provider is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -801,42 +843,49 @@ class LlamaCppClient(GenAIClient):
|
|||||||
|
|
||||||
EMBEDDING_DIM = 768
|
EMBEDDING_DIM = 768
|
||||||
|
|
||||||
inputs: list[dict[str, Any]] = [
|
encoded_images: list[str] = []
|
||||||
{"content": [{"type": "text", "text": text}]} for text in texts
|
|
||||||
]
|
|
||||||
|
|
||||||
for img in images:
|
for img in images:
|
||||||
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||||
jpeg_bytes = _to_jpeg(img)
|
jpeg_bytes = _to_jpeg(img)
|
||||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
|
||||||
# The trailing newline keeps tokenization identical to the older
|
|
||||||
# "<__media__>\n" prompt_string format, so indexed vectors stay valid
|
def build_content() -> list[dict[str, Any]]:
|
||||||
inputs.append(
|
# prompt_string must contain the server's media marker placeholder
|
||||||
{
|
# for each image. The marker is randomized per server startup.
|
||||||
"content": [
|
content: list[dict[str, Any]] = []
|
||||||
{
|
for text in texts:
|
||||||
"type": "image_url",
|
content.append({"prompt_string": text})
|
||||||
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
for encoded in encoded_images:
|
||||||
},
|
content.append(
|
||||||
{"type": "text", "text": "\n"},
|
{
|
||||||
]
|
"prompt_string": f"{self._media_marker}\n",
|
||||||
}
|
"multimodal_data": [encoded],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
def post_embeddings() -> requests.Response:
|
||||||
|
return self._post(
|
||||||
|
f"{self.provider}/embeddings",
|
||||||
|
json={"model": self.genai_config.model, "content": build_content()},
|
||||||
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self._post(
|
try:
|
||||||
f"{self.provider}/v1/embeddings",
|
response = post_embeddings()
|
||||||
json={
|
response.raise_for_status()
|
||||||
"model": self.genai_config.model,
|
except requests.exceptions.RequestException:
|
||||||
"input": inputs,
|
# The server may have restarted with a new media marker.
|
||||||
"encoding_format": "float",
|
# Refresh from /props; only retry if the marker actually changed.
|
||||||
},
|
if not encoded_images or not self._refresh_media_marker():
|
||||||
timeout=self.timeout,
|
raise
|
||||||
)
|
response = post_embeddings()
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
items = response.json().get("data")
|
result = response.json()
|
||||||
|
|
||||||
|
items = result.get("data", result) if isinstance(result, dict) else result
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
logger.warning("llama.cpp embeddings returned unexpected format")
|
logger.warning("llama.cpp embeddings returned unexpected format")
|
||||||
return []
|
return []
|
||||||
@@ -847,7 +896,11 @@ class LlamaCppClient(GenAIClient):
|
|||||||
if emb is None:
|
if emb is None:
|
||||||
logger.warning("llama.cpp embeddings item missing embedding field")
|
logger.warning("llama.cpp embeddings item missing embedding field")
|
||||||
continue
|
continue
|
||||||
arr = np.array(emb, dtype=np.float32).flatten()
|
arr = np.array(emb, dtype=np.float32)
|
||||||
|
if arr.ndim > 1:
|
||||||
|
# llama.cpp can return token-level embeddings; pool per item
|
||||||
|
arr = arr.mean(axis=0)
|
||||||
|
arr = arr.flatten()
|
||||||
orig_dim = arr.size
|
orig_dim = arr.size
|
||||||
if orig_dim != EMBEDDING_DIM:
|
if orig_dim != EMBEDDING_DIM:
|
||||||
if orig_dim > EMBEDDING_DIM:
|
if orig_dim > EMBEDDING_DIM:
|
||||||
|
|||||||
@@ -104,21 +104,6 @@ def build_review_description_prompt(
|
|||||||
else:
|
else:
|
||||||
return "\n- (No objects detected)"
|
return "\n- (No objects detected)"
|
||||||
|
|
||||||
def get_state_changes_section() -> str:
|
|
||||||
# empty when nothing changed so the prompt is otherwise unaffected
|
|
||||||
changes = review_data.get("classification_state_changes")
|
|
||||||
|
|
||||||
if not changes:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
return (
|
|
||||||
"\n\n## State Changes\n\n"
|
|
||||||
"The camera's state classifiers watch fixed areas of the scene and "
|
|
||||||
"reported these changes. They come from the classifiers rather than "
|
|
||||||
"from the images, and they are reliable. Describe each one where it "
|
|
||||||
"fits in the sequence of events.\n- " + "\n- ".join(changes)
|
|
||||||
)
|
|
||||||
|
|
||||||
fields = get_review_field_guidelines(response_style)
|
fields = get_review_field_guidelines(response_style)
|
||||||
frame_guidance = f"\n{FRAME_ANNOTATION_GUIDANCE}" if frame_captions else ""
|
frame_guidance = f"\n{FRAME_ANNOTATION_GUIDANCE}" if frame_captions else ""
|
||||||
|
|
||||||
@@ -160,7 +145,7 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
|
|||||||
- Camera: {review_data["camera"]}
|
- Camera: {review_data["camera"]}
|
||||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest){frame_guidance}
|
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest){frame_guidance}
|
||||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}{get_state_changes_section()}
|
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||||
|
|
||||||
## Objects in Scene
|
## Objects in Scene
|
||||||
|
|
||||||
@@ -211,17 +196,6 @@ def build_review_summary_prompt(
|
|||||||
f" to "
|
f" to "
|
||||||
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||||
)
|
)
|
||||||
has_state_changes = any(
|
|
||||||
"state_changes" in item
|
|
||||||
for event in events
|
|
||||||
for item in [event, *event.get("context", [])]
|
|
||||||
)
|
|
||||||
state_changes_format = (
|
|
||||||
'\n- "state_changes" (only on some events): changes to monitored areas '
|
|
||||||
"reported by the camera's state classifiers, which are reliable"
|
|
||||||
if has_state_changes
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
You are a security officer writing a concise security report.
|
You are a security officer writing a concise security report.
|
||||||
|
|
||||||
@@ -229,7 +203,7 @@ Time range: {time_range}
|
|||||||
|
|
||||||
Input format: Each event is a JSON object with:
|
Input format: Each event is a JSON object with:
|
||||||
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
|
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
|
||||||
- "context": array of related events from other cameras that occurred during overlapping time periods{state_changes_format}
|
- "context": array of related events from other cameras that occurred during overlapping time periods
|
||||||
|
|
||||||
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
|
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
|
||||||
|
|
||||||
|
|||||||
+22
-90
@@ -42,8 +42,6 @@ from frigate.util.ownership import chown_to_runtime
|
|||||||
from frigate.util.recording_coverage import (
|
from frigate.util.recording_coverage import (
|
||||||
build_spans,
|
build_spans,
|
||||||
known_video_codecs,
|
known_video_codecs,
|
||||||
null_audio_glitches,
|
|
||||||
realized_timeline,
|
|
||||||
resolve_coverage,
|
resolve_coverage,
|
||||||
stream_media_summary,
|
stream_media_summary,
|
||||||
)
|
)
|
||||||
@@ -86,20 +84,10 @@ class StreamRun:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _ChapterWindow:
|
class _ChapterWindow:
|
||||||
"""A merged-timeline slice, shaped like the recording rows chapters read.
|
"""A merged-timeline slice, shaped like the recording rows chapters read."""
|
||||||
|
|
||||||
lead_in is the output time the slice's vod clip plays before start_time,
|
|
||||||
from snapping its first frame back to a keyframe.
|
|
||||||
"""
|
|
||||||
|
|
||||||
start_time: float
|
start_time: float
|
||||||
end_time: float
|
end_time: float
|
||||||
lead_in: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _lead_in(recording: Any) -> float:
|
|
||||||
"""Output seconds a chapter source plays before its first wall second."""
|
|
||||||
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
||||||
@@ -388,18 +376,15 @@ class RecordingExporter(threading.Thread):
|
|||||||
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
||||||
"""Resolve the export range into the spans the VOD manifest will serve.
|
"""Resolve the export range into the spans the VOD manifest will serve.
|
||||||
|
|
||||||
Delegates to the same coverage resolution and glitch nulling the
|
Delegates to the same coverage resolution the manifest builder
|
||||||
manifest builder uses, so what we plan around and what nginx-vod
|
uses, so what we plan around and what nginx-vod emits agree by
|
||||||
emits agree by construction. Returns the spans (each [row, start,
|
construction. Returns the spans (each [row, start, end, is_main]),
|
||||||
end, is_main]), the known video codecs, and whether audio survives
|
the known video codecs, and whether audio survives the range.
|
||||||
the range.
|
|
||||||
Memoized: several stages of the export ask the same question, and
|
Memoized: several stages of the export ask the same question, and
|
||||||
the recordings backing a finished range do not change under us.
|
the recordings backing a finished range do not change under us.
|
||||||
"""
|
"""
|
||||||
if self._coverage is None:
|
if self._coverage is None:
|
||||||
intervals = null_audio_glitches(
|
intervals = resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||||
resolve_coverage(self.camera, self.start_time, self.end_time)
|
|
||||||
)
|
|
||||||
self._coverage = (
|
self._coverage = (
|
||||||
build_spans(intervals, self.pinned_stream),
|
build_spans(intervals, self.pinned_stream),
|
||||||
known_video_codecs(intervals),
|
known_video_codecs(intervals),
|
||||||
@@ -448,57 +433,17 @@ class RecordingExporter(threading.Thread):
|
|||||||
# hand-off to stage around
|
# hand-off to stage around
|
||||||
return True
|
return True
|
||||||
|
|
||||||
_spans, codecs, keep_audio = self._resolve_coverage()
|
spans, codecs, keep_audio = self._resolve_coverage()
|
||||||
runs = self._planned_stream_runs()
|
runs = self._stream_runs(spans)
|
||||||
|
|
||||||
# a range one stream covers end to end has nothing to hand off,
|
# a range one stream covers end to end has nothing to hand off,
|
||||||
# so it stays on the existing path however long it is
|
# so it stays on the existing path however long it is
|
||||||
if len(runs) < 2:
|
if len(runs) < 2:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
runs = [piece for run in runs for piece in self._split_long_run(run)]
|
||||||
return self._stage_stream_runs(runs, codecs, keep_audio)
|
return self._stage_stream_runs(runs, codecs, keep_audio)
|
||||||
|
|
||||||
def _planned_stream_runs(self) -> list[StreamRun]:
|
|
||||||
"""The runs a mixed range is staged as, one pinned vod playlist each."""
|
|
||||||
runs = self._stream_runs(self._merged_spans())
|
|
||||||
|
|
||||||
if len(runs) < 2:
|
|
||||||
return runs
|
|
||||||
|
|
||||||
return [piece for run in runs for piece in self._split_long_run(run)]
|
|
||||||
|
|
||||||
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
|
|
||||||
"""Chapter windows for the staged files as they were rendered.
|
|
||||||
|
|
||||||
Each staged run comes from its own pinned vod playlist, whose first
|
|
||||||
clip snaps back to the preceding keyframe, so a staged file runs up
|
|
||||||
to a GOP longer than its slice of the merged timeline. Planning each
|
|
||||||
run the way its playlist does carries that lead-in into the chapter
|
|
||||||
offsets instead of letting it accumulate at every hand-off.
|
|
||||||
"""
|
|
||||||
windows: list[_ChapterWindow] = []
|
|
||||||
|
|
||||||
for run in self._planned_stream_runs():
|
|
||||||
intervals = null_audio_glitches(
|
|
||||||
resolve_coverage(self.camera, run.start_time, run.end_time)
|
|
||||||
)
|
|
||||||
|
|
||||||
for clip in realized_timeline(intervals, run.stream_type):
|
|
||||||
# a skipped clip is absent from the playlist and the file
|
|
||||||
if clip["duration"] <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
span = clip["end_time"] - clip["start_time"]
|
|
||||||
windows.append(
|
|
||||||
_ChapterWindow(
|
|
||||||
clip["start_time"],
|
|
||||||
clip["end_time"],
|
|
||||||
max(0.0, clip["duration"] / 1000 - span),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return windows
|
|
||||||
|
|
||||||
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
||||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
"""Collapse the merged spans into contiguous runs of one stream type.
|
||||||
|
|
||||||
@@ -895,8 +840,6 @@ class RecordingExporter(threading.Thread):
|
|||||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||||
if clipped_end <= clipped_start:
|
if clipped_end <= clipped_start:
|
||||||
continue
|
continue
|
||||||
# a staged window's keyframe lead-in plays before it
|
|
||||||
output_offset += _lead_in(rec)
|
|
||||||
windows.append((clipped_start, clipped_end, output_offset))
|
windows.append((clipped_start, clipped_end, output_offset))
|
||||||
output_offset += clipped_end - clipped_start
|
output_offset += clipped_end - clipped_start
|
||||||
|
|
||||||
@@ -1044,13 +987,9 @@ class RecordingExporter(threading.Thread):
|
|||||||
if duration_ms <= 0:
|
if duration_ms <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# a staged window's keyframe lead-in opens its chapter, with
|
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
||||||
# frames captured that long before the window
|
timespec="seconds"
|
||||||
lead_in = _lead_in(rec)
|
)
|
||||||
duration_ms += int(round(lead_in * 1000))
|
|
||||||
title = datetime.datetime.fromtimestamp(
|
|
||||||
clipped_start - lead_in, tz=tz
|
|
||||||
).isoformat(timespec="seconds")
|
|
||||||
chapter_blocks.append(
|
chapter_blocks.append(
|
||||||
"[CHAPTER]\n"
|
"[CHAPTER]\n"
|
||||||
"TIMEBASE=1/1000\n"
|
"TIMEBASE=1/1000\n"
|
||||||
@@ -1189,12 +1128,12 @@ class RecordingExporter(threading.Thread):
|
|||||||
if self.staged_runs:
|
if self.staged_runs:
|
||||||
# each run was already rendered to a temp file with a common
|
# each run was already rendered to a temp file with a common
|
||||||
# track timescale, so the concat demuxer has nothing left to
|
# track timescale, so the concat demuxer has nothing left to
|
||||||
# reconcile
|
# reconcile and every chapter offset lines up with the merged
|
||||||
recordings = (
|
# timeline the staged files reproduce
|
||||||
self._staged_chapter_windows()
|
recordings = [
|
||||||
if self.chapters not in (None, ChaptersEnum.none)
|
_ChapterWindow(span_start, span_end)
|
||||||
else []
|
for _row, span_start, span_end, _is_main in self._merged_spans()
|
||||||
)
|
]
|
||||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||||
ffmpeg_input = (
|
ffmpeg_input = (
|
||||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||||
@@ -1210,18 +1149,11 @@ class RecordingExporter(threading.Thread):
|
|||||||
# its own rows are the ones the chapters describe
|
# its own rows are the ones the chapters describe
|
||||||
recordings = self._get_recordings_for_range(pin)
|
recordings = self._get_recordings_for_range(pin)
|
||||||
else:
|
else:
|
||||||
# an unstaged auto range resolves to at most one stream run, and
|
# never mix streams in one playlist; use main when available
|
||||||
# its rows are the ones the chapters describe. Main rows the
|
# and fall back to sub for expired-main history
|
||||||
# manifest drops (glitches, slivers at the edges of a sub range)
|
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
||||||
# must not stand in for it.
|
|
||||||
runs = self._stream_runs(self._merged_spans())
|
|
||||||
recordings = self._get_recordings_for_range(
|
|
||||||
runs[0].stream_type if runs else STREAM_TYPE_MAIN
|
|
||||||
)
|
|
||||||
|
|
||||||
# never mix streams in one playlist; fall back to sub for
|
if not recordings:
|
||||||
# expired-main history
|
|
||||||
if not recordings and not runs:
|
|
||||||
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||||
|
|
||||||
playlist_lines = []
|
playlist_lines = []
|
||||||
|
|||||||
@@ -490,17 +490,9 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
reviews = reviews_by_camera[camera]
|
reviews = reviews_by_camera[camera]
|
||||||
|
|
||||||
# probes run concurrently, but each segment's start chains off the
|
tasks.extend(
|
||||||
# previous segment's end, so starts resolve in segment order
|
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
|
||||||
previous: asyncio.Event | None = None
|
)
|
||||||
for recording in recordings:
|
|
||||||
resolved = asyncio.Event()
|
|
||||||
tasks.append(
|
|
||||||
self._validate_in_order(
|
|
||||||
camera, reviews, recording, previous, resolved
|
|
||||||
)
|
|
||||||
)
|
|
||||||
previous = resolved
|
|
||||||
|
|
||||||
# publish most recently available recording time and None if disabled
|
# publish most recently available recording time and None if disabled
|
||||||
if stream_type == STREAM_TYPE_MAIN:
|
if stream_type == STREAM_TYPE_MAIN:
|
||||||
@@ -558,33 +550,12 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
while info and info[0][0] < expire_before:
|
while info and info[0][0] < expire_before:
|
||||||
info.pop(0)
|
info.pop(0)
|
||||||
|
|
||||||
async def _validate_in_order(
|
|
||||||
self,
|
|
||||||
camera: str,
|
|
||||||
reviews: Any,
|
|
||||||
recording: dict[str, Any],
|
|
||||||
previous_start: asyncio.Event | None,
|
|
||||||
start_resolved: asyncio.Event,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Validate a segment, always releasing the next one in its stream."""
|
|
||||||
try:
|
|
||||||
return await self.validate_and_move_segment(
|
|
||||||
camera, reviews, recording, previous_start, start_resolved
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
start_resolved.set()
|
|
||||||
|
|
||||||
def drop_segment(self, cache_path: str) -> None:
|
def drop_segment(self, cache_path: str) -> None:
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
|
|
||||||
async def validate_and_move_segment(
|
async def validate_and_move_segment(
|
||||||
self,
|
self, camera: str, reviews: Any, recording: dict[str, Any]
|
||||||
camera: str,
|
|
||||||
reviews: Any,
|
|
||||||
recording: dict[str, Any],
|
|
||||||
previous_start: asyncio.Event | None = None,
|
|
||||||
start_resolved: asyncio.Event | None = None,
|
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
cache_path: str = recording["cache_path"]
|
cache_path: str = recording["cache_path"]
|
||||||
start_time: datetime.datetime = recording["start_time"]
|
start_time: datetime.datetime = recording["start_time"]
|
||||||
@@ -646,9 +617,6 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
async with self.probe_semaphore:
|
async with self.probe_semaphore:
|
||||||
keyframes = await get_keyframe_offsets(cache_path)
|
keyframes = await get_keyframe_offsets(cache_path)
|
||||||
|
|
||||||
if previous_start is not None:
|
|
||||||
await previous_start.wait()
|
|
||||||
|
|
||||||
start_time = self._resolve_segment_start(
|
start_time = self._resolve_segment_start(
|
||||||
camera, stream_type, start_time, duration, cache_path
|
camera, stream_type, start_time, duration, cache_path
|
||||||
)
|
)
|
||||||
@@ -686,11 +654,6 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
RecordingsDataTypeEnum.valid.value,
|
RecordingsDataTypeEnum.valid.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
# the start is settled, so the next segment of the stream can chain
|
|
||||||
# off it while this one waits on retention and the move
|
|
||||||
if start_resolved is not None:
|
|
||||||
start_resolved.set()
|
|
||||||
|
|
||||||
record_config = self.config.cameras[camera].record
|
record_config = self.config.cameras[camera].record
|
||||||
|
|
||||||
# sub's alerts/detections carry the retain mode directly, unlike
|
# sub's alerts/detections carry the retain mode directly, unlike
|
||||||
@@ -1127,7 +1090,6 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
elif (
|
elif (
|
||||||
topic == DetectionTypeEnum.api.value
|
topic == DetectionTypeEnum.api.value
|
||||||
or topic == DetectionTypeEnum.lpr.value
|
or topic == DetectionTypeEnum.lpr.value
|
||||||
or topic == DetectionTypeEnum.classification_state.value
|
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ logger = logging.getLogger(__name__)
|
|||||||
THUMB_HEIGHT = 180
|
THUMB_HEIGHT = 180
|
||||||
THUMB_WIDTH = 320
|
THUMB_WIDTH = 320
|
||||||
|
|
||||||
# seconds before a review item starts that a state classification change is
|
|
||||||
# still attached to it, e.g. a garage door opening before the car is visible
|
|
||||||
CLASSIFICATION_STATE_PRE_ROLL = 5
|
|
||||||
|
|
||||||
|
|
||||||
class PendingReviewSegment:
|
class PendingReviewSegment:
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -65,7 +61,6 @@ class PendingReviewSegment:
|
|||||||
self.sub_labels = sub_labels
|
self.sub_labels = sub_labels
|
||||||
self.zones = zones
|
self.zones = zones
|
||||||
self.audio = audio
|
self.audio = audio
|
||||||
self.classification_state_changes: list[dict[str, Any]] = []
|
|
||||||
self.thumb_time: float | None = None
|
self.thumb_time: float | None = None
|
||||||
self.last_alert_time: float | None = None
|
self.last_alert_time: float | None = None
|
||||||
self.last_detection_time: float = frame_time
|
self.last_detection_time: float = frame_time
|
||||||
@@ -167,7 +162,6 @@ class PendingReviewSegment:
|
|||||||
"sub_labels": list(self.sub_labels.values()),
|
"sub_labels": list(self.sub_labels.values()),
|
||||||
"zones": self.zones,
|
"zones": self.zones,
|
||||||
"audio": list(self.audio),
|
"audio": list(self.audio),
|
||||||
"classification_state_changes": self.classification_state_changes,
|
|
||||||
"thumb_time": self.thumb_time,
|
"thumb_time": self.thumb_time,
|
||||||
"metadata": None,
|
"metadata": None,
|
||||||
},
|
},
|
||||||
@@ -299,9 +293,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
# manual events
|
# manual events
|
||||||
self.indefinite_events: dict[str, dict[str, Any]] = {}
|
self.indefinite_events: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
# state classification changes seen while a camera had no review item
|
|
||||||
self.recent_classification_state_changes: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
|
|
||||||
# ensure dirs
|
# ensure dirs
|
||||||
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
|
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
|
||||||
|
|
||||||
@@ -383,43 +374,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
self.active_review_segments[segment.camera] = None
|
self.active_review_segments[segment.camera] = None
|
||||||
return end_time
|
return end_time
|
||||||
|
|
||||||
def _activate_segment(self, segment: PendingReviewSegment) -> None:
|
|
||||||
"""Make a segment the camera's active one, attaching any state
|
|
||||||
classification changes seen just before it started."""
|
|
||||||
self.active_review_segments[segment.camera] = segment
|
|
||||||
recent = self.recent_classification_state_changes.pop(segment.camera, [])
|
|
||||||
segment.classification_state_changes.extend(
|
|
||||||
c
|
|
||||||
for c in recent
|
|
||||||
if c["timestamp"] >= segment.start_time - CLASSIFICATION_STATE_PRE_ROLL
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle_classification_state_change(
|
|
||||||
self, camera: str, change: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Attach a verified state classification change to the active segment.
|
|
||||||
|
|
||||||
State changes never start, extend, or upgrade a segment. A change seen
|
|
||||||
with no active segment is held briefly for a segment starting right
|
|
||||||
after it.
|
|
||||||
"""
|
|
||||||
segment = self.active_review_segments.get(camera)
|
|
||||||
|
|
||||||
if segment is None:
|
|
||||||
cutoff = change["timestamp"] - CLASSIFICATION_STATE_PRE_ROLL
|
|
||||||
self.recent_classification_state_changes[camera] = [
|
|
||||||
c
|
|
||||||
for c in self.recent_classification_state_changes.get(camera, [])
|
|
||||||
if c["timestamp"] >= cutoff
|
|
||||||
] + [change]
|
|
||||||
return
|
|
||||||
|
|
||||||
prev_data = segment.get_data(False)
|
|
||||||
segment.classification_state_changes.append(change)
|
|
||||||
self._publish_segment_update(
|
|
||||||
segment, self.config.cameras[camera], None, [], prev_data
|
|
||||||
)
|
|
||||||
|
|
||||||
def forcibly_end_segment(self, camera: str) -> Any:
|
def forcibly_end_segment(self, camera: str) -> Any:
|
||||||
"""Forcibly end the pending segment for a camera."""
|
"""Forcibly end the pending segment for a camera."""
|
||||||
segment = self.active_review_segments.get(camera)
|
segment = self.active_review_segments.get(camera)
|
||||||
@@ -468,7 +422,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
|
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
|
||||||
self.forcibly_end_segment(camera)
|
self.forcibly_end_segment(camera)
|
||||||
self.indefinite_events.pop(camera, None)
|
self.indefinite_events.pop(camera, None)
|
||||||
self.recent_classification_state_changes.pop(camera, None)
|
|
||||||
|
|
||||||
def update_existing_segment(
|
def update_existing_segment(
|
||||||
self,
|
self,
|
||||||
@@ -626,7 +579,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
audio=set(),
|
audio=set(),
|
||||||
zones=list(new_zones),
|
zones=list(new_zones),
|
||||||
)
|
)
|
||||||
self._activate_segment(new_segment)
|
self.active_review_segments[segment.camera] = new_segment
|
||||||
self._publish_segment_start(new_segment)
|
self._publish_segment_start(new_segment)
|
||||||
new_segment.last_detection_time = last_detection_time
|
new_segment.last_detection_time = last_detection_time
|
||||||
elif segment.severity == SeverityEnum.detection and frame_time > (
|
elif segment.severity == SeverityEnum.detection and frame_time > (
|
||||||
@@ -686,7 +639,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
audio=set(),
|
audio=set(),
|
||||||
zones=zones,
|
zones=zones,
|
||||||
)
|
)
|
||||||
self._activate_segment(new_segment)
|
self.active_review_segments[camera] = new_segment
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yuv_frame = self.frame_manager.get(
|
yuv_frame = self.frame_manager.get(
|
||||||
@@ -760,10 +713,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
|
|
||||||
if camera not in self.indefinite_events:
|
if camera not in self.indefinite_events:
|
||||||
self.indefinite_events[camera] = {}
|
self.indefinite_events[camera] = {}
|
||||||
elif topic == DetectionTypeEnum.classification_state.value:
|
|
||||||
(camera, classification_change) = data
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if camera not in self.config.cameras:
|
if camera not in self.config.cameras:
|
||||||
continue
|
continue
|
||||||
@@ -774,10 +723,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if topic == DetectionTypeEnum.classification_state:
|
|
||||||
self.handle_classification_state_change(camera, classification_change)
|
|
||||||
continue
|
|
||||||
|
|
||||||
current_segment = self.active_review_segments.get(camera)
|
current_segment = self.active_review_segments.get(camera)
|
||||||
|
|
||||||
# Check if the current segment should be processed based on enabled settings
|
# Check if the current segment should be processed based on enabled settings
|
||||||
@@ -919,16 +864,14 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
severity = SeverityEnum.detection
|
severity = SeverityEnum.detection
|
||||||
|
|
||||||
if severity:
|
if severity:
|
||||||
self._activate_segment(
|
self.active_review_segments[camera] = PendingReviewSegment(
|
||||||
PendingReviewSegment(
|
camera,
|
||||||
camera,
|
frame_time,
|
||||||
frame_time,
|
severity,
|
||||||
severity,
|
{},
|
||||||
{},
|
{},
|
||||||
{},
|
[],
|
||||||
[],
|
detections,
|
||||||
detections,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
elif topic == DetectionTypeEnum.api:
|
elif topic == DetectionTypeEnum.api:
|
||||||
severity = self.get_manual_event_severity(
|
severity = self.get_manual_event_severity(
|
||||||
@@ -945,7 +888,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
[],
|
[],
|
||||||
set(),
|
set(),
|
||||||
)
|
)
|
||||||
self._activate_segment(api_segment)
|
self.active_review_segments[camera] = api_segment
|
||||||
|
|
||||||
if manual_info["state"] == ManualEventState.start:
|
if manual_info["state"] == ManualEventState.start:
|
||||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||||
@@ -972,7 +915,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
[],
|
[],
|
||||||
set(),
|
set(),
|
||||||
)
|
)
|
||||||
self._activate_segment(lpr_segment)
|
self.active_review_segments[camera] = lpr_segment
|
||||||
|
|
||||||
if manual_info["state"] == ManualEventState.start:
|
if manual_info["state"] == ManualEventState.start:
|
||||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||||
|
|||||||
@@ -256,17 +256,6 @@ class HardwareStats:
|
|||||||
)
|
)
|
||||||
self.update_config()
|
self.update_config()
|
||||||
|
|
||||||
def set_config(self, config: FrigateConfig) -> None:
|
|
||||||
"""Follow a runtime config swap and recalculate the monitored hardware.
|
|
||||||
|
|
||||||
The camera update subscriber has to follow too, or later camera updates
|
|
||||||
would land on the discarded config.
|
|
||||||
"""
|
|
||||||
self.config = config
|
|
||||||
self._config_subscriber.config = config
|
|
||||||
self._config_subscriber.camera_configs = config.cameras
|
|
||||||
self.update_config()
|
|
||||||
|
|
||||||
def update_config(self) -> None:
|
def update_config(self) -> None:
|
||||||
"""Recalculate all hardware that needs to be monitored from the config."""
|
"""Recalculate all hardware that needs to be monitored from the config."""
|
||||||
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
||||||
|
|||||||
@@ -111,18 +111,15 @@ def get_detector_stats(
|
|||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
"""Get stats for all detectors, including temperatures based on detector type."""
|
"""Get stats for all detectors, including temperatures based on detector type."""
|
||||||
detector_stats: dict[str, dict[str, Any]] = {}
|
detector_stats: dict[str, dict[str, Any]] = {}
|
||||||
# detector type -> device -> index into that type's temperatures
|
detector_type_indices: dict[str, int] = {}
|
||||||
device_indices: dict[str, dict[str, int]] = {}
|
|
||||||
|
|
||||||
for name, detector in stats_tracking["detectors"].items():
|
for name, detector in stats_tracking["detectors"].items():
|
||||||
pid = detector.detect_process.pid if detector.detect_process else None
|
pid = detector.detect_process.pid if detector.detect_process else None
|
||||||
detector_type = detector.detector_config.type
|
detector_type = detector.detector_config.type
|
||||||
|
|
||||||
# temperatures are per physical unit, so a repeated device
|
# Keep track of the index for each detector type to match temperatures correctly
|
||||||
# ("hailo:PCIe#2", see runner_names) shares its unit's reading
|
current_index = detector_type_indices.get(detector_type, 0)
|
||||||
device = name.partition("#")[0]
|
detector_type_indices[detector_type] = current_index + 1
|
||||||
type_devices = device_indices.setdefault(detector_type, {})
|
|
||||||
current_index = type_devices.setdefault(device, len(type_devices))
|
|
||||||
|
|
||||||
detector_stat = {
|
detector_stat = {
|
||||||
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import frigate.genai
|
import frigate.genai
|
||||||
from frigate.config import GenAIProviderEnum
|
from frigate.config import GenAIProviderEnum
|
||||||
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||||
from frigate.genai import GenAIClient
|
from frigate.genai import GenAIClient
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.stats.emitter import StatsEmitter
|
from frigate.stats.emitter import StatsEmitter
|
||||||
@@ -92,44 +90,6 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
mqtt = response.json()["mqtt"]
|
mqtt = response.json()["mqtt"]
|
||||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||||
|
|
||||||
def test_config_response_keeps_plus_model_reference(self):
|
|
||||||
model_id = "test_plus_reference"
|
|
||||||
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
|
||||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
with open(model_path, "w") as f:
|
|
||||||
f.write("model")
|
|
||||||
|
|
||||||
with open(f"{model_path}.json", "w") as f:
|
|
||||||
json.dump(
|
|
||||||
{
|
|
||||||
"id": model_id,
|
|
||||||
"type": "ssd",
|
|
||||||
"supportedDetectors": ["cpu"],
|
|
||||||
"width": 320,
|
|
||||||
"height": 320,
|
|
||||||
"inputShape": "nhwc",
|
|
||||||
"pixelFormat": "rgb",
|
|
||||||
"labelMap": {"0": "person"},
|
|
||||||
},
|
|
||||||
f,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.addCleanup(os.remove, model_path)
|
|
||||||
self.addCleanup(os.remove, f"{model_path}.json")
|
|
||||||
self.minimal_config["models"] = [
|
|
||||||
{"path": f"plus://{model_id}", "devices": ["cpu"]}
|
|
||||||
]
|
|
||||||
app = super().create_app()
|
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
response = client.get("/config")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["models"][0]["path"] == f"plus://{model_id}"
|
|
||||||
|
|
||||||
# detection still loads the resolved cache file
|
|
||||||
assert app.frigate_config.models[0].path == model_path
|
|
||||||
|
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
################################### POST /genai/probe Endpoint ##################################################
|
################################### POST /genai/probe Endpoint ##################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for config_set live stream ordering and go2rtc transcode sync."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import ruamel.yaml
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
from frigate.util.live_streams import transcode_stream_source
|
||||||
|
|
||||||
|
STREAMS_PATH = "cameras.front_door.live.streams"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSetLiveStreams(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||||
|
self.minimal_config["go2rtc"] = {
|
||||||
|
"streams": {
|
||||||
|
"front_main": ["rtsp://10.0.0.1:554/main"],
|
||||||
|
"front_sub": ["rtsp://10.0.0.1:554/sub"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.minimal_config["cameras"]["front_door"]["live"] = {
|
||||||
|
"streams": {"Main": "front_main", "Sub": "front_sub"}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _write_config_file(self) -> str:
|
||||||
|
yaml = ruamel.yaml.YAML()
|
||||||
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||||
|
yaml.dump(self.minimal_config, f)
|
||||||
|
f.close()
|
||||||
|
self.addCleanup(os.unlink, f.name)
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
def _read_config_file(self, path: str) -> dict:
|
||||||
|
with open(path) as f:
|
||||||
|
return ruamel.yaml.YAML(typ="safe").load(f)
|
||||||
|
|
||||||
|
def _app(self):
|
||||||
|
publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||||
|
publisher.publisher = MagicMock()
|
||||||
|
app = create_fastapi_app(
|
||||||
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
publisher,
|
||||||
|
None,
|
||||||
|
enforce_default_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_get_current_user(request: Request):
|
||||||
|
return {
|
||||||
|
"username": request.headers.get("remote-user"),
|
||||||
|
"role": request.headers.get("remote-role"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||||
|
return ["front_door"]
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||||
|
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||||
|
mock_get_allowed_cameras_for_filter
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
def _save(self, app, live: dict, replace_paths: list[str] | None = None):
|
||||||
|
body = {
|
||||||
|
"config_data": {"cameras": {"front_door": {"live": live}}},
|
||||||
|
"requires_restart": 0,
|
||||||
|
"update_topic": "config/cameras/front_door/live",
|
||||||
|
}
|
||||||
|
|
||||||
|
if replace_paths is not None:
|
||||||
|
body["replace_paths"] = replace_paths
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
return client.put("/config/set", json=body)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_replace_paths_saves_map_in_sent_order(self, mock_find_config):
|
||||||
|
path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = path
|
||||||
|
|
||||||
|
resp = self._save(
|
||||||
|
self._app(),
|
||||||
|
{"streams": {"Sub": "front_sub", "Main": "front_main"}},
|
||||||
|
[STREAMS_PATH],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
|
||||||
|
"streams"
|
||||||
|
]
|
||||||
|
self.assertEqual(list(streams), ["Sub", "Main"])
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_without_replace_paths_order_is_kept(self, mock_find_config):
|
||||||
|
path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = path
|
||||||
|
|
||||||
|
self._save(self._app(), {"streams": {"Sub": "front_sub", "Main": "front_main"}})
|
||||||
|
|
||||||
|
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
|
||||||
|
"streams"
|
||||||
|
]
|
||||||
|
self.assertEqual(list(streams), ["Main", "Sub"])
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_replace_path_missing_from_yaml_is_skipped(self, mock_find_config):
|
||||||
|
del self.minimal_config["cameras"]["front_door"]["live"]
|
||||||
|
path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = path
|
||||||
|
|
||||||
|
resp = self._save(
|
||||||
|
self._app(), {"streams": {"Main": "front_main"}}, [STREAMS_PATH]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
self._read_config_file(path)["cameras"]["front_door"]["live"]["streams"],
|
||||||
|
{"Main": "front_main"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.sync_transcode_streams", return_value=True)
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_enabling_transcode_syncs_go2rtc(self, mock_find_config, mock_sync):
|
||||||
|
path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = path
|
||||||
|
|
||||||
|
resp = self._save(
|
||||||
|
self._app(),
|
||||||
|
{
|
||||||
|
"transcode": {
|
||||||
|
"enabled": True,
|
||||||
|
"qualities": [{"height": 480, "bitrate": 500}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertTrue(resp.json()["go2rtc_synced"])
|
||||||
|
mock_sync.assert_called_once_with(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
"front_door_transcode_480p": transcode_stream_source(
|
||||||
|
"front_main", 480, 500
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.sync_transcode_streams", return_value=False)
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_sync_failure_is_reported(self, mock_find_config, mock_sync):
|
||||||
|
path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = path
|
||||||
|
|
||||||
|
resp = self._save(self._app(), {"transcode": {"enabled": True}})
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertTrue(resp.json()["success"])
|
||||||
|
self.assertFalse(resp.json()["go2rtc_synced"])
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Tests for the go2rtc stream bitrate endpoint."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpGo2rtcBitrate(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([])
|
||||||
|
self.minimal_config["go2rtc"] = {
|
||||||
|
"streams": {"front_main": ["rtsp://10.0.0.1:554/main"]}
|
||||||
|
}
|
||||||
|
self.minimal_config["cameras"]["front_door"]["live"] = {
|
||||||
|
"streams": {"Main": "front_main"},
|
||||||
|
"transcode": {
|
||||||
|
"enabled": True,
|
||||||
|
"qualities": [{"height": 480, "bitrate": 500}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.app = self.create_app()
|
||||||
|
|
||||||
|
@patch("frigate.api.camera.measure_stream_bitrate", return_value=812.4)
|
||||||
|
def test_returns_rounded_kbps(self, mock_measure):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
resp = client.get("/go2rtc/streams/front_main/bitrate")
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.json()["kbps"], 812)
|
||||||
|
mock_measure.assert_called_once_with("front_main")
|
||||||
|
|
||||||
|
@patch("frigate.api.camera.measure_stream_bitrate", return_value=480.0)
|
||||||
|
def test_generated_streams_are_known(self, _):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
resp = client.get("/go2rtc/streams/front_door_transcode_480p/bitrate")
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
@patch("frigate.api.camera.measure_stream_bitrate")
|
||||||
|
def test_unknown_stream_is_404(self, mock_measure):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
resp = client.get("/go2rtc/streams/rtsp_somewhere/bitrate")
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
mock_measure.assert_not_called()
|
||||||
|
|
||||||
|
@patch("frigate.api.camera.measure_stream_bitrate", return_value=None)
|
||||||
|
def test_no_data_is_502(self, _):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
resp = client.get("/go2rtc/streams/front_main/bitrate")
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 502)
|
||||||
|
|
||||||
|
def test_viewer_is_forbidden(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
resp = client.get(
|
||||||
|
"/go2rtc/streams/front_main/bitrate",
|
||||||
|
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 403)
|
||||||
@@ -179,16 +179,12 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
|
|||||||
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
|
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
|
||||||
maintainer.active_review_segments = {"deleted_cam": MagicMock()}
|
maintainer.active_review_segments = {"deleted_cam": MagicMock()}
|
||||||
maintainer.indefinite_events = {"deleted_cam": {"1234.5-abcdef": 1.0}}
|
maintainer.indefinite_events = {"deleted_cam": {"1234.5-abcdef": 1.0}}
|
||||||
maintainer.recent_classification_state_changes = {
|
|
||||||
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
|
|
||||||
}
|
|
||||||
maintainer.forcibly_end_segment = MagicMock()
|
maintainer.forcibly_end_segment = MagicMock()
|
||||||
|
|
||||||
maintainer._handle_camera_removed("deleted_cam")
|
maintainer._handle_camera_removed("deleted_cam")
|
||||||
|
|
||||||
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
|
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
|
||||||
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
|
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
|
||||||
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutotrackerMoveQueue(unittest.TestCase):
|
class TestAutotrackerMoveQueue(unittest.TestCase):
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
|||||||
app.genai_manager.update_config.assert_called_once_with(config)
|
app.genai_manager.update_config.assert_called_once_with(config)
|
||||||
app.profile_manager.update_config.assert_called_once_with(config)
|
app.profile_manager.update_config.assert_called_once_with(config)
|
||||||
self.assertIs(app.stats_emitter.config, config)
|
self.assertIs(app.stats_emitter.config, config)
|
||||||
app.stats_emitter.hardware_stats.set_config.assert_called_once_with(config)
|
|
||||||
self.assertIs(app.dispatcher.config, config)
|
self.assertIs(app.dispatcher.config, config)
|
||||||
for comm in app.dispatcher.comms:
|
for comm in app.dispatcher.comms:
|
||||||
self.assertIs(comm.config, config)
|
self.assertIs(comm.config, config)
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"""Tests for per-detector stats."""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from frigate.stats.util import get_detector_stats
|
|
||||||
|
|
||||||
|
|
||||||
def _detector(detector_type: str) -> MagicMock:
|
|
||||||
detector = MagicMock()
|
|
||||||
detector.detector_config.type = detector_type
|
|
||||||
detector.avg_inference_speed.value = 0.01
|
|
||||||
detector.detection_start.value = 0.0
|
|
||||||
detector.detect_process.pid = 1
|
|
||||||
return detector
|
|
||||||
|
|
||||||
|
|
||||||
class TestDetectorTemperatures(unittest.TestCase):
|
|
||||||
def test_repeated_device_shares_its_unit_temperature(self):
|
|
||||||
stats_tracking = {
|
|
||||||
"detectors": {
|
|
||||||
"hailo:PCIe": _detector("hailo8l"),
|
|
||||||
"hailo:PCIe#2": _detector("hailo8l"),
|
|
||||||
"hailo:PCIe:1": _detector("hailo8l"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"frigate.stats.util.get_hardware_temperatures", return_value=[50.0, 60.0]
|
|
||||||
):
|
|
||||||
stats = get_detector_stats(stats_tracking)
|
|
||||||
|
|
||||||
self.assertEqual(stats["hailo:PCIe"]["temperature"], 50.0)
|
|
||||||
self.assertEqual(stats["hailo:PCIe#2"]["temperature"], 50.0)
|
|
||||||
self.assertEqual(stats["hailo:PCIe:1"]["temperature"], 60.0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -543,56 +543,6 @@ class TestPinnedStream(unittest.TestCase):
|
|||||||
self.assertFalse(any("/vod/front/main/" in token for token in cmd))
|
self.assertFalse(any("/vod/front/main/" in token for token in cmd))
|
||||||
|
|
||||||
|
|
||||||
class TestExportTimelineAlignment(unittest.TestCase):
|
|
||||||
def test_unstaged_auto_reads_the_stream_it_serves(self) -> None:
|
|
||||||
# a sub-only range whose main rows are glitches the manifest drops
|
|
||||||
exporter = _make_exporter([_span("/s1.mp4", 1_000, 1_040, False)], {"h264"})
|
|
||||||
streams: list[str] = []
|
|
||||||
|
|
||||||
def rows(stream: str) -> list:
|
|
||||||
streams.append(stream)
|
|
||||||
return [_FakeRow(f"/{stream}.mp4")]
|
|
||||||
|
|
||||||
exporter._get_recordings_for_range = rows # type: ignore[method-assign]
|
|
||||||
exporter.get_record_export_command("/exports/out.mp4")
|
|
||||||
|
|
||||||
self.assertEqual(streams, ["sub"])
|
|
||||||
|
|
||||||
def test_staged_chapters_carry_keyframe_lead_in(self) -> None:
|
|
||||||
exporter = _make_exporter(
|
|
||||||
[
|
|
||||||
_span("/m1.mp4", 1_000, 1_020, True),
|
|
||||||
_span("/s1.mp4", 1_020, 1_040, False),
|
|
||||||
],
|
|
||||||
{"h264"},
|
|
||||||
)
|
|
||||||
exporter.config.ui.timezone = None
|
|
||||||
|
|
||||||
# each run's vod clip snaps 1.5s back to a keyframe
|
|
||||||
def timeline(_intervals: list, stream: str) -> list[dict]:
|
|
||||||
start, end = (1_000, 1_020) if stream == "main" else (1_020, 1_040)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"start_time": start,
|
|
||||||
"end_time": end,
|
|
||||||
"duration": (end - start + 1.5) * 1000,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("frigate.record.export.resolve_coverage", return_value=[]),
|
|
||||||
patch("frigate.record.export.realized_timeline", side_effect=timeline),
|
|
||||||
):
|
|
||||||
windows = exporter._staged_chapter_windows()
|
|
||||||
|
|
||||||
path = exporter._build_recording_segment_chapter_metadata_file(windows)
|
|
||||||
self.addCleanup(os.remove, path)
|
|
||||||
content = Path(path).read_text()
|
|
||||||
|
|
||||||
self.assertIn("START=0\nEND=21500", content)
|
|
||||||
self.assertIn("START=21500\nEND=43000", content)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStagedFileCleanup(unittest.TestCase):
|
class TestStagedFileCleanup(unittest.TestCase):
|
||||||
"""A staged path must be tracked before ffmpeg can write to it."""
|
"""A staged path must be tracked before ffmpeg can write to it."""
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from frigate.genai.prompts import (
|
|||||||
REVIEW_DESCRIPTION_FIELD_GUIDELINES,
|
REVIEW_DESCRIPTION_FIELD_GUIDELINES,
|
||||||
REVIEW_RESPONSE_STYLES,
|
REVIEW_RESPONSE_STYLES,
|
||||||
build_review_description_prompt,
|
build_review_description_prompt,
|
||||||
build_review_summary_prompt,
|
|
||||||
get_review_field_guidelines,
|
get_review_field_guidelines,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,49 +83,5 @@ class TestReviewResponseStyle(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestClassificationStateChanges(unittest.TestCase):
|
|
||||||
def _build_prompt(self, **extra) -> str:
|
|
||||||
review_data = {
|
|
||||||
"camera": "Front Door",
|
|
||||||
"start": "Monday, 09:30 AM",
|
|
||||||
"duration": 25,
|
|
||||||
"zones": [],
|
|
||||||
"unified_objects": ["person"],
|
|
||||||
**extra,
|
|
||||||
}
|
|
||||||
return build_review_description_prompt(
|
|
||||||
review_data, [b"fake-image"], [], None, "activity context"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_no_changes_leaves_prompt_unchanged(self):
|
|
||||||
self.assertEqual(
|
|
||||||
self._build_prompt(classification_state_changes=[]),
|
|
||||||
self._build_prompt(),
|
|
||||||
)
|
|
||||||
self.assertNotIn("## State Changes", self._build_prompt())
|
|
||||||
|
|
||||||
def test_changes_are_listed_before_objects(self):
|
|
||||||
change = "front gate changed from closed to open, 12s into the activity"
|
|
||||||
prompt = self._build_prompt(classification_state_changes=[change])
|
|
||||||
self.assertIn(f"\n- {change}\n\n## Objects in Scene", prompt)
|
|
||||||
self.assertLess(
|
|
||||||
prompt.index("## Sequence Details"), prompt.index("## State Changes")
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_summary_describes_state_changes_only_when_present(self):
|
|
||||||
event = {"title": "Person at door", "camera": "Front Door", "context": []}
|
|
||||||
without = build_review_summary_prompt(0, 3600, [event], None)
|
|
||||||
self.assertNotIn('"state_changes"', without)
|
|
||||||
|
|
||||||
context_event = {
|
|
||||||
**event,
|
|
||||||
"context": [
|
|
||||||
{"camera": "Driveway", "state_changes": ["gate changed from a to b"]}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
with_changes = build_review_summary_prompt(0, 3600, [context_event], None)
|
|
||||||
self.assertIn('- "state_changes"', with_changes)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import unittest
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from frigate.config import GenAIConfig, GenAIProviderEnum
|
from frigate.config import GenAIConfig, GenAIProviderEnum
|
||||||
from frigate.genai import PROVIDERS, load_providers
|
from frigate.genai import PROVIDERS, load_providers
|
||||||
|
|
||||||
@@ -568,6 +566,7 @@ class TestLlamaCppProvider(unittest.TestCase):
|
|||||||
"supports_audio": False,
|
"supports_audio": False,
|
||||||
"supports_tools": False,
|
"supports_tools": False,
|
||||||
"supports_reasoning": False,
|
"supports_reasoning": False,
|
||||||
|
"media_marker": "<__media__>",
|
||||||
}
|
}
|
||||||
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
||||||
with patch.object(cls, "_get_model_info", return_value=info):
|
with patch.object(cls, "_get_model_info", return_value=info):
|
||||||
@@ -590,62 +589,6 @@ class TestLlamaCppProvider(unittest.TestCase):
|
|||||||
with patch.object(client, "_fetch_models_data", return_value=models_data):
|
with patch.object(client, "_fetch_models_data", return_value=models_data):
|
||||||
self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"])
|
self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"])
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _embeddings_response(vectors):
|
|
||||||
response = MagicMock()
|
|
||||||
response.status_code = 200
|
|
||||||
response.json.return_value = {
|
|
||||||
"object": "list",
|
|
||||||
"data": [
|
|
||||||
{"object": "embedding", "index": i, "embedding": v}
|
|
||||||
for i, v in enumerate(vectors)
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
|
|
||||||
def test_embed_posts_content_arrays_to_v1_embeddings(self):
|
|
||||||
client = self._client()
|
|
||||||
response = self._embeddings_response([[0.1] * 768, [0.2] * 768])
|
|
||||||
|
|
||||||
with patch.object(client, "_post", return_value=response) as post:
|
|
||||||
result = client.embed(texts=["a person"], images=[b"not an image"])
|
|
||||||
|
|
||||||
url = post.call_args.args[0]
|
|
||||||
payload = post.call_args.kwargs["json"]
|
|
||||||
self.assertEqual(url, "http://localhost:9999/v1/embeddings")
|
|
||||||
self.assertEqual(payload["model"], "m")
|
|
||||||
self.assertEqual(payload["encoding_format"], "float")
|
|
||||||
self.assertEqual(
|
|
||||||
payload["input"][0], {"content": [{"type": "text", "text": "a person"}]}
|
|
||||||
)
|
|
||||||
image_parts = payload["input"][1]["content"]
|
|
||||||
self.assertEqual(image_parts[0]["type"], "image_url")
|
|
||||||
self.assertEqual(
|
|
||||||
image_parts[0]["image_url"]["url"],
|
|
||||||
"data:image/jpeg;base64," + base64.b64encode(b"not an image").decode(),
|
|
||||||
)
|
|
||||||
self.assertEqual(image_parts[1], {"type": "text", "text": "\n"})
|
|
||||||
self.assertEqual(len(result), 2)
|
|
||||||
self.assertAlmostEqual(float(result[1][0]), 0.2, places=5)
|
|
||||||
|
|
||||||
def test_embed_normalizes_dimension(self):
|
|
||||||
client = self._client()
|
|
||||||
response = self._embeddings_response([[1.0] * 1024, [1.0] * 512])
|
|
||||||
|
|
||||||
with patch.object(client, "_post", return_value=response):
|
|
||||||
result = client.embed(texts=["long", "short"])
|
|
||||||
|
|
||||||
self.assertEqual([r.shape for r in result], [(768,), (768,)])
|
|
||||||
self.assertEqual(float(result[1][-1]), 0.0)
|
|
||||||
|
|
||||||
def test_embed_request_error_returns_empty(self):
|
|
||||||
client = self._client()
|
|
||||||
response = MagicMock()
|
|
||||||
response.raise_for_status.side_effect = requests.exceptions.HTTPError("400")
|
|
||||||
|
|
||||||
with patch.object(client, "_post", return_value=response):
|
|
||||||
self.assertEqual(client.embed(texts=["a"]), [])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# transcribe role
|
# transcribe role
|
||||||
@@ -819,6 +762,7 @@ class TestLlamaCppTranscribe(unittest.TestCase):
|
|||||||
"supports_audio": supports_audio,
|
"supports_audio": supports_audio,
|
||||||
"supports_tools": False,
|
"supports_tools": False,
|
||||||
"supports_reasoning": False,
|
"supports_reasoning": False,
|
||||||
|
"media_marker": "<__media__>",
|
||||||
}
|
}
|
||||||
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
||||||
with patch.object(cls, "_get_model_info", return_value=info):
|
with patch.object(cls, "_get_model_info", return_value=info):
|
||||||
|
|||||||
@@ -288,17 +288,6 @@ class TestUpdateConfig(HardwareStatsTestCase):
|
|||||||
|
|
||||||
self.assertEqual(set(stats._monitored), {"rockchip"})
|
self.assertEqual(set(stats._monitored), {"rockchip"})
|
||||||
|
|
||||||
def test_follows_a_runtime_config_swap(self):
|
|
||||||
stats = self.make_stats(self.make_config())
|
|
||||||
self.assertEqual(set(stats._monitored), set())
|
|
||||||
|
|
||||||
swapped = self.make_config("preset-rk-h264")
|
|
||||||
stats.set_config(swapped)
|
|
||||||
|
|
||||||
self.assertEqual(set(stats._monitored), {"rockchip"})
|
|
||||||
self.assertIs(self.subscriber.return_value.config, swapped)
|
|
||||||
self.assertIs(self.subscriber.return_value.camera_configs, swapped.cameras)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateStats(HardwareStatsTestCase):
|
class TestUpdateStats(HardwareStatsTestCase):
|
||||||
def run_stats(self, stats: HardwareStats) -> dict:
|
def run_stats(self, stats: HardwareStats) -> dict:
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Tests for Frigate-managed go2rtc live streams."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.util.live_streams import (
|
||||||
|
DEFAULT_TRANSCODE_QUALITIES,
|
||||||
|
default_transcode_source,
|
||||||
|
generated_transcode_streams,
|
||||||
|
is_transcode_stream_name,
|
||||||
|
measure_stream_bitrate,
|
||||||
|
raw_transcode_streams,
|
||||||
|
sync_transcode_streams,
|
||||||
|
transcode_stream_name,
|
||||||
|
transcode_stream_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranscodeNaming(unittest.TestCase):
|
||||||
|
def test_stream_name(self):
|
||||||
|
self.assertEqual(transcode_stream_name("front", 720), "front_transcode_720p")
|
||||||
|
|
||||||
|
def test_is_transcode_stream_name(self):
|
||||||
|
self.assertTrue(is_transcode_stream_name("front", "front_transcode_480p"))
|
||||||
|
self.assertFalse(is_transcode_stream_name("front", "front_sub"))
|
||||||
|
self.assertFalse(is_transcode_stream_name("front", "back_transcode_480p"))
|
||||||
|
self.assertFalse(is_transcode_stream_name("front", "front_transcode_480"))
|
||||||
|
|
||||||
|
def test_source_gives_each_ffmpeg_token_its_own_raw_param(self):
|
||||||
|
self.assertEqual(
|
||||||
|
transcode_stream_source("front", 480, 500),
|
||||||
|
"ffmpeg:front#video=h264#height=480#hardware#audio=copy"
|
||||||
|
"#raw=-b:v#raw=500k#raw=-maxrate#raw=500k#raw=-bufsize#raw=1000k",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_source_skips_transcoded_streams(self):
|
||||||
|
streams = {"Low": "front_transcode_360p", "Main": "front_main"}
|
||||||
|
self.assertEqual(default_transcode_source("front", streams), "front_main")
|
||||||
|
self.assertIsNone(default_transcode_source("front", {}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRawTranscodeStreams(unittest.TestCase):
|
||||||
|
def test_defaults_to_camera_stream_and_default_qualities(self):
|
||||||
|
config = {"cameras": {"front": {"live": {"transcode": {"enabled": True}}}}}
|
||||||
|
streams = raw_transcode_streams(config)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
list(streams),
|
||||||
|
[
|
||||||
|
transcode_stream_name("front", q["height"])
|
||||||
|
for q in DEFAULT_TRANSCODE_QUALITIES
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(streams["front_transcode_720p"].startswith("ffmpeg:front#"))
|
||||||
|
|
||||||
|
def test_uses_first_live_stream_and_explicit_qualities(self):
|
||||||
|
config = {
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"live": {
|
||||||
|
"streams": {"Main": "front_main", "Sub": "front_sub"},
|
||||||
|
"transcode": {
|
||||||
|
"enabled": True,
|
||||||
|
"qualities": [{"height": 540, "bitrate": 800}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
raw_transcode_streams(config),
|
||||||
|
{"front_transcode_540p": transcode_stream_source("front_main", 540, 800)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_source_wins(self):
|
||||||
|
config = {
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"live": {
|
||||||
|
"streams": {"Main": "front_main"},
|
||||||
|
"transcode": {
|
||||||
|
"enabled": True,
|
||||||
|
"source": "front_sub",
|
||||||
|
"qualities": [{"height": 360, "bitrate": 250}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
raw_transcode_streams(config)["front_transcode_360p"],
|
||||||
|
transcode_stream_source("front_sub", 360, 250),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_qualities_are_skipped(self):
|
||||||
|
config = {
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"live": {
|
||||||
|
"transcode": {
|
||||||
|
"enabled": True,
|
||||||
|
"qualities": [
|
||||||
|
{"height": 720},
|
||||||
|
"480",
|
||||||
|
{"height": 360, "bitrate": 250},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"live": {"transcode": {"enabled": True, "qualities": {"a": 1}}}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(list(raw_transcode_streams(config)), ["front_transcode_360p"])
|
||||||
|
|
||||||
|
def test_disabled_or_missing_generates_nothing(self):
|
||||||
|
self.assertEqual(raw_transcode_streams({}), {})
|
||||||
|
self.assertEqual(
|
||||||
|
raw_transcode_streams({"cameras": {"front": {"live": {}}}}), {}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
raw_transcode_streams(
|
||||||
|
{"cameras": {"front": {"live": {"transcode": {"enabled": False}}}}}
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def camera_config(live: dict | None = None, go2rtc: dict | None = None) -> dict:
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"go2rtc": {
|
||||||
|
"streams": go2rtc
|
||||||
|
or {
|
||||||
|
"front": ["rtsp://10.0.0.1:554/main"],
|
||||||
|
"front_sub": ["rtsp://10.0.0.1:554/sub"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/main", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if live is not None:
|
||||||
|
config["cameras"]["front"]["live"] = live
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveTranscodeConfig(unittest.TestCase):
|
||||||
|
def streams(self, live: dict, go2rtc: dict | None = None) -> dict[str, str]:
|
||||||
|
config = FrigateConfig(**camera_config(live, go2rtc))
|
||||||
|
return config.cameras["front"].live.streams
|
||||||
|
|
||||||
|
def test_global_transcode_is_ignored(self):
|
||||||
|
config = camera_config()
|
||||||
|
config["live"] = {"transcode": {"enabled": True}}
|
||||||
|
|
||||||
|
streams = FrigateConfig(**config).cameras["front"].live.streams
|
||||||
|
self.assertEqual(streams, {"front": "front"})
|
||||||
|
|
||||||
|
def test_disabled_leaves_streams_alone(self):
|
||||||
|
self.assertEqual(self.streams({}), {"front": "front"})
|
||||||
|
|
||||||
|
def test_enabled_appends_default_qualities(self):
|
||||||
|
streams = self.streams({"transcode": {"enabled": True}})
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
list(streams.items()),
|
||||||
|
[
|
||||||
|
("front", "front"),
|
||||||
|
("720p", "front_transcode_720p"),
|
||||||
|
("480p", "front_transcode_480p"),
|
||||||
|
("360p", "front_transcode_360p"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_resolves_to_first_live_stream(self):
|
||||||
|
config = FrigateConfig(
|
||||||
|
**camera_config(
|
||||||
|
{
|
||||||
|
"streams": {"Sub": "front_sub", "Main": "front"},
|
||||||
|
"transcode": {"enabled": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(config.cameras["front"].live.transcode.source, "front_sub")
|
||||||
|
|
||||||
|
def test_placed_entries_keep_position_and_label(self):
|
||||||
|
streams = self.streams(
|
||||||
|
{
|
||||||
|
"streams": {
|
||||||
|
"Main": "front",
|
||||||
|
"Low": "front_transcode_480p",
|
||||||
|
"Sub": "front_sub",
|
||||||
|
},
|
||||||
|
"transcode": {"enabled": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
list(streams.items()),
|
||||||
|
[
|
||||||
|
("Main", "front"),
|
||||||
|
("Low", "front_transcode_480p"),
|
||||||
|
("Sub", "front_sub"),
|
||||||
|
("720p", "front_transcode_720p"),
|
||||||
|
("360p", "front_transcode_360p"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_disabling_drops_transcoded_entries(self):
|
||||||
|
streams = self.streams(
|
||||||
|
{
|
||||||
|
"streams": {"Main": "front", "720p": "front_transcode_720p"},
|
||||||
|
"transcode": {"enabled": False},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(streams, {"Main": "front"})
|
||||||
|
|
||||||
|
def test_changed_height_drops_the_old_entry(self):
|
||||||
|
streams = self.streams(
|
||||||
|
{
|
||||||
|
"streams": {"Main": "front", "720p": "front_transcode_720p"},
|
||||||
|
"transcode": {
|
||||||
|
"enabled": True,
|
||||||
|
"qualities": [{"height": 540, "bitrate": 800}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(streams, {"Main": "front", "540p": "front_transcode_540p"})
|
||||||
|
|
||||||
|
def test_real_go2rtc_stream_with_transcode_name_is_kept(self):
|
||||||
|
streams = self.streams(
|
||||||
|
{"streams": {"Main": "front", "Odd": "front_transcode_720p"}},
|
||||||
|
go2rtc={
|
||||||
|
"front": ["rtsp://10.0.0.1:554/main"],
|
||||||
|
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(streams, {"Main": "front", "Odd": "front_transcode_720p"})
|
||||||
|
|
||||||
|
def test_generated_name_colliding_with_go2rtc_stream_fails(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.streams(
|
||||||
|
{"transcode": {"enabled": True}},
|
||||||
|
go2rtc={
|
||||||
|
"front": ["rtsp://10.0.0.1:554/main"],
|
||||||
|
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_must_be_a_go2rtc_stream(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.streams({"transcode": {"enabled": True, "source": "missing"}})
|
||||||
|
|
||||||
|
def test_duplicate_heights_fail(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.streams(
|
||||||
|
{
|
||||||
|
"transcode": {
|
||||||
|
"enabled": True,
|
||||||
|
"qualities": [
|
||||||
|
{"height": 480, "bitrate": 500},
|
||||||
|
{"height": 480, "bitrate": 600},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_label_collision_fails(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.streams(
|
||||||
|
{
|
||||||
|
"streams": {"Main": "front", "720p": "front_sub"},
|
||||||
|
"transcode": {"enabled": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeneratedTranscodeStreams(unittest.TestCase):
|
||||||
|
def test_collects_enabled_cameras(self):
|
||||||
|
config = FrigateConfig(
|
||||||
|
**camera_config(
|
||||||
|
{
|
||||||
|
"transcode": {
|
||||||
|
"enabled": True,
|
||||||
|
"qualities": [{"height": 480, "bitrate": 500}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
generated_transcode_streams(config),
|
||||||
|
{"front_transcode_480p": transcode_stream_source("front", 480, 500)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_disabled_is_empty(self):
|
||||||
|
self.assertEqual(
|
||||||
|
generated_transcode_streams(FrigateConfig(**camera_config())), {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncTranscodeStreams(unittest.TestCase):
|
||||||
|
@patch("frigate.util.live_streams.requests.request")
|
||||||
|
def test_puts_added_and_changed_and_deletes_removed(self, mock_request):
|
||||||
|
mock_request.return_value = MagicMock(ok=True)
|
||||||
|
|
||||||
|
ok = sync_transcode_streams(
|
||||||
|
{"a": "src-a", "b": "src-b", "c": "src-c"},
|
||||||
|
{"a": "src-a", "b": "src-b2", "d": "src-d"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(ok)
|
||||||
|
url = "http://127.0.0.1:1984/api/streams"
|
||||||
|
self.assertCountEqual(
|
||||||
|
mock_request.call_args_list,
|
||||||
|
[
|
||||||
|
call("put", url, params={"name": "b", "src": "src-b2"}, timeout=5),
|
||||||
|
call("put", url, params={"name": "d", "src": "src-d"}, timeout=5),
|
||||||
|
call("delete", url, params={"src": "c"}, timeout=5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("frigate.util.live_streams.requests.request")
|
||||||
|
def test_unchanged_makes_no_calls(self, mock_request):
|
||||||
|
self.assertTrue(sync_transcode_streams({"a": "x"}, {"a": "x"}))
|
||||||
|
mock_request.assert_not_called()
|
||||||
|
|
||||||
|
@patch("frigate.util.live_streams.requests.request")
|
||||||
|
def test_failure_returns_false_and_keeps_going(self, mock_request):
|
||||||
|
mock_request.side_effect = [
|
||||||
|
requests.ConnectionError("down"),
|
||||||
|
MagicMock(ok=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertFalse(sync_transcode_streams({}, {"a": "x", "b": "y"}))
|
||||||
|
self.assertEqual(mock_request.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeasureStreamBitrate(unittest.TestCase):
|
||||||
|
@patch("frigate.util.live_streams.time.monotonic")
|
||||||
|
@patch("frigate.util.live_streams.requests.get")
|
||||||
|
def test_skips_warmup_and_averages(self, mock_get, mock_monotonic):
|
||||||
|
response = MagicMock(ok=True)
|
||||||
|
response.iter_content.return_value = [b"x" * 1000] * 6
|
||||||
|
mock_get.return_value.__enter__.return_value = response
|
||||||
|
# first byte, warmup, window start, then counted chunks up to 6 s
|
||||||
|
mock_monotonic.side_effect = [0.0, 0.5, 1.0, 2.0, 4.0, 7.0]
|
||||||
|
|
||||||
|
kbps = measure_stream_bitrate("front", duration=6.0, warmup=1.0)
|
||||||
|
|
||||||
|
self.assertAlmostEqual(kbps, 3000 * 8 / 1000 / 6.0)
|
||||||
|
mock_get.assert_called_once_with(
|
||||||
|
"http://127.0.0.1:1984/api/stream.mp4",
|
||||||
|
params={"src": "front"},
|
||||||
|
stream=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("frigate.util.live_streams.requests.get")
|
||||||
|
def test_no_data_returns_none(self, mock_get):
|
||||||
|
response = MagicMock(ok=True)
|
||||||
|
response.iter_content.return_value = []
|
||||||
|
mock_get.return_value.__enter__.return_value = response
|
||||||
|
|
||||||
|
self.assertIsNone(measure_stream_bitrate("front"))
|
||||||
|
|
||||||
|
@patch("frigate.util.live_streams.requests.get")
|
||||||
|
def test_request_error_returns_none(self, mock_get):
|
||||||
|
mock_get.side_effect = requests.ReadTimeout("slow")
|
||||||
|
|
||||||
|
self.assertIsNone(measure_stream_bitrate("front"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
# Mock complex imports before importing maintainer, saving originals so we can
|
# Mock complex imports before importing maintainer, saving originals so we can
|
||||||
# restore them after import and avoid polluting sys.modules for other tests.
|
# restore them after import and avoid polluting sys.modules for other tests.
|
||||||
@@ -48,11 +48,8 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
|||||||
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
||||||
):
|
):
|
||||||
with patch("frigate.record.maintainer.logger.warning") as warn:
|
with patch("frigate.record.maintainer.logger.warning") as warn:
|
||||||
# Mock validate_and_move_segment to avoid further logic.
|
# Mock validate_and_move_segment to avoid further logic
|
||||||
# The requestor is real when another test imported the
|
maintainer.validate_and_move_segment = MagicMock()
|
||||||
# maintainer first, and it would block on a reply.
|
|
||||||
maintainer.validate_and_move_segment = AsyncMock()
|
|
||||||
maintainer.requestor = MagicMock()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await maintainer.move_files()
|
await maintainer.move_files()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for sub stream cache segment handling in the recording maintainer."""
|
"""Tests for sub stream cache segment handling in the recording maintainer."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -533,59 +532,6 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
|
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
|
||||||
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
|
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
|
||||||
|
|
||||||
async def test_out_of_order_probes_chain_in_segment_order(self):
|
|
||||||
maintainer = _build_chaining_maintainer(self.T0)
|
|
||||||
|
|
||||||
async def probe(_ffmpeg, cache_path, get_duration=False):
|
|
||||||
# the earlier segment's probe finishes last
|
|
||||||
if "chain0" in cache_path:
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
|
|
||||||
return {"has_valid_video": True, "duration": 10.4}
|
|
||||||
|
|
||||||
recordings = [
|
|
||||||
{
|
|
||||||
"start_time": datetime.datetime.fromtimestamp(
|
|
||||||
self.T0 + offset, tz=datetime.UTC
|
|
||||||
),
|
|
||||||
"cache_path": f"/tmp/cache/test_cam@chain{offset}.mp4",
|
|
||||||
"stream_type": "main",
|
|
||||||
}
|
|
||||||
for offset in (0, 10)
|
|
||||||
]
|
|
||||||
first_resolved = asyncio.Event()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
|
||||||
patch(
|
|
||||||
"frigate.record.maintainer.get_keyframe_offsets",
|
|
||||||
AsyncMock(return_value=[0]),
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"frigate.record.maintainer.os.path.getmtime",
|
|
||||||
MagicMock(side_effect=OSError("missing")),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
await asyncio.gather(
|
|
||||||
maintainer._validate_in_order(
|
|
||||||
"test_cam", [], recordings[0], None, first_resolved
|
|
||||||
),
|
|
||||||
maintainer._validate_in_order(
|
|
||||||
"test_cam", [], recordings[1], first_resolved, asyncio.Event()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
starts = sorted(
|
|
||||||
call.args[2].timestamp() for call in maintainer.move_segment.await_args_list
|
|
||||||
)
|
|
||||||
self.assertEqual(starts[0], self.T0)
|
|
||||||
self.assertAlmostEqual(starts[1], self.T0 + 10.4, places=3)
|
|
||||||
self.assertAlmostEqual(
|
|
||||||
maintainer.last_segment_end[("test_cam", "main")],
|
|
||||||
self.T0 + 20.8,
|
|
||||||
places=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_genuine_gap_is_not_snapped(self):
|
async def test_genuine_gap_is_not_snapped(self):
|
||||||
maintainer = _build_chaining_maintainer(self.T0)
|
maintainer = _build_chaining_maintainer(self.T0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
"""Tests for tracker-derived review frame annotations."""
|
"""Tests for tracker-derived review frame annotations."""
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from frigate.data_processing.post.review_annotations import (
|
from frigate.data_processing.post.review_annotations import (
|
||||||
annotations_by_frame,
|
annotations_by_frame,
|
||||||
build_frame_captions,
|
|
||||||
build_timeline,
|
build_timeline,
|
||||||
describe_classification_change,
|
|
||||||
describe_heading,
|
describe_heading,
|
||||||
describe_position,
|
describe_position,
|
||||||
event_name,
|
event_name,
|
||||||
@@ -315,75 +312,5 @@ class TestFrameBucketing(unittest.TestCase):
|
|||||||
self.assertEqual(annotations_by_frame([(1.0, "x")], []), {})
|
self.assertEqual(annotations_by_frame([(1.0, "x")], []), {})
|
||||||
|
|
||||||
|
|
||||||
class TestClassificationChangeCaptions(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
person = track(
|
|
||||||
"1789481994.684479-lpyc2z",
|
|
||||||
"person",
|
|
||||||
0.0,
|
|
||||||
straight_path((0.9, 0.6), (0.4, 0.3), 10, 0.0),
|
|
||||||
)
|
|
||||||
patcher = patch(
|
|
||||||
"frigate.data_processing.post.review_annotations.get_tracked_events",
|
|
||||||
return_value=[person],
|
|
||||||
)
|
|
||||||
patcher.start()
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
patcher = patch(
|
|
||||||
"frigate.data_processing.post.review_annotations.get_state_changes",
|
|
||||||
return_value=[],
|
|
||||||
)
|
|
||||||
patcher.start()
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
def test_change_is_phrased_without_underscores(self):
|
|
||||||
self.assertEqual(
|
|
||||||
describe_classification_change(
|
|
||||||
{"model": "trash_day", "from": "no_bins", "to": "bins_at_curb"}
|
|
||||||
),
|
|
||||||
"trash day changed from no bins to bins at curb",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_change_is_noted_before_the_frame_it_precedes(self):
|
|
||||||
change = {"model": "front_gate", "from": "closed", "to": "open"}
|
|
||||||
captions = build_frame_captions(
|
|
||||||
["1789481994.684479-lpyc2z"],
|
|
||||||
[0.0, 10.0, 20.0],
|
|
||||||
[{**change, "timestamp": 12.0}],
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
captions[2],
|
|
||||||
"Frame 3 of 3 (+20.0s):\n[state] front gate changed from closed to open",
|
|
||||||
)
|
|
||||||
self.assertTrue(captions[0].splitlines()[1].startswith("[tracker] "))
|
|
||||||
|
|
||||||
def test_change_is_noted_without_tracked_objects(self):
|
|
||||||
with patch(
|
|
||||||
"frigate.data_processing.post.review_annotations.get_tracked_events",
|
|
||||||
return_value=[],
|
|
||||||
):
|
|
||||||
captions = build_frame_captions(
|
|
||||||
[],
|
|
||||||
[0.0, 10.0],
|
|
||||||
[{"model": "gate", "from": "a", "to": "b", "timestamp": 5.0}],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
captions,
|
|
||||||
[
|
|
||||||
"Frame 1 of 2 (+0.0s):",
|
|
||||||
"Frame 2 of 2 (+10.0s):\n[state] gate changed from a to b",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_change_after_the_last_frame_is_dropped(self):
|
|
||||||
captions = build_frame_captions(
|
|
||||||
["1789481994.684479-lpyc2z"],
|
|
||||||
[0.0, 10.0, 20.0],
|
|
||||||
[{"model": "gate", "from": "a", "to": "b", "timestamp": 25.0}],
|
|
||||||
)
|
|
||||||
self.assertFalse(any("[state]" in caption for caption in captions))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
"""Tests for attaching state classification changes to review items."""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
|
||||||
from frigate.config import FrigateConfig
|
|
||||||
from frigate.data_processing.post.review_descriptions import (
|
|
||||||
ReviewDescriptionProcessor,
|
|
||||||
format_classification_state_changes,
|
|
||||||
)
|
|
||||||
from frigate.data_processing.real_time.custom_classification import (
|
|
||||||
CustomStateClassificationProcessor,
|
|
||||||
)
|
|
||||||
from frigate.models import ReviewSegment
|
|
||||||
from frigate.review.maintainer import (
|
|
||||||
CLASSIFICATION_STATE_PRE_ROLL,
|
|
||||||
PendingReviewSegment,
|
|
||||||
ReviewSegmentMaintainer,
|
|
||||||
)
|
|
||||||
from frigate.review.types import SeverityEnum
|
|
||||||
|
|
||||||
CONFIG = """
|
|
||||||
mqtt:
|
|
||||||
enabled: False
|
|
||||||
cameras:
|
|
||||||
front_door:
|
|
||||||
ffmpeg:
|
|
||||||
inputs:
|
|
||||||
- path: rtsp://10.0.0.1:554/video
|
|
||||||
roles:
|
|
||||||
- detect
|
|
||||||
detect:
|
|
||||||
width: 1920
|
|
||||||
height: 1080
|
|
||||||
fps: 5
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def gate_change(timestamp: float, before: str = "closed", after: str = "open"):
|
|
||||||
return {"model": "front_gate", "from": before, "to": after, "timestamp": timestamp}
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerifyStateChange(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.processor = CustomStateClassificationProcessor.__new__(
|
|
||||||
CustomStateClassificationProcessor
|
|
||||||
)
|
|
||||||
self.processor.state_history = {}
|
|
||||||
|
|
||||||
def verify(self, state: str, timestamp: float):
|
|
||||||
return self.processor.verify_state_change("front_door", state, timestamp)
|
|
||||||
|
|
||||||
def test_first_verified_state_has_no_previous_state(self):
|
|
||||||
self.assertIsNone(self.verify("closed", 1.0))
|
|
||||||
self.assertIsNone(self.verify("closed", 2.0))
|
|
||||||
self.assertEqual(self.verify("closed", 3.0), (None, 1.0))
|
|
||||||
|
|
||||||
def test_change_reports_previous_state_and_first_sighting(self):
|
|
||||||
for timestamp in (1.0, 2.0, 3.0):
|
|
||||||
self.verify("closed", timestamp)
|
|
||||||
|
|
||||||
self.assertIsNone(self.verify("open", 10.0))
|
|
||||||
self.assertIsNone(self.verify("open", 11.0))
|
|
||||||
self.assertEqual(self.verify("open", 12.0), ("closed", 10.0))
|
|
||||||
|
|
||||||
def test_interrupted_verification_restarts_the_first_sighting(self):
|
|
||||||
for timestamp in (1.0, 2.0, 3.0):
|
|
||||||
self.verify("closed", timestamp)
|
|
||||||
|
|
||||||
self.verify("open", 10.0)
|
|
||||||
self.verify("closed", 11.0)
|
|
||||||
self.verify("open", 20.0)
|
|
||||||
self.verify("open", 21.0)
|
|
||||||
self.assertEqual(self.verify("open", 22.0), ("closed", 20.0))
|
|
||||||
|
|
||||||
def test_reload_forgets_states_the_model_no_longer_has(self):
|
|
||||||
for timestamp in (1.0, 2.0, 3.0):
|
|
||||||
self.verify("closed", timestamp)
|
|
||||||
|
|
||||||
self.processor.state_history["back_door"] = {"current_state": "open"}
|
|
||||||
self.processor.labelmap = {0: "open", 1: "shut"}
|
|
||||||
self.processor._forget_unknown_states()
|
|
||||||
|
|
||||||
self.assertEqual(list(self.processor.state_history), ["back_door"])
|
|
||||||
self.assertIsNone(self.verify("shut", 10.0))
|
|
||||||
self.assertIsNone(self.verify("shut", 11.0))
|
|
||||||
self.assertEqual(self.verify("shut", 12.0), (None, 10.0))
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewSegmentAttachment(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
|
|
||||||
self.maintainer.config = FrigateConfig.parse_yaml(CONFIG)
|
|
||||||
self.maintainer.active_review_segments = {}
|
|
||||||
self.maintainer.recent_classification_state_changes = {}
|
|
||||||
self.maintainer._publish_segment_update = MagicMock()
|
|
||||||
|
|
||||||
def segment(self, start_time: float) -> PendingReviewSegment:
|
|
||||||
return PendingReviewSegment(
|
|
||||||
"front_door",
|
|
||||||
start_time,
|
|
||||||
SeverityEnum.alert,
|
|
||||||
{"1.0-abcdef": "person"},
|
|
||||||
{},
|
|
||||||
[],
|
|
||||||
set(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_change_during_segment_is_attached_and_published(self):
|
|
||||||
segment = self.segment(100.0)
|
|
||||||
self.maintainer.active_review_segments["front_door"] = segment
|
|
||||||
|
|
||||||
self.maintainer.handle_classification_state_change(
|
|
||||||
"front_door", gate_change(110.0)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(segment.classification_state_changes, [gate_change(110.0)])
|
|
||||||
self.maintainer._publish_segment_update.assert_called_once()
|
|
||||||
prev_data = self.maintainer._publish_segment_update.call_args.args[4]
|
|
||||||
self.assertEqual(prev_data["data"]["classification_state_changes"], [])
|
|
||||||
self.assertEqual(
|
|
||||||
segment.get_data(False)["data"]["classification_state_changes"],
|
|
||||||
[gate_change(110.0)],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_change_never_starts_a_segment(self):
|
|
||||||
self.maintainer.handle_classification_state_change(
|
|
||||||
"front_door", gate_change(110.0)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIsNone(self.maintainer.active_review_segments.get("front_door"))
|
|
||||||
self.maintainer._publish_segment_update.assert_not_called()
|
|
||||||
|
|
||||||
def test_change_just_before_a_segment_is_attached_when_it_starts(self):
|
|
||||||
self.maintainer.handle_classification_state_change(
|
|
||||||
"front_door", gate_change(98.0)
|
|
||||||
)
|
|
||||||
segment = self.segment(100.0)
|
|
||||||
|
|
||||||
self.maintainer._activate_segment(segment)
|
|
||||||
|
|
||||||
self.assertIs(self.maintainer.active_review_segments["front_door"], segment)
|
|
||||||
self.assertEqual(segment.classification_state_changes, [gate_change(98.0)])
|
|
||||||
self.assertNotIn(
|
|
||||||
"front_door", self.maintainer.recent_classification_state_changes
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_change_long_before_a_segment_is_not_attached(self):
|
|
||||||
self.maintainer.handle_classification_state_change(
|
|
||||||
"front_door", gate_change(100.0 - CLASSIFICATION_STATE_PRE_ROLL - 1)
|
|
||||||
)
|
|
||||||
segment = self.segment(100.0)
|
|
||||||
|
|
||||||
self.maintainer._activate_segment(segment)
|
|
||||||
|
|
||||||
self.assertEqual(segment.classification_state_changes, [])
|
|
||||||
|
|
||||||
def test_held_changes_are_pruned(self):
|
|
||||||
self.maintainer.handle_classification_state_change(
|
|
||||||
"front_door", gate_change(10.0)
|
|
||||||
)
|
|
||||||
self.maintainer.handle_classification_state_change(
|
|
||||||
"front_door", gate_change(50.0, "open", "closed")
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
self.maintainer.recent_classification_state_changes["front_door"],
|
|
||||||
[gate_change(50.0, "open", "closed")],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestChangeTiming(unittest.TestCase):
|
|
||||||
def test_changes_are_placed_relative_to_the_activity(self):
|
|
||||||
lines = format_classification_state_changes(
|
|
||||||
[
|
|
||||||
gate_change(98.0),
|
|
||||||
gate_change(112.4, "open", "closed"),
|
|
||||||
gate_change(140.0),
|
|
||||||
],
|
|
||||||
start_time=100.0,
|
|
||||||
end_time=130.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
lines,
|
|
||||||
[
|
|
||||||
"front gate changed from closed to open, "
|
|
||||||
"just before the activity started",
|
|
||||||
"front gate changed from open to closed, 12s into the activity",
|
|
||||||
"front gate changed from closed to open, after the activity ended",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSummaryContext(unittest.TestCase):
|
|
||||||
def row(self, camera, start, end, threat, changes=()):
|
|
||||||
return {
|
|
||||||
"camera": camera,
|
|
||||||
"start_time": start,
|
|
||||||
"end_time": end,
|
|
||||||
"data": {
|
|
||||||
"metadata": {"title": camera, "potential_threat_level": threat},
|
|
||||||
"classification_state_changes": list(changes),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def summarize(self, rows):
|
|
||||||
processor = ReviewDescriptionProcessor.__new__(ReviewDescriptionProcessor)
|
|
||||||
processor.config = FrigateConfig.parse_yaml(CONFIG)
|
|
||||||
processor.genai_manager = MagicMock()
|
|
||||||
client = processor.genai_manager.description_client
|
|
||||||
|
|
||||||
with patch.object(ReviewSegment, "select") as select:
|
|
||||||
query = select.return_value.where.return_value.order_by.return_value
|
|
||||||
query.dicts.return_value.iterator.return_value = iter(rows)
|
|
||||||
processor.handle_request(
|
|
||||||
EmbeddingsRequestEnum.summarize_review.value,
|
|
||||||
{"start_ts": 0, "end_ts": 100},
|
|
||||||
)
|
|
||||||
|
|
||||||
return client.generate_review_summary.call_args.args[2]
|
|
||||||
|
|
||||||
def test_context_state_changes_stay_with_their_review(self):
|
|
||||||
events = self.summarize(
|
|
||||||
[
|
|
||||||
self.row("front_door", 10, 60, 1),
|
|
||||||
self.row("driveway", 15, 25, 0, [gate_change(20.0)]),
|
|
||||||
self.row("driveway", 30, 40, 0, [gate_change(35.0, "open", "closed")]),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
[
|
|
||||||
(item["start_time"], item["end_time"], item["state_changes"])
|
|
||||||
for item in events[0]["context"]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
(15, 25, ["front gate changed from closed to open"]),
|
|
||||||
(30, 40, ["front gate changed from open to closed"]),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_later_context_review_without_changes_is_deduplicated(self):
|
|
||||||
events = self.summarize(
|
|
||||||
[
|
|
||||||
self.row("front_door", 10, 60, 1),
|
|
||||||
self.row("driveway", 15, 25, 0, [gate_change(20.0)]),
|
|
||||||
self.row("driveway", 30, 40, 0),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(len(events[0]["context"]), 1)
|
|
||||||
self.assertEqual(events[0]["context"][0]["start_time"], 15)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -14,10 +14,6 @@ from frigate.util.file import FileLock
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# (connect, read) seconds. The read timeout bounds each socket read rather than
|
|
||||||
# the whole download, so large models still finish.
|
|
||||||
DOWNLOAD_TIMEOUT = (15, 60)
|
|
||||||
|
|
||||||
# target path -> first line of the last download error for it; every existing
|
# target path -> first line of the last download error for it; every existing
|
||||||
# download function swallows its exceptions, so this is how the downloader
|
# download function swallows its exceptions, so this is how the downloader
|
||||||
# thread learns why a file is still missing
|
# thread learns why a file is still missing
|
||||||
@@ -128,9 +124,7 @@ class ModelDownloader:
|
|||||||
logger.info(f"Downloading model file from: {url}")
|
logger.info(f"Downloading model file from: {url}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with requests.get(
|
with requests.get(url, stream=True, allow_redirects=True) as r:
|
||||||
url, stream=True, allow_redirects=True, timeout=DOWNLOAD_TIMEOUT
|
|
||||||
) as r:
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
with open(temporary_filename, "wb") as f:
|
with open(temporary_filename, "wb") as f:
|
||||||
for chunk in r.iter_content(chunk_size=8192):
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Frigate-managed go2rtc live streams: transcoded rungs and bitrate probes."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GO2RTC_API = "http://127.0.0.1:1984/api"
|
||||||
|
|
||||||
|
DEFAULT_TRANSCODE_QUALITIES: list[dict[str, int]] = [
|
||||||
|
{"height": 720, "bitrate": 1200},
|
||||||
|
{"height": 480, "bitrate": 500},
|
||||||
|
{"height": 360, "bitrate": 250},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_stream_name(camera: str, height: int) -> str:
|
||||||
|
"""Return the go2rtc name of a camera's transcoded stream at one height."""
|
||||||
|
return f"{camera}_transcode_{height}p"
|
||||||
|
|
||||||
|
|
||||||
|
def is_transcode_stream_name(camera: str, name: str) -> bool:
|
||||||
|
"""Return whether a stream name follows this camera's transcoded naming."""
|
||||||
|
return re.fullmatch(rf"{re.escape(camera)}_transcode_\d+p", name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_stream_source(source: str, height: int, bitrate: int) -> str:
|
||||||
|
"""Build the go2rtc ffmpeg source that transcodes a stream to H.264.
|
||||||
|
|
||||||
|
go2rtc rejects API-registered sources containing spaces, so each ffmpeg
|
||||||
|
token gets its own #raw= parameter. #audio=copy keeps audio, which any
|
||||||
|
#video= option would otherwise drop.
|
||||||
|
"""
|
||||||
|
tokens = (
|
||||||
|
"-b:v",
|
||||||
|
f"{bitrate}k",
|
||||||
|
"-maxrate",
|
||||||
|
f"{bitrate}k",
|
||||||
|
"-bufsize",
|
||||||
|
f"{2 * bitrate}k",
|
||||||
|
)
|
||||||
|
raw = "".join(f"#raw={token}" for token in tokens)
|
||||||
|
return f"ffmpeg:{source}#video=h264#height={height}#hardware#audio=copy{raw}"
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_streams(
|
||||||
|
camera: str, source: str, qualities: list[dict[str, int]]
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Map each quality's go2rtc stream name to its source."""
|
||||||
|
return {
|
||||||
|
transcode_stream_name(camera, quality["height"]): transcode_stream_source(
|
||||||
|
source, quality["height"], quality["bitrate"]
|
||||||
|
)
|
||||||
|
for quality in qualities
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_transcode_source(camera: str, streams: dict[str, str]) -> str | None:
|
||||||
|
"""Return the first live stream that is not itself transcoded."""
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
name
|
||||||
|
for name in streams.values()
|
||||||
|
if not is_transcode_stream_name(camera, name)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_transcode_streams(config: dict[str, Any]) -> dict[str, str]:
|
||||||
|
"""Build transcoded go2rtc streams for every enabled camera in a raw config.
|
||||||
|
|
||||||
|
go2rtc's config is written before FrigateConfig validation runs, so this
|
||||||
|
resolves defaults the same way the validator does.
|
||||||
|
"""
|
||||||
|
streams: dict[str, str] = {}
|
||||||
|
|
||||||
|
for camera, camera_config in (config.get("cameras") or {}).items():
|
||||||
|
live = (camera_config or {}).get("live") or {}
|
||||||
|
transcode = live.get("transcode") or {}
|
||||||
|
|
||||||
|
if not transcode.get("enabled"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
source = transcode.get("source") or default_transcode_source(
|
||||||
|
camera, live.get("streams") or {camera: camera}
|
||||||
|
)
|
||||||
|
|
||||||
|
if source is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
qualities = transcode.get("qualities")
|
||||||
|
|
||||||
|
if qualities is None:
|
||||||
|
qualities = DEFAULT_TRANSCODE_QUALITIES
|
||||||
|
|
||||||
|
# malformed entries fail Frigate's validation; skipping them here keeps
|
||||||
|
# go2rtc from crash-looping alongside it
|
||||||
|
valid = [
|
||||||
|
quality
|
||||||
|
for quality in (qualities if isinstance(qualities, list) else [])
|
||||||
|
if isinstance(quality, dict)
|
||||||
|
and "height" in quality
|
||||||
|
and "bitrate" in quality
|
||||||
|
]
|
||||||
|
streams.update(transcode_streams(camera, source, valid))
|
||||||
|
|
||||||
|
return streams
|
||||||
|
|
||||||
|
|
||||||
|
def generated_transcode_streams(config: "FrigateConfig") -> dict[str, str]:
|
||||||
|
"""Build transcoded go2rtc streams for every enabled camera."""
|
||||||
|
streams: dict[str, str] = {}
|
||||||
|
|
||||||
|
for camera in config.cameras.values():
|
||||||
|
transcode = camera.live.transcode
|
||||||
|
|
||||||
|
if transcode.enabled and transcode.source:
|
||||||
|
streams.update(
|
||||||
|
transcode_streams(
|
||||||
|
camera.name,
|
||||||
|
transcode.source,
|
||||||
|
[quality.model_dump() for quality in transcode.qualities],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return streams
|
||||||
|
|
||||||
|
|
||||||
|
def _go2rtc_streams_call(method: str, name: str, params: dict[str, str]) -> bool:
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method, f"{GO2RTC_API}/streams", params=params, timeout=5
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("Failed to sync go2rtc stream %s: %s", name, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logger.error("Failed to sync go2rtc stream %s: %s", name, response.text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sync_transcode_streams(old: dict[str, str], new: dict[str, str]) -> bool:
|
||||||
|
"""Register added or changed streams with go2rtc and remove dropped ones.
|
||||||
|
|
||||||
|
Returns False when any go2rtc call failed.
|
||||||
|
"""
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
for name, source in new.items():
|
||||||
|
if old.get(name) != source:
|
||||||
|
ok = _go2rtc_streams_call("put", name, {"name": name, "src": source}) and ok
|
||||||
|
|
||||||
|
for name in old.keys() - new.keys():
|
||||||
|
ok = _go2rtc_streams_call("delete", name, {"src": name}) and ok
|
||||||
|
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def measure_stream_bitrate(
|
||||||
|
name: str, duration: float = 6.0, warmup: float = 1.0
|
||||||
|
) -> float | None:
|
||||||
|
"""Measure a go2rtc stream's average kbps from its local MP4 output.
|
||||||
|
|
||||||
|
The first warmup seconds after the first byte are skipped so the startup
|
||||||
|
keyframe burst does not inflate the result. Returns None when the stream
|
||||||
|
sends nothing.
|
||||||
|
"""
|
||||||
|
first = start = now = None
|
||||||
|
counted = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with requests.get(
|
||||||
|
f"{GO2RTC_API}/stream.mp4",
|
||||||
|
params={"src": name},
|
||||||
|
stream=True,
|
||||||
|
timeout=5,
|
||||||
|
) as response:
|
||||||
|
if not response.ok:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for chunk in response.iter_content(chunk_size=16384):
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
if first is None:
|
||||||
|
first = now
|
||||||
|
|
||||||
|
if now - first < warmup:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if start is None:
|
||||||
|
start = now
|
||||||
|
continue
|
||||||
|
|
||||||
|
counted += len(chunk)
|
||||||
|
|
||||||
|
if now - start >= duration:
|
||||||
|
break
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.warning("Failed to measure go2rtc stream %s: %s", name, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if start is None or now is None or now <= start or counted == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return counted * 8 / 1000 / (now - start)
|
||||||
@@ -213,19 +213,17 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
|
def null_audio_glitches(
|
||||||
|
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
|
||||||
|
) -> list[CoverageInterval]:
|
||||||
"""Treat video-only glitch rows on audio-bearing streams as no recording.
|
"""Treat video-only glitch rows on audio-bearing streams as no recording.
|
||||||
|
|
||||||
nginx-vod requires every clip in a sequence to carry the same track
|
nginx-vod requires every clip in a sequence to carry the same track
|
||||||
count, so a truncated video-only segment (a backend restart can flush
|
count, so a truncated video-only segment (a backend restart can flush
|
||||||
a sub-second file before any audio packet landed) poisons every
|
a sub-second file before any audio packet landed) poisons every
|
||||||
manifest that includes it. Nulling the row turns the glitch into a
|
manifest that includes it. Nulling the row turns the glitch into a
|
||||||
hole the span builder skips like any recording gap. Every consumer of
|
hole the span builder skips like any recording gap.
|
||||||
a window's coverage (the vod manifest, its realized timelines, and
|
|
||||||
exports) goes through here, so they all agree on which rows exist.
|
|
||||||
"""
|
"""
|
||||||
main_audio = stream_has_audio(intervals, main=True)
|
|
||||||
sub_audio = stream_has_audio(intervals, main=False)
|
|
||||||
result: list[CoverageInterval] = []
|
result: list[CoverageInterval] = []
|
||||||
for interval in intervals:
|
for interval in intervals:
|
||||||
main = interval.main
|
main = interval.main
|
||||||
@@ -451,7 +449,9 @@ def realized_timelines(
|
|||||||
assembles each variant's realized spans. Keyframe snapping reads the
|
assembles each variant's realized spans. Keyframe snapping reads the
|
||||||
per-row index stored at record time, so no file is touched.
|
per-row index stored at record time, so no file is touched.
|
||||||
"""
|
"""
|
||||||
nulled = null_audio_glitches(intervals)
|
main_audio = stream_has_audio(intervals, main=True)
|
||||||
|
sub_audio = stream_has_audio(intervals, main=False)
|
||||||
|
nulled = null_audio_glitches(intervals, main_audio, sub_audio)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"auto": realized_timeline(nulled, None),
|
"auto": realized_timeline(nulled, None),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helpers for the live dashboard's draggable grid layout: reading and seeding
|
|
||||||
* the persisted layout, and measuring rendered tiles.
|
|
||||||
*
|
|
||||||
* DraggableGridLayout persists through useUserPersistence, which namespaces
|
|
||||||
* keys by username, and every write is an async idb put. A test that seeds the
|
|
||||||
* bare key, or seeds before the app's own first write has landed, silently
|
|
||||||
* asserts against a key the app never reads. persistedLayoutKey() closes both
|
|
||||||
* holes, so prefer it over building the key by hand.
|
|
||||||
*
|
|
||||||
* Geometry has its own trap: the grid first lays out against window.innerWidth,
|
|
||||||
* then reflows narrower once useResizeObserver reports the real container.
|
|
||||||
* Tiles measured in separate round-trips can straddle that reflow and disagree
|
|
||||||
* on scale, so cameraBoxes() takes every measurement in one evaluate.
|
|
||||||
*
|
|
||||||
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { expect, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
export type LayoutItem = {
|
|
||||||
i: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PersistedLayout = {
|
|
||||||
version: number;
|
|
||||||
naturalAspect: boolean;
|
|
||||||
layout: LayoutItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function layoutKeySuffix(group: string): string {
|
|
||||||
return `${group}-draggable-layout`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The key the app has actually written an envelope to, or undefined while its
|
|
||||||
* first write is still in flight.
|
|
||||||
*/
|
|
||||||
function findWrittenKey(
|
|
||||||
page: Page,
|
|
||||||
group: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
return page.evaluate(
|
|
||||||
(suffix) =>
|
|
||||||
new Promise<string | undefined>((resolve) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const store = open.result
|
|
||||||
.transaction("keyval", "readonly")
|
|
||||||
.objectStore("keyval");
|
|
||||||
// getAllKeys and getAll both return in key order, so the indexes align
|
|
||||||
const keys = store.getAllKeys();
|
|
||||||
const values = store.getAll();
|
|
||||||
keys.transaction.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
const names = keys.result as string[];
|
|
||||||
const stored = values.result as { version?: number }[];
|
|
||||||
const match = names.findIndex(
|
|
||||||
(name, index) =>
|
|
||||||
(name === suffix || name.startsWith(`${suffix}:`)) &&
|
|
||||||
typeof stored[index]?.version === "number",
|
|
||||||
);
|
|
||||||
resolve(match === -1 ? undefined : names[match]);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
open.onerror = () => resolve(undefined);
|
|
||||||
}),
|
|
||||||
layoutKeySuffix(group),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for the grid to persist its own layout, then return the key it used.
|
|
||||||
* Waiting for that write is what makes a later seed meaningful: it proves the
|
|
||||||
* key is live, and it rules out the app overwriting the seed a moment later.
|
|
||||||
*/
|
|
||||||
export async function persistedLayoutKey(
|
|
||||||
page: Page,
|
|
||||||
group: string,
|
|
||||||
): Promise<string> {
|
|
||||||
let key: string | undefined;
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (key = await findWrittenKey(page, group)), {
|
|
||||||
timeout: 10_000,
|
|
||||||
message: `grid never persisted a layout for group "${group}"`,
|
|
||||||
})
|
|
||||||
.not.toBeUndefined();
|
|
||||||
|
|
||||||
return key!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Overwrite the stored layout, resolving only once the put has committed. */
|
|
||||||
export function seedLayout(
|
|
||||||
page: Page,
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
return page.evaluate(
|
|
||||||
([key, value]) =>
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const tx = open.result.transaction("keyval", "readwrite");
|
|
||||||
tx.objectStore("keyval").put(value, key as string);
|
|
||||||
tx.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
tx.onerror = () => reject(tx.error);
|
|
||||||
};
|
|
||||||
open.onerror = () => reject(open.error);
|
|
||||||
}),
|
|
||||||
[key, value] as const,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the stored layout back. Undefined until the app writes it. */
|
|
||||||
export function readLayout(
|
|
||||||
page: Page,
|
|
||||||
key: string,
|
|
||||||
): Promise<PersistedLayout | undefined> {
|
|
||||||
return page.evaluate(
|
|
||||||
(target) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const open = indexedDB.open("keyval-store");
|
|
||||||
open.onsuccess = () => {
|
|
||||||
const tx = open.result.transaction("keyval", "readonly");
|
|
||||||
const request = tx.objectStore("keyval").get(target);
|
|
||||||
tx.oncomplete = () => {
|
|
||||||
open.result.close();
|
|
||||||
resolve(request.result);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
open.onerror = () => resolve(undefined);
|
|
||||||
}),
|
|
||||||
key,
|
|
||||||
) as Promise<PersistedLayout | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Box = { w: number; h: number; x: number; y: number };
|
|
||||||
|
|
||||||
/** The card is the player root; the cell is the grid slot it sits in. */
|
|
||||||
export type BoxTarget = "card" | "cell";
|
|
||||||
|
|
||||||
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
|
|
||||||
function snapshotBoxes(
|
|
||||||
page: Page,
|
|
||||||
cameras: readonly string[],
|
|
||||||
target: BoxTarget,
|
|
||||||
): Promise<Record<string, Box> | null> {
|
|
||||||
return page.evaluate(
|
|
||||||
({ cams, target }) => {
|
|
||||||
const boxes: Record<string, Box> = {};
|
|
||||||
|
|
||||||
for (const cam of cams) {
|
|
||||||
const card = document.querySelector(`[data-camera='${cam}']`);
|
|
||||||
const el = target === "cell" ? card?.closest(".p-1") : card;
|
|
||||||
|
|
||||||
if (!el) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
|
|
||||||
// a re-rendering tile can briefly report no box at all
|
|
||||||
if (!r.width || !r.height) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
|
|
||||||
}
|
|
||||||
|
|
||||||
return boxes;
|
|
||||||
},
|
|
||||||
{ cams: cameras as readonly string[], target },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measure the given cameras' tiles together, once they have all rendered.
|
|
||||||
* Measuring in one evaluate is what keeps the numbers mutually comparable.
|
|
||||||
*/
|
|
||||||
export async function cameraBoxes<T extends string>(
|
|
||||||
page: Page,
|
|
||||||
cameras: readonly T[],
|
|
||||||
target: BoxTarget = "cell",
|
|
||||||
): Promise<Record<T, Box>> {
|
|
||||||
let boxes: Record<string, Box> | null = null;
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
|
|
||||||
timeout: 10_000,
|
|
||||||
message: `${target}s never rendered for ${cameras.join(", ")}`,
|
|
||||||
})
|
|
||||||
.not.toBeNull();
|
|
||||||
|
|
||||||
return boxes as unknown as Record<T, Box>;
|
|
||||||
}
|
|
||||||
@@ -45,11 +45,6 @@ export class LivePage extends BasePage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
|
|
||||||
get editLayoutButton(): Locator {
|
|
||||||
return this.page.getByTestId("toggle-edit-layout");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the right-click context menu on a camera card (desktop only). */
|
/** Open the right-click context menu on a camera card (desktop only). */
|
||||||
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
||||||
await this.cameraCard(cameraName).first().click({ button: "right" });
|
await this.cameraCard(cameraName).first().click({ button: "right" });
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* Auto live stream selection in the single-camera view.
|
||||||
|
*
|
||||||
|
* go2rtc is mocked per stream. A failing stream answers MSE codec
|
||||||
|
* negotiation with go2rtc's "codecs not matched" error, the one ladder
|
||||||
|
* trigger Chromium reproduces without real media. A healthy stream
|
||||||
|
* answers negotiation and then sends nothing, so it waits quietly.
|
||||||
|
*/
|
||||||
|
import type { Locator, Page } from "@playwright/test";
|
||||||
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { LivePage } from "../pages/live.page";
|
||||||
|
|
||||||
|
// the mocked profile is admin, so useUserPersistence keys are namespaced
|
||||||
|
const STREAM_KEY = "front_door-stream:admin";
|
||||||
|
const CODEC_ERROR = "mse: streams: codecs not matched: H265 => H264";
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
go2rtc: {
|
||||||
|
streams: {
|
||||||
|
front_door: ["rtsp://127.0.0.1:8554/front_door"],
|
||||||
|
front_door_sub: ["rtsp://127.0.0.1:8554/front_door_sub"],
|
||||||
|
backyard: ["rtsp://127.0.0.1:8554/backyard"],
|
||||||
|
},
|
||||||
|
webrtc: { candidates: [], ice_servers: [] },
|
||||||
|
},
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
live: { streams: { Sub: "front_door_sub" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test.use({ expectedErrors: [/MSE error 'mse-codec'/] });
|
||||||
|
|
||||||
|
async function writeIdb(page: Page, entries: Record<string, unknown>) {
|
||||||
|
await page.evaluate(async (data) => {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = indexedDB.open("keyval-store", 1);
|
||||||
|
request.onupgradeneeded = () =>
|
||||||
|
request.result.createObjectStore("keyval");
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const tx = request.result.transaction("keyval", "readwrite");
|
||||||
|
const store = tx.objectStore("keyval");
|
||||||
|
Object.entries(data).forEach(([key, value]) => store.put(value, key));
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIdb(page: Page, key: string) {
|
||||||
|
return page.evaluate(async (target) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open("keyval-store", 1);
|
||||||
|
request.onupgradeneeded = () =>
|
||||||
|
request.result.createObjectStore("keyval");
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const tx = request.result.transaction("keyval", "readonly");
|
||||||
|
const get = tx.objectStore("keyval").get(target);
|
||||||
|
get.onsuccess = () => resolve(get.result ?? null);
|
||||||
|
get.onerror = () => reject(get.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// records the src of every MSE socket in open order
|
||||||
|
async function mockGo2rtc(page: Page, failing: string[] = []) {
|
||||||
|
const opened: string[] = [];
|
||||||
|
|
||||||
|
await page.routeWebSocket("**/live/mse/api/ws**", (ws) => {
|
||||||
|
const src = new URL(ws.url()).searchParams.get("src") ?? "";
|
||||||
|
opened.push(src);
|
||||||
|
|
||||||
|
ws.onMessage((message) => {
|
||||||
|
const msg = JSON.parse(message.toString());
|
||||||
|
if (msg.type !== "mse") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify(
|
||||||
|
failing.includes(src)
|
||||||
|
? { type: "error", value: CODEC_ERROR }
|
||||||
|
: { type: "mse", value: 'video/mp4; codecs="avc1.640029"' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/go2rtc/streams/**", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||||
|
consumers: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return opened;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDesktopSettings(page: Page) {
|
||||||
|
const live = new LivePage(page, true);
|
||||||
|
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.locator("button:has(svg)").last().click();
|
||||||
|
const menu = page.locator('[role="menu"], [data-radix-menu-content]').first();
|
||||||
|
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamTrigger(menu: Locator) {
|
||||||
|
return menu
|
||||||
|
.locator('div:has(> label[for="streaming-method"]) [role="combobox"]')
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Auto live stream @mobile", () => {
|
||||||
|
test("a codec error on the main stream moves Auto to the sub stream", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
||||||
|
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => opened, { timeout: 10_000 })
|
||||||
|
.toContain("front_door_sub");
|
||||||
|
expect(opened[0]).toBe("front_door");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Transcoded live stream @mobile", () => {
|
||||||
|
test("a pinned transcoded stream plays over MSE", async ({ frigateApp }) => {
|
||||||
|
// go2rtc.streams lists only yaml streams; transcoded ones are generated
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: {
|
||||||
|
...CONFIG,
|
||||||
|
cameras: {
|
||||||
|
front_door: {
|
||||||
|
live: {
|
||||||
|
streams: {
|
||||||
|
Sub: "front_door_sub",
|
||||||
|
"720p": "front_door_transcode_720p",
|
||||||
|
},
|
||||||
|
transcode: {
|
||||||
|
enabled: true,
|
||||||
|
source: "front_door",
|
||||||
|
qualities: [{ height: 720, bitrate: 1200 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const opened = await mockGo2rtc(frigateApp.page);
|
||||||
|
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
await writeIdb(frigateApp.page, {
|
||||||
|
[STREAM_KEY]: "front_door_transcode_720p",
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => opened, { timeout: 10_000 })
|
||||||
|
.toContain("front_door_transcode_720p");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Auto live stream selector", () => {
|
||||||
|
test.beforeEach(({ frigateApp }) => {
|
||||||
|
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no saved stream opens Auto on the first stream", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
const opened = await mockGo2rtc(frigateApp.page);
|
||||||
|
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
await expect.poll(() => opened[0], { timeout: 10_000 }).toBe("front_door");
|
||||||
|
const menu = await openDesktopSettings(frigateApp.page);
|
||||||
|
await expect(streamTrigger(menu)).toHaveText("Auto · front_door");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a codec error shows the codec reason", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
||||||
|
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => opened, { timeout: 10_000 })
|
||||||
|
.toContain("front_door_sub");
|
||||||
|
const menu = await openDesktopSettings(frigateApp.page);
|
||||||
|
await expect(streamTrigger(menu)).toHaveText("Auto · Sub");
|
||||||
|
await expect(
|
||||||
|
menu.getByText(/because this browser can't play a higher-quality stream/),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trying the highest quality retries the top stream", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
||||||
|
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => opened, { timeout: 10_000 })
|
||||||
|
.toContain("front_door_sub");
|
||||||
|
const menu = await openDesktopSettings(frigateApp.page);
|
||||||
|
const beforeRetry = opened.length;
|
||||||
|
await menu.getByRole("button", { name: "Try highest quality" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => opened.slice(beforeRetry), { timeout: 10_000 })
|
||||||
|
.toContain("front_door");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pinned main takes the legacy fallback instead of stepping down", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
||||||
|
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
await writeIdb(frigateApp.page, { [STREAM_KEY]: "front_door" });
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
const menu = await openDesktopSettings(frigateApp.page);
|
||||||
|
await expect(
|
||||||
|
menu.getByText(/Live view is in low-bandwidth mode/),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
expect(opened).not.toContain("front_door_sub");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting Auto clears the saved stream", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
await mockGo2rtc(frigateApp.page);
|
||||||
|
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
await writeIdb(frigateApp.page, { [STREAM_KEY]: "front_door_sub" });
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
const menu = await openDesktopSettings(frigateApp.page);
|
||||||
|
await expect(streamTrigger(menu)).toHaveText("Sub");
|
||||||
|
await streamTrigger(menu).click();
|
||||||
|
await frigateApp.page.getByRole("option", { name: /^Auto/ }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => readIdb(frigateApp.page, STREAM_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stale pin falls back to Auto", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
const opened = await mockGo2rtc(frigateApp.page);
|
||||||
|
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
await writeIdb(frigateApp.page, { [STREAM_KEY]: "removed_stream" });
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
await expect.poll(() => readIdb(frigateApp.page, STREAM_KEY)).toBeNull();
|
||||||
|
await expect.poll(() => opened[0], { timeout: 10_000 }).toBe("front_door");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a single-stream camera offers no Auto", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
await mockGo2rtc(frigateApp.page);
|
||||||
|
|
||||||
|
await frigateApp.goto("/#backyard");
|
||||||
|
|
||||||
|
const menu = await openDesktopSettings(frigateApp.page);
|
||||||
|
await streamTrigger(menu).click();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("option", { name: "backyard" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("option", { name: /^Auto/ }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Reset stream at the floor returns to the top stream", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({ config: CONFIG });
|
||||||
|
const opened = await mockGo2rtc(frigateApp.page, [
|
||||||
|
"front_door",
|
||||||
|
"front_door_sub",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await frigateApp.goto("/#front_door");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => opened, { timeout: 10_000 })
|
||||||
|
.toContain("front_door_sub");
|
||||||
|
expect(opened[0]).toBe("front_door");
|
||||||
|
|
||||||
|
const menu = await openDesktopSettings(frigateApp.page);
|
||||||
|
await expect(streamTrigger(menu)).toHaveText("Auto · Low bandwidth");
|
||||||
|
await expect(
|
||||||
|
menu.getByText(/Playing the low-bandwidth feed/),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const beforeReset = opened.length;
|
||||||
|
await menu.getByRole("button", { name: "Reset stream" }).click();
|
||||||
|
|
||||||
|
// leaving the floor changes the stream and the mode in one render,
|
||||||
|
// which must still open each stream exactly once
|
||||||
|
await expect
|
||||||
|
.poll(() => opened.slice(beforeReset), { timeout: 10_000 })
|
||||||
|
.toEqual(["front_door", "front_door_sub"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* Live grid aspect modes.
|
|
||||||
*
|
|
||||||
* Bucketed mode (the default) snaps every camera to a wide, landscape or tall
|
|
||||||
* tile, and converts layouts saved by pre-masonry versions instead of
|
|
||||||
* discarding them. Natural mode sizes each tile to its own camera.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
|
||||||
import { LivePage } from "../pages/live.page";
|
|
||||||
import {
|
|
||||||
cameraBoxes,
|
|
||||||
persistedLayoutKey,
|
|
||||||
readLayout,
|
|
||||||
seedLayout,
|
|
||||||
type LayoutItem,
|
|
||||||
} from "../helpers/grid-layout";
|
|
||||||
|
|
||||||
const GROUP = "outdoor";
|
|
||||||
const GRID_COLS = 96;
|
|
||||||
|
|
||||||
test.describe("Live grid aspect modes @critical", () => {
|
|
||||||
test.skip(
|
|
||||||
({ frigateApp }) => frigateApp.isMobile,
|
|
||||||
"Draggable grid is desktop-only",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 2560, height: 720 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { backyard: wide, front_door: normal } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["backyard", "front_door"] as const,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1);
|
|
||||||
expect(wide.w / normal.w).toBeCloseTo(2, 1);
|
|
||||||
expect(wide.h).toBeCloseTo(normal.h, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a letterboxed still image rounds its own corners", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// A portrait camera pillarboxes inside its 8:9 bucket, so the card's
|
|
||||||
// overflow-hidden clip never reaches the picture's corners. The image has
|
|
||||||
// to carry the radius itself or it renders with square edges on the tile.
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const radii = await frigateApp.page.evaluate(() => {
|
|
||||||
const card = document.querySelector("[data-camera='backyard']");
|
|
||||||
const img = card?.querySelector("img");
|
|
||||||
return {
|
|
||||||
card: card ? getComputedStyle(card).borderTopLeftRadius : null,
|
|
||||||
img: img ? getComputedStyle(img).borderTopLeftRadius : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(radii.card).not.toBe("0px");
|
|
||||||
expect(radii.img).toBe(radii.card);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a pre-masonry layout is converted, keeping resized tiles", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles.
|
|
||||||
// backyard was manually resized to 8x8 and sits beside front_door's column,
|
|
||||||
// front_door is a standard tile on the row below.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, [
|
|
||||||
{ i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false },
|
|
||||||
{ i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false },
|
|
||||||
]);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The conversion is written back on first load, replacing the legacy array
|
|
||||||
// with an envelope. Poll for it: that write is an async idb put.
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBe(2);
|
|
||||||
|
|
||||||
const stored = (await readLayout(frigateApp.page, key))!;
|
|
||||||
expect(stored).toMatchObject({ version: 2, naturalAspect: false });
|
|
||||||
|
|
||||||
// x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per
|
|
||||||
// standard tile), so the manual resize survives instead of snapping back.
|
|
||||||
expect(
|
|
||||||
stored.layout.find((i: LayoutItem) => i.i === "backyard"),
|
|
||||||
).toMatchObject({
|
|
||||||
x: 32,
|
|
||||||
y: 0,
|
|
||||||
w: 64,
|
|
||||||
h: 144,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
stored.layout.find((i: LayoutItem) => i.i === "front_door"),
|
|
||||||
).toMatchObject({
|
|
||||||
x: 0,
|
|
||||||
y: 144,
|
|
||||||
w: 32,
|
|
||||||
h: 72,
|
|
||||||
});
|
|
||||||
|
|
||||||
// arrangement on screen: backyard indented, front_door below it
|
|
||||||
const { backyard, front_door: frontDoor } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["backyard", "front_door"] as const,
|
|
||||||
);
|
|
||||||
expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2);
|
|
||||||
expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("conversion is a pure scale, so odd sizes and positions survive", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The old grid exposed all four resize corners with no aspect constraint,
|
|
||||||
// so a stored tile can be any size. These two are adjacent and non-standard.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, [
|
|
||||||
{ i: "front_door", x: 0, y: 3, w: 5, h: 5 },
|
|
||||||
{ i: "backyard", x: 5, y: 3, w: 7, h: 5 },
|
|
||||||
]);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBe(2);
|
|
||||||
|
|
||||||
const stored = (await readLayout(frigateApp.page, key))!;
|
|
||||||
const frontDoor = stored.layout.find(
|
|
||||||
(i: LayoutItem) => i.i === "front_door",
|
|
||||||
)!;
|
|
||||||
const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!;
|
|
||||||
|
|
||||||
expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 });
|
|
||||||
expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 });
|
|
||||||
|
|
||||||
// still adjacent, still inside the grid, still not overlapping
|
|
||||||
expect(frontDoor.x + frontDoor.w).toBe(backyard.x);
|
|
||||||
expect(backyard.x + backyard.w).toBe(GRID_COLS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
/**
|
|
||||||
* Masonry live grid -- custom-group draggable layout.
|
|
||||||
*
|
|
||||||
* Verifies natural-aspect tile sizing and that a saved layout the current
|
|
||||||
* version cannot read is regenerated cleanly. The grid renders only for a
|
|
||||||
* custom camera group (here: "outdoor") on desktop; mobile keeps the static
|
|
||||||
* grid, which the @mobile block below guards.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
|
||||||
import { LivePage } from "../pages/live.page";
|
|
||||||
import {
|
|
||||||
cameraBoxes,
|
|
||||||
persistedLayoutKey,
|
|
||||||
readLayout,
|
|
||||||
seedLayout,
|
|
||||||
} from "../helpers/grid-layout";
|
|
||||||
|
|
||||||
const GROUP = "outdoor"; // custom group: front_door + backyard
|
|
||||||
|
|
||||||
test.describe("Masonry live grid @critical", () => {
|
|
||||||
test.skip(
|
|
||||||
({ frigateApp }) => frigateApp.isMobile,
|
|
||||||
"Draggable masonry grid is desktop-only",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("custom group renders its cameras in the draggable grid", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tiles render at their camera's natural aspect ratio", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// backyard is 9:16, which bucketed mode would snap to an 8:9 tile
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: {
|
|
||||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await seedLayout(frigateApp.page, "naturalAspectLayout:admin", true);
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { front_door: landscape, backyard: portrait } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
["front_door", "backyard"] as const,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(landscape.w / landscape.h).toBeCloseTo(16 / 9, 1);
|
|
||||||
expect(portrait.w / portrait.h).toBeCloseTo(9 / 16, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dragging a tile does not shove other tiles far away", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await live.editLayoutButton.click();
|
|
||||||
|
|
||||||
const cameras = ["front_door", "backyard"] as const;
|
|
||||||
const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
cameras,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Drag backyard onto front_door's position (a deliberate collision). With
|
|
||||||
// free-placement + prevent-collision, front_door must NOT be shoved down.
|
|
||||||
const from = {
|
|
||||||
x: draggedBox.x + draggedBox.w / 2,
|
|
||||||
y: draggedBox.y + draggedBox.h / 2,
|
|
||||||
};
|
|
||||||
const to = {
|
|
||||||
x: fixedBefore.x + fixedBefore.w / 2,
|
|
||||||
y: fixedBefore.y + fixedBefore.h / 2,
|
|
||||||
};
|
|
||||||
await frigateApp.page.mouse.move(from.x, from.y);
|
|
||||||
await frigateApp.page.mouse.down();
|
|
||||||
await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 });
|
|
||||||
await frigateApp.page.mouse.up();
|
|
||||||
|
|
||||||
const { front_door: fixedAfter } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
cameras,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
// Allow a few px of snap; a collision-push would move it a whole tile down.
|
|
||||||
expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// A lone top tile has room to grow sideways, which is what exposed the bug:
|
|
||||||
// a top-edge handle let width grow while height stayed clamped at y=0.
|
|
||||||
await frigateApp.installDefaults({
|
|
||||||
config: { camera_groups: { outdoor: { cameras: ["front_door"] } } },
|
|
||||||
});
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await live.editLayoutButton.click();
|
|
||||||
|
|
||||||
const tile = frigateApp.page.locator(".react-grid-item", {
|
|
||||||
has: frigateApp.page.locator("[data-camera='front_door']"),
|
|
||||||
});
|
|
||||||
const only = ["front_door"] as const;
|
|
||||||
const { front_door: before } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
const aspect = before.w / before.h;
|
|
||||||
|
|
||||||
// Regression: if a top-edge handle is exposed, dragging it up/out must NOT
|
|
||||||
// distort the aspect (the old bug grew width while height stayed clamped).
|
|
||||||
const ne = tile.locator(".react-resizable-handle-ne");
|
|
||||||
if (await ne.count()) {
|
|
||||||
await ne.dragTo(tile, {
|
|
||||||
force: true,
|
|
||||||
targetPosition: { x: 1000, y: -160 },
|
|
||||||
});
|
|
||||||
const { front_door: afterNe } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
// It must actually resize (not a silent no-op) AND keep its aspect.
|
|
||||||
expect(afterNe.w).toBeGreaterThan(before.w);
|
|
||||||
expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Positive: growing from the bottom-right corner resizes and keeps aspect.
|
|
||||||
const se = tile.locator(".react-resizable-handle-se");
|
|
||||||
await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } });
|
|
||||||
const { front_door: grown } = await cameraBoxes(
|
|
||||||
frigateApp.page,
|
|
||||||
only,
|
|
||||||
"card",
|
|
||||||
);
|
|
||||||
expect(grown.w).toBeGreaterThan(before.w);
|
|
||||||
expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the grid keeps its measured width after a back navigation", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// The grid sizes itself from window.innerWidth until its container is
|
|
||||||
// measured. On a warm back navigation nothing re-renders after that
|
|
||||||
// container mounts, so an observer that never attaches leaves every tile
|
|
||||||
// sized against the full window: the layout widens by the sidebar's width
|
|
||||||
// and the rightmost column clips on a full row.
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// total width the tiles span; tracks the width the grid laid out against
|
|
||||||
const span = () =>
|
|
||||||
frigateApp.page.evaluate(() => {
|
|
||||||
const tiles = [...document.querySelectorAll(".react-grid-item")];
|
|
||||||
|
|
||||||
if (!tiles.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rects = tiles.map((tile) => tile.getBoundingClientRect());
|
|
||||||
return +(
|
|
||||||
Math.max(...rects.map((r) => r.right)) -
|
|
||||||
Math.min(...rects.map((r) => r.left))
|
|
||||||
).toFixed(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
let fresh: number | null = null;
|
|
||||||
await expect
|
|
||||||
.poll(async () => (fresh = await span()), { timeout: 10_000 })
|
|
||||||
.not.toBeNull();
|
|
||||||
|
|
||||||
await live.cameraCard("front_door").first().click();
|
|
||||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
|
||||||
|
|
||||||
await frigateApp.page.goBack();
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// the layout must settle back to the measured width, not window.innerWidth
|
|
||||||
await expect
|
|
||||||
.poll(span, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(fresh! + 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a camera added to a saved layout fills an open column", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// one tall tile in the first column; backyard is missing from the layout
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [{ i: "front_door", x: 0, y: 0, w: 32, h: 400 }],
|
|
||||||
});
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const stored = await readLayout(frigateApp.page, key);
|
|
||||||
return stored?.layout.find((item) => item.i === "backyard");
|
|
||||||
})
|
|
||||||
.toMatchObject({ x: 32, y: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("saved layout from an unreadable version regenerates without error", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, true);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A bare array is converted rather than discarded (covered in
|
|
||||||
// live-grid-aspect-modes), so use a version the current grid cannot read.
|
|
||||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
|
||||||
await seedLayout(frigateApp.page, key, {
|
|
||||||
version: 1,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
await frigateApp.page.reload();
|
|
||||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
|
||||||
|
|
||||||
// Grid regenerated; both cameras still render and the error collector
|
|
||||||
// (frigate-test fixture) catches any crash.
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
|
|
||||||
// The app must have replaced the value it could not read. Without this the
|
|
||||||
// test would still pass against a key the app never touches.
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
|
||||||
timeout: 10_000,
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Masonry live grid on mobile @critical @mobile", () => {
|
|
||||||
test("custom group keeps the static grid, with no draggable layout", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
|
||||||
|
|
||||||
await frigateApp.goto(`/?group=${GROUP}`);
|
|
||||||
const live = new LivePage(frigateApp.page, false);
|
|
||||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
|
||||||
|
|
||||||
// isMobileOnly routes around DraggableGridLayout entirely, so neither the
|
|
||||||
// grid items nor the edit-layout toggle may appear.
|
|
||||||
await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0);
|
|
||||||
await expect(live.editLayoutButton).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* Live playback stream order and transcoded streams -- MEDIUM tier.
|
||||||
|
*
|
||||||
|
* The stream list order is the Auto ladder, so the form saves it with
|
||||||
|
* replace_paths. Transcoded streams are generated from live.transcode and
|
||||||
|
* appear in the list as soon as the switch flips.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test, expect } from "../../fixtures/frigate-test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { configFactory } from "../../fixtures/mock-data/config";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CONFIG_SCHEMA = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const GO2RTC_STREAMS = {
|
||||||
|
front_door_main: ["rtsp://user:pass@192.168.0.20:554/main"],
|
||||||
|
front_door_sub: ["rtsp://user:pass@192.168.0.20:554/sub"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSCODE = {
|
||||||
|
enabled: false,
|
||||||
|
qualities: [
|
||||||
|
{ height: 720, bitrate: 1200 },
|
||||||
|
{ height: 480, bitrate: 500 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETTINGS_URL = "/settings?page=cameraLivePlayback&camera=front_door";
|
||||||
|
|
||||||
|
type RouteOptions = {
|
||||||
|
go2rtcStreams?: Record<string, string[]>;
|
||||||
|
streams?: Record<string, string>;
|
||||||
|
bitrates?: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function installRoutes(page: Page, options: RouteOptions = {}) {
|
||||||
|
const go2rtcStreams = options.go2rtcStreams ?? GO2RTC_STREAMS;
|
||||||
|
const streams = options.streams ?? {
|
||||||
|
Sub: "front_door_sub",
|
||||||
|
Main: "front_door_main",
|
||||||
|
};
|
||||||
|
const live = { streams, transcode: TRANSCODE };
|
||||||
|
const config = configFactory({
|
||||||
|
go2rtc: { streams: go2rtcStreams },
|
||||||
|
cameras: { front_door: { live } },
|
||||||
|
});
|
||||||
|
// configFactory's deepMerge adds override keys onto the base fixture's
|
||||||
|
// streams map instead of replacing it (they share no key names here), so
|
||||||
|
// pin the streams map to exactly what this test wants.
|
||||||
|
config.cameras.front_door.live.streams = streams;
|
||||||
|
|
||||||
|
let lastSaved: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
await page.route("**/api/config/schema.json", (route) =>
|
||||||
|
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||||
|
);
|
||||||
|
await page.route("**/api/config", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({ json: config });
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/config/raw_paths", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
go2rtc: { streams: go2rtcStreams },
|
||||||
|
cameras: { front_door: { live } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/config/set", async (route) => {
|
||||||
|
lastSaved = route.request().postDataJSON();
|
||||||
|
await route.fulfill({ json: { success: true, go2rtc_synced: true } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/go2rtc/streams/*/bitrate", (route) => {
|
||||||
|
const name = decodeURIComponent(
|
||||||
|
new URL(route.request().url()).pathname.split("/").slice(-2)[0],
|
||||||
|
);
|
||||||
|
const kbps = options.bitrates?.[name];
|
||||||
|
return kbps === undefined
|
||||||
|
? route.fulfill({ status: 502, json: { success: false } })
|
||||||
|
: route.fulfill({ json: { success: true, kbps } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { saved: () => lastSaved };
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamNames(page: Page) {
|
||||||
|
return page
|
||||||
|
.getByRole("textbox", { name: "Stream name" })
|
||||||
|
.evaluateAll((inputs) =>
|
||||||
|
inputs.map((input) => (input as HTMLInputElement).value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragRow(page: Page, from: number, to: number) {
|
||||||
|
const handles = page.getByRole("button", { name: "Drag to reorder" });
|
||||||
|
const start = await handles.nth(from).boundingBox();
|
||||||
|
const end = await handles.nth(to).boundingBox();
|
||||||
|
|
||||||
|
if (!start || !end) throw new Error("drag handle not visible");
|
||||||
|
|
||||||
|
await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
end.x + end.width / 2,
|
||||||
|
end.y + end.height / 2 + (to > from ? 12 : -12),
|
||||||
|
{ steps: 15 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("live stream order and transcoded streams @medium", () => {
|
||||||
|
test.skip(({ frigateApp }) => frigateApp.isMobile, "desktop drag");
|
||||||
|
|
||||||
|
test("dragging a stream saves the new order", async ({ frigateApp }) => {
|
||||||
|
const capture = await installRoutes(frigateApp.page);
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => streamNames(frigateApp.page))
|
||||||
|
.toEqual(["Sub", "Main"]);
|
||||||
|
|
||||||
|
await dragRow(frigateApp.page, 1, 0);
|
||||||
|
await expect
|
||||||
|
.poll(() => streamNames(frigateApp.page))
|
||||||
|
.toEqual(["Main", "Sub"]);
|
||||||
|
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => capture.saved()).not.toBeNull();
|
||||||
|
const saved = capture.saved() as {
|
||||||
|
replace_paths: string[];
|
||||||
|
config_data: {
|
||||||
|
cameras: { front_door: { live: { streams: Record<string, string> } } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(saved.replace_paths).toEqual(["cameras.front_door.live.streams"]);
|
||||||
|
expect(
|
||||||
|
Object.keys(saved.config_data.cameras.front_door.live.streams),
|
||||||
|
).toEqual(["Main", "Sub"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enabling transcoding saves the shown source", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const capture = await installRoutes(frigateApp.page);
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("switch", { name: "Enable transcoded streams" })
|
||||||
|
.click();
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => capture.saved()).not.toBeNull();
|
||||||
|
const saved = capture.saved() as {
|
||||||
|
config_data: {
|
||||||
|
cameras: {
|
||||||
|
front_door: { live: { transcode: Record<string, unknown> } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// pinned, so dragging another stream to the top can't change it
|
||||||
|
expect(saved.config_data.cameras.front_door.live.transcode).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
source: "front_door_sub",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the transcode switch adds and removes transcoded rows", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installRoutes(frigateApp.page);
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
const toggle = frigateApp.page.getByRole("switch", {
|
||||||
|
name: "Enable transcoded streams",
|
||||||
|
});
|
||||||
|
const save = frigateApp.page.getByRole("button", { name: "Save" });
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => streamNames(frigateApp.page))
|
||||||
|
.toEqual(["Sub", "Main", "720p", "480p"]);
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText("Transcoded", { exact: true }),
|
||||||
|
).toHaveCount(2);
|
||||||
|
await expect(save).toBeEnabled();
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => streamNames(frigateApp.page))
|
||||||
|
.toEqual(["Sub", "Main"]);
|
||||||
|
await expect(save).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auto order sorts measured and set bitrates", async ({ frigateApp }) => {
|
||||||
|
await installRoutes(frigateApp.page, {
|
||||||
|
bitrates: { front_door_main: 4000, front_door_sub: 800 },
|
||||||
|
});
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("switch", { name: "Enable transcoded streams" })
|
||||||
|
.click();
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Auto order" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => streamNames(frigateApp.page))
|
||||||
|
.toEqual(["Main", "720p", "Sub", "480p"]);
|
||||||
|
await expect(frigateApp.page.getByText("4000 kbps")).toBeVisible();
|
||||||
|
await expect(frigateApp.page.getByText("1200 kbps (set)")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unmeasurable stream sorts last", async ({ frigateApp }) => {
|
||||||
|
await installRoutes(frigateApp.page, {
|
||||||
|
bitrates: { front_door_main: 4000 },
|
||||||
|
});
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Auto order" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => streamNames(frigateApp.page))
|
||||||
|
.toEqual(["Main", "Sub"]);
|
||||||
|
await expect(frigateApp.page.getByText("Couldn't measure")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transcoding needs a go2rtc stream", async ({ frigateApp }) => {
|
||||||
|
await installRoutes(frigateApp.page, {
|
||||||
|
go2rtcStreams: {},
|
||||||
|
streams: { front_door: "front_door" },
|
||||||
|
});
|
||||||
|
await frigateApp.goto(SETTINGS_URL);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("switch", {
|
||||||
|
name: "Enable transcoded streams",
|
||||||
|
}),
|
||||||
|
).toBeDisabled();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText(/need a go2rtc stream to read from/),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -217,23 +217,6 @@ test.describe("Detection models settings @high", () => {
|
|||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shareable hardware another model uses can still be picked", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await installRoutes(frigateApp.page, [
|
|
||||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
|
||||||
{ scene: "outdoor", devices: ["openvino:GPU.1"] },
|
|
||||||
]);
|
|
||||||
await openPage(frigateApp);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").first(),
|
|
||||||
).toBeEnabled();
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
|
|
||||||
"used by outdoor",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("adding a model appends a card with an unused scene", async ({
|
test("adding a model appends a card with an unused scene", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -265,13 +248,15 @@ test.describe("Detection models settings @high", () => {
|
|||||||
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
|
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
// the backend resolves plus:// to a cache path before serving the config
|
||||||
|
// back, so the plus metadata is the only signal the model is a Plus one
|
||||||
await installRoutes(
|
await installRoutes(
|
||||||
frigateApp.page,
|
frigateApp.page,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
scene: "all",
|
scene: "all",
|
||||||
devices: ["openvino:GPU.0"],
|
devices: ["openvino:GPU.0"],
|
||||||
path: "plus://abc123",
|
path: "/config/model_cache/abc123",
|
||||||
plus: PLUS_MODEL,
|
plus: PLUS_MODEL,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -317,65 +302,6 @@ test.describe("Detection models settings @high", () => {
|
|||||||
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
|
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("saving a Frigate+ model keeps its reference without the Frigate+ fields", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// the backend fills these in from the Frigate+ model info when it loads
|
|
||||||
const saves = await installRoutes(
|
|
||||||
frigateApp.page,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
scene: "all",
|
|
||||||
devices: ["openvino:GPU.0"],
|
|
||||||
path: "plus://abc123",
|
|
||||||
plus: PLUS_MODEL,
|
|
||||||
width: 320,
|
|
||||||
height: 320,
|
|
||||||
input_tensor: "nchw",
|
|
||||||
input_dtype: "float",
|
|
||||||
model_type: "yolo-generic",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await openPage(frigateApp);
|
|
||||||
|
|
||||||
await frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").click();
|
|
||||||
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
|
|
||||||
await expect.poll(() => saves.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const model = saves.at(-1)?.config_data?.models?.[0];
|
|
||||||
expect(model?.path).toBe("plus://abc123");
|
|
||||||
expect(model?.devices).toEqual(["openvino:GPU.0", "openvino:GPU.1"]);
|
|
||||||
expect(model).not.toHaveProperty("width");
|
|
||||||
expect(model).not.toHaveProperty("input_tensor");
|
|
||||||
expect(model).not.toHaveProperty("model_type");
|
|
||||||
// a leftover dtype from a custom model must not override the int default
|
|
||||||
expect(model).not.toHaveProperty("input_dtype");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a Frigate+ model only shows its path without a Frigate+ API key", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
await installRoutes(frigateApp.page, [
|
|
||||||
{
|
|
||||||
scene: "all",
|
|
||||||
devices: ["openvino:GPU.0"],
|
|
||||||
path: "plus://abc123",
|
|
||||||
width: 320,
|
|
||||||
height: 320,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await openPage(frigateApp);
|
|
||||||
|
|
||||||
const root = frigateApp.page.locator("#pageRoot");
|
|
||||||
await expect(root).toContainText("Custom object detector model path");
|
|
||||||
await expect(root).not.toContainText("Object detection model input width");
|
|
||||||
await expect(root).not.toContainText(
|
|
||||||
"Label map for custom object detector",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a Frigate+ Hailo model is listed by the device it was built for", async ({
|
test("a Frigate+ Hailo model is listed by the device it was built for", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -13,26 +13,7 @@ import type { Page } from "@playwright/test";
|
|||||||
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
||||||
const STREAMING_KEY = "streaming-settings:admin";
|
const STREAMING_KEY = "streaming-settings:admin";
|
||||||
|
|
||||||
// the shape DraggableGridLayout writes
|
const OUTDOOR_LAYOUT = [
|
||||||
const OUTDOOR_LAYOUT = {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: false,
|
|
||||||
layout: [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
|
||||||
{ i: "backyard", x: 32, y: 0, w: 32, h: 72 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const NATURAL_OUTDOOR_LAYOUT = {
|
|
||||||
version: 2,
|
|
||||||
naturalAspect: true,
|
|
||||||
layout: [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
|
||||||
{ i: "backyard", x: 32, y: 0, w: 24, h: 96 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEGACY_OUTDOOR_LAYOUT = [
|
|
||||||
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
||||||
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
||||||
];
|
];
|
||||||
@@ -178,160 +159,6 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
expect(payload.sections.preferences.playbackRate).toBe(2);
|
expect(payload.sections.preferences.playbackRate).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exports only layouts built for the current tile sizing mode", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
// a group not opened since the mode changed still holds a layout from
|
|
||||||
// the other mode, which would import into a mode that cannot show it
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await writeIdb(frigateApp.page, {
|
|
||||||
[OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT,
|
|
||||||
"default-draggable-layout:admin": NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
"naturalAspectLayout:admin": true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("button", { name: "Export Settings" })
|
|
||||||
.click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
const payload = JSON.parse(readFileSync((await download.path())!, "utf-8"));
|
|
||||||
|
|
||||||
expect(payload.sections.layouts).toEqual({
|
|
||||||
default: NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toggling tile sizing mode clears stored layouts", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "The setting is hidden on phones");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT });
|
|
||||||
|
|
||||||
await frigateApp.page.locator("#natural-aspect-desktop").click();
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("alertdialog")
|
|
||||||
.getByRole("button", { name: "Enable" })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY))
|
|
||||||
.toBeNull();
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("round-trips a layout left unconverted by an upgrade", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
// DraggableGridLayout rewrites a pre-0.19 layout only when that group's
|
|
||||||
// dashboard is opened, so exporting first carries the bare array into the
|
|
||||||
// file. Import must accept it back rather than rejecting the whole file.
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await writeIdb(frigateApp.page, {
|
|
||||||
[OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
"playbackRate:admin": 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadPromise = frigateApp.page.waitForEvent("download");
|
|
||||||
await frigateApp.page
|
|
||||||
.getByRole("button", { name: "Export Settings" })
|
|
||||||
.click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
const contents = readFileSync((await download.path())!, "utf-8");
|
|
||||||
|
|
||||||
expect(JSON.parse(contents).sections.layouts.outdoor).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
await clearIdb(frigateApp.page);
|
|
||||||
await chooseImportText(frigateApp.page, contents);
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
// the rest of the file must survive alongside it
|
|
||||||
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("legacy layouts import turns natural aspect off so they display", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
// Bare-array layouts only render in bucketed mode; with natural aspect on
|
|
||||||
// they would be discarded and regenerated on the next dashboard visit. The
|
|
||||||
// import applies the mode the layouts were built for, and the file's own
|
|
||||||
// naturalAspectLayout preference must not override that.
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
|
||||||
|
|
||||||
await chooseImportFile(
|
|
||||||
frigateApp.page,
|
|
||||||
importPayload({
|
|
||||||
sections: {
|
|
||||||
layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT },
|
|
||||||
streaming: {},
|
|
||||||
preferences: { naturalAspectLayout: true },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const note = frigateApp.page.getByText(/standard tile sizing/);
|
|
||||||
await expect(note).toBeVisible();
|
|
||||||
|
|
||||||
// the note is about the layouts section, so it follows its switch
|
|
||||||
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
|
|
||||||
await expect(note).toBeHidden();
|
|
||||||
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
|
|
||||||
await expect(note).toBeVisible();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
LEGACY_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("natural aspect layouts import turns the setting on", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await chooseImportFile(
|
|
||||||
frigateApp.page,
|
|
||||||
importPayload({
|
|
||||||
sections: {
|
|
||||||
layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT },
|
|
||||||
streaming: {},
|
|
||||||
preferences: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText(/camera aspect ratio tile sizing/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
|
||||||
NATURAL_OUTDOOR_LAYOUT,
|
|
||||||
);
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("omits settings that were never stored", async ({ frigateApp }) => {
|
test("omits settings that were never stored", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
await frigateApp.goto("/settings?page=uiSettings");
|
||||||
|
|
||||||
@@ -363,15 +190,12 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.getByText("UI preferences (2 settings)"),
|
frigateApp.page.getByText("UI preferences (2 settings)"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// patio is layout-only, so its warning follows the layouts section
|
await expect(frigateApp.page.getByText(/patio/)).toBeVisible();
|
||||||
await expect(frigateApp.page.getByText(/patio/)).toBeVisible({
|
|
||||||
visible: !frigateApp.isMobile,
|
|
||||||
});
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
await confirmImport(frigateApp.page);
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
);
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||||
STREAMING_SETTINGS,
|
STREAMING_SETTINGS,
|
||||||
@@ -382,7 +206,6 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
test("hides the unknown-group warning when layouts are switched off", async ({
|
test("hides the unknown-group warning when layouts are switched off", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
await frigateApp.goto("/settings?page=uiSettings");
|
||||||
|
|
||||||
// patio is a layout-only group absent from this server, so the warning
|
// patio is a layout-only group absent from this server, so the warning
|
||||||
@@ -412,39 +235,7 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
|
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => {
|
|
||||||
test.skip(!frigateApp.isMobile, "Phone-only");
|
|
||||||
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false });
|
|
||||||
|
|
||||||
await chooseImportFile(frigateApp.page, importPayload());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText(/aren't imported on phones/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// the section is still listed, but cannot be switched on
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("Camera group layouts (2 groups)"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'),
|
|
||||||
).toBeDisabled();
|
|
||||||
|
|
||||||
await confirmImport(frigateApp.page);
|
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
|
||||||
STREAMING_SETTINGS,
|
|
||||||
);
|
|
||||||
// a layouts import is what flips this, so it must stay put
|
|
||||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -534,7 +325,7 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
await confirmImport(frigateApp.page);
|
await confirmImport(frigateApp.page);
|
||||||
|
|
||||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
OUTDOOR_LAYOUT,
|
||||||
);
|
);
|
||||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||||
STREAMING_SETTINGS,
|
STREAMING_SETTINGS,
|
||||||
|
|||||||
@@ -1223,6 +1223,7 @@
|
|||||||
"desc": "Frigate можа сам адпраўляць push-апавяшчэнні на вашу прыладу, калі працуе ў браўзеры або ўсталяваны як PWA."
|
"desc": "Frigate можа сам адпраўляць push-апавяшчэнні на вашу прыладу, калі працуе ў браўзеры або ўсталяваны як PWA."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "Апавяшчэнні недаступныя",
|
||||||
"desc": "Вэб-push-апавяшчэнні патрабуюць бяспечнага кантэксту (<code>https://…</code>). Гэта абмежаванне браўзера. Каб карыстацца апавяшчэннямі, адкрывайце Frigate па бяспечным пратаколе.",
|
"desc": "Вэб-push-апавяшчэнні патрабуюць бяспечнага кантэксту (<code>https://…</code>). Гэта абмежаванне браўзера. Каб карыстацца апавяшчэннямі, адкрывайце Frigate па бяспечным пратаколе.",
|
||||||
"descPwa": "У iOS вэб-push-апавяшчэнні даступныя, толькі калі Frigate дададзены на хатні экран. Адкрыйце меню <strong>Абагуліць</strong>, выберыце <strong>На хатні экран</strong>, а потым адкрыйце Frigate праз новы значок, каб зарэгістраваць гэту прыладу для апавяшчэнняў."
|
"descPwa": "У iOS вэб-push-апавяшчэнні даступныя, толькі калі Frigate дададзены на хатні экран. Адкрыйце меню <strong>Абагуліць</strong>, выберыце <strong>На хатні экран</strong>, а потым адкрыйце Frigate праз новы значок, каб зарэгістраваць гэту прыладу для апавяшчэнняў."
|
||||||
},
|
},
|
||||||
@@ -1330,6 +1331,7 @@
|
|||||||
"triggers": {
|
"triggers": {
|
||||||
"documentTitle": "Трыгеры",
|
"documentTitle": "Трыгеры",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "Семантычны пошук адключаны",
|
||||||
"desc": "Каб карыстацца трыгерамі, трэба ўключыць семантычны пошук."
|
"desc": "Каб карыстацца трыгерамі, трэба ўключыць семантычны пошук."
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
@@ -236,6 +236,8 @@
|
|||||||
},
|
},
|
||||||
"lastRefreshed": "Апошняе абнаўленне: ",
|
"lastRefreshed": "Апошняе абнаўленне: ",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} моцна нагружае CPU праз FFmpeg ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} моцна нагружае CPU дэтэктаваннем ({{detectAvg}}%)",
|
||||||
"healthy": "Сістэма спраўная",
|
"healthy": "Сістэма спраўная",
|
||||||
"reindexingEmbeddings": "Пераіндэксацыя ўбудаванняў (гатова {{processed}}%)",
|
"reindexingEmbeddings": "Пераіндэксацыя ўбудаванняў (гатова {{processed}}%)",
|
||||||
"cameraIsOffline": "{{camera}} па-за сеткай",
|
"cameraIsOffline": "{{camera}} па-за сеткай",
|
||||||
@@ -279,12 +281,20 @@
|
|||||||
"notices": {
|
"notices": {
|
||||||
"title": "Заўвагі",
|
"title": "Заўвагі",
|
||||||
"empty": "Ваша ўсталёўка Frigate у парадку",
|
"empty": "Ваша ўсталёўка Frigate у парадку",
|
||||||
|
"dismiss": "Схаваць",
|
||||||
"openSettings": "Адкрыць налады",
|
"openSettings": "Адкрыць налады",
|
||||||
"openLink": "Адкрыць спасылку",
|
"openLink": "Адкрыць спасылку",
|
||||||
"noMatches": "Няма заўваг, якія адпавядаюць фільтру",
|
"noMatches": "Няма заўваг, якія адпавядаюць фільтру",
|
||||||
|
"dismissedTitle": "Схаваныя",
|
||||||
|
"noneDismissed": "Няма схаваных заўваг",
|
||||||
|
"clearDismissed": "Ачысціць схаваныя",
|
||||||
|
"clearDismissedTitle": "Ачысціць схаваныя заўвагі?",
|
||||||
|
"clearDismissedDesc": "Усе схаваныя заўвагі будуць выдалены. Праверкі канфігурацыі і плыняў пакажуцца адразу, а іншыя заўвагі вернуцца пры наступным узнікненні.",
|
||||||
"firstSeen_one": "Упершыню {{time}}",
|
"firstSeen_one": "Упершыню {{time}}",
|
||||||
"firstSeen_other": "Упершыню {{time}} · {{count}} разу",
|
"firstSeen_other": "Упершыню {{time}} · {{count}} разу",
|
||||||
|
"dismissedAt": "Схавана {{time}}",
|
||||||
"filter": {
|
"filter": {
|
||||||
|
"showDismissed": "Паказаць схаваныя",
|
||||||
"severity": "Узровень важнасці",
|
"severity": "Узровень важнасці",
|
||||||
"error": "Памылка",
|
"error": "Памылка",
|
||||||
"warning": "Папярэджанне",
|
"warning": "Папярэджанне",
|
||||||
|
|||||||
@@ -1037,6 +1037,7 @@
|
|||||||
"desc": "Frigate može nativno slati obavijesti na vaš uređaj kada radi u pregledaču ili je instalirana kao PWA."
|
"desc": "Frigate može nativno slati obavijesti na vaš uređaj kada radi u pregledaču ili je instalirana kao PWA."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "Obavijesti nedostupne",
|
||||||
"desc": "Web obavijesti zahtijevaju sigurni kontekst (<code>https://…</code>). Ovo je ograničenje pregledača. Pristupite Frigate sigurno da biste koristili obavijesti."
|
"desc": "Web obavijesti zahtijevaju sigurni kontekst (<code>https://…</code>). Ovo je ograničenje pregledača. Pristupite Frigate sigurno da biste koristili obavijesti."
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
@@ -1134,6 +1135,7 @@
|
|||||||
"triggers": {
|
"triggers": {
|
||||||
"documentTitle": "Pokretači",
|
"documentTitle": "Pokretači",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "Semantička pretraga je onemogućena",
|
||||||
"desc": "Semantička pretraga mora biti omogućena da biste koristili izazivače."
|
"desc": "Semantička pretraga mora biti omogućena da biste koristili izazivače."
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
@@ -211,6 +211,8 @@
|
|||||||
},
|
},
|
||||||
"lastRefreshed": "Posljednje ažuriranje: ",
|
"lastRefreshed": "Posljednje ažuriranje: ",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za FFmpeg ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za detekciju ({{detectAvg}}%)",
|
||||||
"healthy": "Sistem je zdrav",
|
"healthy": "Sistem je zdrav",
|
||||||
"reindexingEmbeddings": "Ponovno indeksiranje ugrađenih vjerodajnica ({{processed}}% završeno)",
|
"reindexingEmbeddings": "Ponovno indeksiranje ugrađenih vjerodajnica ({{processed}}% završeno)",
|
||||||
"cameraIsOffline": "{{camera}} je offline",
|
"cameraIsOffline": "{{camera}} je offline",
|
||||||
|
|||||||
@@ -119,9 +119,6 @@
|
|||||||
"liveFallbackTimeout": {
|
"liveFallbackTimeout": {
|
||||||
"label": "Temps d'espera per a la reserva del jugador en directe",
|
"label": "Temps d'espera per a la reserva del jugador en directe",
|
||||||
"desc": "Quan el flux en viu d'alta qualitat d'una càmera no està disponible, torneu al mode d'amplada de banda baixa després d'aquests molts segons. Per defecte: 3."
|
"desc": "Quan el flux en viu d'alta qualitat d'una càmera no està disponible, torneu al mode d'amplada de banda baixa després d'aquests molts segons. Per defecte: 3."
|
||||||
},
|
|
||||||
"naturalAspectLayout": {
|
|
||||||
"label": "Utilitza relació natural d'aspecte"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storedLayouts": {
|
"storedLayouts": {
|
||||||
@@ -458,8 +455,9 @@
|
|||||||
},
|
},
|
||||||
"title": "Notificacions",
|
"title": "Notificacions",
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
"desc": "Les notificacions push web requereixen un context segur (https://…). Aquesta és una limitació del navegador. Accedeix a Frigate de manera segura per utilitzar les notificacions.",
|
"title": "Notificacions no disponibles",
|
||||||
"descPwa": "A iOS, les notificacions push web només estàn disponibles quan Frigate està instalat a la pantalla principal. Obre el menú Compartir, selecciona Afegir a la pantalla, i obre Frigate des del nou icona per registrar les notificacions en aquest dispositiu."
|
"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",
|
"unsavedChanges": "Canvis de notificació no desats",
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
@@ -883,7 +881,8 @@
|
|||||||
},
|
},
|
||||||
"addTrigger": "Afegir disaprador",
|
"addTrigger": "Afegir disaprador",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
"desc": "La cerca semàntica ha d'estar activada per a utilitzar els activadors."
|
"desc": "La cerca semàntica ha d'estar activada per a utilitzar els activadors.",
|
||||||
|
"title": "La cerca semàntica està desactivada"
|
||||||
},
|
},
|
||||||
"wizard": {
|
"wizard": {
|
||||||
"title": "Crea un activador",
|
"title": "Crea un activador",
|
||||||
@@ -1545,9 +1544,7 @@
|
|||||||
"preset-record-generic-audio-aac": "Enregistra (Genèric + Àudio a AAC)",
|
"preset-record-generic-audio-aac": "Enregistra (Genèric + Àudio a AAC)",
|
||||||
"preset-record-mjpeg": "Registre - Càmeres MJPEG",
|
"preset-record-mjpeg": "Registre - Càmeres MJPEG",
|
||||||
"preset-record-jpeg": "Registre - Càmeres JPEG",
|
"preset-record-jpeg": "Registre - Càmeres JPEG",
|
||||||
"preset-record-ubiquiti": "Registre - Càmeres Ubiquiti",
|
"preset-record-ubiquiti": "Registre - Càmeres Ubiquiti"
|
||||||
"preset-apple-silicon-h265": "Apple Silicon (H.265)",
|
|
||||||
"preset-apple-silicon-h264": "Apple Silicon (H.264)"
|
|
||||||
},
|
},
|
||||||
"sameAsRecord": "Igual que els arguments de sortida del registre"
|
"sameAsRecord": "Igual que els arguments de sortida del registre"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -239,15 +239,15 @@
|
|||||||
"reindexingEmbeddings": "Reindexant vectors ({{processed}}% completat)",
|
"reindexingEmbeddings": "Reindexant vectors ({{processed}}% completat)",
|
||||||
"healthy": "El sistema és saludable",
|
"healthy": "El sistema és saludable",
|
||||||
"cameraIsOffline": "{{camera}} està fora de línia",
|
"cameraIsOffline": "{{camera}} està fora de línia",
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} te un ús elevat de CPU per FFmpeg ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} te un ús elevat de CPU per la detecció ({{detectAvg}}%)",
|
||||||
"detectIsVerySlow": "{{detect}} és molt lent ({{speed}} ms)",
|
"detectIsVerySlow": "{{detect}} és molt lent ({{speed}} ms)",
|
||||||
"detectIsSlow": "{{detect}} és lent ({{speed}} ms)",
|
"detectIsSlow": "{{detect}} és lent ({{speed}} ms)",
|
||||||
"debugReplayActive": "La sessió de repetició de depuració està activa",
|
"debugReplayActive": "La sessió de repetició de depuració està activa",
|
||||||
"cameraSkippedDetections": "{{camera}} està ometent la detecció en {{pct}}% de fotogrames",
|
"cameraSkippedDetections": "{{camera}} està ometent la detecció en {{pct}}% de fotogrames",
|
||||||
"systemNotices_one": "{{count}} avís del sistema",
|
"systemNotices_one": "{{count}} avís del sistema",
|
||||||
"systemNotices_other": "{{count}} avisos del sistema",
|
"systemNotices_other": "{{count}} avisos del sistema",
|
||||||
"retentionUnmet": "Els enregistraments s'estan suprimint abans que acabi el seu període de retenció a l'espai lliure",
|
"retentionUnmet": "Els enregistraments s'estan suprimint abans que acabi el seu període de retenció a l'espai lliure"
|
||||||
"moreMessages_other": "+{{count}}",
|
|
||||||
"moreMessages_one": "+{{count}}"
|
|
||||||
},
|
},
|
||||||
"enrichments": {
|
"enrichments": {
|
||||||
"title": "Anàlisi avançada",
|
"title": "Anàlisi avançada",
|
||||||
@@ -281,17 +281,24 @@
|
|||||||
"notices": {
|
"notices": {
|
||||||
"title": "Avís",
|
"title": "Avís",
|
||||||
"empty": "La instal·lació de Frigate és saludable",
|
"empty": "La instal·lació de Frigate és saludable",
|
||||||
|
"dismiss": "Descarta",
|
||||||
"openSettings": "Obre la configuració",
|
"openSettings": "Obre la configuració",
|
||||||
"openLink": "Obre l'enllaç",
|
"openLink": "Obre l'enllaç",
|
||||||
"noMatches": "No hi ha avisos que coincideixin amb el filtre",
|
"noMatches": "No hi ha avisos que coincideixin amb el filtre",
|
||||||
|
"dismissedTitle": "Descartat",
|
||||||
|
"noneDismissed": "No hi ha avisos descartats",
|
||||||
|
"clearDismissed": "Neteja descartada",
|
||||||
|
"clearDismissedTitle": "Voleu netejar els avisos descartats?",
|
||||||
|
"clearDismissedDesc": "S'elimina cada avís acomiadat. Les comprovacions de configuració i flux es mostren de nou immediatament, i altres avisos tornen la propera vegada que succeeixin.",
|
||||||
"firstSeen_one": "{{time}} vist per primera vegada",
|
"firstSeen_one": "{{time}} vist per primera vegada",
|
||||||
"firstSeen_other": "{{time}} · {{count}} vegades",
|
"firstSeen_other": "{{time}} · {{count}} vegades",
|
||||||
|
"dismissedAt": "S'ha suprimit {{time}}",
|
||||||
"filter": {
|
"filter": {
|
||||||
|
"showDismissed": "Mostra descartat",
|
||||||
"severity": "Severitat",
|
"severity": "Severitat",
|
||||||
"warning": "Avís",
|
"warning": "Avís",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"info": "Info",
|
"info": "Info"
|
||||||
"showHidden": "Mostra els ocults"
|
|
||||||
},
|
},
|
||||||
"kinds": {
|
"kinds": {
|
||||||
"detector_stuck": "S'ha reiniciat el detector {{detector}} després que deixés de respondre",
|
"detector_stuck": "S'ha reiniciat el detector {{detector}} després que deixés de respondre",
|
||||||
@@ -300,27 +307,12 @@
|
|||||||
"shm_too_low": "L'assignació /dev/shm ({{total}} MB) s'hauria d'augmentar a com a mínim {{min}} MB",
|
"shm_too_low": "L'assignació /dev/shm ({{total}} MB) s'hauria d'augmentar a com a mínim {{min}} MB",
|
||||||
"failed_login_one": "Error en l'intent d'inici de sessió de {{user}}",
|
"failed_login_one": "Error en l'intent d'inici de sessió de {{user}}",
|
||||||
"failed_login_other": "Intents d'inici de sessió fallits per {{user}}",
|
"failed_login_other": "Intents d'inici de sessió fallits per {{user}}",
|
||||||
"update_available": "Frigate {{version}} està disponible",
|
"update_available": "Frigate {{version}} està disponible"
|
||||||
"ffmpeg_high_cpu": "L'ús de la CPU FFmpeg és alt ({{cpu}}% mitjana)",
|
|
||||||
"detect_high_cpu": "L'ús de la CPU de detecció és alt ({{cpu}}% mitjana)"
|
|
||||||
},
|
},
|
||||||
"streamPrefix": "Flux {{index}}: {{message}}",
|
"streamPrefix": "Flux {{index}}: {{message}}",
|
||||||
"streamPrefixRestream": "Flux {{index}} (via go2rtc): {{message}}",
|
"streamPrefixRestream": "Flux {{index}} (via go2rtc): {{message}}",
|
||||||
"streamProbeFailed": "No s'ha pogut provar el flux {{index}}: {{error}}",
|
"streamProbeFailed": "No s'ha pogut provar el flux {{index}}: {{error}}",
|
||||||
"cameraProbeFailed": "No s'han pogut explorar els fluxos: {{error}}",
|
"cameraProbeFailed": "No s'han pogut explorar els fluxos: {{error}}"
|
||||||
"acknowledge": "Agraïment",
|
|
||||||
"acknowledgeHint": "Amaga fins que això torni a passar",
|
|
||||||
"mute": "Silenciar",
|
|
||||||
"muteHint": "No ho mostris mai més",
|
|
||||||
"unmute": "No silenciis",
|
|
||||||
"showAgain": "Torna a mostrar",
|
|
||||||
"hiddenTitle": "Ocultar",
|
|
||||||
"noneHidden": "No hi ha avisos ocults",
|
|
||||||
"showAll": "Mostra-ho tot de nou",
|
|
||||||
"showAllTitle": "Voleu mostrar tots els avisos ocults?",
|
|
||||||
"showAllDesc": "Cada avís reconegut i silenciat torna a la llista d'avisos, incloent-hi les comprovacions de configuració i flux.",
|
|
||||||
"acknowledgedAt": "Reconegut {{time}}",
|
|
||||||
"mutedAt": "Mutat {{time}}"
|
|
||||||
},
|
},
|
||||||
"hardware": {
|
"hardware": {
|
||||||
"title": "Maquinari",
|
"title": "Maquinari",
|
||||||
|
|||||||
@@ -374,6 +374,7 @@
|
|||||||
"desc": "Frigate může nativně odesílat push notifikace do vašeho zařízení, pokud běží v prohlížeči nebo je nainstalován jako PWA (progresivní webová aplikace)."
|
"desc": "Frigate může nativně odesílat push notifikace do vašeho zařízení, pokud běží v prohlížeči nebo je nainstalován jako PWA (progresivní webová aplikace)."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "Notifikace Nedostupné",
|
||||||
"desc": "Webové push notifikace vyžadují zabezpečený kontext (<code>https://…</code>). Jedná se o omezení prohlížeče. Pro použití notifikací přistupujte k Frigate přes zabezpečené připojení."
|
"desc": "Webové push notifikace vyžadují zabezpečený kontext (<code>https://…</code>). Jedná se o omezení prohlížeče. Pro použití notifikací přistupujte k Frigate přes zabezpečené připojení."
|
||||||
},
|
},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
|
|||||||
@@ -50,7 +50,9 @@
|
|||||||
"healthy": "Systém je zdravý",
|
"healthy": "Systém je zdravý",
|
||||||
"reindexingEmbeddings": "Přeindexování vektorů ({{processed}} % dokončeno)",
|
"reindexingEmbeddings": "Přeindexování vektorů ({{processed}} % dokončeno)",
|
||||||
"detectIsSlow": "{{detect}} je pomalé ({{speed}} ms)",
|
"detectIsSlow": "{{detect}} je pomalé ({{speed}} ms)",
|
||||||
"detectIsVerySlow": "{{detect}} je velmi pomalé ({{speed}} ms)"
|
"detectIsVerySlow": "{{detect}} je velmi pomalé ({{speed}} ms)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} má vysoké využití CPU detekcemi ({{detectAvg}} %)",
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} má vyské využití CPU FFmpegem ({{ffmpegAvg}}%)"
|
||||||
},
|
},
|
||||||
"enrichments": {
|
"enrichments": {
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
|
|||||||
@@ -346,71 +346,5 @@
|
|||||||
"bluegrass": "Bluegrass",
|
"bluegrass": "Bluegrass",
|
||||||
"folk_music": "Folkemusik",
|
"folk_music": "Folkemusik",
|
||||||
"echo": "Ekko",
|
"echo": "Ekko",
|
||||||
"pulse": "Puls",
|
"pulse": "Puls"
|
||||||
"funk": "Funk",
|
|
||||||
"middle_eastern_music": "Mellemøstlig musik",
|
|
||||||
"carnatic_music": "Karnatisk musik",
|
|
||||||
"music_of_bollywood": "Bollywood musik",
|
|
||||||
"dental_drill's_drill": "Tandlægebor",
|
|
||||||
"medium_engine": "Mellemstor motor",
|
|
||||||
"heavy_engine": "Kraftig motor",
|
|
||||||
"engine_knocking": "Motorbanken",
|
|
||||||
"engine_starting": "Motor, der starter",
|
|
||||||
"idling": "Tomgang",
|
|
||||||
"accelerating": "Acceleration",
|
|
||||||
"ding-dong": "Ding-dong",
|
|
||||||
"sliding_door": "Skydedør",
|
|
||||||
"tap": "let bank",
|
|
||||||
"cupboard_open_or_close": "Skab åbnes eller lukkes",
|
|
||||||
"drawer_open_or_close": "Skuffe åbnes eller lukkes",
|
|
||||||
"chopping": "Hugning",
|
|
||||||
"frying": "Stegning",
|
|
||||||
"microwave_oven": "Mikrobølgeovn",
|
|
||||||
"water_tap": "Vandhane",
|
|
||||||
"toilet_flush": "Toiletskylning",
|
|
||||||
"electric_toothbrush": "Elektrisk tandbørste",
|
|
||||||
"vacuum_cleaner": "Støvsuger",
|
|
||||||
"keys_jangling": "Nøgleraslen",
|
|
||||||
"electric_shaver": "Elektrisk barbermaskine",
|
|
||||||
"shuffling_cards": "Kortblanding",
|
|
||||||
"typing": "Skrivning (på tastatur)",
|
|
||||||
"computer_keyboard": "Computertastatur",
|
|
||||||
"writing": "Skrivning",
|
|
||||||
"telephone_bell_ringing": "Telefonringen",
|
|
||||||
"telephone_dialing": "Telefonopkald",
|
|
||||||
"dial_tone": "Summetone",
|
|
||||||
"busy_signal": "Optaget-signal",
|
|
||||||
"alarm_clock": "Vækkeur",
|
|
||||||
"civil_defense_siren": "Luftsirene",
|
|
||||||
"buzzer": "Summer",
|
|
||||||
"smoke_detector": "Røgalarm",
|
|
||||||
"fire_alarm": "Brandalarm",
|
|
||||||
"steam_whistle": "Dampfløjte",
|
|
||||||
"mechanisms": "Mekanismer",
|
|
||||||
"ratchet": "Skralde",
|
|
||||||
"tick": "Tik",
|
|
||||||
"tick-tock": "Tik-tak",
|
|
||||||
"gears": "Tandhjul",
|
|
||||||
"pulleys": "Trisser",
|
|
||||||
"sewing_machine": "Symaskine",
|
|
||||||
"mechanical_fan": "Mekanisk blæser",
|
|
||||||
"air_conditioning": "Air Condition",
|
|
||||||
"cash_register": "Kasseapparat",
|
|
||||||
"single-lens_reflex_camera": "Spejlreflekskamera",
|
|
||||||
"jackhammer": "Trykkluftbor",
|
|
||||||
"sawing": "Savning",
|
|
||||||
"filing": "Filning",
|
|
||||||
"sanding": "Slibning",
|
|
||||||
"power_tool": "Elværktøj",
|
|
||||||
"gunshot": "Skud",
|
|
||||||
"machine_gun": "Maskingevær",
|
|
||||||
"fusillade": "Salve",
|
|
||||||
"artillery_fire": "Artilleriild",
|
|
||||||
"cap_gun": "Knaldpistol",
|
|
||||||
"firecracker": "Kanonslag",
|
|
||||||
"burst": "Sprang",
|
|
||||||
"eruption": "Udbrud",
|
|
||||||
"boom": "Bum",
|
|
||||||
"wood": "Træ",
|
|
||||||
"chop": "Hugge"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,6 +580,7 @@
|
|||||||
},
|
},
|
||||||
"title": "Benachrichtigungen",
|
"title": "Benachrichtigungen",
|
||||||
"notificationUnavailable": {
|
"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.",
|
"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.",
|
||||||
"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."
|
"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."
|
||||||
},
|
},
|
||||||
@@ -838,6 +839,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "Semantische Suche ist deaktiviert",
|
||||||
"desc": "Semantische Suche muss aktiviert sein um Auslöser nutzen zu können."
|
"desc": "Semantische Suche muss aktiviert sein um Auslöser nutzen zu können."
|
||||||
},
|
},
|
||||||
"wizard": {
|
"wizard": {
|
||||||
|
|||||||
@@ -254,6 +254,8 @@
|
|||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"healthy": "Das System läuft problemlos",
|
"healthy": "Das System läuft problemlos",
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} hat eine hohe FFmpeg CPU Auslastung ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} hat eine hohe CPU Auslastung bei der Erkennung ({{detectAvg}}%)",
|
||||||
"reindexingEmbeddings": "Neuindizierung von Einbettungen ({{processed}}% erledigt)",
|
"reindexingEmbeddings": "Neuindizierung von Einbettungen ({{processed}}% erledigt)",
|
||||||
"detectIsSlow": "{{detect}} ist langsam ({{speed}} ms)",
|
"detectIsSlow": "{{detect}} ist langsam ({{speed}} ms)",
|
||||||
"detectIsVerySlow": "{{detect}} ist sehr langsam ({{speed}} ms)",
|
"detectIsVerySlow": "{{detect}} ist sehr langsam ({{speed}} ms)",
|
||||||
|
|||||||
@@ -131,7 +131,6 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"clear": "Clear",
|
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copiedToClipboard": "Copied to clipboard",
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
|||||||
@@ -45,7 +45,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"decodedFrames": "Decoded Frames:",
|
"decodedFrames": "Decoded Frames:",
|
||||||
"droppedFrameRate": "Dropped Frame Rate:"
|
"droppedFrameRate": "Dropped Frame Rate:",
|
||||||
|
"stream": "Stream:",
|
||||||
|
"streamAuto": "{{stream}} (auto)"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
|
|||||||
@@ -238,6 +238,30 @@
|
|||||||
"label": "Live stream names",
|
"label": "Live stream names",
|
||||||
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
|
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
|
||||||
},
|
},
|
||||||
|
"transcode": {
|
||||||
|
"label": "Transcoded streams",
|
||||||
|
"description": "Lower-quality live streams transcoded on demand by go2rtc.",
|
||||||
|
"enabled": {
|
||||||
|
"label": "Enable transcoded streams",
|
||||||
|
"description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching."
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Source stream",
|
||||||
|
"description": "go2rtc stream to transcode. Defaults to the first live stream."
|
||||||
|
},
|
||||||
|
"qualities": {
|
||||||
|
"label": "Qualities",
|
||||||
|
"description": "One transcoded stream is added per quality.",
|
||||||
|
"height": {
|
||||||
|
"label": "Height",
|
||||||
|
"description": "Output height in pixels; width follows the source aspect ratio."
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"label": "Bitrate",
|
||||||
|
"description": "Target and maximum video bitrate in kbps."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"height": {
|
"height": {
|
||||||
"label": "Live height",
|
"label": "Live height",
|
||||||
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
|
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
|
||||||
|
|||||||
@@ -594,6 +594,30 @@
|
|||||||
"label": "Live stream names",
|
"label": "Live stream names",
|
||||||
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
|
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
|
||||||
},
|
},
|
||||||
|
"transcode": {
|
||||||
|
"label": "Transcoded streams",
|
||||||
|
"description": "Lower-quality live streams transcoded on demand by go2rtc.",
|
||||||
|
"enabled": {
|
||||||
|
"label": "Enable transcoded streams",
|
||||||
|
"description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching."
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Source stream",
|
||||||
|
"description": "go2rtc stream to transcode. Defaults to the first live stream."
|
||||||
|
},
|
||||||
|
"qualities": {
|
||||||
|
"label": "Qualities",
|
||||||
|
"description": "One transcoded stream is added per quality.",
|
||||||
|
"height": {
|
||||||
|
"label": "Height",
|
||||||
|
"description": "Output height in pixels; width follows the source aspect ratio."
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"label": "Bitrate",
|
||||||
|
"description": "Target and maximum video bitrate in kbps."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"height": {
|
"height": {
|
||||||
"label": "Live height",
|
"label": "Live height",
|
||||||
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
|
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
|
||||||
|
|||||||
@@ -132,6 +132,19 @@
|
|||||||
},
|
},
|
||||||
"stream": {
|
"stream": {
|
||||||
"title": "Stream",
|
"title": "Stream",
|
||||||
|
"auto": {
|
||||||
|
"label": "Auto",
|
||||||
|
"tips": "Switches streams to match your connection",
|
||||||
|
"selected": "Auto · {{stream}}",
|
||||||
|
"floorLabel": "Low bandwidth",
|
||||||
|
"retry": "Try highest quality",
|
||||||
|
"reason": {
|
||||||
|
"bandwidth": "Playing {{stream}} because the connection couldn't keep up with a higher-quality stream",
|
||||||
|
"codec": "Playing {{stream}} because this browser can't play a higher-quality stream",
|
||||||
|
"saveData": "Playing {{stream}} because Data Saver is on",
|
||||||
|
"floor": "Playing the low-bandwidth feed because no stream could keep up"
|
||||||
|
}
|
||||||
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"tips": {
|
"tips": {
|
||||||
"title": "Audio must be output from your camera and configured in go2rtc for this stream."
|
"title": "Audio must be output from your camera and configured in go2rtc for this stream."
|
||||||
|
|||||||
@@ -167,11 +167,6 @@
|
|||||||
"label": "Always Show Camera Names",
|
"label": "Always Show Camera Names",
|
||||||
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
||||||
},
|
},
|
||||||
"naturalAspectLayout": {
|
|
||||||
"label": "Use Natural Aspect Ratios",
|
|
||||||
"desc": "On camera group live view dashboards, size each tile to its camera's own natural aspect ratio. When disabled, cameras are snapped to a standard wide, landscape, or tall tile shape.",
|
|
||||||
"descNote": "Toggling this setting on or off will clear the stored layout for all camera group live dashboards. Manual reconfiguration will be required."
|
|
||||||
},
|
|
||||||
"liveFallbackTimeout": {
|
"liveFallbackTimeout": {
|
||||||
"label": "Live Player Fallback Timeout",
|
"label": "Live Player Fallback Timeout",
|
||||||
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
||||||
@@ -180,14 +175,12 @@
|
|||||||
"storedLayouts": {
|
"storedLayouts": {
|
||||||
"title": "Stored Layouts",
|
"title": "Stored Layouts",
|
||||||
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
|
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
|
||||||
"clearAll": "Clear All Layouts",
|
"clearAll": "Clear All Layouts"
|
||||||
"clearConfirm": "This will clear the stored layout for every camera group in this browser. This cannot be undone."
|
|
||||||
},
|
},
|
||||||
"cameraGroupStreaming": {
|
"cameraGroupStreaming": {
|
||||||
"title": "Camera Group Streaming Settings",
|
"title": "Camera Group Streaming Settings",
|
||||||
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
|
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
|
||||||
"clearAll": "Clear All Streaming Settings",
|
"clearAll": "Clear All Streaming Settings"
|
||||||
"clearConfirm": "This will clear the streaming settings for every camera group in this browser. This cannot be undone."
|
|
||||||
},
|
},
|
||||||
"backupRestore": {
|
"backupRestore": {
|
||||||
"title": "Backup & Restore",
|
"title": "Backup & Restore",
|
||||||
@@ -202,9 +195,6 @@
|
|||||||
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
|
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
|
||||||
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
|
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
|
||||||
"layouts_one": "Camera group layouts ({{count}} group)",
|
"layouts_one": "Camera group layouts ({{count}} group)",
|
||||||
"layoutsPhone": "Camera group layouts aren't imported on phones, which always use the standard grid.",
|
|
||||||
"layoutsModeOn": "These layouts use camera aspect ratio tile sizing, so importing them will also turn on \"Use Natural Aspect Ratios\".",
|
|
||||||
"layoutsModeOff": "These layouts use standard tile sizing, so importing them will also turn off \"Use Natural Aspect Ratios\".",
|
|
||||||
"layouts_other": "Camera group layouts ({{count}} groups)",
|
"layouts_other": "Camera group layouts ({{count}} groups)",
|
||||||
"streaming_one": "Streaming settings ({{count}} camera)",
|
"streaming_one": "Streaming settings ({{count}} camera)",
|
||||||
"streaming_other": "Streaming settings ({{count}} cameras)",
|
"streaming_other": "Streaming settings ({{count}} cameras)",
|
||||||
@@ -1553,7 +1543,27 @@
|
|||||||
"noGo2rtcStreams": "No go2rtc streams configured",
|
"noGo2rtcStreams": "No go2rtc streams configured",
|
||||||
"availableStreams": "Available streams",
|
"availableStreams": "Available streams",
|
||||||
"useCustom": "Use \"{{value}}\"",
|
"useCustom": "Use \"{{value}}\"",
|
||||||
"addStream": "Add stream"
|
"addStream": "Add stream",
|
||||||
|
"reorderHandle": "Drag to reorder",
|
||||||
|
"transcodedBadge": "Transcoded",
|
||||||
|
"autoOrder": "Auto order",
|
||||||
|
"autoOrderTips": "Measure each stream's bitrate and sort highest first",
|
||||||
|
"measuring": "Measuring…",
|
||||||
|
"rate": "{{kbps}} kbps",
|
||||||
|
"rateSet": "{{kbps}} kbps (set)",
|
||||||
|
"rateFailed": "Couldn't measure"
|
||||||
|
},
|
||||||
|
"liveTranscode": {
|
||||||
|
"title": "Transcoded streams",
|
||||||
|
"description": "Adds lower-quality streams that go2rtc transcodes in real time while someone is watching.",
|
||||||
|
"unavailable": "Transcoded streams need a go2rtc stream to read from. Add one under go2rtc streams first.",
|
||||||
|
"enable": "Enable transcoded streams",
|
||||||
|
"source": "Source stream",
|
||||||
|
"height": "Height",
|
||||||
|
"heightOption": "{{height}}p",
|
||||||
|
"bitrate": "Bitrate (kbps)",
|
||||||
|
"addQuality": "Add quality",
|
||||||
|
"removeQuality": "Remove quality"
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"defaultOption": "Use browser timezone"
|
"defaultOption": "Use browser timezone"
|
||||||
@@ -1788,6 +1798,7 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"success": "Settings saved successfully",
|
"success": "Settings saved successfully",
|
||||||
"applied": "Settings applied successfully",
|
"applied": "Settings applied successfully",
|
||||||
|
"go2rtcSyncFailed": "Saved, but go2rtc couldn't be updated. Restart Frigate to apply.",
|
||||||
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
||||||
"error": "Failed to save settings",
|
"error": "Failed to save settings",
|
||||||
"validationError": "Validation failed: {{message}}",
|
"validationError": "Validation failed: {{message}}",
|
||||||
@@ -2063,7 +2074,7 @@
|
|||||||
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
|
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
|
||||||
"description": "The hardware this model runs its detection on.",
|
"description": "The hardware this model runs its detection on.",
|
||||||
"detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.",
|
"detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.",
|
||||||
"unitsDescription": "Each unit runs its own detection process. A unit that can't be shared, such as a Coral, can only be used by one model."
|
"unitsDescription": "Each unit runs its own detection process. A unit already used by another model can not be selected."
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"plus": "Frigate+",
|
"plus": "Frigate+",
|
||||||
|
|||||||
@@ -137,15 +137,6 @@
|
|||||||
"partial": "Se iniciaron {{successful}} de {{total}} exportaciones. Fallidas: {{failedItems}}",
|
"partial": "Se iniciaron {{successful}} de {{total}} exportaciones. Fallidas: {{failedItems}}",
|
||||||
"failed": "No se pudieron iniciar {{total}} exportaciones. Fallidas: {{failedItems}}"
|
"failed": "No se pudieron iniciar {{total}} exportaciones. Fallidas: {{failedItems}}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"stream": {
|
|
||||||
"label": "Calidad",
|
|
||||||
"auto": "Auto",
|
|
||||||
"main": "Original",
|
|
||||||
"sub": "Bajo",
|
|
||||||
"autoDesc": "Usa el stream princial y vuelve a la calidad baja del sub-stream si la principal no está disponible.",
|
|
||||||
"mainDesc": "Exporta sólo la calidad original del stream principal. Los tiempos dónde el stream principal no esté disponible estarán faltantes.",
|
|
||||||
"subDesc": "Exporta sólo la calidad inferior del sub-stream. Los tiempos dónde el sub-steam no está disponible faltarán."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
@@ -191,8 +182,7 @@
|
|||||||
"export": "Exportar",
|
"export": "Exportar",
|
||||||
"markAsReviewed": "Marcar como revisado",
|
"markAsReviewed": "Marcar como revisado",
|
||||||
"deleteNow": "Eliminar ahora",
|
"deleteNow": "Eliminar ahora",
|
||||||
"markAsUnreviewed": "Marcar como no revisado",
|
"markAsUnreviewed": "Marcar como no revisado"
|
||||||
"generateDescription": "Generar descripción"
|
|
||||||
},
|
},
|
||||||
"shareTimestamp": {
|
"shareTimestamp": {
|
||||||
"description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara.",
|
"description": "Comparta una URL con marca de tiempo de la posición actual del reproductor o elija una marca de tiempo personalizada. Tenga en cuenta que esta no es una URL pública para compartir y solo es accesible para los usuarios que tienen acceso a Frigate y a esta cámara.",
|
||||||
@@ -201,11 +191,6 @@
|
|||||||
"custom": "Marca de tiempo personalizada",
|
"custom": "Marca de tiempo personalizada",
|
||||||
"button": "Compartir URL de la marca de tiempo",
|
"button": "Compartir URL de la marca de tiempo",
|
||||||
"shareTitle": "Marca de tiempo de revisión de Frigate: {{camera}}"
|
"shareTitle": "Marca de tiempo de revisión de Frigate: {{camera}}"
|
||||||
},
|
|
||||||
"genaiDescription": {
|
|
||||||
"toast": {
|
|
||||||
"error": "Falló al pedir descripción: {{error}}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"imagePicker": {
|
"imagePicker": {
|
||||||
|
|||||||
@@ -35,25 +35,10 @@
|
|||||||
"submittedFrigatePlus": "Fotograma enviado correctamente a Frigate+"
|
"submittedFrigatePlus": "Fotograma enviado correctamente a Frigate+"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"submitFrigatePlusFailed": "Error al enviar el fotograma a Frigate+",
|
"submitFrigatePlusFailed": "Error al enviar el fotograma a Frigate+"
|
||||||
"playRecordingsFailed": "Falló al reproducir grabaciones(error{{code}}:{{message}})"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"livePlayerRequiredIOSVersion": "Se requiere iOS 17.1 o superior para este tipo de transmisión en vivo.",
|
"livePlayerRequiredIOSVersion": "Se requiere iOS 17.1 o superior para este tipo de transmisión en vivo.",
|
||||||
"noRecordingsFoundForThisTime": "No se encontraron grabaciones para este momento",
|
"noRecordingsFoundForThisTime": "No se encontraron grabaciones para este momento",
|
||||||
"cameraOff": "La cámara está apagada",
|
"cameraOff": "La cámara está apagada"
|
||||||
"quality": {
|
|
||||||
"auto": "Auto",
|
|
||||||
"autoLow": "Reproduciendo con baja calidad (ancho de banda limitado)",
|
|
||||||
"autoLowCodec": "Reproduciendo con baja calidad (Este navegador no soporta la calidad original)",
|
|
||||||
"autoLowSaveData": "Reproduciendo con baja calidad (ahorro de datos)",
|
|
||||||
"main": "Original",
|
|
||||||
"sub": "Baja",
|
|
||||||
"notSupportedBrowser": "No soportado por este navegador",
|
|
||||||
"label": "Calidad",
|
|
||||||
"noAudio": "Sin audio",
|
|
||||||
"audioRate": "{{rate}}kHz audio",
|
|
||||||
"audioCodecRate": "{{codec}}{{rate}}kHz",
|
|
||||||
"noRecordings": "Sin grabaciones en este rango de tiempo"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,9 +496,6 @@
|
|||||||
"annotation_offset": {
|
"annotation_offset": {
|
||||||
"label": "Desplazamiento de anotaciones",
|
"label": "Desplazamiento de anotaciones",
|
||||||
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
|
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
|
||||||
},
|
|
||||||
"scene": {
|
|
||||||
"label": "Detectar escena"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"record": {
|
"record": {
|
||||||
@@ -602,19 +599,6 @@
|
|||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "Estado de grabación original",
|
"label": "Estado de grabación original",
|
||||||
"description": "Indica si la grabación estaba habilitada en la configuración estática original."
|
"description": "Indica si la grabación estaba habilitada en la configuración estática original."
|
||||||
},
|
|
||||||
"sub": {
|
|
||||||
"continuous": {
|
|
||||||
"days": {
|
|
||||||
"label": "Días de retención",
|
|
||||||
"description": "Días a retener las grabaciones."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"motion": {
|
|
||||||
"days": {
|
|
||||||
"label": "Días de retención"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
@@ -751,9 +735,6 @@
|
|||||||
"order": {
|
"order": {
|
||||||
"label": "Posición",
|
"label": "Posición",
|
||||||
"description": "Posición numérica que controla el orden de la cámara en el diseño de Birdseye."
|
"description": "Posición numérica que controla el orden de la cámara en el diseño de Birdseye."
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"label": "Tipos de actividad"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
|
|||||||
@@ -711,9 +711,6 @@
|
|||||||
"annotation_offset": {
|
"annotation_offset": {
|
||||||
"label": "Desplazamiento de anotaciones",
|
"label": "Desplazamiento de anotaciones",
|
||||||
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
|
"description": "Milisegundos para desplazar las anotaciones de detección y alinear mejor los cuadros delimitadores de la línea de tiempo con las grabaciones; puede ser positivo o negativo."
|
||||||
},
|
|
||||||
"scene": {
|
|
||||||
"label": "Detectar escena"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"record": {
|
"record": {
|
||||||
@@ -817,20 +814,7 @@
|
|||||||
"label": "Estado de grabación original",
|
"label": "Estado de grabación original",
|
||||||
"description": "Indica si la grabación estaba habilitada en la configuración estática original."
|
"description": "Indica si la grabación estaba habilitada en la configuración estática original."
|
||||||
},
|
},
|
||||||
"description": "Ajustes de grabación y retención aplicados a las cámaras salvo que se sobrescriban por cámara.",
|
"description": "Ajustes de grabación y retención aplicados a las cámaras salvo que se sobrescriban por cámara."
|
||||||
"sub": {
|
|
||||||
"continuous": {
|
|
||||||
"days": {
|
|
||||||
"label": "Días de retención",
|
|
||||||
"description": "Días a retener las grabaciones."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"motion": {
|
|
||||||
"days": {
|
|
||||||
"label": "Días de retención"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"camera_ui": {
|
"camera_ui": {
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -1054,9 +1038,6 @@
|
|||||||
"idle_heartbeat_fps": {
|
"idle_heartbeat_fps": {
|
||||||
"label": "FPS de latido en reposo",
|
"label": "FPS de latido en reposo",
|
||||||
"description": "Fotogramas por segundo para reenviar el último fotograma compuesto de Birdseye en reposo; establécelo en 0 para deshabilitarlo."
|
"description": "Fotogramas por segundo para reenviar el último fotograma compuesto de Birdseye en reposo; establécelo en 0 para deshabilitarlo."
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"label": "Tipos de actividad"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
|
|||||||
@@ -578,6 +578,7 @@
|
|||||||
"desc": "Frigate puede enviar notificaciones push a tu dispositivo de forma nativa cuando se ejecuta en el navegador o está instalado como una PWA."
|
"desc": "Frigate puede enviar notificaciones push a tu dispositivo de forma nativa cuando se ejecuta en el navegador o está instalado como una PWA."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "Notificaciones no disponibles",
|
||||||
"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."
|
"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."
|
||||||
},
|
},
|
||||||
@@ -829,6 +830,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "Búsqueda semántica desactivada",
|
||||||
"desc": "Búsqueda semántica debe estar activada para usar Disparadores."
|
"desc": "Búsqueda semántica debe estar activada para usar Disparadores."
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
|
|||||||
@@ -254,6 +254,8 @@
|
|||||||
"averageInf": "Tiempo promedio de inferencia"
|
"averageInf": "Tiempo promedio de inferencia"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} tiene un uso elevado de CPU por FFmpeg ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} tiene un uso elevado de CPU por detección ({{detectAvg}}%)",
|
||||||
"healthy": "El sistema está saludable",
|
"healthy": "El sistema está saludable",
|
||||||
"reindexingEmbeddings": "Reindexando empotrados ({{processed}}% completado)",
|
"reindexingEmbeddings": "Reindexando empotrados ({{processed}}% completado)",
|
||||||
"detectIsSlow": "{{detect}} es lento ({{speed}} ms)",
|
"detectIsSlow": "{{detect}} es lento ({{speed}} ms)",
|
||||||
|
|||||||
@@ -21,12 +21,7 @@
|
|||||||
"lowBandwidthMode": "Väikese ribalaiusega režiim",
|
"lowBandwidthMode": "Väikese ribalaiusega režiim",
|
||||||
"twoWayTalk": {
|
"twoWayTalk": {
|
||||||
"enable": "Lülita kahepoolne kõneside sisse",
|
"enable": "Lülita kahepoolne kõneside sisse",
|
||||||
"disable": "Lülita kahepoolne kõneside välja",
|
"disable": "Lülita kahepoolne kõneside välja"
|
||||||
"requiresWebRTC": "Kahepoolne suhtlus eeldab WebRTC kasutamist, aga see poel saadaval",
|
|
||||||
"error": {
|
|
||||||
"microphone": "Kahepoolsel suhtlusel puudub ligipääs sinu mikrofonile",
|
|
||||||
"refused": "Kahepoolse suhtluse käivitamine ei õnnestunud. Lisateavet leiad veebibrauseri konsoolist."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"cameraAudio": {
|
"cameraAudio": {
|
||||||
"enable": "Lülita kaamera heli sisse",
|
"enable": "Lülita kaamera heli sisse",
|
||||||
@@ -117,36 +112,10 @@
|
|||||||
"title": "Voogedastus",
|
"title": "Voogedastus",
|
||||||
"lowBandwidth": {
|
"lowBandwidth": {
|
||||||
"resetStream": "Lähtesta voogedastus",
|
"resetStream": "Lähtesta voogedastus",
|
||||||
"tips": "Reaalaja pilt on puhverdamise või voogedastuse vigade tõttu madala ribalaiusega režiimis.",
|
"tips": "Reaalaja pilt on puhverdamise või voogedastuse vigade tõttu madala ribalaiusega režiimis."
|
||||||
"force": {
|
|
||||||
"label": "Kasuta sundkorras väikese ribalaiusega režiimi",
|
|
||||||
"desc": "Valitud voogedastuse asemel esita sisu alati Frigate'i sisseehitatud väikese ribalaiusega voogedastusena. Toimib igasuguste ühendustega, aga pildikvaliteet on kehvem ning heliriba pole kasutusel."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"picker": "Voogedastuse osa valik pole silumisrežiimis saadaval. Silumisvaade kasutab alati voogedastust, millele on määratud tuvastamisroll.",
|
"picker": "Voogedastuse osa valik pole silumisrežiimis saadaval. Silumisvaade kasutab alati voogedastust, millele on määratud tuvastamisroll."
|
||||||
"technology": "Voogedastuse tehnoloogia valik pole silumisrežiimis saadaval."
|
|
||||||
},
|
|
||||||
"mode": "Voogedastuse tehnoloogia",
|
|
||||||
"technology": {
|
|
||||||
"description": "Vali eelistatud voogedastuse tehnoloogia. Taasesituse ja võrguvigade puhul võib Frigate tagavaravariandina kasutada väikese ribalaiusega režiimi.",
|
|
||||||
"name": {
|
|
||||||
"mse": "MSE",
|
|
||||||
"webrtc": "WebRTC",
|
|
||||||
"jsmpeg": "JSMpeg"
|
|
||||||
},
|
|
||||||
"tips": {
|
|
||||||
"mse": "Soovitatud vaikimisi väärtus, millel on lai ühilduvus ja sujuv taasesitus",
|
|
||||||
"webrtc": "Vajab täiendavat seadistust ja pole igas seadmes toetatud"
|
|
||||||
},
|
|
||||||
"unavailable": {
|
|
||||||
"browser": "Sinu veebibrauseris puudub WebRTC tugi.",
|
|
||||||
"not-configured": "WebRTC pole seadistatud. Seadista go2rtc webrtc kandidaadid või ice_server teenused.",
|
|
||||||
"unreachable": "WebRTC ühendamine ei õnnestunud. Kontrolli, et port 8555 on kättesaadav ja STUN/TURN-serveri seadistus on õige.",
|
|
||||||
"video-codec": "Selles veebibrauseris puudub WebRTC jaoks antud voogedastuse videokoodeki tugi.",
|
|
||||||
"audio-codec": "WebRTC ei toeta selle voogedastuse helikoodekit. Kodeeri go2rtc abil opus või G.711 vormingusse.",
|
|
||||||
"checking": "Kontrollin WebRTC saadavust…"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": "Teavitused",
|
"notifications": "Teavitused",
|
||||||
|
|||||||
@@ -1102,6 +1102,7 @@
|
|||||||
"desc": "Frigate میتواند بهصورت بومی وقتی در مرورگر اجرا میشود یا بهعنوان PWA نصب شده است، اعلانهای پوش را به دستگاه شما ارسال کند."
|
"desc": "Frigate میتواند بهصورت بومی وقتی در مرورگر اجرا میشود یا بهعنوان PWA نصب شده است، اعلانهای پوش را به دستگاه شما ارسال کند."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "اعلانها در دسترس نیستند",
|
||||||
"desc": "اعلانهای پوش وب نیاز به یک بستر امن دارند ( <code>https://… </code>). این محدودیت مرورگر است. برای استفاده از اعلانها، بهصورت امن به Frigate دسترسی پیدا کنید.",
|
"desc": "اعلانهای پوش وب نیاز به یک بستر امن دارند ( <code>https://… </code>). این محدودیت مرورگر است. برای استفاده از اعلانها، بهصورت امن به Frigate دسترسی پیدا کنید.",
|
||||||
"descPwa": "در iOS، اعلانهای وب پوش فقط زمانی در دسترس هستند که فریگیت روی صفحه اصلی شما نصب شده باشد. منوی <strong>اشتراکگذاری</strong> را باز کنید، <strong>افزودن به صفحه اصلی</strong> را انتخاب کنید، سپس فریگیت را از آیکون جدید باز کنید تا این دستگاه برای اعلانها ثبت شود."
|
"descPwa": "در iOS، اعلانهای وب پوش فقط زمانی در دسترس هستند که فریگیت روی صفحه اصلی شما نصب شده باشد. منوی <strong>اشتراکگذاری</strong> را باز کنید، <strong>افزودن به صفحه اصلی</strong> را انتخاب کنید، سپس فریگیت را از آیکون جدید باز کنید تا این دستگاه برای اعلانها ثبت شود."
|
||||||
},
|
},
|
||||||
@@ -1209,6 +1210,7 @@
|
|||||||
"triggers": {
|
"triggers": {
|
||||||
"documentTitle": "تریگرها",
|
"documentTitle": "تریگرها",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "جستجوی معنایی غیرفعال است",
|
||||||
"desc": "برای استفاده از تریگرها باید جستجوی معنایی فعال باشد."
|
"desc": "برای استفاده از تریگرها باید جستجوی معنایی فعال باشد."
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
@@ -226,6 +226,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} استفادهٔ CPU بالایی برای FFmpeg دارد ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} استفادهٔ CPU بالایی برای تشخیص دارد ({{detectAvg}}%)",
|
||||||
"reindexingEmbeddings": "بازتولید نمایهٔ embeddingها ({{processed}}% تکمیل شده)",
|
"reindexingEmbeddings": "بازتولید نمایهٔ embeddingها ({{processed}}% تکمیل شده)",
|
||||||
"cameraIsOffline": "{{camera}} آفلاین است",
|
"cameraIsOffline": "{{camera}} آفلاین است",
|
||||||
"detectIsVerySlow": "{{detect}} بسیار کند است ({{speed}} ms)",
|
"detectIsVerySlow": "{{detect}} بسیار کند است ({{speed}} ms)",
|
||||||
|
|||||||
@@ -181,6 +181,7 @@
|
|||||||
"desc": "Frigate peut envoyer nativement des notifications push à votre appareil lorsqu'il est exécuté dans le navigateur ou installé en tant que PWA."
|
"desc": "Frigate peut envoyer nativement des notifications push à votre appareil lorsqu'il est exécuté dans le navigateur ou installé en tant que PWA."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "Notifications indisponibles",
|
||||||
"desc": "Les notifications push Web nécessitent un contexte sécurisé (<code>https://…</code>). Il s'agit d'une limitation du navigateur. Accédez à Frigate en toute sécurité pour utiliser les notifications."
|
"desc": "Les notifications push Web nécessitent un contexte sécurisé (<code>https://…</code>). Il s'agit d'une limitation du navigateur. Accédez à Frigate en toute sécurité pour utiliser les notifications."
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
@@ -796,6 +797,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "La recherche sémantique est désactivée",
|
||||||
"desc": "La recherche sémantique doit être activée pour utiliser les déclencheurs."
|
"desc": "La recherche sémantique doit être activée pour utiliser les déclencheurs."
|
||||||
},
|
},
|
||||||
"wizard": {
|
"wizard": {
|
||||||
|
|||||||
@@ -227,6 +227,8 @@
|
|||||||
},
|
},
|
||||||
"lastRefreshed": "Dernier rafraichissement : ",
|
"lastRefreshed": "Dernier rafraichissement : ",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} a un taux élevé d'utilisation processeur par FFmpeg ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} : charge CPU détection élevée ({{detectAvg}}%)",
|
||||||
"healthy": "Le système est sain",
|
"healthy": "Le système est sain",
|
||||||
"reindexingEmbeddings": "Réindexation des embeddings ({{processed}} % terminée)",
|
"reindexingEmbeddings": "Réindexation des embeddings ({{processed}} % terminée)",
|
||||||
"cameraIsOffline": "{{camera}} est hors ligne",
|
"cameraIsOffline": "{{camera}} est hors ligne",
|
||||||
|
|||||||
@@ -531,6 +531,7 @@
|
|||||||
"desc": "Frigate יכולה לשלוח התראות דחיפה באופן טבעי למכשיר שלך כאשר הוא פועל בדפדפן או מותקן כ-PWA."
|
"desc": "Frigate יכולה לשלוח התראות דחיפה באופן טבעי למכשיר שלך כאשר הוא פועל בדפדפן או מותקן כ-PWA."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "התראות לא זמינות",
|
||||||
"desc": "התראות דחיפה באינטרנט דורשות קישור מאובטח (<code>https://…</code>). זוהי מגבלה של הדפדפן. יש לגשת ל-Frigate בצורה מאובטחת כדי להשתמש בהתראות."
|
"desc": "התראות דחיפה באינטרנט דורשות קישור מאובטח (<code>https://…</code>). זוהי מגבלה של הדפדפן. יש לגשת ל-Frigate בצורה מאובטחת כדי להשתמש בהתראות."
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
@@ -948,6 +949,7 @@
|
|||||||
"triggers": {
|
"triggers": {
|
||||||
"documentTitle": "טריגרים",
|
"documentTitle": "טריגרים",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "חיפוש סמנטי מושבת",
|
||||||
"desc": "כדי להשתמש בטריגרים, יש להפעיל חיפוש סמנטי."
|
"desc": "כדי להשתמש בטריגרים, יש להפעיל חיפוש סמנטי."
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"lastRefreshed": "רענון אחרון: ",
|
"lastRefreshed": "רענון אחרון: ",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "ל-{{camera}} יש צריכת מעבד גבוהה של FFmpeg ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "ל-{{camera}} יש צריכת CPU גבוהה ({{detectAvg}}%)",
|
||||||
"healthy": "המערכת פועלת בצורה תקינה",
|
"healthy": "המערכת פועלת בצורה תקינה",
|
||||||
"reindexingEmbeddings": "אינדקס מחדש של ההטמעות ({{processed}}% הושלם)",
|
"reindexingEmbeddings": "אינדקס מחדש של ההטמעות ({{processed}}% הושלם)",
|
||||||
"cameraIsOffline": "{{camera}} לא זמינה",
|
"cameraIsOffline": "{{camera}} לא זמינה",
|
||||||
|
|||||||
@@ -855,6 +855,7 @@
|
|||||||
"desc": "Frigate može nativno slati push obavijesti na vaš uređaj kada je pokrenut u pregledniku ili instaliran kao PWA."
|
"desc": "Frigate može nativno slati push obavijesti na vaš uređaj kada je pokrenut u pregledniku ili instaliran kao PWA."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "Obavijesti nisu dostupne",
|
||||||
"desc": "Web push obavijesti zahtijevaju siguran kontekst (<code>https://...</code>). Ovo je ograničenje preglednika. Pristupite Frigateu sigurno kako biste koristili obavijesti."
|
"desc": "Web push obavijesti zahtijevaju siguran kontekst (<code>https://...</code>). Ovo je ograničenje preglednika. Pristupite Frigateu sigurno kako biste koristili obavijesti."
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
@@ -945,6 +946,7 @@
|
|||||||
"triggers": {
|
"triggers": {
|
||||||
"documentTitle": "Okidači",
|
"documentTitle": "Okidači",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "Semantičko pretraživanje je onemogućeno",
|
||||||
"desc": "Semantičko pretraživanje mora biti omogućeno za korištenje okidača."
|
"desc": "Semantičko pretraživanje mora biti omogućeno za korištenje okidača."
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
@@ -164,6 +164,8 @@
|
|||||||
},
|
},
|
||||||
"lastRefreshed": "Zadnje osvježavanje: ",
|
"lastRefreshed": "Zadnje osvježavanje: ",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} ima visoku upotrebu CPU-a za FFmpeg ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} ima visoku upotrebu CPU-a za detekciju ({{detectAvg}}%)",
|
||||||
"healthy": "Sustav je zdrav",
|
"healthy": "Sustav je zdrav",
|
||||||
"reindexingEmbeddings": "Reindeksiranje embeddings ({{processed}}% završeno)",
|
"reindexingEmbeddings": "Reindeksiranje embeddings ({{processed}}% završeno)",
|
||||||
"cameraIsOffline": "{{camera}} je offline",
|
"cameraIsOffline": "{{camera}} je offline",
|
||||||
|
|||||||
@@ -210,6 +210,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "Értesítés elérhetetlen",
|
||||||
"desc": "A webes push értesítésekhez biztonságos környezet (<code>https://…</code>) szükséges. Ez egy böngészői korlátozás. A értesítések használatához férjen hozzá biztonságosan a Frigate-hez."
|
"desc": "A webes push értesítésekhez biztonságos környezet (<code>https://…</code>) szükséges. Ez egy böngészői korlátozás. A értesítések használatához férjen hozzá biztonságosan a Frigate-hez."
|
||||||
},
|
},
|
||||||
"sendTestNotification": "Teszt értesítés küldése",
|
"sendTestNotification": "Teszt értesítés küldése",
|
||||||
@@ -710,6 +711,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "Szemantikus keresés le van tiltva",
|
||||||
"desc": "A Triggerek használatához engedélyezni kell a szemantikus keresést."
|
"desc": "A Triggerek használatához engedélyezni kell a szemantikus keresést."
|
||||||
},
|
},
|
||||||
"wizard": {
|
"wizard": {
|
||||||
|
|||||||
@@ -201,7 +201,9 @@
|
|||||||
"healthy": "A rendszer egészséges",
|
"healthy": "A rendszer egészséges",
|
||||||
"cameraIsOffline": "{{camera}} nem elérhető",
|
"cameraIsOffline": "{{camera}} nem elérhető",
|
||||||
"detectIsSlow": "{{detect}} lassú ({{speed}} ms)",
|
"detectIsSlow": "{{detect}} lassú ({{speed}} ms)",
|
||||||
"reindexingEmbeddings": "Beágyazások újra indexelése ({{processed}}% kész)"
|
"reindexingEmbeddings": "Beágyazások újra indexelése ({{processed}}% kész)",
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}}-nak/-nek magas FFmpeg CPU felhasználása ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "A(z) {{camera}} kameránál magas az észlelési CPU-használat ({{detectAvg}}%)"
|
||||||
},
|
},
|
||||||
"lastRefreshed": "Utoljára frissítve: "
|
"lastRefreshed": "Utoljára frissítve: "
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,7 @@
|
|||||||
"lowBandwidthMode": "Mode bandwidth rendah",
|
"lowBandwidthMode": "Mode bandwidth rendah",
|
||||||
"twoWayTalk": {
|
"twoWayTalk": {
|
||||||
"enable": "Aktifkan Audio Dua Arah",
|
"enable": "Aktifkan Audio Dua Arah",
|
||||||
"disable": "Nonaktifkan Audio Dua Arah",
|
"disable": "Nonaktifkan Audio Dua Arah"
|
||||||
"requiresWebRTC": "Percakapan dua arah memerlukan WebRTC, yang tidak tersedia",
|
|
||||||
"error": {
|
|
||||||
"microphone": "Percakapan dua arah tidak dapat mengakses mikrofon Anda",
|
|
||||||
"refused": "Percakapan dua arah gagal dimulai. Lihat konsol browser untuk detail."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"cameraAudio": {
|
"cameraAudio": {
|
||||||
"enable": "Aktifkan Audio Kamera",
|
"enable": "Aktifkan Audio Kamera",
|
||||||
@@ -136,8 +131,7 @@
|
|||||||
"unavailable": "Audio tidak tersedia untuk stream ini"
|
"unavailable": "Audio tidak tersedia untuk stream ini"
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"picker": "Pemilihan stream tidak tersedia dalam mode debug. Tampilan debug selalu menggunakan stream yang ditetapkan ke peran detect.",
|
"picker": "Pemilihan stream tidak tersedia dalam mode debug. Tampilan debug selalu menggunakan stream yang ditetapkan ke peran detect."
|
||||||
"technology": "Pemilihan teknologi streaming tidak tersedia dalam mode debug."
|
|
||||||
},
|
},
|
||||||
"twoWayTalk": {
|
"twoWayTalk": {
|
||||||
"tips": "Perangkat Anda harus mendukung fitur ini dan WebRTC harus dikonfigurasi untuk audio dua arah.",
|
"tips": "Perangkat Anda harus mendukung fitur ini dan WebRTC harus dikonfigurasi untuk audio dua arah.",
|
||||||
@@ -146,36 +140,11 @@
|
|||||||
},
|
},
|
||||||
"lowBandwidth": {
|
"lowBandwidth": {
|
||||||
"tips": "Tampilan live berada dalam mode bandwidth rendah karena buffering atau kesalahan stream.",
|
"tips": "Tampilan live berada dalam mode bandwidth rendah karena buffering atau kesalahan stream.",
|
||||||
"resetStream": "Atur ulang stream",
|
"resetStream": "Atur ulang stream"
|
||||||
"force": {
|
|
||||||
"label": "Paksa mode bandwidth rendah",
|
|
||||||
"desc": "Selalu putar feed bandwidth rendah bawaan Frigate, bukan stream yang dipilih. Berfungsi pada semua koneksi, tetapi kualitasnya lebih rendah dan tanpa audio."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"playInBackground": {
|
"playInBackground": {
|
||||||
"label": "Putar di latar belakang",
|
"label": "Putar di latar belakang",
|
||||||
"tips": "Aktifkan opsi ini untuk melanjutkan streaming saat pemutar disembunyikan."
|
"tips": "Aktifkan opsi ini untuk melanjutkan streaming saat pemutar disembunyikan."
|
||||||
},
|
|
||||||
"mode": "Teknologi Streaming",
|
|
||||||
"technology": {
|
|
||||||
"description": "Pilih teknologi streaming yang Anda inginkan. Frigate mungkin tetap beralih ke mode bandwidth rendah jika terjadi kesalahan pemutaran atau jaringan.",
|
|
||||||
"name": {
|
|
||||||
"mse": "MSE",
|
|
||||||
"webrtc": "WebRTC",
|
|
||||||
"jsmpeg": "JSMpeg"
|
|
||||||
},
|
|
||||||
"tips": {
|
|
||||||
"mse": "Default yang direkomendasikan, kompatibilitas luas dan pemutaran lancar",
|
|
||||||
"webrtc": "Memerlukan pengaturan tambahan dan tidak didukung pada setiap perangkat"
|
|
||||||
},
|
|
||||||
"unavailable": {
|
|
||||||
"browser": "Browser Anda tidak mendukung WebRTC.",
|
|
||||||
"not-configured": "WebRTC belum dikonfigurasi. Atur candidates WebRTC go2rtc atau ice_servers.",
|
|
||||||
"unreachable": "WebRTC tidak dapat terhubung. Pastikan port 8555 dapat dijangkau dan server STUN/TURN telah dikonfigurasi dengan benar.",
|
|
||||||
"video-codec": "Codec video stream ini tidak didukung oleh WebRTC pada browser ini.",
|
|
||||||
"audio-codec": "Codec audio stream ini tidak didukung oleh WebRTC. Lakukan transcoding ke Opus atau G.711 menggunakan go2rtc.",
|
|
||||||
"checking": "Memeriksa ketersediaan WebRTC…"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cameraSettings": {
|
"cameraSettings": {
|
||||||
@@ -185,8 +154,7 @@
|
|||||||
"snapshots": "Snapshot",
|
"snapshots": "Snapshot",
|
||||||
"audioDetection": "Deteksi Audio",
|
"audioDetection": "Deteksi Audio",
|
||||||
"transcription": "Transkripsi Audio",
|
"transcription": "Transkripsi Audio",
|
||||||
"autotracking": "Pelacakan Otomatis",
|
"autotracking": "Pelacakan Otomatis"
|
||||||
"camera": "Kamera"
|
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"label": "Tampilkan rekaman historis"
|
"label": "Tampilkan rekaman historis"
|
||||||
@@ -223,9 +191,5 @@
|
|||||||
"description": "Grup kamera ini tidak memiliki kamera yang ditetapkan atau diaktifkan.",
|
"description": "Grup kamera ini tidak memiliki kamera yang ditetapkan atau diaktifkan.",
|
||||||
"buttonText": "Kelola Grup"
|
"buttonText": "Kelola Grup"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"camera": {
|
|
||||||
"turnOn": "Nyalakan Kamera",
|
|
||||||
"turnOff": "Matikan Kamera"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
"systemTelemetry": "Telemetri",
|
"systemTelemetry": "Telemetri",
|
||||||
"systemBirdseye": "Birdseye",
|
"systemBirdseye": "Birdseye",
|
||||||
"systemFfmpeg": "FFmpeg",
|
"systemFfmpeg": "FFmpeg",
|
||||||
"systemDetectorsAndModel": "Model deteksi",
|
"systemDetectorsAndModel": "Detektor dan model",
|
||||||
"systemMqtt": "MQTT",
|
"systemMqtt": "MQTT",
|
||||||
"systemGo2rtcStreams": "Stream go2rtc",
|
"systemGo2rtcStreams": "Stream go2rtc",
|
||||||
"integrationSemanticSearch": "Pencarian semantik",
|
"integrationSemanticSearch": "Pencarian semantik",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"integrationObjectClassification": "Klasifikasi objek",
|
"integrationObjectClassification": "Klasifikasi objek",
|
||||||
"integrationAudioTranscription": "Transkripsi audio",
|
"integrationAudioTranscription": "Transkripsi audio",
|
||||||
"cameraDetect": "Deteksi objek",
|
"cameraDetect": "Deteksi objek",
|
||||||
"cameraFfmpeg": "Stream (FFmpeg)",
|
"cameraFfmpeg": "FFmpeg",
|
||||||
"cameraRecording": "Perekaman",
|
"cameraRecording": "Perekaman",
|
||||||
"cameraSnapshots": "Cuplikan",
|
"cameraSnapshots": "Cuplikan",
|
||||||
"cameraMotion": "Deteksi gerakan",
|
"cameraMotion": "Deteksi gerakan",
|
||||||
@@ -150,48 +150,17 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"clearStoredLayout": "Tata letak tersimpan untuk {{cameraName}} telah dihapus",
|
"clearStoredLayout": "Tata letak tersimpan untuk {{cameraName}} telah dihapus",
|
||||||
"clearStreamingSettings": "Pengaturan streaming untuk semua grup kamera telah dihapus.",
|
"clearStreamingSettings": "Pengaturan streaming untuk semua grup kamera telah dihapus."
|
||||||
"exportUiSettings": "Pengaturan diekspor",
|
|
||||||
"importUiSettings": "Pengaturan diimpor"
|
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"clearStoredLayoutFailed": "Gagal menghapus tata letak tersimpan: {{errorMessage}}",
|
"clearStoredLayoutFailed": "Gagal menghapus tata letak tersimpan: {{errorMessage}}",
|
||||||
"clearStreamingSettingsFailed": "Gagal menghapus pengaturan streaming: {{errorMessage}}",
|
"clearStreamingSettingsFailed": "Gagal menghapus pengaturan streaming: {{errorMessage}}"
|
||||||
"exportUiSettingsFailed": "Gagal mengekspor pengaturan",
|
|
||||||
"importNothingToApply": "Gagal mengimpor pengaturan: file tidak berisi pengaturan",
|
|
||||||
"importInvalidJson": "Gagal mengimpor pengaturan: file bukan JSON yang valid",
|
|
||||||
"importWrongType": "Gagal mengimpor pengaturan: file bukan ekspor pengaturan Frigate",
|
|
||||||
"importUnsupportedVersion": "Gagal mengimpor pengaturan: file memerlukan versi Frigate yang lebih baru",
|
|
||||||
"importInvalidSchema": "Gagal mengimpor pengaturan: file tidak valid",
|
|
||||||
"importUiSettingsFailed": "Gagal mengimpor pengaturan"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"backupRestore": {
|
|
||||||
"title": "Pencadangan & Pemulihan",
|
|
||||||
"transfer": {
|
|
||||||
"label": "File Pengaturan Perangkat",
|
|
||||||
"desc": "Tata letak grup kamera, pengaturan streaming, dan preferensi UI disimpan di browser Anda. Ekspor ke file untuk mencadangkannya atau memindahkannya ke perangkat lain.",
|
|
||||||
"export": "Ekspor Pengaturan",
|
|
||||||
"import": "Impor Pengaturan"
|
|
||||||
},
|
|
||||||
"importDialog": {
|
|
||||||
"title": "Impor Pengaturan",
|
|
||||||
"desc": "Pilih yang akan diterapkan dari file ini. Frigate akan dimuat ulang setelah impor selesai.",
|
|
||||||
"exportedFrom": "Diekspor pada {{date}} dari konfigurasi Frigate versi {{version}}",
|
|
||||||
"layouts_other": "Tata letak grup kamera ({{count}} grup)",
|
|
||||||
"streaming_other": "Pengaturan streaming ({{count}} kamera)",
|
|
||||||
"preferences_other": "Preferensi UI ({{count}} pengaturan)",
|
|
||||||
"unknownGroups_other": "Grup kamera {{groups}} tidak ada di server ini. Pengaturannya akan disimpan tetapi tidak digunakan.",
|
|
||||||
"unknownCameras_other": "Kamera {{cameras}} tidak ada di server ini. Pengaturan streamingnya akan diabaikan.",
|
|
||||||
"confirm": "Impor"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configMessages": {
|
"configMessages": {
|
||||||
"audioTranscription": {
|
"audioTranscription": {
|
||||||
"audioDetectionDisabled": "Deteksi audio tidak diaktifkan untuk kamera ini. Transkripsi audio memerlukan deteksi audio yang aktif.",
|
"audioDetectionDisabled": "Deteksi audio tidak diaktifkan untuk kamera ini. Transkripsi audio memerlukan deteksi audio yang aktif."
|
||||||
"audioDetectionRuntimeDisabled": "Deteksi audio diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini, sehingga transkripsi audio tidak akan berjalan. Aktifkan kembali dari tampilan langsung kamera, atau periksa apakah profil aktif menonaktifkannya.",
|
|
||||||
"genaiProviderSelected": "Penyedia GenAI dipilih, sehingga pengaturan perangkat dan ukuran model diabaikan."
|
|
||||||
},
|
},
|
||||||
"detect": {
|
"detect": {
|
||||||
"fpsGreaterThanFive": "Mengatur FPS deteksi lebih dari 5 tidak direkomendasikan. Nilai yang lebih tinggi dapat menyebabkan masalah performa dan tidak akan memberikan manfaat apa pun.",
|
"fpsGreaterThanFive": "Mengatur FPS deteksi lebih dari 5 tidak direkomendasikan. Nilai yang lebih tinggi dapat menyebabkan masalah performa dan tidak akan memberikan manfaat apa pun.",
|
||||||
@@ -201,9 +170,7 @@
|
|||||||
"maxFramesSet": "Menetapkan frame maksimum akan menimpa perilaku default dan menonaktifkan pelacakan objek diam. Hanya sedikit situasi yang memerlukan ini, gunakan dengan hati-hati.",
|
"maxFramesSet": "Menetapkan frame maksimum akan menimpa perilaku default dan menonaktifkan pelacakan objek diam. Hanya sedikit situasi yang memerlukan ini, gunakan dengan hati-hati.",
|
||||||
"squareResolution": "Resolusi deteksi berbentuk persegi tidak lazim. Lebar dan tinggi deteksi harus sesuai dengan rasio aspek kamera Anda, misalnya 16:9, bukan dimensi model deteksi objek. Rasio aspek yang tidak sesuai dapat meregangkan gambar dan mengurangi akurasi deteksi.",
|
"squareResolution": "Resolusi deteksi berbentuk persegi tidak lazim. Lebar dan tinggi deteksi harus sesuai dengan rasio aspek kamera Anda, misalnya 16:9, bukan dimensi model deteksi objek. Rasio aspek yang tidak sesuai dapat meregangkan gambar dan mengurangi akurasi deteksi.",
|
||||||
"resolutionHigh": "Resolusi deteksi ini lebih tinggi daripada yang direkomendasikan dan dapat meningkatkan penggunaan sumber daya tanpa meningkatkan akurasi deteksi. Resolusi deteksi 1080p atau lebih rendah direkomendasikan untuk sebagian besar kamera.",
|
"resolutionHigh": "Resolusi deteksi ini lebih tinggi daripada yang direkomendasikan dan dapat meningkatkan penggunaan sumber daya tanpa meningkatkan akurasi deteksi. Resolusi deteksi 1080p atau lebih rendah direkomendasikan untuk sebagian besar kamera.",
|
||||||
"globalResolutionMultipleCameras": "Resolusi deteksi global ditetapkan saat beberapa kamera dikonfigurasi. Kecuali semua kamera memiliki resolusi dan rasio aspek yang sama, lebar dan tinggi deteksi sebaiknya ditentukan per kamera agar sesuai dengan rasio aspek asli masing-masing kamera.",
|
"globalResolutionMultipleCameras": "Resolusi deteksi global ditetapkan saat beberapa kamera dikonfigurasi. Kecuali semua kamera memiliki resolusi dan rasio aspek yang sama, lebar dan tinggi deteksi sebaiknya ditentukan per kamera agar sesuai dengan rasio aspek asli masing-masing kamera."
|
||||||
"runtimeDisabled": "Deteksi objek diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini. Snapshot, item tinjauan, dan pengayaan seperti pengenalan wajah, pengenalan pelat nomor, serta Generative AI tidak akan berfungsi hingga fitur tersebut diaktifkan kembali dari tampilan langsung kamera, atau hingga profil aktif berhenti menonaktifkannya.",
|
|
||||||
"sceneWithoutModel": "Tidak ada model deteksi yang dikonfigurasi untuk scene ini, sehingga kamera ini menggunakan model dengan scene “All cameras” sebagai cadangan. Tambahkan model untuk scene ini agar kamera memiliki modelnya sendiri."
|
|
||||||
},
|
},
|
||||||
"faceRecognition": {
|
"faceRecognition": {
|
||||||
"globalDisabled": "Pengayaan pengenalan wajah harus diaktifkan agar fitur pengenalan wajah berfungsi pada kamera ini.",
|
"globalDisabled": "Pengayaan pengenalan wajah harus diaktifkan agar fitur pengenalan wajah berfungsi pada kamera ini.",
|
||||||
@@ -212,17 +179,14 @@
|
|||||||
},
|
},
|
||||||
"lpr": {
|
"lpr": {
|
||||||
"globalDisabled": "Pengayaan pengenalan pelat nomor harus diaktifkan agar fitur LPR berfungsi pada kamera ini.",
|
"globalDisabled": "Pengayaan pengenalan pelat nomor harus diaktifkan agar fitur LPR berfungsi pada kamera ini.",
|
||||||
"vehicleNotTracked": "Pengenalan pelat nomor memerlukan kendaraan untuk dilacak. Aktifkan “car” atau jenis kendaraan lain pada Objects untuk kamera ini.",
|
"vehicleNotTracked": "Pengenalan pelat nomor memerlukan 'car' atau 'motorcycle' untuk dilacak. Aktifkan 'car' atau 'motorcycle' di Objek untuk kamera ini.",
|
||||||
"modelSizeLarge": "Model 'large' dioptimalkan untuk pelat nomor multi-baris. Model 'small' memberikan performa lebih baik daripada 'large' dan sebaiknya digunakan kecuali wilayah Anda menggunakan format pelat multi-baris."
|
"modelSizeLarge": "Model 'large' dioptimalkan untuk pelat nomor multi-baris. Model 'small' memberikan performa lebih baik daripada 'large' dan sebaiknya digunakan kecuali wilayah Anda menggunakan format pelat multi-baris."
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"recordDisabled": "Perekaman dinonaktifkan, item tinjauan tidak akan dibuat.",
|
"recordDisabled": "Perekaman dinonaktifkan, item tinjauan tidak akan dibuat.",
|
||||||
"detectDisabled": "Deteksi objek dinonaktifkan. Item tinjauan memerlukan objek yang terdeteksi untuk mengategorikan alert dan deteksi.",
|
"detectDisabled": "Deteksi objek dinonaktifkan. Item tinjauan memerlukan objek yang terdeteksi untuk mengategorikan alert dan deteksi.",
|
||||||
"allNonAlertDetections": "Semua aktivitas non-alert akan disertakan sebagai deteksi.",
|
"allNonAlertDetections": "Semua aktivitas non-alert akan disertakan sebagai deteksi.",
|
||||||
"genaiImageSourceRecordingsRecordDisabled": "Sumber gambar disetel ke 'rekaman', tetapi perekaman dinonaktifkan. Frigate akan kembali ke gambar pratinjau.",
|
"genaiImageSourceRecordingsRecordDisabled": "Sumber gambar disetel ke 'rekaman', tetapi perekaman dinonaktifkan. Frigate akan kembali ke gambar pratinjau."
|
||||||
"recordRuntimeDisabled": "Perekaman diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini, sehingga item tinjauan tidak akan dibuat. Aktifkan kembali dari tampilan langsung kamera, atau periksa apakah profil aktif menonaktifkannya.",
|
|
||||||
"detectRuntimeDisabled": "Deteksi objek diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini. Item tinjauan memerlukan objek yang terdeteksi untuk mengategorikan peringatan dan deteksi. Aktifkan kembali dari tampilan langsung kamera, atau periksa apakah profil aktif menonaktifkannya.",
|
|
||||||
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Sumber gambar diatur ke “recordings”, tetapi perekaman saat ini dinonaktifkan untuk kamera ini meskipun konfigurasi Anda mengaktifkannya. Frigate akan menggunakan gambar pratinjau sebagai pengganti."
|
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"noAudioRole": "Tidak ada stream yang memiliki peran audio yang didefinisikan. Anda harus mengaktifkan peran audio agar deteksi audio berfungsi."
|
"noAudioRole": "Tidak ada stream yang memiliki peran audio yang didefinisikan. Anda harus mengaktifkan peran audio agar deteksi audio berfungsi."
|
||||||
@@ -231,30 +195,16 @@
|
|||||||
"genaiNoDescriptionsProvider": "Anda harus mengonfigurasi penyedia GenAI dengan peran 'deskripsi' agar deskripsi dapat dibuat."
|
"genaiNoDescriptionsProvider": "Anda harus mengonfigurasi penyedia GenAI dengan peran 'deskripsi' agar deskripsi dapat dibuat."
|
||||||
},
|
},
|
||||||
"record": {
|
"record": {
|
||||||
"noRecordRole": "Tidak ada stream yang memiliki peran record yang didefinisikan. Perekaman tidak akan berfungsi.",
|
"noRecordRole": "Tidak ada stream yang memiliki peran record yang didefinisikan. Perekaman tidak akan berfungsi."
|
||||||
"noRecordSubRole": "Tidak ada stream yang memiliki peran record_sub. Perekaman sub stream tidak akan berfungsi."
|
|
||||||
},
|
},
|
||||||
"snapshots": {
|
"snapshots": {
|
||||||
"detectDisabled": "Deteksi objek dinonaktifkan. Cuplikan dihasilkan dari objek yang terlacak dan tidak akan dibuat.",
|
"detectDisabled": "Deteksi objek dinonaktifkan. Cuplikan dihasilkan dari objek yang terlacak dan tidak akan dibuat."
|
||||||
"detectRuntimeDisabled": "Deteksi objek diaktifkan dalam konfigurasi Anda, tetapi saat ini dinonaktifkan untuk kamera ini, sehingga snapshot tidak akan dibuat. Aktifkan kembali dari tampilan langsung kamera, atau periksa apakah profil aktif menonaktifkannya."
|
|
||||||
},
|
},
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
"jinav2SmallModelSize": "Ukuran 'small' dengan model Jina V2 memiliki RAM tinggi dan biaya inferensi. Model 'large' dengan GPU diskrit direkomendasikan."
|
"jinav2SmallModelSize": "Ukuran 'small' dengan model Jina V2 memiliki RAM tinggi dan biaya inferensi. Model 'large' dengan GPU diskrit direkomendasikan."
|
||||||
},
|
},
|
||||||
"onvif": {
|
"onvif": {
|
||||||
"autotrackingNoZones": "Pelacakan otomatis memerlukan setidaknya satu zona. Tentukan zona untuk kamera ini di Masker / Zona, lalu tetapkan sebagai zona wajib di bawah."
|
"autotrackingNoZones": "Pelacakan otomatis memerlukan setidaknya satu zona. Tentukan zona untuk kamera ini di Masker / Zona, lalu tetapkan sebagai zona wajib di bawah."
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"optimizedFor320": "Frigate dioptimalkan untuk model 320x320, yang merupakan pilihan terbaik untuk sebagian besar konfigurasi. Model 640x640 lebih lambat dan hanya membantu dalam skenario tertentu.",
|
|
||||||
"inputDimensionsNotDetectResolution": "Lebar dan tinggi input model adalah dimensi input model deteksi objek, bukan resolusi deteksi kamera Anda. Nilainya harus sesuai dengan dimensi model yang digunakan—biasanya ukuran persegi seperti 320x320 atau 640x640."
|
|
||||||
},
|
|
||||||
"ffmpeg": {
|
|
||||||
"hwaccelManualNotRecommended": "Argumen akselerasi perangkat keras manual tidak direkomendasikan. Kecuali terdapat kebutuhan khusus, pilih preset yang sesuai dengan perangkat keras Anda.",
|
|
||||||
"inputsMissingGo2rtcStream": "Input di bawah ini mengarah ke restream go2rtc yang sudah tidak ada. Pilih restream yang tersedia atau masukkan URL kamera secara manual; jika tidak, kamera ini akan gagal terhubung."
|
|
||||||
},
|
|
||||||
"birdseye": {
|
|
||||||
"objectTrackingDetectDisabled": "Birdseye mencakup objek yang dilacak, tetapi deteksi objek dinonaktifkan untuk kamera ini. Kamera tidak akan muncul di Birdseye.",
|
|
||||||
"objectTrackingDetectRuntimeDisabled": "Birdseye mencakup objek yang dilacak, tetapi deteksi objek saat ini dinonaktifkan untuk kamera ini meskipun konfigurasi Anda mengaktifkannya. Kamera tidak akan muncul di Birdseye hingga deteksi objek diaktifkan kembali dari tampilan langsung kamera, atau hingga profil aktif berhenti menonaktifkannya."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@@ -272,10 +222,7 @@
|
|||||||
"heading_other": "Bagian global ini memiliki bidang yang ditimpa di {{count}} kamera.",
|
"heading_other": "Bagian global ini memiliki bidang yang ditimpa di {{count}} kamera.",
|
||||||
"othersField_other": "{{count}} lainnya",
|
"othersField_other": "{{count}} lainnya",
|
||||||
"profilePrefix": "Profil {{profile}}: {{fields}}"
|
"profilePrefix": "Profil {{profile}}: {{fields}}"
|
||||||
},
|
}
|
||||||
"overriddenLive": "Ditimpa (Langsung)",
|
|
||||||
"overriddenLiveTooltip": "Kamera ini berjalan dengan nilai yang berbeda dari nilai yang tersimpan dalam konfigurasi Anda, yang ditampilkan di sini. Tampilan langsung, MQTT, atau profil aktif dapat mengubahnya saat Frigate berjalan.",
|
|
||||||
"overriddenLiveValue": "Nilai yang berjalan: {{value}}"
|
|
||||||
},
|
},
|
||||||
"menuDot": {
|
"menuDot": {
|
||||||
"overrideGlobal": "Bagian ini menimpa konfigurasi global",
|
"overrideGlobal": "Bagian ini menimpa konfigurasi global",
|
||||||
@@ -460,7 +407,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step3": {
|
"step3": {
|
||||||
"description": "Konfigurasikan fitur, peran stream, dan tambahkan stream tambahan untuk kamera Anda.",
|
"description": "Konfigurasikan peran stream dan tambahkan stream tambahan untuk kamera Anda.",
|
||||||
"streamsTitle": "Stream Kamera",
|
"streamsTitle": "Stream Kamera",
|
||||||
"addStream": "Tambah Stream",
|
"addStream": "Tambah Stream",
|
||||||
"addAnotherStream": "Tambah Stream Lain",
|
"addAnotherStream": "Tambah Stream Lain",
|
||||||
@@ -494,26 +441,11 @@
|
|||||||
"title": "Peran Stream",
|
"title": "Peran Stream",
|
||||||
"detect": "Umpan utama untuk deteksi objek.",
|
"detect": "Umpan utama untuk deteksi objek.",
|
||||||
"record": "Menyimpan segmen umpan video berdasarkan pengaturan konfigurasi.",
|
"record": "Menyimpan segmen umpan video berdasarkan pengaturan konfigurasi.",
|
||||||
"audio": "Umpan untuk deteksi berbasis audio.",
|
"audio": "Umpan untuk deteksi berbasis audio."
|
||||||
"record_sub": "Rekaman berkualitas lebih rendah untuk pemutaran adaptif dan retensi lebih lama (biasanya sub stream kamera)."
|
|
||||||
},
|
},
|
||||||
"featuresPopover": {
|
"featuresPopover": {
|
||||||
"title": "Fitur Stream",
|
"title": "Fitur Stream",
|
||||||
"description": "Gunakan restreaming go2rtc untuk mengurangi koneksi ke kamera Anda."
|
"description": "Gunakan restreaming go2rtc untuk mengurangi koneksi ke kamera Anda."
|
||||||
},
|
|
||||||
"appleCompatibility": {
|
|
||||||
"title": "Tingkatkan pemutaran pada perangkat Apple",
|
|
||||||
"description": "Aktifkan jika Anda menonton rekaman melalui Safari atau perangkat iPhone, iPad, atau Mac."
|
|
||||||
},
|
|
||||||
"ptz": {
|
|
||||||
"title": "Aktifkan Kontrol PTZ",
|
|
||||||
"detectedNote": "Dukungan PTZ telah terdeteksi melalui ONVIF. Jika ini adalah kamera PTZ, mengaktifkan opsi ini memungkinkan Anda mengendalikan fungsi pan/tilt/zoom kamera dari UI.",
|
|
||||||
"connectionDetails": "Detail koneksi ONVIF",
|
|
||||||
"host": "Host ONVIF",
|
|
||||||
"port": "Port ONVIF",
|
|
||||||
"username": "Nama Pengguna ONVIF",
|
|
||||||
"password": "Kata Sandi ONVIF",
|
|
||||||
"hostRequiredWarning": "Host dan port ONVIF diperlukan saat kontrol PTZ diaktifkan."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step4": {
|
"step4": {
|
||||||
@@ -574,7 +506,7 @@
|
|||||||
"deleteCameraDialog": {
|
"deleteCameraDialog": {
|
||||||
"title": "Hapus Kamera",
|
"title": "Hapus Kamera",
|
||||||
"description": "Menghapus kamera akan menghapus secara permanen semua rekaman, objek terlacak, dan konfigurasi untuk kamera tersebut. Semua stream go2rtc yang terkait dengan kamera ini mungkin masih perlu dihapus secara manual.",
|
"description": "Menghapus kamera akan menghapus secara permanen semua rekaman, objek terlacak, dan konfigurasi untuk kamera tersebut. Semua stream go2rtc yang terkait dengan kamera ini mungkin masih perlu dihapus secara manual.",
|
||||||
"selectPlaceholder": "Pilih kamera…",
|
"selectPlaceholder": "Pilih kamera...",
|
||||||
"confirmTitle": "Apakah Anda yakin?",
|
"confirmTitle": "Apakah Anda yakin?",
|
||||||
"confirmWarning": "Menghapus <strong>{{cameraName}}</strong> tidak dapat dibatalkan.",
|
"confirmWarning": "Menghapus <strong>{{cameraName}}</strong> tidak dapat dibatalkan.",
|
||||||
"deleteExports": "Juga hapus ekspor untuk kamera ini",
|
"deleteExports": "Juga hapus ekspor untuk kamera ini",
|
||||||
@@ -610,7 +542,7 @@
|
|||||||
"webuiUrlHelp": "URL untuk membuka web UI kamera langsung dari tampilan Debug. Biarkan kosong untuk menonaktifkan tautan.",
|
"webuiUrlHelp": "URL untuk membuka web UI kamera langsung dari tampilan Debug. Biarkan kosong untuk menonaktifkan tautan.",
|
||||||
"webuiUrlInvalid": "Harus berupa URL yang valid (misalnya, https://example.com).",
|
"webuiUrlInvalid": "Harus berupa URL yang valid (misalnya, https://example.com).",
|
||||||
"dashboardLabel": "Tampilkan di dasbor Live",
|
"dashboardLabel": "Tampilkan di dasbor Live",
|
||||||
"dashboardHelp": "Tampilkan kamera ini pada dasbor tampilan langsung Semua Kamera secara default. Kamera tetap tersedia di semua tempat lain, termasuk grup kamera.",
|
"dashboardHelp": "Tampilkan kamera ini di dasbor Live.",
|
||||||
"reviewLabel": "Tampilkan di Review",
|
"reviewLabel": "Tampilkan di Review",
|
||||||
"reviewHelp": "Tampilkan kamera ini di Review, termasuk filter kamera, review gerakan, dan tampilan riwayat."
|
"reviewHelp": "Tampilkan kamera ini di Review, termasuk filter kamera, review gerakan, dan tampilan riwayat."
|
||||||
}
|
}
|
||||||
@@ -1157,7 +1089,7 @@
|
|||||||
},
|
},
|
||||||
"createUser": {
|
"createUser": {
|
||||||
"title": "Buat Pengguna Baru",
|
"title": "Buat Pengguna Baru",
|
||||||
"desc": "Tambahkan akun pengguna baru dan tentukan peran untuk akses ke area UI Frigate.",
|
"desc": "Tambahkan akun pengguna baru dan tentukan perannya untuk akses ke area UI Frigate.",
|
||||||
"usernameOnlyInclude": "Nama pengguna hanya boleh berisi huruf, angka, . atau _",
|
"usernameOnlyInclude": "Nama pengguna hanya boleh berisi huruf, angka, . atau _",
|
||||||
"confirmPassword": "Harap konfirmasi kata sandi Anda"
|
"confirmPassword": "Harap konfirmasi kata sandi Anda"
|
||||||
},
|
},
|
||||||
@@ -1260,8 +1192,9 @@
|
|||||||
"desc": "Frigate dapat secara native mengirim notifikasi push ke perangkat Anda saat berjalan di browser atau diinstal sebagai PWA."
|
"desc": "Frigate dapat secara native mengirim notifikasi push ke perangkat Anda saat berjalan di browser atau diinstal sebagai PWA."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
"desc": "Notifikasi push web memerlukan konteks aman ([https://…](https://…)). Ini merupakan batasan browser. Akses Frigate secara aman untuk menggunakan notifikasi.",
|
"title": "Notifikasi Tidak Tersedia",
|
||||||
"descPwa": "Di iOS, notifikasi push web hanya tersedia jika Frigate dipasang ke Layar Utama. Buka menu Bagikan, pilih Tambahkan ke Layar Utama, lalu buka Frigate dari ikon baru untuk mendaftarkan perangkat ini untuk notifikasi."
|
"desc": "Notifikasi push web memerlukan konteks aman (<code>https://…</code>). Ini adalah batasan browser. Akses Frigate secara aman untuk menggunakan notifikasi.",
|
||||||
|
"descPwa": "Di iOS, notifikasi push web hanya tersedia jika Frigate dipasang ke Layar Utama Anda. Buka menu <strong>Bagikan</strong>, pilih <strong>Tambahkan ke Layar Utama</strong>, lalu buka Frigate dari ikon baru tersebut untuk mendaftarkan perangkat ini agar menerima notifikasi."
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
"title": "Pengaturan Global",
|
"title": "Pengaturan Global",
|
||||||
@@ -1367,6 +1300,7 @@
|
|||||||
"triggers": {
|
"triggers": {
|
||||||
"documentTitle": "Pemicu",
|
"documentTitle": "Pemicu",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "Pencarian Semantik dinonaktifkan",
|
||||||
"desc": "Pencarian Semantik harus diaktifkan untuk menggunakan Pemicu."
|
"desc": "Pencarian Semantik harus diaktifkan untuk menggunakan Pemicu."
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
@@ -1524,9 +1458,7 @@
|
|||||||
"orphansDeleted": "File Yatim Piatu Dihapus",
|
"orphansDeleted": "File Yatim Piatu Dihapus",
|
||||||
"aborted": "Dibatalkan. Penghapusan akan melebihi ambang keamanan.",
|
"aborted": "Dibatalkan. Penghapusan akan melebihi ambang keamanan.",
|
||||||
"error": "Kesalahan",
|
"error": "Kesalahan",
|
||||||
"totals": "Total",
|
"totals": "Total"
|
||||||
"spaceToReclaim": "Ruang yang Dapat Diklaim Kembali",
|
|
||||||
"spaceReclaimed": "Ruang Berhasil Diklaim Kembali"
|
|
||||||
},
|
},
|
||||||
"event_snapshots": "Cuplikan Objek Terlacak",
|
"event_snapshots": "Cuplikan Objek Terlacak",
|
||||||
"event_thumbnails": "Thumbnail Objek Terlacak",
|
"event_thumbnails": "Thumbnail Objek Terlacak",
|
||||||
@@ -1568,23 +1500,11 @@
|
|||||||
"keyLabel": "Kunci",
|
"keyLabel": "Kunci",
|
||||||
"valueLabel": "Nilai",
|
"valueLabel": "Nilai",
|
||||||
"keyPlaceholder": "Kunci baru",
|
"keyPlaceholder": "Kunci baru",
|
||||||
"remove": "Hapus",
|
"remove": "Hapus"
|
||||||
"providerNameLabel": "Nama penyedia",
|
|
||||||
"providerNamePlaceholder": "mis., openai",
|
|
||||||
"variableNameLabel": "Nama variabel",
|
|
||||||
"variableNamePlaceholder": "mis., MY_VARIABLE",
|
|
||||||
"loggerNameLabel": "Nama logger",
|
|
||||||
"loggerNamePlaceholder": "mis., frigate.record",
|
|
||||||
"keyPatternError": "Gunakan hanya huruf, angka, tanda hubung, dan garis bawah (tanpa spasi)"
|
|
||||||
},
|
},
|
||||||
"knownPlates": {
|
"knownPlates": {
|
||||||
"namePlaceholder": "mis., Mobil Istri",
|
"namePlaceholder": "mis., Mobil Istri",
|
||||||
"platePlaceholder": "Nomor pelat atau regex",
|
"platePlaceholder": "Nomor pelat atau regex"
|
||||||
"assignedTo": "Ditugaskan kepada {{name}}",
|
|
||||||
"detected": "Pelat nomor terdeteksi",
|
|
||||||
"noneDetected": "Belum ada pelat nomor yang terdeteksi",
|
|
||||||
"search": "Cari atau masukkan pelat nomor",
|
|
||||||
"useCustom": "Gunakan \"{{value}}\""
|
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"defaultOption": "Gunakan zona waktu browser"
|
"defaultOption": "Gunakan zona waktu browser"
|
||||||
@@ -1629,21 +1549,10 @@
|
|||||||
"preset-record-mjpeg": "Rekam - Kamera MJPEG",
|
"preset-record-mjpeg": "Rekam - Kamera MJPEG",
|
||||||
"preset-record-jpeg": "Rekam - Kamera JPEG",
|
"preset-record-jpeg": "Rekam - Kamera JPEG",
|
||||||
"preset-record-ubiquiti": "Rekam - Kamera Ubiquiti"
|
"preset-record-ubiquiti": "Rekam - Kamera Ubiquiti"
|
||||||
},
|
}
|
||||||
"sameAsRecord": "Sama seperti argumen keluaran rekaman"
|
|
||||||
},
|
},
|
||||||
"cameraInputs": {
|
"cameraInputs": {
|
||||||
"itemTitle": "Stream {{index}}",
|
"itemTitle": "Stream {{index}}"
|
||||||
"sourceMode": {
|
|
||||||
"restream": "Restream (go2rtc)",
|
|
||||||
"manual": "Jalur input manual",
|
|
||||||
"go2rtcStreamLabel": "Stream go2rtc",
|
|
||||||
"go2rtcStreamPlaceholder": "Pilih stream go2rtc",
|
|
||||||
"noGo2rtcStreams": "Tidak ada stream go2rtc yang dikonfigurasi",
|
|
||||||
"go2rtcStreamSearch": "Cari stream...",
|
|
||||||
"availableStreams": "Stream yang tersedia",
|
|
||||||
"noMatchingStreams": "Tidak ada stream yang sesuai"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"restartRequiredField": "Perlu mulai ulang",
|
"restartRequiredField": "Perlu mulai ulang",
|
||||||
"restartRequiredFooter": "Konfigurasi berubah - Perlu mulai ulang",
|
"restartRequiredFooter": "Konfigurasi berubah - Perlu mulai ulang",
|
||||||
@@ -1715,18 +1624,14 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"detect": "Deteksi",
|
"detect": "Deteksi",
|
||||||
"record": "Rekam",
|
"record": "Rekam",
|
||||||
"audio": "Audio",
|
"audio": "Audio"
|
||||||
"record_sub": "Rekam (Sub Stream)"
|
}
|
||||||
},
|
|
||||||
"roleInUse": "Sudah ditetapkan ke stream lain",
|
|
||||||
"recordSubConflict": "Sebuah stream tidak dapat memiliki peran record dan record_sub sekaligus"
|
|
||||||
},
|
},
|
||||||
"genaiRoles": {
|
"genaiRoles": {
|
||||||
"options": {
|
"options": {
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"descriptions": "Deskripsi",
|
"descriptions": "Deskripsi",
|
||||||
"chat": "Chat",
|
"chat": "Chat"
|
||||||
"transcribe": "Transkripsi"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semanticSearchModel": {
|
"semanticSearchModel": {
|
||||||
@@ -1784,26 +1689,7 @@
|
|||||||
},
|
},
|
||||||
"defaultRole": {
|
"defaultRole": {
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"viewer": "Penampil",
|
"viewer": "Penampil"
|
||||||
"none": "Tidak ada (tolak akses)"
|
|
||||||
},
|
|
||||||
"birdseyeModes": {
|
|
||||||
"summary": "{{count}} dipilih",
|
|
||||||
"options": {
|
|
||||||
"continuous": "Kontinu",
|
|
||||||
"motion": "Gerakan",
|
|
||||||
"all_objects": "Semua objek",
|
|
||||||
"alerts": "Peringatan",
|
|
||||||
"detections": "Deteksi"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"audioTranscriptionModel": {
|
|
||||||
"placeholder": "Pilih model…",
|
|
||||||
"builtIn": "Model Bawaan",
|
|
||||||
"genaiProviders": "Penyedia GenAI"
|
|
||||||
},
|
|
||||||
"audioTranscriptionModelSize": {
|
|
||||||
"notApplicable": "Tidak berlaku untuk penyedia GenAI"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalConfig": {
|
"globalConfig": {
|
||||||
@@ -1970,10 +1856,6 @@
|
|||||||
"imageSource": {
|
"imageSource": {
|
||||||
"recordings": "Rekaman",
|
"recordings": "Rekaman",
|
||||||
"previews": "Pratinjau"
|
"previews": "Pratinjau"
|
||||||
},
|
|
||||||
"frameMode": {
|
|
||||||
"frames": "Frame",
|
|
||||||
"annotated_frames": "Frame beranotasi"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logger": {
|
"logger": {
|
||||||
@@ -1999,42 +1881,5 @@
|
|||||||
"modelSize": {
|
"modelSize": {
|
||||||
"small": "Kecil",
|
"small": "Kecil",
|
||||||
"large": "Besar"
|
"large": "Besar"
|
||||||
},
|
|
||||||
"detectionModels": {
|
|
||||||
"title": "Model deteksi",
|
|
||||||
"description": "Konfigurasikan model deteksi objek dan perangkat keras yang digunakan oleh setiap model. Kamera memilih model berdasarkan scene dalam pengaturan deteksi kamera.",
|
|
||||||
"addModel": "Tambahkan model",
|
|
||||||
"cameras_other": "{{count}} kamera",
|
|
||||||
"scene": {
|
|
||||||
"label": "Scene",
|
|
||||||
"description": "Lingkungan yang menjadi tujuan model ini. Kamera memilih model dengan menetapkan scene yang sama dalam pengaturan deteksinya, sedangkan model dengan scene “all” digunakan oleh setiap kamera yang tidak menetapkan scene."
|
|
||||||
},
|
|
||||||
"scenes": {
|
|
||||||
"all": "Semua kamera",
|
|
||||||
"indoor": "Dalam ruangan",
|
|
||||||
"outdoor": "Luar ruangan",
|
|
||||||
"indoor_thermal": "Termal dalam ruangan",
|
|
||||||
"outdoor_thermal": "Termal luar ruangan"
|
|
||||||
},
|
|
||||||
"hardware": {
|
|
||||||
"label": "Perangkat keras",
|
|
||||||
"placeholder": "Pilih perangkat keras",
|
|
||||||
"loading": "Mencari perangkat keras deteksi...",
|
|
||||||
"none": "Tidak ada perangkat keras yang dipilih",
|
|
||||||
"claimedBy": "digunakan oleh {{scene}}",
|
|
||||||
"detectorCount": "Detektor",
|
|
||||||
"countRecommended_other": "Direkomendasikan untuk {{count}} kamera",
|
|
||||||
"unrecognized": "Model ini dikonfigurasi untuk perangkat keras yang tidak ditemukan pada sistem ini: {{devices}}",
|
|
||||||
"description": "Perangkat keras tempat model ini menjalankan proses deteksi.",
|
|
||||||
"detectorCountDescription": "Jumlah proses deteksi yang dijalankan pada perangkat keras ini. Lebih banyak detektor dapat menangani lebih banyak kamera, dengan konsekuensi penggunaan memori perangkat tambahan.",
|
|
||||||
"unitsDescription": "Setiap unit menjalankan proses deteksinya sendiri. Unit yang sudah digunakan oleh model lain tidak dapat dipilih."
|
|
||||||
},
|
|
||||||
"tabs": {
|
|
||||||
"plus": "Frigate+",
|
|
||||||
"custom": "Model Kustom"
|
|
||||||
},
|
|
||||||
"plusModel": {
|
|
||||||
"noModelSelected": "Pilih model Frigate+"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,8 @@
|
|||||||
},
|
},
|
||||||
"lastRefreshed": "Terakhir diperbarui: ",
|
"lastRefreshed": "Terakhir diperbarui: ",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} memiliki penggunaan CPU FFmpeg yang tinggi ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} memiliki penggunaan CPU deteksi yang tinggi ({{detectAvg}}%)",
|
||||||
"healthy": "Sistem sehat",
|
"healthy": "Sistem sehat",
|
||||||
"reindexingEmbeddings": "Mengindeks ulang embedding ({{processed}}% selesai)",
|
"reindexingEmbeddings": "Mengindeks ulang embedding ({{processed}}% selesai)",
|
||||||
"cameraIsOffline": "{{camera}} sedang offline",
|
"cameraIsOffline": "{{camera}} sedang offline",
|
||||||
|
|||||||
@@ -661,6 +661,7 @@
|
|||||||
"registerDevice": "Registra questo dispositivo",
|
"registerDevice": "Registra questo dispositivo",
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
"desc": "Le notifiche push web richiedono un contesto sicuro (<code>https://...</code>). Questa è una limitazione del browser. Accedi a Frigate in modo sicuro per utilizzare le notifiche.",
|
"desc": "Le notifiche push web richiedono un contesto sicuro (<code>https://...</code>). Questa è una limitazione del browser. Accedi a Frigate in modo sicuro per utilizzare le notifiche.",
|
||||||
|
"title": "Notifiche non disponibili",
|
||||||
"descPwa": "Su iOS, le notifiche push web sono disponibili solo se Frigate è installato sulla schermata Home. Apri il menu <strong>Condividi</strong>, scegli <strong>Aggiungi alla schermata Home</strong>, quindi apri Frigate dalla nuova icona per registrare questo dispositivo per le notifiche."
|
"descPwa": "Su iOS, le notifiche push web sono disponibili solo se Frigate è installato sulla schermata Home. Apri il menu <strong>Condividi</strong>, scegli <strong>Aggiungi alla schermata Home</strong>, quindi apri Frigate dalla nuova icona per registrare questo dispositivo per le notifiche."
|
||||||
},
|
},
|
||||||
"deviceSpecific": "Impostazioni specifiche del dispositivo",
|
"deviceSpecific": "Impostazioni specifiche del dispositivo",
|
||||||
@@ -841,6 +842,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "La ricerca semantica è disabilitata",
|
||||||
"desc": "Per utilizzare gli attivatori, è necessario abilitare la ricerca semantica."
|
"desc": "Per utilizzare gli attivatori, è necessario abilitare la ricerca semantica."
|
||||||
},
|
},
|
||||||
"wizard": {
|
"wizard": {
|
||||||
|
|||||||
@@ -222,6 +222,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"detectHighCpuUsage": "{{camera}} ha un utilizzo elevato della CPU con il rilevamento ({{detectAvg}}%)",
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} ha un elevato utilizzo della CPU con FFmpeg ({{ffmpegAvg}}%)",
|
||||||
"healthy": "Il sistema è integro",
|
"healthy": "Il sistema è integro",
|
||||||
"reindexingEmbeddings": "Reindicizzazione degli incorporamenti (completata al {{processed}}%)",
|
"reindexingEmbeddings": "Reindicizzazione degli incorporamenti (completata al {{processed}}%)",
|
||||||
"cameraIsOffline": "{{camera}} è disconnessa",
|
"cameraIsOffline": "{{camera}} è disconnessa",
|
||||||
|
|||||||
@@ -691,6 +691,7 @@
|
|||||||
"desc": "Frigate はブラウザで実行中、または PWA としてインストールされている場合に、端末へネイティブのプッシュ通知を送信できます。"
|
"desc": "Frigate はブラウザで実行中、または PWA としてインストールされている場合に、端末へネイティブのプッシュ通知を送信できます。"
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "通知は利用できません",
|
||||||
"desc": "Web プッシュ通知にはセキュアコンテキスト(<code>https://…</code>)が必要です。これはブラウザの制限です。通知を利用するには、セキュアに Frigate へアクセスしてください。",
|
"desc": "Web プッシュ通知にはセキュアコンテキスト(<code>https://…</code>)が必要です。これはブラウザの制限です。通知を利用するには、セキュアに Frigate へアクセスしてください。",
|
||||||
"descPwa": "iOSでは、Frigateをホーム画面に追加した場合にのみ、Webプッシュ通知を利用できます。<strong>共有</strong>メニューを開き、<strong>ホーム画面に追加</strong>を選択してから、新しいアイコンからFrigateを起動し、このデバイスを通知対象として登録してください。"
|
"descPwa": "iOSでは、Frigateをホーム画面に追加した場合にのみ、Webプッシュ通知を利用できます。<strong>共有</strong>メニューを開き、<strong>ホーム画面に追加</strong>を選択してから、新しいアイコンからFrigateを起動し、このデバイスを通知対象として登録してください。"
|
||||||
},
|
},
|
||||||
@@ -895,7 +896,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
"desc": "トリガーを使用するにはセマンティック検索を有効にする必要があります。"
|
"desc": "トリガーを使用するにはセマンティック検索を有効にする必要があります。",
|
||||||
|
"title": "セマンティック検索が無効です"
|
||||||
},
|
},
|
||||||
"wizard": {
|
"wizard": {
|
||||||
"title": "トリガーを作成",
|
"title": "トリガーを作成",
|
||||||
|
|||||||
@@ -227,6 +227,8 @@
|
|||||||
},
|
},
|
||||||
"lastRefreshed": "最終更新: ",
|
"lastRefreshed": "最終更新: ",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} の FFmpeg の CPU 使用率が高い({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} の検知の CPU 使用率が高い({{detectAvg}}%)",
|
||||||
"healthy": "システムは正常です",
|
"healthy": "システムは正常です",
|
||||||
"reindexingEmbeddings": "埋め込みを再インデックス中({{processed}}% 完了)",
|
"reindexingEmbeddings": "埋め込みを再インデックス中({{processed}}% 完了)",
|
||||||
"cameraIsOffline": "{{camera}} はオフラインです",
|
"cameraIsOffline": "{{camera}} はオフラインです",
|
||||||
|
|||||||
@@ -1143,6 +1143,7 @@
|
|||||||
"desc": "Frigate អាចផ្ញើការជូនដំណឹង Push ទៅឧបករណ៍របស់អ្នកដោយផ្ទាល់ នៅពេលដែលវាកំពុងដំណើរការក្នុងកម្មវិធីរុករក ឬបានដំឡើងជា PWA។"
|
"desc": "Frigate អាចផ្ញើការជូនដំណឹង Push ទៅឧបករណ៍របស់អ្នកដោយផ្ទាល់ នៅពេលដែលវាកំពុងដំណើរការក្នុងកម្មវិធីរុករក ឬបានដំឡើងជា PWA។"
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "ការជូនដំណឹងមិនអាចប្រើបាន",
|
||||||
"desc": "ការជូនដំណឹង Push លើវេប ត្រូវការបរិយាកាសសុវត្ថិភាព (<code>https://…</code>)។ នេះគឺជាការកំណត់របស់កម្មវិធីរុករក។ ចូលប្រើ Frigate ដោយសុវត្ថិភាព ដើម្បីប្រើការជូនដំណឹង។",
|
"desc": "ការជូនដំណឹង Push លើវេប ត្រូវការបរិយាកាសសុវត្ថិភាព (<code>https://…</code>)។ នេះគឺជាការកំណត់របស់កម្មវិធីរុករក។ ចូលប្រើ Frigate ដោយសុវត្ថិភាព ដើម្បីប្រើការជូនដំណឹង។",
|
||||||
"descPwa": "នៅលើ iOS ការជូនដំណឹង Push លើវេប អាចប្រើបានតែនៅពេលដែល Frigate ត្រូវបានដំឡើងទៅកាន់អេក្រង់ដើមរបស់អ្នក។ បើកម៉ឺនុយ <strong>ចែករំលែក</strong> ជ្រើសរើស <strong>បន្ថែមទៅអេក្រង់ដើម</strong> បន្ទាប់មកបើក Frigate ពីរូបតំណាងថ្មី ដើម្បីចុះឈ្មោះឧបករណ៍នេះសម្រាប់ការជូនដំណឹង។"
|
"descPwa": "នៅលើ iOS ការជូនដំណឹង Push លើវេប អាចប្រើបានតែនៅពេលដែល Frigate ត្រូវបានដំឡើងទៅកាន់អេក្រង់ដើមរបស់អ្នក។ បើកម៉ឺនុយ <strong>ចែករំលែក</strong> ជ្រើសរើស <strong>បន្ថែមទៅអេក្រង់ដើម</strong> បន្ទាប់មកបើក Frigate ពីរូបតំណាងថ្មី ដើម្បីចុះឈ្មោះឧបករណ៍នេះសម្រាប់ការជូនដំណឹង។"
|
||||||
},
|
},
|
||||||
@@ -1250,6 +1251,7 @@
|
|||||||
"triggers": {
|
"triggers": {
|
||||||
"documentTitle": "កេះ",
|
"documentTitle": "កេះ",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "ការស្វែងរកតាមអត្ថន័យត្រូវបានបិទ",
|
||||||
"desc": "ការស្វែងរកតាមអត្ថន័យត្រូវតែបើក ដើម្បីប្រើកេះ។"
|
"desc": "ការស្វែងរកតាមអត្ថន័យត្រូវតែបើក ដើម្បីប្រើកេះ។"
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
},
|
},
|
||||||
"documentTitle": "트리거",
|
"documentTitle": "트리거",
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "시맨틱 검색이 비활성화됨",
|
||||||
"desc": "트리거 기능을 사용하려면 시맨틱 검색이 활성화되어 있어야 합니다."
|
"desc": "트리거 기능을 사용하려면 시맨틱 검색이 활성화되어 있어야 합니다."
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
@@ -1307,6 +1308,7 @@
|
|||||||
"desc": "Frigate이 브라우저에서 실행 중이거나 PWA로 설치된 경우 기기로 푸시 알림을 기본 전송할 수 있습니다."
|
"desc": "Frigate이 브라우저에서 실행 중이거나 PWA로 설치된 경우 기기로 푸시 알림을 기본 전송할 수 있습니다."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "알림을 사용할 수 없음",
|
||||||
"desc": "웹 푸시 알림을 사용하려면 보안 컨텍스트(<code>https://…</code>)가 필요합니다. 이는 브라우저 자체의 제한 사항입니다. 알림을 사용하려면 Frigate에 보안 연결로 접속하세요.",
|
"desc": "웹 푸시 알림을 사용하려면 보안 컨텍스트(<code>https://…</code>)가 필요합니다. 이는 브라우저 자체의 제한 사항입니다. 알림을 사용하려면 Frigate에 보안 연결로 접속하세요.",
|
||||||
"descPwa": "iOS의 경우 Frigate이 홈 화면에 추가되어 있어야만 웹 푸시 알림을 사용할 수 있습니다. <strong>공유</strong> 메뉴를 열고 <strong>홈 화면에 추가</strong>를 선택한 다음, 새로 생성된 아이콘으로 Frigate을 실행하여 기기를 알림에 등록하세요."
|
"descPwa": "iOS의 경우 Frigate이 홈 화면에 추가되어 있어야만 웹 푸시 알림을 사용할 수 있습니다. <strong>공유</strong> 메뉴를 열고 <strong>홈 화면에 추가</strong>를 선택한 다음, 새로 생성된 아이콘으로 Frigate을 실행하여 기기를 알림에 등록하세요."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -236,6 +236,8 @@
|
|||||||
},
|
},
|
||||||
"lastRefreshed": "마지막 갱신: ",
|
"lastRefreshed": "마지막 갱신: ",
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"ffmpegHighCpuUsage": "{{camera}} 카메라의 FFmpeg CPU 사용량이 높습니다 ({{ffmpegAvg}}%)",
|
||||||
|
"detectHighCpuUsage": "{{camera}} 카메라의 감지(detect) CPU 사용량이 높습니다 ({{detectAvg}}%)",
|
||||||
"healthy": "시스템 상태가 양호합니다",
|
"healthy": "시스템 상태가 양호합니다",
|
||||||
"reindexingEmbeddings": "임베딩 재색인 중 ({{processed}}% 완료)",
|
"reindexingEmbeddings": "임베딩 재색인 중 ({{processed}}% 완료)",
|
||||||
"cameraIsOffline": "{{camera}} 카메라가 오프라인 상태입니다",
|
"cameraIsOffline": "{{camera}} 카메라가 오프라인 상태입니다",
|
||||||
@@ -279,12 +281,20 @@
|
|||||||
"notices": {
|
"notices": {
|
||||||
"title": "알림",
|
"title": "알림",
|
||||||
"empty": "Frigate 이 잘 설치되어있습니다",
|
"empty": "Frigate 이 잘 설치되어있습니다",
|
||||||
|
"dismiss": "무시",
|
||||||
"openSettings": "설정 열기",
|
"openSettings": "설정 열기",
|
||||||
"openLink": "링크 열기",
|
"openLink": "링크 열기",
|
||||||
"noMatches": "필터에 맞는 알림이 없습니다",
|
"noMatches": "필터에 맞는 알림이 없습니다",
|
||||||
|
"dismissedTitle": "무시됨",
|
||||||
|
"noneDismissed": "무시된 알림 없음",
|
||||||
|
"clearDismissed": "무시됨 지우기",
|
||||||
|
"clearDismissedTitle": "무시된 알림을 지울까요?",
|
||||||
|
"clearDismissedDesc": "무시된 모든 알림이 삭제됩니다. 설정 및 스트림 점검 항목은 즉시 다시 표시되며, 기타 알림은 다음에 다시 발생할 때 표시됩니다.",
|
||||||
"firstSeen_one": "최초 감지: {{time}}",
|
"firstSeen_one": "최초 감지: {{time}}",
|
||||||
"firstSeen_other": "최초 감지: {{time}} · {{count}}회",
|
"firstSeen_other": "최초 감지: {{time}} · {{count}}회",
|
||||||
|
"dismissedAt": "무시됨: {{time}}",
|
||||||
"filter": {
|
"filter": {
|
||||||
|
"showDismissed": "무시된 항목 표시",
|
||||||
"severity": "심각도",
|
"severity": "심각도",
|
||||||
"error": "오류",
|
"error": "오류",
|
||||||
"warning": "경고",
|
"warning": "경고",
|
||||||
|
|||||||
@@ -605,6 +605,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semanticSearch": {
|
"semanticSearch": {
|
||||||
|
"title": "Semantic Paieška išjungta",
|
||||||
"desc": "Norint naudoti Trigerius Semantic Paieška privalo būti įjungta."
|
"desc": "Norint naudoti Trigerius Semantic Paieška privalo būti įjungta."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -615,6 +616,7 @@
|
|||||||
"desc": "Frigate praneįimai sukurti veikti su push pranešimais į įrenginį kai naršoma per naršyklę arba įdiegta kaip PWA."
|
"desc": "Frigate praneįimai sukurti veikti su push pranešimais į įrenginį kai naršoma per naršyklę arba įdiegta kaip PWA."
|
||||||
},
|
},
|
||||||
"notificationUnavailable": {
|
"notificationUnavailable": {
|
||||||
|
"title": "Pranešimai Negalimi",
|
||||||
"desc": "Web push pranešimai reikalauja saugios aplinkos (<code>https://...</code>). Tai yra naršyklės apribojimai. Atsidarykit Frigate saugiu kanalu kad galėtumėte naudotis pranešimais."
|
"desc": "Web push pranešimai reikalauja saugios aplinkos (<code>https://...</code>). Tai yra naršyklės apribojimai. Atsidarykit Frigate saugiu kanalu kad galėtumėte naudotis pranešimais."
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user