Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 423ee5bb48 | |||
| c8789670e9 |
@@ -13,6 +13,16 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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
|
from packages.domain.subtitle import SubtitleTimeline
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -100,7 +110,10 @@ def generate_ass_from_timeline(
|
|||||||
*,
|
*,
|
||||||
video_width: int,
|
video_width: int,
|
||||||
video_height: int,
|
video_height: int,
|
||||||
|
video_duration: float = 0.0,
|
||||||
subtitle_config: dict[str, Any] | None = None,
|
subtitle_config: dict[str, Any] | None = None,
|
||||||
|
title_text: str = "",
|
||||||
|
title_config: dict[str, Any] | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""从字幕时间轴生成 ASS 字幕文件。
|
"""从字幕时间轴生成 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}")
|
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]
|
ass_content = f"""[Script Info]
|
||||||
ScriptType: v4.00+
|
ScriptType: v4.00+
|
||||||
PlayResX: {video_width}
|
PlayResX: {video_width}
|
||||||
@@ -170,12 +252,12 @@ WrapStyle: 2
|
|||||||
Encoding: UTF-8
|
Encoding: UTF-8
|
||||||
|
|
||||||
[V4+ Styles]
|
[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
|
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
|
||||||
{style_line}
|
{chr(10).join(filter(None, [title_style_line, style_line]))}
|
||||||
|
|
||||||
[Events]
|
[Events]
|
||||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
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)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -513,7 +513,10 @@ class UnifiedRenderService:
|
|||||||
timeline,
|
timeline,
|
||||||
video_width=self.output_width,
|
video_width=self.output_width,
|
||||||
video_height=self.output_height,
|
video_height=self.output_height,
|
||||||
|
video_duration=video_duration,
|
||||||
subtitle_config=subtitle_cfg,
|
subtitle_config=subtitle_cfg,
|
||||||
|
title_text=title_text,
|
||||||
|
title_config=title_cfg,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs",
|
"ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -1123,6 +1124,7 @@ def _render_video(
|
|||||||
resolution: str = "",
|
resolution: str = "",
|
||||||
bgm_config: dict | None = None,
|
bgm_config: dict | None = None,
|
||||||
voice_ids: list[str] | None = None,
|
voice_ids: list[str] | None = None,
|
||||||
|
custom_title: str = "",
|
||||||
) -> tuple[Path, float]:
|
) -> tuple[Path, float]:
|
||||||
"""渲染视频(含配音混音)。
|
"""渲染视频(含配音混音)。
|
||||||
|
|
||||||
@@ -1155,6 +1157,27 @@ def _render_video(
|
|||||||
list(template_config.keys()),
|
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(用户指定优先级最高)
|
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
|
||||||
if bgm_config:
|
if bgm_config:
|
||||||
plan_cfg = virtual_plan.config or {}
|
plan_cfg = virtual_plan.config or {}
|
||||||
@@ -1533,6 +1556,7 @@ def generate_video(self, task_id: str) -> dict:
|
|||||||
resolution=_resolved_resolution,
|
resolution=_resolved_resolution,
|
||||||
bgm_config=task_info.get("bgm_config", {}),
|
bgm_config=task_info.get("bgm_config", {}),
|
||||||
voice_ids=task_info.get("voice_ids", []),
|
voice_ids=task_info.get("voice_ids", []),
|
||||||
|
custom_title=task_info.get("custom_title", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
if gen_task:
|
if gen_task:
|
||||||
|
|||||||
@@ -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 == ""
|
||||||
Reference in New Issue
Block a user