diff --git a/apps/worker/video_processing/subtitle_generator.py b/apps/worker/video_processing/subtitle_generator.py index 5e959b948..b8c6706bd 100755 --- a/apps/worker/video_processing/subtitle_generator.py +++ b/apps/worker/video_processing/subtitle_generator.py @@ -13,6 +13,16 @@ import logging from pathlib import Path from typing import Any +from packages.domain.ass_subtitle_builder import ( + TITLE_MARGIN_SIDE, + TITLE_MARGIN_TOP, + _wrap_title_text, + build_ass_style, + escape_ass_text, + format_ass_time, + hex_to_ass_color, + position_to_ass_alignment, +) from packages.domain.subtitle import SubtitleTimeline logger = logging.getLogger(__name__) @@ -100,7 +110,10 @@ def generate_ass_from_timeline( *, video_width: int, video_height: int, + video_duration: float = 0.0, subtitle_config: dict[str, Any] | None = None, + title_text: str = "", + title_config: dict[str, Any] | None = None, ) -> Path: """从字幕时间轴生成 ASS 字幕文件。 @@ -160,7 +173,76 @@ def generate_ass_from_timeline( events.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{safe_text}") - # 组装 ASS 文件 + # ── 标题样式与事件(叠加在 ASR 字幕之上)─────────────────────────── + title_cfg = title_config or {} + if not isinstance(title_cfg, dict): + title_cfg = {} + title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip()) + + title_style_line = "" + title_event_line = "" + + if title_enabled: + # 兼容 boolean stroke/shadow → dict + _stroke_val = title_cfg.get("stroke") + if isinstance(_stroke_val, bool): + title_cfg["stroke"] = ( + {"enabled": _stroke_val, "color": "#000000", "width": 2} if _stroke_val else {"enabled": False} + ) + _shadow_val = title_cfg.get("shadow") + if isinstance(_shadow_val, bool): + title_cfg["shadow"] = ( + {"enabled": _shadow_val, "color": "#000000", "blur": 4, "offset_x": 2, "offset_y": 2} + if _shadow_val + else {"enabled": False} + ) + + # 字段名归一化: font_size→size, font_color→color + if "font_size" in title_cfg and "size" not in title_cfg: + title_cfg["size"] = title_cfg["font_size"] + if "font_color" in title_cfg and "color" not in title_cfg: + title_cfg["color"] = title_cfg["font_color"] + + t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff")) + t_stroke = title_cfg.get("stroke", {}) or {} + t_shadow = title_cfg.get("shadow", {}) or {} + s_color = hex_to_ass_color(t_stroke.get("color", "#000000")) + s_width = float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0 + sh_blur = float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0 + sh_offset = ( + t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0, + t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0, + ) + t_alignment = position_to_ass_alignment(title_cfg.get("position", "top")) + + title_style_line = build_ass_style( + "TitleStyle", + font_name=title_cfg.get("font", "思源黑体"), + font_size=min(int(title_cfg.get("size", 36)), 36), + primary_color=t_color, + outline_color=s_color, + outline_width=s_width, + shadow_blur=sh_blur, + shadow_offset=sh_offset, + bold=bool(title_cfg.get("bold", True)), + italic=bool(title_cfg.get("italic", False)), + alignment=t_alignment, + margin_v=TITLE_MARGIN_TOP, + margin_l=TITLE_MARGIN_SIDE, + margin_r=TITLE_MARGIN_SIDE, + ) + + t_font_size = min(int(title_cfg.get("size", 36)), 36) + safe_raw = escape_ass_text(title_text.strip()) + safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size) + + if video_duration > 0: + t_end_time = format_ass_time(video_duration) + else: + t_end_time = format_ass_time((timeline.segments[-1].end + 5.0) if timeline.segments else 60.0) + title_event_line = f"Dialogue: 0,0:00:00.00,{t_end_time},TitleStyle,,0,0,0,,{safe_wrapped}" + + # 组装 ASS 文件 ass_content = f"""[Script Info] ScriptType: v4.00+ PlayResX: {video_width} @@ -170,12 +252,12 @@ WrapStyle: 2 Encoding: UTF-8 [V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -{style_line} +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501 +{chr(10).join(filter(None, [title_style_line, style_line]))} [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -{chr(10).join(events)} +{chr(10).join(filter(None, [title_event_line] + events))} """ output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 5ec1d7dee..44ed3b5da 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -513,7 +513,10 @@ class UnifiedRenderService: timeline, video_width=self.output_width, video_height=self.output_height, + video_duration=video_duration, subtitle_config=subtitle_cfg, + title_text=title_text, + title_config=title_cfg, ) logger.info( "ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs", diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 0597d03a3..38a32f6ae 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -13,6 +13,7 @@ from __future__ import annotations +import json import logging import os import tempfile @@ -1123,6 +1124,7 @@ def _render_video( resolution: str = "", bgm_config: dict | None = None, voice_ids: list[str] | None = None, + custom_title: str = "", ) -> tuple[Path, float]: """渲染视频(含配音混音)。 @@ -1155,6 +1157,27 @@ def _render_video( list(template_config.keys()), ) + # ── 用户自定义标题覆盖模板标题配置 ────────────────────────────────── + if custom_title: + try: + user_title_cfg = json.loads(custom_title) if isinstance(custom_title, str) else custom_title + if isinstance(user_title_cfg, dict) and user_title_cfg.get("text", "").strip(): + # 字段名归一化: 前端 font_size/font_color → 后端 size/color + if "font_size" in user_title_cfg and "size" not in user_title_cfg: + user_title_cfg["size"] = user_title_cfg["font_size"] + if "font_color" in user_title_cfg and "color" not in user_title_cfg: + user_title_cfg["color"] = user_title_cfg["font_color"] + plan_cfg = dict(virtual_plan.config or {}) + plan_cfg["title"] = user_title_cfg + virtual_plan.config = plan_cfg + logger.info( + "[task_id=%s] [渲染] 用户自定义标题已注入: text=%s", + task_id, + user_title_cfg.get("text", "")[:30], + ) + except (json.JSONDecodeError, TypeError): + logger.warning("[task_id=%s] custom_title JSON解析失败: %s", task_id, custom_title[:100]) + # 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高) if bgm_config: plan_cfg = virtual_plan.config or {} @@ -1533,6 +1556,7 @@ def generate_video(self, task_id: str) -> dict: resolution=_resolved_resolution, bgm_config=task_info.get("bgm_config", {}), voice_ids=task_info.get("voice_ids", []), + custom_title=task_info.get("custom_title", ""), ) if gen_task: diff --git a/tests/unit/test_preview_title_render.py b/tests/unit/test_preview_title_render.py new file mode 100644 index 000000000..c6206e3f9 --- /dev/null +++ b/tests/unit/test_preview_title_render.py @@ -0,0 +1,256 @@ +"""预览视频标题渲染修复测试 — 覆盖3个断点。 + +断点1: generate_video() → _render_video() 传递 custom_title +断点2: _render_video() 解析 custom_title 并注入 virtual_plan.config["title"] +断点3: generate_ass_from_timeline() ASR路径也渲染标题 +""" + +import json +import time +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest + +# ── 断点2: _render_video 标题注入 ───────────────────────────────────────────── + + +class TestRenderVideoCustomTitleInjection: + """验证 _render_video 正确接收并注入 custom_title 到 virtual_plan.config['title']。""" + + @pytest.fixture + def sample_custom_title(self): + """模拟前端发送的 custom_title JSON(含 font_size/font_color)。""" + return json.dumps( + { + "text": "测试标题", + "font": "思源黑体", + "font_size": 30, + "font_color": "#FF0000", + "position": "top", + "bold": True, + "stroke": True, + "shadow": False, + }, + ensure_ascii=False, + ) + + def _call_render_video_with_capture(self, custom_title, template_config=None, tmp_path=None): + """调用 _render_video,在 RenderAdapter 处中断并捕获 virtual_plan.config。""" + from worker_app.tasks.generation import _render_video + + captured_config = {} + + class FakePlan: + def __init__(self): + self.config = {} + self.id = "test-plan" + + fake_plan = FakePlan() + + def capture_and_raise(*args, **kwargs): + # 此时 title 已注入到 fake_plan.config + captured_config.update(fake_plan.config or {}) + raise RuntimeError("STOP_HERE") + + with ( + patch("worker_app.tasks.generation._build_plan_and_clips_from_task") as mock_build, + patch("worker_app.tasks.generation._load_template_plan_config", return_value=template_config), + patch("worker_app.tasks.generation.time.monotonic", side_effect=[0.0, 1.0]), + patch("video_processing.render_adapter.RenderAdapter") as mock_adapter_cls, + ): + + mock_build.return_value = (fake_plan, [], {}) + mock_adapter_cls.side_effect = capture_and_raise + + with pytest.raises(RuntimeError, match="STOP_HERE"): + _render_video( + task_id="test-task", + downloaded_videos=[tmp_path / "v1.mp4"] if tmp_path else [Path("/tmp/v1.mp4")], + voice_path=None, + editing_mode=MagicMock(value="one_take"), + project_id="proj-1", + template_id="tpl-1", + user_id="user-1", + temp_path=tmp_path or Path("/tmp"), + output_name="test_output", + resolution="1280x720", + bgm_config={}, + voice_ids=[], + custom_title=custom_title, + ) + + return captured_config + + def test_custom_title_injected_into_plan_config(self, sample_custom_title, tmp_path): + """custom_title JSON 应被解析并注入 virtual_plan.config['title']。""" + config = self._call_render_video_with_capture(sample_custom_title, tmp_path=tmp_path) + + assert "title" in config + title_cfg = config["title"] + assert title_cfg["text"] == "测试标题" + # 字段归一化: font_size → size + assert title_cfg["size"] == 30 + # 字段归一化: font_color → color + assert title_cfg["color"] == "#FF0000" + + def test_custom_title_overrides_template_title(self, sample_custom_title, tmp_path): + """用户自定义标题应覆盖模板默认标题。""" + template_config = {"title": {"text": "模板默认标题", "size": 24}} + config = self._call_render_video_with_capture( + sample_custom_title, template_config=template_config, tmp_path=tmp_path + ) + + # 用户标题应覆盖模板标题 + assert config["title"]["text"] == "测试标题" + assert config["title"]["size"] == 30 + + def test_empty_custom_title_no_injection(self, tmp_path): + """空 custom_title 不应注入 title 字段。""" + config = self._call_render_video_with_capture("", tmp_path=tmp_path) + assert "title" not in config + + def test_malformed_custom_title_gracefully_ignored(self, tmp_path): + """非法 JSON 不应崩溃,应跳过注入。""" + config = self._call_render_video_with_capture("{invalid json!!!", tmp_path=tmp_path) + assert "title" not in config + + +# ── 断点3: generate_ass_from_timeline ASR路径支持标题 ────────────────────────── + + +class TestGenerateAssFromTimelineWithTitle: + """验证 generate_ass_from_timeline 在有标题时生成包含 TitleStyle 的 ASS。""" + + def test_title_included_in_ass_output(self, tmp_path): + """有 title_text 时,ASS 输出应包含 TitleStyle 和标题事件。""" + from video_processing.subtitle_generator import generate_ass_from_timeline + + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + + timeline = SubtitleTimeline( + segments=[ + SubtitleSegment(start=0.0, end=2.0, text="你好世界"), + ] + ) + + ass_path = tmp_path / "test.ass" + result = generate_ass_from_timeline( + ass_path, + timeline, + video_width=1280, + video_height=720, + video_duration=10.0, + subtitle_config={"font": "思源黑体", "size": 24}, + title_text="我的标题", + title_config={"font": "思源黑体", "size": 36, "color": "#FFFFFF", "position": "top"}, + ) + + content = result.read_text(encoding="utf-8") + # 应包含 TitleStyle + assert "TitleStyle" in content + # 应包含标题文本 + assert "我的标题" in content + # 也应包含 ASR 字幕 + assert "你好世界" in content + + def test_no_title_no_title_style(self, tmp_path): + """无标题时,ASS 输出不应包含 TitleStyle。""" + from video_processing.subtitle_generator import generate_ass_from_timeline + + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + + timeline = SubtitleTimeline( + segments=[ + SubtitleSegment(start=0.0, end=2.0, text="只有字幕"), + ] + ) + + ass_path = tmp_path / "test.ass" + result = generate_ass_from_timeline( + ass_path, + timeline, + video_width=1280, + video_height=720, + video_duration=10.0, + subtitle_config={}, + title_text="", + title_config={}, + ) + + content = result.read_text(encoding="utf-8") + assert "TitleStyle" not in content + assert "只有字幕" in content + + def test_title_field_normalization_in_ass(self, tmp_path): + """前端字段名 font_size/font_color 应被正确归一化。""" + from video_processing.subtitle_generator import generate_ass_from_timeline + + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + + timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")]) + + ass_path = tmp_path / "test.ass" + result = generate_ass_from_timeline( + ass_path, + timeline, + video_width=1280, + video_height=720, + video_duration=10.0, + subtitle_config={}, + title_text="归一化测试", + title_config={ + "font_size": 30, # 前端字段名 + "font_color": "#FF0000", # 前端字段名 + "position": "top", + }, + ) + + content = result.read_text(encoding="utf-8") + assert "TitleStyle" in content + assert "归一化测试" in content + + def test_title_boolean_stroke_shadow_compat(self, tmp_path): + """boolean stroke/shadow 应被兼容处理。""" + from video_processing.subtitle_generator import generate_ass_from_timeline + + from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline + + timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")]) + + ass_path = tmp_path / "test.ass" + result = generate_ass_from_timeline( + ass_path, + timeline, + video_width=1280, + video_height=720, + video_duration=10.0, + subtitle_config={}, + title_text="描边测试", + title_config={ + "size": 36, + "stroke": True, # boolean + "shadow": False, # boolean + }, + ) + + content = result.read_text(encoding="utf-8") + assert "TitleStyle" in content + assert "描边测试" in content + + +# ── 断点1: _render_video 签名包含 custom_title ──────────────────────────────── + + +class TestRenderVideoSignature: + """验证 _render_video 函数签名正确。""" + + def test_custom_title_parameter_exists(self): + """_render_video 应有 custom_title 参数,默认空字符串。""" + import inspect + + from worker_app.tasks.generation import _render_video + + sig = inspect.signature(_render_video) + assert "custom_title" in sig.parameters + assert sig.parameters["custom_title"].default == ""