Files
xiaoxia-saas/packages/domain/pip_config.py

266 lines
8.5 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""画中画(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: 自定义xposition=custom时有效)
custom_y: 自定义yposition=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])