Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be86ea817 |
@@ -11,121 +11,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.sticker_config import ( # noqa: F401 向后兼容导出
|
||||
ImageStickerConfig,
|
||||
POSITION_PRESETS,
|
||||
STICKER_CATEGORIES,
|
||||
StickerOverlayResult,
|
||||
TextStickerConfig,
|
||||
get_sticker_categories as _get_sticker_categories_base,
|
||||
parse_stickers_from_config as _parse_stickers_base,
|
||||
resolve_sticker_position,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 预设贴纸分类 ──────────────────────────────────────────────────────────────
|
||||
|
||||
# 预设贴纸分类(仅用于前端展示,后端不依赖具体素材)
|
||||
STICKER_CATEGORIES = [
|
||||
("emoji", "表情包"),
|
||||
("text", "文字花字"),
|
||||
("decoration", "装饰"),
|
||||
("arrow", "箭头指示"),
|
||||
("frame", "边框"),
|
||||
]
|
||||
|
||||
# 9宫格位置映射
|
||||
POSITION_PRESETS = {
|
||||
"top_left": (0.05, 0.05),
|
||||
"top_center": (0.5, 0.05),
|
||||
"top_right": (0.95, 0.05),
|
||||
"center_left": (0.05, 0.5),
|
||||
"center": (0.5, 0.5),
|
||||
"center_right": (0.95, 0.5),
|
||||
"bottom_left": (0.05, 0.95),
|
||||
"bottom_center": (0.5, 0.95),
|
||||
"bottom_right": (0.95, 0.95),
|
||||
}
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageStickerConfig:
|
||||
"""图片贴纸配置."""
|
||||
|
||||
enabled: bool = False
|
||||
type: str = "image" # image / text
|
||||
# 位置
|
||||
position: str = "top_right" # 9宫格预设
|
||||
x: float | None = None # 自定义x(像素或百分比)
|
||||
y: float | None = None # 自定义y
|
||||
x_unit: str = "percent" # pixel / percent
|
||||
y_unit: str = "percent"
|
||||
# 大小
|
||||
scale: float = 1.0 # 缩放比例(相对于原始大小)
|
||||
width: int | None = None # 指定宽度(像素)
|
||||
height: int | None = None # 指定高度(像素)
|
||||
# 透明度
|
||||
opacity: float = 1.0 # 0.0~1.0
|
||||
# 时间范围
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0 # 0 表示持续到结束
|
||||
# 动画
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长
|
||||
# 层级
|
||||
z_index: int = 10
|
||||
# 素材
|
||||
image_url: str = "" # 图片URL或本地路径
|
||||
preset_id: str = "" # 预设贴纸ID
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextStickerConfig:
|
||||
"""文字贴纸配置."""
|
||||
|
||||
enabled: bool = False
|
||||
type: str = "text"
|
||||
text: str = ""
|
||||
# 字体
|
||||
font_size: int = 36
|
||||
font_color: str = "#FFFFFF"
|
||||
font_family: str = "sans"
|
||||
# 描边
|
||||
stroke_color: str = "#000000"
|
||||
stroke_width: int = 2
|
||||
# 阴影
|
||||
shadow_color: str = "#000000"
|
||||
shadow_x: int = 2
|
||||
shadow_y: int = 2
|
||||
shadow_alpha: float = 0.5
|
||||
# 位置
|
||||
position: str = "center"
|
||||
x: float | None = None
|
||||
y: float | None = None
|
||||
x_unit: str = "percent"
|
||||
y_unit: str = "percent"
|
||||
# 时间范围
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
# 动画
|
||||
fade_in: float = 0.0
|
||||
fade_out: float = 0.0
|
||||
# 层级
|
||||
z_index: int = 10
|
||||
# 背景框
|
||||
bg_color: str = "" # 空表示无背景
|
||||
bg_padding: int = 8
|
||||
bg_alpha: float = 0.8
|
||||
bg_corner_radius: int = 8
|
||||
|
||||
|
||||
@dataclass
|
||||
class StickerOverlayResult:
|
||||
"""贴纸叠加结果."""
|
||||
|
||||
filter_str: str # 滤镜字符串
|
||||
output_label: str # 输出标签
|
||||
extra_inputs: list[str] = field(default_factory=list) # 额外的输入文件路径
|
||||
|
||||
|
||||
# ── 贴纸引擎 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -144,38 +46,18 @@ class StickerEngine:
|
||||
sticker_w: int = 0,
|
||||
sticker_h: int = 0,
|
||||
) -> tuple[float, float]:
|
||||
"""解析贴纸位置(像素坐标).
|
||||
|
||||
优先级:自定义坐标 > 9宫格预设
|
||||
"""
|
||||
# 先取预设的基准位置
|
||||
if config.position in POSITION_PRESETS:
|
||||
px, py = POSITION_PRESETS[config.position]
|
||||
else:
|
||||
px, py = 0.5, 0.5 # 默认居中
|
||||
|
||||
# 自定义坐标覆盖
|
||||
if config.x is not None:
|
||||
if config.x_unit == "percent":
|
||||
px = config.x / 100.0
|
||||
else:
|
||||
px = config.x / canvas_w if canvas_w > 0 else 0.5
|
||||
|
||||
if config.y is not None:
|
||||
if config.y_unit == "percent":
|
||||
py = config.y / 100.0
|
||||
else:
|
||||
py = config.y / canvas_h if canvas_h > 0 else 0.5
|
||||
|
||||
# 转换为像素坐标(考虑贴纸尺寸,使位置为贴纸中心点)
|
||||
x = px * canvas_w - sticker_w / 2
|
||||
y = py * canvas_h - sticker_h / 2
|
||||
|
||||
# 钳制在画布内
|
||||
x = max(0, min(x, canvas_w - sticker_w))
|
||||
y = max(0, min(y, canvas_h - sticker_h))
|
||||
|
||||
return x, y
|
||||
"""解析贴纸位置(像素坐标)(转发到 sticker_config 模块)."""
|
||||
return resolve_sticker_position(
|
||||
config.position,
|
||||
config.x,
|
||||
config.y,
|
||||
config.x_unit,
|
||||
config.y_unit,
|
||||
canvas_w,
|
||||
canvas_h,
|
||||
sticker_w,
|
||||
sticker_h,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_overlay_filter(
|
||||
@@ -594,19 +476,14 @@ class StickerEngine:
|
||||
return None
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
# ── 便捷函数(薄包装,转发到 sticker_config 模块) ────────────────────────────
|
||||
|
||||
|
||||
def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""从 plan.config.stickers 解析贴纸列表."""
|
||||
if not config:
|
||||
return []
|
||||
stickers = config.get("stickers", [])
|
||||
if not isinstance(stickers, list):
|
||||
return []
|
||||
return stickers
|
||||
"""从 plan.config.stickers 解析贴纸列表(薄包装)."""
|
||||
return _parse_stickers_base(config)
|
||||
|
||||
|
||||
def get_sticker_categories() -> list[tuple[str, str]]:
|
||||
"""获取贴纸分类列表."""
|
||||
return list(STICKER_CATEGORIES)
|
||||
"""获取贴纸分类列表(薄包装)."""
|
||||
return _get_sticker_categories_base()
|
||||
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
"""贴纸配置领域模型 — 纯逻辑,无外部依赖.
|
||||
|
||||
抽离自 sticker_engine.py 的数据类、常量和纯逻辑函数,
|
||||
方便单测覆盖,同时保持向后兼容。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ── 预设贴纸分类 ──────────────────────────────────────────────────────────────
|
||||
|
||||
STICKER_CATEGORIES = [
|
||||
("emoji", "表情包"),
|
||||
("text", "文字花字"),
|
||||
("decoration", "装饰"),
|
||||
("arrow", "箭头指示"),
|
||||
("frame", "边框"),
|
||||
]
|
||||
|
||||
# 9宫格位置映射(归一化坐标 0-1)
|
||||
POSITION_PRESETS: dict[str, tuple[float, float]] = {
|
||||
"top_left": (0.05, 0.05),
|
||||
"top_center": (0.5, 0.05),
|
||||
"top_right": (0.95, 0.05),
|
||||
"center_left": (0.05, 0.5),
|
||||
"center": (0.5, 0.5),
|
||||
"center_right": (0.95, 0.5),
|
||||
"bottom_left": (0.05, 0.95),
|
||||
"bottom_center": (0.5, 0.95),
|
||||
"bottom_right": (0.95, 0.95),
|
||||
}
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageStickerConfig:
|
||||
"""图片贴纸配置."""
|
||||
|
||||
enabled: bool = False
|
||||
type: str = "image"
|
||||
# 位置
|
||||
position: str = "top_right"
|
||||
x: float | None = None
|
||||
y: float | None = None
|
||||
x_unit: str = "percent" # pixel / percent
|
||||
y_unit: str = "percent"
|
||||
# 大小
|
||||
scale: float = 1.0
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
# 透明度
|
||||
opacity: float = 1.0
|
||||
# 时间范围
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0 # 0 表示持续到结束
|
||||
# 动画
|
||||
fade_in: float = 0.0
|
||||
fade_out: float = 0.0
|
||||
# 层级
|
||||
z_index: int = 10
|
||||
# 素材
|
||||
image_url: str = ""
|
||||
preset_id: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "ImageStickerConfig":
|
||||
"""从字典创建配置,带安全类型转换."""
|
||||
if not data or not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
def safe_float(key: str, default: float) -> float:
|
||||
try:
|
||||
val = data.get(key, default)
|
||||
return float(val) if val is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_int(key: str, default: int | None) -> int | None:
|
||||
val = data.get(key, default)
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
x_val = data.get("x")
|
||||
y_val = data.get("y")
|
||||
try:
|
||||
x_float = float(x_val) if x_val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
x_float = None
|
||||
try:
|
||||
y_float = float(y_val) if y_val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
y_float = None
|
||||
|
||||
return cls(
|
||||
enabled=bool(data.get("enabled", False)),
|
||||
type=str(data.get("type", "image")),
|
||||
position=str(data.get("position", "top_right")),
|
||||
x=x_float,
|
||||
y=y_float,
|
||||
x_unit=str(data.get("x_unit", "percent")),
|
||||
y_unit=str(data.get("y_unit", "percent")),
|
||||
scale=max(0.01, safe_float("scale", 1.0)),
|
||||
width=safe_int("width", None),
|
||||
height=safe_int("height", None),
|
||||
opacity=max(0.0, min(1.0, safe_float("opacity", 1.0))),
|
||||
start_time=max(0.0, safe_float("start_time", 0.0)),
|
||||
duration=max(0.0, safe_float("duration", 0.0)),
|
||||
fade_in=max(0.0, safe_float("fade_in", 0.0)),
|
||||
fade_out=max(0.0, safe_float("fade_out", 0.0)),
|
||||
z_index=safe_int("z_index", 10) or 10,
|
||||
image_url=str(data.get("image_url", "")),
|
||||
preset_id=str(data.get("preset_id", "")),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_time_range(self) -> bool:
|
||||
"""是否有明确的时间范围."""
|
||||
return self.duration > 0
|
||||
|
||||
@property
|
||||
def end_time(self) -> float:
|
||||
"""结束时间(仅当 duration>0 时有意义)."""
|
||||
return self.start_time + max(0.0, self.duration)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextStickerConfig:
|
||||
"""文字贴纸配置."""
|
||||
|
||||
enabled: bool = False
|
||||
type: str = "text"
|
||||
text: str = ""
|
||||
# 字体
|
||||
font_size: int = 36
|
||||
font_color: str = "#FFFFFF"
|
||||
font_family: str = "sans"
|
||||
# 描边
|
||||
stroke_color: str = "#000000"
|
||||
stroke_width: int = 2
|
||||
# 阴影
|
||||
shadow_color: str = "#000000"
|
||||
shadow_x: int = 2
|
||||
shadow_y: int = 2
|
||||
shadow_alpha: float = 0.5
|
||||
# 位置
|
||||
position: str = "center"
|
||||
x: float | None = None
|
||||
y: float | None = None
|
||||
x_unit: str = "percent"
|
||||
y_unit: str = "percent"
|
||||
# 时间范围
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
# 动画
|
||||
fade_in: float = 0.0
|
||||
fade_out: float = 0.0
|
||||
# 层级
|
||||
z_index: int = 10
|
||||
# 背景框
|
||||
bg_color: str = ""
|
||||
bg_padding: int = 8
|
||||
bg_alpha: float = 0.8
|
||||
bg_corner_radius: int = 8
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "TextStickerConfig":
|
||||
"""从字典创建配置,带安全类型转换."""
|
||||
if not data or not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
def safe_float(key: str, default: float) -> float:
|
||||
try:
|
||||
val = data.get(key, default)
|
||||
return float(val) if val is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_int(key: str, default: int) -> int:
|
||||
try:
|
||||
val = data.get(key, default)
|
||||
return int(val) if val is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
x_val = data.get("x")
|
||||
y_val = data.get("y")
|
||||
try:
|
||||
x_float = float(x_val) if x_val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
x_float = None
|
||||
try:
|
||||
y_float = float(y_val) if y_val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
y_float = None
|
||||
|
||||
return cls(
|
||||
enabled=bool(data.get("enabled", False)),
|
||||
type=str(data.get("type", "text")),
|
||||
text=str(data.get("text", "")),
|
||||
font_size=max(1, safe_int("font_size", 36)),
|
||||
font_color=str(data.get("font_color", "#FFFFFF")),
|
||||
font_family=str(data.get("font_family", "sans")),
|
||||
stroke_color=str(data.get("stroke_color", "#000000")),
|
||||
stroke_width=max(0, safe_int("stroke_width", 2)),
|
||||
shadow_color=str(data.get("shadow_color", "#000000")),
|
||||
shadow_x=safe_int("shadow_x", 2),
|
||||
shadow_y=safe_int("shadow_y", 2),
|
||||
shadow_alpha=max(0.0, min(1.0, safe_float("shadow_alpha", 0.5))),
|
||||
position=str(data.get("position", "center")),
|
||||
x=x_float,
|
||||
y=y_float,
|
||||
x_unit=str(data.get("x_unit", "percent")),
|
||||
y_unit=str(data.get("y_unit", "percent")),
|
||||
start_time=max(0.0, safe_float("start_time", 0.0)),
|
||||
duration=max(0.0, safe_float("duration", 0.0)),
|
||||
fade_in=max(0.0, safe_float("fade_in", 0.0)),
|
||||
fade_out=max(0.0, safe_float("fade_out", 0.0)),
|
||||
z_index=safe_int("z_index", 10),
|
||||
bg_color=str(data.get("bg_color", "")),
|
||||
bg_padding=max(0, safe_int("bg_padding", 8)),
|
||||
bg_alpha=max(0.0, min(1.0, safe_float("bg_alpha", 0.8))),
|
||||
bg_corner_radius=max(0, safe_int("bg_corner_radius", 8)),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_background(self) -> bool:
|
||||
"""是否有背景框."""
|
||||
return bool(self.bg_color)
|
||||
|
||||
@property
|
||||
def has_time_range(self) -> bool:
|
||||
"""是否有明确的时间范围."""
|
||||
return self.duration > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class StickerOverlayResult:
|
||||
"""贴纸叠加结果."""
|
||||
|
||||
filter_str: str
|
||||
output_label: str
|
||||
extra_inputs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_sticker_position(
|
||||
position: str,
|
||||
x: float | None,
|
||||
y: float | None,
|
||||
x_unit: str,
|
||||
y_unit: str,
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
sticker_w: int = 0,
|
||||
sticker_h: int = 0,
|
||||
) -> tuple[float, float]:
|
||||
"""解析贴纸位置(像素坐标).
|
||||
|
||||
优先级:自定义坐标 > 9宫格预设
|
||||
返回贴纸左上角的像素坐标,已钳制在画布内。
|
||||
"""
|
||||
# 先取预设的基准位置
|
||||
if position in POSITION_PRESETS:
|
||||
px, py = POSITION_PRESETS[position]
|
||||
else:
|
||||
px, py = 0.5, 0.5 # 默认居中
|
||||
|
||||
# 自定义坐标覆盖
|
||||
if x is not None:
|
||||
if x_unit == "percent":
|
||||
px = max(0.0, min(1.0, x / 100.0))
|
||||
else:
|
||||
px = x / canvas_w if canvas_w > 0 else 0.5
|
||||
|
||||
if y is not None:
|
||||
if y_unit == "percent":
|
||||
py = max(0.0, min(1.0, y / 100.0))
|
||||
else:
|
||||
py = y / canvas_h if canvas_h > 0 else 0.5
|
||||
|
||||
# 转换为像素坐标(考虑贴纸尺寸,使位置为贴纸中心点)
|
||||
pos_x = px * canvas_w - sticker_w / 2
|
||||
pos_y = py * canvas_h - sticker_h / 2
|
||||
|
||||
# 钳制在画布内
|
||||
pos_x = max(0, min(pos_x, canvas_w - sticker_w))
|
||||
pos_y = max(0, min(pos_y, canvas_h - sticker_h))
|
||||
|
||||
return pos_x, pos_y
|
||||
|
||||
|
||||
def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""从 plan.config.stickers 解析贴纸列表."""
|
||||
if not config:
|
||||
return []
|
||||
stickers = config.get("stickers", [])
|
||||
if not isinstance(stickers, list):
|
||||
return []
|
||||
return stickers
|
||||
|
||||
|
||||
def get_sticker_categories() -> list[tuple[str, str]]:
|
||||
"""获取贴纸分类列表."""
|
||||
return list(STICKER_CATEGORIES)
|
||||
Executable
+478
@@ -0,0 +1,478 @@
|
||||
"""sticker_config 模块单测 — 纯逻辑."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.sticker_config import (
|
||||
POSITION_PRESETS,
|
||||
STICKER_CATEGORIES,
|
||||
ImageStickerConfig,
|
||||
StickerOverlayResult,
|
||||
TextStickerConfig,
|
||||
get_sticker_categories,
|
||||
parse_stickers_from_config,
|
||||
resolve_sticker_position,
|
||||
)
|
||||
|
||||
# ── 常量测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_position_presets_has_9_positions(self):
|
||||
assert len(POSITION_PRESETS) == 9
|
||||
|
||||
def test_position_presets_normalized(self):
|
||||
for name, (x, y) in POSITION_PRESETS.items():
|
||||
assert 0.0 <= x <= 1.0
|
||||
assert 0.0 <= y <= 1.0
|
||||
|
||||
def test_sticker_categories(self):
|
||||
assert len(STICKER_CATEGORIES) >= 3
|
||||
assert ("emoji", "表情包") in STICKER_CATEGORIES
|
||||
assert ("text", "文字花字") in STICKER_CATEGORIES
|
||||
|
||||
|
||||
# ── ImageStickerConfig 测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImageStickerDefaults:
|
||||
def test_default_values(self):
|
||||
cfg = ImageStickerConfig()
|
||||
assert cfg.enabled is False
|
||||
assert cfg.type == "image"
|
||||
assert cfg.position == "top_right"
|
||||
assert cfg.x is None
|
||||
assert cfg.y is None
|
||||
assert cfg.scale == 1.0
|
||||
assert cfg.opacity == 1.0
|
||||
assert cfg.start_time == 0.0
|
||||
assert cfg.duration == 0.0
|
||||
assert cfg.fade_in == 0.0
|
||||
assert cfg.fade_out == 0.0
|
||||
assert cfg.z_index == 10
|
||||
|
||||
|
||||
class TestImageStickerFromDict:
|
||||
def test_none_returns_default(self):
|
||||
cfg = ImageStickerConfig.from_dict(None)
|
||||
assert cfg.position == "top_right"
|
||||
assert cfg.scale == 1.0
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
cfg = ImageStickerConfig.from_dict({})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = ImageStickerConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"position": "center",
|
||||
"scale": 1.5,
|
||||
"opacity": 0.8,
|
||||
"start_time": 2.0,
|
||||
"duration": 5.0,
|
||||
"z_index": 20,
|
||||
"image_url": "https://example.com/img.png",
|
||||
}
|
||||
)
|
||||
assert cfg.enabled is True
|
||||
assert cfg.position == "center"
|
||||
assert cfg.scale == 1.5
|
||||
assert cfg.opacity == 0.8
|
||||
assert cfg.start_time == 2.0
|
||||
assert cfg.duration == 5.0
|
||||
assert cfg.z_index == 20
|
||||
assert cfg.image_url == "https://example.com/img.png"
|
||||
|
||||
def test_custom_xy_pixel(self):
|
||||
cfg = ImageStickerConfig.from_dict(
|
||||
{
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"x_unit": "pixel",
|
||||
"y_unit": "pixel",
|
||||
}
|
||||
)
|
||||
assert cfg.x == 100.0
|
||||
assert cfg.y == 200.0
|
||||
assert cfg.x_unit == "pixel"
|
||||
assert cfg.y_unit == "pixel"
|
||||
|
||||
def test_opacity_clamped(self):
|
||||
cfg = ImageStickerConfig.from_dict({"opacity": 1.5})
|
||||
assert cfg.opacity == 1.0
|
||||
cfg2 = ImageStickerConfig.from_dict({"opacity": -0.5})
|
||||
assert cfg2.opacity == 0.0
|
||||
|
||||
def test_scale_minimum(self):
|
||||
cfg = ImageStickerConfig.from_dict({"scale": 0.001})
|
||||
assert cfg.scale == 0.01
|
||||
|
||||
def test_start_time_clamped(self):
|
||||
cfg = ImageStickerConfig.from_dict({"start_time": -1})
|
||||
assert cfg.start_time == 0.0
|
||||
|
||||
def test_duration_clamped(self):
|
||||
cfg = ImageStickerConfig.from_dict({"duration": -5})
|
||||
assert cfg.duration == 0.0
|
||||
|
||||
def test_invalid_x_returns_none(self):
|
||||
cfg = ImageStickerConfig.from_dict({"x": "invalid"})
|
||||
assert cfg.x is None
|
||||
|
||||
def test_width_height_int(self):
|
||||
cfg = ImageStickerConfig.from_dict({"width": 200, "height": 100})
|
||||
assert cfg.width == 200
|
||||
assert cfg.height == 100
|
||||
|
||||
|
||||
class TestImageStickerProperties:
|
||||
def test_has_time_range_true(self):
|
||||
cfg = ImageStickerConfig(duration=5.0)
|
||||
assert cfg.has_time_range is True
|
||||
|
||||
def test_has_time_range_false(self):
|
||||
cfg = ImageStickerConfig(duration=0.0)
|
||||
assert cfg.has_time_range is False
|
||||
|
||||
def test_end_time(self):
|
||||
cfg = ImageStickerConfig(start_time=2.0, duration=3.0)
|
||||
assert cfg.end_time == 5.0
|
||||
|
||||
def test_end_time_zero_duration(self):
|
||||
cfg = ImageStickerConfig(start_time=2.0, duration=0.0)
|
||||
assert cfg.end_time == 2.0
|
||||
|
||||
|
||||
# ── TextStickerConfig 测试 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTextStickerDefaults:
|
||||
def test_default_values(self):
|
||||
cfg = TextStickerConfig()
|
||||
assert cfg.enabled is False
|
||||
assert cfg.type == "text"
|
||||
assert cfg.text == ""
|
||||
assert cfg.font_size == 36
|
||||
assert cfg.font_color == "#FFFFFF"
|
||||
assert cfg.stroke_width == 2
|
||||
assert cfg.position == "center"
|
||||
assert cfg.bg_color == ""
|
||||
assert cfg.bg_padding == 8
|
||||
assert cfg.bg_alpha == 0.8
|
||||
assert cfg.z_index == 10
|
||||
|
||||
|
||||
class TestTextStickerFromDict:
|
||||
def test_none_returns_default(self):
|
||||
cfg = TextStickerConfig.from_dict(None)
|
||||
assert cfg.font_size == 36
|
||||
|
||||
def test_custom_text(self):
|
||||
cfg = TextStickerConfig.from_dict({"text": "Hello World", "font_size": 48})
|
||||
assert cfg.text == "Hello World"
|
||||
assert cfg.font_size == 48
|
||||
|
||||
def test_font_color(self):
|
||||
cfg = TextStickerConfig.from_dict({"font_color": "#FF0000"})
|
||||
assert cfg.font_color == "#FF0000"
|
||||
|
||||
def test_stroke_config(self):
|
||||
cfg = TextStickerConfig.from_dict(
|
||||
{
|
||||
"stroke_color": "#00FF00",
|
||||
"stroke_width": 4,
|
||||
}
|
||||
)
|
||||
assert cfg.stroke_color == "#00FF00"
|
||||
assert cfg.stroke_width == 4
|
||||
|
||||
def test_shadow_config(self):
|
||||
cfg = TextStickerConfig.from_dict(
|
||||
{
|
||||
"shadow_x": 4,
|
||||
"shadow_y": 4,
|
||||
"shadow_alpha": 0.7,
|
||||
}
|
||||
)
|
||||
assert cfg.shadow_x == 4
|
||||
assert cfg.shadow_y == 4
|
||||
assert cfg.shadow_alpha == 0.7
|
||||
|
||||
def test_background_config(self):
|
||||
cfg = TextStickerConfig.from_dict(
|
||||
{
|
||||
"bg_color": "#000000",
|
||||
"bg_padding": 12,
|
||||
"bg_alpha": 0.9,
|
||||
"bg_corner_radius": 10,
|
||||
}
|
||||
)
|
||||
assert cfg.bg_color == "#000000"
|
||||
assert cfg.bg_padding == 12
|
||||
assert cfg.bg_alpha == 0.9
|
||||
assert cfg.bg_corner_radius == 10
|
||||
|
||||
def test_font_size_minimum(self):
|
||||
cfg = TextStickerConfig.from_dict({"font_size": 0})
|
||||
assert cfg.font_size == 1
|
||||
|
||||
def test_stroke_width_negative_clamped(self):
|
||||
cfg = TextStickerConfig.from_dict({"stroke_width": -2})
|
||||
assert cfg.stroke_width == 0
|
||||
|
||||
def test_shadow_alpha_clamped(self):
|
||||
cfg = TextStickerConfig.from_dict({"shadow_alpha": 1.5})
|
||||
assert cfg.shadow_alpha == 1.0
|
||||
|
||||
def test_bg_alpha_clamped(self):
|
||||
cfg = TextStickerConfig.from_dict({"bg_alpha": -0.5})
|
||||
assert cfg.bg_alpha == 0.0
|
||||
|
||||
def test_invalid_font_size_falls_back(self):
|
||||
cfg = TextStickerConfig.from_dict({"font_size": "large"})
|
||||
assert cfg.font_size == 36
|
||||
|
||||
|
||||
class TestTextStickerProperties:
|
||||
def test_has_background_true(self):
|
||||
cfg = TextStickerConfig(bg_color="#000000")
|
||||
assert cfg.has_background is True
|
||||
|
||||
def test_has_background_false(self):
|
||||
cfg = TextStickerConfig(bg_color="")
|
||||
assert cfg.has_background is False
|
||||
|
||||
def test_has_time_range_true(self):
|
||||
cfg = TextStickerConfig(duration=3.0)
|
||||
assert cfg.has_time_range is True
|
||||
|
||||
|
||||
# ── StickerOverlayResult 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStickerOverlayResult:
|
||||
def test_basic(self):
|
||||
result = StickerOverlayResult(filter_str="overlay", output_label="[out]")
|
||||
assert result.filter_str == "overlay"
|
||||
assert result.output_label == "[out]"
|
||||
assert result.extra_inputs == []
|
||||
|
||||
def test_with_extra_inputs(self):
|
||||
result = StickerOverlayResult(
|
||||
filter_str="overlay",
|
||||
output_label="[out]",
|
||||
extra_inputs=["sticker.png"],
|
||||
)
|
||||
assert result.extra_inputs == ["sticker.png"]
|
||||
|
||||
|
||||
# ── resolve_sticker_position 测试 ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolvePositionPresets:
|
||||
def test_top_left(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"top_left",
|
||||
None,
|
||||
None,
|
||||
"percent",
|
||||
"percent",
|
||||
canvas_w=1000,
|
||||
canvas_h=500,
|
||||
sticker_w=100,
|
||||
sticker_h=50,
|
||||
)
|
||||
# 0.05 * 1000 - 50 = 0, 0.05 * 500 - 25 = 0
|
||||
assert x == pytest.approx(0.0)
|
||||
assert y == pytest.approx(0.0)
|
||||
|
||||
def test_center(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"center",
|
||||
None,
|
||||
None,
|
||||
"percent",
|
||||
"percent",
|
||||
canvas_w=1000,
|
||||
canvas_h=500,
|
||||
sticker_w=100,
|
||||
sticker_h=50,
|
||||
)
|
||||
# 0.5 * 1000 - 50 = 450, 0.5 * 500 - 25 = 225
|
||||
assert x == pytest.approx(450.0)
|
||||
assert y == pytest.approx(225.0)
|
||||
|
||||
def test_bottom_right(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"bottom_right",
|
||||
None,
|
||||
None,
|
||||
"percent",
|
||||
"percent",
|
||||
canvas_w=1000,
|
||||
canvas_h=500,
|
||||
sticker_w=100,
|
||||
sticker_h=50,
|
||||
)
|
||||
# 0.95 * 1000 - 50 = 900, 0.95 * 500 - 25 = 450
|
||||
assert x == pytest.approx(900.0)
|
||||
assert y == pytest.approx(450.0)
|
||||
|
||||
def test_invalid_position_defaults_center(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"invalid_pos",
|
||||
None,
|
||||
None,
|
||||
"percent",
|
||||
"percent",
|
||||
canvas_w=1000,
|
||||
canvas_h=500,
|
||||
sticker_w=100,
|
||||
sticker_h=50,
|
||||
)
|
||||
assert x == pytest.approx(450.0)
|
||||
assert y == pytest.approx(225.0)
|
||||
|
||||
|
||||
class TestResolvePositionCustomPercent:
|
||||
def test_custom_percent(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"center",
|
||||
25.0,
|
||||
75.0,
|
||||
"percent",
|
||||
"percent",
|
||||
canvas_w=1000,
|
||||
canvas_h=500,
|
||||
sticker_w=100,
|
||||
sticker_h=50,
|
||||
)
|
||||
# 0.25 * 1000 - 50 = 200, 0.75 * 500 - 25 = 350
|
||||
assert x == pytest.approx(200.0)
|
||||
assert y == pytest.approx(350.0)
|
||||
|
||||
def test_percent_clamped_0_100(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"center",
|
||||
150.0,
|
||||
-50.0,
|
||||
"percent",
|
||||
"percent",
|
||||
canvas_w=1000,
|
||||
canvas_h=500,
|
||||
sticker_w=100,
|
||||
sticker_h=50,
|
||||
)
|
||||
# x=100% → 1.0*1000-50=950, y=0% → 0*500-25=钳制到0
|
||||
assert x == pytest.approx(900.0)
|
||||
assert y == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestResolvePositionCustomPixel:
|
||||
def test_custom_pixel(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"center",
|
||||
200.0,
|
||||
300.0,
|
||||
"pixel",
|
||||
"pixel",
|
||||
canvas_w=1000,
|
||||
canvas_h=500,
|
||||
sticker_w=100,
|
||||
sticker_h=50,
|
||||
)
|
||||
# 200/1000 = 0.2 → 0.2*1000-50=150, 300/500=0.6 → 0.6*500-25=275
|
||||
assert x == pytest.approx(150.0)
|
||||
assert y == pytest.approx(275.0)
|
||||
|
||||
|
||||
class TestResolvePositionEdgeCases:
|
||||
def test_zero_canvas(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"center",
|
||||
50.0,
|
||||
50.0,
|
||||
"pixel",
|
||||
"pixel",
|
||||
canvas_w=0,
|
||||
canvas_h=0,
|
||||
sticker_w=10,
|
||||
sticker_h=10,
|
||||
)
|
||||
# canvas=0 时用默认 0.5, 0.5
|
||||
assert x == pytest.approx(0.0)
|
||||
assert y == pytest.approx(0.0)
|
||||
|
||||
def test_zero_sticker_size(self):
|
||||
x, y = resolve_sticker_position(
|
||||
"center",
|
||||
None,
|
||||
None,
|
||||
"percent",
|
||||
"percent",
|
||||
canvas_w=1000,
|
||||
canvas_h=500,
|
||||
sticker_w=0,
|
||||
sticker_h=0,
|
||||
)
|
||||
assert x == pytest.approx(500.0)
|
||||
assert y == pytest.approx(250.0)
|
||||
|
||||
def test_clamped_when_sticker_larger_than_canvas(self):
|
||||
# 贴纸比画布大时,钳制到0(x=0, y=0)
|
||||
x, y = resolve_sticker_position(
|
||||
"top_left",
|
||||
None,
|
||||
None,
|
||||
"percent",
|
||||
"percent",
|
||||
canvas_w=100,
|
||||
canvas_h=100,
|
||||
sticker_w=200,
|
||||
sticker_h=200,
|
||||
)
|
||||
# 位置为左上角0.05 → 钳制到 0
|
||||
assert x == 0.0
|
||||
assert y == 0.0
|
||||
|
||||
|
||||
# ── parse_stickers_from_config 测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseStickersFromConfig:
|
||||
def test_none_config(self):
|
||||
assert parse_stickers_from_config(None) == []
|
||||
|
||||
def test_empty_dict(self):
|
||||
assert parse_stickers_from_config({}) == []
|
||||
|
||||
def test_stickers_list(self):
|
||||
cfg = {"stickers": [{"type": "image"}, {"type": "text"}]}
|
||||
result = parse_stickers_from_config(cfg)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_stickers_not_list(self):
|
||||
cfg = {"stickers": "not_a_list"}
|
||||
assert parse_stickers_from_config(cfg) == []
|
||||
|
||||
def test_empty_stickers_list(self):
|
||||
cfg = {"stickers": []}
|
||||
assert parse_stickers_from_config(cfg) == []
|
||||
|
||||
|
||||
# ── get_sticker_categories 测试 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetStickerCategories:
|
||||
def test_returns_list(self):
|
||||
result = get_sticker_categories()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_returns_copy(self):
|
||||
a = get_sticker_categories()
|
||||
b = get_sticker_categories()
|
||||
assert a is not b
|
||||
assert a == b
|
||||
Reference in New Issue
Block a user