mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-29 11:26:49 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc97f294a0 | ||
|
|
a1c8bf99a7 | ||
|
|
f9a37d4f4f | ||
|
|
0977fa5e15 |
@@ -15,7 +15,6 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
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 (
|
||||
is_go2rtc_arbitrary_exec_allowed,
|
||||
is_restricted_go2rtc_source,
|
||||
@@ -175,17 +174,6 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
del go2rtc_config["streams"][name]
|
||||
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
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
|
||||
@@ -900,21 +900,6 @@ live:
|
||||
streams:
|
||||
main_stream: main_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)
|
||||
# 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.
|
||||
|
||||
@@ -92,7 +92,7 @@ go2rtc:
|
||||
|
||||
### 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. 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.
|
||||
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.
|
||||
|
||||
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,26 +158,6 @@ cameras:
|
||||
</TabItem>
|
||||
</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 works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
|
||||
@@ -383,13 +363,6 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
- `Safari reported InvalidStateError.`
|
||||
- `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**
|
||||
- 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" /> .
|
||||
|
||||
Vendored
-38
@@ -412,39 +412,6 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
@@ -7764,11 +7731,6 @@ components:
|
||||
type: boolean
|
||||
title: Skip Save
|
||||
default: false
|
||||
replace_paths:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Replace Paths
|
||||
type: object
|
||||
title: AppConfigSetBody
|
||||
AppPostLoginBody:
|
||||
|
||||
+5
-36
@@ -63,7 +63,6 @@ from frigate.util.builtin import (
|
||||
flatten_config_data,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
split_config_key_path,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import (
|
||||
@@ -71,10 +70,6 @@ from frigate.util.config import (
|
||||
find_config_file,
|
||||
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.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
@@ -398,6 +393,11 @@ def config(request: Request):
|
||||
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
||||
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"]:
|
||||
continue
|
||||
|
||||
@@ -815,17 +815,6 @@ 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"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
@@ -880,19 +869,6 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
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
|
||||
update_yaml_file_bulk(config_file, updates)
|
||||
|
||||
@@ -956,15 +932,9 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||
|
||||
go2rtc_synced = True
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_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.startswith("config/cameras/"):
|
||||
@@ -1024,7 +994,6 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if body.requires_restart == 0
|
||||
else "Config successfully updated, restart to apply"
|
||||
),
|
||||
"go2rtc_synced": go2rtc_synced,
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
|
||||
@@ -39,11 +39,6 @@ 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.config import find_config_file
|
||||
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 (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
@@ -253,34 +248,6 @@ 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"]))])
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
@@ -1375,12 +1342,6 @@ async def delete_camera(
|
||||
except Exception:
|
||||
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(
|
||||
content={
|
||||
"success": True,
|
||||
|
||||
@@ -51,6 +51,7 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
|
||||
if app.stats_emitter is not None:
|
||||
app.stats_emitter.config = config
|
||||
app.stats_emitter.hardware_stats.set_config(config)
|
||||
|
||||
if app.dispatcher is not None:
|
||||
app.dispatcher.config = config
|
||||
|
||||
@@ -10,8 +10,6 @@ class AppConfigSetBody(BaseModel):
|
||||
update_topic: str | None = None
|
||||
config_data: dict[str, Any] | None = None
|
||||
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):
|
||||
|
||||
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
resolve_coverage,
|
||||
stream_has_audio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -681,15 +680,10 @@ async def _vod_response(
|
||||
end_ts,
|
||||
force_discontinuity,
|
||||
)
|
||||
intervals = resolve_coverage(camera_name, start_ts, end_ts)
|
||||
|
||||
# rows contradicting their stream's audio composition are
|
||||
# truncated-shutdown glitches
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
|
||||
spans = build_spans(
|
||||
null_audio_glitches(intervals, main_audio, sub_audio),
|
||||
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
|
||||
stream_preference,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,57 +1,8 @@
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.util.live_streams import DEFAULT_TRANSCODE_QUALITIES
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__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
|
||||
__all__ = ["CameraLiveConfig"]
|
||||
|
||||
|
||||
class CameraLiveConfig(FrigateBaseModel):
|
||||
@@ -60,11 +11,6 @@ class CameraLiveConfig(FrigateBaseModel):
|
||||
title="Live stream names",
|
||||
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(
|
||||
default=720,
|
||||
title="Live height",
|
||||
|
||||
@@ -35,12 +35,6 @@ from frigate.util.config import (
|
||||
migrate_frigate_config,
|
||||
)
|
||||
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 .auth import AuthConfig
|
||||
@@ -288,78 +282,11 @@ 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(
|
||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||
) -> ValueError | None:
|
||||
"""Verify that a restream exists to use for live view."""
|
||||
for _, stream_name in camera_config.live.streams.items():
|
||||
if is_transcode_stream_name(camera_config.name, stream_name):
|
||||
continue
|
||||
|
||||
if (
|
||||
stream_name
|
||||
not in frigate_config.go2rtc.model_dump().get("streams", {}).keys()
|
||||
@@ -1051,8 +978,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
"face_recognition": ["enabled", "min_area"],
|
||||
"lpr": ["enabled", "expire_time", "min_area", "enhancement"],
|
||||
"audio_transcription": ["enabled", "live_enabled"],
|
||||
# transcode is camera-level only
|
||||
"live": ["streams", "height", "quality"],
|
||||
}
|
||||
|
||||
for section in allowed_fields_map:
|
||||
@@ -1281,8 +1206,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if not camera_config.live.streams:
|
||||
camera_config.live.streams = {name: name}
|
||||
|
||||
apply_live_transcode_streams(self, camera_config)
|
||||
|
||||
# generate the ffmpeg commands
|
||||
camera_config.create_ffmpeg_cmds()
|
||||
self.cameras[name] = camera_config
|
||||
|
||||
@@ -123,6 +123,7 @@ class ModelConfig(BaseModel):
|
||||
_all_attributes: list[str] = PrivateAttr()
|
||||
_all_attribute_logos: list[str] = PrivateAttr()
|
||||
_model_hash: str = PrivateAttr()
|
||||
_plus_id: str | None = PrivateAttr(default=None)
|
||||
|
||||
@property
|
||||
def merged_labelmap(self) -> dict[int, str]:
|
||||
@@ -148,6 +149,11 @@ class ModelConfig(BaseModel):
|
||||
def model_hash(self) -> str:
|
||||
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):
|
||||
super().__init__(**config)
|
||||
|
||||
@@ -178,6 +184,7 @@ class ModelConfig(BaseModel):
|
||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||
|
||||
model_id = self.path[7:]
|
||||
self._plus_id = model_id
|
||||
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
model_info_path = f"{self.path}.json"
|
||||
|
||||
|
||||
@@ -103,11 +103,10 @@ class LlamaCppClient(GenAIClient):
|
||||
_supports_reasoning: bool
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_text_baseline_tokens: int | None
|
||||
_media_marker: str
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
"""llama.cpp exposes an /embeddings endpoint for any loaded model."""
|
||||
"""llama.cpp exposes a /v1/embeddings endpoint for any loaded model."""
|
||||
return True
|
||||
|
||||
def _auth_headers(self) -> dict | None:
|
||||
@@ -159,7 +158,6 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_reasoning = False
|
||||
self._image_token_cache = {}
|
||||
self._text_baseline_tokens = None
|
||||
self._media_marker = "<__media__>"
|
||||
|
||||
base_url = (
|
||||
self.genai_config.base_url.rstrip("/")
|
||||
@@ -187,7 +185,6 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_audio = info["supports_audio"]
|
||||
self._supports_tools = info["supports_tools"]
|
||||
self._supports_reasoning = info["supports_reasoning"]
|
||||
self._media_marker = info["media_marker"]
|
||||
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||
@@ -215,9 +212,7 @@ class LlamaCppClient(GenAIClient):
|
||||
`architecture.input_modalities` (text/image/audio) — the primary
|
||||
source. When proxied through llama-swap, the same entry carries
|
||||
`status.args` (server launch argv) and, for the loaded model,
|
||||
`meta.n_ctx`. /props remains the only source for `media_marker`,
|
||||
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
|
||||
is set.
|
||||
`meta.n_ctx`.
|
||||
"""
|
||||
info: dict[str, Any] = {
|
||||
"context_size": None,
|
||||
@@ -225,7 +220,6 @@ class LlamaCppClient(GenAIClient):
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
|
||||
model_entry: dict[str, Any] | None = None
|
||||
@@ -314,16 +308,8 @@ class LlamaCppClient(GenAIClient):
|
||||
# in the Jinja chat template itself.
|
||||
chat_template = props.get("chat_template") or ""
|
||||
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:
|
||||
logger.warning(
|
||||
"Failed to query llama.cpp /props endpoint: %s. "
|
||||
"Image embeddings may fail if the server randomized its media marker.",
|
||||
e,
|
||||
)
|
||||
logger.warning("Failed to query llama.cpp /props endpoint: %s", e)
|
||||
|
||||
return info
|
||||
|
||||
@@ -474,9 +460,6 @@ class LlamaCppClient(GenAIClient):
|
||||
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
|
||||
"""Transcribe through /v1/chat/completions, for servers without the
|
||||
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."
|
||||
|
||||
@@ -794,41 +777,16 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
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(
|
||||
self,
|
||||
texts: list[str] | None = None,
|
||||
images: list[bytes] | None = None,
|
||||
) -> list[np.ndarray]:
|
||||
"""Generate embeddings via llama.cpp /embeddings endpoint.
|
||||
"""Generate embeddings via llama.cpp /v1/embeddings endpoint.
|
||||
|
||||
Supports batch requests. Uses content format with prompt_string and
|
||||
multimodal_data for images (PR #15108). Server must be started with
|
||||
--embeddings and --mmproj for multimodal support.
|
||||
Each text or image is one entry in `input`, using the chat-style
|
||||
content array from ggml-org/llama.cpp#29556. Server must be started
|
||||
with --embeddings, and --mmproj for image support.
|
||||
"""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
@@ -843,49 +801,42 @@ class LlamaCppClient(GenAIClient):
|
||||
|
||||
EMBEDDING_DIM = 768
|
||||
|
||||
encoded_images: list[str] = []
|
||||
inputs: list[dict[str, Any]] = [
|
||||
{"content": [{"type": "text", "text": text}]} for text in texts
|
||||
]
|
||||
|
||||
for img in images:
|
||||
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||
jpeg_bytes = _to_jpeg(img)
|
||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
|
||||
|
||||
def build_content() -> list[dict[str, Any]]:
|
||||
# prompt_string must contain the server's media marker placeholder
|
||||
# for each image. The marker is randomized per server startup.
|
||||
content: list[dict[str, Any]] = []
|
||||
for text in texts:
|
||||
content.append({"prompt_string": text})
|
||||
for encoded in encoded_images:
|
||||
content.append(
|
||||
encoded = 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
|
||||
inputs.append(
|
||||
{
|
||||
"prompt_string": f"{self._media_marker}\n",
|
||||
"multimodal_data": [encoded],
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
||||
},
|
||||
{"type": "text", "text": "\n"},
|
||||
]
|
||||
}
|
||||
)
|
||||
return content
|
||||
|
||||
def post_embeddings() -> requests.Response:
|
||||
return self._post(
|
||||
f"{self.provider}/embeddings",
|
||||
json={"model": self.genai_config.model, "content": build_content()},
|
||||
try:
|
||||
response = self._post(
|
||||
f"{self.provider}/v1/embeddings",
|
||||
json={
|
||||
"model": self.genai_config.model,
|
||||
"input": inputs,
|
||||
"encoding_format": "float",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException:
|
||||
# The server may have restarted with a new media marker.
|
||||
# Refresh from /props; only retry if the marker actually changed.
|
||||
if not encoded_images or not self._refresh_media_marker():
|
||||
raise
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
items = response.json().get("data")
|
||||
|
||||
items = result.get("data", result) if isinstance(result, dict) else result
|
||||
if not isinstance(items, list):
|
||||
logger.warning("llama.cpp embeddings returned unexpected format")
|
||||
return []
|
||||
@@ -896,11 +847,7 @@ class LlamaCppClient(GenAIClient):
|
||||
if emb is None:
|
||||
logger.warning("llama.cpp embeddings item missing embedding field")
|
||||
continue
|
||||
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()
|
||||
arr = np.array(emb, dtype=np.float32).flatten()
|
||||
orig_dim = arr.size
|
||||
if orig_dim != EMBEDDING_DIM:
|
||||
if orig_dim > EMBEDDING_DIM:
|
||||
|
||||
+90
-22
@@ -42,6 +42,8 @@ from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.recording_coverage import (
|
||||
build_spans,
|
||||
known_video_codecs,
|
||||
null_audio_glitches,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
stream_media_summary,
|
||||
)
|
||||
@@ -84,10 +86,20 @@ class StreamRun:
|
||||
|
||||
@dataclass
|
||||
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
|
||||
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).
|
||||
@@ -376,15 +388,18 @@ class RecordingExporter(threading.Thread):
|
||||
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
||||
"""Resolve the export range into the spans the VOD manifest will serve.
|
||||
|
||||
Delegates to the same coverage resolution the manifest builder
|
||||
uses, so what we plan around and what nginx-vod emits agree by
|
||||
construction. Returns the spans (each [row, start, end, is_main]),
|
||||
the known video codecs, and whether audio survives the range.
|
||||
Delegates to the same coverage resolution and glitch nulling the
|
||||
manifest builder uses, so what we plan around and what nginx-vod
|
||||
emits agree by construction. Returns the spans (each [row, start,
|
||||
end, is_main]), the known video codecs, and whether audio survives
|
||||
the range.
|
||||
Memoized: several stages of the export ask the same question, and
|
||||
the recordings backing a finished range do not change under us.
|
||||
"""
|
||||
if self._coverage is None:
|
||||
intervals = resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
intervals = null_audio_glitches(
|
||||
resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
)
|
||||
self._coverage = (
|
||||
build_spans(intervals, self.pinned_stream),
|
||||
known_video_codecs(intervals),
|
||||
@@ -433,17 +448,57 @@ class RecordingExporter(threading.Thread):
|
||||
# hand-off to stage around
|
||||
return True
|
||||
|
||||
spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._stream_runs(spans)
|
||||
_spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._planned_stream_runs()
|
||||
|
||||
# a range one stream covers end to end has nothing to hand off,
|
||||
# so it stays on the existing path however long it is
|
||||
if len(runs) < 2:
|
||||
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)
|
||||
|
||||
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]:
|
||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
||||
|
||||
@@ -840,6 +895,8 @@ class RecordingExporter(threading.Thread):
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
# a staged window's keyframe lead-in plays before it
|
||||
output_offset += _lead_in(rec)
|
||||
windows.append((clipped_start, clipped_end, output_offset))
|
||||
output_offset += clipped_end - clipped_start
|
||||
|
||||
@@ -987,9 +1044,13 @@ class RecordingExporter(threading.Thread):
|
||||
if duration_ms <= 0:
|
||||
continue
|
||||
|
||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
# a staged window's keyframe lead-in opens its chapter, with
|
||||
# frames captured that long before the window
|
||||
lead_in = _lead_in(rec)
|
||||
duration_ms += int(round(lead_in * 1000))
|
||||
title = datetime.datetime.fromtimestamp(
|
||||
clipped_start - lead_in, tz=tz
|
||||
).isoformat(timespec="seconds")
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
@@ -1128,12 +1189,12 @@ class RecordingExporter(threading.Thread):
|
||||
if self.staged_runs:
|
||||
# each run was already rendered to a temp file with a common
|
||||
# track timescale, so the concat demuxer has nothing left to
|
||||
# reconcile and every chapter offset lines up with the merged
|
||||
# timeline the staged files reproduce
|
||||
recordings = [
|
||||
_ChapterWindow(span_start, span_end)
|
||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
||||
]
|
||||
# reconcile
|
||||
recordings = (
|
||||
self._staged_chapter_windows()
|
||||
if self.chapters not in (None, ChaptersEnum.none)
|
||||
else []
|
||||
)
|
||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||
ffmpeg_input = (
|
||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||
@@ -1149,11 +1210,18 @@ class RecordingExporter(threading.Thread):
|
||||
# its own rows are the ones the chapters describe
|
||||
recordings = self._get_recordings_for_range(pin)
|
||||
else:
|
||||
# never mix streams in one playlist; use main when available
|
||||
# and fall back to sub for expired-main history
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
||||
# an unstaged auto range resolves to at most one stream run, and
|
||||
# its rows are the ones the chapters describe. Main rows the
|
||||
# manifest drops (glitches, slivers at the edges of a sub range)
|
||||
# must not stand in for it.
|
||||
runs = self._stream_runs(self._merged_spans())
|
||||
recordings = self._get_recordings_for_range(
|
||||
runs[0].stream_type if runs else STREAM_TYPE_MAIN
|
||||
)
|
||||
|
||||
if not recordings:
|
||||
# never mix streams in one playlist; fall back to sub for
|
||||
# expired-main history
|
||||
if not recordings and not runs:
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||
|
||||
playlist_lines = []
|
||||
|
||||
@@ -490,9 +490,17 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
reviews = reviews_by_camera[camera]
|
||||
|
||||
tasks.extend(
|
||||
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
|
||||
# probes run concurrently, but each segment's start chains off the
|
||||
# previous segment's end, so starts resolve in segment order
|
||||
previous: asyncio.Event | None = None
|
||||
for recording in recordings:
|
||||
resolved = asyncio.Event()
|
||||
tasks.append(
|
||||
self._validate_in_order(
|
||||
camera, reviews, recording, previous, resolved
|
||||
)
|
||||
)
|
||||
previous = resolved
|
||||
|
||||
# publish most recently available recording time and None if disabled
|
||||
if stream_type == STREAM_TYPE_MAIN:
|
||||
@@ -550,12 +558,33 @@ class RecordingMaintainer(threading.Thread):
|
||||
while info and info[0][0] < expire_before:
|
||||
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:
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
async def validate_and_move_segment(
|
||||
self, camera: str, reviews: Any, recording: dict[str, Any]
|
||||
self,
|
||||
camera: str,
|
||||
reviews: Any,
|
||||
recording: dict[str, Any],
|
||||
previous_start: asyncio.Event | None = None,
|
||||
start_resolved: asyncio.Event | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
@@ -617,6 +646,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
async with self.probe_semaphore:
|
||||
keyframes = await get_keyframe_offsets(cache_path)
|
||||
|
||||
if previous_start is not None:
|
||||
await previous_start.wait()
|
||||
|
||||
start_time = self._resolve_segment_start(
|
||||
camera, stream_type, start_time, duration, cache_path
|
||||
)
|
||||
@@ -654,6 +686,11 @@ class RecordingMaintainer(threading.Thread):
|
||||
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
|
||||
|
||||
# sub's alerts/detections carry the retain mode directly, unlike
|
||||
|
||||
@@ -256,6 +256,17 @@ class HardwareStats:
|
||||
)
|
||||
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:
|
||||
"""Recalculate all hardware that needs to be monitored from the config."""
|
||||
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
||||
|
||||
@@ -111,15 +111,18 @@ def get_detector_stats(
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Get stats for all detectors, including temperatures based on detector type."""
|
||||
detector_stats: dict[str, dict[str, Any]] = {}
|
||||
detector_type_indices: dict[str, int] = {}
|
||||
# detector type -> device -> index into that type's temperatures
|
||||
device_indices: dict[str, dict[str, int]] = {}
|
||||
|
||||
for name, detector in stats_tracking["detectors"].items():
|
||||
pid = detector.detect_process.pid if detector.detect_process else None
|
||||
detector_type = detector.detector_config.type
|
||||
|
||||
# Keep track of the index for each detector type to match temperatures correctly
|
||||
current_index = detector_type_indices.get(detector_type, 0)
|
||||
detector_type_indices[detector_type] = current_index + 1
|
||||
# temperatures are per physical unit, so a repeated device
|
||||
# ("hailo:PCIe#2", see runner_names) shares its unit's reading
|
||||
device = name.partition("#")[0]
|
||||
type_devices = device_indices.setdefault(detector_type, {})
|
||||
current_index = type_devices.setdefault(device, len(type_devices))
|
||||
|
||||
detector_stat = {
|
||||
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
@@ -90,6 +92,44 @@ class TestHttpApp(BaseTestHttp):
|
||||
mqtt = response.json()["mqtt"]
|
||||
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 ##################################################
|
||||
####################################################################################################################
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"""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"])
|
||||
@@ -1,61 +0,0 @@
|
||||
"""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)
|
||||
@@ -26,6 +26,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
app.genai_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)
|
||||
app.stats_emitter.hardware_stats.set_config.assert_called_once_with(config)
|
||||
self.assertIs(app.dispatcher.config, config)
|
||||
for comm in app.dispatcher.comms:
|
||||
self.assertIs(comm.config, config)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for per-detector stats."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.stats.util import get_detector_stats
|
||||
|
||||
|
||||
def _detector(detector_type: str) -> MagicMock:
|
||||
detector = MagicMock()
|
||||
detector.detector_config.type = detector_type
|
||||
detector.avg_inference_speed.value = 0.01
|
||||
detector.detection_start.value = 0.0
|
||||
detector.detect_process.pid = 1
|
||||
return detector
|
||||
|
||||
|
||||
class TestDetectorTemperatures(unittest.TestCase):
|
||||
def test_repeated_device_shares_its_unit_temperature(self):
|
||||
stats_tracking = {
|
||||
"detectors": {
|
||||
"hailo:PCIe": _detector("hailo8l"),
|
||||
"hailo:PCIe#2": _detector("hailo8l"),
|
||||
"hailo:PCIe:1": _detector("hailo8l"),
|
||||
}
|
||||
}
|
||||
|
||||
with patch(
|
||||
"frigate.stats.util.get_hardware_temperatures", return_value=[50.0, 60.0]
|
||||
):
|
||||
stats = get_detector_stats(stats_tracking)
|
||||
|
||||
self.assertEqual(stats["hailo:PCIe"]["temperature"], 50.0)
|
||||
self.assertEqual(stats["hailo:PCIe#2"]["temperature"], 50.0)
|
||||
self.assertEqual(stats["hailo:PCIe:1"]["temperature"], 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -543,6 +543,56 @@ class TestPinnedStream(unittest.TestCase):
|
||||
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):
|
||||
"""A staged path must be tracked before ffmpeg can write to it."""
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.config import GenAIConfig, GenAIProviderEnum
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
|
||||
@@ -566,7 +568,6 @@ class TestLlamaCppProvider(unittest.TestCase):
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
||||
with patch.object(cls, "_get_model_info", return_value=info):
|
||||
@@ -589,6 +590,62 @@ class TestLlamaCppProvider(unittest.TestCase):
|
||||
with patch.object(client, "_fetch_models_data", return_value=models_data):
|
||||
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
|
||||
@@ -762,7 +819,6 @@ class TestLlamaCppTranscribe(unittest.TestCase):
|
||||
"supports_audio": supports_audio,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
||||
with patch.object(cls, "_get_model_info", return_value=info):
|
||||
|
||||
@@ -288,6 +288,17 @@ class TestUpdateConfig(HardwareStatsTestCase):
|
||||
|
||||
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):
|
||||
def run_stats(self, stats: HardwareStats) -> dict:
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
"""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 sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# Mock complex imports before importing maintainer, saving originals so we can
|
||||
# restore them after import and avoid polluting sys.modules for other tests.
|
||||
@@ -48,8 +48,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
||||
):
|
||||
with patch("frigate.record.maintainer.logger.warning") as warn:
|
||||
# Mock validate_and_move_segment to avoid further logic
|
||||
maintainer.validate_and_move_segment = MagicMock()
|
||||
# Mock validate_and_move_segment to avoid further logic.
|
||||
# The requestor is real when another test imported the
|
||||
# maintainer first, and it would block on a reply.
|
||||
maintainer.validate_and_move_segment = AsyncMock()
|
||||
maintainer.requestor = MagicMock()
|
||||
|
||||
try:
|
||||
await maintainer.move_files()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for sub stream cache segment handling in the recording maintainer."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import tempfile
|
||||
@@ -532,6 +533,59 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
|
||||
self.assertAlmostEqual(calls[1].args[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):
|
||||
maintainer = _build_chaining_maintainer(self.T0)
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ from frigate.util.file import FileLock
|
||||
|
||||
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
|
||||
# download function swallows its exceptions, so this is how the downloader
|
||||
# thread learns why a file is still missing
|
||||
@@ -124,7 +128,9 @@ class ModelDownloader:
|
||||
logger.info(f"Downloading model file from: {url}")
|
||||
|
||||
try:
|
||||
with requests.get(url, stream=True, allow_redirects=True) as r:
|
||||
with requests.get(
|
||||
url, stream=True, allow_redirects=True, timeout=DOWNLOAD_TIMEOUT
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
with open(temporary_filename, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
"""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,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def null_audio_glitches(
|
||||
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
|
||||
) -> list[CoverageInterval]:
|
||||
def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
|
||||
"""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
|
||||
count, so a truncated video-only segment (a backend restart can flush
|
||||
a sub-second file before any audio packet landed) poisons every
|
||||
manifest that includes it. Nulling the row turns the glitch into a
|
||||
hole the span builder skips like any recording gap.
|
||||
hole the span builder skips like any recording gap. Every consumer of
|
||||
a window's coverage (the vod manifest, its realized timelines, and
|
||||
exports) goes through here, so they all agree on which rows exist.
|
||||
"""
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
result: list[CoverageInterval] = []
|
||||
for interval in intervals:
|
||||
main = interval.main
|
||||
@@ -449,9 +451,7 @@ def realized_timelines(
|
||||
assembles each variant's realized spans. Keyframe snapping reads the
|
||||
per-row index stored at record time, so no file is touched.
|
||||
"""
|
||||
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)
|
||||
nulled = null_audio_glitches(intervals)
|
||||
|
||||
return {
|
||||
"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
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Helpers for the live dashboard's draggable grid layout: reading and seeding
|
||||
* the persisted layout, and measuring rendered tiles.
|
||||
*
|
||||
* DraggableGridLayout persists through useUserPersistence, which namespaces
|
||||
* keys by username, and every write is an async idb put. A test that seeds the
|
||||
* bare key, or seeds before the app's own first write has landed, silently
|
||||
* asserts against a key the app never reads. persistedLayoutKey() closes both
|
||||
* holes, so prefer it over building the key by hand.
|
||||
*
|
||||
* Geometry has its own trap: the grid first lays out against window.innerWidth,
|
||||
* then reflows narrower once useResizeObserver reports the real container.
|
||||
* Tiles measured in separate round-trips can straddle that reflow and disagree
|
||||
* on scale, so cameraBoxes() takes every measurement in one evaluate.
|
||||
*
|
||||
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
|
||||
*/
|
||||
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export type LayoutItem = {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
export type PersistedLayout = {
|
||||
version: number;
|
||||
naturalAspect: boolean;
|
||||
layout: LayoutItem[];
|
||||
};
|
||||
|
||||
function layoutKeySuffix(group: string): string {
|
||||
return `${group}-draggable-layout`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the app has actually written an envelope to, or undefined while its
|
||||
* first write is still in flight.
|
||||
*/
|
||||
function findWrittenKey(
|
||||
page: Page,
|
||||
group: string,
|
||||
): Promise<string | undefined> {
|
||||
return page.evaluate(
|
||||
(suffix) =>
|
||||
new Promise<string | undefined>((resolve) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onsuccess = () => {
|
||||
const store = open.result
|
||||
.transaction("keyval", "readonly")
|
||||
.objectStore("keyval");
|
||||
// getAllKeys and getAll both return in key order, so the indexes align
|
||||
const keys = store.getAllKeys();
|
||||
const values = store.getAll();
|
||||
keys.transaction.oncomplete = () => {
|
||||
open.result.close();
|
||||
const names = keys.result as string[];
|
||||
const stored = values.result as { version?: number }[];
|
||||
const match = names.findIndex(
|
||||
(name, index) =>
|
||||
(name === suffix || name.startsWith(`${suffix}:`)) &&
|
||||
typeof stored[index]?.version === "number",
|
||||
);
|
||||
resolve(match === -1 ? undefined : names[match]);
|
||||
};
|
||||
};
|
||||
open.onerror = () => resolve(undefined);
|
||||
}),
|
||||
layoutKeySuffix(group),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the grid to persist its own layout, then return the key it used.
|
||||
* Waiting for that write is what makes a later seed meaningful: it proves the
|
||||
* key is live, and it rules out the app overwriting the seed a moment later.
|
||||
*/
|
||||
export async function persistedLayoutKey(
|
||||
page: Page,
|
||||
group: string,
|
||||
): Promise<string> {
|
||||
let key: string | undefined;
|
||||
|
||||
await expect
|
||||
.poll(async () => (key = await findWrittenKey(page, group)), {
|
||||
timeout: 10_000,
|
||||
message: `grid never persisted a layout for group "${group}"`,
|
||||
})
|
||||
.not.toBeUndefined();
|
||||
|
||||
return key!;
|
||||
}
|
||||
|
||||
/** Overwrite the stored layout, resolving only once the put has committed. */
|
||||
export function seedLayout(
|
||||
page: Page,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
return page.evaluate(
|
||||
([key, value]) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction("keyval", "readwrite");
|
||||
tx.objectStore("keyval").put(value, key as string);
|
||||
tx.oncomplete = () => {
|
||||
open.result.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
open.onerror = () => reject(open.error);
|
||||
}),
|
||||
[key, value] as const,
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the stored layout back. Undefined until the app writes it. */
|
||||
export function readLayout(
|
||||
page: Page,
|
||||
key: string,
|
||||
): Promise<PersistedLayout | undefined> {
|
||||
return page.evaluate(
|
||||
(target) =>
|
||||
new Promise((resolve) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction("keyval", "readonly");
|
||||
const request = tx.objectStore("keyval").get(target);
|
||||
tx.oncomplete = () => {
|
||||
open.result.close();
|
||||
resolve(request.result);
|
||||
};
|
||||
};
|
||||
open.onerror = () => resolve(undefined);
|
||||
}),
|
||||
key,
|
||||
) as Promise<PersistedLayout | undefined>;
|
||||
}
|
||||
|
||||
export type Box = { w: number; h: number; x: number; y: number };
|
||||
|
||||
/** The card is the player root; the cell is the grid slot it sits in. */
|
||||
export type BoxTarget = "card" | "cell";
|
||||
|
||||
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
|
||||
function snapshotBoxes(
|
||||
page: Page,
|
||||
cameras: readonly string[],
|
||||
target: BoxTarget,
|
||||
): Promise<Record<string, Box> | null> {
|
||||
return page.evaluate(
|
||||
({ cams, target }) => {
|
||||
const boxes: Record<string, Box> = {};
|
||||
|
||||
for (const cam of cams) {
|
||||
const card = document.querySelector(`[data-camera='${cam}']`);
|
||||
const el = target === "cell" ? card?.closest(".p-1") : card;
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
|
||||
// a re-rendering tile can briefly report no box at all
|
||||
if (!r.width || !r.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
|
||||
}
|
||||
|
||||
return boxes;
|
||||
},
|
||||
{ cams: cameras as readonly string[], target },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the given cameras' tiles together, once they have all rendered.
|
||||
* Measuring in one evaluate is what keeps the numbers mutually comparable.
|
||||
*/
|
||||
export async function cameraBoxes<T extends string>(
|
||||
page: Page,
|
||||
cameras: readonly T[],
|
||||
target: BoxTarget = "cell",
|
||||
): Promise<Record<T, Box>> {
|
||||
let boxes: Record<string, Box> | null = null;
|
||||
|
||||
await expect
|
||||
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
|
||||
timeout: 10_000,
|
||||
message: `${target}s never rendered for ${cameras.join(", ")}`,
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
return boxes as unknown as Record<T, Box>;
|
||||
}
|
||||
@@ -45,6 +45,11 @@ export class LivePage extends BasePage {
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
|
||||
get editLayoutButton(): Locator {
|
||||
return this.page.getByTestId("toggle-edit-layout");
|
||||
}
|
||||
|
||||
/** Open the right-click context menu on a camera card (desktop only). */
|
||||
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
||||
await this.cameraCard(cameraName).first().click({ button: "right" });
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
/**
|
||||
* 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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Live grid aspect modes.
|
||||
*
|
||||
* Bucketed mode (the default) snaps every camera to a wide, landscape or tall
|
||||
* tile, and converts layouts saved by pre-masonry versions instead of
|
||||
* discarding them. Natural mode sizes each tile to its own camera.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { LivePage } from "../pages/live.page";
|
||||
import {
|
||||
cameraBoxes,
|
||||
persistedLayoutKey,
|
||||
readLayout,
|
||||
seedLayout,
|
||||
type LayoutItem,
|
||||
} from "../helpers/grid-layout";
|
||||
|
||||
const GROUP = "outdoor";
|
||||
const GRID_COLS = 96;
|
||||
|
||||
test.describe("Live grid aspect modes @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Draggable grid is desktop-only",
|
||||
);
|
||||
|
||||
test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: { backyard: { detect: { width: 2560, height: 720 } } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const { backyard: wide, front_door: normal } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
["backyard", "front_door"] as const,
|
||||
);
|
||||
|
||||
expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1);
|
||||
expect(wide.w / normal.w).toBeCloseTo(2, 1);
|
||||
expect(wide.h).toBeCloseTo(normal.h, 0);
|
||||
});
|
||||
|
||||
test("a letterboxed still image rounds its own corners", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// A portrait camera pillarboxes inside its 8:9 bucket, so the card's
|
||||
// overflow-hidden clip never reaches the picture's corners. The image has
|
||||
// to carry the radius itself or it renders with square edges on the tile.
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const radii = await frigateApp.page.evaluate(() => {
|
||||
const card = document.querySelector("[data-camera='backyard']");
|
||||
const img = card?.querySelector("img");
|
||||
return {
|
||||
card: card ? getComputedStyle(card).borderTopLeftRadius : null,
|
||||
img: img ? getComputedStyle(img).borderTopLeftRadius : null,
|
||||
};
|
||||
});
|
||||
|
||||
expect(radii.card).not.toBe("0px");
|
||||
expect(radii.img).toBe(radii.card);
|
||||
});
|
||||
|
||||
test("a pre-masonry layout is converted, keeping resized tiles", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles.
|
||||
// backyard was manually resized to 8x8 and sits beside front_door's column,
|
||||
// front_door is a standard tile on the row below.
|
||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
||||
await seedLayout(frigateApp.page, key, [
|
||||
{ i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false },
|
||||
{ i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false },
|
||||
]);
|
||||
await frigateApp.page.reload();
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The conversion is written back on first load, replacing the legacy array
|
||||
// with an envelope. Poll for it: that write is an async idb put.
|
||||
await expect
|
||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(2);
|
||||
|
||||
const stored = (await readLayout(frigateApp.page, key))!;
|
||||
expect(stored).toMatchObject({ version: 2, naturalAspect: false });
|
||||
|
||||
// x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per
|
||||
// standard tile), so the manual resize survives instead of snapping back.
|
||||
expect(
|
||||
stored.layout.find((i: LayoutItem) => i.i === "backyard"),
|
||||
).toMatchObject({
|
||||
x: 32,
|
||||
y: 0,
|
||||
w: 64,
|
||||
h: 144,
|
||||
});
|
||||
expect(
|
||||
stored.layout.find((i: LayoutItem) => i.i === "front_door"),
|
||||
).toMatchObject({
|
||||
x: 0,
|
||||
y: 144,
|
||||
w: 32,
|
||||
h: 72,
|
||||
});
|
||||
|
||||
// arrangement on screen: backyard indented, front_door below it
|
||||
const { backyard, front_door: frontDoor } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
["backyard", "front_door"] as const,
|
||||
);
|
||||
expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2);
|
||||
expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2);
|
||||
});
|
||||
|
||||
test("conversion is a pure scale, so odd sizes and positions survive", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The old grid exposed all four resize corners with no aspect constraint,
|
||||
// so a stored tile can be any size. These two are adjacent and non-standard.
|
||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
||||
await seedLayout(frigateApp.page, key, [
|
||||
{ i: "front_door", x: 0, y: 3, w: 5, h: 5 },
|
||||
{ i: "backyard", x: 5, y: 3, w: 7, h: 5 },
|
||||
]);
|
||||
await frigateApp.page.reload();
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(2);
|
||||
|
||||
const stored = (await readLayout(frigateApp.page, key))!;
|
||||
const frontDoor = stored.layout.find(
|
||||
(i: LayoutItem) => i.i === "front_door",
|
||||
)!;
|
||||
const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!;
|
||||
|
||||
expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 });
|
||||
expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 });
|
||||
|
||||
// still adjacent, still inside the grid, still not overlapping
|
||||
expect(frontDoor.x + frontDoor.w).toBe(backyard.x);
|
||||
expect(backyard.x + backyard.w).toBe(GRID_COLS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Masonry live grid -- custom-group draggable layout.
|
||||
*
|
||||
* Verifies natural-aspect tile sizing and that a saved layout the current
|
||||
* version cannot read is regenerated cleanly. The grid renders only for a
|
||||
* custom camera group (here: "outdoor") on desktop; mobile keeps the static
|
||||
* grid, which the @mobile block below guards.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { LivePage } from "../pages/live.page";
|
||||
import {
|
||||
cameraBoxes,
|
||||
persistedLayoutKey,
|
||||
readLayout,
|
||||
seedLayout,
|
||||
} from "../helpers/grid-layout";
|
||||
|
||||
const GROUP = "outdoor"; // custom group: front_door + backyard
|
||||
|
||||
test.describe("Masonry live grid @critical", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Draggable masonry grid is desktop-only",
|
||||
);
|
||||
|
||||
test("custom group renders its cameras in the draggable grid", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("tiles render at their camera's natural aspect ratio", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// backyard is 9:16, which bucketed mode would snap to an 8:9 tile
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await seedLayout(frigateApp.page, "naturalAspectLayout:admin", true);
|
||||
await frigateApp.page.reload();
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const { front_door: landscape, backyard: portrait } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
["front_door", "backyard"] as const,
|
||||
"card",
|
||||
);
|
||||
|
||||
expect(landscape.w / landscape.h).toBeCloseTo(16 / 9, 1);
|
||||
expect(portrait.w / portrait.h).toBeCloseTo(9 / 16, 1);
|
||||
});
|
||||
|
||||
test("dragging a tile does not shove other tiles far away", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await live.editLayoutButton.click();
|
||||
|
||||
const cameras = ["front_door", "backyard"] as const;
|
||||
const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
cameras,
|
||||
"card",
|
||||
);
|
||||
|
||||
// Drag backyard onto front_door's position (a deliberate collision). With
|
||||
// free-placement + prevent-collision, front_door must NOT be shoved down.
|
||||
const from = {
|
||||
x: draggedBox.x + draggedBox.w / 2,
|
||||
y: draggedBox.y + draggedBox.h / 2,
|
||||
};
|
||||
const to = {
|
||||
x: fixedBefore.x + fixedBefore.w / 2,
|
||||
y: fixedBefore.y + fixedBefore.h / 2,
|
||||
};
|
||||
await frigateApp.page.mouse.move(from.x, from.y);
|
||||
await frigateApp.page.mouse.down();
|
||||
await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 });
|
||||
await frigateApp.page.mouse.up();
|
||||
|
||||
const { front_door: fixedAfter } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
cameras,
|
||||
"card",
|
||||
);
|
||||
// Allow a few px of snap; a collision-push would move it a whole tile down.
|
||||
expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40);
|
||||
});
|
||||
|
||||
test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// A lone top tile has room to grow sideways, which is what exposed the bug:
|
||||
// a top-edge handle let width grow while height stayed clamped at y=0.
|
||||
await frigateApp.installDefaults({
|
||||
config: { camera_groups: { outdoor: { cameras: ["front_door"] } } },
|
||||
});
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await live.editLayoutButton.click();
|
||||
|
||||
const tile = frigateApp.page.locator(".react-grid-item", {
|
||||
has: frigateApp.page.locator("[data-camera='front_door']"),
|
||||
});
|
||||
const only = ["front_door"] as const;
|
||||
const { front_door: before } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
only,
|
||||
"card",
|
||||
);
|
||||
const aspect = before.w / before.h;
|
||||
|
||||
// Regression: if a top-edge handle is exposed, dragging it up/out must NOT
|
||||
// distort the aspect (the old bug grew width while height stayed clamped).
|
||||
const ne = tile.locator(".react-resizable-handle-ne");
|
||||
if (await ne.count()) {
|
||||
await ne.dragTo(tile, {
|
||||
force: true,
|
||||
targetPosition: { x: 1000, y: -160 },
|
||||
});
|
||||
const { front_door: afterNe } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
only,
|
||||
"card",
|
||||
);
|
||||
// It must actually resize (not a silent no-op) AND keep its aspect.
|
||||
expect(afterNe.w).toBeGreaterThan(before.w);
|
||||
expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2);
|
||||
}
|
||||
|
||||
// Positive: growing from the bottom-right corner resizes and keeps aspect.
|
||||
const se = tile.locator(".react-resizable-handle-se");
|
||||
await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } });
|
||||
const { front_door: grown } = await cameraBoxes(
|
||||
frigateApp.page,
|
||||
only,
|
||||
"card",
|
||||
);
|
||||
expect(grown.w).toBeGreaterThan(before.w);
|
||||
expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2);
|
||||
});
|
||||
|
||||
test("the grid keeps its measured width after a back navigation", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The grid sizes itself from window.innerWidth until its container is
|
||||
// measured. On a warm back navigation nothing re-renders after that
|
||||
// container mounts, so an observer that never attaches leaves every tile
|
||||
// sized against the full window: the layout widens by the sidebar's width
|
||||
// and the rightmost column clips on a full row.
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// total width the tiles span; tracks the width the grid laid out against
|
||||
const span = () =>
|
||||
frigateApp.page.evaluate(() => {
|
||||
const tiles = [...document.querySelectorAll(".react-grid-item")];
|
||||
|
||||
if (!tiles.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rects = tiles.map((tile) => tile.getBoundingClientRect());
|
||||
return +(
|
||||
Math.max(...rects.map((r) => r.right)) -
|
||||
Math.min(...rects.map((r) => r.left))
|
||||
).toFixed(1);
|
||||
});
|
||||
|
||||
let fresh: number | null = null;
|
||||
await expect
|
||||
.poll(async () => (fresh = await span()), { timeout: 10_000 })
|
||||
.not.toBeNull();
|
||||
|
||||
await live.cameraCard("front_door").first().click();
|
||||
await expect(frigateApp.page).toHaveURL(/#front_door/);
|
||||
|
||||
await frigateApp.page.goBack();
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// the layout must settle back to the measured width, not window.innerWidth
|
||||
await expect
|
||||
.poll(span, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(fresh! + 2);
|
||||
});
|
||||
|
||||
test("a camera added to a saved layout fills an open column", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// one tall tile in the first column; backyard is missing from the layout
|
||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
||||
await seedLayout(frigateApp.page, key, {
|
||||
version: 2,
|
||||
naturalAspect: false,
|
||||
layout: [{ i: "front_door", x: 0, y: 0, w: 32, h: 400 }],
|
||||
});
|
||||
await frigateApp.page.reload();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const stored = await readLayout(frigateApp.page, key);
|
||||
return stored?.layout.find((item) => item.i === "backyard");
|
||||
})
|
||||
.toMatchObject({ x: 32, y: 0 });
|
||||
});
|
||||
|
||||
test("saved layout from an unreadable version regenerates without error", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// A bare array is converted rather than discarded (covered in
|
||||
// live-grid-aspect-modes), so use a version the current grid cannot read.
|
||||
const key = await persistedLayoutKey(frigateApp.page, GROUP);
|
||||
await seedLayout(frigateApp.page, key, {
|
||||
version: 1,
|
||||
naturalAspect: false,
|
||||
layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }],
|
||||
});
|
||||
|
||||
await frigateApp.page.reload();
|
||||
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
|
||||
|
||||
// Grid regenerated; both cameras still render and the error collector
|
||||
// (frigate-test fixture) catches any crash.
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
||||
|
||||
// The app must have replaced the value it could not read. Without this the
|
||||
// test would still pass against a key the app never touches.
|
||||
await expect
|
||||
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Masonry live grid on mobile @critical @mobile", () => {
|
||||
test("custom group keeps the static grid, with no draggable layout", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
await frigateApp.goto(`/?group=${GROUP}`);
|
||||
const live = new LivePage(frigateApp.page, false);
|
||||
await expect(live.cameraCard("front_door").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(live.cameraCard("backyard").first()).toBeVisible();
|
||||
|
||||
// isMobileOnly routes around DraggableGridLayout entirely, so neither the
|
||||
// grid items nor the edit-layout toggle may appear.
|
||||
await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0);
|
||||
await expect(live.editLayoutButton).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* 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,6 +217,23 @@ test.describe("Detection models settings @high", () => {
|
||||
).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 ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -248,15 +265,13 @@ test.describe("Detection models settings @high", () => {
|
||||
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
|
||||
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(
|
||||
frigateApp.page,
|
||||
[
|
||||
{
|
||||
scene: "all",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "/config/model_cache/abc123",
|
||||
path: "plus://abc123",
|
||||
plus: PLUS_MODEL,
|
||||
},
|
||||
],
|
||||
@@ -302,6 +317,65 @@ test.describe("Detection models settings @high", () => {
|
||||
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
|
||||
});
|
||||
|
||||
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 ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
|
||||
@@ -13,7 +13,26 @@ import type { Page } from "@playwright/test";
|
||||
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
|
||||
const STREAMING_KEY = "streaming-settings:admin";
|
||||
|
||||
const OUTDOOR_LAYOUT = [
|
||||
// the shape DraggableGridLayout writes
|
||||
const OUTDOOR_LAYOUT = {
|
||||
version: 2,
|
||||
naturalAspect: false,
|
||||
layout: [
|
||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
||||
{ i: "backyard", x: 32, y: 0, w: 32, h: 72 },
|
||||
],
|
||||
};
|
||||
|
||||
const NATURAL_OUTDOOR_LAYOUT = {
|
||||
version: 2,
|
||||
naturalAspect: true,
|
||||
layout: [
|
||||
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
|
||||
{ i: "backyard", x: 32, y: 0, w: 24, h: 96 },
|
||||
],
|
||||
};
|
||||
|
||||
const LEGACY_OUTDOOR_LAYOUT = [
|
||||
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
|
||||
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
||||
];
|
||||
@@ -159,6 +178,160 @@ test.describe("UI settings import/export @medium", () => {
|
||||
expect(payload.sections.preferences.playbackRate).toBe(2);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
@@ -190,12 +363,15 @@ test.describe("UI settings import/export @medium", () => {
|
||||
await expect(
|
||||
frigateApp.page.getByText("UI preferences (2 settings)"),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(/patio/)).toBeVisible();
|
||||
// patio is layout-only, so its warning follows the layouts section
|
||||
await expect(frigateApp.page.getByText(/patio/)).toBeVisible({
|
||||
visible: !frigateApp.isMobile,
|
||||
});
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
OUTDOOR_LAYOUT,
|
||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
||||
);
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||
STREAMING_SETTINGS,
|
||||
@@ -206,6 +382,7 @@ test.describe("UI settings import/export @medium", () => {
|
||||
test("hides the unknown-group warning when layouts are switched off", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
// patio is a layout-only group absent from this server, so the warning
|
||||
@@ -235,7 +412,39 @@ test.describe("UI settings import/export @medium", () => {
|
||||
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
OUTDOOR_LAYOUT,
|
||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
||||
);
|
||||
});
|
||||
|
||||
test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => {
|
||||
test.skip(!frigateApp.isMobile, "Phone-only");
|
||||
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false });
|
||||
|
||||
await chooseImportFile(frigateApp.page, importPayload());
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText(/aren't imported on phones/),
|
||||
).toBeVisible();
|
||||
|
||||
// the section is still listed, but cannot be switched on
|
||||
await expect(
|
||||
frigateApp.page.getByText("Camera group layouts (2 groups)"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'),
|
||||
).toBeDisabled();
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||
STREAMING_SETTINGS,
|
||||
);
|
||||
// a layouts import is what flips this, so it must stay put
|
||||
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -325,7 +534,7 @@ test.describe("UI settings import/export @medium", () => {
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
|
||||
OUTDOOR_LAYOUT,
|
||||
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
|
||||
);
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
|
||||
STREAMING_SETTINGS,
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
"close": "Close",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"clear": "Clear",
|
||||
"copy": "Copy",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"back": "Back",
|
||||
|
||||
@@ -45,9 +45,7 @@
|
||||
}
|
||||
},
|
||||
"decodedFrames": "Decoded Frames:",
|
||||
"droppedFrameRate": "Dropped Frame Rate:",
|
||||
"stream": "Stream:",
|
||||
"streamAuto": "{{stream}} (auto)"
|
||||
"droppedFrameRate": "Dropped Frame Rate:"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
|
||||
@@ -238,30 +238,6 @@
|
||||
"label": "Live stream names",
|
||||
"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": {
|
||||
"label": "Live height",
|
||||
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
|
||||
|
||||
@@ -594,30 +594,6 @@
|
||||
"label": "Live stream names",
|
||||
"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": {
|
||||
"label": "Live height",
|
||||
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
|
||||
|
||||
@@ -132,19 +132,6 @@
|
||||
},
|
||||
"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": {
|
||||
"tips": {
|
||||
"title": "Audio must be output from your camera and configured in go2rtc for this stream."
|
||||
|
||||
@@ -167,6 +167,11 @@
|
||||
"label": "Always Show Camera Names",
|
||||
"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": {
|
||||
"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."
|
||||
@@ -175,12 +180,14 @@
|
||||
"storedLayouts": {
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Camera Group Streaming Settings",
|
||||
"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": {
|
||||
"title": "Backup & Restore",
|
||||
@@ -195,6 +202,9 @@
|
||||
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
|
||||
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
|
||||
"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)",
|
||||
"streaming_one": "Streaming settings ({{count}} camera)",
|
||||
"streaming_other": "Streaming settings ({{count}} cameras)",
|
||||
@@ -1543,27 +1553,7 @@
|
||||
"noGo2rtcStreams": "No go2rtc streams configured",
|
||||
"availableStreams": "Available streams",
|
||||
"useCustom": "Use \"{{value}}\"",
|
||||
"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"
|
||||
"addStream": "Add stream"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Use browser timezone"
|
||||
@@ -1798,7 +1788,6 @@
|
||||
"toast": {
|
||||
"success": "Settings saved 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.",
|
||||
"error": "Failed to save settings",
|
||||
"validationError": "Validation failed: {{message}}",
|
||||
@@ -2074,7 +2063,7 @@
|
||||
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
|
||||
"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.",
|
||||
"unitsDescription": "Each unit runs its own detection process. A unit already used by another model can not be selected."
|
||||
"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."
|
||||
},
|
||||
"tabs": {
|
||||
"plus": "Frigate+",
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function CameraImage({
|
||||
)}
|
||||
{!imageLoaded && enabled ? (
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -4,18 +4,17 @@ const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["streams", "transcode", "height", "quality"],
|
||||
fieldOrder: ["streams", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality"],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["streams", "height", "quality"],
|
||||
hiddenFields: ["streams", "transcode"],
|
||||
hiddenFields: ["streams"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["height", "quality"],
|
||||
orderedMaps: ["streams"],
|
||||
uiSchema: {
|
||||
streams: {
|
||||
"ui:field": "LiveStreamsField",
|
||||
@@ -24,13 +23,6 @@ const live: SectionConfigOverrides = {
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
transcode: {
|
||||
"ui:field": "LiveTranscodeField",
|
||||
"ui:options": {
|
||||
label: false,
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -66,8 +66,6 @@ import {
|
||||
cameraUpdateTopicMap,
|
||||
globalCameraDefaultSections,
|
||||
buildOverrides,
|
||||
applyOrderedMaps,
|
||||
changedOrderedMapPaths,
|
||||
buildConfigDataForPath,
|
||||
flattenOverrides,
|
||||
getBaseCameraSectionValue,
|
||||
@@ -123,8 +121,6 @@ export interface SectionConfig {
|
||||
messages?: ConditionalMessage[];
|
||||
/** Conditional messages displayed inline with specific fields */
|
||||
fieldMessages?: FieldConditionalMessage[];
|
||||
/** Maps whose key order is saved (e.g. live stream ladder order) */
|
||||
orderedMaps?: string[];
|
||||
}
|
||||
|
||||
export interface BaseSectionProps {
|
||||
@@ -492,18 +488,6 @@ export function ConfigSection({
|
||||
[rawFormData, sanitizeSectionData],
|
||||
);
|
||||
|
||||
const orderedMaps = sectionConfig.orderedMaps;
|
||||
const buildSectionOverrides = useCallback(
|
||||
(current: unknown, base: unknown, defaults: unknown) =>
|
||||
applyOrderedMaps(
|
||||
buildOverrides(current, base, defaults),
|
||||
current,
|
||||
base,
|
||||
orderedMaps ?? [],
|
||||
),
|
||||
[orderedMaps],
|
||||
);
|
||||
|
||||
// Clear pendingData whenever the section/camera key changes (e.g., switching
|
||||
// cameras) or when there is no pending data yet (initialization).
|
||||
// This prevents RJSF's initial onChange call from being treated as a user edit.
|
||||
@@ -544,12 +528,10 @@ export function ConfigSection({
|
||||
// Track if there are unsaved changes
|
||||
const hasChanges = useMemo(() => {
|
||||
const pendingChanged = pendingData
|
||||
? !isEqual(formData, pendingData) ||
|
||||
changedOrderedMapPaths(pendingData, formData, orderedMaps ?? [])
|
||||
.length > 0
|
||||
? !isEqual(formData, pendingData)
|
||||
: false;
|
||||
return pendingChanged || extraHasChanges;
|
||||
}, [formData, pendingData, extraHasChanges, orderedMaps]);
|
||||
}, [formData, pendingData, extraHasChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.({
|
||||
@@ -582,7 +564,7 @@ export function ConfigSection({
|
||||
}
|
||||
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
|
||||
const nextBaselineFormData = baselineSnapshot;
|
||||
const overrides = buildSectionOverrides(
|
||||
const overrides = buildOverrides(
|
||||
sanitizedData,
|
||||
compareBaseData,
|
||||
effectiveSchemaDefaults,
|
||||
@@ -597,7 +579,7 @@ export function ConfigSection({
|
||||
return;
|
||||
}
|
||||
}
|
||||
const dirty = buildSectionOverrides(
|
||||
const dirty = buildOverrides(
|
||||
sanitizedData,
|
||||
nextBaselineFormData,
|
||||
undefined,
|
||||
@@ -620,24 +602,12 @@ export function ConfigSection({
|
||||
setPendingOverrides,
|
||||
setDirtyOverrides,
|
||||
baselineSnapshot,
|
||||
buildSectionOverrides,
|
||||
],
|
||||
);
|
||||
|
||||
const currentFormData = pendingData || formData;
|
||||
const effectiveBaselineFormData = baselineSnapshot;
|
||||
|
||||
// RJSF memoizes fields with an order-blind deep compare, so a pure reorder
|
||||
// of an ordered map would not re-render; the key order breaks the tie
|
||||
const orderedMapsKeyOrder = useMemo(
|
||||
() =>
|
||||
(orderedMaps ?? []).map((path) => {
|
||||
const value = get(currentFormData, path);
|
||||
return value && typeof value === "object" ? Object.keys(value) : [];
|
||||
}),
|
||||
[currentFormData, orderedMaps],
|
||||
);
|
||||
|
||||
// Build context for conditional messages
|
||||
const messageContext = useMemo<MessageConditionContext | undefined>(() => {
|
||||
if (!config || !currentFormData) return undefined;
|
||||
@@ -666,7 +636,7 @@ export function ConfigSection({
|
||||
const sanitizedData = sanitizeSectionData(
|
||||
currentFormData as ConfigSectionData,
|
||||
);
|
||||
return buildSectionOverrides(
|
||||
return buildOverrides(
|
||||
sanitizedData,
|
||||
compareBaseData,
|
||||
effectiveSchemaDefaults,
|
||||
@@ -676,7 +646,6 @@ export function ConfigSection({
|
||||
sanitizeSectionData,
|
||||
compareBaseData,
|
||||
effectiveSchemaDefaults,
|
||||
buildSectionOverrides,
|
||||
]);
|
||||
|
||||
const effectiveOverrides = pendingData
|
||||
@@ -715,12 +684,7 @@ export function ConfigSection({
|
||||
? `cameras.${cameraName}.${effectiveSectionPath}`
|
||||
: effectiveSectionPath;
|
||||
const rawData = sanitizeSectionData(rawFormData);
|
||||
const replacePaths = changedOrderedMapPaths(
|
||||
pendingData,
|
||||
rawData,
|
||||
orderedMaps ?? [],
|
||||
).map((path) => `${basePath}.${path}`);
|
||||
const overrides = buildSectionOverrides(
|
||||
const overrides = buildOverrides(
|
||||
pendingData,
|
||||
rawData,
|
||||
effectiveSchemaDefaults,
|
||||
@@ -747,11 +711,10 @@ export function ConfigSection({
|
||||
: requiresRestartForOverrides(sanitizedOverrides);
|
||||
|
||||
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
|
||||
const response = await axios.put("config/set", {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
config_data: configData,
|
||||
...(replacePaths.length > 0 ? { replace_paths: replacePaths } : {}),
|
||||
...(skipSave ? { skip_save: true } : {}),
|
||||
});
|
||||
|
||||
@@ -789,8 +752,6 @@ export function ConfigSection({
|
||||
),
|
||||
},
|
||||
);
|
||||
} else if (response.data?.go2rtc_synced === false) {
|
||||
toast.warning(t("toast.go2rtcSyncFailed", { ns: "views/settings" }));
|
||||
} else {
|
||||
toast.success(
|
||||
t("toast.success", {
|
||||
@@ -862,8 +823,6 @@ export function ConfigSection({
|
||||
requiresRestartForOverrides,
|
||||
skipSave,
|
||||
onSavingChange,
|
||||
orderedMaps,
|
||||
buildSectionOverrides,
|
||||
]);
|
||||
|
||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||
@@ -1071,7 +1030,6 @@ export function ConfigSection({
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
orderedMapsKeyOrder,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { omit } from "lodash";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
|
||||
@@ -352,6 +353,18 @@ export function synthesizeMissingFilters(
|
||||
return { ...(data as JsonObject), filters: newFilters };
|
||||
}
|
||||
|
||||
// a Frigate+ model's config comes from its model info, so these are either
|
||||
// redundant or stale leftovers from a custom model. A missing input_dtype
|
||||
// in the model info means the int default.
|
||||
const PLUS_SUPPLIED_MODEL_FIELDS = [
|
||||
"width",
|
||||
"height",
|
||||
"input_tensor",
|
||||
"input_pixel_format",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitize overrides payloads for section-specific quirks.
|
||||
*/
|
||||
@@ -360,6 +373,21 @@ export function sanitizeOverridesForSection(
|
||||
level: string,
|
||||
overrides: unknown,
|
||||
): unknown {
|
||||
// the models list is saved whole
|
||||
if (sectionPath === "models" && Array.isArray(overrides)) {
|
||||
return overrides.map((model) => {
|
||||
if (
|
||||
!isJsonObject(model) ||
|
||||
typeof model.path !== "string" ||
|
||||
!model.path.startsWith("plus://")
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
|
||||
return omit(model, PLUS_SUPPLIED_MODEL_FIELDS);
|
||||
});
|
||||
}
|
||||
|
||||
if (!overrides || !isJsonObject(overrides)) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ type HardwarePickerProps = {
|
||||
// scopes the unit checkbox ids, since several models can list the same unit
|
||||
idPrefix: string;
|
||||
devices: string[];
|
||||
// device strings already taken by another model, mapped to that model's scene
|
||||
// device strings already taken by another model, mapped to that model's
|
||||
// scene. Only binding for hardware that can't be shared.
|
||||
claimedElsewhere: Record<string, string>;
|
||||
cameraCount: number;
|
||||
disabled?: boolean;
|
||||
@@ -84,8 +85,11 @@ export function HardwarePicker({
|
||||
return;
|
||||
}
|
||||
|
||||
// start with the first unit no other model has taken
|
||||
const free = entry.units.find((unit) => !claimedElsewhere[unit.device]);
|
||||
// start with the first unit no other model has taken, falling back to
|
||||
// a taken one when the hardware can be shared
|
||||
const free =
|
||||
entry.units.find((unit) => !claimedElsewhere[unit.device]) ??
|
||||
(entry.unlimited ? entry.units[0] : undefined);
|
||||
|
||||
if (!free) {
|
||||
onChange([]);
|
||||
@@ -184,7 +188,9 @@ export function HardwarePicker({
|
||||
{t("detectionModels.hardware.unitsDescription")}
|
||||
</p>
|
||||
{selected.units.map((unit) => {
|
||||
const claimedBy = claimedElsewhere[unit.device];
|
||||
const claimedBy = selected.unlimited
|
||||
? undefined
|
||||
: claimedElsewhere[unit.device];
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import axios from "axios";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Command,
|
||||
@@ -20,35 +16,16 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import {
|
||||
LuArrowDownWideNarrow,
|
||||
LuGripVertical,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { MapKeyInput } from "../components";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type { LiveTranscodeConfig } from "@/types/frigateConfig";
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { isSubtreeModified } from "../utils";
|
||||
import {
|
||||
isTranscodeStreamName,
|
||||
transcodeBitrateFor,
|
||||
} from "@/utils/liveTranscode";
|
||||
|
||||
type LiveStreamsData = Record<string, string>;
|
||||
|
||||
// kbps measured by the server, or configured for a transcoded stream
|
||||
type StreamRate = { kbps: number; set: boolean } | { failed: true };
|
||||
|
||||
function rateValue(rate: StreamRate | undefined): number {
|
||||
return rate && "kbps" in rate ? rate.kbps : -1;
|
||||
}
|
||||
|
||||
type StreamValueComboboxProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -179,154 +156,6 @@ function StreamValueCombobox({
|
||||
);
|
||||
}
|
||||
|
||||
type StreamRowProps = {
|
||||
entryKey: string;
|
||||
value: string;
|
||||
entryId: string;
|
||||
data: LiveStreamsData;
|
||||
generated: boolean;
|
||||
rate?: StreamRate;
|
||||
options: string[];
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
onRename: (oldKey: string, newKey: string) => void;
|
||||
onUpdateValue: (key: string, value: string) => void;
|
||||
onRemove: (key: string) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
function StreamRow({
|
||||
entryKey,
|
||||
value,
|
||||
entryId,
|
||||
data,
|
||||
generated,
|
||||
rate,
|
||||
options,
|
||||
disabled,
|
||||
readonly,
|
||||
onRename,
|
||||
onUpdateValue,
|
||||
onRemove,
|
||||
onDragEnd,
|
||||
}: StreamRowProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const controls = useDragControls();
|
||||
const locked = disabled || readonly;
|
||||
|
||||
const deleteLabel = t("button.delete", {
|
||||
ns: "common",
|
||||
defaultValue: "Delete",
|
||||
});
|
||||
|
||||
let rateLabel: string | undefined;
|
||||
if (rate && "failed" in rate) {
|
||||
rateLabel = t("configForm.liveStreams.rateFailed", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
} else if (rate?.set) {
|
||||
rateLabel = t("configForm.liveStreams.rateSet", {
|
||||
ns: "views/settings",
|
||||
kbps: rate.kbps,
|
||||
});
|
||||
} else if (rate) {
|
||||
rateLabel = t("configForm.liveStreams.rate", {
|
||||
ns: "views/settings",
|
||||
kbps: rate.kbps,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
as="div"
|
||||
value={entryKey}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
onDragEnd={onDragEnd}
|
||||
className="grid grid-cols-12 items-end gap-2 rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="col-span-12 flex items-end gap-1 md:col-span-5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={locked}
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className="mb-2 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing disabled:cursor-not-allowed"
|
||||
aria-label={t("configForm.liveStreams.reorderHandle", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
>
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor={`${entryId}-key`}>
|
||||
{t("configForm.liveStreams.streamNameLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Label>
|
||||
<MapKeyInput
|
||||
id={`${entryId}-key`}
|
||||
value={entryKey}
|
||||
placeholder={t("configForm.liveStreams.streamNamePlaceholder", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
disabled={locked}
|
||||
onCommit={(next) => onRename(entryKey, next)}
|
||||
isKeyTaken={(next) =>
|
||||
next !== entryKey &&
|
||||
Object.prototype.hasOwnProperty.call(data, next)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-10 space-y-2 md:col-span-6">
|
||||
<Label htmlFor={`${entryId}-value`} className="flex items-center gap-2">
|
||||
{t("configForm.liveStreams.go2rtcStreamLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
{generated && (
|
||||
<Badge variant="secondary">
|
||||
{t("configForm.liveStreams.transcodedBadge", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
{generated ? (
|
||||
<Input id={`${entryId}-value`} value={value} disabled readOnly />
|
||||
) : (
|
||||
<StreamValueCombobox
|
||||
id={`${entryId}-value`}
|
||||
value={value}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
onChange={(next) => onUpdateValue(entryKey, next)}
|
||||
/>
|
||||
)}
|
||||
{rateLabel && (
|
||||
<p className="text-xs text-muted-foreground">{rateLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end md:col-span-1">
|
||||
{!generated && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(entryKey)}
|
||||
disabled={locked}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveStreamsField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
@@ -337,7 +166,6 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
const { t: tSettings } = useTranslation(["views/settings"]);
|
||||
|
||||
const data: LiveStreamsData = useMemo(() => {
|
||||
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
|
||||
@@ -347,7 +175,6 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
}, [formData]);
|
||||
|
||||
const entries = useMemo(() => Object.entries(data), [data]);
|
||||
const keys = useMemo(() => entries.map(([key]) => key), [entries]);
|
||||
|
||||
const id = idSchema?.$id ?? props.name;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix;
|
||||
@@ -358,22 +185,12 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
t(`${sectionPrefix}.${id}.description`) ??
|
||||
(schema as RJSFSchema).description;
|
||||
|
||||
const camera = formContext?.cameraName ?? "";
|
||||
const transcode = formContext?.formData?.transcode as
|
||||
LiveTranscodeConfig | undefined;
|
||||
|
||||
const go2rtcStreamNames = useMemo<string[]>(() => {
|
||||
const streams = formContext?.fullConfig?.go2rtc?.streams;
|
||||
if (!streams || typeof streams !== "object") return [];
|
||||
return Object.keys(streams).sort();
|
||||
}, [formContext?.fullConfig?.go2rtc?.streams]);
|
||||
|
||||
const isGenerated = useCallback(
|
||||
(name: string) =>
|
||||
isTranscodeStreamName(camera, name) && !go2rtcStreamNames.includes(name),
|
||||
[camera, go2rtcStreamNames],
|
||||
);
|
||||
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
@@ -393,51 +210,6 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
);
|
||||
}, [fieldPath, formContext, data]);
|
||||
|
||||
const commitOrder = useCallback(
|
||||
(order: string[]) => {
|
||||
const next: LiveStreamsData = {};
|
||||
order.forEach((key) => {
|
||||
next[key] = data[key];
|
||||
});
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
// rows keep one React key through renames, so the name input stays mounted
|
||||
const rowIdsRef = useRef(new Map<string, number>());
|
||||
const nextRowIdRef = useRef(0);
|
||||
|
||||
const rowIdFor = (key: string) => {
|
||||
let id = rowIdsRef.current.get(key);
|
||||
|
||||
if (id === undefined) {
|
||||
id = nextRowIdRef.current++;
|
||||
rowIdsRef.current.set(key, id);
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
// local order while dragging; committed to the form on drop
|
||||
const [dragOrder, setDragOrder] = useState<string[] | null>(null);
|
||||
const dragOrderRef = useRef<string[] | null>(null);
|
||||
|
||||
const handleReorder = useCallback((next: string[]) => {
|
||||
dragOrderRef.current = next;
|
||||
setDragOrder(next);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
const next = dragOrderRef.current;
|
||||
dragOrderRef.current = null;
|
||||
setDragOrder(null);
|
||||
|
||||
if (next && !isEqual(next, keys)) {
|
||||
commitOrder(next);
|
||||
}
|
||||
}, [commitOrder, keys]);
|
||||
|
||||
const handleAddEntry = useCallback(() => {
|
||||
const next = { ...data, "": "" };
|
||||
onChange(next, fieldPath);
|
||||
@@ -455,13 +227,6 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
const handleRenameKey = useCallback(
|
||||
(oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey) return;
|
||||
|
||||
const id = rowIdsRef.current.get(oldKey);
|
||||
if (id !== undefined) {
|
||||
rowIdsRef.current.delete(oldKey);
|
||||
rowIdsRef.current.set(newKey, id);
|
||||
}
|
||||
|
||||
const next: LiveStreamsData = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (k === oldKey) {
|
||||
@@ -483,76 +248,24 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
// Auto order commits after measuring, so edits made meanwhile must survive
|
||||
const dataRef = useRef(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
const [measurement, setMeasurement] = useState<{
|
||||
data: LiveStreamsData;
|
||||
transcode: LiveTranscodeConfig | undefined;
|
||||
rates: Record<string, StreamRate>;
|
||||
} | null>(null);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
|
||||
// rates describe the streams as measured; a later edit hides them, while a
|
||||
// reorder alone keeps them since isEqual ignores key order
|
||||
const rates =
|
||||
measurement &&
|
||||
isEqual(measurement.data, data) &&
|
||||
isEqual(measurement.transcode, transcode)
|
||||
? measurement.rates
|
||||
: {};
|
||||
|
||||
const handleAutoOrder = useCallback(async () => {
|
||||
setMeasuring(true);
|
||||
|
||||
const measured = await Promise.all(
|
||||
entries.map(async ([, name]): Promise<[string, StreamRate]> => {
|
||||
const setRate = transcodeBitrateFor(camera, name, transcode);
|
||||
|
||||
if (setRate !== undefined) {
|
||||
return [name, { kbps: setRate, set: true }];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`go2rtc/streams/${encodeURIComponent(name)}/bitrate`,
|
||||
);
|
||||
return [name, { kbps: response.data.kbps, set: false }];
|
||||
} catch {
|
||||
return [name, { failed: true }];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const next = Object.fromEntries(measured);
|
||||
const latest = dataRef.current;
|
||||
setMeasurement({ data: latest, transcode, rates: next });
|
||||
setMeasuring(false);
|
||||
|
||||
// stable sort, so ties and unmeasured rows keep their order; rows added
|
||||
// or renamed while measuring go last
|
||||
const sorted = [...entries]
|
||||
.sort(([, a], [, b]) => rateValue(next[b]) - rateValue(next[a]))
|
||||
.map(([key]) => key)
|
||||
.filter((key) => key in latest);
|
||||
const order = [
|
||||
...sorted,
|
||||
...Object.keys(latest).filter((key) => !sorted.includes(key)),
|
||||
];
|
||||
|
||||
if (!isEqual(order, Object.keys(latest))) {
|
||||
onChange(
|
||||
Object.fromEntries(order.map((key) => [key, latest[key]])),
|
||||
fieldPath,
|
||||
);
|
||||
}
|
||||
}, [camera, entries, fieldPath, onChange, transcode]);
|
||||
|
||||
const baseId = idSchema?.$id || "live_streams";
|
||||
const order = dragOrder ?? keys;
|
||||
const deleteLabel = t("button.delete", {
|
||||
ns: "common",
|
||||
defaultValue: "Delete",
|
||||
});
|
||||
const streamNameLabel = t("configForm.liveStreams.streamNameLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const streamNamePlaceholder = t(
|
||||
"configForm.liveStreams.streamNamePlaceholder",
|
||||
{ ns: "views/settings" },
|
||||
);
|
||||
const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const addStreamLabel = t("configForm.liveStreams.addStream", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
@@ -565,34 +278,57 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={order}
|
||||
onReorder={handleReorder}
|
||||
className="space-y-3"
|
||||
{entries.map(([key, value], entryIndex) => {
|
||||
const entryId = `${baseId}-${entryIndex}`;
|
||||
return (
|
||||
<div
|
||||
key={entryIndex}
|
||||
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
|
||||
>
|
||||
{order.map((key, entryIndex) => (
|
||||
<StreamRow
|
||||
key={rowIdFor(key)}
|
||||
entryKey={key}
|
||||
value={data[key] ?? ""}
|
||||
entryId={`${baseId}-${entryIndex}`}
|
||||
data={data}
|
||||
generated={isGenerated(data[key] ?? "")}
|
||||
rate={rates[data[key] ?? ""]}
|
||||
<div className="col-span-12 space-y-2 md:col-span-5">
|
||||
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
|
||||
<MapKeyInput
|
||||
id={`${entryId}-key`}
|
||||
value={key}
|
||||
placeholder={streamNamePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onCommit={(next) => handleRenameKey(key, next)}
|
||||
isKeyTaken={(next) =>
|
||||
next !== key &&
|
||||
Object.prototype.hasOwnProperty.call(data, next)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-10 space-y-2 md:col-span-6">
|
||||
<Label htmlFor={`${entryId}-value`}>{go2rtcStreamLabel}</Label>
|
||||
<StreamValueCombobox
|
||||
id={`${entryId}-value`}
|
||||
value={value}
|
||||
options={go2rtcStreamNames}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
onRename={handleRenameKey}
|
||||
onUpdateValue={handleUpdateValue}
|
||||
onRemove={handleRemoveEntry}
|
||||
onDragEnd={handleDragEnd}
|
||||
onChange={(next) => handleUpdateValue(key, next)}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end md:col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveEntry(key)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -602,25 +338,7 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{tSettings("configForm.liveStreams.addStream")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoOrder}
|
||||
disabled={disabled || readonly || measuring || entries.length < 2}
|
||||
title={tSettings("configForm.liveStreams.autoOrderTips")}
|
||||
className="gap-2"
|
||||
>
|
||||
{measuring ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
<LuArrowDownWideNarrow className="h-4 w-4" />
|
||||
)}
|
||||
{measuring
|
||||
? tSettings("configForm.liveStreams.measuring")
|
||||
: tSettings("configForm.liveStreams.autoOrder")}
|
||||
{addStreamLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import type { FieldPathList, FieldProps } from "@rjsf/utils";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import get from "lodash/get";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type {
|
||||
LiveTranscodeConfig,
|
||||
LiveTranscodeQuality,
|
||||
} from "@/types/frigateConfig";
|
||||
import {
|
||||
TRANSCODE_HEIGHTS,
|
||||
defaultTranscodeBitrate,
|
||||
reconcileTranscodeStreams,
|
||||
} from "@/utils/liveTranscode";
|
||||
|
||||
const EMPTY_TRANSCODE: LiveTranscodeConfig = {
|
||||
enabled: false,
|
||||
source: null,
|
||||
qualities: [],
|
||||
};
|
||||
|
||||
// presets plus a height set in yaml, minus heights other rows use
|
||||
function heightOptions(current: number, used: number[]): number[] {
|
||||
return [...new Set([...TRANSCODE_HEIGHTS, current])]
|
||||
.filter((height) => height === current || !used.includes(height))
|
||||
.sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
export function LiveTranscodeField(props: FieldProps) {
|
||||
const { formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
ConfigFormContext | undefined;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const transcode =
|
||||
(formData as LiveTranscodeConfig | undefined) ?? EMPTY_TRANSCODE;
|
||||
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
emptyPath;
|
||||
|
||||
const camera = formContext?.cameraName ?? "";
|
||||
const streams = useMemo(
|
||||
() => (formContext?.formData?.streams ?? {}) as Record<string, string>,
|
||||
[formContext?.formData?.streams],
|
||||
);
|
||||
const go2rtcStreams = useMemo(
|
||||
() => Object.keys(formContext?.fullConfig?.go2rtc?.streams ?? {}),
|
||||
[formContext?.fullConfig?.go2rtc?.streams],
|
||||
);
|
||||
const sources = useMemo(
|
||||
() =>
|
||||
[...new Set(Object.values(streams))].filter((name) =>
|
||||
go2rtcStreams.includes(name),
|
||||
),
|
||||
[streams, go2rtcStreams],
|
||||
);
|
||||
|
||||
const savedSource = get(formContext?.baselineFormData, [
|
||||
...fieldPath,
|
||||
"source",
|
||||
]) as string | null | undefined;
|
||||
|
||||
const available = sources.length > 0;
|
||||
const locked = disabled || readonly || !available;
|
||||
const baseId = idSchema?.$id || "live_transcode";
|
||||
|
||||
// the stream list follows the transcode config in the same form change
|
||||
const update = useCallback(
|
||||
(next: LiveTranscodeConfig) => {
|
||||
onChange(next, fieldPath);
|
||||
onChange(
|
||||
reconcileTranscodeStreams(camera, streams, next, go2rtcStreams),
|
||||
[...fieldPath.slice(0, -1), "streams"],
|
||||
);
|
||||
},
|
||||
[camera, fieldPath, go2rtcStreams, onChange, streams],
|
||||
);
|
||||
|
||||
const updateQuality = useCallback(
|
||||
(index: number, quality: LiveTranscodeQuality) => {
|
||||
const qualities = [...transcode.qualities];
|
||||
qualities[index] = quality;
|
||||
update({ ...transcode, qualities });
|
||||
},
|
||||
[transcode, update],
|
||||
);
|
||||
|
||||
const addQuality = useCallback(() => {
|
||||
const used = new Set(transcode.qualities.map((q) => q.height));
|
||||
const height = TRANSCODE_HEIGHTS.find((h) => !used.has(h));
|
||||
|
||||
if (height === undefined) return;
|
||||
|
||||
update({
|
||||
...transcode,
|
||||
qualities: [
|
||||
...transcode.qualities,
|
||||
{ height, bitrate: defaultTranscodeBitrate(height) },
|
||||
],
|
||||
});
|
||||
}, [transcode, update]);
|
||||
|
||||
const removeQuality = useCallback(
|
||||
(index: number) =>
|
||||
update({
|
||||
...transcode,
|
||||
qualities: transcode.qualities.filter((_, i) => i !== index),
|
||||
}),
|
||||
[transcode, update],
|
||||
);
|
||||
|
||||
const usedHeights = transcode.qualities.map((q) => q.height);
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-sm">
|
||||
{t("configForm.liveTranscode.title")}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{available
|
||||
? t("configForm.liveTranscode.description")
|
||||
: t("configForm.liveTranscode.unavailable")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id={`${baseId}-enabled`}
|
||||
checked={transcode.enabled}
|
||||
disabled={locked}
|
||||
onCheckedChange={(enabled) =>
|
||||
update({
|
||||
...transcode,
|
||||
enabled,
|
||||
// pin the shown source so reordering streams can't change it;
|
||||
// turning off drops the pin
|
||||
source: enabled
|
||||
? (transcode.source ?? sources[0])
|
||||
: savedSource,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${baseId}-enabled`}>
|
||||
{t("configForm.liveTranscode.enable")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{transcode.enabled && available && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${baseId}-source`}>
|
||||
{t("configForm.liveTranscode.source")}
|
||||
</Label>
|
||||
<Select
|
||||
value={transcode.source ?? sources[0]}
|
||||
disabled={locked}
|
||||
onValueChange={(source) => update({ ...transcode, source })}
|
||||
>
|
||||
<SelectTrigger id={`${baseId}-source`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.map((source) => (
|
||||
<SelectItem key={source} value={source}>
|
||||
{source}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{transcode.qualities.map((quality, index) => (
|
||||
<div
|
||||
key={quality.height}
|
||||
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="col-span-5 space-y-2">
|
||||
<Label htmlFor={`${baseId}-${index}-height`}>
|
||||
{t("configForm.liveTranscode.height")}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(quality.height)}
|
||||
disabled={locked}
|
||||
onValueChange={(value) =>
|
||||
updateQuality(index, {
|
||||
height: Number(value),
|
||||
bitrate: defaultTranscodeBitrate(Number(value)),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${baseId}-${index}-height`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{heightOptions(quality.height, usedHeights).map(
|
||||
(height) => (
|
||||
<SelectItem key={height} value={String(height)}>
|
||||
{t("configForm.liveTranscode.heightOption", {
|
||||
height,
|
||||
})}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-5 space-y-2">
|
||||
<Label htmlFor={`${baseId}-${index}-bitrate`}>
|
||||
{t("configForm.liveTranscode.bitrate")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${baseId}-${index}-bitrate`}
|
||||
type="number"
|
||||
min={64}
|
||||
value={quality.bitrate}
|
||||
disabled={locked}
|
||||
onChange={(e) => {
|
||||
// an empty input would save 0, which the backend rejects
|
||||
if (e.target.value === "") return;
|
||||
|
||||
updateQuality(index, {
|
||||
...quality,
|
||||
bitrate: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={locked}
|
||||
onClick={() => removeQuality(index)}
|
||||
aria-label={t("configForm.liveTranscode.removeQuality")}
|
||||
title={t("configForm.liveTranscode.removeQuality")}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={
|
||||
locked || usedHeights.length >= TRANSCODE_HEIGHTS.length
|
||||
}
|
||||
onClick={addQuality}
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("configForm.liveTranscode.addQuality")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveTranscodeField;
|
||||
@@ -38,9 +38,7 @@ function plusModelId(path: unknown): string | undefined {
|
||||
|
||||
type ModelSourcePickerProps = {
|
||||
path: unknown;
|
||||
// Frigate+ metadata the backend attaches to a saved model, and the only
|
||||
// reliable signal that one is active: it resolves `plus://<id>` to a local
|
||||
// cache path before serving the config back
|
||||
// Frigate+ metadata the backend attaches to a saved model
|
||||
plus?: { id: string } | null;
|
||||
// the detector this model runs on, used to filter incompatible Plus models
|
||||
detector?: string;
|
||||
|
||||
@@ -60,6 +60,15 @@ const CUSTOM_MODEL_FIELDS = [
|
||||
"model_type",
|
||||
];
|
||||
|
||||
/**
|
||||
* The fields a model can edit. A Frigate+ model's size, format, type, and
|
||||
* labels come from its model info, so only its path stays editable.
|
||||
*/
|
||||
const editableFields = (model: DetectionModel): string[] =>
|
||||
typeof model.path === "string" && model.path.startsWith("plus://")
|
||||
? ["path"]
|
||||
: CUSTOM_MODEL_FIELDS;
|
||||
|
||||
/** The detector a model runs on, which is the prefix of its device strings. */
|
||||
const detectorForModel = (model: DetectionModel): string | undefined =>
|
||||
model.devices?.[0]?.split(":")[0];
|
||||
@@ -169,20 +178,23 @@ export function ModelsField(props: FieldProps) {
|
||||
[savedModels],
|
||||
);
|
||||
|
||||
// a model serves the cameras naming its scene, plus every camera that names
|
||||
// no scene at all when it is the "all" model
|
||||
// a model serves the cameras naming its scene, and like the backend, the
|
||||
// "all" model also serves every camera whose scene has no model of its own
|
||||
const cameraCountForScene = useCallback(
|
||||
(scene: string | undefined): number => {
|
||||
if (!cameras) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const modelScenes = new Set(models.map((model) => model.scene ?? "all"));
|
||||
|
||||
return Object.values(cameras).filter((camera) => {
|
||||
const cameraScene = camera?.detect?.scene;
|
||||
return cameraScene ? cameraScene === scene : scene === "all";
|
||||
const cameraScene = camera?.detect?.scene ?? "all";
|
||||
const servedBy = modelScenes.has(cameraScene) ? cameraScene : "all";
|
||||
return servedBy === (scene ?? "all");
|
||||
}).length;
|
||||
},
|
||||
[cameras],
|
||||
[cameras, models],
|
||||
);
|
||||
|
||||
const claimedByOtherModels = useCallback(
|
||||
@@ -314,7 +326,9 @@ export function ModelsField(props: FieldProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={`${baseId}-${index}`} className="w-full">
|
||||
// keyed by scene, which is unique per model, so deleting a card
|
||||
// doesn't hand its state (such as the model source tab) to the next
|
||||
<Card key={`${baseId}-${model.scene ?? index}`} className="w-full">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
@@ -428,7 +442,7 @@ export function ModelsField(props: FieldProps) {
|
||||
detector={detectorForModel(model)}
|
||||
disabled={disabled || readonly}
|
||||
onPathChange={(path) => updateModel(index, { path })}
|
||||
customFields={CUSTOM_MODEL_FIELDS.map((fieldName) =>
|
||||
customFields={editableFields(model).map((fieldName) =>
|
||||
renderField(index, fieldName),
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -3,4 +3,3 @@ export { LayoutGridField } from "./LayoutGridField";
|
||||
export { ModelsField } from "./ModelsField";
|
||||
export { ReplaceRulesField } from "./ReplaceRulesField";
|
||||
export { LiveStreamsField } from "./LiveStreamsField";
|
||||
export { LiveTranscodeField } from "./LiveTranscodeField";
|
||||
|
||||
@@ -57,7 +57,6 @@ import { CameraInputsField } from "./fields/CameraInputsField";
|
||||
import { DictAsYamlField } from "./fields/DictAsYamlField";
|
||||
import { KnownPlatesField } from "./fields/KnownPlatesField";
|
||||
import { LiveStreamsField } from "./fields/LiveStreamsField";
|
||||
import { LiveTranscodeField } from "./fields/LiveTranscodeField";
|
||||
|
||||
export interface FrigateTheme {
|
||||
widgets: RegistryWidgetsType;
|
||||
@@ -122,6 +121,5 @@ export const frigateTheme: FrigateTheme = {
|
||||
DictAsYamlField: DictAsYamlField,
|
||||
KnownPlatesField: KnownPlatesField,
|
||||
LiveStreamsField: LiveStreamsField,
|
||||
LiveTranscodeField: LiveTranscodeField,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent",
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/20 to-transparent",
|
||||
upperClassName,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full bg-gradient-to-t from-black/20 to-transparent",
|
||||
lowerClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { LuFileJson, LuInfo, LuTriangleAlert } from "react-icons/lu";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
importedLayoutsNaturalAspect,
|
||||
ImportSummary,
|
||||
TransferSection,
|
||||
UiSettingsFile,
|
||||
@@ -25,6 +27,7 @@ type ImportUiSettingsDialogProps = {
|
||||
fileName: string;
|
||||
file: UiSettingsFile;
|
||||
summary: ImportSummary;
|
||||
currentNaturalAspect: boolean;
|
||||
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -34,6 +37,7 @@ export default function ImportUiSettingsDialog({
|
||||
fileName,
|
||||
file,
|
||||
summary,
|
||||
currentNaturalAspect,
|
||||
onConfirm,
|
||||
}: ImportUiSettingsDialogProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
@@ -41,7 +45,9 @@ export default function ImportUiSettingsDialog({
|
||||
|
||||
const available = useMemo(
|
||||
() => ({
|
||||
layouts: summary.layoutGroupCount > 0,
|
||||
// phones use the static grid, so a saved grid layout has nothing to
|
||||
// apply to and would only flip the tile sizing mode behind the scenes
|
||||
layouts: !isMobileOnly && summary.layoutGroupCount > 0,
|
||||
streaming: summary.streamingCameraCount > 0,
|
||||
preferences: summary.preferenceCount > 0,
|
||||
}),
|
||||
@@ -90,6 +96,16 @@ export default function ImportUiSettingsDialog({
|
||||
[sections.streaming, summary.unknownCameras],
|
||||
);
|
||||
|
||||
// importing layouts also applies the tile-sizing mode they were built for
|
||||
const layoutsModeChange = useMemo(() => {
|
||||
if (!sections.layouts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = importedLayoutsNaturalAspect(file);
|
||||
return mode === null || mode === currentNaturalAspect ? null : mode;
|
||||
}, [sections.layouts, file, currentNaturalAspect]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setIsImporting(true);
|
||||
await onConfirm(sections);
|
||||
@@ -113,17 +129,23 @@ export default function ImportUiSettingsDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="break-all text-base text-primary-variant">{fileName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
|
||||
<LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="break-all text-base font-medium text-primary-variant">
|
||||
{fileName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general.backupRestore.importDialog.exportedFrom", {
|
||||
date: exportedDate,
|
||||
version: file.frigate_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<FilterSwitch
|
||||
label={t("general.backupRestore.importDialog.layouts", {
|
||||
count: summary.layoutGroupCount,
|
||||
@@ -156,6 +178,28 @@ export default function ImportUiSettingsDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMobileOnly && summary.layoutGroupCount > 0 && (
|
||||
<Alert variant="info">
|
||||
<LuInfo className="size-5" />
|
||||
<AlertDescription>
|
||||
{t("general.backupRestore.importDialog.layoutsPhone")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{layoutsModeChange !== null && (
|
||||
<Alert variant="info">
|
||||
<LuInfo className="size-5" />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
layoutsModeChange
|
||||
? "general.backupRestore.importDialog.layoutsModeOn"
|
||||
: "general.backupRestore.importDialog.layoutsModeOff",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(visibleUnknownGroups.length > 0 ||
|
||||
visibleUnknownCameras.length > 0) && (
|
||||
<Alert variant="warning">
|
||||
@@ -180,6 +224,7 @@ export default function ImportUiSettingsDialog({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -198,7 +243,7 @@ export default function ImportUiSettingsDialog({
|
||||
>
|
||||
{isImporting ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("general.backupRestore.importDialog.confirm")}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -29,22 +29,10 @@ export default function BirdseyeLivePlayer({
|
||||
}: LivePlayerProps) {
|
||||
let player;
|
||||
if (liveMode == "webrtc") {
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
player = <WebRtcPlayer className="size-full" camera="birdseye" pip={pip} />;
|
||||
} else if (liveMode == "mse") {
|
||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||
player = (
|
||||
<MSEPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
player = <MSEPlayer className="size-full" camera="birdseye" pip={pip} />;
|
||||
} else {
|
||||
player = (
|
||||
<div className="w-5xl text-center text-sm">
|
||||
@@ -55,7 +43,7 @@ export default function BirdseyeLivePlayer({
|
||||
} else if (liveMode == "jsmpeg") {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
className="flex size-full justify-center overflow-hidden"
|
||||
camera="birdseye"
|
||||
width={birdseyeConfig.width}
|
||||
height={birdseyeConfig.height}
|
||||
@@ -65,22 +53,31 @@ export default function BirdseyeLivePlayer({
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
player = <ActivityIndicator className="w-full [.bg-black_&]:text-white" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer justify-center",
|
||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
||||
style={
|
||||
{
|
||||
"--pic-ar":
|
||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
||||
<ImageShadowOverlay />
|
||||
</div>
|
||||
</div>
|
||||
<div className="size-full" ref={playerRef}>
|
||||
{player}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
// Android native hls does not seek correctly
|
||||
const USE_NATIVE_HLS = false;
|
||||
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
|
||||
const DEFAULT_MAX_BUFFER_LENGTH_S = 10;
|
||||
const unsupportedErrorCodes: number[] = [
|
||||
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||
MediaError.MEDIA_ERR_DECODE,
|
||||
@@ -148,6 +149,42 @@ export default function HlsVideoPlayer({
|
||||
// a ref rather than an effect-scoped counter so the element error
|
||||
// handler can hold its toast while a recovery is still possible
|
||||
const mediaRecoveryBudgetRef = useRef(0);
|
||||
// hls.js and the element can both report one failure, so each source
|
||||
// toasts at most once
|
||||
const failureReportedRef = useRef(false);
|
||||
|
||||
const reportPlaybackFailure = useCallback(
|
||||
(code: number, message: string) => {
|
||||
if (failureReportedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
failureReportedRef.current = true;
|
||||
toast.error(t("toast.error.playRecordingsFailed", { code, message }), {
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
const reportPlaybackFailureRef = useRef(reportPlaybackFailure);
|
||||
|
||||
useEffect(() => {
|
||||
reportPlaybackFailureRef.current = reportPlaybackFailure;
|
||||
}, [reportPlaybackFailure]);
|
||||
|
||||
// a quality switch changes the buffer length a commit before the new
|
||||
// source arrives, so it updates the live instance instead of rebuilding
|
||||
// it on the outgoing playlist
|
||||
const bufferLengthRef = useRef(bufferLength);
|
||||
|
||||
useEffect(() => {
|
||||
bufferLengthRef.current = bufferLength;
|
||||
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.config.maxBufferLength =
|
||||
bufferLength ?? DEFAULT_MAX_BUFFER_LENGTH_S;
|
||||
}
|
||||
}, [bufferLength]);
|
||||
|
||||
const applyVideoDimensions = useCallback(
|
||||
(width: number, height: number) => {
|
||||
@@ -225,6 +262,7 @@ export default function HlsVideoPlayer({
|
||||
// the element already holds a decoded frame, and keeping it visible
|
||||
// bridges the gap while the new source loads
|
||||
const currentPlaybackRate = videoRef.current.playbackRate;
|
||||
failureReportedRef.current = false;
|
||||
|
||||
if (!useHlsCompat) {
|
||||
nativeRetryRef.current = 0;
|
||||
@@ -236,7 +274,7 @@ export default function HlsVideoPlayer({
|
||||
|
||||
// Base HLS configuration
|
||||
const hlsConfig: Partial<HlsConfig> = {
|
||||
maxBufferLength: bufferLength ?? 10,
|
||||
maxBufferLength: bufferLengthRef.current ?? DEFAULT_MAX_BUFFER_LENGTH_S,
|
||||
maxBufferSize: 20 * 1000 * 1000,
|
||||
startPosition: currentSource.startPosition,
|
||||
};
|
||||
@@ -269,10 +307,18 @@ export default function HlsVideoPlayer({
|
||||
data.details ===
|
||||
Hls.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR ||
|
||||
data.details === Hls.ErrorDetails.BUFFER_ADD_CODEC_ERROR;
|
||||
if (isCodecError && qualitySignalsRef.current.onFatalCodecError?.()) {
|
||||
if (isCodecError) {
|
||||
// with no stream to fall back to, the element may never raise
|
||||
// an error of its own, so report the failure here
|
||||
if (!qualitySignalsRef.current.onFatalCodecError?.()) {
|
||||
reportPlaybackFailureRef.current(
|
||||
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||
data.details,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isCodecError && mediaRecoveryBudgetRef.current > 0) {
|
||||
if (mediaRecoveryBudgetRef.current > 0) {
|
||||
mediaRecoveryBudgetRef.current -= 1;
|
||||
hls.recoverMediaError();
|
||||
}
|
||||
@@ -308,7 +354,7 @@ export default function HlsVideoPlayer({
|
||||
hlsRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [videoRef, hlsRef, useHlsCompat, currentSource, bufferLength]);
|
||||
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
|
||||
|
||||
// state handling
|
||||
|
||||
@@ -714,15 +760,7 @@ export default function HlsVideoPlayer({
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(
|
||||
t("toast.error.playRecordingsFailed", {
|
||||
code: mediaError.code,
|
||||
message: mediaError.message,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
reportPlaybackFailure(mediaError.code, mediaError.message);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { MdCircle } from "react-icons/md";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import {
|
||||
LiveHealthSample,
|
||||
LivePlayerError,
|
||||
TwoWayTalkError,
|
||||
LivePlayerMode,
|
||||
@@ -53,10 +52,9 @@ type LivePlayerProps = {
|
||||
onClick?: () => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onHealthSample?: (sample: LiveHealthSample) => void;
|
||||
streamAuto?: boolean;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
onResetLiveMode?: () => void;
|
||||
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
|
||||
};
|
||||
|
||||
export default function LivePlayer({
|
||||
@@ -81,10 +79,9 @@ export default function LivePlayer({
|
||||
onClick,
|
||||
setFullResolution,
|
||||
onError,
|
||||
onHealthSample,
|
||||
streamAuto = false,
|
||||
onMicrophoneError,
|
||||
onResetLiveMode,
|
||||
onLiveAspectChange,
|
||||
}: LivePlayerProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
|
||||
@@ -112,14 +109,6 @@ export default function LivePlayer({
|
||||
droppedFrameRate: 0, // percentage
|
||||
});
|
||||
|
||||
const streamLabel = useMemo(
|
||||
() =>
|
||||
Object.keys(cameraConfig.live.streams).find(
|
||||
(label) => cameraConfig.live.streams[label] === streamName,
|
||||
),
|
||||
[cameraConfig.live.streams, streamName],
|
||||
);
|
||||
|
||||
// camera activity
|
||||
|
||||
const {
|
||||
@@ -140,6 +129,37 @@ export default function LivePlayer({
|
||||
// camera live state
|
||||
|
||||
const [liveReady, setLiveReady] = useState(false);
|
||||
const [liveAspect, setLiveAspect] = useState<number | undefined>();
|
||||
|
||||
const handleFullResolution = useCallback(
|
||||
(value: React.SetStateAction<VideoResolutionType>) => {
|
||||
setFullResolution?.(value);
|
||||
|
||||
if (typeof value === "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLiveAspect(
|
||||
value.width && value.height ? value.width / value.height : undefined,
|
||||
);
|
||||
},
|
||||
[setFullResolution],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLiveAspectChange?.(liveReady ? liveAspect : undefined);
|
||||
}, [liveReady, liveAspect, onLiveAspectChange]);
|
||||
|
||||
// The card can be a different shape than the picture (a bucketed tile, or a
|
||||
// still whose detect aspect differs from the stream), so overlays that are
|
||||
// meant to sit on the image have to be fitted to it rather than to the card.
|
||||
const pictureAspect = useMemo(() => {
|
||||
if (liveReady && liveAspect) {
|
||||
return liveAspect;
|
||||
}
|
||||
const { width, height } = cameraConfig.detect;
|
||||
return width && height ? width / height : 16 / 9;
|
||||
}, [liveReady, liveAspect, cameraConfig.detect]);
|
||||
|
||||
const liveReadyRef = useRef(liveReady);
|
||||
const cameraActiveRef = useRef(cameraActive);
|
||||
@@ -210,17 +230,21 @@ export default function LivePlayer({
|
||||
}, [preferredLiveMode]);
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
const prevStreamNameRef = useRef(streamName);
|
||||
|
||||
// the stream is part of the MSE and WebRTC keys, so a stream change
|
||||
// remounts them in the same render and the new player is hidden until it
|
||||
// plays. jsmpeg plays the camera, not the stream, and keeps playing
|
||||
const [renderedStream, setRenderedStream] = useState(streamName);
|
||||
if (renderedStream !== streamName) {
|
||||
setRenderedStream(streamName);
|
||||
if (preferredLiveMode !== "jsmpeg") {
|
||||
const resetPlayer = () => {
|
||||
setLiveReady(false);
|
||||
setKey((prevKey) => prevKey + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (prevStreamNameRef.current !== streamName) {
|
||||
prevStreamNameRef.current = streamName;
|
||||
if (streamName) {
|
||||
resetPlayer();
|
||||
}
|
||||
}
|
||||
}, [streamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showStillWithoutActivity && !autoLive) {
|
||||
@@ -270,12 +294,13 @@ export default function LivePlayer({
|
||||
} else if (preferredLiveMode == "webrtc") {
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
key={`webrtc_${streamName}_${key}`}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
key={"webrtc_" + key}
|
||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
getStats={showStats}
|
||||
setStats={setStats}
|
||||
setFullResolution={handleFullResolution}
|
||||
audioEnabled={playAudio}
|
||||
volume={volume}
|
||||
microphoneEnabled={micEnabled}
|
||||
@@ -283,7 +308,6 @@ export default function LivePlayer({
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
onError={onError}
|
||||
onHealthSample={onHealthSample}
|
||||
onMicrophoneError={onMicrophoneError}
|
||||
/>
|
||||
);
|
||||
@@ -291,8 +315,8 @@ export default function LivePlayer({
|
||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||
player = (
|
||||
<MSEPlayer
|
||||
key={`mse_${streamName}_${key}`}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
key={"mse_" + key}
|
||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
audioEnabled={playAudio}
|
||||
@@ -302,9 +326,8 @@ export default function LivePlayer({
|
||||
setStats={setStats}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
setFullResolution={handleFullResolution}
|
||||
onError={onError}
|
||||
onHealthSample={onHealthSample}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -319,7 +342,7 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
key={"jsmpeg_" + key}
|
||||
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
className="flex justify-center overflow-hidden"
|
||||
camera={cameraConfig.name}
|
||||
width={cameraConfig.detect.width}
|
||||
height={cameraConfig.detect.height}
|
||||
@@ -336,7 +359,9 @@ export default function LivePlayer({
|
||||
player = null;
|
||||
}
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
player = (
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -351,10 +376,12 @@ export default function LivePlayer({
|
||||
}}
|
||||
data-camera={cameraConfig.name}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer justify-center outline",
|
||||
// the card owns the corner: overflow-hidden clips the stream, the still
|
||||
// image, and every overlay to this one radius so they stay concentric
|
||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg outline md:rounded-2xl",
|
||||
activeTracking &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady)
|
||||
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
|
||||
? "shadow-severity_alert outline-[3px] outline-severity_alert"
|
||||
: "outline-0 outline-background",
|
||||
"transition-all duration-500",
|
||||
className,
|
||||
@@ -368,10 +395,14 @@ export default function LivePlayer({
|
||||
>
|
||||
{cameraEnabled &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
||||
style={{ "--pic-ar": pictureAspect } as React.CSSProperties}
|
||||
>
|
||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
||||
<ImageShadowOverlay />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{player}
|
||||
{cameraEnabled &&
|
||||
@@ -379,7 +410,7 @@ export default function LivePlayer({
|
||||
(!showStillWithoutActivity || isReEnabling) &&
|
||||
!liveReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -458,7 +489,7 @@ export default function LivePlayer({
|
||||
|
||||
{offline && inDashboard && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||
<div>{t("streamOffline.title")}</div>
|
||||
@@ -502,7 +533,7 @@ export default function LivePlayer({
|
||||
)}
|
||||
|
||||
{!cameraEnabled && (
|
||||
<div className="relative flex h-full w-full items-center justify-center rounded-2xl border border-secondary-foreground bg-background_alt">
|
||||
<div className="relative flex h-full w-full items-center justify-center border border-secondary-foreground bg-background_alt">
|
||||
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
|
||||
<LuVideoOff className="mb-2 size-8 md:size-10" />
|
||||
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
|
||||
@@ -530,12 +561,7 @@ export default function LivePlayer({
|
||||
)}
|
||||
</div>
|
||||
{showStats && (
|
||||
<PlayerStats
|
||||
stats={stats}
|
||||
minimal={cameraRef !== undefined}
|
||||
streamLabel={preferredLiveMode === "jsmpeg" ? undefined : streamLabel}
|
||||
streamAuto={streamAuto}
|
||||
/>
|
||||
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
/**
|
||||
* Policy engine for auto live stream selection.
|
||||
*
|
||||
* Rungs index the camera's live streams in config order, followed by
|
||||
* the jsmpeg floor. Evidence is media delivery falling behind wall
|
||||
* time while bytes still arrive, which separates congestion from a
|
||||
* dead camera or a go2rtc restart. No browser API reports spare
|
||||
* bandwidth, so upswitching is try-and-see with backoff.
|
||||
*
|
||||
* Time comes from an injected clock and the owner calls tick() once a
|
||||
* second, so the class holds no timers.
|
||||
*/
|
||||
|
||||
import type {
|
||||
LiveAutoReason,
|
||||
LiveHealthSample,
|
||||
LivePlayerError,
|
||||
} from "@/types/live";
|
||||
|
||||
// go2rtc bursts its GOP cache when a consumer attaches
|
||||
const WARMUP_MS = 3000;
|
||||
// media per wall second below this is a low sample. Low samples reset
|
||||
// the upswitch clock and feed the chronic window
|
||||
const LOW_RATE = 0.85;
|
||||
// net shortfall within the window that downswitches on sustained
|
||||
// under-delivery. Keyframes make per-second delivery bursty, so the
|
||||
// window lets a fast second offset a slow one
|
||||
const SUSTAINED_DEFICIT_S = 2;
|
||||
const SUSTAINED_WINDOW_MS = 10000;
|
||||
// low-sample deficit within the window that downswitches on repeated
|
||||
// short stalls
|
||||
const CHRONIC_DEFICIT_S = 6;
|
||||
const CHRONIC_WINDOW_MS = 60000;
|
||||
// clean time before trying one rung up, doubled after each failed try
|
||||
const UPSWITCH_WAIT_MS = 60000;
|
||||
const UPSWITCH_WAIT_MAX_MS = 600000;
|
||||
// a downswitch this soon after an upswitch counts as a failed try
|
||||
const PROBATION_MS = 30000;
|
||||
|
||||
export type LiveGovernorSnapshot = {
|
||||
rung: number;
|
||||
reason: LiveAutoReason | undefined;
|
||||
};
|
||||
|
||||
type Shortfall = {
|
||||
ts: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
const total = (entries: Shortfall[]) =>
|
||||
entries.reduce((sum, entry) => sum + entry.seconds, 0);
|
||||
|
||||
export class LiveStreamGovernor {
|
||||
private readonly floor: number;
|
||||
private readonly now: () => number;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly unplayable = new Set<number>();
|
||||
|
||||
private snapshot: LiveGovernorSnapshot = { rung: 0, reason: undefined };
|
||||
private holdLow = false;
|
||||
|
||||
private rungStartTs: number;
|
||||
private cleanSinceTs: number;
|
||||
// every sample, signed, for the sustained window
|
||||
private shortfalls: Shortfall[] = [];
|
||||
// low samples only, for the chronic window
|
||||
private deficits: Shortfall[] = [];
|
||||
private upswitchWaitMs = UPSWITCH_WAIT_MS;
|
||||
private probationUntil = 0;
|
||||
|
||||
/** rungCount includes the jsmpeg floor. */
|
||||
constructor(rungCount: number, now: () => number = Date.now) {
|
||||
this.floor = rungCount - 1;
|
||||
this.now = now;
|
||||
this.rungStartTs = now();
|
||||
this.cleanSinceTs = this.rungStartTs;
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
getSnapshot = (): LiveGovernorSnapshot => this.snapshot;
|
||||
|
||||
/**
|
||||
* One health sample from the playing transport. Zero-byte samples
|
||||
* belong to the stall watchdog, and warmup samples only prove that
|
||||
* bytes are arriving.
|
||||
*/
|
||||
sample({ bytes, mediaSeconds, wallSeconds }: LiveHealthSample) {
|
||||
if (bytes <= 0 || wallSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
|
||||
if (now - this.rungStartTs < WARMUP_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// WebRTC reports a finished freeze in one sample, so media can be
|
||||
// negative and one sample can carry more shortfall than its wall time
|
||||
const shortfall = { ts: now, seconds: wallSeconds - mediaSeconds };
|
||||
this.shortfalls.push(shortfall);
|
||||
if (mediaSeconds / wallSeconds < LOW_RATE) {
|
||||
this.cleanSinceTs = now;
|
||||
this.deficits.push(shortfall);
|
||||
}
|
||||
|
||||
this.shortfalls = this.shortfalls.filter(
|
||||
(entry) => entry.ts > now - SUSTAINED_WINDOW_MS,
|
||||
);
|
||||
this.deficits = this.deficits.filter(
|
||||
(entry) => entry.ts > now - CHRONIC_WINDOW_MS,
|
||||
);
|
||||
|
||||
if (
|
||||
total(this.shortfalls) >= SUSTAINED_DEFICIT_S ||
|
||||
total(this.deficits) >= CHRONIC_DEFICIT_S
|
||||
) {
|
||||
this.stepDown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A player error. Returns true when auto handled it by stepping down,
|
||||
* false to hand it to the error fallback.
|
||||
*/
|
||||
playerError(error: LivePlayerError, sourceOnline: boolean): boolean {
|
||||
const { rung } = this.snapshot;
|
||||
if (rung === this.floor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// go2rtc's codec reply is proof, so the rung is not retried this session
|
||||
if (error === "mse-codec") {
|
||||
this.unplayable.add(rung);
|
||||
return this.stepDown();
|
||||
}
|
||||
|
||||
// the rung below played a moment ago, so a failed upswitch returns to it
|
||||
if (this.now() < this.probationUntil) {
|
||||
return this.stepDown();
|
||||
}
|
||||
|
||||
// an offline camera fails every stream alike; a stall on a live camera
|
||||
// is the viewer's link, even when congestion stops delivery outright
|
||||
if (!sourceOnline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.stepDown();
|
||||
}
|
||||
|
||||
/** Called once a second while auto is active and the page is visible. */
|
||||
tick() {
|
||||
const now = this.now();
|
||||
|
||||
// the higher rung survived probation, so backoff starts over
|
||||
if (this.probationUntil > 0 && now >= this.probationUntil) {
|
||||
this.probationUntil = 0;
|
||||
this.upswitchWaitMs = UPSWITCH_WAIT_MS;
|
||||
}
|
||||
|
||||
if (now - this.cleanSinceTs < this.upswitchWaitMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// data saver may climb out of the floor, but never past its stream
|
||||
const target = this.nextPlayableAbove(this.snapshot.rung);
|
||||
if (
|
||||
target === undefined ||
|
||||
(this.holdLow && target < this.lowestPlayable())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.probationUntil = now + PROBATION_MS;
|
||||
this.moveTo(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data saver holds the lowest playable stream and never upswitches past
|
||||
* it, whatever the connection could carry.
|
||||
*/
|
||||
setHoldLow(hold: boolean) {
|
||||
this.holdLow = hold;
|
||||
this.moveTo(
|
||||
hold
|
||||
? Math.max(this.snapshot.rung, this.lowestPlayable())
|
||||
: this.snapshot.rung,
|
||||
);
|
||||
}
|
||||
|
||||
/** A remount or unpause on the same rung starts with clean evidence. */
|
||||
resetHistory() {
|
||||
const now = this.now();
|
||||
this.rungStartTs = now;
|
||||
this.cleanSinceTs = now;
|
||||
this.shortfalls = [];
|
||||
this.deficits = [];
|
||||
}
|
||||
|
||||
/** Returns to the top rung and clears every session fact. */
|
||||
reset() {
|
||||
this.unplayable.clear();
|
||||
this.upswitchWaitMs = UPSWITCH_WAIT_MS;
|
||||
this.probationUntil = 0;
|
||||
this.moveTo(this.holdLow ? this.lowestPlayable() : 0);
|
||||
}
|
||||
|
||||
private stepDown(): boolean {
|
||||
const target = this.nextPlayableBelow(this.snapshot.rung);
|
||||
if (target === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.now() < this.probationUntil) {
|
||||
this.upswitchWaitMs = Math.min(
|
||||
this.upswitchWaitMs * 2,
|
||||
UPSWITCH_WAIT_MAX_MS,
|
||||
);
|
||||
}
|
||||
this.probationUntil = 0;
|
||||
|
||||
this.moveTo(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
private moveTo(rung: number) {
|
||||
this.resetHistory();
|
||||
|
||||
const reason = this.reasonAt(rung);
|
||||
if (rung === this.snapshot.rung && reason === this.snapshot.reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.snapshot = { rung, reason };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
private reasonAt(rung: number): LiveAutoReason | undefined {
|
||||
if (rung === this.floor) {
|
||||
return "floor";
|
||||
}
|
||||
|
||||
if (this.nextPlayableAbove(rung) === undefined) {
|
||||
return [...this.unplayable].some((unplayable) => unplayable < rung)
|
||||
? "codec"
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return this.holdLow ? "saveData" : "bandwidth";
|
||||
}
|
||||
|
||||
private nextPlayableAbove(rung: number): number | undefined {
|
||||
for (let candidate = rung - 1; candidate >= 0; candidate--) {
|
||||
if (!this.unplayable.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// the floor is always playable
|
||||
private nextPlayableBelow(rung: number): number | undefined {
|
||||
for (let candidate = rung + 1; candidate <= this.floor; candidate++) {
|
||||
if (candidate === this.floor || !this.unplayable.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private lowestPlayable(): number {
|
||||
for (let candidate = this.floor - 1; candidate >= 0; candidate--) {
|
||||
if (!this.unplayable.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return this.floor;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LiveAutoReason } from "@/types/live";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { MdOutlineRestartAlt } from "react-icons/md";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Radix Select needs a non-empty value. Auto itself is stored as the
|
||||
// absence of a pinned stream
|
||||
const AUTO_VALUE = "__frigate_auto__";
|
||||
|
||||
type LiveStreamSelectProps = {
|
||||
// friendly name to go2rtc stream name, in ladder order
|
||||
streams: Record<string, string>;
|
||||
pinnedStream: string | undefined;
|
||||
playingStream: string;
|
||||
autoAvailable: boolean;
|
||||
autoReason?: LiveAutoReason;
|
||||
// undefined selects auto
|
||||
onSelect: (stream: string | undefined) => void;
|
||||
// sends auto back to the top of the ladder
|
||||
onRetry: () => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export default function LiveStreamSelect({
|
||||
streams,
|
||||
pinnedStream,
|
||||
playingStream,
|
||||
autoAvailable,
|
||||
autoReason,
|
||||
onSelect,
|
||||
onRetry,
|
||||
disabled,
|
||||
}: LiveStreamSelectProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
|
||||
const labelFor = useCallback(
|
||||
(name: string) =>
|
||||
Object.keys(streams).find((label) => streams[label] === name) ?? name,
|
||||
[streams],
|
||||
);
|
||||
|
||||
const auto = autoAvailable && pinnedStream == undefined;
|
||||
const value = auto ? AUTO_VALUE : (pinnedStream ?? playingStream);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) =>
|
||||
onSelect(next === AUTO_VALUE ? undefined : next)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{auto
|
||||
? t("stream.auto.selected", {
|
||||
stream:
|
||||
autoReason === "floor"
|
||||
? t("stream.auto.floorLabel")
|
||||
: labelFor(playingStream),
|
||||
})
|
||||
: labelFor(value)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{autoAvailable && (
|
||||
<SelectItem className="cursor-pointer" value={AUTO_VALUE}>
|
||||
<span className="flex flex-col gap-0.5 whitespace-normal">
|
||||
<span>{t("stream.auto.label")}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("stream.auto.tips")}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{Object.entries(streams).map(([label, name]) => (
|
||||
<SelectItem key={label} className="cursor-pointer" value={name}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{auto && autoReason && !disabled && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuInfo className="size-4 shrink-0" />
|
||||
<div>
|
||||
{t(`stream.auto.reason.${autoReason}`, {
|
||||
stream: labelFor(playingStream),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* data saver pins the lowest stream, so there is no higher one to try */}
|
||||
{autoReason !== "saveData" && (
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("stream.auto.retry")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.auto.retry")}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
LiveHealthSample,
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
VideoResolutionType,
|
||||
@@ -29,7 +28,6 @@ type MSEPlayerProps = {
|
||||
onPlaying?: () => void;
|
||||
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onHealthSample?: (sample: LiveHealthSample) => void;
|
||||
};
|
||||
|
||||
function MSEPlayer({
|
||||
@@ -45,7 +43,6 @@ function MSEPlayer({
|
||||
onPlaying,
|
||||
setFullResolution,
|
||||
onError,
|
||||
onHealthSample,
|
||||
}: MSEPlayerProps) {
|
||||
const RECONNECT_TIMEOUT: number = 10000;
|
||||
const BUFFERING_COOLDOWN_TIMEOUT: number = 5000;
|
||||
@@ -74,7 +71,6 @@ function MSEPlayer({
|
||||
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
|
||||
const [errorCount, setErrorCount] = useState<number>(0);
|
||||
const totalBytesLoaded = useRef(0);
|
||||
const appendingRef = useRef(false);
|
||||
|
||||
const [fallbackTimeout] = useUserPersistence<number>(
|
||||
"liveFallbackTimeout",
|
||||
@@ -99,13 +95,6 @@ function MSEPlayer({
|
||||
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
|
||||
}, [camera]);
|
||||
|
||||
// socket handlers are bound once per connection, so they reach the
|
||||
// current onError through a ref
|
||||
const onErrorRef = useRef(onError);
|
||||
useEffect(() => {
|
||||
onErrorRef.current = onError;
|
||||
}, [onError]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: LivePlayerError, description: string = "Unknown error") => {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -121,11 +110,11 @@ function MSEPlayer({
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`${camera} - Supported codecs: ${CODECS.join(", ")}`);
|
||||
}
|
||||
onErrorRef.current?.(error);
|
||||
onError?.(error);
|
||||
},
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[camera],
|
||||
[camera, onError],
|
||||
);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
@@ -481,12 +470,7 @@ function MSEPlayer({
|
||||
const data = buf.slice(0, bufLen);
|
||||
bufLen = 0;
|
||||
sb.appendBuffer(data);
|
||||
return;
|
||||
}
|
||||
|
||||
appendingRef.current = false;
|
||||
|
||||
if (sb.buffered && sb.buffered.length) {
|
||||
} else if (sb.buffered && sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
|
||||
const start = sb.buffered.start(0);
|
||||
if (end > start) {
|
||||
@@ -504,7 +488,6 @@ function MSEPlayer({
|
||||
|
||||
ondataRef.current = (data) => {
|
||||
totalBytesLoaded.current += data.byteLength;
|
||||
appendingRef.current = true;
|
||||
|
||||
if (sb?.updating || bufLen > 0) {
|
||||
const b = new Uint8Array(data);
|
||||
@@ -520,19 +503,6 @@ function MSEPlayer({
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// go2rtc answers a codec it cannot provide with an error message
|
||||
// instead of a negotiation reply
|
||||
onmessageRef.current["error"] = (msg) => {
|
||||
if (msg.type !== "error" || !msg.value?.includes("codecs not matched")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
onDisconnect();
|
||||
}
|
||||
handleError("mse-codec", msg.value);
|
||||
};
|
||||
};
|
||||
|
||||
const getBufferedTime = (video: HTMLVideoElement | null) => {
|
||||
@@ -770,38 +740,19 @@ function MSEPlayer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playbackEnabled]);
|
||||
|
||||
// stats and health samples
|
||||
|
||||
const onHealthSampleRef = useRef(onHealthSample);
|
||||
useEffect(() => {
|
||||
onHealthSampleRef.current = onHealthSample;
|
||||
}, [onHealthSample]);
|
||||
const sampleHealth = onHealthSample !== undefined;
|
||||
// stats
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
let lastLoadedBytes = totalBytesLoaded.current;
|
||||
let lastTimestamp = Date.now();
|
||||
let healthBytes = lastLoadedBytes;
|
||||
let healthTimestamp = lastTimestamp;
|
||||
// a restart mid-stream must not count the existing buffer as new media
|
||||
let lastBufferedEnd =
|
||||
video && video.buffered.length > 0
|
||||
? video.buffered.end(video.buffered.length - 1)
|
||||
: 0;
|
||||
let deferred = false;
|
||||
|
||||
if (!getStats && !sampleHealth) return;
|
||||
|
||||
const update = () => {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
if (!getStats) return;
|
||||
|
||||
const updateStats = () => {
|
||||
if (video) {
|
||||
const now = Date.now();
|
||||
const bytesLoaded = totalBytesLoaded.current;
|
||||
|
||||
if (getStats) {
|
||||
const timeElapsed = (now - lastTimestamp) / 1000; // seconds
|
||||
const bandwidth = (bytesLoaded - lastLoadedBytes) / timeElapsed / 1000; // kBps
|
||||
|
||||
@@ -823,53 +774,12 @@ function MSEPlayer({
|
||||
droppedFrameRate,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sampleHealth) {
|
||||
return;
|
||||
}
|
||||
|
||||
// a sample taken mid-append would count the bytes without their
|
||||
// media, so it waits one tick for the append to land
|
||||
if (appendingRef.current && !deferred) {
|
||||
deferred = true;
|
||||
return;
|
||||
}
|
||||
deferred = false;
|
||||
|
||||
// the buffered end tracks delivered media, so playback-rate
|
||||
// catch-up does not skew it
|
||||
const bufferedEnd =
|
||||
video.buffered.length > 0
|
||||
? video.buffered.end(video.buffered.length - 1)
|
||||
: 0;
|
||||
const mediaSeconds = bufferedEnd - lastBufferedEnd;
|
||||
const bytes = bytesLoaded - healthBytes;
|
||||
const wallSeconds = (now - healthTimestamp) / 1000;
|
||||
|
||||
lastBufferedEnd = bufferedEnd;
|
||||
healthBytes = bytesLoaded;
|
||||
healthTimestamp = now;
|
||||
|
||||
// a reconnect starts a new MediaSource timeline
|
||||
if (mediaSeconds < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
document.visibilityState !== "visible" ||
|
||||
(video.paused && bufferedEnd > 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onHealthSampleRef.current?.({ bytes, mediaSeconds, wallSeconds });
|
||||
};
|
||||
|
||||
const interval = setInterval(update, 1000);
|
||||
const interval = setInterval(updateStats, 1000); // Update every second
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (getStats) {
|
||||
setStats?.({
|
||||
streamType: "-",
|
||||
bandwidth: 0,
|
||||
@@ -878,9 +788,8 @@ function MSEPlayer({
|
||||
decodedFrames: 0,
|
||||
droppedFrameRate: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [setStats, getStats, sampleHealth]);
|
||||
}, [setStats, getStats]);
|
||||
|
||||
return (
|
||||
<video
|
||||
|
||||
@@ -5,16 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
type PlayerStatsProps = {
|
||||
stats: PlayerStatsType;
|
||||
minimal: boolean;
|
||||
streamLabel?: string;
|
||||
streamAuto?: boolean;
|
||||
};
|
||||
|
||||
export function PlayerStats({
|
||||
stats,
|
||||
minimal,
|
||||
streamLabel,
|
||||
streamAuto = false,
|
||||
}: PlayerStatsProps) {
|
||||
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
|
||||
const { t } = useTranslation(["components/player", "views/live"]);
|
||||
const streamTypeLabel = t(
|
||||
`stream.technology.name.${stats.streamType.toLowerCase()}`,
|
||||
@@ -22,16 +15,6 @@ export function PlayerStats({
|
||||
);
|
||||
const fullStatsContent = (
|
||||
<>
|
||||
{streamLabel != undefined && (
|
||||
<p>
|
||||
<span className="text-white/70">{t("stats.stream")}</span>{" "}
|
||||
<span className="text-white">
|
||||
{streamAuto
|
||||
? t("stats.streamAuto", { stream: streamLabel })
|
||||
: streamLabel}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-white/70">{t("stats.streamType.title")}</span>{" "}
|
||||
<span className="text-white">{streamTypeLabel}</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import {
|
||||
LiveHealthSample,
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
TwoWayTalkError,
|
||||
VideoResolutionType,
|
||||
} from "@/types/live";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
||||
@@ -21,9 +21,9 @@ type WebRtcPlayerProps = {
|
||||
pip?: boolean;
|
||||
getStats?: boolean;
|
||||
setStats?: (stats: PlayerStatsType) => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onPlaying?: () => void;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onHealthSample?: (sample: LiveHealthSample) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
};
|
||||
|
||||
@@ -38,9 +38,9 @@ export default function WebRtcPlayer({
|
||||
pip = false,
|
||||
getStats = false,
|
||||
setStats,
|
||||
setFullResolution,
|
||||
onPlaying,
|
||||
onError,
|
||||
onHealthSample,
|
||||
onMicrophoneError,
|
||||
}: WebRtcPlayerProps) {
|
||||
// metadata
|
||||
@@ -345,23 +345,22 @@ export default function WebRtcPlayer({
|
||||
if (videoLoadTimeoutRef.current) {
|
||||
clearTimeout(videoLoadTimeoutRef.current);
|
||||
}
|
||||
if (videoRef.current) {
|
||||
setFullResolution?.({
|
||||
width: videoRef.current.videoWidth,
|
||||
height: videoRef.current.videoHeight,
|
||||
});
|
||||
}
|
||||
onPlaying?.();
|
||||
};
|
||||
|
||||
// stats and health samples
|
||||
|
||||
const onHealthSampleRef = useRef(onHealthSample);
|
||||
useEffect(() => {
|
||||
onHealthSampleRef.current = onHealthSample;
|
||||
}, [onHealthSample]);
|
||||
const sampleHealth = onHealthSample !== undefined;
|
||||
// stats
|
||||
|
||||
useEffect(() => {
|
||||
if (!pcRef.current || (!getStats && !sampleHealth)) return;
|
||||
if (!pcRef.current || !getStats) return;
|
||||
|
||||
let lastBytesReceived = 0;
|
||||
let lastTimestamp = 0;
|
||||
let lastFreezeSeconds: number | undefined;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (pcRef.current && videoRef.current && !videoRef.current.paused) {
|
||||
@@ -370,7 +369,6 @@ export default function WebRtcPlayer({
|
||||
let timestamp = 0;
|
||||
let framesReceived = 0;
|
||||
let framesDecoded = 0;
|
||||
let freezeSeconds: number | undefined;
|
||||
|
||||
report.forEach((stat) => {
|
||||
if (stat.type === "inbound-rtp" && stat.kind === "video") {
|
||||
@@ -378,13 +376,10 @@ export default function WebRtcPlayer({
|
||||
timestamp = stat.timestamp;
|
||||
framesReceived = stat.framesReceived;
|
||||
framesDecoded = stat.framesDecoded;
|
||||
freezeSeconds = stat.totalFreezesDuration;
|
||||
}
|
||||
});
|
||||
|
||||
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
|
||||
|
||||
if (getStats) {
|
||||
const bitrate =
|
||||
timeDiff > 0
|
||||
? (bytesReceived - lastBytesReceived) / timeDiff / 1000
|
||||
@@ -398,39 +393,14 @@ export default function WebRtcPlayer({
|
||||
decodedFrames: framesDecoded,
|
||||
droppedFrameRate: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
sampleHealth &&
|
||||
lastTimestamp > 0 &&
|
||||
timeDiff > 0 &&
|
||||
document.visibilityState === "visible"
|
||||
) {
|
||||
// freeze time does not depend on the camera's frame rate. A
|
||||
// freeze is credited only when it ends, so media goes negative
|
||||
// to carry its full length. Browsers without the field report
|
||||
// full delivery and rely on the stall watchdog
|
||||
const frozen =
|
||||
freezeSeconds !== undefined && lastFreezeSeconds !== undefined
|
||||
? Math.max(0, freezeSeconds - lastFreezeSeconds)
|
||||
: 0;
|
||||
|
||||
onHealthSampleRef.current?.({
|
||||
bytes: bytesReceived - lastBytesReceived,
|
||||
mediaSeconds: timeDiff - frozen,
|
||||
wallSeconds: timeDiff,
|
||||
});
|
||||
}
|
||||
|
||||
lastBytesReceived = bytesReceived;
|
||||
lastTimestamp = timestamp;
|
||||
lastFreezeSeconds = freezeSeconds;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (getStats) {
|
||||
setStats?.({
|
||||
streamType: "-",
|
||||
bandwidth: 0,
|
||||
@@ -439,11 +409,10 @@ export default function WebRtcPlayer({
|
||||
decodedFrames: 0,
|
||||
droppedFrameRate: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
// we need to listen on the value of the ref
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pcRef, pcRef.current, getStats, sampleHealth]);
|
||||
}, [pcRef, pcRef.current, getStats]);
|
||||
|
||||
return (
|
||||
<video
|
||||
|
||||
@@ -340,8 +340,11 @@ export class AutoQualityGovernor {
|
||||
private triggerDownswitch(reason: DownswitchReason): boolean {
|
||||
const handled = this.requestDownswitch(reason);
|
||||
if (handled) {
|
||||
// the low stream starts with a clean record
|
||||
// the low stream starts with a clean record, and the probe lets a
|
||||
// recovered connection (or a wrong downswitch) return to full
|
||||
// quality mid-chunk rather than at the next boundary
|
||||
this.resetStallHistory();
|
||||
this.armUpswitchProbe();
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
|
||||
@@ -484,9 +484,6 @@ export default function DynamicVideoPlayer({
|
||||
}
|
||||
setAutoLowQuality(true);
|
||||
setAutoLowReason(reason === "codec" ? "codec" : "bandwidth");
|
||||
// so a recovered connection (or a wrong downswitch) returns to
|
||||
// full quality mid-chunk rather than at the next boundary
|
||||
governor.armUpswitchProbe();
|
||||
return true;
|
||||
};
|
||||
tryUpswitchRef.current = () => {
|
||||
@@ -495,7 +492,7 @@ export default function DynamicVideoPlayer({
|
||||
setAutoLowReason(undefined);
|
||||
}
|
||||
};
|
||||
}, [resolvedQuality, subAvailable, governor]);
|
||||
}, [resolvedQuality, subAvailable]);
|
||||
|
||||
// persisted across sessions so a device on a known-slow connection
|
||||
// starts low instead of paying the first stall to find out
|
||||
@@ -616,7 +613,19 @@ export default function DynamicVideoPlayer({
|
||||
governor.sourceLoadStarted();
|
||||
}, [source, isScrubbing, governor]);
|
||||
|
||||
const lastChunkRef = useRef(timeRange);
|
||||
useEffect(() => {
|
||||
// the seed effect picks the starting quality, so only a later chunk
|
||||
// boundary reconsiders it
|
||||
const lastChunk = lastChunkRef.current;
|
||||
lastChunkRef.current = timeRange;
|
||||
if (
|
||||
lastChunk.after === timeRange.after &&
|
||||
lastChunk.before === timeRange.before
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// a chunk boundary is where full quality may be retried, and a
|
||||
// natural point to persist what the governor has learned
|
||||
setAutoLowQuality((prev) => prev && !governor.shouldRetryMain());
|
||||
|
||||
@@ -35,7 +35,6 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
|
||||
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
|
||||
@@ -83,7 +82,9 @@ export function CameraStreamingDialog({
|
||||
// metadata
|
||||
|
||||
const isRestreamed = useMemo(
|
||||
() => isRestreamedStream(config, streamName),
|
||||
() =>
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { LiveStreamGovernor } from "@/components/player/LiveStreamGovernor";
|
||||
import { isCameraOffline } from "@/hooks/use-camera-activity";
|
||||
import { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import {
|
||||
LiveAutoReason,
|
||||
LiveHealthSample,
|
||||
LivePlayerError,
|
||||
} from "@/types/live";
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
|
||||
// one governor per camera ladder for the tab session, so reopening a
|
||||
// camera resumes where auto settled
|
||||
const governors = new Map<string, LiveStreamGovernor>();
|
||||
|
||||
function governorFor(cameraName: string, streams: string[]) {
|
||||
const key = `${cameraName}|${streams.join(",")}`;
|
||||
let governor = governors.get(key);
|
||||
if (!governor) {
|
||||
// the extra rung is the jsmpeg floor
|
||||
governor = new LiveStreamGovernor(streams.length + 1);
|
||||
governors.set(key, governor);
|
||||
}
|
||||
return governor;
|
||||
}
|
||||
|
||||
type UseAutoLiveStreamProps = {
|
||||
cameraName: string;
|
||||
// go2rtc stream names in config order
|
||||
streams: string[];
|
||||
// auto is the stream setting
|
||||
selected: boolean;
|
||||
// mic, debug, force low-bandwidth, the error fallback latch, or unloaded
|
||||
// preferences own the player
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
export type AutoLiveStream = {
|
||||
streamName: string | undefined;
|
||||
atFloor: boolean;
|
||||
reason: LiveAutoReason | undefined;
|
||||
onHealthSample: (sample: LiveHealthSample) => void;
|
||||
handleError: (error: LivePlayerError) => boolean;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export function useAutoLiveStream({
|
||||
cameraName,
|
||||
streams,
|
||||
selected,
|
||||
paused,
|
||||
}: UseAutoLiveStreamProps): AutoLiveStream {
|
||||
const governor = useMemo(
|
||||
() => governorFor(cameraName, streams),
|
||||
[cameraName, streams],
|
||||
);
|
||||
const { rung, reason } = useSyncExternalStore(
|
||||
governor.subscribe,
|
||||
governor.getSnapshot,
|
||||
);
|
||||
const active = selected && !paused;
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
const offline = isCameraOffline(stats, cameraName);
|
||||
|
||||
// evidence from before a pin, a pause, or a remount does not count
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
governor.resetHistory();
|
||||
}
|
||||
}, [active, governor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saveData =
|
||||
(navigator as Navigator & { connection?: { saveData?: boolean } })
|
||||
.connection?.saveData === true;
|
||||
governor.setHoldLow(saveData);
|
||||
}, [selected, governor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// a hidden tab delivers no samples, so its time is not clean time
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === "visible") {
|
||||
governor.tick();
|
||||
} else {
|
||||
governor.resetHistory();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [active, governor]);
|
||||
|
||||
const onHealthSample = useCallback(
|
||||
(sample: LiveHealthSample) => {
|
||||
if (active) {
|
||||
governor.sample(sample);
|
||||
}
|
||||
},
|
||||
[active, governor],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: LivePlayerError) => active && governor.playerError(error, !offline),
|
||||
[active, governor, offline],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => governor.reset(), [governor]);
|
||||
|
||||
const floor = streams.length;
|
||||
|
||||
return {
|
||||
streamName: selected ? streams[Math.min(rung, floor - 1)] : undefined,
|
||||
atFloor: selected && rung === floor,
|
||||
reason: selected ? reason : undefined,
|
||||
onHealthSample,
|
||||
handleError,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { useTimelineUtils } from "./use-timeline-utils";
|
||||
import useDeepMemo from "./use-deep-memo";
|
||||
import { isEqual } from "lodash";
|
||||
import { useAutoFrigateStats } from "./use-stats";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import useSWR from "swr";
|
||||
import { getAttributeLabels } from "@/utils/iconUtil";
|
||||
|
||||
@@ -31,19 +30,6 @@ type useCameraActivityReturn = {
|
||||
offline: boolean;
|
||||
};
|
||||
|
||||
export function isCameraOffline(
|
||||
stats: FrigateStats | undefined,
|
||||
cameraName: string | undefined,
|
||||
): boolean {
|
||||
if (!stats?.cameras || !cameraName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
stats.cameras[cameraName]?.camera_fps == 0 && stats.service.uptime > 60
|
||||
);
|
||||
}
|
||||
|
||||
export function useCameraActivity(
|
||||
camera: CameraConfig | undefined,
|
||||
revalidateOnFocus: boolean = true,
|
||||
@@ -181,10 +167,25 @@ export function useCameraActivity(
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
|
||||
const offline = useMemo(
|
||||
() => isCameraOffline(stats, camera?.name),
|
||||
[camera, stats],
|
||||
const offline = useMemo(() => {
|
||||
if (!stats) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cameras = stats["cameras"];
|
||||
|
||||
if (!cameras) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!camera?.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60
|
||||
);
|
||||
}, [camera, stats]);
|
||||
|
||||
const isCameraEnabled = cameraEnabled ? cameraEnabled === "ON" : true;
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import useSWR from "swr";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
|
||||
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import {
|
||||
evaluateStreamWebRTCAvailability,
|
||||
useWebRTCGloballyAvailable,
|
||||
@@ -52,14 +51,18 @@ export default function useCameraLiveMode(
|
||||
cameras.forEach((camera) => {
|
||||
if (activeStreams && activeStreams[camera.name]) {
|
||||
const selectedStreamName = activeStreams[camera.name];
|
||||
const isRestreamed = isRestreamedStream(config, selectedStreamName);
|
||||
const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes(
|
||||
selectedStreamName,
|
||||
);
|
||||
|
||||
if (isRestreamed) {
|
||||
streamNames.add(selectedStreamName);
|
||||
}
|
||||
} else {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
const isRestreamed = isRestreamedStream(config, streamName);
|
||||
const isRestreamed = Object.keys(
|
||||
config.go2rtc.streams || {},
|
||||
).includes(streamName);
|
||||
|
||||
if (isRestreamed) {
|
||||
streamNames.add(streamName);
|
||||
@@ -122,7 +125,9 @@ export default function useCameraLiveMode(
|
||||
cameras.forEach((camera) => {
|
||||
const selectedStreamName =
|
||||
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
|
||||
const isRestreamed = isRestreamedStream(config, selectedStreamName);
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
|
||||
|
||||
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
|
||||
|
||||
@@ -176,7 +181,9 @@ export default function useCameraLiveMode(
|
||||
(cameraConfig
|
||||
? Object.values(cameraConfig.live.streams)[0]
|
||||
: cameraName);
|
||||
const isRestreamed = isRestreamedStream(config, selectedStreamName);
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
|
||||
|
||||
setPreferredLiveModes((prevModes) => ({
|
||||
...prevModes,
|
||||
|
||||
@@ -79,7 +79,6 @@ export function evaluateStreamWebRTCAvailability(args: {
|
||||
/** Console detail for a reason decided by the global availability check. */
|
||||
function describeGlobalReason(
|
||||
reason: WebRTCUnavailableReason,
|
||||
testStream: string | undefined,
|
||||
probeDetail: string | undefined,
|
||||
): string {
|
||||
switch (reason) {
|
||||
@@ -88,7 +87,7 @@ function describeGlobalReason(
|
||||
case "not-configured":
|
||||
return "No candidates or ice_servers are set under go2rtc.webrtc.";
|
||||
case "unreachable":
|
||||
return `The connectivity probe against stream '${testStream}' failed: ${probeDetail ?? "unknown cause"}.`;
|
||||
return `The connectivity probe failed against ${probeDetail ?? "an unknown stream"}.`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -98,9 +97,13 @@ function describeGlobalReason(
|
||||
let lastProbeSignature: string | null = null;
|
||||
|
||||
/**
|
||||
* Once-per-session: browser support, go2rtc config, and a live handshake probe.
|
||||
* Browser support, go2rtc config, and a live handshake probe shared by the
|
||||
* page. A failed probe is retried on a later mount or when the page becomes
|
||||
* visible again once its cached result has expired.
|
||||
*/
|
||||
export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
export function useWebRTCGloballyAvailable(
|
||||
preferredStream?: string,
|
||||
): GlobalAvailability {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [probe, setProbe] = useState<{
|
||||
state: "pending" | "pass" | "fail";
|
||||
@@ -118,11 +121,19 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
// Representative restreamed stream to probe against.
|
||||
const testStream = useMemo(() => {
|
||||
const streams = config?.go2rtc?.streams ?? {};
|
||||
return Object.keys(streams)[0];
|
||||
}, [config]);
|
||||
// the stream being viewed is the one most likely to be online
|
||||
const testStreams = useMemo(() => {
|
||||
const streams = Object.keys(config?.go2rtc?.streams ?? {});
|
||||
|
||||
if (!preferredStream || !streams.includes(preferredStream)) {
|
||||
return streams;
|
||||
}
|
||||
|
||||
return [preferredStream, ...streams.filter((s) => s !== preferredStream)];
|
||||
}, [config, preferredStream]);
|
||||
const testStreamsKey = testStreams.join("\n");
|
||||
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
|
||||
const iceServers = useMemo(
|
||||
() => webRTCIceServers(config?.go2rtc?.webrtc?.ice_servers),
|
||||
@@ -136,9 +147,9 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
JSON.stringify({
|
||||
candidates: config?.go2rtc?.webrtc?.candidates ?? [],
|
||||
iceServers,
|
||||
testStream,
|
||||
streams: Object.keys(config?.go2rtc?.streams ?? {}),
|
||||
}),
|
||||
[config, iceServers, testStream],
|
||||
[config, iceServers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -153,11 +164,29 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
}, [probeSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserOk || !configured || !testStream) {
|
||||
if (probe.state !== "fail") {
|
||||
return;
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
setRetryToken((token) => token + 1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
}, [probe.state]);
|
||||
|
||||
useEffect(() => {
|
||||
const streams = testStreamsKey ? testStreamsKey.split("\n") : [];
|
||||
|
||||
if (!browserOk || !configured || streams.length === 0) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
probeWebRTCAvailability(testStream, iceServers).then((result) => {
|
||||
probeWebRTCAvailability(streams, iceServers).then((result) => {
|
||||
if (!cancelled) {
|
||||
setProbe({
|
||||
state: result.ok ? "pass" : "fail",
|
||||
@@ -168,7 +197,7 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [browserOk, configured, testStream, iceServers]);
|
||||
}, [browserOk, configured, testStreamsKey, iceServers, retryToken]);
|
||||
|
||||
const availability = useMemo<GlobalAvailability>(() => {
|
||||
if (!browserOk) {
|
||||
@@ -196,9 +225,9 @@ export function useWebRTCGloballyAvailable(): GlobalAvailability {
|
||||
logWebRTCUnavailable(
|
||||
undefined,
|
||||
reason,
|
||||
describeGlobalReason(reason, testStream, probe.detail),
|
||||
describeGlobalReason(reason, probe.detail),
|
||||
);
|
||||
}, [config, availability, testStream, probe.detail]);
|
||||
}, [config, availability, probe.detail]);
|
||||
|
||||
return availability;
|
||||
}
|
||||
@@ -208,7 +237,8 @@ export function useWebRTCAvailableForStream(
|
||||
metadata: LiveStreamMetadata | null | undefined,
|
||||
streamName?: string,
|
||||
): StreamAvailability {
|
||||
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
|
||||
const { globallyAvailable, globalReason } =
|
||||
useWebRTCGloballyAvailable(streamName);
|
||||
|
||||
const availability = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -175,6 +175,21 @@ html {
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Live masonry grid: only the bottom-right corner resizes, drawn as a corner
|
||||
bracket on the real se handle (drop-shadow keeps it legible over footage). */
|
||||
.grid-layout .react-resizable-handle-se::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-right: 2.5px solid rgba(233, 238, 246, 0.92);
|
||||
border-bottom: 2.5px solid rgba(233, 238, 246, 0.92);
|
||||
border-bottom-right-radius: 3px;
|
||||
filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
border: 3px solid #a00000 !important;
|
||||
opacity: 0.5 !important;
|
||||
|
||||
@@ -132,8 +132,7 @@ export default function Events() {
|
||||
setMotionSearchDay(normalizedDay);
|
||||
}, []);
|
||||
|
||||
const [notificationTab, setNotificationTab] =
|
||||
useState<TimelineType>("timeline");
|
||||
const [notificationTab, setNotificationTab] = useState<TimelineType>();
|
||||
|
||||
const getReviewDayBounds = useCallback((date: Date) => {
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
@@ -39,8 +39,6 @@ export type ConfigFormContext = {
|
||||
extraHasChanges?: boolean;
|
||||
setExtraHasChanges?: (hasChanges: boolean) => void;
|
||||
formData?: JsonObject;
|
||||
/** Key order of each ordered map, so reorders re-render fields */
|
||||
orderedMapsKeyOrder?: string[][];
|
||||
pendingDataBySection?: Record<string, ConfigSectionData>;
|
||||
onPendingDataChange?: (
|
||||
sectionKey: string,
|
||||
|
||||
@@ -36,17 +36,6 @@ export interface FaceRecognitionConfig {
|
||||
export type SearchModel = "jinav1" | "jinav2" | (string & NonNullable<unknown>);
|
||||
export type SearchModelSize = "small" | "large";
|
||||
|
||||
export type LiveTranscodeQuality = {
|
||||
height: number;
|
||||
bitrate: number;
|
||||
};
|
||||
|
||||
export type LiveTranscodeConfig = {
|
||||
enabled: boolean;
|
||||
source?: string | null;
|
||||
qualities: LiveTranscodeQuality[];
|
||||
};
|
||||
|
||||
export interface CameraConfig {
|
||||
friendly_name: string;
|
||||
audio: {
|
||||
@@ -120,7 +109,6 @@ export interface CameraConfig {
|
||||
height: number;
|
||||
quality: number;
|
||||
streams: { [key: string]: string };
|
||||
transcode: LiveTranscodeConfig;
|
||||
};
|
||||
lpr: {
|
||||
enabled: boolean;
|
||||
|
||||
+1
-14
@@ -31,20 +31,7 @@ export type LiveStreamMetadata = {
|
||||
consumers: LiveConsumerMetadata[];
|
||||
};
|
||||
|
||||
// mse-codec is go2rtc's proof that it has no codec this browser plays;
|
||||
// mse-decode also covers transient Safari failures
|
||||
export type LivePlayerError =
|
||||
"stalled" | "startup" | "mse-decode" | "mse-codec";
|
||||
|
||||
// one second of delivery from the playing transport
|
||||
export type LiveHealthSample = {
|
||||
bytes: number;
|
||||
mediaSeconds: number;
|
||||
wallSeconds: number;
|
||||
};
|
||||
|
||||
// why auto is playing below the top stream
|
||||
export type LiveAutoReason = "bandwidth" | "codec" | "saveData" | "floor";
|
||||
export type LivePlayerError = "stalled" | "startup" | "mse-decode";
|
||||
|
||||
export type TwoWayTalkError = "microphone" | "refused";
|
||||
|
||||
|
||||
@@ -306,49 +306,6 @@ export function buildOverrides(
|
||||
return current;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ordered maps: config maps whose key order is meaningful
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// lodash isEqual ignores key order, so ordered maps compare entry lists.
|
||||
export function changedOrderedMapPaths(
|
||||
current: unknown,
|
||||
base: unknown,
|
||||
paths: string[],
|
||||
): string[] {
|
||||
return paths.filter((path) => {
|
||||
const value = get(current, path);
|
||||
|
||||
if (!isJsonObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseValue = get(base, path);
|
||||
return !isEqual(
|
||||
Object.entries(value),
|
||||
isJsonObject(baseValue) ? Object.entries(baseValue) : undefined,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Send a changed ordered map whole; per-key overrides cannot carry order.
|
||||
export function applyOrderedMaps(
|
||||
overrides: unknown,
|
||||
current: unknown,
|
||||
base: unknown,
|
||||
paths: string[],
|
||||
): unknown {
|
||||
const changed = changedOrderedMapPaths(current, base, paths);
|
||||
|
||||
if (changed.length === 0) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
const result = isJsonObject(overrides) ? cloneDeep(overrides) : {};
|
||||
changed.forEach((path) => set(result, path, cloneDeep(get(current, path))));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// flattenOverrides — turn an overrides object into a list of leaf paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { FrigateConfig, LiveTranscodeConfig } from "@/types/frigateConfig";
|
||||
|
||||
// Mirrors frigate/util/live_streams.py and apply_live_transcode_streams.
|
||||
|
||||
export const TRANSCODE_HEIGHTS = [1080, 720, 480, 360, 240];
|
||||
|
||||
const DEFAULT_BITRATES: Record<number, number> = {
|
||||
1080: 2500,
|
||||
720: 1200,
|
||||
480: 500,
|
||||
360: 250,
|
||||
240: 150,
|
||||
};
|
||||
|
||||
export function defaultTranscodeBitrate(height: number): number {
|
||||
return DEFAULT_BITRATES[height] ?? 500;
|
||||
}
|
||||
|
||||
export function transcodeStreamName(camera: string, height: number): string {
|
||||
return `${camera}_transcode_${height}p`;
|
||||
}
|
||||
|
||||
export function isTranscodeStreamName(camera: string, name: string): boolean {
|
||||
const prefix = `${camera}_transcode_`;
|
||||
return name.startsWith(prefix) && /^\d+p$/.test(name.slice(prefix.length));
|
||||
}
|
||||
|
||||
/** A generated stream's configured bitrate, or undefined for other streams. */
|
||||
export function transcodeBitrateFor(
|
||||
camera: string,
|
||||
name: string,
|
||||
transcode: LiveTranscodeConfig | undefined,
|
||||
): number | undefined {
|
||||
if (!transcode?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return transcode.qualities.find(
|
||||
(quality) => transcodeStreamName(camera, quality.height) === name,
|
||||
)?.bitrate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop transcoded entries that are no longer generated and append generated
|
||||
* streams that are missing. Placed entries keep their position and label.
|
||||
*/
|
||||
export function reconcileTranscodeStreams(
|
||||
camera: string,
|
||||
streams: Record<string, string>,
|
||||
transcode: LiveTranscodeConfig,
|
||||
go2rtcStreams: string[],
|
||||
): Record<string, string> {
|
||||
const generated = transcode.enabled
|
||||
? transcode.qualities.map((quality) => ({
|
||||
label: `${quality.height}p`,
|
||||
name: transcodeStreamName(camera, quality.height),
|
||||
}))
|
||||
: [];
|
||||
const generatedNames = new Set(generated.map(({ name }) => name));
|
||||
|
||||
const kept = Object.entries(streams).filter(
|
||||
([, name]) =>
|
||||
generatedNames.has(name) ||
|
||||
go2rtcStreams.includes(name) ||
|
||||
!isTranscodeStreamName(camera, name),
|
||||
);
|
||||
const placed = new Set(kept.map(([, name]) => name));
|
||||
const labels = new Set(kept.map(([label]) => label));
|
||||
|
||||
generated.forEach(({ label, name }) => {
|
||||
// a taken label is left for the backend to reject on save
|
||||
if (!placed.has(name) && !labels.has(label)) {
|
||||
kept.push([label, name]);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.fromEntries(kept);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether go2rtc serves a live stream. config.go2rtc.streams lists only the
|
||||
* yaml streams, so transcoded streams of enabled cameras count too.
|
||||
*/
|
||||
export function isRestreamedStream(
|
||||
config: FrigateConfig | undefined,
|
||||
name: string | undefined,
|
||||
): boolean {
|
||||
if (!config || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Object.keys(config.go2rtc.streams || {}).includes(name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(config.cameras).some(
|
||||
(camera) =>
|
||||
camera.live.transcode?.enabled &&
|
||||
isTranscodeStreamName(camera.name, name),
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,12 @@ export const TRANSFER_KEYS: TransferKey[] = [
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "naturalAspectLayout",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "alertVideos",
|
||||
section: "preferences",
|
||||
@@ -182,13 +188,22 @@ const layoutItemSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// a group whose dashboard has not been opened since upgrading still holds
|
||||
// the pre-0.19 bare array, so both shapes reach the file
|
||||
const storedLayoutSchema = z.union([
|
||||
z.array(layoutItemSchema),
|
||||
z
|
||||
.object({ version: z.number(), layout: z.array(layoutItemSchema) })
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
export const uiSettingsFileSchema = z.object({
|
||||
type: z.literal(UI_SETTINGS_FILE_TYPE),
|
||||
version: z.number().int().positive(),
|
||||
exported_at: z.string(),
|
||||
frigate_version: z.string(),
|
||||
sections: z.object({
|
||||
layouts: z.record(z.string(), z.array(layoutItemSchema)),
|
||||
layouts: z.record(z.string(), storedLayoutSchema),
|
||||
streaming: allGroupsStreamingSettingsSchema,
|
||||
preferences: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
@@ -204,6 +219,8 @@ export async function buildExportPayload(
|
||||
const layouts: UiSettingsFile["sections"]["layouts"] = {};
|
||||
let streaming: UiSettingsFile["sections"]["streaming"] = {};
|
||||
const preferences: UiSettingsFile["sections"]["preferences"] = {};
|
||||
const naturalAspect =
|
||||
(await readTransferable("naturalAspectLayout", true, username)) === true;
|
||||
|
||||
await Promise.all(
|
||||
groupNames.map(async (group) => {
|
||||
@@ -213,7 +230,9 @@ export async function buildExportPayload(
|
||||
username,
|
||||
);
|
||||
|
||||
if (value !== undefined) {
|
||||
// a group not opened since the mode changed still holds a layout from
|
||||
// the other mode, which the grid discards, so leave it out of the file
|
||||
if (value !== undefined && layoutIsNatural(value) === naturalAspect) {
|
||||
layouts[group] = value;
|
||||
}
|
||||
}),
|
||||
@@ -384,6 +403,30 @@ export function summarizeImport(
|
||||
};
|
||||
}
|
||||
|
||||
// Bare arrays are pre-masonry bucketed layouts
|
||||
function layoutIsNatural(layout: unknown): boolean {
|
||||
return (
|
||||
typeof layout === "object" &&
|
||||
layout !== null &&
|
||||
!Array.isArray(layout) &&
|
||||
(layout as { naturalAspect?: unknown }).naturalAspect === true
|
||||
);
|
||||
}
|
||||
|
||||
// A layout only renders under the mode that built it, so importing layouts
|
||||
// applies this mode too. Exports hold a single mode.
|
||||
export function importedLayoutsNaturalAspect(
|
||||
file: UiSettingsFile,
|
||||
): boolean | null {
|
||||
const layouts = Object.values(file.sections.layouts);
|
||||
|
||||
if (!layouts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return layouts.some(layoutIsNatural);
|
||||
}
|
||||
|
||||
export function hasImportableContent(summary: ImportSummary): boolean {
|
||||
return (
|
||||
summary.layoutGroupCount > 0 ||
|
||||
@@ -398,6 +441,9 @@ export async function applyImportPayload(
|
||||
username: string | undefined,
|
||||
): Promise<void> {
|
||||
const writes: Promise<void>[] = [];
|
||||
const layoutsMode = sections.layouts
|
||||
? importedLayoutsNaturalAspect(file)
|
||||
: null;
|
||||
|
||||
if (sections.layouts) {
|
||||
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
|
||||
@@ -408,6 +454,15 @@ export async function applyImportPayload(
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (layoutsMode !== null) {
|
||||
writes.push(
|
||||
setData(
|
||||
getUserNamespacedKey("naturalAspectLayout", username),
|
||||
layoutsMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const streamingEntry = TRANSFER_KEYS.find(
|
||||
@@ -447,6 +502,12 @@ export async function applyImportPayload(
|
||||
if (sections.preferences) {
|
||||
validPreferenceEntries(file.sections.preferences).forEach(
|
||||
({ entry, value }) => {
|
||||
// the layouts must win this key or they import into a mode that
|
||||
// cannot display them
|
||||
if (entry.key === "naturalAspectLayout" && layoutsMode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
writes.push(setData(storageKey(entry, username), value));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
/**
|
||||
* Performs a single real WebRTC handshake against go2rtc to verify that a
|
||||
* media connection can actually be established (validates candidates, port
|
||||
* 8555 reachability, and STUN/TURN end-to-end). Result is cached per page
|
||||
* session via a module-level promise.
|
||||
* Performs a real WebRTC handshake against go2rtc to verify that a media
|
||||
* connection can actually be established (validates candidates, port 8555
|
||||
* reachability, and STUN/TURN end-to-end). A success is cached for the page
|
||||
* session, and a failure for PROBE_FAILURE_TTL_MS and only for probes that
|
||||
* start from the same stream.
|
||||
*/
|
||||
|
||||
export type WebRTCProbeResult = {
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
// go2rtc could not open the stream's source, which says nothing about
|
||||
// whether WebRTC itself can connect
|
||||
streamError?: boolean;
|
||||
};
|
||||
|
||||
let probePromise: Promise<WebRTCProbeResult> | null = null;
|
||||
const PROBE_FAILURE_TTL_MS = 30_000;
|
||||
|
||||
let cachedProbe: {
|
||||
promise: Promise<WebRTCProbeResult>;
|
||||
firstStream: string;
|
||||
failedAt?: number;
|
||||
} | null = null;
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
@@ -118,23 +128,83 @@ function runProbe(
|
||||
pc.setRemoteDescription({ type: "answer", sdp: msg.value }).catch(
|
||||
(err) => fail(`remote answer rejected: ${describeError(err)}`),
|
||||
);
|
||||
} else if (msg.type === "error") {
|
||||
cleanup({ ok: false, streamError: true, detail: msg.value });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runProbes(
|
||||
testStreams: string[],
|
||||
iceServers: RTCIceServer[],
|
||||
timeoutMs: number,
|
||||
): Promise<WebRTCProbeResult> {
|
||||
let result: WebRTCProbeResult = { ok: false, detail: "no stream to probe" };
|
||||
|
||||
for (const stream of testStreams) {
|
||||
result = await runProbe(stream, iceServers, timeoutMs);
|
||||
|
||||
if (!result.ok) {
|
||||
result.detail = `stream '${stream}': ${result.detail}`;
|
||||
}
|
||||
|
||||
if (!result.streamError) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Probes the streams in order, moving on only when go2rtc refuses one. */
|
||||
export function probeWebRTCAvailability(
|
||||
testStream: string,
|
||||
testStreams: string[],
|
||||
iceServers: RTCIceServer[],
|
||||
timeoutMs: number = 5000,
|
||||
): Promise<WebRTCProbeResult> {
|
||||
if (!probePromise) {
|
||||
probePromise = runProbe(testStream, iceServers, timeoutMs);
|
||||
const firstStream = testStreams[0];
|
||||
|
||||
if (cachedProbe) {
|
||||
const { promise, failedAt } = cachedProbe;
|
||||
|
||||
if (
|
||||
cachedProbe.firstStream === firstStream &&
|
||||
(failedAt === undefined || Date.now() - failedAt < PROBE_FAILURE_TTL_MS)
|
||||
) {
|
||||
return promise;
|
||||
}
|
||||
return probePromise;
|
||||
|
||||
// a pass from any stream proves the connection, but another stream's
|
||||
// failure says nothing about this one
|
||||
if (failedAt === undefined) {
|
||||
return promise.then((result) =>
|
||||
result.ok
|
||||
? result
|
||||
: probeWebRTCAvailability(testStreams, iceServers, timeoutMs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const entry: NonNullable<typeof cachedProbe> = {
|
||||
firstStream,
|
||||
promise: runProbes(testStreams, iceServers, timeoutMs)
|
||||
.catch((err): WebRTCProbeResult => ({
|
||||
ok: false,
|
||||
detail: describeError(err),
|
||||
}))
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
entry.failedAt = Date.now();
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
cachedProbe = entry;
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** Clears the cached probe result (e.g. when go2rtc config changes). */
|
||||
export function resetWebRTCProbe(): void {
|
||||
probePromise = null;
|
||||
cachedProbe = null;
|
||||
}
|
||||
|
||||
@@ -8,16 +8,17 @@ import {
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import {
|
||||
Layout,
|
||||
LayoutItem,
|
||||
ResponsiveGridLayout as Responsive,
|
||||
} from "react-grid-layout";
|
||||
import { aspectRatio, getCompactor } from "react-grid-layout/core";
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import "react-resizable/css/styles.css";
|
||||
import {
|
||||
@@ -28,12 +29,11 @@ import {
|
||||
StatsState,
|
||||
VolumeState,
|
||||
} from "@/types/live";
|
||||
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
import { ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { isEqual } from "lodash";
|
||||
import useSWR from "swr";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
||||
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
||||
import LivePlayer from "@/components/player/LivePlayer";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
@@ -52,6 +52,43 @@ import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// rowHeight is 1/VERTICAL_RESOLUTION of a column, so h = round(w *
|
||||
// VERTICAL_RESOLUTION / aspect) lands a tile on its camera's aspect. GRID_COLS
|
||||
// also sets resize granularity: the aspect constraint derives height from
|
||||
// width, making one column the smallest step in both axes.
|
||||
const GRID_COLS = 96;
|
||||
const TILE_BASE_W = 32;
|
||||
const TILE_WIDE_W = 64;
|
||||
const VERTICAL_RESOLUTION = 4;
|
||||
const DEFAULT_ASPECT = 16 / 9;
|
||||
// Bucketed tile shapes, matching the aspect-wide / aspect-tall Tailwind utilities.
|
||||
const TILE_ASPECT_WIDE = 32 / 9;
|
||||
const TILE_ASPECT_TALL = 8 / 9;
|
||||
|
||||
// Cells quantize to whole rows/columns, so the card takes the camera's exact
|
||||
// ratio and fits itself inside its cell. --ar and container-type live on the
|
||||
// cell; min() picks whichever axis binds first.
|
||||
const CARD_FIT =
|
||||
"h-auto w-[min(100%,calc(100cqh*var(--ar)))] aspect-[var(--ar)]";
|
||||
|
||||
// Stored coordinates are grid units, so bump this whenever GRID_COLS or
|
||||
// VERTICAL_RESOLUTION changes in a released version.
|
||||
const LAYOUT_VERSION = 2;
|
||||
type PersistedLayout = {
|
||||
version: number;
|
||||
naturalAspect: boolean;
|
||||
layout: Layout;
|
||||
};
|
||||
|
||||
// Without preventCollision, RGL shoves collided tiles down the page and never
|
||||
// compacts them back.
|
||||
const FREE_PLACEMENT_COMPACTOR = getCompactor(null, false, true);
|
||||
|
||||
// 0.17/0.18 stored a bare array on a 12-column grid whose standard tile was
|
||||
// 4x4. Bucketed mode reproduces that geometry, so those layouts convert exactly.
|
||||
const LEGACY_GRID_COLS = 12;
|
||||
const LEGACY_TILE_ROWS = 4;
|
||||
|
||||
type DraggableGridLayoutProps = {
|
||||
cameras: CameraConfig[];
|
||||
cameraGroup: string;
|
||||
@@ -98,6 +135,66 @@ export default function DraggableGridLayout({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
|
||||
const aspectRatios = useMemo(() => {
|
||||
const map: { [key: string]: number } = {};
|
||||
if (birdseyeConfig) {
|
||||
map["birdseye"] =
|
||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1);
|
||||
}
|
||||
cameras.forEach((camera) => {
|
||||
map[camera.name] =
|
||||
camera.detect.width / camera.detect.height || DEFAULT_ASPECT;
|
||||
});
|
||||
return map;
|
||||
}, [cameras, birdseyeConfig]);
|
||||
|
||||
const [naturalAspectSetting, , isNaturalAspectLoaded] = useUserPersistence(
|
||||
"naturalAspectLayout",
|
||||
false,
|
||||
);
|
||||
// phones never reach this grid, and the setting is hidden there, so an
|
||||
// imported or stale value must not take effect
|
||||
const naturalAspectLayout = !isMobileOnly && (naturalAspectSetting ?? false);
|
||||
|
||||
// Bucketed mode snaps every camera to one of three tile shapes, matching the
|
||||
// pre-masonry layout; the picture letterboxes inside its bucket.
|
||||
const layoutAspects = useMemo(() => {
|
||||
if (naturalAspectLayout) {
|
||||
return aspectRatios;
|
||||
}
|
||||
const map: { [key: string]: number } = {};
|
||||
Object.entries(aspectRatios).forEach(([name, ratio]) => {
|
||||
map[name] =
|
||||
ratio > ASPECT_WIDE_LAYOUT
|
||||
? TILE_ASPECT_WIDE
|
||||
: ratio < 1
|
||||
? TILE_ASPECT_TALL
|
||||
: DEFAULT_ASPECT;
|
||||
});
|
||||
return map;
|
||||
}, [aspectRatios, naturalAspectLayout]);
|
||||
|
||||
// A live stream can be shaped differently than detect, so the card follows
|
||||
// whatever is on screen and falls back to detect. Cells stay detect-sized, so
|
||||
// only the card resizes.
|
||||
const [liveAspects, setLiveAspects] = useState<{
|
||||
[key: string]: number | undefined;
|
||||
}>({});
|
||||
|
||||
const liveAspectHandlers = useMemo(() => {
|
||||
const map: { [key: string]: (aspectRatio: number | undefined) => void } =
|
||||
{};
|
||||
cameras.forEach((camera) => {
|
||||
map[camera.name] = (aspectRatio) =>
|
||||
setLiveAspects((prev) =>
|
||||
prev[camera.name] === aspectRatio
|
||||
? prev
|
||||
: { ...prev, [camera.name]: aspectRatio },
|
||||
);
|
||||
});
|
||||
return map;
|
||||
}, [cameras]);
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
@@ -115,7 +212,31 @@ export default function DraggableGridLayout({
|
||||
// grid layout
|
||||
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
||||
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
|
||||
useUserPersistence<PersistedLayout>(`${cameraGroup}-draggable-layout`);
|
||||
|
||||
const readPersistedLayout = useCallback(
|
||||
(stored: PersistedLayout | undefined): Layout | undefined => {
|
||||
if (
|
||||
!stored ||
|
||||
stored.version !== LAYOUT_VERSION ||
|
||||
!Array.isArray(stored.layout)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return stored.layout;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Strips per-item `constraints`, which are functions.
|
||||
const toPersisted = useCallback(
|
||||
(layout: Layout): PersistedLayout => ({
|
||||
version: LAYOUT_VERSION,
|
||||
naturalAspect: naturalAspectLayout,
|
||||
layout: layout.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })),
|
||||
}),
|
||||
[naturalAspectLayout],
|
||||
);
|
||||
|
||||
const [group] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
@@ -140,11 +261,11 @@ export default function DraggableGridLayout({
|
||||
useEffect(() => {
|
||||
setIsEditMode(false);
|
||||
setEditGroup(false);
|
||||
// Reset camera tracking state when group changes to prevent the camera-change
|
||||
// effect from incorrectly overwriting the loaded layout
|
||||
// Keeps the camera-change effect from overwriting the layout we load next.
|
||||
setCurrentCameras(undefined);
|
||||
setCurrentIncludeBirdseye(undefined);
|
||||
setCurrentGridLayout(undefined);
|
||||
setCurrentNaturalAspect(undefined);
|
||||
}, [cameraGroup, setIsEditMode]);
|
||||
|
||||
// camera state
|
||||
@@ -155,21 +276,84 @@ export default function DraggableGridLayout({
|
||||
const [currentGridLayout, setCurrentGridLayout] = useState<
|
||||
Layout | undefined
|
||||
>();
|
||||
const [currentNaturalAspect, setCurrentNaturalAspect] = useState<boolean>();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(currentLayout: Layout) => {
|
||||
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
|
||||
if (
|
||||
!isGridLayoutLoaded ||
|
||||
!isEqual(readPersistedLayout(gridLayout), currentGridLayout)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// save layout to idb
|
||||
setGridLayout(currentLayout);
|
||||
setGridLayout(toPersisted(currentLayout));
|
||||
setShowCircles(true);
|
||||
},
|
||||
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||
[
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
gridLayout,
|
||||
currentGridLayout,
|
||||
readPersistedLayout,
|
||||
toPersisted,
|
||||
],
|
||||
);
|
||||
|
||||
const dimsFor = useCallback(
|
||||
(name: string) => {
|
||||
const ratio = layoutAspects[name] ?? DEFAULT_ASPECT;
|
||||
const w = ratio >= ASPECT_WIDE_LAYOUT ? TILE_WIDE_W : TILE_BASE_W;
|
||||
const h = Math.max(1, Math.round((w * VERTICAL_RESOLUTION) / ratio));
|
||||
return { w, h };
|
||||
},
|
||||
[layoutAspects],
|
||||
);
|
||||
|
||||
// Rescale a pre-masonry layout onto the current grid. Both axes scale by a
|
||||
// constant, so tiles the user resized keep their size and their arrangement
|
||||
// stays intact. Only meaningful in bucketed mode, where a tile still has the
|
||||
// shape those coordinates assumed.
|
||||
const convertLegacyLayout = useCallback(
|
||||
(stored: unknown): Layout | undefined => {
|
||||
if (naturalAspectLayout || !Array.isArray(stored) || !stored.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const xScale = GRID_COLS / LEGACY_GRID_COLS;
|
||||
const yScale =
|
||||
Math.round((TILE_BASE_W * VERTICAL_RESOLUTION) / DEFAULT_ASPECT) /
|
||||
LEGACY_TILE_ROWS;
|
||||
const converted: LayoutItem[] = [];
|
||||
|
||||
for (const item of stored) {
|
||||
if (
|
||||
!item ||
|
||||
typeof item.i !== "string" ||
|
||||
typeof item.x !== "number" ||
|
||||
typeof item.y !== "number" ||
|
||||
typeof item.w !== "number" ||
|
||||
typeof item.h !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const w = Math.min(Math.max(1, Math.round(item.w * xScale)), GRID_COLS);
|
||||
converted.push({
|
||||
i: item.i,
|
||||
x: Math.min(Math.max(0, Math.round(item.x * xScale)), GRID_COLS - w),
|
||||
y: Math.max(0, Math.round(item.y * yScale)),
|
||||
w,
|
||||
h: Math.max(1, Math.round(item.h * yScale)),
|
||||
});
|
||||
}
|
||||
|
||||
return converted;
|
||||
},
|
||||
[naturalAspectLayout],
|
||||
);
|
||||
|
||||
const generateLayout = useCallback(
|
||||
(baseLayout: Layout | undefined) => {
|
||||
(baseLayout: Layout | undefined): Layout | undefined => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
@@ -179,91 +363,98 @@ export default function DraggableGridLayout({
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
const optionsMap: LayoutItem[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
const existing: LayoutItem[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames.includes(layout.i))
|
||||
: [];
|
||||
const placed = new Set(existing.map((layout) => layout.i));
|
||||
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns
|
||||
// Each column starts below every existing tile that overlaps it, so new
|
||||
// cameras fill open columns without overlapping the user's tiles.
|
||||
const colBottoms = Array.from({ length: tileColumns }, (_, c) =>
|
||||
existing.reduce(
|
||||
(max, layout) =>
|
||||
layout.x < (c + 1) * TILE_BASE_W &&
|
||||
layout.x + layout.w > c * TILE_BASE_W
|
||||
? Math.max(max, layout.y + layout.h)
|
||||
: max,
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
const result: LayoutItem[] = [...existing];
|
||||
|
||||
cameraNames.forEach((name) => {
|
||||
if (placed.has(name)) {
|
||||
return;
|
||||
}
|
||||
const { w, h } = dimsFor(name);
|
||||
|
||||
let aspectRatio;
|
||||
let col;
|
||||
|
||||
// Handle "birdseye" camera as a special case
|
||||
if (cameraName === "birdseye") {
|
||||
aspectRatio =
|
||||
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||
col = 0; // Set birdseye camera in the first column
|
||||
} else {
|
||||
const camera = cameras.find((cam) => cam.name === cameraName);
|
||||
aspectRatio =
|
||||
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
|
||||
col = index % 3; // Regular cameras distributed across columns
|
||||
if (w === TILE_BASE_W) {
|
||||
let col = 0;
|
||||
for (let c = 1; c < tileColumns; c++) {
|
||||
if (colBottoms[c] < colBottoms[col]) {
|
||||
col = c;
|
||||
}
|
||||
|
||||
// Calculate layout options based on aspect ratio
|
||||
const columnsPerPlayer = 4;
|
||||
let height;
|
||||
let width;
|
||||
|
||||
if (aspectRatio < 1) {
|
||||
// Portrait
|
||||
height = 2 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
} else if (aspectRatio > 2) {
|
||||
// Wide
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = 2 * columnsPerPlayer;
|
||||
} else {
|
||||
// Landscape
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
}
|
||||
|
||||
const options = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
|
||||
optionsMap.push(options);
|
||||
result.push({
|
||||
i: name,
|
||||
x: col * TILE_BASE_W,
|
||||
y: colBottoms[col],
|
||||
w,
|
||||
h,
|
||||
});
|
||||
colBottoms[col] += h;
|
||||
} else {
|
||||
let pair = 0;
|
||||
for (let c = 1; c + 1 < tileColumns; c++) {
|
||||
if (
|
||||
Math.max(colBottoms[c], colBottoms[c + 1]) <
|
||||
Math.max(colBottoms[pair], colBottoms[pair + 1])
|
||||
) {
|
||||
pair = c;
|
||||
}
|
||||
}
|
||||
const y = Math.max(colBottoms[pair], colBottoms[pair + 1]);
|
||||
result.push({ i: name, x: pair * TILE_BASE_W, y, w, h });
|
||||
colBottoms[pair] = y + h;
|
||||
colBottoms[pair + 1] = y + h;
|
||||
}
|
||||
});
|
||||
|
||||
return optionsMap;
|
||||
return result;
|
||||
},
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGridLayoutLoaded) {
|
||||
if (gridLayout) {
|
||||
// set current grid layout from loaded, possibly adding new cameras
|
||||
const updatedLayout = generateLayout(gridLayout);
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = readPersistedLayout(gridLayout);
|
||||
const converted = saved ? undefined : convertLegacyLayout(gridLayout);
|
||||
const base = saved ?? converted;
|
||||
|
||||
if (base) {
|
||||
const updatedLayout = generateLayout(base) ?? base;
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
// Only save if cameras were added (layout changed)
|
||||
if (!isEqual(updatedLayout, gridLayout)) {
|
||||
setGridLayout(updatedLayout);
|
||||
if (converted || !isEqual(updatedLayout, base)) {
|
||||
setGridLayout(toPersisted(updatedLayout));
|
||||
}
|
||||
// Set camera tracking state so the camera-change effect has a baseline
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
setCurrentNaturalAspect(
|
||||
converted ? naturalAspectLayout : gridLayout?.naturalAspect,
|
||||
);
|
||||
} else {
|
||||
// idb is empty, set it with an initial layout
|
||||
const newLayout = generateLayout(undefined);
|
||||
// empty or incompatible (pre-masonry) data
|
||||
const newLayout = generateLayout(undefined) ?? [];
|
||||
setCurrentGridLayout(newLayout);
|
||||
setGridLayout(newLayout);
|
||||
setGridLayout(toPersisted(newLayout));
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
}
|
||||
setCurrentNaturalAspect(naturalAspectLayout);
|
||||
}
|
||||
}, [
|
||||
gridLayout,
|
||||
@@ -272,12 +463,15 @@ export default function DraggableGridLayout({
|
||||
generateLayout,
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
naturalAspectLayout,
|
||||
readPersistedLayout,
|
||||
convertLegacyLayout,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||
// Skip if currentCameras is undefined (means we just switched groups and
|
||||
// the first useEffect hasn't run yet to set things up)
|
||||
// Only for camera changes within a loaded group; undefined currentCameras
|
||||
// means the load effect above has not run yet.
|
||||
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -289,10 +483,10 @@ export default function DraggableGridLayout({
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
|
||||
// Regenerate layout based on current layout, adding any new cameras
|
||||
const updatedLayout = generateLayout(currentGridLayout);
|
||||
const updatedLayout =
|
||||
generateLayout(currentGridLayout) ?? currentGridLayout ?? [];
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
setGridLayout(updatedLayout);
|
||||
setGridLayout(toPersisted(updatedLayout));
|
||||
}
|
||||
}, [
|
||||
cameras,
|
||||
@@ -303,39 +497,47 @@ export default function DraggableGridLayout({
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
const [marginValue, setMarginValue] = useState(16);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isNaturalAspectLoaded ||
|
||||
currentNaturalAspect === undefined ||
|
||||
currentNaturalAspect === naturalAspectLayout
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// calculate margin value for browsers that don't have default font size of 16px
|
||||
useLayoutEffect(() => {
|
||||
const calculateRemValue = () => {
|
||||
const htmlElement = document.documentElement;
|
||||
const fontSize = window.getComputedStyle(htmlElement).fontSize;
|
||||
setMarginValue(parseFloat(fontSize));
|
||||
};
|
||||
setCurrentNaturalAspect(naturalAspectLayout);
|
||||
const regenerated = generateLayout(undefined) ?? [];
|
||||
setCurrentGridLayout(regenerated);
|
||||
setGridLayout(toPersisted(regenerated));
|
||||
}, [
|
||||
naturalAspectLayout,
|
||||
isNaturalAspectLoaded,
|
||||
currentNaturalAspect,
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
calculateRemValue();
|
||||
const gridContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Commit-time measure: paints the first frame at the real width (no
|
||||
// innerWidth flash), and the setState re-render is what lets
|
||||
// useResizeObserver see a node mounted after the skeleton swap.
|
||||
const [mountWidth, setMountWidth] = useState<number | null>(null);
|
||||
|
||||
const attachGridContainer = useCallback((node: HTMLDivElement | null) => {
|
||||
gridContainerRef.current = node;
|
||||
setMountWidth(node ? node.getBoundingClientRect().width : null);
|
||||
}, []);
|
||||
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ width: containerWidth, height: containerHeight }] =
|
||||
useResizeObserver(gridContainerRef);
|
||||
|
||||
const scrollBarWidth = useMemo(() => {
|
||||
if (containerWidth && containerHeight && containerRef.current) {
|
||||
return (
|
||||
containerRef.current.offsetWidth - containerRef.current.clientWidth
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}, [containerRef, containerHeight, containerWidth]);
|
||||
|
||||
const availableWidth = useMemo(
|
||||
() => (scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth),
|
||||
[containerWidth, scrollBarWidth],
|
||||
);
|
||||
const availableWidth = containerWidth || mountWidth || 0;
|
||||
|
||||
const hasScrollbar = useMemo(() => {
|
||||
if (containerHeight && containerRef.current) {
|
||||
@@ -346,61 +548,10 @@ export default function DraggableGridLayout({
|
||||
}, [containerRef, containerHeight]);
|
||||
|
||||
const cellHeight = useMemo(() => {
|
||||
const aspectRatio = 16 / 9;
|
||||
// subtract container margin, 1 camera takes up at least 4 rows
|
||||
// account for additional margin on bottom of each row
|
||||
return (
|
||||
((availableWidth ?? window.innerWidth) - 2 * marginValue) /
|
||||
12 /
|
||||
aspectRatio -
|
||||
marginValue +
|
||||
marginValue / 4
|
||||
);
|
||||
}, [availableWidth, marginValue]);
|
||||
|
||||
const handleResize = (
|
||||
_layout: Layout,
|
||||
oldLayoutItem: LayoutItem | null,
|
||||
layoutItem: LayoutItem | null,
|
||||
placeholder: LayoutItem | null,
|
||||
) => {
|
||||
if (!oldLayoutItem || !layoutItem || !placeholder) return;
|
||||
|
||||
const heightDiff = layoutItem.h - oldLayoutItem.h;
|
||||
const widthDiff = layoutItem.w - oldLayoutItem.w;
|
||||
const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
|
||||
|
||||
let newWidth, newHeight;
|
||||
|
||||
if (Math.abs(heightDiff) < Math.abs(widthDiff)) {
|
||||
newHeight = Math.round(layoutItem.w / changeCoef);
|
||||
newWidth = Math.round(newHeight * changeCoef);
|
||||
} else {
|
||||
newWidth = Math.round(layoutItem.h * changeCoef);
|
||||
newHeight = Math.round(newWidth / changeCoef);
|
||||
}
|
||||
|
||||
// Ensure dimensions maintain aspect ratio and fit within the grid
|
||||
if (layoutItem.x + newWidth > 12) {
|
||||
newWidth = 12 - layoutItem.x;
|
||||
newHeight = Math.round(newWidth / changeCoef);
|
||||
}
|
||||
|
||||
if (changeCoef == 0.5) {
|
||||
// portrait
|
||||
newHeight = Math.ceil(newHeight / 2) * 2;
|
||||
} else if (changeCoef == 2) {
|
||||
// pano/wide
|
||||
newHeight = Math.ceil(newHeight * 2) / 2;
|
||||
}
|
||||
|
||||
newWidth = Math.round(newHeight * changeCoef);
|
||||
|
||||
layoutItem.w = newWidth;
|
||||
layoutItem.h = newHeight;
|
||||
placeholder.w = layoutItem.w;
|
||||
placeholder.h = layoutItem.h;
|
||||
};
|
||||
const width = availableWidth || window.innerWidth;
|
||||
const columnWidth = width / GRID_COLS;
|
||||
return columnWidth / VERTICAL_RESOLUTION;
|
||||
}, [availableWidth]);
|
||||
|
||||
// audio and stats states
|
||||
|
||||
@@ -503,6 +654,19 @@ export default function DraggableGridLayout({
|
||||
onSaveMuting(true);
|
||||
};
|
||||
|
||||
// RGL's per-item constraint derives height from width, holding each tile at
|
||||
// its camera's aspect while resizing. Constraints are functions, so they live
|
||||
// only on this render copy; toPersisted strips them.
|
||||
const layoutWithConstraints = useMemo(() => {
|
||||
if (!currentGridLayout) {
|
||||
return [] as Layout;
|
||||
}
|
||||
return currentGridLayout.map((item) => ({
|
||||
...item,
|
||||
constraints: [aspectRatio(layoutAspects[item.i] ?? DEFAULT_ASPECT)],
|
||||
}));
|
||||
}, [currentGridLayout, layoutAspects]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
@@ -525,8 +689,8 @@ export default function DraggableGridLayout({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
|
||||
ref={gridContainerRef}
|
||||
className="no-scrollbar my-2 select-none overflow-x-hidden pb-8"
|
||||
ref={attachGridContainer}
|
||||
>
|
||||
<EditGroupDialog
|
||||
open={editGroup}
|
||||
@@ -536,28 +700,36 @@ export default function DraggableGridLayout({
|
||||
/>
|
||||
<Responsive
|
||||
className="grid-layout"
|
||||
width={availableWidth ?? window.innerWidth}
|
||||
width={availableWidth || window.innerWidth}
|
||||
layouts={{
|
||||
lg: currentGridLayout,
|
||||
md: currentGridLayout,
|
||||
sm: currentGridLayout,
|
||||
xs: currentGridLayout,
|
||||
xxs: currentGridLayout,
|
||||
lg: layoutWithConstraints,
|
||||
md: layoutWithConstraints,
|
||||
sm: layoutWithConstraints,
|
||||
xs: layoutWithConstraints,
|
||||
xxs: layoutWithConstraints,
|
||||
}}
|
||||
rowHeight={cellHeight}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
|
||||
margin={[marginValue, marginValue]}
|
||||
cols={{
|
||||
lg: GRID_COLS,
|
||||
md: GRID_COLS,
|
||||
sm: GRID_COLS,
|
||||
xs: GRID_COLS,
|
||||
xxs: GRID_COLS,
|
||||
}}
|
||||
margin={[0, 0]}
|
||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||
containerPadding={[0, isEditMode ? 6 : 3]}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
|
||||
// se only: top/left handles fight the aspect constraint at a grid
|
||||
// boundary (RGL re-clamps the opposite edge) and distort the tile.
|
||||
handles: isEditMode ? ["se"] : [],
|
||||
}}
|
||||
dragConfig={{
|
||||
enabled: isEditMode,
|
||||
}}
|
||||
onDragStop={handleLayoutChange}
|
||||
onResize={handleResize}
|
||||
onResizeStart={() => setShowCircles(false)}
|
||||
onResizeStop={handleLayoutChange}
|
||||
>
|
||||
@@ -570,22 +742,12 @@ export default function DraggableGridLayout({
|
||||
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||
)}
|
||||
birdseyeConfig={birdseyeConfig}
|
||||
aspectRatio={layoutAspects["birdseye"] ?? DEFAULT_ASPECT}
|
||||
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
||||
onClick={() => onSelectCamera("birdseye")}
|
||||
>
|
||||
{isEditMode && showCircles && <CornerCircles />}
|
||||
</BirdseyeLivePlayerGridItem>
|
||||
></BirdseyeLivePlayerGridItem>
|
||||
)}
|
||||
{cameras.map((camera) => {
|
||||
let grow;
|
||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||
grow = `aspect-wide w-full`;
|
||||
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
|
||||
grow = `aspect-tall h-full`;
|
||||
} else {
|
||||
grow = "aspect-video";
|
||||
}
|
||||
const availableStreams = camera.live.streams || {};
|
||||
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
||||
|
||||
@@ -614,7 +776,14 @@ export default function DraggableGridLayout({
|
||||
?.compatibilityMode || false;
|
||||
return (
|
||||
<GridLiveContextMenu
|
||||
className={grow}
|
||||
className={CARD_FIT}
|
||||
aspectRatio={
|
||||
(naturalAspectLayout
|
||||
? liveAspects[camera.name]
|
||||
: undefined) ??
|
||||
layoutAspects[camera.name] ??
|
||||
DEFAULT_ASPECT
|
||||
}
|
||||
key={camera.name}
|
||||
camera={camera.name}
|
||||
streamName={streamName}
|
||||
@@ -653,8 +822,8 @@ export default function DraggableGridLayout({
|
||||
useWebGL={useWebGL}
|
||||
cameraRef={cameraRef}
|
||||
className={cn(
|
||||
"rounded-lg bg-black md:rounded-2xl",
|
||||
grow,
|
||||
"size-full",
|
||||
naturalAspectLayout ? "bg-background" : "bg-black",
|
||||
isEditMode &&
|
||||
showCircles &&
|
||||
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||
@@ -675,8 +844,8 @@ export default function DraggableGridLayout({
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
playAudio={audioStates[camera.name]}
|
||||
volume={volumeStates[camera.name]}
|
||||
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
||||
/>
|
||||
{isEditMode && showCircles && <CornerCircles />}
|
||||
</GridLiveContextMenu>
|
||||
);
|
||||
})}
|
||||
@@ -694,6 +863,7 @@ export default function DraggableGridLayout({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
data-testid="toggle-edit-layout"
|
||||
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
||||
onClick={() =>
|
||||
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
||||
@@ -762,17 +932,6 @@ export default function DraggableGridLayout({
|
||||
);
|
||||
}
|
||||
|
||||
function CornerCircles() {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute left-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute right-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute bottom-[-4px] right-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute bottom-[-4px] left-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type BirdseyeLivePlayerGridItemProps = {
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
@@ -781,6 +940,7 @@ type BirdseyeLivePlayerGridItemProps = {
|
||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||
children?: React.ReactNode;
|
||||
birdseyeConfig: BirdseyeConfig;
|
||||
aspectRatio: number;
|
||||
liveMode: LivePlayerMode;
|
||||
onClick: () => void;
|
||||
};
|
||||
@@ -798,6 +958,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
onTouchEnd,
|
||||
children,
|
||||
birdseyeConfig,
|
||||
aspectRatio: cellAspect,
|
||||
liveMode,
|
||||
onClick,
|
||||
...props
|
||||
@@ -806,7 +967,8 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
style={{ ...style }}
|
||||
className="flex items-center justify-center p-1 [container-type:size]"
|
||||
style={{ ...style, "--ar": cellAspect } as React.CSSProperties}
|
||||
ref={ref}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
@@ -814,7 +976,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<BirdseyeLivePlayer
|
||||
className={className}
|
||||
className={cn(CARD_FIT, className)}
|
||||
birdseyeConfig={birdseyeConfig}
|
||||
liveMode={liveMode}
|
||||
onClick={onClick}
|
||||
@@ -829,6 +991,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
type GridLiveContextMenuProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
aspectRatio?: number;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||
@@ -860,6 +1023,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
{
|
||||
className,
|
||||
style,
|
||||
aspectRatio: cameraAspect,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onTouchEnd,
|
||||
@@ -887,7 +1051,8 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
style={{ ...style }}
|
||||
className="flex items-center justify-center p-1 [container-type:size]"
|
||||
style={{ ...style, "--ar": cameraAspect } as React.CSSProperties}
|
||||
ref={ref}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function LiveBirdseyeView({
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<BirdseyeLivePlayer
|
||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||
className={fullscreen ? "rounded-none" : ""}
|
||||
birdseyeConfig={config.birdseye}
|
||||
liveMode={preferredLiveMode}
|
||||
containerRef={containerRef}
|
||||
|
||||
@@ -13,8 +13,6 @@ import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import LivePlayer from "@/components/player/LivePlayer";
|
||||
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
|
||||
import LiveStreamSelect from "@/components/player/LiveStreamSelect";
|
||||
import { useAutoLiveStream } from "@/hooks/use-auto-live-stream";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import {
|
||||
@@ -29,13 +27,9 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import {
|
||||
useWebRTCAvailableForStream,
|
||||
useWebRTCGloballyAvailable,
|
||||
} from "@/hooks/use-webrtc-availability";
|
||||
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
LiveAutoReason,
|
||||
LivePlayerError,
|
||||
TwoWayTalkError,
|
||||
LivePlayerMode,
|
||||
@@ -44,7 +38,13 @@ import {
|
||||
WebRTCUnavailableReason,
|
||||
} from "@/types/live";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
isDesktop,
|
||||
isFirefox,
|
||||
@@ -98,6 +98,14 @@ import useSWR from "swr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSessionPersistence } from "@/hooks/use-session-persistence";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -108,7 +116,6 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
|
||||
import ObjectSettingsView from "../settings/ObjectSettingsView";
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
@@ -150,9 +157,11 @@ export default function LiveCameraView({
|
||||
|
||||
// supported features
|
||||
|
||||
// an absent saved stream means auto
|
||||
const [pinnedStream, setPinnedStream, streamNameLoaded, clearPinnedStream] =
|
||||
useUserPersistence<string>(`${camera.name}-stream`);
|
||||
const [streamName, setStreamName, streamNameLoaded] =
|
||||
useUserPersistence<string>(
|
||||
`${camera.name}-stream`,
|
||||
Object.values(camera.live.streams)[0],
|
||||
);
|
||||
|
||||
const [
|
||||
userPreferredLiveMode,
|
||||
@@ -171,67 +180,25 @@ export default function LiveCameraView({
|
||||
const preferencesLoaded =
|
||||
streamNameLoaded && userPreferredLiveModeLoaded && forceLowBandwidthLoaded;
|
||||
|
||||
const [mic, setMic] = useState(false);
|
||||
const [debug, setDebug] = useState(false);
|
||||
// error fallback latches, layered on top in preferredLiveMode
|
||||
const [webRTC, setWebRTC] = useState(false);
|
||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||
|
||||
const liveStreams = useMemo(
|
||||
() => Object.values(camera.live.streams || {}),
|
||||
[camera.live.streams],
|
||||
);
|
||||
const autoAvailable = liveStreams.length > 1;
|
||||
const autoSelected = autoAvailable && pinnedStream == undefined;
|
||||
|
||||
const autoStream = useAutoLiveStream({
|
||||
cameraName: camera.name,
|
||||
streams: liveStreams,
|
||||
selected: autoSelected,
|
||||
paused:
|
||||
!preferencesLoaded ||
|
||||
mic ||
|
||||
debug ||
|
||||
webRTC ||
|
||||
lowBandwidth ||
|
||||
(forceLowBandwidth ?? false),
|
||||
});
|
||||
const {
|
||||
handleError: autoHandleError,
|
||||
reset: resetAutoStream,
|
||||
onHealthSample,
|
||||
} = autoStream;
|
||||
|
||||
// the stream actually playing; everything downstream keys off it
|
||||
const streamName = autoSelected
|
||||
? autoStream.streamName
|
||||
: (pinnedStream ?? liveStreams[0]);
|
||||
|
||||
const selectStream = useCallback(
|
||||
(stream: string | undefined) => {
|
||||
if (stream == undefined) {
|
||||
clearPinnedStream();
|
||||
} else {
|
||||
setPinnedStream(stream);
|
||||
}
|
||||
},
|
||||
[clearPinnedStream, setPinnedStream],
|
||||
);
|
||||
|
||||
const isRestreamed = useMemo(
|
||||
() => isRestreamedStream(config, streamName),
|
||||
() =>
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
// a pin to a stream no longer in config falls back to auto
|
||||
// validate stored stream name and reset if now invalid
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamNameLoaded || pinnedStream == undefined) return;
|
||||
if (!streamNameLoaded) return;
|
||||
|
||||
if (liveStreams.length > 0 && !liveStreams.includes(pinnedStream)) {
|
||||
clearPinnedStream();
|
||||
const available = Object.values(camera.live.streams || {});
|
||||
if (available.length === 0) return;
|
||||
|
||||
if (streamName != null && !available.includes(streamName)) {
|
||||
setStreamName(available[0]);
|
||||
}
|
||||
}, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]);
|
||||
}, [streamNameLoaded, camera.live.streams, streamName, setStreamName]);
|
||||
|
||||
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
@@ -249,15 +216,16 @@ export default function LiveCameraView({
|
||||
);
|
||||
const isWebRTCAvailable = webRTCAvailability.available;
|
||||
|
||||
// Two-way talk is the sendonly backchannel: global, not per-stream.
|
||||
const { globallyAvailable: webRTCGloballyAvailable } =
|
||||
useWebRTCGloballyAvailable();
|
||||
|
||||
// "checking" means the probe has not answered yet, and it re-enters that on
|
||||
// every mount, so treating it as unavailable downgrades the saved choice.
|
||||
const webRTCVerdictPending = webRTCAvailability.reason === "checking";
|
||||
const webRTCUsable = isWebRTCAvailable || webRTCVerdictPending;
|
||||
|
||||
// Two-way talk is a sendonly backchannel, so playback audio that WebRTC
|
||||
// can't carry doesn't rule it out, but the stream's video must connect
|
||||
const talkAvailable =
|
||||
isWebRTCAvailable || webRTCAvailability.reason === "audio-codec";
|
||||
|
||||
// Resolves the saved preference without overwriting it. Transient error
|
||||
// fallbacks layer on top in preferredLiveMode.
|
||||
const resolvedUserMode = useMemo<LivePlayerMode>(() => {
|
||||
@@ -427,7 +395,18 @@ export default function LiveCameraView({
|
||||
// playback state
|
||||
|
||||
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
|
||||
const [mic, setMic] = useState(false);
|
||||
|
||||
// the mic only connects through the WebRTC player, so it can't stay on
|
||||
// for a stream that has lost it
|
||||
useEffect(() => {
|
||||
if (!talkAvailable) {
|
||||
setMic(false);
|
||||
}
|
||||
}, [talkAvailable]);
|
||||
const [webRTC, setWebRTC] = useState(false);
|
||||
const [pip, setPip] = useState(false);
|
||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||
|
||||
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
|
||||
`${camera.name}-background-play`,
|
||||
@@ -435,6 +414,7 @@ export default function LiveCameraView({
|
||||
);
|
||||
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const [debug, setDebug] = useState(false);
|
||||
|
||||
useSearchEffect("debug", (value: string) => {
|
||||
if (value === "true") {
|
||||
@@ -450,11 +430,11 @@ export default function LiveCameraView({
|
||||
});
|
||||
|
||||
const preferredLiveMode = useMemo(() => {
|
||||
if (mic && isWebRTCAvailable) {
|
||||
if (mic && talkAvailable) {
|
||||
return "webrtc";
|
||||
}
|
||||
|
||||
if (forceLowBandwidth || autoStream.atFloor) {
|
||||
if (forceLowBandwidth) {
|
||||
return "jsmpeg";
|
||||
}
|
||||
|
||||
@@ -482,11 +462,11 @@ export default function LiveCameraView({
|
||||
lowBandwidth,
|
||||
forceLowBandwidth,
|
||||
mic,
|
||||
talkAvailable,
|
||||
webRTC,
|
||||
isRestreamed,
|
||||
resolvedUserMode,
|
||||
isWebRTCAvailable,
|
||||
autoStream.atFloor,
|
||||
]);
|
||||
|
||||
// A latched error fallback would keep overriding the user's new choice.
|
||||
@@ -516,7 +496,7 @@ export default function LiveCameraView({
|
||||
}
|
||||
break;
|
||||
case "t":
|
||||
if (supports2WayTalk) {
|
||||
if (supports2WayTalk && talkAvailable) {
|
||||
setMic(!mic);
|
||||
return true;
|
||||
}
|
||||
@@ -613,11 +593,6 @@ export default function LiveCameraView({
|
||||
const handleError = useCallback(
|
||||
(e: LivePlayerError) => {
|
||||
if (e) {
|
||||
// auto answers congestion and codec failures with a lower stream
|
||||
if (autoHandleError(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// WebRTC can now be the user's own choice, so the fallback flag no
|
||||
// longer implies untried: hopping to the mode that just failed sticks.
|
||||
if (preferredLiveMode !== "webrtc" && webRTCUsable) {
|
||||
@@ -628,14 +603,9 @@ export default function LiveCameraView({
|
||||
}
|
||||
}
|
||||
},
|
||||
[autoHandleError, preferredLiveMode, webRTCUsable],
|
||||
[preferredLiveMode, webRTCUsable],
|
||||
);
|
||||
|
||||
const resetStream = useCallback(() => {
|
||||
setLowBandwidth(false);
|
||||
resetAutoStream();
|
||||
}, [resetAutoStream]);
|
||||
|
||||
const handleMicrophoneError = useCallback(
|
||||
(error: TwoWayTalkError) => {
|
||||
setMic(false);
|
||||
@@ -774,7 +744,11 @@ export default function LiveCameraView({
|
||||
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
|
||||
isActive={mic}
|
||||
title={
|
||||
!webRTCGloballyAvailable
|
||||
webRTCVerdictPending
|
||||
? t("stream.technology.unavailable.checking", {
|
||||
ns: "views/live",
|
||||
})
|
||||
: !talkAvailable
|
||||
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
|
||||
: mic
|
||||
? t("twoWayTalk.disable", { ns: "views/live" })
|
||||
@@ -786,7 +760,7 @@ export default function LiveCameraView({
|
||||
setAudio(true);
|
||||
}
|
||||
}}
|
||||
disabled={!cameraEnabled || debug || !webRTCGloballyAvailable}
|
||||
disabled={!cameraEnabled || debug || !talkAvailable}
|
||||
/>
|
||||
)}
|
||||
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
||||
@@ -813,11 +787,8 @@ export default function LiveCameraView({
|
||||
camera.audio_transcription.enabled_in_config
|
||||
}
|
||||
fullscreen={fullscreen}
|
||||
pinnedStream={pinnedStream}
|
||||
playingStream={streamName ?? ""}
|
||||
autoAvailable={autoAvailable}
|
||||
autoReason={autoStream.reason}
|
||||
onSelectStream={selectStream}
|
||||
streamName={streamName ?? ""}
|
||||
setStreamName={setStreamName}
|
||||
userPreferredLiveMode={resolvedUserMode}
|
||||
setUserPreferredLiveMode={setUserPreferredLiveMode}
|
||||
forceLowBandwidth={forceLowBandwidth ?? false}
|
||||
@@ -830,7 +801,7 @@ export default function LiveCameraView({
|
||||
isRestreamed={isRestreamed ?? false}
|
||||
isWebRTCAvailable={isWebRTCAvailable}
|
||||
webRTCUnavailableReason={webRTCAvailability.reason}
|
||||
onResetStream={resetStream}
|
||||
setLowBandwidth={setLowBandwidth}
|
||||
supportsAudioOutput={supportsAudioOutput}
|
||||
supports2WayTalk={supports2WayTalk}
|
||||
cameraEnabled={cameraEnabled}
|
||||
@@ -900,7 +871,7 @@ export default function LiveCameraView({
|
||||
)}
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||
className={fullscreen ? "rounded-none" : ""}
|
||||
windowVisible
|
||||
showStillWithoutActivity={false}
|
||||
alwaysShowCameraName={false}
|
||||
@@ -918,8 +889,6 @@ export default function LiveCameraView({
|
||||
containerRef={containerRef}
|
||||
setFullResolution={setFullResolution}
|
||||
onError={handleError}
|
||||
onHealthSample={autoSelected ? onHealthSample : undefined}
|
||||
streamAuto={autoSelected}
|
||||
onMicrophoneError={handleMicrophoneError}
|
||||
/>
|
||||
</div>
|
||||
@@ -973,11 +942,8 @@ type FrigateCameraFeaturesProps = {
|
||||
autotrackingEnabled: boolean;
|
||||
transcriptionEnabled: boolean;
|
||||
fullscreen: boolean;
|
||||
pinnedStream: string | undefined;
|
||||
playingStream: string;
|
||||
autoAvailable: boolean;
|
||||
autoReason?: LiveAutoReason;
|
||||
onSelectStream: (stream: string | undefined) => void;
|
||||
streamName: string;
|
||||
setStreamName?: (value: string | undefined) => void;
|
||||
userPreferredLiveMode: LivePlayerMode;
|
||||
setUserPreferredLiveMode: (value: LivePlayerMode | undefined) => void;
|
||||
forceLowBandwidth: boolean;
|
||||
@@ -990,7 +956,7 @@ type FrigateCameraFeaturesProps = {
|
||||
isRestreamed: boolean;
|
||||
isWebRTCAvailable: boolean;
|
||||
webRTCUnavailableReason?: WebRTCUnavailableReason;
|
||||
onResetStream: () => void;
|
||||
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
supportsAudioOutput: boolean;
|
||||
supports2WayTalk: boolean;
|
||||
cameraEnabled: boolean;
|
||||
@@ -1004,11 +970,8 @@ function FrigateCameraFeatures({
|
||||
autotrackingEnabled,
|
||||
transcriptionEnabled,
|
||||
fullscreen,
|
||||
pinnedStream,
|
||||
playingStream,
|
||||
autoAvailable,
|
||||
autoReason,
|
||||
onSelectStream,
|
||||
streamName,
|
||||
setStreamName,
|
||||
userPreferredLiveMode,
|
||||
setUserPreferredLiveMode,
|
||||
forceLowBandwidth,
|
||||
@@ -1021,7 +984,7 @@ function FrigateCameraFeatures({
|
||||
isRestreamed,
|
||||
isWebRTCAvailable,
|
||||
webRTCUnavailableReason,
|
||||
onResetStream,
|
||||
setLowBandwidth,
|
||||
supportsAudioOutput,
|
||||
supports2WayTalk,
|
||||
cameraEnabled,
|
||||
@@ -1053,6 +1016,10 @@ function FrigateCameraFeatures({
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const streamSelectLabel = Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
);
|
||||
|
||||
// manual event
|
||||
|
||||
const recordingEventIdRef = useRef<string | null>(null);
|
||||
@@ -1396,16 +1363,33 @@ function FrigateCameraFeatures({
|
||||
<Label htmlFor="streaming-method">
|
||||
{t("stream.title")}
|
||||
</Label>
|
||||
<LiveStreamSelect
|
||||
streams={camera.live.streams}
|
||||
pinnedStream={pinnedStream}
|
||||
playingStream={playingStream}
|
||||
autoAvailable={autoAvailable}
|
||||
autoReason={autoReason}
|
||||
onSelect={onSelectStream}
|
||||
onRetry={onResetStream}
|
||||
<Select
|
||||
value={streamName}
|
||||
disabled={debug || forceLowBandwidth}
|
||||
/>
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{streamSelectLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{debug && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
@@ -1526,7 +1510,7 @@ function FrigateCameraFeatures({
|
||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onResetStream}
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
@@ -1788,16 +1772,33 @@ function FrigateCameraFeatures({
|
||||
Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="mt-1 p-2">
|
||||
<div className="mb-1 text-sm">{t("stream.title")}</div>
|
||||
<LiveStreamSelect
|
||||
streams={camera.live.streams}
|
||||
pinnedStream={pinnedStream}
|
||||
playingStream={playingStream}
|
||||
autoAvailable={autoAvailable}
|
||||
autoReason={autoReason}
|
||||
onSelect={onSelectStream}
|
||||
onRetry={onResetStream}
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
disabled={debug || forceLowBandwidth}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{streamSelectLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{debug && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
@@ -1914,7 +1915,7 @@ function FrigateCameraFeatures({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={debug}
|
||||
onClick={onResetStream}
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
|
||||
@@ -298,10 +298,7 @@ export default function LiveDashboardView({
|
||||
(cameraName: string, error: LivePlayerError) => {
|
||||
setPreferredLiveModes((prevModes) => {
|
||||
const newModes = { ...prevModes };
|
||||
if (
|
||||
(error === "mse-decode" || error === "mse-codec") &&
|
||||
webRTCUsableStates[cameraName]
|
||||
) {
|
||||
if (error === "mse-decode" && webRTCUsableStates[cameraName]) {
|
||||
newModes[cameraName] = "webrtc";
|
||||
} else {
|
||||
newModes[cameraName] = "jsmpeg";
|
||||
@@ -413,7 +410,7 @@ export default function LiveDashboardView({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
|
||||
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 [scrollbar-gutter:stable] md:p-2"
|
||||
ref={containerRef}
|
||||
>
|
||||
{isMobile && (
|
||||
@@ -612,7 +609,7 @@ export default function LiveDashboardView({
|
||||
<LivePlayer
|
||||
cameraRef={cameraRef}
|
||||
key={camera.name}
|
||||
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
|
||||
className={`${grow} bg-black`}
|
||||
windowVisible={
|
||||
windowVisible && visibleCameras.includes(camera.name)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { DEFAULT_DRAWER_FEATURES, ExportMode } from "@/types/filter";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@@ -182,9 +183,19 @@ export function RecordingView({
|
||||
false,
|
||||
);
|
||||
|
||||
const [timelineType, setTimelineType] = useOverlayState<TimelineType>(
|
||||
const [savedTimelineType, setSavedTimelineType] =
|
||||
useUserPersistence<TimelineType>("recordingTimelineType");
|
||||
const [timelineType, setOverlayTimelineType] = useOverlayState<TimelineType>(
|
||||
"timelineType",
|
||||
recording?.timelineType ?? "timeline",
|
||||
recording?.timelineType ?? savedTimelineType ?? "timeline",
|
||||
);
|
||||
|
||||
const setTimelineType = useCallback(
|
||||
(value: TimelineType, replace?: boolean) => {
|
||||
setOverlayTimelineType(value, replace);
|
||||
setSavedTimelineType(value);
|
||||
},
|
||||
[setOverlayTimelineType, setSavedTimelineType],
|
||||
);
|
||||
|
||||
const chunkedTimeRange = useMemo(
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Button, buttonVariants } from "../../components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
useUserPersistence,
|
||||
deleteUserNamespacedKey,
|
||||
} from "@/hooks/use-user-persistence";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,6 +34,17 @@ import {
|
||||
CONTROL_COLUMN_CLASS_NAME,
|
||||
} from "@/components/card/SettingsGroupCard";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
|
||||
import {
|
||||
applyImportPayload,
|
||||
@@ -51,6 +63,8 @@ import {
|
||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
|
||||
|
||||
type ConfirmTarget = "layouts" | "streaming" | "naturalAspect";
|
||||
|
||||
type SwitchSettingRowProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -321,6 +335,10 @@ export default function UiSettingsView() {
|
||||
"displayCameraNames",
|
||||
false,
|
||||
);
|
||||
const [naturalAspect, setNaturalAspect] = useUserPersistence(
|
||||
"naturalAspectLayout",
|
||||
false,
|
||||
);
|
||||
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||
@@ -329,6 +347,66 @@ export default function UiSettingsView() {
|
||||
3,
|
||||
);
|
||||
|
||||
const [pendingConfirm, setPendingConfirm] = useState<ConfirmTarget | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const confirmCopy = useCallback(
|
||||
(target: ConfirmTarget) => {
|
||||
// literal keys per branch: a template key would be invisible to
|
||||
// npm run i18n:extract, which CI verifies
|
||||
switch (target) {
|
||||
case "layouts":
|
||||
return {
|
||||
title: t("general.storedLayouts.clearAll"),
|
||||
description: t("general.storedLayouts.clearConfirm"),
|
||||
action: t("button.clear", { ns: "common" }),
|
||||
};
|
||||
case "streaming":
|
||||
return {
|
||||
title: t("general.cameraGroupStreaming.clearAll"),
|
||||
description: t("general.cameraGroupStreaming.clearConfirm"),
|
||||
action: t("button.clear", { ns: "common" }),
|
||||
};
|
||||
case "naturalAspect":
|
||||
return {
|
||||
title: t("general.liveDashboard.naturalAspectLayout.label"),
|
||||
description: t(
|
||||
"general.liveDashboard.naturalAspectLayout.descNote",
|
||||
),
|
||||
action: naturalAspect
|
||||
? t("button.disable", { ns: "common" })
|
||||
: t("button.enable", { ns: "common" }),
|
||||
};
|
||||
}
|
||||
},
|
||||
[naturalAspect, t],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
switch (pendingConfirm) {
|
||||
case "layouts":
|
||||
clearStoredLayouts();
|
||||
break;
|
||||
case "streaming":
|
||||
clearStreamingSettings();
|
||||
break;
|
||||
case "naturalAspect":
|
||||
// a layout only renders in the mode that built it
|
||||
setNaturalAspect(!naturalAspect);
|
||||
clearStoredLayouts();
|
||||
break;
|
||||
}
|
||||
|
||||
setPendingConfirm(null);
|
||||
}, [
|
||||
pendingConfirm,
|
||||
clearStoredLayouts,
|
||||
clearStreamingSettings,
|
||||
naturalAspect,
|
||||
setNaturalAspect,
|
||||
]);
|
||||
|
||||
const liveDashboardSwitchRows = [
|
||||
{
|
||||
id: "auto-live",
|
||||
@@ -351,6 +429,18 @@ export default function UiSettingsView() {
|
||||
checked: cameraNames,
|
||||
onCheckedChange: setCameraName,
|
||||
},
|
||||
// phones use the static grid, so tile sizing has nothing to affect there
|
||||
...(isMobileOnly
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "natural-aspect",
|
||||
label: t("general.liveDashboard.naturalAspectLayout.label"),
|
||||
description: t("general.liveDashboard.naturalAspectLayout.desc"),
|
||||
checked: naturalAspect,
|
||||
onCheckedChange: () => setPendingConfirm("naturalAspect"),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -419,7 +509,7 @@ export default function UiSettingsView() {
|
||||
id="stored-layouts-clear"
|
||||
aria-label={t("general.storedLayouts.clearAll")}
|
||||
className="w-full md:w-auto"
|
||||
onClick={clearStoredLayouts}
|
||||
onClick={() => setPendingConfirm("layouts")}
|
||||
>
|
||||
{t("general.storedLayouts.clearAll")}
|
||||
</Button>
|
||||
@@ -435,7 +525,7 @@ export default function UiSettingsView() {
|
||||
id="camera-group-streaming-clear"
|
||||
aria-label={t("general.cameraGroupStreaming.clearAll")}
|
||||
className="w-full md:w-auto"
|
||||
onClick={clearStreamingSettings}
|
||||
onClick={() => setPendingConfirm("streaming")}
|
||||
>
|
||||
{t("general.cameraGroupStreaming.clearAll")}
|
||||
</Button>
|
||||
@@ -569,9 +659,41 @@ export default function UiSettingsView() {
|
||||
fileName={pendingImport.name}
|
||||
file={pendingImport.file}
|
||||
summary={pendingImport.summary}
|
||||
currentNaturalAspect={naturalAspect ?? false}
|
||||
onConfirm={handleImportConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={pendingConfirm != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingConfirm(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingConfirm && confirmCopy(pendingConfirm).title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingConfirm && confirmCopy(pendingConfirm).description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{pendingConfirm && confirmCopy(pendingConfirm).action}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user