"""画中画(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(layer.z_index for layer 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])