2d67fe8631
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
315 lines
9.9 KiB
Python
Executable File
315 lines
9.9 KiB
Python
Executable File
"""贴纸配置领域模型 — 纯逻辑,无外部依赖.
|
|
|
|
抽离自 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)
|