266 lines
8.5 KiB
Python
Executable File
266 lines
8.5 KiB
Python
Executable File
"""画中画(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])
|