feat: #1789 标题 drawtext 滤镜渲染 — 单视频+批量生成都生效 #1792
@@ -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"]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user