test(wave134): 贴纸引擎纯逻辑抽离 + 113单测 #1048
+534
@@ -0,0 +1,534 @@
|
||||
"""贴纸引擎纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 安全类型转换 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def safe_float(val: Any) -> Optional[float]:
|
||||
"""安全转换为 float.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
|
||||
Returns:
|
||||
float 值,失败返回 None
|
||||
"""
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
result = float(val)
|
||||
if result != result: # NaN check
|
||||
return None
|
||||
return result
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def safe_int(val: Any, default: int = 0) -> int:
|
||||
"""安全转换为 int.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
int 值,失败返回默认值
|
||||
"""
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
result = int(float(val))
|
||||
return result
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def safe_bool(val: Any) -> bool:
|
||||
"""安全转换为 bool.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
|
||||
Returns:
|
||||
bool 值
|
||||
"""
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if val is None:
|
||||
return False
|
||||
if isinstance(val, str):
|
||||
return val.lower() in ("true", "1", "yes", "on")
|
||||
return bool(val)
|
||||
|
||||
|
||||
# ── 尺寸估算 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def estimate_sticker_size(
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
scale: float = 1.0,
|
||||
fixed_width: Optional[int] = None,
|
||||
fixed_height: Optional[int] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""估算贴纸尺寸.
|
||||
|
||||
如果指定了固定宽高,直接使用;否则按画布的 30% * scale 估算。
|
||||
|
||||
Args:
|
||||
canvas_w: 画布宽度
|
||||
canvas_h: 画布高度
|
||||
scale: 缩放比例
|
||||
fixed_width: 固定宽度(可选)
|
||||
fixed_height: 固定高度(可选)
|
||||
|
||||
Returns:
|
||||
(估算宽度, 估算高度)
|
||||
"""
|
||||
if fixed_width and fixed_height:
|
||||
return (fixed_width, fixed_height)
|
||||
|
||||
base_ratio = 0.3
|
||||
est_w = int(canvas_w * base_ratio * scale) if not fixed_width else fixed_width
|
||||
est_h = int(canvas_h * base_ratio * scale) if not fixed_height else fixed_height
|
||||
|
||||
return (max(1, est_w), max(1, est_h))
|
||||
|
||||
|
||||
def estimate_text_size(
|
||||
text: str,
|
||||
font_size: int,
|
||||
) -> tuple[int, int]:
|
||||
"""估算文字贴纸尺寸.
|
||||
|
||||
粗略估算:宽度 = 字数 * 字号 * 0.6,高度 = 字号 * 1.4
|
||||
|
||||
Args:
|
||||
text: 文字内容
|
||||
font_size: 字号
|
||||
|
||||
Returns:
|
||||
(估算宽度, 估算高度)
|
||||
"""
|
||||
if not text:
|
||||
return (0, 0)
|
||||
est_w = int(len(text) * font_size * 0.6)
|
||||
est_h = int(font_size * 1.4)
|
||||
return (max(1, est_w), max(1, est_h))
|
||||
|
||||
|
||||
# ── 时间计算 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_fade_out_start(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_out: float,
|
||||
) -> float:
|
||||
"""计算淡出开始时间.
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
淡出开始时间(秒),最小为 0
|
||||
"""
|
||||
if fade_out <= 0 or duration <= 0:
|
||||
return 0.0
|
||||
fade_start = start_time + duration - fade_out
|
||||
return max(0.0, fade_start)
|
||||
|
||||
|
||||
def calculate_end_time(start_time: float, duration: float) -> float:
|
||||
"""计算结束时间.
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
结束时间(秒)
|
||||
"""
|
||||
if duration <= 0:
|
||||
return start_time
|
||||
return start_time + duration
|
||||
|
||||
|
||||
def has_time_range(duration: float) -> bool:
|
||||
"""是否有时间范围限制.
|
||||
|
||||
Args:
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
duration > 0 时返回 True
|
||||
"""
|
||||
return duration > 0
|
||||
|
||||
|
||||
# ── 滤镜组件构建 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_scale_filter(
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
scale: float = 1.0,
|
||||
) -> Optional[str]:
|
||||
"""构建缩放滤镜.
|
||||
|
||||
优先使用固定宽高,否则按比例缩放。
|
||||
scale=1.0 且无固定尺寸时返回 None。
|
||||
|
||||
Args:
|
||||
width: 固定宽度(可选)
|
||||
height: 固定高度(可选)
|
||||
scale: 缩放比例
|
||||
|
||||
Returns:
|
||||
scale 滤镜字符串,不需要缩放时返回 None
|
||||
"""
|
||||
if width and height:
|
||||
return f"scale={width}:{height}"
|
||||
if scale != 1.0:
|
||||
return f"scale=iw*{scale}:ih*{scale}"
|
||||
return None
|
||||
|
||||
|
||||
def build_opacity_filter(opacity: float) -> Optional[str]:
|
||||
"""构建透明度滤镜.
|
||||
|
||||
Args:
|
||||
opacity: 不透明度 0.0~1.0
|
||||
|
||||
Returns:
|
||||
colorchannelmixer 滤镜字符串,完全不透明时返回 None
|
||||
"""
|
||||
if opacity >= 1.0:
|
||||
return None
|
||||
safe_opacity = max(0.0, min(1.0, opacity))
|
||||
return f"colorchannelmixer=aa={safe_opacity}"
|
||||
|
||||
|
||||
def build_image_fade_filters(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_in: float = 0.0,
|
||||
fade_out: float = 0.0,
|
||||
) -> list[str]:
|
||||
"""构建图片贴纸淡入淡出滤镜列表.
|
||||
|
||||
使用 FFmpeg fade 滤镜(alpha 通道)。
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
fade 滤镜字符串列表
|
||||
"""
|
||||
filters: list[str] = []
|
||||
|
||||
if fade_in > 0:
|
||||
filters.append(f"fade=in:st={start_time}:d={fade_in}:alpha=1")
|
||||
|
||||
if fade_out > 0 and duration > 0:
|
||||
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
|
||||
filters.append(f"fade=out:st={fade_out_start}:d={fade_out}:alpha=1")
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def build_enable_expr(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
) -> str:
|
||||
"""构建 enable 表达式(时间范围).
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
enable 表达式字符串(包含开头的冒号),无时间限制时返回空字符串
|
||||
"""
|
||||
if duration <= 0:
|
||||
return ""
|
||||
end_time = calculate_end_time(start_time, duration)
|
||||
return f":enable='between(t,{start_time},{end_time})'"
|
||||
|
||||
|
||||
# ── drawtext 文字贴纸相关 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def escape_drawtext_text(text: str) -> str:
|
||||
"""转义 drawtext 中的特殊字符.
|
||||
|
||||
转义冒号和单引号。
|
||||
|
||||
Args:
|
||||
text: 原始文字
|
||||
|
||||
Returns:
|
||||
转义后的文字
|
||||
"""
|
||||
result = text.replace(":", "\\:")
|
||||
result = result.replace("'", "\\'")
|
||||
return result
|
||||
|
||||
|
||||
def build_drawtext_alpha_expr(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_in: float = 0.0,
|
||||
fade_out: float = 0.0,
|
||||
) -> str:
|
||||
"""构建 drawtext 的 alpha 淡入淡出表达式.
|
||||
|
||||
drawtext 没有直接的 fade 滤镜,用 alpha 表达式模拟。
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
alpha 表达式字符串,无淡入淡出时返回 "1"
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
if fade_in > 0:
|
||||
fade_in_end = start_time + fade_in
|
||||
parts.append(f"if(lt(t,{fade_in_end}),(t-{start_time})/{fade_in},1)")
|
||||
|
||||
if fade_out > 0 and duration > 0:
|
||||
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
|
||||
end_time = calculate_end_time(start_time, duration)
|
||||
parts.append(f"if(gt(t,{fade_out_start}),({end_time}-t)/{fade_out},1)")
|
||||
|
||||
if not parts:
|
||||
return "1"
|
||||
|
||||
return "*".join(parts)
|
||||
|
||||
|
||||
def build_stroke_params(
|
||||
stroke_width: int = 0,
|
||||
stroke_color: str = "black",
|
||||
) -> list[str]:
|
||||
"""构建 drawtext 描边参数.
|
||||
|
||||
Args:
|
||||
stroke_width: 描边宽度(0 表示无描边)
|
||||
stroke_color: 描边颜色
|
||||
|
||||
Returns:
|
||||
描边参数列表
|
||||
"""
|
||||
if stroke_width <= 0:
|
||||
return []
|
||||
return [
|
||||
f"borderw={stroke_width}",
|
||||
f"bordercolor={stroke_color}",
|
||||
]
|
||||
|
||||
|
||||
def build_shadow_params(
|
||||
shadow_alpha: float = 0.0,
|
||||
shadow_x: int = 2,
|
||||
shadow_y: int = 2,
|
||||
shadow_color: str = "black",
|
||||
) -> list[str]:
|
||||
"""构建 drawtext 阴影参数.
|
||||
|
||||
Args:
|
||||
shadow_alpha: 阴影透明度(0 表示无阴影)
|
||||
shadow_x: 阴影 X 偏移
|
||||
shadow_y: 阴影 Y 偏移
|
||||
shadow_color: 阴影颜色
|
||||
|
||||
Returns:
|
||||
阴影参数列表
|
||||
"""
|
||||
if shadow_alpha <= 0:
|
||||
return []
|
||||
safe_alpha = max(0.0, min(1.0, shadow_alpha))
|
||||
return [
|
||||
f"shadowx={shadow_x}",
|
||||
f"shadowy={shadow_y}",
|
||||
f"shadowcolor={shadow_color}@{safe_alpha}",
|
||||
]
|
||||
|
||||
|
||||
# ── 贴纸排序与过滤 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sort_stickers_by_z_index(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""按 z_index 排序贴纸.
|
||||
|
||||
z_index 小的在底层,大的在上层。
|
||||
相同 z_index 保持原顺序(稳定排序)。
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
排序后的贴纸列表
|
||||
"""
|
||||
return sorted(stickers, key=lambda s: safe_int(s.get("z_index"), 10))
|
||||
|
||||
|
||||
def filter_enabled_stickers(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""过滤出启用的贴纸.
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
启用的贴纸列表
|
||||
"""
|
||||
result = []
|
||||
for s in stickers:
|
||||
enabled = s.get("enabled", True)
|
||||
if safe_bool(enabled):
|
||||
result.append(s)
|
||||
return result
|
||||
|
||||
|
||||
def count_sticker_types(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> dict[str, int]:
|
||||
"""统计各类型贴纸数量.
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
类型计数字典
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for s in stickers:
|
||||
stype = s.get("type", "image")
|
||||
counts[stype] = counts.get(stype, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
# ── overlay 滤镜构建 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_overlay_position(
|
||||
pos_x: float,
|
||||
pos_y: float,
|
||||
) -> str:
|
||||
"""构建 overlay 位置参数.
|
||||
|
||||
Args:
|
||||
pos_x: X 坐标
|
||||
pos_y: Y 坐标
|
||||
|
||||
Returns:
|
||||
overlay 位置字符串 "x:y"
|
||||
"""
|
||||
return f"{pos_x:.0f}:{pos_y:.0f}"
|
||||
|
||||
|
||||
def build_pre_filter_label(idx: int) -> str:
|
||||
"""构建贴纸预处理后的标签名.
|
||||
|
||||
Args:
|
||||
idx: 贴纸索引
|
||||
|
||||
Returns:
|
||||
滤镜标签字符串
|
||||
"""
|
||||
return f"sticker_{idx}_scaled"
|
||||
|
||||
|
||||
# ── 验证函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_image_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
"""验证图片贴纸配置.
|
||||
|
||||
Args:
|
||||
sticker: 贴纸配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 图片路径
|
||||
image_path = sticker.get("image_path", "")
|
||||
if not image_path and not sticker.get("asset_id"):
|
||||
errors.append("图片贴纸需要 image_path 或 asset_id")
|
||||
|
||||
# 透明度范围
|
||||
opacity = safe_float(sticker.get("opacity", 1.0))
|
||||
if opacity is not None and (opacity < 0 or opacity > 1):
|
||||
errors.append("opacity 必须在 0~1 之间")
|
||||
|
||||
# 缩放比例
|
||||
scale = safe_float(sticker.get("scale", 1.0))
|
||||
if scale is not None and scale <= 0:
|
||||
errors.append("scale 必须大于 0")
|
||||
|
||||
# 时间参数
|
||||
duration = safe_float(sticker.get("duration", 0))
|
||||
if duration is not None and duration < 0:
|
||||
errors.append("duration 不能为负数")
|
||||
|
||||
start_time = safe_float(sticker.get("start_time", 0))
|
||||
if start_time is not None and start_time < 0:
|
||||
errors.append("start_time 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
def validate_text_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
"""验证文字贴纸配置.
|
||||
|
||||
Args:
|
||||
sticker: 贴纸配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 文字内容
|
||||
text = sticker.get("text", "")
|
||||
if not text:
|
||||
errors.append("文字贴纸需要 text 内容")
|
||||
|
||||
# 字号
|
||||
font_size = safe_int(sticker.get("font_size", 36))
|
||||
if font_size <= 0:
|
||||
errors.append("font_size 必须大于 0")
|
||||
|
||||
# 颜色
|
||||
font_color = sticker.get("font_color", "white")
|
||||
if not font_color:
|
||||
errors.append("font_color 不能为空")
|
||||
|
||||
# 时间参数
|
||||
duration = safe_float(sticker.get("duration", 0))
|
||||
if duration is not None and duration < 0:
|
||||
errors.append("duration 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
Executable
+780
@@ -0,0 +1,780 @@
|
||||
"""贴纸引擎纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.sticker_engine_pure import (
|
||||
build_drawtext_alpha_expr,
|
||||
build_enable_expr,
|
||||
build_image_fade_filters,
|
||||
build_opacity_filter,
|
||||
build_overlay_position,
|
||||
build_pre_filter_label,
|
||||
build_scale_filter,
|
||||
build_shadow_params,
|
||||
build_stroke_params,
|
||||
calculate_end_time,
|
||||
calculate_fade_out_start,
|
||||
count_sticker_types,
|
||||
escape_drawtext_text,
|
||||
estimate_sticker_size,
|
||||
estimate_text_size,
|
||||
filter_enabled_stickers,
|
||||
has_time_range,
|
||||
safe_bool,
|
||||
safe_float,
|
||||
safe_int,
|
||||
sort_stickers_by_z_index,
|
||||
validate_image_sticker,
|
||||
validate_text_sticker,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 安全类型转换测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSafeFloat:
|
||||
"""safe_float 测试."""
|
||||
|
||||
def test_int_input(self):
|
||||
"""整数输入."""
|
||||
assert safe_float(42) == 42.0
|
||||
|
||||
def test_float_input(self):
|
||||
"""浮点数输入."""
|
||||
assert safe_float(3.14) == 3.14
|
||||
|
||||
def test_string_number(self):
|
||||
"""字符串数字."""
|
||||
assert safe_float("3.14") == 3.14
|
||||
|
||||
def test_string_int(self):
|
||||
"""字符串整数."""
|
||||
assert safe_float("100") == 100.0
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入."""
|
||||
assert safe_float(None) is None
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert safe_float("abc") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串."""
|
||||
assert safe_float("") is None
|
||||
|
||||
def test_zero(self):
|
||||
"""零值."""
|
||||
assert safe_float(0) == 0.0
|
||||
|
||||
def test_negative(self):
|
||||
"""负值."""
|
||||
assert safe_float(-5.5) == -5.5
|
||||
|
||||
|
||||
class TestSafeInt:
|
||||
"""safe_int 测试."""
|
||||
|
||||
def test_int_input(self):
|
||||
"""整数输入."""
|
||||
assert safe_int(42) == 42
|
||||
|
||||
def test_float_input(self):
|
||||
"""浮点数输入(截断)."""
|
||||
assert safe_int(3.7) == 3
|
||||
|
||||
def test_string_number(self):
|
||||
"""字符串数字."""
|
||||
assert safe_int("42") == 42
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入用默认值."""
|
||||
assert safe_int(None) == 0
|
||||
|
||||
def test_none_custom_default(self):
|
||||
"""None 输入自定义默认值."""
|
||||
assert safe_int(None, default=10) == 10
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert safe_int("abc") == 0
|
||||
|
||||
def test_negative(self):
|
||||
"""负值."""
|
||||
assert safe_int(-5) == -5
|
||||
|
||||
def test_zero(self):
|
||||
"""零值."""
|
||||
assert safe_int(0) == 0
|
||||
|
||||
|
||||
class TestSafeBool:
|
||||
"""safe_bool 测试."""
|
||||
|
||||
def test_true_bool(self):
|
||||
"""True."""
|
||||
assert safe_bool(True) is True
|
||||
|
||||
def test_false_bool(self):
|
||||
"""False."""
|
||||
assert safe_bool(False) is False
|
||||
|
||||
def test_none(self):
|
||||
"""None -> False."""
|
||||
assert safe_bool(None) is False
|
||||
|
||||
def test_string_true(self):
|
||||
"""字符串 true."""
|
||||
assert safe_bool("true") is True
|
||||
|
||||
def test_string_yes(self):
|
||||
"""字符串 yes."""
|
||||
assert safe_bool("yes") is True
|
||||
|
||||
def test_string_one(self):
|
||||
"""字符串 1."""
|
||||
assert safe_bool("1") is True
|
||||
|
||||
def test_string_false(self):
|
||||
"""字符串 false."""
|
||||
assert safe_bool("false") is False
|
||||
|
||||
def test_int_one(self):
|
||||
"""整数 1 -> True."""
|
||||
assert safe_bool(1) is True
|
||||
|
||||
def test_int_zero(self):
|
||||
"""整数 0 -> False."""
|
||||
assert safe_bool(0) is False
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表 -> False."""
|
||||
assert safe_bool([]) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 尺寸估算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateStickerSize:
|
||||
"""贴纸尺寸估算测试."""
|
||||
|
||||
def test_default_scale(self):
|
||||
"""默认 scale=1.0."""
|
||||
w, h = estimate_sticker_size(1000, 1000)
|
||||
assert w == 300 # 1000 * 0.3 * 1.0
|
||||
assert h == 300
|
||||
|
||||
def test_custom_scale(self):
|
||||
"""自定义缩放."""
|
||||
w, h = estimate_sticker_size(1000, 1000, scale=0.5)
|
||||
assert w == 150
|
||||
assert h == 150
|
||||
|
||||
def test_fixed_width_height(self):
|
||||
"""固定宽高."""
|
||||
w, h = estimate_sticker_size(1000, 1000, fixed_width=200, fixed_height=100)
|
||||
assert w == 200
|
||||
assert h == 100
|
||||
|
||||
def test_scale_2x(self):
|
||||
"""2倍缩放."""
|
||||
w, h = estimate_sticker_size(800, 600, scale=2.0)
|
||||
assert w == 480 # 800 * 0.3 * 2
|
||||
assert h == 360 # 600 * 0.3 * 2
|
||||
|
||||
def test_zero_canvas(self):
|
||||
"""零画布尺寸,返回最小 1."""
|
||||
w, h = estimate_sticker_size(0, 0)
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
|
||||
|
||||
class TestEstimateTextSize:
|
||||
"""文字尺寸估算测试."""
|
||||
|
||||
def test_normal_text(self):
|
||||
"""普通文字."""
|
||||
w, h = estimate_text_size("Hello", 36)
|
||||
assert w == int(5 * 36 * 0.6)
|
||||
assert h == int(36 * 1.4)
|
||||
|
||||
def test_empty_text(self):
|
||||
"""空文字."""
|
||||
w, h = estimate_text_size("", 36)
|
||||
assert w == 0
|
||||
assert h == 0
|
||||
|
||||
def test_large_font(self):
|
||||
"""大字号."""
|
||||
w, h = estimate_text_size("A", 72)
|
||||
assert w == int(1 * 72 * 0.6)
|
||||
assert h == int(72 * 1.4)
|
||||
|
||||
def test_chinese_chars(self):
|
||||
"""中文字符."""
|
||||
w, h = estimate_text_size("你好世界", 48)
|
||||
assert w == int(4 * 48 * 0.6)
|
||||
assert h == int(48 * 1.4)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 时间计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateFadeOutStart:
|
||||
"""淡出开始时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_fade_out_start(10, 30, 2) == pytest.approx(38.0)
|
||||
|
||||
def test_no_fade_out(self):
|
||||
"""无淡出."""
|
||||
assert calculate_fade_out_start(10, 30, 0) == 0.0
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""负淡出."""
|
||||
assert calculate_fade_out_start(10, 30, -1) == 0.0
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert calculate_fade_out_start(10, 0, 2) == 0.0
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出超过时长,返回 0."""
|
||||
# start=10, dur=5, fade=10 -> 10+5-10 = 5 > 0
|
||||
assert calculate_fade_out_start(10, 5, 10) == pytest.approx(5.0)
|
||||
|
||||
def test_fade_out_starts_before_zero(self):
|
||||
"""淡出开始时间在 0 之前,钳制到 0."""
|
||||
# start=0, dur=3, fade=5 -> 0+3-5 = -2 -> 0
|
||||
assert calculate_fade_out_start(0, 3, 5) == 0.0
|
||||
|
||||
|
||||
class TestCalculateEndTime:
|
||||
"""结束时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_end_time(10, 30) == 40.0
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert calculate_end_time(10, 0) == 10.0
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
assert calculate_end_time(10, -5) == 10.0
|
||||
|
||||
def test_zero_start(self):
|
||||
"""零开始."""
|
||||
assert calculate_end_time(0, 100) == 100.0
|
||||
|
||||
|
||||
class TestHasTimeRange:
|
||||
"""时间范围判断测试."""
|
||||
|
||||
def test_positive_duration(self):
|
||||
"""正时长."""
|
||||
assert has_time_range(30) is True
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert has_time_range(0) is False
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
assert has_time_range(-5) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScaleFilter:
|
||||
"""缩放滤镜构建测试."""
|
||||
|
||||
def test_fixed_width_height(self):
|
||||
"""固定宽高."""
|
||||
result = build_scale_filter(width=200, height=100)
|
||||
assert result == "scale=200:100"
|
||||
|
||||
def test_scale_only(self):
|
||||
"""仅缩放."""
|
||||
result = build_scale_filter(scale=0.5)
|
||||
assert result == "scale=iw*0.5:ih*0.5"
|
||||
|
||||
def test_no_scaling_needed(self):
|
||||
"""无需缩放."""
|
||||
result = build_scale_filter(scale=1.0)
|
||||
assert result is None
|
||||
|
||||
def test_scale_2x(self):
|
||||
"""2倍缩放."""
|
||||
result = build_scale_filter(scale=2.0)
|
||||
assert result == "scale=iw*2.0:ih*2.0"
|
||||
|
||||
def test_fixed_overrides_scale(self):
|
||||
"""固定宽高优先于 scale."""
|
||||
result = build_scale_filter(width=100, height=50, scale=0.5)
|
||||
assert result == "scale=100:50"
|
||||
|
||||
|
||||
class TestBuildOpacityFilter:
|
||||
"""透明度滤镜构建测试."""
|
||||
|
||||
def test_partial_opacity(self):
|
||||
"""部分透明."""
|
||||
result = build_opacity_filter(0.5)
|
||||
assert result == "colorchannelmixer=aa=0.5"
|
||||
|
||||
def test_fully_opaque(self):
|
||||
"""完全不透明."""
|
||||
result = build_opacity_filter(1.0)
|
||||
assert result is None
|
||||
|
||||
def test_fully_transparent(self):
|
||||
"""完全透明."""
|
||||
result = build_opacity_filter(0.0)
|
||||
assert result == "colorchannelmixer=aa=0.0"
|
||||
|
||||
def test_opacity_above_1_clamped(self):
|
||||
"""超过 1 被钳制."""
|
||||
result = build_opacity_filter(1.5)
|
||||
assert result is None
|
||||
|
||||
def test_opacity_below_0_clamped(self):
|
||||
"""低于 0 被钳制."""
|
||||
result = build_opacity_filter(-0.5)
|
||||
assert result == "colorchannelmixer=aa=0.0"
|
||||
|
||||
|
||||
class TestBuildImageFadeFilters:
|
||||
"""图片淡入淡出滤镜测试."""
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""仅淡入."""
|
||||
result = build_image_fade_filters(10, 30, fade_in=1.0)
|
||||
assert len(result) == 1
|
||||
assert "fade=in:st=10:d=1.0:alpha=1" in result[0]
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""仅淡出."""
|
||||
result = build_image_fade_filters(10, 30, fade_out=2.0)
|
||||
assert len(result) == 1
|
||||
assert "fade=out" in result[0]
|
||||
assert "st=38.0" in result[0] # 10 + 30 - 2 = 38
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出."""
|
||||
result = build_image_fade_filters(0, 10, fade_in=1.0, fade_out=1.0)
|
||||
assert len(result) == 2
|
||||
assert "fade=in" in result[0]
|
||||
assert "fade=out" in result[1]
|
||||
|
||||
def test_no_fade(self):
|
||||
"""无淡入淡出."""
|
||||
result = build_image_fade_filters(10, 30)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_zero_duration_no_fade_out(self):
|
||||
"""零时长不生成淡出."""
|
||||
result = build_image_fade_filters(10, 0, fade_out=1.0)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestBuildEnableExpr:
|
||||
"""enable 表达式构建测试."""
|
||||
|
||||
def test_normal_duration(self):
|
||||
"""正常时长."""
|
||||
result = build_enable_expr(10, 30)
|
||||
assert "between(t,10,40" in result
|
||||
assert "enable" in result
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长返回空."""
|
||||
result = build_enable_expr(10, 0)
|
||||
assert result == ""
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长返回空."""
|
||||
result = build_enable_expr(10, -5)
|
||||
assert result == ""
|
||||
|
||||
def test_zero_start(self):
|
||||
"""从零开始."""
|
||||
result = build_enable_expr(0, 100)
|
||||
assert "t,0,100" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# drawtext 相关测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEscapeDrawtextText:
|
||||
"""文字转义测试."""
|
||||
|
||||
def test_no_special_chars(self):
|
||||
"""无特殊字符."""
|
||||
assert escape_drawtext_text("Hello") == "Hello"
|
||||
|
||||
def test_colon_escaped(self):
|
||||
"""冒号转义."""
|
||||
assert escape_drawtext_text("a:b") == "a\\:b"
|
||||
|
||||
def test_quote_escaped(self):
|
||||
"""单引号转义."""
|
||||
assert escape_drawtext_text("it's") == "it\\'s"
|
||||
|
||||
def test_multiple_special_chars(self):
|
||||
"""多个特殊字符."""
|
||||
assert escape_drawtext_text("a:b:c'd") == "a\\:b\\:c\\'d"
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串."""
|
||||
assert escape_drawtext_text("") == ""
|
||||
|
||||
|
||||
class TestBuildDrawtextAlphaExpr:
|
||||
"""drawtext alpha 表达式测试."""
|
||||
|
||||
def test_no_fade(self):
|
||||
"""无淡入淡出."""
|
||||
assert build_drawtext_alpha_expr(10, 30) == "1"
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""仅淡入."""
|
||||
result = build_drawtext_alpha_expr(10, 30, fade_in=2.0)
|
||||
assert "if(lt(t,12.0)" in result
|
||||
assert "(t-10)/2.0" in result
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""仅淡出."""
|
||||
result = build_drawtext_alpha_expr(10, 30, fade_out=3.0)
|
||||
assert "if(gt(t,37" in result
|
||||
assert "-t)/3.0" in result
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出(相乘)."""
|
||||
result = build_drawtext_alpha_expr(0, 10, fade_in=1.0, fade_out=1.0)
|
||||
assert "*" in result
|
||||
assert result.count("if(") == 2
|
||||
|
||||
def test_zero_duration_no_fade_out(self):
|
||||
"""零时长不生成淡出."""
|
||||
result = build_drawtext_alpha_expr(10, 0, fade_out=1.0)
|
||||
assert result == "1"
|
||||
|
||||
|
||||
class TestBuildStrokeParams:
|
||||
"""描边参数测试."""
|
||||
|
||||
def test_no_stroke(self):
|
||||
"""无描边."""
|
||||
result = build_stroke_params(0)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_with_stroke(self):
|
||||
"""有描边."""
|
||||
result = build_stroke_params(2, "red")
|
||||
assert len(result) == 2
|
||||
assert "borderw=2" in result
|
||||
assert "bordercolor=red" in result
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
result = build_stroke_params(-1)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestBuildShadowParams:
|
||||
"""阴影参数测试."""
|
||||
|
||||
def test_no_shadow(self):
|
||||
"""无阴影."""
|
||||
result = build_shadow_params(0)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_with_shadow(self):
|
||||
"""有阴影."""
|
||||
result = build_shadow_params(0.5, 3, 4, "black")
|
||||
assert len(result) == 3
|
||||
assert "shadowx=3" in result
|
||||
assert "shadowy=4" in result
|
||||
assert "shadowcolor=black@0.5" in result
|
||||
|
||||
def test_shadow_alpha_clamped(self):
|
||||
"""透明度钳制."""
|
||||
result = build_shadow_params(1.5)
|
||||
assert "shadowcolor=black@1.0" in result[2]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 贴纸排序与过滤测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSortStickersByZIndex:
|
||||
"""贴纸排序测试."""
|
||||
|
||||
def test_sorted_by_z_index(self):
|
||||
"""按 z_index 排序."""
|
||||
stickers = [
|
||||
{"z_index": 20, "name": "top"},
|
||||
{"z_index": 5, "name": "bottom"},
|
||||
{"z_index": 10, "name": "middle"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "bottom"
|
||||
assert result[1]["name"] == "middle"
|
||||
assert result[2]["name"] == "top"
|
||||
|
||||
def test_same_z_index_preserves_order(self):
|
||||
"""相同 z_index 保持原顺序."""
|
||||
stickers = [
|
||||
{"z_index": 10, "name": "first"},
|
||||
{"z_index": 10, "name": "second"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "first"
|
||||
assert result[1]["name"] == "second"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert sort_stickers_by_z_index([]) == []
|
||||
|
||||
def test_default_z_index_10(self):
|
||||
"""无 z_index 默认 10."""
|
||||
stickers = [
|
||||
{"z_index": 5, "name": "low"},
|
||||
{"name": "default"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "low"
|
||||
assert result[1]["name"] == "default"
|
||||
|
||||
|
||||
class TestFilterEnabledStickers:
|
||||
"""启用贴纸过滤测试."""
|
||||
|
||||
def test_all_enabled(self):
|
||||
"""全部启用."""
|
||||
stickers = [{"enabled": True}, {"enabled": True}]
|
||||
assert len(filter_enabled_stickers(stickers)) == 2
|
||||
|
||||
def test_mixed(self):
|
||||
"""混合."""
|
||||
stickers = [
|
||||
{"enabled": True, "name": "a"},
|
||||
{"enabled": False, "name": "b"},
|
||||
{"enabled": True, "name": "c"},
|
||||
]
|
||||
result = filter_enabled_stickers(stickers)
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "a"
|
||||
|
||||
def test_default_enabled(self):
|
||||
"""默认启用."""
|
||||
stickers = [{"name": "a"}]
|
||||
result = filter_enabled_stickers(stickers)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert filter_enabled_stickers([]) == []
|
||||
|
||||
|
||||
class TestCountStickerTypes:
|
||||
"""贴纸类型统计测试."""
|
||||
|
||||
def test_mixed_types(self):
|
||||
"""混合类型."""
|
||||
stickers = [
|
||||
{"type": "image"},
|
||||
{"type": "text"},
|
||||
{"type": "image"},
|
||||
]
|
||||
counts = count_sticker_types(stickers)
|
||||
assert counts["image"] == 2
|
||||
assert counts["text"] == 1
|
||||
|
||||
def test_default_type(self):
|
||||
"""默认 image."""
|
||||
stickers = [{}]
|
||||
counts = count_sticker_types(stickers)
|
||||
assert counts["image"] == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_sticker_types([]) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# overlay 相关测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildOverlayPosition:
|
||||
"""overlay 位置构建测试."""
|
||||
|
||||
def test_integer_position(self):
|
||||
"""整数位置."""
|
||||
assert build_overlay_position(100, 200) == "100:200"
|
||||
|
||||
def test_float_position_rounded(self):
|
||||
"""浮点取整."""
|
||||
assert build_overlay_position(100.6, 200.4) == "101:200"
|
||||
|
||||
def test_zero_position(self):
|
||||
"""零位置."""
|
||||
assert build_overlay_position(0, 0) == "0:0"
|
||||
|
||||
def test_negative_position(self):
|
||||
"""负位置."""
|
||||
assert build_overlay_position(-10, -20) == "-10:-20"
|
||||
|
||||
|
||||
class TestBuildPreFilterLabel:
|
||||
"""预处理标签构建测试."""
|
||||
|
||||
def test_normal_idx(self):
|
||||
"""正常索引."""
|
||||
assert build_pre_filter_label(3) == "sticker_3_scaled"
|
||||
|
||||
def test_zero_idx(self):
|
||||
"""零索引."""
|
||||
assert build_pre_filter_label(0) == "sticker_0_scaled"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 验证函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateImageSticker:
|
||||
"""图片贴纸验证测试."""
|
||||
|
||||
def test_valid_with_image_path(self):
|
||||
"""有 image_path,合法."""
|
||||
ok, errors = validate_image_sticker({"image_path": "/a.png"})
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_valid_with_asset_id(self):
|
||||
"""有 asset_id,合法."""
|
||||
ok, errors = validate_image_sticker({"asset_id": "123"})
|
||||
assert ok is True
|
||||
|
||||
def test_missing_image_source(self):
|
||||
"""缺图片来源."""
|
||||
ok, errors = validate_image_sticker({})
|
||||
assert ok is False
|
||||
assert any("image_path" in e or "asset_id" in e for e in errors)
|
||||
|
||||
def test_opacity_out_of_range(self):
|
||||
"""透明度超范围."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"opacity": 1.5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("opacity" in e for e in errors)
|
||||
|
||||
def test_negative_scale(self):
|
||||
"""负缩放."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"scale": -0.5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("scale" in e for e in errors)
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"duration": -10,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("duration" in e for e in errors)
|
||||
|
||||
def test_multiple_errors(self):
|
||||
"""多个错误."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"opacity": 1.5,
|
||||
"duration": -1,
|
||||
"start_time": -5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert len(errors) >= 3
|
||||
|
||||
|
||||
class TestValidateTextSticker:
|
||||
"""文字贴纸验证测试."""
|
||||
|
||||
def test_valid(self):
|
||||
"""合法配置."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hello",
|
||||
"font_size": 36,
|
||||
"font_color": "white",
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_empty_text(self):
|
||||
"""空文字."""
|
||||
ok, errors = validate_text_sticker({"text": ""})
|
||||
assert ok is False
|
||||
assert any("text" in e for e in errors)
|
||||
|
||||
def test_zero_font_size(self):
|
||||
"""零字号."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"font_size": 0,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("font_size" in e for e in errors)
|
||||
|
||||
def test_empty_font_color(self):
|
||||
"""空颜色."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"font_color": "",
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("font_color" in e for e in errors)
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"duration": -5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("duration" in e for e in errors)
|
||||
Reference in New Issue
Block a user