"""贴纸配置领域模型 — 纯逻辑,无外部依赖. 抽离自 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)