diff --git a/apps/worker/video_processing/intro_outro_engine.py b/apps/worker/video_processing/intro_outro_engine.py index 067bc093d..20c73c5b2 100755 --- a/apps/worker/video_processing/intro_outro_engine.py +++ b/apps/worker/video_processing/intro_outro_engine.py @@ -11,129 +11,22 @@ from __future__ import annotations import logging import subprocess -from dataclasses import dataclass from pathlib import Path -from typing import Any +from packages.domain.intro_outro_config import ( # noqa: F401 — 向后兼容 + INTRO_OUTRO_TYPE_FOLLOW, + INTRO_OUTRO_TYPE_NONE, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_VIDEO, + TRANSITION_FADE, + IntroOutroConfig, +) from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg logger = logging.getLogger(__name__) -@dataclass -class IntroOutroConfig: - """片头片尾配置. - - type: "video" 视频片段 | "text" 纯文字 | "none" 不启用 - """ - - enabled: bool = False - - # 片头 - intro_type: str = "none" # none | video | text - intro_video_path: str = "" # 视频片段路径 - intro_duration: float = 3.0 # 片头时长(秒) - - # 文字片头配置 - intro_background: str = "#000000" # 背景色 - intro_title: str = "" - intro_subtitle: str = "" - intro_title_color: str = "white" - intro_title_size: int = 48 - intro_subtitle_color: str = "gray" - intro_subtitle_size: int = 24 - - # 片尾 - outro_type: str = "none" # none | video | text | follow - outro_video_path: str = "" # 视频片段路径 - outro_duration: float = 3.0 # 片尾时长(秒) - - # 文字片尾配置 - outro_background: str = "#000000" - outro_title: str = "感谢观看" - outro_subtitle: str = "点赞关注不迷路" - outro_title_color: str = "white" - outro_title_size: int = 48 - outro_subtitle_color: str = "gray" - outro_subtitle_size: int = 24 - - # 转场 - transition_effect: str = "fade" - transition_duration: float = 0.5 - - @classmethod - def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig: - """从字典构造.""" - if not data: - return cls() - - enabled = data.get("enabled", False) - if not enabled: - return cls() - - intro = data.get("intro", {}) or {} - outro = data.get("outro", {}) or {} - - return cls( - enabled=True, - # 片头 - intro_type=str(intro.get("type", "none")), - intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""), - intro_duration=float(intro.get("duration", 3.0)), - intro_background=str(intro.get("background", "#000000")), - intro_title=str(intro.get("title", "") or ""), - intro_subtitle=str(intro.get("subtitle", "") or ""), - intro_title_color=str(intro.get("title_color", "white")), - intro_title_size=int(intro.get("title_size", 48)), - intro_subtitle_color=str(intro.get("subtitle_color", "gray")), - intro_subtitle_size=int(intro.get("subtitle_size", 24)), - # 片尾 - outro_type=str(outro.get("type", "none")), - outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""), - outro_duration=float(outro.get("duration", 3.0)), - outro_background=str(outro.get("background", "#000000")), - outro_title=str(outro.get("title", "感谢观看") or "感谢观看"), - outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"), - outro_title_color=str(outro.get("title_color", "white")), - outro_title_size=int(outro.get("title_size", 48)), - outro_subtitle_color=str(outro.get("subtitle_color", "gray")), - outro_subtitle_size=int(outro.get("subtitle_size", 24)), - # 转场 - transition_effect=str(data.get("transition", "fade")), - transition_duration=float(data.get("transition_duration", 0.5)), - ) - - @property - def has_intro(self) -> bool: - """是否有片头.""" - return self.enabled and self.intro_type in ("video", "text") - - @property - def has_outro(self) -> bool: - """是否有片尾.""" - return self.enabled and self.outro_type in ("video", "text", "follow") - - def validate(self) -> tuple[bool, str]: - """校验配置.""" - if not self.enabled: - return True, "" - - if self.intro_type == "video" and not self.intro_video_path: - return False, "视频片头缺少 video_path" - if self.intro_type == "text" and not self.intro_title: - return False, "文字片头缺少 title" - - if self.outro_type == "video" and not self.outro_video_path: - return False, "视频片尾缺少 video_path" - if self.outro_type in ("text", "follow") and not self.outro_title: - return False, "文字片尾缺少 title" - - if self.intro_duration <= 0: - return False, "片头时长必须大于 0" - if self.outro_duration <= 0: - return False, "片尾时长必须大于 0" - - return True, "" +# ── 片头片尾引擎 ────────────────────────────────────────────────────────────── class IntroOutroEngine: diff --git a/packages/domain/intro_outro_config.py b/packages/domain/intro_outro_config.py new file mode 100755 index 000000000..be205123f --- /dev/null +++ b/packages/domain/intro_outro_config.py @@ -0,0 +1,219 @@ +"""片头片尾配置领域模型 — 纯逻辑,无外部依赖. + +抽离自 intro_outro_engine.py 的数据类和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +INTRO_OUTRO_TYPE_NONE = "none" +INTRO_OUTRO_TYPE_VIDEO = "video" +INTRO_OUTRO_TYPE_TEXT = "text" +INTRO_OUTRO_TYPE_FOLLOW = "follow" + +TRANSITION_FADE = "fade" +TRANSITION_SLIDE = "slide" +TRANSITION_WIPE = "wipe" + +_VALID_INTRO_TYPES = {INTRO_OUTRO_TYPE_NONE, INTRO_OUTRO_TYPE_VIDEO, INTRO_OUTRO_TYPE_TEXT} +_VALID_OUTRO_TYPES = { + INTRO_OUTRO_TYPE_NONE, + INTRO_OUTRO_TYPE_VIDEO, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_FOLLOW, +} + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class IntroOutroConfig: + """片头片尾配置. + + type: "video" 视频片段 | "text" 纯文字 | "none" 不启用 + """ + + enabled: bool = False + + # 片头 + intro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text + intro_video_path: str = "" # 视频片段路径 + intro_duration: float = 3.0 # 片头时长(秒) + + # 文字片头配置 + intro_background: str = "#000000" # 背景色 + intro_title: str = "" + intro_subtitle: str = "" + intro_title_color: str = "white" + intro_title_size: int = 48 + intro_subtitle_color: str = "gray" + intro_subtitle_size: int = 24 + + # 片尾 + outro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text | follow + outro_video_path: str = "" # 视频片段路径 + outro_duration: float = 3.0 # 片尾时长(秒) + + # 文字片尾配置 + outro_background: str = "#000000" + outro_title: str = "感谢观看" + outro_subtitle: str = "点赞关注不迷路" + outro_title_color: str = "white" + outro_title_size: int = 48 + outro_subtitle_color: str = "gray" + outro_subtitle_size: int = 24 + + # 转场 + transition_effect: str = TRANSITION_FADE + transition_duration: float = 0.5 + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "IntroOutroConfig": + """从字典构造.""" + if not data: + return cls() + + enabled = data.get("enabled", False) + if not enabled: + return cls() + + intro = data.get("intro", {}) or {} + outro = data.get("outro", {}) or {} + + # 安全解析数值,失败时回退到默认值 + try: + intro_duration = float(intro.get("duration", 3.0)) + except (TypeError, ValueError): + intro_duration = 3.0 + + try: + intro_title_size = int(intro.get("title_size", 48)) + except (TypeError, ValueError): + intro_title_size = 48 + + try: + intro_subtitle_size = int(intro.get("subtitle_size", 24)) + except (TypeError, ValueError): + intro_subtitle_size = 24 + + try: + outro_duration = float(outro.get("duration", 3.0)) + except (TypeError, ValueError): + outro_duration = 3.0 + + try: + outro_title_size = int(outro.get("title_size", 48)) + except (TypeError, ValueError): + outro_title_size = 48 + + try: + outro_subtitle_size = int(outro.get("subtitle_size", 24)) + except (TypeError, ValueError): + outro_subtitle_size = 24 + + try: + transition_duration = float(data.get("transition_duration", 0.5)) + except (TypeError, ValueError): + transition_duration = 0.5 + + return cls( + enabled=True, + # 片头 + intro_type=str(intro.get("type", INTRO_OUTRO_TYPE_NONE)), + intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""), + intro_duration=intro_duration, + intro_background=str(intro.get("background", "#000000")), + intro_title=str(intro.get("title", "") or ""), + intro_subtitle=str(intro.get("subtitle", "") or ""), + intro_title_color=str(intro.get("title_color", "white")), + intro_title_size=intro_title_size, + intro_subtitle_color=str(intro.get("subtitle_color", "gray")), + intro_subtitle_size=intro_subtitle_size, + # 片尾 + outro_type=str(outro.get("type", INTRO_OUTRO_TYPE_NONE)), + outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""), + outro_duration=outro_duration, + outro_background=str(outro.get("background", "#000000")), + outro_title=str(outro.get("title", "感谢观看") or "感谢观看"), + outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"), + outro_title_color=str(outro.get("title_color", "white")), + outro_title_size=outro_title_size, + outro_subtitle_color=str(outro.get("subtitle_color", "gray")), + outro_subtitle_size=outro_subtitle_size, + # 转场 + transition_effect=str(data.get("transition", TRANSITION_FADE)), + transition_duration=transition_duration, + ) + + @property + def has_intro(self) -> bool: + """是否有片头(视频或文字类型).""" + return self.enabled and self.intro_type in ( + INTRO_OUTRO_TYPE_VIDEO, + INTRO_OUTRO_TYPE_TEXT, + ) + + @property + def has_outro(self) -> bool: + """是否有片尾(视频/文字/follow类型).""" + return self.enabled and self.outro_type in ( + INTRO_OUTRO_TYPE_VIDEO, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_FOLLOW, + ) + + @property + def total_extra_duration(self) -> float: + """片头片尾总共增加的时长(秒).""" + total = 0.0 + if self.has_intro and self.intro_duration > 0: + total += self.intro_duration + if self.has_outro and self.outro_duration > 0: + total += self.outro_duration + return total + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if not self.enabled: + return True, "" + + if self.intro_type not in _VALID_INTRO_TYPES: + return False, f"无效的片头类型: {self.intro_type}" + + if self.outro_type not in _VALID_OUTRO_TYPES: + return False, f"无效的片尾类型: {self.outro_type}" + + if self.intro_type == INTRO_OUTRO_TYPE_VIDEO and not self.intro_video_path: + return False, "视频片头缺少 video_path" + if self.intro_type == INTRO_OUTRO_TYPE_TEXT and not self.intro_title: + return False, "文字片头缺少 title" + + if self.outro_type == INTRO_OUTRO_TYPE_VIDEO and not self.outro_video_path: + return False, "视频片尾缺少 video_path" + if self.outro_type in (INTRO_OUTRO_TYPE_TEXT, INTRO_OUTRO_TYPE_FOLLOW) and not self.outro_title: + return False, "文字片尾缺少 title" + + if self.intro_duration <= 0: + return False, "片头时长必须大于 0" + if self.outro_duration <= 0: + return False, "片尾时长必须大于 0" + + if self.transition_duration < 0: + return False, "转场时长不能为负数" + + if self.intro_title_size <= 0: + return False, "片头标题字号必须大于 0" + if self.intro_subtitle_size <= 0: + return False, "片头副标题字号必须大于 0" + if self.outro_title_size <= 0: + return False, "片尾标题字号必须大于 0" + if self.outro_subtitle_size <= 0: + return False, "片尾副标题字号必须大于 0" + + return True, "" diff --git a/tests/unit/test_intro_outro_config.py b/tests/unit/test_intro_outro_config.py new file mode 100755 index 000000000..5c4b1f644 --- /dev/null +++ b/tests/unit/test_intro_outro_config.py @@ -0,0 +1,537 @@ +"""intro_outro_config 模块单测 — 纯逻辑,无外部依赖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.intro_outro_config import ( + INTRO_OUTRO_TYPE_FOLLOW, + INTRO_OUTRO_TYPE_NONE, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_VIDEO, + TRANSITION_FADE, + IntroOutroConfig, +) + +# ── 默认值 ──────────────────────────────────────────────────────────────────── + + +class TestIntroOutroConfigDefaults: + def test_default_disabled(self): + cfg = IntroOutroConfig() + assert cfg.enabled is False + assert cfg.intro_type == INTRO_OUTRO_TYPE_NONE + assert cfg.outro_type == INTRO_OUTRO_TYPE_NONE + assert cfg.transition_effect == TRANSITION_FADE + assert cfg.transition_duration == 0.5 + + def test_default_intro_text(self): + cfg = IntroOutroConfig() + assert cfg.intro_background == "#000000" + assert cfg.intro_title == "" + assert cfg.intro_subtitle == "" + assert cfg.intro_title_color == "white" + assert cfg.intro_title_size == 48 + assert cfg.intro_subtitle_color == "gray" + assert cfg.intro_subtitle_size == 24 + assert cfg.intro_duration == 3.0 + + def test_default_outro_text(self): + cfg = IntroOutroConfig() + assert cfg.outro_background == "#000000" + assert cfg.outro_title == "感谢观看" + assert cfg.outro_subtitle == "点赞关注不迷路" + assert cfg.outro_title_color == "white" + assert cfg.outro_title_size == 48 + assert cfg.outro_subtitle_color == "gray" + assert cfg.outro_subtitle_size == 24 + assert cfg.outro_duration == 3.0 + + +# ── from_dict ──────────────────────────────────────────────────────────────── + + +class TestIntroOutroConfigFromDict: + def test_none_data_disabled(self): + cfg = IntroOutroConfig.from_dict(None) + assert cfg.enabled is False + + def test_empty_dict_disabled(self): + cfg = IntroOutroConfig.from_dict({}) + assert cfg.enabled is False + + def test_enabled_false(self): + cfg = IntroOutroConfig.from_dict({"enabled": False}) + assert cfg.enabled is False + + def test_enabled_but_no_intro_outro(self): + cfg = IntroOutroConfig.from_dict({"enabled": True}) + assert cfg.enabled is True + assert cfg.has_intro is False + assert cfg.has_outro is False + + def test_intro_video(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "video", + "video_path": "/tmp/intro.mp4", + "duration": 2.5, + }, + } + ) + assert cfg.enabled is True + assert cfg.intro_type == "video" + assert cfg.intro_video_path == "/tmp/intro.mp4" + assert cfg.intro_duration == 2.5 + assert cfg.has_intro is True + + def test_intro_text(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "text", + "title": "欢迎来到", + "subtitle": "我的频道", + "background": "#FF0000", + "title_color": "yellow", + "title_size": 64, + "subtitle_color": "white", + "subtitle_size": 32, + }, + } + ) + assert cfg.intro_type == "text" + assert cfg.intro_title == "欢迎来到" + assert cfg.intro_subtitle == "我的频道" + assert cfg.intro_background == "#FF0000" + assert cfg.intro_title_size == 64 + assert cfg.intro_subtitle_size == 32 + assert cfg.has_intro is True + + def test_intro_video_path_alias(self): + # video 和 video_path 都支持 + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"type": "video", "video": "/tmp/a.mp4"}, + } + ) + assert cfg.intro_video_path == "/tmp/a.mp4" + + def test_outro_video(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": { + "type": "video", + "video_path": "/tmp/outro.mp4", + "duration": 4.0, + }, + } + ) + assert cfg.outro_type == "video" + assert cfg.outro_video_path == "/tmp/outro.mp4" + assert cfg.outro_duration == 4.0 + assert cfg.has_outro is True + + def test_outro_text(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": { + "type": "text", + "title": "谢谢观看", + "subtitle": "下期再见", + }, + } + ) + assert cfg.outro_type == "text" + assert cfg.outro_title == "谢谢观看" + assert cfg.outro_subtitle == "下期再见" + assert cfg.has_outro is True + + def test_outro_follow(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "follow", "title": "关注我"}, + } + ) + assert cfg.outro_type == "follow" + assert cfg.has_outro is True + + def test_outro_default_title_when_empty(self): + # 空字符串标题会回退到默认值 + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "text", "title": ""}, + } + ) + assert cfg.outro_title == "感谢观看" + + def test_outro_default_subtitle_when_empty(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "text", "subtitle": ""}, + } + ) + assert cfg.outro_subtitle == "点赞关注不迷路" + + def test_transition_config(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "transition": "slide", + "transition_duration": 1.0, + } + ) + assert cfg.transition_effect == "slide" + assert cfg.transition_duration == 1.0 + + def test_invalid_intro_duration_fallback(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"type": "text", "title": "hi", "duration": "bad"}, + } + ) + assert cfg.intro_duration == 3.0 + + def test_invalid_outro_duration_fallback(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "text", "title": "hi", "duration": "bad"}, + } + ) + assert cfg.outro_duration == 3.0 + + def test_invalid_title_size_fallback(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"type": "text", "title": "hi", "title_size": "bad"}, + } + ) + assert cfg.intro_title_size == 48 + + def test_invalid_transition_duration_fallback(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "transition_duration": "bad", + } + ) + assert cfg.transition_duration == 0.5 + + def test_intro_is_none_dict(self): + # intro 可能是 None + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": None, + "outro": None, + } + ) + assert cfg.enabled is True + assert cfg.intro_type == "none" + + def test_full_config(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "text", + "title": "开场", + "subtitle": "精彩马上开始", + "background": "#123456", + "title_color": "white", + "title_size": 72, + "subtitle_color": "gray", + "subtitle_size": 28, + "duration": 2.0, + }, + "outro": { + "type": "text", + "title": "结束", + "subtitle": "再见", + "background": "#654321", + "duration": 3.5, + }, + "transition": "wipe", + "transition_duration": 0.8, + } + ) + assert cfg.has_intro is True + assert cfg.has_outro is True + assert cfg.intro_title == "开场" + assert cfg.outro_title == "结束" + assert cfg.transition_effect == "wipe" + assert cfg.transition_duration == 0.8 + + +# ── has_intro / has_outro ──────────────────────────────────────────────────── + + +class TestHasIntroHasOutro: + def test_disabled_no_intro_outro(self): + cfg = IntroOutroConfig(enabled=False) + assert cfg.has_intro is False + assert cfg.has_outro is False + + def test_enabled_none_type(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="none", + ) + assert cfg.has_intro is False + assert cfg.has_outro is False + + def test_intro_video_type(self): + cfg = IntroOutroConfig(enabled=True, intro_type="video", intro_video_path="a.mp4") + assert cfg.has_intro is True + + def test_intro_text_type(self): + cfg = IntroOutroConfig(enabled=True, intro_type="text", intro_title="Hi") + assert cfg.has_intro is True + + def test_outro_video(self): + cfg = IntroOutroConfig(enabled=True, outro_type="video", outro_video_path="a.mp4") + assert cfg.has_outro is True + + def test_outro_text(self): + cfg = IntroOutroConfig(enabled=True, outro_type="text", outro_title="Bye") + assert cfg.has_outro is True + + def test_outro_follow(self): + cfg = IntroOutroConfig(enabled=True, outro_type="follow", outro_title="Follow") + assert cfg.has_outro is True + + def test_intro_follow_not_valid(self): + # intro 不支持 follow 类型 + cfg = IntroOutroConfig(enabled=True, intro_type="follow") + assert cfg.has_intro is False + + +# ── total_extra_duration ───────────────────────────────────────────────────── + + +class TestTotalExtraDuration: + def test_disabled_zero(self): + cfg = IntroOutroConfig(enabled=False) + assert cfg.total_extra_duration == 0.0 + + def test_both_intro_outro(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + intro_duration=2.0, + outro_type="text", + outro_title="Bye", + outro_duration=3.0, + ) + assert cfg.total_extra_duration == 5.0 + + def test_only_intro(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="video", + intro_video_path="a.mp4", + intro_duration=2.5, + ) + assert cfg.total_extra_duration == 2.5 + + def test_only_outro(self): + cfg = IntroOutroConfig( + enabled=True, + outro_type="video", + outro_video_path="a.mp4", + outro_duration=4.0, + ) + assert cfg.total_extra_duration == 4.0 + + def test_zero_duration_ignored(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + intro_duration=0.0, + outro_type="text", + outro_title="Bye", + outro_duration=0.0, + ) + assert cfg.total_extra_duration == 0.0 + + +# ── validate ───────────────────────────────────────────────────────────────── + + +class TestValidate: + def test_disabled_always_valid(self): + cfg = IntroOutroConfig(enabled=False) + ok, err = cfg.validate() + assert ok is True + assert err == "" + + def test_none_type_valid(self): + cfg = IntroOutroConfig(enabled=True, intro_type="none", outro_type="none") + ok, err = cfg.validate() + assert ok is True + + def test_video_intro_without_path_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="video", + intro_video_path="", + outro_type="none", + ) + ok, err = cfg.validate() + assert ok is False + assert "片头" in err and "video_path" in err + + def test_text_intro_without_title_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="", + outro_type="none", + ) + ok, err = cfg.validate() + assert ok is False + assert "片头" in err and "title" in err + + def test_video_outro_without_path_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="video", + outro_video_path="", + ) + ok, err = cfg.validate() + assert ok is False + assert "片尾" in err and "video_path" in err + + def test_text_outro_without_title_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="text", + outro_title="", + ) + ok, err = cfg.validate() + assert ok is False + assert "片尾" in err and "title" in err + + def test_follow_outro_without_title_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="follow", + outro_title="", + ) + ok, err = cfg.validate() + assert ok is False + assert "片尾" in err and "title" in err + + def test_zero_intro_duration_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + intro_duration=0, + outro_type="none", + ) + ok, err = cfg.validate() + assert ok is False + assert "片头时长" in err + + def test_negative_intro_duration_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + ) + object.__setattr__(cfg, "intro_duration", -1.0) + ok, err = cfg.validate() + assert ok is False + assert "片头时长" in err + + def test_zero_outro_duration_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="text", + outro_title="Bye", + outro_duration=0, + ) + ok, err = cfg.validate() + assert ok is False + assert "片尾时长" in err + + def test_negative_transition_duration_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + transition_duration=-0.5, + ) + ok, err = cfg.validate() + assert ok is False + assert "转场" in err + + def test_zero_title_size_invalid(self): + cfg = IntroOutroConfig(enabled=True) + object.__setattr__(cfg, "intro_title_size", 0) + ok, err = cfg.validate() + assert ok is False + assert "片头" in err and "字号" in err + + def test_zero_subtitle_size_invalid(self): + cfg = IntroOutroConfig(enabled=True) + object.__setattr__(cfg, "outro_subtitle_size", 0) + ok, err = cfg.validate() + assert ok is False + assert "片尾" in err and "副标题字号" in err + + def test_valid_video_intro_outro(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="video", + intro_video_path="/tmp/i.mp4", + intro_duration=2.0, + outro_type="video", + outro_video_path="/tmp/o.mp4", + outro_duration=3.0, + ) + ok, err = cfg.validate() + assert ok is True, f"expected valid but got: {err}" + + def test_valid_text_intro_outro(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + intro_duration=2.0, + outro_type="text", + outro_title="Bye", + outro_duration=3.0, + ) + ok, err = cfg.validate() + assert ok is True, f"expected valid but got: {err}" + + def test_invalid_intro_type(self): + cfg = IntroOutroConfig(enabled=True, intro_type="invalid") + ok, err = cfg.validate() + assert ok is False + assert "片头类型" in err + + def test_invalid_outro_type(self): + cfg = IntroOutroConfig(enabled=True, outro_type="invalid") + ok, err = cfg.validate() + assert ok is False + assert "片尾类型" in err