toposync-ext-streaming 0.8.0__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/.gitignore +6 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/PKG-INFO +2 -2
- toposync_ext_streaming-0.10.0/hatch_build.py +36 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/pyproject.toml +4 -1
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/api/models.py +1 -0
- toposync_ext_streaming-0.10.0/src/toposync_ext_streaming/api/playback_plan.py +362 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/api/routes.py +488 -846
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/pipelines/operators.py +70 -11
- toposync_ext_streaming-0.10.0/src/toposync_ext_streaming/static/387.js +1 -0
- toposync_ext_streaming-0.10.0/src/toposync_ext_streaming/static/703.js +2 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/main.js +1 -1
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/distributed_sync.py +12 -52
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/encoder_state.py +11 -1
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/engine_manager.py +1 -9
- toposync_ext_streaming-0.10.0/src/toposync_ext_streaming/streaming/http_client.py +124 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/ingest_resolver.py +10 -59
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/mediamtx_api_client.py +34 -27
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/mediamtx_config.py +5 -1
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/publisher_manager.py +11 -1
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/runtime_state.py +103 -111
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/writer_bridge.py +13 -22
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/wizard/pipeline_builder.py +51 -23
- toposync_ext_streaming-0.10.0/ui/package.json +11 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/api/streamingApi.ts +4 -38
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/settings/StreamingSettingsPanel.tsx +169 -210
- toposync_ext_streaming-0.8.0/src/toposync_ext_streaming/static/387.js +0 -1
- toposync_ext_streaming-0.8.0/src/toposync_ext_streaming/static/703.js +0 -2
- toposync_ext_streaming-0.8.0/ui/package.json +0 -22
- toposync_ext_streaming-0.8.0/ui/webpack.config.js +0 -42
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/LICENSE +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/LICENSE.ffmpeg +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/LICENSE.mediamtx +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/README.md +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/__init__.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/api/__init__.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/bin/ffmpeg/LICENSE +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/bin/mediamtx/LICENSE +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/extension.json +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/pipelines/__init__.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/plugin.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/326.js +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/326.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/4.js +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/4.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/623.js +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/623.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/703.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/main.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/static/remoteEntry.js +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/__init__.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/arbitration.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/camera_ingest.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/ffmpeg_binary.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/go2rtc_binary.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/go2rtc_config.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/go2rtc_manager.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/ingest_auth.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/jsmpeg_manager.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/mediamtx_binary.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/mediamtx_processes.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/placeholder.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/platform.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/playback_events.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/streaming/resize.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/src/toposync_ext_streaming/wizard/__init__.py +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/activate.tsx +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/constants.ts +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/entry.ts +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/settings/SubModal.tsx +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/settings/WizardCreatePipelineFromTransmission.tsx +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/translations.ts +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/src/types.ts +0 -0
- {toposync_ext_streaming-0.8.0 → toposync_ext_streaming-0.10.0}/ui/tsconfig.json +0 -0
|
@@ -17,6 +17,8 @@ build/
|
|
|
17
17
|
.parcel-cache/
|
|
18
18
|
|
|
19
19
|
.toposync-data/
|
|
20
|
+
.toposync-validation-*/
|
|
21
|
+
.toposync-physical-validation-*/
|
|
20
22
|
.toposync-processor-data
|
|
21
23
|
toposync-data/
|
|
22
24
|
|
|
@@ -37,5 +39,9 @@ extensions/streaming/src/toposync_ext_streaming/bin/ffmpeg/**/ffmpeg*
|
|
|
37
39
|
# Vision model artifacts are provisioned locally and must not be committed.
|
|
38
40
|
extensions/vision/models/
|
|
39
41
|
|
|
42
|
+
# Extension UI bundles are generated by npm run build:extension-ui/build:extensions.
|
|
43
|
+
extensions/*/src/**/static/*.js
|
|
44
|
+
extensions/*/src/**/static/*.js.LICENSE.txt
|
|
45
|
+
|
|
40
46
|
*.temporary.*
|
|
41
47
|
.playwright-mcp/*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: toposync-ext-streaming
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Toposync first-party extension: streaming settings, API surface, and pipeline sink bootstrap.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CustomBuildHook(BuildHookInterface):
|
|
12
|
+
def initialize(self, version: str, build_data: dict[str, object]) -> None:
|
|
13
|
+
extension_root = Path(self.root)
|
|
14
|
+
project = tomllib.loads((extension_root / "pyproject.toml").read_text(encoding="utf-8"))[
|
|
15
|
+
"project"
|
|
16
|
+
]
|
|
17
|
+
extension_name = project["name"].removeprefix("toposync-ext-").replace("-", "_")
|
|
18
|
+
ui_package = extension_root / "ui" / "package.json"
|
|
19
|
+
static_entry = (
|
|
20
|
+
extension_root / "src" / f"toposync_ext_{extension_name}" / "static" / "remoteEntry.js"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
npm = shutil.which("npm")
|
|
24
|
+
repo_root = extension_root.parent.parent
|
|
25
|
+
if npm is not None and ui_package.is_file() and (repo_root / "package.json").is_file():
|
|
26
|
+
subprocess.run(
|
|
27
|
+
[npm, "run", "build:extension-ui", "--", extension_name],
|
|
28
|
+
cwd=repo_root,
|
|
29
|
+
check=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if not static_entry.is_file():
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
f"Missing extension UI bundle for {extension_name}. "
|
|
35
|
+
f"Run `npm run build:extension-ui -- {extension_name}` before building the wheel."
|
|
36
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "toposync-ext-streaming"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.0"
|
|
4
4
|
description = "Toposync first-party extension: streaming settings, API surface, and pipeline sink bootstrap."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -15,5 +15,8 @@ streaming = "toposync_ext_streaming.plugin:StreamingExtension"
|
|
|
15
15
|
requires = ["hatchling>=1.25"]
|
|
16
16
|
build-backend = "hatchling.build"
|
|
17
17
|
|
|
18
|
+
[tool.hatch.build.hooks.custom]
|
|
19
|
+
path = "hatch_build.py"
|
|
20
|
+
|
|
18
21
|
[tool.hatch.build.targets.wheel]
|
|
19
22
|
packages = ["src/toposync_ext_streaming"]
|
|
@@ -1257,6 +1257,7 @@ class CameraLiveViewPlaybackResponse(BaseModel):
|
|
|
1257
1257
|
camera_source_id: str = ""
|
|
1258
1258
|
camera_source_name: str = ""
|
|
1259
1259
|
source_role: str | None = None
|
|
1260
|
+
optical_source_resolution: Resolution | None = None
|
|
1260
1261
|
transmission: Transmission
|
|
1261
1262
|
urls: TransmissionUrlsResponse
|
|
1262
1263
|
playback_plan: StreamingPlaybackPlanResponse | None = None
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from .models import (
|
|
7
|
+
StreamingCameraLiveContext,
|
|
8
|
+
StreamingPlaybackClientKind,
|
|
9
|
+
StreamingPlaybackPlanResponse,
|
|
10
|
+
StreamingPlaybackPlanTransport,
|
|
11
|
+
StreamingRuntimeTransmissionHealth,
|
|
12
|
+
TransmissionOutputUrl,
|
|
13
|
+
TransmissionUrlsResponse,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_webrtc_contract_message(message: str) -> bool:
|
|
18
|
+
lowered = str(message or "").lower()
|
|
19
|
+
return "webrtc" in lowered or "whep" in lowered or re.search(r"\bice\b", lowered) is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _playback_plan_transport_from_output(
|
|
23
|
+
*,
|
|
24
|
+
transport: Literal["webrtc", "hls", "mse", "jsmpeg"],
|
|
25
|
+
rank: int,
|
|
26
|
+
output: TransmissionOutputUrl | None,
|
|
27
|
+
available: bool,
|
|
28
|
+
blocking_errors: list[str] | None = None,
|
|
29
|
+
warnings: list[str] | None = None,
|
|
30
|
+
health: dict[str, Any] | None = None,
|
|
31
|
+
) -> StreamingPlaybackPlanTransport:
|
|
32
|
+
return StreamingPlaybackPlanTransport(
|
|
33
|
+
transport=transport,
|
|
34
|
+
rank=rank,
|
|
35
|
+
available=bool(available and output is not None),
|
|
36
|
+
output_id=output.output_id if output is not None else None,
|
|
37
|
+
protocol=output.protocol if output is not None else transport,
|
|
38
|
+
url=output.url if output is not None else None,
|
|
39
|
+
media_auth_type=output.media_auth_type if output is not None else "none",
|
|
40
|
+
requires_auth=bool(output.requires_auth) if output is not None else False,
|
|
41
|
+
quality_profile_id=output.quality_profile_id if output is not None else None,
|
|
42
|
+
resolution=output.resolution if output is not None else None,
|
|
43
|
+
fps_limit=output.fps_limit if output is not None else None,
|
|
44
|
+
bitrate_kbps=output.bitrate_kbps if output is not None else None,
|
|
45
|
+
latency_profile=output.latency_profile if output is not None else None,
|
|
46
|
+
blocking_errors=list(blocking_errors or []),
|
|
47
|
+
warnings=list(warnings or []),
|
|
48
|
+
health=dict(health or {}),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _best_hls_output(
|
|
53
|
+
*,
|
|
54
|
+
urls: TransmissionUrlsResponse,
|
|
55
|
+
quality_profile_id: str | None = None,
|
|
56
|
+
) -> TransmissionOutputUrl | None:
|
|
57
|
+
requested_profile_id = str(quality_profile_id or "").strip()
|
|
58
|
+
candidates = [item for item in urls.outputs if item.protocol == "hls"]
|
|
59
|
+
if not candidates:
|
|
60
|
+
return None
|
|
61
|
+
if requested_profile_id:
|
|
62
|
+
for item in candidates:
|
|
63
|
+
if item.quality_profile_id == requested_profile_id:
|
|
64
|
+
return item
|
|
65
|
+
for preferred in ("stable_apple_tv", "quad_grid"):
|
|
66
|
+
for item in candidates:
|
|
67
|
+
if item.quality_profile_id == preferred:
|
|
68
|
+
return item
|
|
69
|
+
return candidates[0]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _best_webrtc_output(*, urls: TransmissionUrlsResponse) -> TransmissionOutputUrl | None:
|
|
73
|
+
return next((item for item in urls.outputs if item.protocol == "webrtc"), None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def select_webrtc_output(*, urls: TransmissionUrlsResponse) -> TransmissionOutputUrl | None:
|
|
77
|
+
return _best_webrtc_output(urls=urls)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _best_mse_output(
|
|
81
|
+
*,
|
|
82
|
+
urls: TransmissionUrlsResponse,
|
|
83
|
+
quality_profile_id: str | None = None,
|
|
84
|
+
) -> TransmissionOutputUrl | None:
|
|
85
|
+
requested_profile_id = str(quality_profile_id or "").strip()
|
|
86
|
+
candidates = [item for item in urls.outputs if item.protocol == "mse"]
|
|
87
|
+
if not candidates:
|
|
88
|
+
return None
|
|
89
|
+
if requested_profile_id:
|
|
90
|
+
for item in candidates:
|
|
91
|
+
if item.quality_profile_id == requested_profile_id:
|
|
92
|
+
return item
|
|
93
|
+
for preferred in ("stable_apple_tv", "quad_grid", "fullscreen_quality"):
|
|
94
|
+
for item in candidates:
|
|
95
|
+
if item.quality_profile_id == preferred:
|
|
96
|
+
return item
|
|
97
|
+
return candidates[0]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _best_jsmpeg_output(
|
|
101
|
+
*,
|
|
102
|
+
urls: TransmissionUrlsResponse,
|
|
103
|
+
quality_profile_id: str | None = None,
|
|
104
|
+
) -> TransmissionOutputUrl | None:
|
|
105
|
+
requested_profile_id = str(quality_profile_id or "").strip()
|
|
106
|
+
candidates = [item for item in urls.outputs if item.protocol == "jsmpeg"]
|
|
107
|
+
if not candidates:
|
|
108
|
+
return None
|
|
109
|
+
if requested_profile_id:
|
|
110
|
+
for item in candidates:
|
|
111
|
+
if item.quality_profile_id == requested_profile_id:
|
|
112
|
+
return item
|
|
113
|
+
for preferred in ("diagnostic_low", "quad_grid", "stable_apple_tv", "fullscreen_quality"):
|
|
114
|
+
for item in candidates:
|
|
115
|
+
if item.quality_profile_id == preferred:
|
|
116
|
+
return item
|
|
117
|
+
return candidates[0]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _runtime_health_for_playback_plan(
|
|
121
|
+
runtime_health: StreamingRuntimeTransmissionHealth | None,
|
|
122
|
+
output_id: str | None,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
if runtime_health is None:
|
|
125
|
+
return {}
|
|
126
|
+
health: dict[str, Any] = {
|
|
127
|
+
"status": runtime_health.status,
|
|
128
|
+
"selected_frame_age_seconds": runtime_health.selected_frame_age_seconds,
|
|
129
|
+
"last_incoming_frame_age_seconds": runtime_health.last_incoming_frame_age_seconds,
|
|
130
|
+
"fallback_active": runtime_health.fallback_active,
|
|
131
|
+
"fallback_reason": runtime_health.fallback_reason,
|
|
132
|
+
"stale": runtime_health.stale,
|
|
133
|
+
"placeholder_active": runtime_health.placeholder_active,
|
|
134
|
+
}
|
|
135
|
+
if output_id:
|
|
136
|
+
output = next((item for item in runtime_health.outputs if item.output_id == output_id), None)
|
|
137
|
+
if output is not None:
|
|
138
|
+
health.update(
|
|
139
|
+
{
|
|
140
|
+
"transport_health": output.status,
|
|
141
|
+
"viewer_count": output.viewer_count,
|
|
142
|
+
"publisher_running": output.publisher_running,
|
|
143
|
+
"publisher_frames_sent": output.publisher_frames_sent,
|
|
144
|
+
"publisher_last_error": output.publisher_last_error,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
return health
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_playback_plan_response(
|
|
151
|
+
*,
|
|
152
|
+
transmission_id: str,
|
|
153
|
+
client: StreamingPlaybackClientKind,
|
|
154
|
+
urls: TransmissionUrlsResponse,
|
|
155
|
+
runtime_health: StreamingRuntimeTransmissionHealth | None = None,
|
|
156
|
+
quality_profile_id: str | None = None,
|
|
157
|
+
visual_context: StreamingCameraLiveContext | None = None,
|
|
158
|
+
transmission_role: str | None = None,
|
|
159
|
+
low_latency_requested: bool = False,
|
|
160
|
+
) -> StreamingPlaybackPlanResponse:
|
|
161
|
+
contract = urls.network_contract
|
|
162
|
+
home_assistant_proxy_hls = (
|
|
163
|
+
contract is not None
|
|
164
|
+
and (
|
|
165
|
+
contract.environment == "home_assistant_addon"
|
|
166
|
+
or contract.public_base_path.startswith("/api/hassio_ingress/")
|
|
167
|
+
)
|
|
168
|
+
and contract.public_hls_mode == "proxy"
|
|
169
|
+
)
|
|
170
|
+
hls_output = _best_hls_output(urls=urls, quality_profile_id=quality_profile_id)
|
|
171
|
+
webrtc_output = _best_webrtc_output(urls=urls)
|
|
172
|
+
mse_output = _best_mse_output(urls=urls, quality_profile_id=quality_profile_id)
|
|
173
|
+
jsmpeg_output = _best_jsmpeg_output(urls=urls, quality_profile_id=quality_profile_id)
|
|
174
|
+
|
|
175
|
+
hls_blocking: list[str] = []
|
|
176
|
+
if hls_output is None:
|
|
177
|
+
hls_blocking.append("No HLS output is available.")
|
|
178
|
+
|
|
179
|
+
webrtc_blocking: list[str] = []
|
|
180
|
+
if webrtc_output is None:
|
|
181
|
+
webrtc_blocking.append(
|
|
182
|
+
"This transmission has no WebRTC/WHEP output. Enable low-latency playback on a zoom/PTZ publication or set transport_policy.enable_webrtc=true."
|
|
183
|
+
)
|
|
184
|
+
if client == "ha_ingress":
|
|
185
|
+
webrtc_blocking.append(
|
|
186
|
+
"Home Assistant ingress must use the Home Assistant native camera path for Cloud/WebRTC relay; direct Toposync WebRTC is disabled by default."
|
|
187
|
+
)
|
|
188
|
+
if client == "ha_entity":
|
|
189
|
+
webrtc_blocking.append(
|
|
190
|
+
"Home Assistant entity playback is negotiated by the Home Assistant camera platform."
|
|
191
|
+
)
|
|
192
|
+
web_webrtc_contextual = (
|
|
193
|
+
client == "web"
|
|
194
|
+
and (
|
|
195
|
+
bool(low_latency_requested)
|
|
196
|
+
or str(visual_context or "").strip().lower() == "ptz"
|
|
197
|
+
or str(transmission_role or "").strip().lower() == "zoom"
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
if client == "web" and not web_webrtc_contextual:
|
|
201
|
+
webrtc_blocking.append("WebRTC is reserved for explicit low-latency or PTZ playback.")
|
|
202
|
+
if urls.network_contract is not None:
|
|
203
|
+
for message in urls.network_contract.blocking_errors:
|
|
204
|
+
if is_webrtc_contract_message(message):
|
|
205
|
+
webrtc_blocking.append(message)
|
|
206
|
+
for message in urls.webrtc_warnings:
|
|
207
|
+
if message not in webrtc_blocking:
|
|
208
|
+
webrtc_blocking.append(message)
|
|
209
|
+
|
|
210
|
+
mse_blocking: list[str] = []
|
|
211
|
+
if not urls.engine_running:
|
|
212
|
+
mse_blocking.append("MediaMTX engine is not running; MSE needs the internal RTSP output.")
|
|
213
|
+
if hls_output is None:
|
|
214
|
+
mse_blocking.append("No HLS backing output is available for MSE.")
|
|
215
|
+
if mse_output is None:
|
|
216
|
+
sidecar_warning = next(
|
|
217
|
+
(
|
|
218
|
+
message
|
|
219
|
+
for message in urls.warnings
|
|
220
|
+
if "mse" in message.lower() or "go2rtc" in message.lower()
|
|
221
|
+
),
|
|
222
|
+
"",
|
|
223
|
+
)
|
|
224
|
+
mse_blocking.append(sidecar_warning or "MSE is not available for this output.")
|
|
225
|
+
jsmpeg_blocking: list[str] = []
|
|
226
|
+
if hls_output is None:
|
|
227
|
+
jsmpeg_blocking.append("No HLS backing output is available for JSMpeg.")
|
|
228
|
+
if jsmpeg_output is None:
|
|
229
|
+
jsmpeg_hint = next(
|
|
230
|
+
(
|
|
231
|
+
message
|
|
232
|
+
for message in urls.warnings
|
|
233
|
+
if "jsmpeg" in message.lower() or "ffmpeg" in message.lower()
|
|
234
|
+
),
|
|
235
|
+
"",
|
|
236
|
+
)
|
|
237
|
+
jsmpeg_blocking.append(
|
|
238
|
+
jsmpeg_hint
|
|
239
|
+
or "JSMpeg fallback is unavailable. Check /api/streams/jsmpeg/status for FFmpeg and session limits."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if client == "ha_entity":
|
|
243
|
+
return StreamingPlaybackPlanResponse(
|
|
244
|
+
transmission_id=transmission_id,
|
|
245
|
+
client=client,
|
|
246
|
+
transports=[],
|
|
247
|
+
selected_transport=None,
|
|
248
|
+
warnings=[
|
|
249
|
+
*list(urls.warnings),
|
|
250
|
+
"Home Assistant entity playback uses the Home Assistant camera contract from /api/streams/home-assistant/cameras.",
|
|
251
|
+
],
|
|
252
|
+
hls_warnings=list(urls.hls_warnings),
|
|
253
|
+
webrtc_warnings=list(urls.webrtc_warnings),
|
|
254
|
+
blocking_errors=list(urls.blocking_errors),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if client in {"app", "ha_ingress"} or home_assistant_proxy_hls:
|
|
258
|
+
order: list[Literal["hls", "mse", "webrtc", "jsmpeg"]] = ["hls", "mse", "jsmpeg", "webrtc"]
|
|
259
|
+
elif web_webrtc_contextual:
|
|
260
|
+
order = ["webrtc", "mse", "hls", "jsmpeg"]
|
|
261
|
+
else:
|
|
262
|
+
order = ["mse", "hls", "jsmpeg", "webrtc"]
|
|
263
|
+
|
|
264
|
+
transports: list[StreamingPlaybackPlanTransport] = []
|
|
265
|
+
for rank, transport in enumerate(order):
|
|
266
|
+
if transport == "hls":
|
|
267
|
+
transports.append(
|
|
268
|
+
_playback_plan_transport_from_output(
|
|
269
|
+
transport="hls",
|
|
270
|
+
rank=rank,
|
|
271
|
+
output=hls_output,
|
|
272
|
+
available=hls_output is not None and not hls_blocking,
|
|
273
|
+
blocking_errors=hls_blocking,
|
|
274
|
+
warnings=list(urls.hls_warnings),
|
|
275
|
+
health=_runtime_health_for_playback_plan(runtime_health, hls_output.output_id if hls_output else None),
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
elif transport == "webrtc":
|
|
279
|
+
warnings = [
|
|
280
|
+
*list(urls.webrtc_warnings),
|
|
281
|
+
*(
|
|
282
|
+
["WebRTC is reserved for Home Assistant native camera/WebRTC relay in HA ingress mode."]
|
|
283
|
+
if client == "ha_ingress"
|
|
284
|
+
else ["WebRTC is reserved for low-latency/PTZ in Home Assistant proxy mode."]
|
|
285
|
+
if home_assistant_proxy_hls
|
|
286
|
+
else []
|
|
287
|
+
),
|
|
288
|
+
]
|
|
289
|
+
transports.append(
|
|
290
|
+
_playback_plan_transport_from_output(
|
|
291
|
+
transport="webrtc",
|
|
292
|
+
rank=rank,
|
|
293
|
+
output=webrtc_output,
|
|
294
|
+
available=webrtc_output is not None and not webrtc_blocking and client not in {"app", "ha_ingress"},
|
|
295
|
+
blocking_errors=webrtc_blocking if client not in {"app"} else [*webrtc_blocking, "Native app playback uses HLS first."],
|
|
296
|
+
warnings=warnings,
|
|
297
|
+
health=_runtime_health_for_playback_plan(runtime_health, webrtc_output.output_id if webrtc_output else None),
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
elif transport == "mse":
|
|
301
|
+
if mse_output is not None:
|
|
302
|
+
transports.append(
|
|
303
|
+
_playback_plan_transport_from_output(
|
|
304
|
+
transport="mse",
|
|
305
|
+
rank=rank,
|
|
306
|
+
output=mse_output,
|
|
307
|
+
available=not mse_blocking,
|
|
308
|
+
blocking_errors=mse_blocking,
|
|
309
|
+
health=_runtime_health_for_playback_plan(runtime_health, mse_output.output_id),
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
transports.append(
|
|
314
|
+
StreamingPlaybackPlanTransport(
|
|
315
|
+
transport="mse",
|
|
316
|
+
rank=rank,
|
|
317
|
+
available=False,
|
|
318
|
+
protocol="mse",
|
|
319
|
+
blocking_errors=mse_blocking,
|
|
320
|
+
health=_runtime_health_for_playback_plan(runtime_health, None),
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
elif transport == "jsmpeg":
|
|
324
|
+
if jsmpeg_output is not None:
|
|
325
|
+
transports.append(
|
|
326
|
+
_playback_plan_transport_from_output(
|
|
327
|
+
transport="jsmpeg",
|
|
328
|
+
rank=rank,
|
|
329
|
+
output=jsmpeg_output,
|
|
330
|
+
available=not jsmpeg_blocking,
|
|
331
|
+
blocking_errors=jsmpeg_blocking,
|
|
332
|
+
health=_runtime_health_for_playback_plan(runtime_health, jsmpeg_output.output_id),
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
transports.append(
|
|
337
|
+
StreamingPlaybackPlanTransport(
|
|
338
|
+
transport="jsmpeg",
|
|
339
|
+
rank=rank,
|
|
340
|
+
available=False,
|
|
341
|
+
protocol="jsmpeg",
|
|
342
|
+
blocking_errors=jsmpeg_blocking,
|
|
343
|
+
health=_runtime_health_for_playback_plan(runtime_health, None),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
selected = next((item.transport for item in transports if item.available), None)
|
|
348
|
+
plan_warnings = list(urls.warnings)
|
|
349
|
+
if home_assistant_proxy_hls:
|
|
350
|
+
plan_warnings.append("Home Assistant proxy mode prefers signed HLS for stable playback.")
|
|
351
|
+
if client == "ha_ingress":
|
|
352
|
+
plan_warnings.append("Home Assistant ingress prefers HLS; use HA camera entities for Home Assistant Cloud/WebRTC relay.")
|
|
353
|
+
return StreamingPlaybackPlanResponse(
|
|
354
|
+
transmission_id=transmission_id,
|
|
355
|
+
client=client,
|
|
356
|
+
transports=transports,
|
|
357
|
+
selected_transport=selected,
|
|
358
|
+
warnings=plan_warnings,
|
|
359
|
+
hls_warnings=list(urls.hls_warnings),
|
|
360
|
+
webrtc_warnings=list(urls.webrtc_warnings),
|
|
361
|
+
blocking_errors=list(urls.blocking_errors),
|
|
362
|
+
)
|