From 4513f82fb126c14048e2d9ad4bec9995682f781f Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Tue, 8 Sep 2026 12:49:09 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20#1789=20=E6=A0=87=E9=A2=98=20drawte?= =?UTF-8?q?xt=20=E6=BB=A4=E9=95=9C=E6=B8=B2=E6=9F=93=20=E2=80=94=20?= =?UTF-8?q?=E5=8D=95=E8=A7=86=E9=A2=91+=E6=89=B9=E9=87=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=83=BD=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 video_filter_builder.py 新增 build_title_drawtext_filter(): - 从 title_config 生成 FFmpeg drawtext 滤镜 - 支持 position(top/center/bottom/custom+pos_x/pos_y) - 支持 font_size/font_color/bold/stroke/shadow - 字段名归一化:兼容 content↔text、font_preset↔font、size↔font_size、color↔font_color - 特殊字符转义(:/'%) - 字体搜索路径(Noto Sans CJK 优先) 在 video_compose_service.py build_compose_command() 集成: - 从 plan.config 读取 title_config(兼容 title/title_config 两个 key) - drawtext 滤镜链入 filter_complex([composed]→drawtext→[outv]) - 单视频和批量生成共用同一 compose 路径,标题都能生效 26 新测试覆盖:基本参数/位置/样式/字段归一化/特殊字符转义/输出尺寸 --- .../api/app/services/video_compose_service.py | 24 +++ packages/domain/video_filter_builder.py | 192 ++++++++++++++++- tests/unit/domain/test_title_drawtext_1789.py | 201 ++++++++++++++++++ 3 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 tests/unit/domain/test_title_drawtext_1789.py diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index 7ff2e7559..06b88f2a2 100755 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -39,6 +39,9 @@ from packages.domain.video_filter_builder import ( ) from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex +from packages.domain.video_filter_builder import ( + build_title_drawtext_filter, +) from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func from packages.domain.video_filter_builder import chain_filters as _chain_filters_func from packages.domain.video_filter_builder import has_audio as _has_audio_func @@ -248,6 +251,27 @@ class VideoComposeService: transitions=[c.transition_effect for c in ready_clips], ) + # ── #1789 标题 drawtext 滤镜叠加 ── + # 从 plan.config 读取 title_config,生成 drawtext 滤镜链入 filter_complex + title_cfg = (plan.config or {}).get("title", {}) or {} + if not isinstance(title_cfg, dict): + title_cfg = {} + # 同时兼容 plan.config["title_config"](API 回写路径) + if not title_cfg.get("text") and not title_cfg.get("content"): + title_cfg_alt = (plan.config or {}).get("title_config", {}) or {} + if isinstance(title_cfg_alt, dict) and (title_cfg_alt.get("text") or title_cfg_alt.get("content")): + title_cfg = title_cfg_alt + drawtext_filter = build_title_drawtext_filter(title_cfg, output_width, output_height) + if drawtext_filter: + # 将最终输出标签从 [outv] 改为 [composed],再链入 drawtext → [outv] + filter_complex = filter_complex.replace("[outv]", "[composed]") + filter_complex += f";[composed]{drawtext_filter}[outv]" + logger.info( + "[#1789] 标题 drawtext 滤镜已注入: plan_id=%s text=%s", + plan_id, + (title_cfg.get("text") or title_cfg.get("content") or "")[:30], + ) + # 构建完整命令 command: list[str] = ["ffmpeg", "-y"] diff --git a/packages/domain/video_filter_builder.py b/packages/domain/video_filter_builder.py index 7cbfc36b6..2915893fa 100755 --- a/packages/domain/video_filter_builder.py +++ b/packages/domain/video_filter_builder.py @@ -14,7 +14,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from packages.domain.template_clip_config import TransitionEffect @@ -373,3 +373,193 @@ def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) - # concat 滤镜(使用 audio_label 作为输入) audio_inputs = "".join(f"[{c.audio_label}]" for c in audio_chains) parts.append(f"{audio_inputs}concat=n={len(audio_chains)}:v=0:a=1[outa]") + + +# ── 标题 drawtext 滤镜构建(#1789)───────────────────────────────────────────── + +# drawtext 字体搜索路径:按优先级列出常见安装位置 +# 服务器使用 Noto Sans SC(思源黑体)作为默认字体 +DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf", + "/usr/share/fonts/noto/NotoSansSC-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", +] + +# 前端字体名 → drawtext 字体搜索关键字 +DRAWTEXT_FONT_MAP: dict[str, str] = { + "思源黑体": "NotoSansCJK", + "思源宋体": "NotoSerifCJK", + "苹方": "NotoSansCJK", + "PingFang": "NotoSansCJK", + "微软雅黑": "NotoSansCJK", + "楷体": "NotoSerifCJK", + "华康俪金黑": "NotoSansCJK", +} + + +def _escape_drawtext_text(text: str) -> str: + """转义 drawtext 特殊字符。 + + FFmpeg drawtext 要求转义: + - \\ → \\\\ + - ' → \\\\' + - : → \\\\: + - % → %%(drawtext 中 % 是时间码特殊字符) + """ + result = text.replace("\\", "\\\\\\\\") + result = result.replace("'", "\\\\'") + result = result.replace(":", "\\\\:") + result = result.replace("%", "%%") + return result + + +def _resolve_font_path(font_name: str) -> str: + """解析字体名到服务器实际字体文件路径。 + + 查找策略: + 1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字 + 2. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径 + 3. 未找到则返回空字符串(drawtext 使用内置默认字体) + """ + keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name) + import os + + for path in DRAWTEXT_FONT_SEARCH_PATHS: + if keyword.lower() in path.lower() and os.path.isfile(path): + return path + # fallback:遍历搜索任意可用字体 + for path in DRAWTEXT_FONT_SEARCH_PATHS: + if os.path.isfile(path): + return path + return "" + + +def build_title_drawtext_filter( + title_config: dict[str, Any], + output_width: int = DEFAULT_OUTPUT_WIDTH, + output_height: int = DEFAULT_OUTPUT_HEIGHT, +) -> str | None: + """从 title_config 生成 FFmpeg drawtext 滤镜字符串。 + + 支持前端 TitleSettings 的全部参数: + - text / 标题文字 + - font / 字体名 + - font_size / 字号 + - font_color / 颜色(#RRGGBB) + - position / 位置(top / center / bottom / custom) + - bold / 粗体 + - stroke / 描边 + - shadow / 阴影 + - pos_x, pos_y / 自由位置坐标 + + Args: + title_config: 标题配置 dict(来自 plan.config["title"]) + output_width: 输出视频宽度 + output_height: 输出视频高度 + + Returns: + drawtext 滤镜字符串;标题为空或 disabled 时返回 None + """ + if not title_config or not isinstance(title_config, dict): + return None + + # 字段名归一化:兼容 content/text、font_preset/font 两套命名 + text = (title_config.get("text") or title_config.get("content") or "").strip() + if not text: + return None + + enabled = title_config.get("enabled", True) + if not enabled: + return None + + # ── 样式参数 ── + font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体" + font_size = int(title_config.get("font_size") or title_config.get("size") or 36) + font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff" + # 去掉 # 前缀(drawtext 用纯 hex 或颜色名) + if font_color.startswith("#"): + font_color = font_color[1:] + + position = title_config.get("position", "top") + bold = bool(title_config.get("bold", True)) + stroke = title_config.get("stroke") + shadow = title_config.get("shadow") + + # ── 构建 drawtext 参数 ── + params: list[str] = [] + + # 字体文件 + font_path = _resolve_font_path(font_name) + if font_path: + escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'") + params.append(f"fontfile='{escaped_path}'") + + # 文字内容 + params.append(f"text='{_escape_drawtext_text(text)}'") + + # 字号 & 颜色 + params.append(f"fontsize={font_size}") + params.append(f"fontcolor={font_color}") + + # 粗体:bold 在 drawtext 中通过 font 的 Bold 变体实现 + # 若字体有 Bold 变体可用 fontfont=bold;否则通过 borderw 模拟 + if bold: + # 使用 font 参数尝试加载 Bold 变体(Noto Sans SC 有 Bold 变体文件) + params.append("font=bold") + + # 描边(borderw 需要 libfreetype 支持) + if stroke: + if isinstance(stroke, bool): + border_width = 2 + border_color = "black" + elif isinstance(stroke, dict): + border_width = int(stroke.get("width", 2)) if stroke.get("enabled", True) else 0 + border_color = (stroke.get("color") or "#000000").lstrip("#") + else: + border_width = 0 + border_color = "black" + if border_width > 0: + params.append(f"borderw={border_width}") + params.append(f"bordercolor={border_color}") + + # 阴影(shadowcolor + shadowx/y) + if shadow: + if isinstance(shadow, bool): + params.append("shadowcolor=black") + params.append("shadowx=2") + params.append("shadowy=2") + elif isinstance(shadow, dict): + if shadow.get("enabled", True): + params.append(f"shadowcolor={(shadow.get('color') or '#000000').lstrip('#')}") + params.append(f"shadowx={int(shadow.get('offset_x', 2))}") + params.append(f"shadowy={int(shadow.get('offset_y', 2))}") + + # ── 位置计算 ── + # 优先使用自定义坐标 pos_x / pos_y + pos_x = title_config.get("pos_x") + pos_y = title_config.get("pos_y") + if ( + position == "custom" + and isinstance(pos_x, (int, float)) + and isinstance(pos_y, (int, float)) + and not isinstance(pos_x, bool) + and not isinstance(pos_y, bool) + ): + params.append(f"x={int(pos_x)}") + params.append(f"y={int(pos_y)}") + else: + # 三档预设位置:top / center / bottom + # x 始终水平居中:(w-text_w)/2 + params.append("x=(w-text_w)/2") + if position == "center": + params.append("y=(h-text_h)/2") + elif position == "bottom": + params.append("y=h-text_h-50") + else: + # top(默认) + params.append("y=50") + + return "drawtext=" + ":".join(params) diff --git a/tests/unit/domain/test_title_drawtext_1789.py b/tests/unit/domain/test_title_drawtext_1789.py new file mode 100644 index 000000000..2a78572bc --- /dev/null +++ b/tests/unit/domain/test_title_drawtext_1789.py @@ -0,0 +1,201 @@ +"""#1789 标题 drawtext 滤镜构建 — 单元测试。 + +验证 build_title_drawtext_filter() 对各种 title_config 的滤镜生成: +- 基本参数:text、font_size、font_color +- 位置:top / center / bottom / custom(pos_x, pos_y) +- 样式:bold、stroke、shadow +- 字段归一化:content→text、font_preset→font、size→font_size、color→font_color +- 边界:空 text、disabled、无效 config +""" + +import pytest + +from packages.domain.video_filter_builder import build_title_drawtext_filter + + +class TestBuildTitleDrawtextFilter: + """build_title_drawtext_filter 基础行为测试。""" + + def test_none_config_returns_none(self): + assert build_title_drawtext_filter(None) is None + + def test_empty_config_returns_none(self): + assert build_title_drawtext_filter({}) is None + + def test_empty_text_returns_none(self): + assert build_title_drawtext_filter({"text": ""}) is None + assert build_title_drawtext_filter({"text": " "}) is None + + def test_disabled_returns_none(self): + assert build_title_drawtext_filter({"text": "标题", "enabled": False}) is None + + def test_basic_title(self): + result = build_title_drawtext_filter({"text": "测试标题", "font_size": 36}) + assert result is not None + assert result.startswith("drawtext=") + assert "text=" in result + assert "fontsize=36" in result + + def test_default_position_is_top(self): + result = build_title_drawtext_filter({"text": "标题"}) + assert result is not None + assert "x=(w-text_w)/2" in result + assert "y=50" in result + + def test_position_center(self): + result = build_title_drawtext_filter({"text": "标题", "position": "center"}) + assert result is not None + assert "y=(h-text_h)/2" in result + + def test_position_bottom(self): + result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}) + assert result is not None + assert "y=h-text_h-50" in result + + def test_position_custom_with_coords(self): + result = build_title_drawtext_filter( + { + "text": "标题", + "position": "custom", + "pos_x": 100, + "pos_y": 200, + } + ) + assert result is not None + assert "x=100" in result + assert "y=200" in result + + def test_position_custom_without_coords_fallback_to_top(self): + """custom 但没给坐标,回退到 top。""" + result = build_title_drawtext_filter( + { + "text": "标题", + "position": "custom", + } + ) + assert result is not None + assert "x=(w-text_w)/2" in result + assert "y=50" in result + + def test_font_color_hex(self): + result = build_title_drawtext_filter({"text": "标题", "font_color": "#ff4444"}) + assert result is not None + assert "fontcolor=ff4444" in result + + def test_bold_true(self): + result = build_title_drawtext_filter({"text": "标题", "bold": True}) + assert result is not None + assert "font=bold" in result + + def test_stroke_bool_true(self): + result = build_title_drawtext_filter({"text": "标题", "stroke": True}) + assert result is not None + assert "borderw=2" in result + assert "bordercolor=black" in result + + def test_stroke_dict(self): + result = build_title_drawtext_filter( + { + "text": "标题", + "stroke": {"enabled": True, "width": 3, "color": "#000000"}, + } + ) + assert result is not None + assert "borderw=3" in result + + def test_stroke_disabled(self): + result = build_title_drawtext_filter( + { + "text": "标题", + "stroke": {"enabled": False}, + } + ) + assert result is not None + assert "borderw" not in result + + def test_shadow_bool_true(self): + result = build_title_drawtext_filter({"text": "标题", "shadow": True}) + assert result is not None + assert "shadowcolor=black" in result + assert "shadowx=2" in result + assert "shadowy=2" in result + + def test_shadow_dict(self): + result = build_title_drawtext_filter( + { + "text": "标题", + "shadow": {"enabled": True, "offset_x": 3, "offset_y": 4, "color": "#333333"}, + } + ) + assert result is not None + assert "shadowx=3" in result + assert "shadowy=4" in result + assert "shadowcolor=333333" in result + + +class TestFieldNameNormalization: + """字段名归一化:兼容前端两套命名。""" + + def test_content_as_text(self): + """前端 TitleConfig 用 content 而非 text。""" + result = build_title_drawtext_filter({"content": "内容标题"}) + assert result is not None + assert "text=" in result + + def test_font_preset_as_font(self): + """前端 TitleConfig 用 font_preset 而非 font。""" + result = build_title_drawtext_filter({"text": "标题", "font_preset": "思源黑体"}) + assert result is not None + # 不检查具体字体路径(依赖服务器文件系统),只验证不崩溃 + assert "drawtext=" in result + + def test_size_as_font_size(self): + """兼容 size 字段。""" + result = build_title_drawtext_filter({"text": "标题", "size": 48}) + assert result is not None + assert "fontsize=48" in result + + def test_color_as_font_color(self): + """兼容 color 字段。""" + result = build_title_drawtext_filter({"text": "标题", "color": "#00ff00"}) + assert result is not None + assert "fontcolor=00ff00" in result + + def test_text_takes_precedence_over_content(self): + """text 优先于 content。""" + result = build_title_drawtext_filter({"text": "优先", "content": "备用"}) + assert result is not None + # 验证包含"优先"而不是"备用" + assert "优先" in result + + +class TestSpecialCharEscaping: + """drawtext 特殊字符转义。""" + + def test_colon_escaped(self): + result = build_title_drawtext_filter({"text": "标题:测试"}) + assert result is not None + # : 应该被转义 + assert "\\\\:" in result or "\\:" in result + + def test_single_quote_escaped(self): + result = build_title_drawtext_filter({"text": "it's a title"}) + assert result is not None + + def test_percent_escaped(self): + result = build_title_drawtext_filter({"text": "100%"}) + assert result is not None + assert "%%" in result + + +class TestOutputDimensions: + """输出尺寸参数传递。""" + + def test_custom_dimensions(self): + result = build_title_drawtext_filter( + {"text": "竖屏标题", "font_size": 60}, + output_width=1080, + output_height=1920, + ) + assert result is not None + assert "fontsize=60" in result -- 2.54.0 From 53f17db0a720293042294abff2258ec7b2690967 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Tue, 8 Sep 2026 12:59:54 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test:=20#1789=20=E8=A1=A5=E5=85=85=20drawte?= =?UTF-8?q?xt=20=E6=BB=A4=E9=95=9C=E5=88=86=E6=94=AF=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=8E=87=20=E2=80=94=2040=20=E4=B8=AA=E6=96=B0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _escape_drawtext_text 直接测试(6 用例) - _resolve_font_path mock 字体搜索/fallback/无字体(6 用例) - build_title_drawtext_filter 全分支覆盖:font_path 存在/不存在、stroke 各类型、shadow 各类型、bold、位置各模式、字段归一化(23 用例) - video_compose_service 标题集成测试(5 用例):drawtext 注入、title_config 回退、非 dict 容错 目标:CI diff coverage 从 17% 提升到 ≥40% --- .../test_title_drawtext_coverage_1789.py | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 tests/unit/domain/test_title_drawtext_coverage_1789.py diff --git a/tests/unit/domain/test_title_drawtext_coverage_1789.py b/tests/unit/domain/test_title_drawtext_coverage_1789.py new file mode 100644 index 000000000..f4c8132a1 --- /dev/null +++ b/tests/unit/domain/test_title_drawtext_coverage_1789.py @@ -0,0 +1,455 @@ +"""#1789 补充覆盖率测试 — 针对 CI diff coverage < 40% 的补救。 + +覆盖以下未覆盖的代码路径: +- _escape_drawtext_text 全部转义分支 +- _resolve_font_path 字体匹配 / fallback / 无字体 +- build_title_drawtext_filter 各种参数组合(font_path 存在、stroke/shadow 分支、位置分支) +- video_compose_service.build_compose_command 标题 drawtext 集成 +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch + +import pytest + +from packages.domain.video_filter_builder import ( + _escape_drawtext_text, + _resolve_font_path, + build_title_drawtext_filter, +) + +# ── _escape_drawtext_text 直接测试 ──────────────────────────────────────────── + + +class TestEscapeDrawtextText: + """直接测试转义函数,覆盖每一行。""" + + def test_backslash_escape(self): + result = _escape_drawtext_text("a\\b") + assert "\\\\" in result + + def test_single_quote_escape(self): + result = _escape_drawtext_text("it's") + assert "\\'" in result + + def test_colon_escape(self): + result = _escape_drawtext_text("a:b") + assert "\\:" in result + + def test_percent_escape(self): + result = _escape_drawtext_text("100%") + assert "%%" in result + + def test_all_special_chars_combined(self): + result = _escape_drawtext_text("\\':%") + assert "\\\\" in result + assert "\\'" in result + assert "\\:" in result + assert "%%" in result + + def test_no_special_chars(self): + result = _escape_drawtext_text("hello world") + assert result == "hello world" + + +# ── _resolve_font_path 测试 ─────────────────────────────────────────────────── + + +class TestResolveFontPath: + """测试字体路径解析逻辑。""" + + @patch("os.path.isfile") + def test_known_font_found(self, mock_isfile): + """已知字体名映射后找到文件。""" + mock_isfile.side_effect = lambda p: "NotoSansCJK" in p + result = _resolve_font_path("思源黑体") + assert result != "" + assert "NotoSansCJK" in result + + @patch("os.path.isfile") + def test_unknown_font_fallback(self, mock_isfile): + """未知字体名 → fallback 到第一个可用字体。""" + # 只有 DejaVu 存在 + mock_isfile.side_effect = lambda p: "DejaVu" in p + result = _resolve_font_path("UnknownFont") + assert "DejaVu" in result + + @patch("os.path.isfile") + def test_no_fonts_available(self, mock_isfile): + """没有任何字体 → 返回空字符串。""" + mock_isfile.return_value = False + result = _resolve_font_path("思源黑体") + assert result == "" + + @patch("os.path.isfile") + def test_passthrough_font_name(self, mock_isfile): + """不在映射表中的字体名直接作为关键字搜索。""" + mock_isfile.side_effect = lambda p: "NotoSansCJK" in p + result = _resolve_font_path("NotoSansCJK") + assert result != "" + + @patch("os.path.isfile") + def test_font_search_first_match(self, mock_isfile): + """关键字匹配第一个搜索路径。""" + mock_isfile.side_effect = lambda p: "opentype" in p + result = _resolve_font_path("思源黑体") + assert result != "" + assert "opentype" in result + + @patch("os.path.isfile") + def test_font_fallback_skips_nonexistent(self, mock_isfile): + """关键字不匹配任何路径时 fallback 到任意可用字体。""" + # 关键字映射后找不到,但 fallback 路径找到 DejaVu + call_count = [0] + + def side_effect(p): + call_count[0] += 1 + return "DejaVu" in p + + mock_isfile.side_effect = side_effect + result = _resolve_font_path("不存在字体") + assert "DejaVu" in result + + +# ── build_title_drawtext_filter 分支覆盖 ────────────────────────────────────── + + +class TestDrawtextFontFileIncluded: + """当字体文件存在时,fontfile 参数出现在输出中。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_fontfile_in_output(self, mock_font): + mock_font.return_value = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" + result = build_title_drawtext_filter({"text": "标题"}) + assert result is not None + assert "fontfile=" in result + assert "NotoSansCJK" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_fontfile_escaped(self, mock_font): + """字体路径中的特殊字符被转义。""" + mock_font.return_value = "/path/with:special'chars.ttf" + result = build_title_drawtext_filter({"text": "标题"}) + assert result is not None + assert "fontfile=" in result + + +class TestDrawtextFontFileNotIncluded: + """当字体文件不存在时,无 fontfile 参数。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_no_fontfile(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题"}) + assert result is not None + assert "fontfile=" not in result + + +class TestDrawtextStrokeBranches: + """stroke 各分支覆盖。""" + + def test_stroke_non_bool_non_dict(self): + """stroke 为非 bool/dict 类型 → border_width=0,不添加 borderw。""" + result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"}) + assert result is not None + assert "borderw" not in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_default_color(self, mock_font): + """stroke dict 没有 color → 默认 #000000。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}}) + assert result is not None + assert "borderw=4" in result + assert "bordercolor=000000" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_enabled_false(self, mock_font): + """stroke dict enabled=False → border_width=0。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}}) + assert result is not None + assert "borderw" not in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_custom_color(self, mock_font): + """stroke dict 自定义颜色。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}}) + assert result is not None + assert "bordercolor=ff0000" in result + + +class TestDrawtextShadowBranches: + """shadow 各分支覆盖。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_default_color(self, mock_font): + """shadow dict 没有 color → 默认 #000000。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}}) + assert result is not None + assert "shadowcolor=000000" in result + assert "shadowx=5" in result + assert "shadowy=5" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_disabled(self, mock_font): + """shadow dict enabled=False → 不添加阴影参数。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}}) + assert result is not None + assert "shadowcolor" not in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_custom_color(self, mock_font): + """shadow dict 自定义颜色。""" + mock_font.return_value = "" + result = build_title_drawtext_filter( + {"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}} + ) + assert result is not None + assert "shadowcolor=555555" in result + + +class TestDrawtextBoldFalse: + """bold=False 时不添加 font=bold。""" + + def test_bold_false(self): + result = build_title_drawtext_filter({"text": "标题", "bold": False}) + assert result is not None + assert "font=bold" not in result + + +class TestDrawtextPositionBranches: + """位置相关分支覆盖。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_top_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "top"}) + assert result is not None + assert "y=50" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_center_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "center"}) + assert result is not None + assert "y=(h-text_h)/2" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_bottom_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}) + assert result is not None + assert "y=h-text_h-50" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_custom_with_float_coords(self, mock_font): + """自定义位置用浮点坐标 → 转为整数。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3}) + assert result is not None + assert "x=100" in result + assert "y=200" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_custom_bool_coords_fallback(self, mock_font): + """pos_x/pos_y 为 bool 时回退到 top(bool 是 int 子类需排除)。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True}) + assert result is not None + assert "x=(w-text_w)/2" in result + assert "y=50" in result + + +class TestDrawtextColorNoHash: + """颜色不带 # 前缀。""" + + def test_color_without_hash(self): + result = build_title_drawtext_filter({"text": "标题", "font_color": "red"}) + assert result is not None + assert "fontcolor=red" in result + + +class TestDrawtextFieldNormalization: + """字段归一化覆盖更多分支。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_content_fallback(self, mock_font): + """text 为空时从 content 取值。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"content": "备用标题"}) + assert result is not None + assert "备用标题" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_font_preset_fallback(self, mock_font): + """font 为空时从 font_preset 取值。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"}) + assert result is not None + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_size_fallback(self, mock_font): + """font_size 为空时从 size 取值。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "size": 72}) + assert result is not None + assert "fontsize=72" in result + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_color_fallback(self, mock_font): + """font_color 为空时从 color 取值。""" + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"}) + assert result is not None + assert "fontcolor=abcdef" in result + + +class TestDrawtextNotDictConfig: + """非 dict 的 title_config。""" + + def test_string_config_returns_none(self): + assert build_title_drawtext_filter("not a dict") is None + + def test_list_config_returns_none(self): + assert build_title_drawtext_filter([1, 2, 3]) is None + + +# ── video_compose_service 集成测试 ──────────────────────────────────────────── + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + + +from packages.domain.edit_plan import EditPlanStatus +from packages.domain.edit_plan_clip import EditPlanClipStatus + + +class _StubClip: + def __init__( + self, + clip_id="clip-1", + plan_id="plan-1", + asset_id="assets/video.mp4", + duration=5.0, + status=EditPlanClipStatus.READY, + ): + self.id = clip_id + self.plan_id = plan_id + self.clip_type = "main" + self.order = 0 + self.asset_id = asset_id + self.duration = duration + self.start_time = 0.0 + self.transition_effect = "cut" + self.status = status + self.template_clip_config_id = "" + self.text_content = "" + self.config = {} + + +class _StubPlan: + def __init__(self, plan_id="plan-1", config=None): + self.id = plan_id + self.template_id = "tpl-1" + self.name = "测试计划" + self.status = EditPlanStatus.EDITING + self.total_duration = 0.0 + self.config = config or {} + + +class _StubPlanRepo: + def __init__(self, plans): + self._plans = plans + + def get(self, plan_id): + return self._plans.get(plan_id) + + +class _StubClipRepo: + def __init__(self, clips): + self._clips = {c.id: c for c in clips} + self._by_plan = {} + for c in clips: + self._by_plan.setdefault(c.plan_id, []).append(c) + + def get(self, clip_id): + return self._clips.get(clip_id) + + def list_by_plan(self, plan_id, skip=0, limit=100): + return self._by_plan.get(plan_id, [])[skip : skip + limit] + + +def _make_svc(plan, clips): + from app.services.video_compose_service import VideoComposeService + + svc = VideoComposeService.__new__(VideoComposeService) + svc._db = None + svc._plan_repo = _StubPlanRepo({plan.id: plan}) + svc._clip_repo = _StubClipRepo(clips) + return svc + + +class TestComposeCommandTitleDrawtext(TestCase): + """build_compose_command 中标题 drawtext 集成测试。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_config_injected(self, mock_font): + """plan.config 有 title 时,filter_complex 包含 drawtext。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {"text": "测试标题", "font_size": 48, "position": "top"}}) + clips = [_StubClip(plan_id=plan.id)] + svc = _make_svc(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertIn("drawtext=", cmd.filter_complex) + self.assertIn("[composed]", cmd.filter_complex) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_config_alt_key(self, mock_font): + """plan.config['title'] 无文本时回退到 title_config。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {}, "title_config": {"text": "备用标题", "font_size": 36}}) + clips = [_StubClip(plan_id=plan.id)] + svc = _make_svc(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertIn("drawtext=", cmd.filter_complex) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_no_title_no_drawtext(self, mock_font): + """无标题配置时,filter_complex 不包含 drawtext。""" + mock_font.return_value = "" + plan = _StubPlan(config={}) + clips = [_StubClip(plan_id=plan.id)] + svc = _make_svc(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertNotIn("drawtext=", cmd.filter_complex) + # 单片段输出标签是 [v0],不是 [outv] + self.assertIn("[v0]", cmd.filter_complex) + + def test_title_config_not_dict(self): + """title config 为非 dict 值时不崩溃。""" + plan = _StubPlan(config={"title": "not a dict"}) + clips = [_StubClip(plan_id=plan.id)] + svc = _make_svc(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + # 不应崩溃 + self.assertIsNotNone(cmd) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_content_field(self, mock_font): + """title config 使用 content 字段(前端 TitleConfig 命名)。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {"content": "内容标题", "font_size": 36}}) + clips = [_StubClip(plan_id=plan.id)] + svc = _make_svc(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + self.assertIn("drawtext=", cmd.filter_complex) -- 2.54.0 From 51b1556f8b831a540fa0c2f5e7a792be774c4ff8 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Tue, 8 Sep 2026 13:07:50 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test:=20#1789=20=E5=B0=86=20drawtext=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=90=88=E5=B9=B6=E5=88=B0=20CI=20=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E7=9A=84=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 增量测试模式只运行与变更源文件映射的测试文件: - video_filter_builder.py → test_video_filter_builder.py - video_compose_service.py → test_video_compose_service.py 之前独立测试文件 (test_title_drawtext_*.py) 未被映射所以被跳过, 导致 diff coverage 始终只有 17%。 本次改动: - 37 个 drawtext 测试合并到 test_video_filter_builder.py - 5 个 compose 标题集成测试合并到 test_video_compose_service.py - _StubPlan 新增 config 参数支持 - 删除独立测试文件(不再需要) 共 155 个测试全部通过。 --- tests/unit/domain/test_title_drawtext_1789.py | 201 -------- .../test_title_drawtext_coverage_1789.py | 455 ------------------ tests/unit/test_video_compose_service.py | 63 ++- tests/unit/test_video_filter_builder.py | 264 ++++++++++ 4 files changed, 326 insertions(+), 657 deletions(-) delete mode 100644 tests/unit/domain/test_title_drawtext_1789.py delete mode 100644 tests/unit/domain/test_title_drawtext_coverage_1789.py diff --git a/tests/unit/domain/test_title_drawtext_1789.py b/tests/unit/domain/test_title_drawtext_1789.py deleted file mode 100644 index 2a78572bc..000000000 --- a/tests/unit/domain/test_title_drawtext_1789.py +++ /dev/null @@ -1,201 +0,0 @@ -"""#1789 标题 drawtext 滤镜构建 — 单元测试。 - -验证 build_title_drawtext_filter() 对各种 title_config 的滤镜生成: -- 基本参数:text、font_size、font_color -- 位置:top / center / bottom / custom(pos_x, pos_y) -- 样式:bold、stroke、shadow -- 字段归一化:content→text、font_preset→font、size→font_size、color→font_color -- 边界:空 text、disabled、无效 config -""" - -import pytest - -from packages.domain.video_filter_builder import build_title_drawtext_filter - - -class TestBuildTitleDrawtextFilter: - """build_title_drawtext_filter 基础行为测试。""" - - def test_none_config_returns_none(self): - assert build_title_drawtext_filter(None) is None - - def test_empty_config_returns_none(self): - assert build_title_drawtext_filter({}) is None - - def test_empty_text_returns_none(self): - assert build_title_drawtext_filter({"text": ""}) is None - assert build_title_drawtext_filter({"text": " "}) is None - - def test_disabled_returns_none(self): - assert build_title_drawtext_filter({"text": "标题", "enabled": False}) is None - - def test_basic_title(self): - result = build_title_drawtext_filter({"text": "测试标题", "font_size": 36}) - assert result is not None - assert result.startswith("drawtext=") - assert "text=" in result - assert "fontsize=36" in result - - def test_default_position_is_top(self): - result = build_title_drawtext_filter({"text": "标题"}) - assert result is not None - assert "x=(w-text_w)/2" in result - assert "y=50" in result - - def test_position_center(self): - result = build_title_drawtext_filter({"text": "标题", "position": "center"}) - assert result is not None - assert "y=(h-text_h)/2" in result - - def test_position_bottom(self): - result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}) - assert result is not None - assert "y=h-text_h-50" in result - - def test_position_custom_with_coords(self): - result = build_title_drawtext_filter( - { - "text": "标题", - "position": "custom", - "pos_x": 100, - "pos_y": 200, - } - ) - assert result is not None - assert "x=100" in result - assert "y=200" in result - - def test_position_custom_without_coords_fallback_to_top(self): - """custom 但没给坐标,回退到 top。""" - result = build_title_drawtext_filter( - { - "text": "标题", - "position": "custom", - } - ) - assert result is not None - assert "x=(w-text_w)/2" in result - assert "y=50" in result - - def test_font_color_hex(self): - result = build_title_drawtext_filter({"text": "标题", "font_color": "#ff4444"}) - assert result is not None - assert "fontcolor=ff4444" in result - - def test_bold_true(self): - result = build_title_drawtext_filter({"text": "标题", "bold": True}) - assert result is not None - assert "font=bold" in result - - def test_stroke_bool_true(self): - result = build_title_drawtext_filter({"text": "标题", "stroke": True}) - assert result is not None - assert "borderw=2" in result - assert "bordercolor=black" in result - - def test_stroke_dict(self): - result = build_title_drawtext_filter( - { - "text": "标题", - "stroke": {"enabled": True, "width": 3, "color": "#000000"}, - } - ) - assert result is not None - assert "borderw=3" in result - - def test_stroke_disabled(self): - result = build_title_drawtext_filter( - { - "text": "标题", - "stroke": {"enabled": False}, - } - ) - assert result is not None - assert "borderw" not in result - - def test_shadow_bool_true(self): - result = build_title_drawtext_filter({"text": "标题", "shadow": True}) - assert result is not None - assert "shadowcolor=black" in result - assert "shadowx=2" in result - assert "shadowy=2" in result - - def test_shadow_dict(self): - result = build_title_drawtext_filter( - { - "text": "标题", - "shadow": {"enabled": True, "offset_x": 3, "offset_y": 4, "color": "#333333"}, - } - ) - assert result is not None - assert "shadowx=3" in result - assert "shadowy=4" in result - assert "shadowcolor=333333" in result - - -class TestFieldNameNormalization: - """字段名归一化:兼容前端两套命名。""" - - def test_content_as_text(self): - """前端 TitleConfig 用 content 而非 text。""" - result = build_title_drawtext_filter({"content": "内容标题"}) - assert result is not None - assert "text=" in result - - def test_font_preset_as_font(self): - """前端 TitleConfig 用 font_preset 而非 font。""" - result = build_title_drawtext_filter({"text": "标题", "font_preset": "思源黑体"}) - assert result is not None - # 不检查具体字体路径(依赖服务器文件系统),只验证不崩溃 - assert "drawtext=" in result - - def test_size_as_font_size(self): - """兼容 size 字段。""" - result = build_title_drawtext_filter({"text": "标题", "size": 48}) - assert result is not None - assert "fontsize=48" in result - - def test_color_as_font_color(self): - """兼容 color 字段。""" - result = build_title_drawtext_filter({"text": "标题", "color": "#00ff00"}) - assert result is not None - assert "fontcolor=00ff00" in result - - def test_text_takes_precedence_over_content(self): - """text 优先于 content。""" - result = build_title_drawtext_filter({"text": "优先", "content": "备用"}) - assert result is not None - # 验证包含"优先"而不是"备用" - assert "优先" in result - - -class TestSpecialCharEscaping: - """drawtext 特殊字符转义。""" - - def test_colon_escaped(self): - result = build_title_drawtext_filter({"text": "标题:测试"}) - assert result is not None - # : 应该被转义 - assert "\\\\:" in result or "\\:" in result - - def test_single_quote_escaped(self): - result = build_title_drawtext_filter({"text": "it's a title"}) - assert result is not None - - def test_percent_escaped(self): - result = build_title_drawtext_filter({"text": "100%"}) - assert result is not None - assert "%%" in result - - -class TestOutputDimensions: - """输出尺寸参数传递。""" - - def test_custom_dimensions(self): - result = build_title_drawtext_filter( - {"text": "竖屏标题", "font_size": 60}, - output_width=1080, - output_height=1920, - ) - assert result is not None - assert "fontsize=60" in result diff --git a/tests/unit/domain/test_title_drawtext_coverage_1789.py b/tests/unit/domain/test_title_drawtext_coverage_1789.py deleted file mode 100644 index f4c8132a1..000000000 --- a/tests/unit/domain/test_title_drawtext_coverage_1789.py +++ /dev/null @@ -1,455 +0,0 @@ -"""#1789 补充覆盖率测试 — 针对 CI diff coverage < 40% 的补救。 - -覆盖以下未覆盖的代码路径: -- _escape_drawtext_text 全部转义分支 -- _resolve_font_path 字体匹配 / fallback / 无字体 -- build_title_drawtext_filter 各种参数组合(font_path 存在、stroke/shadow 分支、位置分支) -- video_compose_service.build_compose_command 标题 drawtext 集成 -""" - -from __future__ import annotations - -import sys -from pathlib import Path -from unittest import TestCase -from unittest.mock import patch - -import pytest - -from packages.domain.video_filter_builder import ( - _escape_drawtext_text, - _resolve_font_path, - build_title_drawtext_filter, -) - -# ── _escape_drawtext_text 直接测试 ──────────────────────────────────────────── - - -class TestEscapeDrawtextText: - """直接测试转义函数,覆盖每一行。""" - - def test_backslash_escape(self): - result = _escape_drawtext_text("a\\b") - assert "\\\\" in result - - def test_single_quote_escape(self): - result = _escape_drawtext_text("it's") - assert "\\'" in result - - def test_colon_escape(self): - result = _escape_drawtext_text("a:b") - assert "\\:" in result - - def test_percent_escape(self): - result = _escape_drawtext_text("100%") - assert "%%" in result - - def test_all_special_chars_combined(self): - result = _escape_drawtext_text("\\':%") - assert "\\\\" in result - assert "\\'" in result - assert "\\:" in result - assert "%%" in result - - def test_no_special_chars(self): - result = _escape_drawtext_text("hello world") - assert result == "hello world" - - -# ── _resolve_font_path 测试 ─────────────────────────────────────────────────── - - -class TestResolveFontPath: - """测试字体路径解析逻辑。""" - - @patch("os.path.isfile") - def test_known_font_found(self, mock_isfile): - """已知字体名映射后找到文件。""" - mock_isfile.side_effect = lambda p: "NotoSansCJK" in p - result = _resolve_font_path("思源黑体") - assert result != "" - assert "NotoSansCJK" in result - - @patch("os.path.isfile") - def test_unknown_font_fallback(self, mock_isfile): - """未知字体名 → fallback 到第一个可用字体。""" - # 只有 DejaVu 存在 - mock_isfile.side_effect = lambda p: "DejaVu" in p - result = _resolve_font_path("UnknownFont") - assert "DejaVu" in result - - @patch("os.path.isfile") - def test_no_fonts_available(self, mock_isfile): - """没有任何字体 → 返回空字符串。""" - mock_isfile.return_value = False - result = _resolve_font_path("思源黑体") - assert result == "" - - @patch("os.path.isfile") - def test_passthrough_font_name(self, mock_isfile): - """不在映射表中的字体名直接作为关键字搜索。""" - mock_isfile.side_effect = lambda p: "NotoSansCJK" in p - result = _resolve_font_path("NotoSansCJK") - assert result != "" - - @patch("os.path.isfile") - def test_font_search_first_match(self, mock_isfile): - """关键字匹配第一个搜索路径。""" - mock_isfile.side_effect = lambda p: "opentype" in p - result = _resolve_font_path("思源黑体") - assert result != "" - assert "opentype" in result - - @patch("os.path.isfile") - def test_font_fallback_skips_nonexistent(self, mock_isfile): - """关键字不匹配任何路径时 fallback 到任意可用字体。""" - # 关键字映射后找不到,但 fallback 路径找到 DejaVu - call_count = [0] - - def side_effect(p): - call_count[0] += 1 - return "DejaVu" in p - - mock_isfile.side_effect = side_effect - result = _resolve_font_path("不存在字体") - assert "DejaVu" in result - - -# ── build_title_drawtext_filter 分支覆盖 ────────────────────────────────────── - - -class TestDrawtextFontFileIncluded: - """当字体文件存在时,fontfile 参数出现在输出中。""" - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_fontfile_in_output(self, mock_font): - mock_font.return_value = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" - result = build_title_drawtext_filter({"text": "标题"}) - assert result is not None - assert "fontfile=" in result - assert "NotoSansCJK" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_fontfile_escaped(self, mock_font): - """字体路径中的特殊字符被转义。""" - mock_font.return_value = "/path/with:special'chars.ttf" - result = build_title_drawtext_filter({"text": "标题"}) - assert result is not None - assert "fontfile=" in result - - -class TestDrawtextFontFileNotIncluded: - """当字体文件不存在时,无 fontfile 参数。""" - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_no_fontfile(self, mock_font): - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题"}) - assert result is not None - assert "fontfile=" not in result - - -class TestDrawtextStrokeBranches: - """stroke 各分支覆盖。""" - - def test_stroke_non_bool_non_dict(self): - """stroke 为非 bool/dict 类型 → border_width=0,不添加 borderw。""" - result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"}) - assert result is not None - assert "borderw" not in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_stroke_dict_default_color(self, mock_font): - """stroke dict 没有 color → 默认 #000000。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}}) - assert result is not None - assert "borderw=4" in result - assert "bordercolor=000000" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_stroke_dict_enabled_false(self, mock_font): - """stroke dict enabled=False → border_width=0。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}}) - assert result is not None - assert "borderw" not in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_stroke_dict_custom_color(self, mock_font): - """stroke dict 自定义颜色。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}}) - assert result is not None - assert "bordercolor=ff0000" in result - - -class TestDrawtextShadowBranches: - """shadow 各分支覆盖。""" - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_shadow_dict_default_color(self, mock_font): - """shadow dict 没有 color → 默认 #000000。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}}) - assert result is not None - assert "shadowcolor=000000" in result - assert "shadowx=5" in result - assert "shadowy=5" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_shadow_dict_disabled(self, mock_font): - """shadow dict enabled=False → 不添加阴影参数。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}}) - assert result is not None - assert "shadowcolor" not in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_shadow_dict_custom_color(self, mock_font): - """shadow dict 自定义颜色。""" - mock_font.return_value = "" - result = build_title_drawtext_filter( - {"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}} - ) - assert result is not None - assert "shadowcolor=555555" in result - - -class TestDrawtextBoldFalse: - """bold=False 时不添加 font=bold。""" - - def test_bold_false(self): - result = build_title_drawtext_filter({"text": "标题", "bold": False}) - assert result is not None - assert "font=bold" not in result - - -class TestDrawtextPositionBranches: - """位置相关分支覆盖。""" - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_position_top_explicit(self, mock_font): - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "position": "top"}) - assert result is not None - assert "y=50" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_position_center_explicit(self, mock_font): - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "position": "center"}) - assert result is not None - assert "y=(h-text_h)/2" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_position_bottom_explicit(self, mock_font): - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}) - assert result is not None - assert "y=h-text_h-50" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_position_custom_with_float_coords(self, mock_font): - """自定义位置用浮点坐标 → 转为整数。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3}) - assert result is not None - assert "x=100" in result - assert "y=200" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_position_custom_bool_coords_fallback(self, mock_font): - """pos_x/pos_y 为 bool 时回退到 top(bool 是 int 子类需排除)。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True}) - assert result is not None - assert "x=(w-text_w)/2" in result - assert "y=50" in result - - -class TestDrawtextColorNoHash: - """颜色不带 # 前缀。""" - - def test_color_without_hash(self): - result = build_title_drawtext_filter({"text": "标题", "font_color": "red"}) - assert result is not None - assert "fontcolor=red" in result - - -class TestDrawtextFieldNormalization: - """字段归一化覆盖更多分支。""" - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_content_fallback(self, mock_font): - """text 为空时从 content 取值。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"content": "备用标题"}) - assert result is not None - assert "备用标题" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_font_preset_fallback(self, mock_font): - """font 为空时从 font_preset 取值。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"}) - assert result is not None - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_size_fallback(self, mock_font): - """font_size 为空时从 size 取值。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "size": 72}) - assert result is not None - assert "fontsize=72" in result - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_color_fallback(self, mock_font): - """font_color 为空时从 color 取值。""" - mock_font.return_value = "" - result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"}) - assert result is not None - assert "fontcolor=abcdef" in result - - -class TestDrawtextNotDictConfig: - """非 dict 的 title_config。""" - - def test_string_config_returns_none(self): - assert build_title_drawtext_filter("not a dict") is None - - def test_list_config_returns_none(self): - assert build_title_drawtext_filter([1, 2, 3]) is None - - -# ── video_compose_service 集成测试 ──────────────────────────────────────────── - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) - - -from packages.domain.edit_plan import EditPlanStatus -from packages.domain.edit_plan_clip import EditPlanClipStatus - - -class _StubClip: - def __init__( - self, - clip_id="clip-1", - plan_id="plan-1", - asset_id="assets/video.mp4", - duration=5.0, - status=EditPlanClipStatus.READY, - ): - self.id = clip_id - self.plan_id = plan_id - self.clip_type = "main" - self.order = 0 - self.asset_id = asset_id - self.duration = duration - self.start_time = 0.0 - self.transition_effect = "cut" - self.status = status - self.template_clip_config_id = "" - self.text_content = "" - self.config = {} - - -class _StubPlan: - def __init__(self, plan_id="plan-1", config=None): - self.id = plan_id - self.template_id = "tpl-1" - self.name = "测试计划" - self.status = EditPlanStatus.EDITING - self.total_duration = 0.0 - self.config = config or {} - - -class _StubPlanRepo: - def __init__(self, plans): - self._plans = plans - - def get(self, plan_id): - return self._plans.get(plan_id) - - -class _StubClipRepo: - def __init__(self, clips): - self._clips = {c.id: c for c in clips} - self._by_plan = {} - for c in clips: - self._by_plan.setdefault(c.plan_id, []).append(c) - - def get(self, clip_id): - return self._clips.get(clip_id) - - def list_by_plan(self, plan_id, skip=0, limit=100): - return self._by_plan.get(plan_id, [])[skip : skip + limit] - - -def _make_svc(plan, clips): - from app.services.video_compose_service import VideoComposeService - - svc = VideoComposeService.__new__(VideoComposeService) - svc._db = None - svc._plan_repo = _StubPlanRepo({plan.id: plan}) - svc._clip_repo = _StubClipRepo(clips) - return svc - - -class TestComposeCommandTitleDrawtext(TestCase): - """build_compose_command 中标题 drawtext 集成测试。""" - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_title_config_injected(self, mock_font): - """plan.config 有 title 时,filter_complex 包含 drawtext。""" - mock_font.return_value = "" - plan = _StubPlan(config={"title": {"text": "测试标题", "font_size": 48, "position": "top"}}) - clips = [_StubClip(plan_id=plan.id)] - svc = _make_svc(plan, clips) - cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") - - self.assertIn("drawtext=", cmd.filter_complex) - self.assertIn("[composed]", cmd.filter_complex) - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_title_config_alt_key(self, mock_font): - """plan.config['title'] 无文本时回退到 title_config。""" - mock_font.return_value = "" - plan = _StubPlan(config={"title": {}, "title_config": {"text": "备用标题", "font_size": 36}}) - clips = [_StubClip(plan_id=plan.id)] - svc = _make_svc(plan, clips) - cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") - - self.assertIn("drawtext=", cmd.filter_complex) - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_no_title_no_drawtext(self, mock_font): - """无标题配置时,filter_complex 不包含 drawtext。""" - mock_font.return_value = "" - plan = _StubPlan(config={}) - clips = [_StubClip(plan_id=plan.id)] - svc = _make_svc(plan, clips) - cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") - - self.assertNotIn("drawtext=", cmd.filter_complex) - # 单片段输出标签是 [v0],不是 [outv] - self.assertIn("[v0]", cmd.filter_complex) - - def test_title_config_not_dict(self): - """title config 为非 dict 值时不崩溃。""" - plan = _StubPlan(config={"title": "not a dict"}) - clips = [_StubClip(plan_id=plan.id)] - svc = _make_svc(plan, clips) - cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") - # 不应崩溃 - self.assertIsNotNone(cmd) - - @patch("packages.domain.video_filter_builder._resolve_font_path") - def test_title_content_field(self, mock_font): - """title config 使用 content 字段(前端 TitleConfig 命名)。""" - mock_font.return_value = "" - plan = _StubPlan(config={"title": {"content": "内容标题", "font_size": 36}}) - clips = [_StubClip(plan_id=plan.id)] - svc = _make_svc(plan, clips) - cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") - self.assertIn("drawtext=", cmd.filter_complex) diff --git a/tests/unit/test_video_compose_service.py b/tests/unit/test_video_compose_service.py index 7bb2a24db..bda6ea9b9 100755 --- a/tests/unit/test_video_compose_service.py +++ b/tests/unit/test_video_compose_service.py @@ -8,6 +8,7 @@ from __future__ import annotations import sys from pathlib import Path from unittest import TestCase +from unittest.mock import patch # 修正 import 路径 sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) @@ -62,13 +63,14 @@ class _StubPlan: self, plan_id: str = "plan-1", status: EditPlanStatus = EditPlanStatus.EDITING, + config: dict | None = None, ): self.id = plan_id self.template_id = "tpl-1" self.name = "测试计划" self.status = status self.total_duration = 0.0 - self.config = {} + self.config = config or {} # ── Stub 仓储 ───────────────────────────────────────────────────────────────── @@ -712,6 +714,65 @@ class TestHasAudioTitleSubtitleFix(TestCase): self.assertEqual(chain.audio_label, "a0") +# ── #1789 标题 drawtext 集成测试 ────────────────────────────────────────────── + + +class TestComposeCommandTitleDrawtext(TestCase): + """build_compose_command 中标题 drawtext 集成测试。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_config_injected(self, mock_font): + """plan.config 有 title 时,filter_complex 包含 drawtext。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {"text": "测试标题", "font_size": 48, "position": "top"}}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertIn("drawtext=", cmd.filter_complex) + self.assertIn("[composed]", cmd.filter_complex) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_config_alt_key(self, mock_font): + """plan.config['title'] 无文本时回退到 title_config。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {}, "title_config": {"text": "备用标题", "font_size": 36}}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertIn("drawtext=", cmd.filter_complex) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_no_title_no_drawtext(self, mock_font): + """无标题配置时,filter_complex 不包含 drawtext。""" + mock_font.return_value = "" + plan = _StubPlan(config={}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertNotIn("drawtext=", cmd.filter_complex) + + def test_title_config_not_dict(self): + """title config 为非 dict 值时不崩溃。""" + plan = _StubPlan(config={"title": "not a dict"}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + self.assertIsNotNone(cmd) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_content_field(self, mock_font): + """title config 使用 content 字段(前端 TitleConfig 命名)。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {"content": "内容标题", "font_size": 36}}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + self.assertIn("drawtext=", cmd.filter_complex) + + if __name__ == "__main__": import unittest diff --git a/tests/unit/test_video_filter_builder.py b/tests/unit/test_video_filter_builder.py index bb984165e..0832dffdb 100755 --- a/tests/unit/test_video_filter_builder.py +++ b/tests/unit/test_video_filter_builder.py @@ -15,6 +15,7 @@ from __future__ import annotations import unittest from dataclasses import FrozenInstanceError +from unittest.mock import patch from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus from packages.domain.template_clip_config import TransitionEffect @@ -26,9 +27,12 @@ from packages.domain.video_filter_builder import ( DEFAULT_TRANSITION_DURATION, XFADE_TRANSITION_MAP, ClipFilterChain, + _escape_drawtext_text, + _resolve_font_path, build_clip_filter, build_concat_filter, build_filter_complex, + build_title_drawtext_filter, build_xfade_filter, chain_filters, has_audio, @@ -852,5 +856,265 @@ class TestEndToEndFilterBuilding(unittest.TestCase): self.assertNotIn("[a0]", filter_str) +# ── #1789 标题 drawtext 滤镜补充覆盖率 ────────────────────────────────────── + + +class TestEscapeDrawtextText(unittest.TestCase): + """直接测试转义函数,覆盖每一行。""" + + def test_backslash_escape(self): + result = _escape_drawtext_text("a\\b") + self.assertIn("\\\\", result) + + def test_single_quote_escape(self): + result = _escape_drawtext_text("it's") + self.assertIn("\\'", result) + + def test_colon_escape(self): + result = _escape_drawtext_text("a:b") + self.assertIn("\\:", result) + + def test_percent_escape(self): + result = _escape_drawtext_text("100%") + self.assertIn("%%", result) + + def test_all_special_chars_combined(self): + result = _escape_drawtext_text("\\':%") + self.assertIn("\\\\", result) + self.assertIn("\\'", result) + self.assertIn("\\:", result) + self.assertIn("%%", result) + + def test_no_special_chars(self): + result = _escape_drawtext_text("hello world") + self.assertEqual(result, "hello world") + + +class TestResolveFontPath(unittest.TestCase): + """测试字体路径解析逻辑。""" + + @patch("os.path.isfile") + def test_known_font_found(self, mock_isfile): + mock_isfile.side_effect = lambda p: "NotoSansCJK" in p + result = _resolve_font_path("思源黑体") + self.assertNotEqual(result, "") + self.assertIn("NotoSansCJK", result) + + @patch("os.path.isfile") + def test_unknown_font_fallback(self, mock_isfile): + mock_isfile.side_effect = lambda p: "DejaVu" in p + result = _resolve_font_path("UnknownFont") + self.assertIn("DejaVu", result) + + @patch("os.path.isfile") + def test_no_fonts_available(self, mock_isfile): + mock_isfile.return_value = False + result = _resolve_font_path("思源黑体") + self.assertEqual(result, "") + + @patch("os.path.isfile") + def test_passthrough_font_name(self, mock_isfile): + mock_isfile.side_effect = lambda p: "NotoSansCJK" in p + result = _resolve_font_path("NotoSansCJK") + self.assertNotEqual(result, "") + + @patch("os.path.isfile") + def test_font_search_first_match(self, mock_isfile): + mock_isfile.side_effect = lambda p: "opentype" in p + result = _resolve_font_path("思源黑体") + self.assertNotEqual(result, "") + self.assertIn("opentype", result) + + @patch("os.path.isfile") + def test_font_fallback_skips_nonexistent(self, mock_isfile): + mock_isfile.side_effect = lambda p: "DejaVu" in p + result = _resolve_font_path("不存在字体") + self.assertIn("DejaVu", result) + + +class TestDrawtextFontFileIncluded(unittest.TestCase): + """当字体文件存在时,fontfile 参数出现在输出中。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_fontfile_in_output(self, mock_font): + mock_font.return_value = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" + result = build_title_drawtext_filter({"text": "标题"}) + self.assertIsNotNone(result) + self.assertIn("fontfile=", result) + self.assertIn("NotoSansCJK", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_fontfile_escaped(self, mock_font): + mock_font.return_value = "/path/with:special'chars.ttf" + result = build_title_drawtext_filter({"text": "标题"}) + self.assertIsNotNone(result) + self.assertIn("fontfile=", result) + + +class TestDrawtextFontFileNotIncluded(unittest.TestCase): + """当字体文件不存在时,无 fontfile 参数。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_no_fontfile(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题"}) + self.assertIsNotNone(result) + self.assertNotIn("fontfile=", result) + + +class TestDrawtextStrokeBranches(unittest.TestCase): + """stroke 各分支覆盖。""" + + def test_stroke_non_bool_non_dict(self): + result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"}) + self.assertIsNotNone(result) + self.assertNotIn("borderw", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_default_color(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}}) + self.assertIsNotNone(result) + self.assertIn("borderw=4", result) + self.assertIn("bordercolor=000000", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_enabled_false(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}}) + self.assertIsNotNone(result) + self.assertNotIn("borderw", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_custom_color(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}}) + self.assertIsNotNone(result) + self.assertIn("bordercolor=ff0000", result) + + +class TestDrawtextShadowBranches(unittest.TestCase): + """shadow 各分支覆盖。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_default_color(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}}) + self.assertIsNotNone(result) + self.assertIn("shadowcolor=000000", result) + self.assertIn("shadowx=5", result) + self.assertIn("shadowy=5", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_disabled(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}}) + self.assertIsNotNone(result) + self.assertNotIn("shadowcolor", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_custom_color(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter( + {"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}} + ) + self.assertIsNotNone(result) + self.assertIn("shadowcolor=555555", result) + + +class TestDrawtextBoldFalse(unittest.TestCase): + def test_bold_false(self): + result = build_title_drawtext_filter({"text": "标题", "bold": False}) + self.assertIsNotNone(result) + self.assertNotIn("font=bold", result) + + +class TestDrawtextPositionBranches(unittest.TestCase): + """位置相关分支覆盖。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_top_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "top"}) + self.assertIsNotNone(result) + self.assertIn("y=50", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_center_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "center"}) + self.assertIsNotNone(result) + self.assertIn("y=(h-text_h)/2", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_bottom_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}) + self.assertIsNotNone(result) + self.assertIn("y=h-text_h-50", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_custom_with_float_coords(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3}) + self.assertIsNotNone(result) + self.assertIn("x=100", result) + self.assertIn("y=200", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_custom_bool_coords_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True}) + self.assertIsNotNone(result) + self.assertIn("x=(w-text_w)/2", result) + self.assertIn("y=50", result) + + +class TestDrawtextColorNoHash(unittest.TestCase): + def test_color_without_hash(self): + result = build_title_drawtext_filter({"text": "标题", "font_color": "red"}) + self.assertIsNotNone(result) + self.assertIn("fontcolor=red", result) + + +class TestDrawtextFieldNormalization(unittest.TestCase): + """字段归一化覆盖更多分支。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_content_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"content": "备用标题"}) + self.assertIsNotNone(result) + self.assertIn("备用标题", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_font_preset_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"}) + self.assertIsNotNone(result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_size_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "size": 72}) + self.assertIsNotNone(result) + self.assertIn("fontsize=72", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_color_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"}) + self.assertIsNotNone(result) + self.assertIn("fontcolor=abcdef", result) + + +class TestDrawtextNotDictConfig(unittest.TestCase): + def test_string_config_returns_none(self): + self.assertIsNone(build_title_drawtext_filter("not a dict")) + + def test_list_config_returns_none(self): + self.assertIsNone(build_title_drawtext_filter([1, 2, 3])) + + if __name__ == "__main__": unittest.main() -- 2.54.0