diff --git a/apps/worker/video_processing/subtitle_generator.py b/apps/worker/video_processing/subtitle_generator.py index 30b757118..b8c6706bd 100755 --- a/apps/worker/video_processing/subtitle_generator.py +++ b/apps/worker/video_processing/subtitle_generator.py @@ -13,17 +13,17 @@ import logging from pathlib import Path from typing import Any -from packages.domain.subtitle import SubtitleTimeline from packages.domain.ass_subtitle_builder import ( + TITLE_MARGIN_SIDE, + TITLE_MARGIN_TOP, + _wrap_title_text, build_ass_style, - hex_to_ass_color, - position_to_ass_alignment, escape_ass_text, format_ass_time, - _wrap_title_text, - TITLE_MARGIN_TOP, - TITLE_MARGIN_SIDE, + hex_to_ass_color, + position_to_ass_alignment, ) +from packages.domain.subtitle import SubtitleTimeline logger = logging.getLogger(__name__) @@ -187,14 +187,14 @@ def generate_ass_from_timeline( _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} + {"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} + if _shadow_val + else {"enabled": False} ) # 字段名归一化: font_size→size, font_color→color @@ -239,9 +239,7 @@ def generate_ass_from_timeline( 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 - ) + 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 文件 diff --git a/tests/unit/test_preview_title_render.py b/tests/unit/test_preview_title_render.py index 5ed0fb4af..c6206e3f9 100644 --- a/tests/unit/test_preview_title_render.py +++ b/tests/unit/test_preview_title_render.py @@ -4,32 +4,36 @@ 断点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, patch, PropertyMock +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) + 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。""" @@ -49,10 +53,12 @@ class TestRenderVideoCustomTitleInjection: 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: + 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 @@ -112,14 +118,16 @@ class TestRenderVideoCustomTitleInjection: # ── 断点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 packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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="你好世界"), @@ -148,9 +156,10 @@ class TestGenerateAssFromTimelineWithTitle: def test_no_title_no_title_style(self, tmp_path): """无标题时,ASS 输出不应包含 TitleStyle。""" - from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline 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="只有字幕"), @@ -175,12 +184,11 @@ class TestGenerateAssFromTimelineWithTitle: def test_title_field_normalization_in_ass(self, tmp_path): """前端字段名 font_size/font_color 应被正确归一化。""" - from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline from video_processing.subtitle_generator import generate_ass_from_timeline - timeline = SubtitleTimeline( - segments=[SubtitleSegment(start=0.0, end=2.0, text="test")] - ) + 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( @@ -192,8 +200,8 @@ class TestGenerateAssFromTimelineWithTitle: subtitle_config={}, title_text="归一化测试", title_config={ - "font_size": 30, # 前端字段名 - "font_color": "#FF0000", # 前端字段名 + "font_size": 30, # 前端字段名 + "font_color": "#FF0000", # 前端字段名 "position": "top", }, ) @@ -204,12 +212,11 @@ class TestGenerateAssFromTimelineWithTitle: def test_title_boolean_stroke_shadow_compat(self, tmp_path): """boolean stroke/shadow 应被兼容处理。""" - from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline from video_processing.subtitle_generator import generate_ass_from_timeline - timeline = SubtitleTimeline( - segments=[SubtitleSegment(start=0.0, end=2.0, text="test")] - ) + 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( @@ -222,8 +229,8 @@ class TestGenerateAssFromTimelineWithTitle: title_text="描边测试", title_config={ "size": 36, - "stroke": True, # boolean - "shadow": False, # boolean + "stroke": True, # boolean + "shadow": False, # boolean }, ) @@ -234,14 +241,16 @@ class TestGenerateAssFromTimelineWithTitle: # ── 断点1: _render_video 签名包含 custom_title ──────────────────────────────── + class TestRenderVideoSignature: """验证 _render_video 函数签名正确。""" def test_custom_title_parameter_exists(self): """_render_video 应有 custom_title 参数,默认空字符串。""" - from worker_app.tasks.generation import _render_video 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 == ""