From c8789670e9a7b107f427ba2c79f129169464e7cb Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 17 Aug 2026 00:27:43 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E9=A2=84=E8=A7=88=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=B8=B2=E6=9F=93=E5=85=A8=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20custom=5Ftitle=E6=B3=A8?= =?UTF-8?q?=E5=85=A5+ASR=E8=B7=AF=E5=BE=84=E6=A0=87=E9=A2=98=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复3个断点导致预览视频不渲染用户自定义标题: 断点1(P0): generate_video() 未将 custom_title 传给 _render_video() 断点2(P0): _render_video() 没有接收 custom_title,也未注入 plan.config[title] 断点3(P1): generate_ass_from_timeline() ASR路径完全跳过标题渲染 次要: 前端 font_size/font_color 与后端 size/color 字段名不匹配 修改: - generation.py: _render_video 增加 custom_title 参数,JSON解析+字段归一化后 注入 virtual_plan.config[title]; generate_video 调用处传递 task_info[custom_title] - subtitle_generator.py: generate_ass_from_timeline 增加 title_text/title_config/ video_duration 参数,ASR字幕与标题共存于同一ASS文件 - unified_render_service.py: _maybe_generate_ass 的ASR路径传递标题参数 - 新增 test_preview_title_render.py (9个测试覆盖3个断点) --- .../video_processing/subtitle_generator.py | 92 ++++++- .../unified_render_service.py | 3 + apps/worker/worker_app/tasks/generation.py | 24 ++ tests/unit/test_preview_title_render.py | 247 ++++++++++++++++++ 4 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_preview_title_render.py diff --git a/apps/worker/video_processing/subtitle_generator.py b/apps/worker/video_processing/subtitle_generator.py index 5e959b948..30b757118 100755 --- a/apps/worker/video_processing/subtitle_generator.py +++ b/apps/worker/video_processing/subtitle_generator.py @@ -14,6 +14,16 @@ from pathlib import Path from typing import Any from packages.domain.subtitle import SubtitleTimeline +from packages.domain.ass_subtitle_builder import ( + 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, +) 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,78 @@ 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 +254,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..5ed0fb4af --- /dev/null +++ b/tests/unit/test_preview_title_render.py @@ -0,0 +1,247 @@ +"""预览视频标题渲染修复测试 — 覆盖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, patch, PropertyMock + +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 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="你好世界"), + ] + ) + + 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 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="只有字幕"), + ] + ) + + 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 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")] + ) + + 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 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")] + ) + + 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 参数,默认空字符串。""" + from worker_app.tasks.generation import _render_video + import inspect + + sig = inspect.signature(_render_video) + assert "custom_title" in sig.parameters + assert sig.parameters["custom_title"].default == "" -- 2.54.0 From 423ee5bb48827fa0b6d96b0c571e12767b582e3b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 16 Aug 2026 16:33:37 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- .../video_processing/subtitle_generator.py | 22 +++--- tests/unit/test_preview_title_render.py | 71 +++++++++++-------- 2 files changed, 50 insertions(+), 43 deletions(-) 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 == "" -- 2.54.0