diff --git a/apps/worker/video_processing/sticker_engine_pure.py b/apps/worker/video_processing/sticker_engine_pure.py new file mode 100755 index 000000000..1fc70487d --- /dev/null +++ b/apps/worker/video_processing/sticker_engine_pure.py @@ -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) diff --git a/tests/unit/test_sticker_engine_pure.py b/tests/unit/test_sticker_engine_pure.py new file mode 100755 index 000000000..f945f5192 --- /dev/null +++ b/tests/unit/test_sticker_engine_pure.py @@ -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)