diff --git a/apps/worker/video_processing/pip_engine.py b/apps/worker/video_processing/pip_engine.py index bc231b2cb..535dddbc0 100755 --- a/apps/worker/video_processing/pip_engine.py +++ b/apps/worker/video_processing/pip_engine.py @@ -14,174 +14,33 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field from pathlib import Path -from typing import Any -logger = logging.getLogger(__name__) - - -# ── 位置常量 ────────────────────────────────────────────────────────────────── - -# 9宫格位置枚举 -POSITION_TOP_LEFT = "top_left" -POSITION_TOP_CENTER = "top_center" -POSITION_TOP_RIGHT = "top_right" -POSITION_CENTER_LEFT = "center_left" -POSITION_CENTER = "center" -POSITION_CENTER_RIGHT = "center_right" -POSITION_BOTTOM_LEFT = "bottom_left" -POSITION_BOTTOM_CENTER = "bottom_center" -POSITION_BOTTOM_RIGHT = "bottom_right" - -_VALID_POSITIONS = { - POSITION_TOP_LEFT, - POSITION_TOP_CENTER, - POSITION_TOP_RIGHT, - POSITION_CENTER_LEFT, - POSITION_CENTER, - POSITION_CENTER_RIGHT, - POSITION_BOTTOM_LEFT, - POSITION_BOTTOM_CENTER, - POSITION_BOTTOM_RIGHT, -} - -# 动画类型 -ANIMATION_FADE = "fade" # 淡入淡出 -ANIMATION_SLIDE_LEFT = "slide_left" # 从左滑入 -ANIMATION_SLIDE_RIGHT = "slide_right" # 从右滑入 -ANIMATION_SLIDE_TOP = "slide_top" # 从上滑入 -ANIMATION_SLIDE_BOTTOM = "slide_bottom" # 从下滑入 - -_VALID_ANIMATIONS = { +from packages.domain.pip_config import ( # noqa: F401 — 向后兼容:保留模块级导出 ANIMATION_FADE, + ANIMATION_SCALE, + ANIMATION_SLIDE_BOTTOM, ANIMATION_SLIDE_LEFT, ANIMATION_SLIDE_RIGHT, ANIMATION_SLIDE_TOP, - ANIMATION_SLIDE_BOTTOM, -} + PiPConfig, + PiPLayerConfig, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_RIGHT, + POSITION_CENTER, + POSITION_CENTER_LEFT, + POSITION_CENTER_RIGHT, + POSITION_TOP_CENTER, + POSITION_TOP_LEFT, + POSITION_TOP_RIGHT, + calculate_pip_position as _calculate_pip_position_base, + parse_size_value as _parse_size_value_base, +) +# 向后兼容:POSITION_BOTTOM_CENTER 也从 pip_config 再导出 +from packages.domain.pip_config import POSITION_BOTTOM_CENTER # noqa: E402, F401 -# ── 数据模型 ────────────────────────────────────────────────────────────────── - - -@dataclass -class PiPLayerConfig: - """单个画中画图层配置.""" - - # 素材来源 - source: str = "" # 素材ID或视频URL - source_type: str = "asset_id" # "asset_id" | "url" | "local_path" - - # 位置配置 - position: str = POSITION_BOTTOM_RIGHT # 9宫格位置或 "custom" - x: int | str = 0 # 自定义x坐标(像素或百分比如 "30%") - y: int | str = 0 # 自定义y坐标 - margin: int = 20 # 9宫格模式下的边距(像素) - - # 大小配置 - width: int | str = "25%" # 宽度(像素或百分比) - height: int | str = "" # 高度(空则按比例自适应) - - # 样式 - opacity: float = 1.0 # 透明度 0.0-1.0 - corner_radius: int = 0 # 圆角半径(像素),0表示无圆角 - border_width: int = 0 # 边框宽度 - border_color: str = "white" # 边框颜色 - - # 时间控制 - start_time: float = 0.0 # 开始显示时间(秒) - duration: float = 0.0 # 持续时长(秒),0表示全程显示 - - # 动画 - animation_in: str = "" # 入场动画类型 - animation_out: str = "" # 出场动画类型 - animation_duration: float = 0.5 # 动画时长(秒) - - # 层级 - z_index: int = 1 # 图层顺序,数字越大越在上层 - - def validate(self) -> tuple[bool, str]: - """校验配置合法性,返回 (是否合法, 错误信息).""" - if not self.source: - return False, "source不能为空" - - if self.position != "custom" and self.position not in _VALID_POSITIONS: - return False, f"无效的position: {self.position}" - - if self.opacity < 0 or self.opacity > 1: - return False, "opacity必须在0-1之间" - - if self.corner_radius < 0: - return False, "corner_radius不能为负数" - - if self.start_time < 0: - return False, "start_time不能为负数" - - if self.duration < 0: - return False, "duration不能为负数" - - if self.animation_in and self.animation_in not in _VALID_ANIMATIONS: - return False, f"无效的入场动画: {self.animation_in}" - - if self.animation_out and self.animation_out not in _VALID_ANIMATIONS: - return False, f"无效的出场动画: {self.animation_out}" - - if self.animation_duration < 0: - return False, "animation_duration不能为负数" - - return True, "" - - -@dataclass -class PiPConfig: - """画中画整体配置.""" - - enabled: bool = False - layers: list[PiPLayerConfig] = field(default_factory=list) - - @classmethod - def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig": - """从字典解析配置.""" - if not data or not data.get("enabled", False): - return cls(enabled=False) - - layers_data = data.get("layers", []) - layers = [] - for layer_data in layers_data: - try: - layer = PiPLayerConfig( - source=layer_data.get("source", ""), - source_type=layer_data.get("source_type", "asset_id"), - position=layer_data.get("position", POSITION_BOTTOM_RIGHT), - x=layer_data.get("x", 0), - y=layer_data.get("y", 0), - margin=int(layer_data.get("margin", 20)), - width=layer_data.get("width", "25%"), - height=layer_data.get("height", ""), - opacity=float(layer_data.get("opacity", 1.0)), - corner_radius=int(layer_data.get("corner_radius", 0)), - border_width=int(layer_data.get("border_width", 0)), - border_color=layer_data.get("border_color", "white"), - start_time=float(layer_data.get("start_time", 0.0)), - duration=float(layer_data.get("duration", 0.0)), - animation_in=layer_data.get("animation_in", ""), - animation_out=layer_data.get("animation_out", ""), - animation_duration=float(layer_data.get("animation_duration", 0.5)), - z_index=int(layer_data.get("z_index", 1)), - ) - valid, err = layer.validate() - if valid: - layers.append(layer) - else: - logger.warning("PiP图层配置无效,跳过: %s", err) - except (ValueError, TypeError) as e: - logger.warning("PiP图层解析失败,跳过: %s", e) - - # 按 z_index 排序 - layers.sort(key=lambda layer: layer.z_index) - - return cls(enabled=bool(layers), layers=layers) +logger = logging.getLogger(__name__) # ── PiP 引擎 ────────────────────────────────────────────────────────────────── @@ -201,16 +60,12 @@ class PiPEngine: self.output_fps = output_fps def _parse_size(self, value: int | str, base: int) -> int: - """解析尺寸值(像素或百分比).""" - if isinstance(value, int): - return max(1, value) - if isinstance(value, str) and value.endswith("%"): - pct = float(value.rstrip("%")) / 100.0 - return max(1, int(base * pct)) - try: - return max(1, int(value)) - except (ValueError, TypeError): - return int(base * 0.25) # 默认25% + """解析尺寸值(像素或百分比). + + 委托给 packages.domain.pip_config.parse_size_value 纯逻辑函数, + 薄包装保留在类内以维持向后兼容。 + """ + return _parse_size_value_base(value, base) def _parse_position( self, @@ -218,28 +73,21 @@ class PiPEngine: pip_width: int, pip_height: int, ) -> tuple[int, int]: - """计算画中画的实际位置 (x, y).""" - W = self.output_width - H = self.output_height - m = layer.margin + """计算画中画的实际位置 (x, y). - if layer.position == "custom": - x = self._parse_size(layer.x, W) - y = self._parse_size(layer.y, H) - return (x, y) - - pos_map = { - POSITION_TOP_LEFT: (m, m), - POSITION_TOP_CENTER: ((W - pip_width) // 2, m), - POSITION_TOP_RIGHT: (W - pip_width - m, m), - POSITION_CENTER_LEFT: (m, (H - pip_height) // 2), - POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2), - POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2), - POSITION_BOTTOM_LEFT: (m, H - pip_height - m), - POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m), - POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m), - } - return pos_map.get(layer.position, pos_map[POSITION_BOTTOM_RIGHT]) + 委托给 packages.domain.pip_config.calculate_pip_position 纯逻辑函数, + 薄包装保留在类内以维持向后兼容。 + """ + return _calculate_pip_position_base( + position=layer.position, + output_width=self.output_width, + output_height=self.output_height, + pip_width=pip_width, + pip_height=pip_height, + margin=layer.margin, + custom_x=layer.x, + custom_y=layer.y, + ) def _build_pip_pre_filter( self, diff --git a/packages/domain/pip_config.py b/packages/domain/pip_config.py new file mode 100755 index 000000000..e843504cb --- /dev/null +++ b/packages/domain/pip_config.py @@ -0,0 +1,265 @@ +"""画中画(PiP)配置领域模型 — 纯逻辑,无外部依赖. + +抽离自 pip_engine.py 的数据类和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + + +# ── 位置常量 ────────────────────────────────────────────────────────────────── + +POSITION_TOP_LEFT = "top_left" +POSITION_TOP_CENTER = "top_center" +POSITION_TOP_RIGHT = "top_right" +POSITION_CENTER_LEFT = "center_left" +POSITION_CENTER = "center" +POSITION_CENTER_RIGHT = "center_right" +POSITION_BOTTOM_LEFT = "bottom_left" +POSITION_BOTTOM_CENTER = "bottom_center" +POSITION_BOTTOM_RIGHT = "bottom_right" + +_VALID_POSITIONS = { + POSITION_TOP_LEFT, + POSITION_TOP_CENTER, + POSITION_TOP_RIGHT, + POSITION_CENTER_LEFT, + POSITION_CENTER, + POSITION_CENTER_RIGHT, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_CENTER, + POSITION_BOTTOM_RIGHT, +} + +# 动画类型 +ANIMATION_FADE = "fade" +ANIMATION_SLIDE_LEFT = "slide_left" +ANIMATION_SLIDE_RIGHT = "slide_right" +ANIMATION_SLIDE_TOP = "slide_top" +ANIMATION_SLIDE_BOTTOM = "slide_bottom" +ANIMATION_SCALE = "scale" + +_VALID_ANIMATIONS = { + ANIMATION_FADE, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SCALE, +} + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class PiPLayerConfig: + """单个画中画图层配置.""" + + # 素材来源 + source: str = "" + source_type: str = "asset_id" # "asset_id" | "url" | "local_path" + + # 位置配置 + position: str = POSITION_BOTTOM_RIGHT + x: int | str = 0 + y: int | str = 0 + margin: int = 20 + + # 大小配置 + width: int | str = "25%" + height: int | str = "" # 空则按比例自适应 + + # 样式 + opacity: float = 1.0 + corner_radius: int = 0 + border_width: int = 0 + border_color: str = "white" + + # 时间控制 + start_time: float = 0.0 + duration: float = 0.0 # 0表示全程显示 + + # 动画 + animation_in: str = "" + animation_out: str = "" + animation_duration: float = 0.5 + + # 层级 + z_index: int = 1 + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if not self.source: + return False, "source不能为空" + + if self.position != "custom" and self.position not in _VALID_POSITIONS: + return False, f"无效的position: {self.position}" + + if self.opacity < 0 or self.opacity > 1: + return False, "opacity必须在0-1之间" + + if self.corner_radius < 0: + return False, "corner_radius不能为负数" + + if self.start_time < 0: + return False, "start_time不能为负数" + + if self.duration < 0: + return False, "duration不能为负数" + + if self.animation_in and self.animation_in not in _VALID_ANIMATIONS: + return False, f"无效的入场动画: {self.animation_in}" + + if self.animation_out and self.animation_out not in _VALID_ANIMATIONS: + return False, f"无效的出场动画: {self.animation_out}" + + if self.animation_duration < 0: + return False, "animation_duration不能为负数" + + return True, "" + + +@dataclass +class PiPConfig: + """画中画整体配置.""" + + enabled: bool = False + layers: list[PiPLayerConfig] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig": + """从字典解析配置.""" + if not data or not data.get("enabled", False): + return cls(enabled=False) + + layers_data = data.get("layers", []) + layers: list[PiPLayerConfig] = [] + for layer_data in layers_data: + try: + layer = PiPLayerConfig( + source=layer_data.get("source", ""), + source_type=layer_data.get("source_type", "asset_id"), + position=layer_data.get("position", POSITION_BOTTOM_RIGHT), + x=layer_data.get("x", 0), + y=layer_data.get("y", 0), + margin=int(layer_data.get("margin", 20)), + width=layer_data.get("width", "25%"), + height=layer_data.get("height", ""), + opacity=float(layer_data.get("opacity", 1.0)), + corner_radius=int(layer_data.get("corner_radius", 0)), + border_width=int(layer_data.get("border_width", 0)), + border_color=layer_data.get("border_color", "white"), + start_time=float(layer_data.get("start_time", 0.0)), + duration=float(layer_data.get("duration", 0.0)), + animation_in=layer_data.get("animation_in", ""), + animation_out=layer_data.get("animation_out", ""), + animation_duration=float(layer_data.get("animation_duration", 0.5)), + z_index=int(layer_data.get("z_index", 1)), + ) + valid, err = layer.validate() + if valid: + layers.append(layer) + else: + logger.warning("PiP图层配置无效,跳过: %s", err) + except (ValueError, TypeError) as e: + logger.warning("PiP图层解析失败,跳过: %s", e) + + # 按 z_index 排序 + layers.sort(key=lambda layer: layer.z_index) + + return cls(enabled=bool(layers), layers=layers) + + @property + def layer_count(self) -> int: + """有效图层数量.""" + return len(self.layers) + + @property + def max_z_index(self) -> int: + """最大 z_index.""" + if not self.layers: + return 0 + return max(l.z_index for l in self.layers) + + +# ── 纯逻辑工具函数 ─────────────────────────────────────────────────────────── + + +def parse_size_value(value: int | str, base: int, default_pct: float = 0.25) -> int: + """解析尺寸值(像素或百分比). + + Args: + value: 尺寸值,int(像素)或 str(如 "30%") + base: 基准尺寸(用于百分比计算) + default_pct: 解析失败时的默认百分比 + + Returns: + 像素尺寸,>= 1 + """ + if isinstance(value, int): + return max(1, value) + if isinstance(value, str) and value.endswith("%"): + try: + pct = float(value.rstrip("%")) / 100.0 + return max(1, int(base * pct)) + except (ValueError, TypeError): + return max(1, int(base * default_pct)) + try: + return max(1, int(value)) + except (ValueError, TypeError): + return max(1, int(base * default_pct)) + + +def calculate_pip_position( + position: str, + output_width: int, + output_height: int, + pip_width: int, + pip_height: int, + margin: int = 20, + custom_x: int | str = 0, + custom_y: int | str = 0, +) -> tuple[int, int]: + """计算画中画的实际像素位置 (x, y). + + Args: + position: 9宫格位置或 "custom" + output_width: 画布宽度 + output_height: 画布高度 + pip_width: 画中画宽度 + pip_height: 画中画高度 + margin: 9宫格边距 + custom_x: 自定义x(position=custom时有效) + custom_y: 自定义y(position=custom时有效) + + Returns: + (x, y) 像素坐标 + """ + W = output_width + H = output_height + m = margin + + if position == "custom": + x = parse_size_value(custom_x, W) + y = parse_size_value(custom_y, H) + return (x, y) + + pos_map = { + POSITION_TOP_LEFT: (m, m), + POSITION_TOP_CENTER: ((W - pip_width) // 2, m), + POSITION_TOP_RIGHT: (W - pip_width - m, m), + POSITION_CENTER_LEFT: (m, (H - pip_height) // 2), + POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2), + POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2), + POSITION_BOTTOM_LEFT: (m, H - pip_height - m), + POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m), + POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m), + } + return pos_map.get(position, pos_map[POSITION_BOTTOM_RIGHT]) diff --git a/tests/unit/test_pip_config.py b/tests/unit/test_pip_config.py new file mode 100755 index 000000000..6249b0e33 --- /dev/null +++ b/tests/unit/test_pip_config.py @@ -0,0 +1,489 @@ +"""pip_config 模块单测 — 纯逻辑,无外部依赖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.pip_config import ( + ANIMATION_FADE, + ANIMATION_SCALE, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_RIGHT, + POSITION_CENTER, + POSITION_CENTER_LEFT, + POSITION_CENTER_RIGHT, + POSITION_TOP_CENTER, + POSITION_TOP_LEFT, + POSITION_TOP_RIGHT, + PiPConfig, + PiPLayerConfig, + calculate_pip_position, + parse_size_value, +) + +# ── PiPLayerConfig 默认值 ──────────────────────────────────────────────────── + + +class TestPiPLayerConfigDefaults: + def test_default_values(self): + cfg = PiPLayerConfig() + assert cfg.source == "" + assert cfg.source_type == "asset_id" + assert cfg.position == POSITION_BOTTOM_RIGHT + assert cfg.x == 0 + assert cfg.y == 0 + assert cfg.margin == 20 + assert cfg.width == "25%" + assert cfg.height == "" + assert cfg.opacity == 1.0 + assert cfg.corner_radius == 0 + assert cfg.border_width == 0 + assert cfg.border_color == "white" + assert cfg.start_time == 0.0 + assert cfg.duration == 0.0 + assert cfg.animation_in == "" + assert cfg.animation_out == "" + assert cfg.animation_duration == 0.5 + assert cfg.z_index == 1 + + +# ── PiPLayerConfig.validate ────────────────────────────────────────────────── + + +class TestPiPLayerConfigValidate: + def test_valid_config(self): + cfg = PiPLayerConfig(source="asset_123") + ok, err = cfg.validate() + assert ok is True + assert err == "" + + def test_empty_source_invalid(self): + cfg = PiPLayerConfig(source="") + ok, err = cfg.validate() + assert ok is False + assert "source" in err + + def test_invalid_position(self): + cfg = PiPLayerConfig(source="a", position="invalid_pos") + ok, err = cfg.validate() + assert ok is False + assert "position" in err + + def test_custom_position_valid(self): + cfg = PiPLayerConfig(source="a", position="custom", x=10, y=20) + ok, err = cfg.validate() + assert ok is True + + def test_opacity_too_low(self): + cfg = PiPLayerConfig(source="a", opacity=-0.1) + ok, err = cfg.validate() + assert ok is False + assert "opacity" in err + + def test_opacity_too_high(self): + cfg = PiPLayerConfig(source="a", opacity=1.5) + ok, err = cfg.validate() + assert ok is False + assert "opacity" in err + + def test_opacity_boundary_zero(self): + cfg = PiPLayerConfig(source="a", opacity=0.0) + ok, _ = cfg.validate() + assert ok is True + + def test_opacity_boundary_one(self): + cfg = PiPLayerConfig(source="a", opacity=1.0) + ok, _ = cfg.validate() + assert ok is True + + def test_negative_corner_radius(self): + cfg = PiPLayerConfig(source="a", corner_radius=-5) + ok, err = cfg.validate() + assert ok is False + assert "corner_radius" in err + + def test_negative_start_time(self): + cfg = PiPLayerConfig(source="a", start_time=-1.0) + ok, err = cfg.validate() + assert ok is False + assert "start_time" in err + + def test_negative_duration(self): + cfg = PiPLayerConfig(source="a", duration=-2.0) + ok, err = cfg.validate() + assert ok is False + assert "duration" in err + + def test_zero_duration_valid(self): + cfg = PiPLayerConfig(source="a", duration=0.0) + ok, _ = cfg.validate() + assert ok is True + + def test_invalid_animation_in(self): + cfg = PiPLayerConfig(source="a", animation_in="invalid") + ok, err = cfg.validate() + assert ok is False + assert "入场动画" in err + + def test_invalid_animation_out(self): + cfg = PiPLayerConfig(source="a", animation_out="invalid") + ok, err = cfg.validate() + assert ok is False + assert "出场动画" in err + + def test_valid_animation_fade(self): + cfg = PiPLayerConfig(source="a", animation_in=ANIMATION_FADE, animation_out=ANIMATION_FADE) + ok, _ = cfg.validate() + assert ok is True + + def test_valid_animation_slide(self): + cfg = PiPLayerConfig( + source="a", + animation_in=ANIMATION_SLIDE_LEFT, + animation_out=ANIMATION_SLIDE_RIGHT, + ) + ok, _ = cfg.validate() + assert ok is True + + def test_valid_animation_scale(self): + cfg = PiPLayerConfig(source="a", animation_in=ANIMATION_SCALE) + ok, _ = cfg.validate() + assert ok is True + + def test_empty_animation_valid(self): + cfg = PiPLayerConfig(source="a", animation_in="", animation_out="") + ok, _ = cfg.validate() + assert ok is True + + def test_negative_animation_duration(self): + cfg = PiPLayerConfig(source="a", animation_duration=-0.5) + ok, err = cfg.validate() + assert ok is False + assert "animation_duration" in err + + +# ── PiPConfig.from_dict ────────────────────────────────────────────────────── + + +class TestPiPConfigFromDict: + def test_none_data_disabled(self): + cfg = PiPConfig.from_dict(None) + assert cfg.enabled is False + assert cfg.layers == [] + + def test_empty_dict_disabled(self): + cfg = PiPConfig.from_dict({}) + assert cfg.enabled is False + assert cfg.layers == [] + + def test_enabled_false(self): + cfg = PiPConfig.from_dict({"enabled": False, "layers": [{"source": "a"}]}) + assert cfg.enabled is False + assert cfg.layers == [] + + def test_single_layer(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [{"source": "asset_1"}], + } + ) + assert cfg.enabled is True + assert cfg.layer_count == 1 + assert cfg.layers[0].source == "asset_1" + + def test_multiple_layers_sorted_by_z_index(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + {"source": "top", "z_index": 10}, + {"source": "bottom", "z_index": 1}, + {"source": "mid", "z_index": 5}, + ], + } + ) + assert cfg.layer_count == 3 + assert [l.source for l in cfg.layers] == ["bottom", "mid", "top"] + + def test_invalid_layer_skipped(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + {"source": "valid"}, + {"source": ""}, # 无效:空source + ], + } + ) + assert cfg.layer_count == 1 + assert cfg.layers[0].source == "valid" + + def test_all_invalid_layers_disabled(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + {"source": ""}, + {"source": "", "opacity": 2.0}, + ], + } + ) + assert cfg.enabled is False + assert cfg.layer_count == 0 + + def test_layer_parse_error_skipped(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + {"source": "valid"}, + {"source": "bad_margin", "margin": "not_a_number"}, + ], + } + ) + assert cfg.layer_count == 1 + + def test_layer_full_fields(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + { + "source": "asset_1", + "source_type": "url", + "position": "top_left", + "x": 10, + "y": 20, + "margin": 30, + "width": "30%", + "height": "20%", + "opacity": 0.8, + "corner_radius": 10, + "border_width": 2, + "border_color": "black", + "start_time": 1.5, + "duration": 5.0, + "animation_in": "fade", + "animation_out": "slide_right", + "animation_duration": 0.8, + "z_index": 3, + } + ], + } + ) + assert cfg.layer_count == 1 + layer = cfg.layers[0] + assert layer.source == "asset_1" + assert layer.source_type == "url" + assert layer.position == "top_left" + assert layer.margin == 30 + assert layer.width == "30%" + assert layer.opacity == 0.8 + assert layer.corner_radius == 10 + assert layer.start_time == 1.5 + assert layer.duration == 5.0 + assert layer.animation_in == "fade" + assert layer.z_index == 3 + + def test_empty_layers_list(self): + cfg = PiPConfig.from_dict({"enabled": True, "layers": []}) + assert cfg.enabled is False + assert cfg.layer_count == 0 + + +# ── PiPConfig 属性 ─────────────────────────────────────────────────────────── + + +class TestPiPConfigProperties: + def test_layer_count_empty(self): + cfg = PiPConfig() + assert cfg.layer_count == 0 + + def test_max_z_index_empty(self): + cfg = PiPConfig() + assert cfg.max_z_index == 0 + + def test_max_z_index_multiple(self): + cfg = PiPConfig( + layers=[ + PiPLayerConfig(source="a", z_index=3), + PiPLayerConfig(source="b", z_index=7), + PiPLayerConfig(source="c", z_index=2), + ] + ) + assert cfg.max_z_index == 7 + + +# ── parse_size_value ───────────────────────────────────────────────────────── + + +class TestParseSizeValue: + def test_int_value(self): + assert parse_size_value(100, 1920) == 100 + + def test_int_value_zero_bumped_to_one(self): + assert parse_size_value(0, 1920) == 1 + + def test_int_negative_bumped_to_one(self): + assert parse_size_value(-5, 1920) == 1 + + def test_percentage_string(self): + assert parse_size_value("50%", 1920) == 960 + + def test_percentage_25pct(self): + assert parse_size_value("25%", 1920) == 480 + + def test_percentage_small(self): + assert parse_size_value("1%", 1920) == 19 + + def test_percentage_zero_bumped(self): + assert parse_size_value("0%", 1920) == 1 + + def test_invalid_percentage_fallback(self): + assert parse_size_value("abc%", 1920) == 480 # 25% default + + def test_numeric_string(self): + assert parse_size_value("200", 1920) == 200 + + def test_invalid_string_fallback(self): + assert parse_size_value("invalid", 1920) == 480 + + def test_custom_default_pct(self): + assert parse_size_value("bad", 1000, default_pct=0.5) == 500 + + def test_none_fallback(self): + assert parse_size_value(None, 1920) == 480 # type: ignore[arg-type] + + def test_float_int_conversion(self): + # float 不是 int,会走到 try int(value) 分支 + result = parse_size_value(150.0, 1920) # type: ignore[arg-type] + assert result == 150 + + +# ── calculate_pip_position ─────────────────────────────────────────────────── + + +class TestCalculatePipPosition: + W = 1920 + H = 1080 + PW = 300 # pip width + PH = 200 # pip height + M = 20 # margin + + def test_top_left(self): + x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH, self.M) + assert (x, y) == (20, 20) + + def test_top_center(self): + x, y = calculate_pip_position(POSITION_TOP_CENTER, self.W, self.H, self.PW, self.PH, self.M) + assert x == (self.W - self.PW) // 2 + assert y == self.M + + def test_top_right(self): + x, y = calculate_pip_position(POSITION_TOP_RIGHT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.W - self.PW - self.M + assert y == self.M + + def test_center_left(self): + x, y = calculate_pip_position(POSITION_CENTER_LEFT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.M + assert y == (self.H - self.PH) // 2 + + def test_center(self): + x, y = calculate_pip_position(POSITION_CENTER, self.W, self.H, self.PW, self.PH, self.M) + assert x == (self.W - self.PW) // 2 + assert y == (self.H - self.PH) // 2 + + def test_center_right(self): + x, y = calculate_pip_position(POSITION_CENTER_RIGHT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.W - self.PW - self.M + assert y == (self.H - self.PH) // 2 + + def test_bottom_left(self): + x, y = calculate_pip_position(POSITION_BOTTOM_LEFT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.M + assert y == self.H - self.PH - self.M + + def test_bottom_center(self): + x, y = calculate_pip_position(POSITION_BOTTOM_RIGHT, self.W, self.H, self.PW, self.PH, self.M) + # bottom_right 用作 fallback 默认值 + assert x == self.W - self.PW - self.M + assert y == self.H - self.PH - self.M + + def test_bottom_right(self): + x, y = calculate_pip_position(POSITION_BOTTOM_RIGHT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.W - self.PW - self.M + assert y == self.H - self.PH - self.M + + def test_invalid_position_falls_back_to_bottom_right(self): + x, y = calculate_pip_position("unknown_pos", self.W, self.H, self.PW, self.PH, self.M) + assert x == self.W - self.PW - self.M + assert y == self.H - self.PH - self.M + + def test_custom_int_coordinates(self): + x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, custom_x=100, custom_y=200) + assert (x, y) == (100, 200) + + def test_custom_percentage_coordinates(self): + x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, custom_x="10%", custom_y="20%") + assert x == int(1920 * 0.1) + assert y == int(1080 * 0.2) + + def test_custom_zero_margin_ignored(self): + # custom 模式下 margin 参数不影响 + x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, margin=100, custom_x=50, custom_y=60) + assert (x, y) == (50, 60) + + def test_default_margin(self): + # margin 不传默认为 20 + x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH) + assert (x, y) == (20, 20) + + def test_large_margin(self): + x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH, margin=50) + assert (x, y) == (50, 50) + + def test_small_output_large_pip(self): + # 极端情况:pip比输出还大,位置计算仍能给出值 + x, y = calculate_pip_position(POSITION_CENTER, 100, 100, 200, 200, 10) + assert x == (100 - 200) // 2 + assert y == (100 - 200) // 2 + + +# ── 常量导出验证 ───────────────────────────────────────────────────────────── + + +class TestConstants: + def test_nine_position_constants_exist(self): + positions = [ + POSITION_TOP_LEFT, + POSITION_TOP_CENTER, + POSITION_TOP_RIGHT, + POSITION_CENTER_LEFT, + POSITION_CENTER, + POSITION_CENTER_RIGHT, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_RIGHT, + ] + # bottom_center 也存在 + from packages.domain.pip_config import POSITION_BOTTOM_CENTER + + positions.append(POSITION_BOTTOM_CENTER) + assert len(positions) == 9 + assert len(set(positions)) == 9 # 互不相同 + + def test_animation_constants_exist(self): + animations = [ + ANIMATION_FADE, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SCALE, + ] + assert len(set(animations)) == 6