Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cedb3ff8a4 |
@@ -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,
|
||||
|
||||
Executable
+265
@@ -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])
|
||||
Executable
+489
@@ -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
|
||||
Reference in New Issue
Block a user