"""片头片尾配置领域模型 — 纯逻辑,无外部依赖. 抽离自 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, ""