From 1471e7ce78bc86cbbb01a4d7d5e4495251d82b97 Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 21 Jul 2026 22:56:14 +0800 Subject: [PATCH] feat(v1): configurable low-latency capture options Config gains source.transport (tcp/udp), timeout_seconds and low_latency; build_ffmpeg_options assembles OPENCV_FFMPEG_CAPTURE_OPTIONS and app.py sets it into the environment before opening the stream, so tuning lives in config/settings instead of a launch script. Settings tab exposes a dropdown/number/checkbox rather than the raw option string. 64 tests pass. Co-Authored-By: Claude Opus 4.8 --- docs/06-tasks.md | 2 +- docs/api.md | 1 + docs/current-state.md | 6 ++--- progress.md | 9 +++++++ v1/app.py | 5 +++- v1/config.example.json | 5 +++- v1/config.py | 51 ++++++++++++++++++++++++++++++++++++++ v1/gui.py | 17 +++++++++++++ v1/tests/test_config.py | 54 ++++++++++++++++++++++++++++++++++++++++- 9 files changed, 143 insertions(+), 7 deletions(-) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 945c294..62519dd 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -31,7 +31,7 @@ | T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | BLOCKED | | T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | BLOCKED | | T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE | -| T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DOING | +| T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DONE | ## Phase 3 · Go V2 迁移门槛与实现 diff --git a/docs/api.md b/docs/api.md index 91ba667..21c1501 100644 --- a/docs/api.md +++ b/docs/api.md @@ -35,6 +35,7 @@ - 来源二选一:`rtsp_url_env`(从同名环境变量读取真实 URL)**或**结构化字段 `host`/`port`(默认 554)/`channel`(默认 `101`,`102` 为子码流)/`username`/`password`;两者都会在内存中解析出 RTSP URL。结构化凭证只允许出现在**未跟踪的** `config.local.json`;公开示例 `config.example.json` 只能用 `rtsp_url_env`,禁止任何 `host`/`username`/`password`/完整 URL(有测试守卫)。 - 结构化模式下应用用 `build_rtsp_url` 拼接,并对用户名/密码做 RFC 3986 百分号编码,因此密码含 `@`、`:`、`/` 也不会破坏 URL 解析。 - `source.mode` 显式声明来源类型,取值 `stream`(默认,实时 RTSP,允许有界重连)或 `replay`(本地录像,EOF 不重放);由配置决定,不再按 URL 前缀猜测。 +- 抓流低延迟调优(非凭证,可进公开示例):`source.transport`(`tcp` 默认 / `udp`)、`source.timeout_seconds`(默认 5)、`source.low_latency`(默认 true)。应用用 `build_ffmpeg_options` 拼成 `OPENCV_FFMPEG_CAPTURE_OPTIONS`,并在打开流之前写入 `os.environ`(OpenCV 在打开时读取该环境变量);因此无需再靠外部脚本设置该变量。 - 凭证(`host`/`username`/`password`/解析出的 URL)绝不进入 `runtime_config_version`、事件 JSONL、日志或截图文件名。 - `model.confidence_threshold` 是模型检测置信度,可在设置草稿中调整,并在下次开始监控时经 `PoseAdapter.set_confidence_threshold` 真正生效。 - 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。 diff --git a/docs/current-state.md b/docs/current-state.md index d5d6a01..17db122 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,13 +5,13 @@ ## 当前快照 - 日期:2026-07-21 -- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件与结构化摄像头配置/连接测试已建立;T-202、T-206 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。 +- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件、结构化摄像头配置/连接测试与抓流低延迟参数已建立;T-202、T-206、T-207 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。 - 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。 - 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。 -- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。 +- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。 - V2 代码:`v2/` 目录存在但尚无实现。 - 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。 -- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 60 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件和摄像头/URL 构建测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 +- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 64 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建和抓流参数拼串测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 - 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。 - 当前标准启动:`./init.ps1`。 - 当前标准验证:`python -m compileall -q demo`。 diff --git a/progress.md b/progress.md index 9e35b30..0cd9ec8 100644 --- a/progress.md +++ b/progress.md @@ -271,3 +271,12 @@ - 阻塞:设置页下拉/数字/勾选控件需 Windows + PyQt5 冒烟;配置解析与 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 拼串在本环境可真实验证。 - 决策:`OPENCV_FFMPEG_CAPTURE_OPTIONS` 是 OpenCV 在打开流时读的环境变量,因此由 config 存值、app 在开流前写 `os.environ` 注入;不向操作员暴露原始字符串,只暴露 transport(tcp/udp)/连接超时(秒)/低延迟(勾选) 三个友好控件,程序内部拼串。这些不是凭证,可进公开示例默认值;启动可不再依赖 run 脚本设环境变量。 - 下一步:先写 `build_ffmpeg_options` 拼串与配置解析的失败测试,再实现;随后加设置页控件与保存。 + +## 【2026-07-21】T-207 抓流低延迟参数进配置与设置页(完成) + +- 状态:DONE +- 变更:`config.py` 新增 `build_ffmpeg_options(transport, timeout_seconds, low_latency)`(拼 `rtsp_transport;..|stimeout;..(微秒)|fflags;nobuffer|flags;low_delay`),`AppConfig` 增 `transport`/`timeout_seconds`/`low_latency` 字段与 `ffmpeg_capture_options` 属性;`load_config` 解析并校验三者(默认 tcp/5/true);`write_local_camera_source` 一并写回。`app.py` 的 `FrameWorker.run` 在打开流之前把 `ffmpeg_capture_options` 写进 `os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"]`。`gui.py` 设置页摄像头分组新增「传输协议」下拉、「连接超时」数字、「低延迟」勾选,保存时一并落盘;`config.example.json` 加入三项安全默认值。 +- 验证:新增 config 四项测试(`build_ffmpeg_options` 三种组合与非法值、调优默认值、显式解析、非法 transport 被拒);`python3 -m pytest v1/tests -q` 为 64 passed;`python3 -m compileall -q v1 demo` 退出码 0。以现有 `v1/config.local.json` 端到端加载确认 `transport=tcp/timeout=5/low_latency=True`、`ffmpeg_capture_options=rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay`。 +- 阻塞:设置页三个新控件需 Windows + PyQt5 冒烟;拼串、配置解析与 env 注入逻辑本环境已验证。 +- 决策:不向操作员暴露原始 ffmpeg 字符串,只给 transport/超时/低延迟三个友好控件,程序内部拼串;由 config 存值、app 在开流前注入环境变量;这些非凭证,进入公开示例默认值。启动可不再依赖 `run_v1.ps1` 设 `OPENCV_FFMPEG_CAPTURE_OPTIONS`。 +- 下一步:Windows 上冒烟设置页三控件与实际低延迟效果;继续 T-203 现场 GUI 验收。 diff --git a/v1/app.py b/v1/app.py index 8d0bf09..aea3112 100644 --- a/v1/app.py +++ b/v1/app.py @@ -35,6 +35,8 @@ class FrameWorker(QtCore.QThread): self._stop = False def run(self) -> None: + # OpenCV reads this env var when it opens the stream, so set it first. + os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = self._config.ffmpeg_capture_options mode = SourceMode.STREAM if self._config.source_mode == "stream" else SourceMode.REPLAY source = VideoSource(self._config.source_url, mode=mode) pipeline = FallPipeline.from_config(self._config, self._pose_adapter) @@ -162,7 +164,8 @@ def _camera_fields(path: Path) -> Dict: return {} if "host" not in source: return {} - return {k: source.get(k) for k in ("host", "port", "channel", "username", "password")} + fields = ("host", "port", "channel", "username", "password", "transport", "timeout_seconds", "low_latency") + return {k: source.get(k) for k in fields if k in source} if __name__ == "__main__": diff --git a/v1/config.example.json b/v1/config.example.json index 83f2b84..b3a5c24 100644 --- a/v1/config.example.json +++ b/v1/config.example.json @@ -2,7 +2,10 @@ "source": { "id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL", - "mode": "stream" + "mode": "stream", + "transport": "tcp", + "timeout_seconds": 5.0, + "low_latency": true }, "model": { "path": "models/best.pt", diff --git a/v1/config.py b/v1/config.py index 5444ca4..9d343e2 100644 --- a/v1/config.py +++ b/v1/config.py @@ -39,6 +39,29 @@ def build_rtsp_url( return "rtsp://{0}:{1}@{2}:{3}{4}".format(user, secret, host, int(port), path) +def build_ffmpeg_options( + transport: str = "tcp", + timeout_seconds: float = 5.0, + low_latency: bool = True, +) -> str: + """Build the OpenCV FFmpeg capture options string from friendly settings. + + OpenCV reads ``OPENCV_FFMPEG_CAPTURE_OPTIONS`` when the stream is opened, so + the caller must set this into the environment before ``cv2.VideoCapture``. + Format is ``key;value`` pairs joined by ``|``. + """ + + if transport not in ("tcp", "udp"): + raise ValueError("transport must be 'tcp' or 'udp'") + parts = ["rtsp_transport;{0}".format(transport)] + if timeout_seconds and float(timeout_seconds) > 0: + parts.append("stimeout;{0}".format(int(float(timeout_seconds) * 1_000_000))) + if low_latency: + parts.append("fflags;nobuffer") + parts.append("flags;low_delay") + return "|".join(parts) + + @dataclass(frozen=True) class EventConfig: keypoint_confidence_threshold: float @@ -58,6 +81,15 @@ class AppConfig: event: EventConfig event_dir: Path source_mode: str = "stream" + transport: str = "tcp" + timeout_seconds: float = 5.0 + low_latency: bool = True + + @property + def ffmpeg_capture_options(self) -> str: + """OpenCV FFmpeg options string to set before opening the stream.""" + + return build_ffmpeg_options(self.transport, self.timeout_seconds, self.low_latency) @property def runtime_config_version(self) -> str: @@ -123,6 +155,9 @@ def write_local_camera_source( username: str, password: str, mode: str = "stream", + transport: str = "tcp", + timeout_seconds: float = 5.0, + low_latency: bool = True, ) -> Path: """Persist a structured camera source into an untracked local config file. @@ -141,6 +176,9 @@ def write_local_camera_source( "username": str(username), "password": str(password), "mode": mode, + "transport": transport, + "timeout_seconds": float(timeout_seconds), + "low_latency": bool(low_latency), } path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return path @@ -189,6 +227,16 @@ def load_config(path: Path) -> AppConfig: if source_mode not in ("replay", "stream"): raise ConfigError("source.mode must be 'replay' or 'stream'") + transport = str(source.get("transport", "tcp")).strip().lower() + if transport not in ("tcp", "udp"): + raise ConfigError("source.transport must be 'tcp' or 'udp'") + timeout_seconds = _number( + source.get("timeout_seconds", 5.0), "source.timeout_seconds", 0.0, 60.0 + ) + low_latency = source.get("low_latency", True) + if not isinstance(low_latency, bool): + raise ConfigError("source.low_latency must be a boolean") + model = _mapping(root.get("model"), "model") model_sha256 = _text(model.get("sha256"), "model.sha256").lower() if not _SHA256.match(model_sha256): @@ -240,4 +288,7 @@ def load_config(path: Path) -> AppConfig: event=event, event_dir=_resolve_path(config_path, artifacts.get("event_dir"), "artifacts.event_dir"), source_mode=source_mode, + transport=transport, + timeout_seconds=timeout_seconds, + low_latency=low_latency, ) diff --git a/v1/gui.py b/v1/gui.py index f23d4b2..8f4646e 100644 --- a/v1/gui.py +++ b/v1/gui.py @@ -314,11 +314,25 @@ class SettingsTab(QtWidgets.QWidget): self.user_edit = QtWidgets.QLineEdit(str(camera.get("username", ""))) self.password_edit = QtWidgets.QLineEdit(str(camera.get("password", ""))) self.password_edit.setEchoMode(QtWidgets.QLineEdit.Password) + self.transport_combo = QtWidgets.QComboBox() + self.transport_combo.addItem("TCP", "tcp") + self.transport_combo.addItem("UDP", "udp") + self.transport_combo.setCurrentIndex(1 if str(camera.get("transport", "tcp")) == "udp" else 0) + self.timeout_spin = QtWidgets.QDoubleSpinBox() + self.timeout_spin.setRange(0.0, 60.0) + self.timeout_spin.setSingleStep(1.0) + self.timeout_spin.setDecimals(1) + self.timeout_spin.setValue(float(camera.get("timeout_seconds", 5.0))) + self.low_latency_check = QtWidgets.QCheckBox("低延迟(丢弃缓冲)") + self.low_latency_check.setChecked(bool(camera.get("low_latency", True))) form.addRow("IP / 主机", self.host_edit) form.addRow("端口", self.port_spin) form.addRow("通道", self.channel_combo) form.addRow("账号", self.user_edit) form.addRow("密码", self.password_edit) + form.addRow("传输协议", self.transport_combo) + form.addRow("连接超时(秒)", self.timeout_spin) + form.addRow("", self.low_latency_check) self.test_button = QtWidgets.QPushButton("测试连接") self.save_camera_button = QtWidgets.QPushButton("保存连接到本地配置") @@ -386,6 +400,9 @@ class SettingsTab(QtWidgets.QWidget): channel=self.channel_combo.currentData(), username=self.user_edit.text().strip(), password=self.password_edit.text(), + transport=self.transport_combo.currentData(), + timeout_seconds=self.timeout_spin.value(), + low_latency=self.low_latency_check.isChecked(), ) except Exception as exc: # noqa: BLE001 - surface any write error to the UI self.camera_status.setText("保存失败:{0}".format(exc)) diff --git a/v1/tests/test_config.py b/v1/tests/test_config.py index 0eda890..9c3fc79 100644 --- a/v1/tests/test_config.py +++ b/v1/tests/test_config.py @@ -2,7 +2,7 @@ import json import pytest -from v1.config import ConfigError, load_config +from v1.config import ConfigError, build_ffmpeg_options, load_config def _write_config(path, source): @@ -140,6 +140,58 @@ def test_public_example_config_has_no_embedded_credentials(): assert "rtsp_url_env" in source +def test_build_ffmpeg_options_variants(): + assert build_ffmpeg_options() == "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay" + assert build_ffmpeg_options("udp", 3, False) == "rtsp_transport;udp|stimeout;3000000" + with pytest.raises(ValueError): + build_ffmpeg_options("http") + + +def test_source_capture_tuning_defaults_to_tcp_low_latency(tmp_path): + config_file = tmp_path / "config.json" + _write_config( + config_file, + {"id": "c", "host": "192.0.2.10", "username": "u", "password": "p"}, + ) + + config = load_config(config_file) + + assert config.transport == "tcp" + assert config.timeout_seconds == 5.0 + assert config.low_latency is True + assert ( + config.ffmpeg_capture_options + == "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay" + ) + + +def test_source_capture_tuning_is_parsed(tmp_path): + config_file = tmp_path / "config.json" + _write_config( + config_file, + { + "id": "c", "host": "192.0.2.10", "username": "u", "password": "p", + "transport": "udp", "timeout_seconds": 3, "low_latency": False, + }, + ) + + config = load_config(config_file) + + assert config.transport == "udp" + assert config.ffmpeg_capture_options == "rtsp_transport;udp|stimeout;3000000" + + +def test_invalid_transport_is_rejected(tmp_path): + config_file = tmp_path / "config.json" + _write_config( + config_file, + {"id": "c", "host": "192.0.2.10", "username": "u", "password": "p", "transport": "http"}, + ) + + with pytest.raises(ConfigError, match="transport"): + load_config(config_file) + + def test_source_mode_defaults_to_stream(tmp_path, monkeypatch): config_file = tmp_path / "config.json" _write_config(