feat: #1789 标题 drawtext 滤镜渲染 — 单视频+批量生成都生效 #1792

Merged
auto-approve-bot merged 3 commits from feat/title-drawtext-1789 into develop 2026-09-08 13:14:13 +08:00
4 changed files with 541 additions and 2 deletions
@@ -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"]
+191 -1
View File
@@ -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)
+62 -1
View File
@@ -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
+264
View File
@@ -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()