Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 601dd1dba9 |
@@ -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:
|
||||
|
||||
Executable
+219
@@ -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, ""
|
||||
Executable
+537
@@ -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
|
||||
Reference in New Issue
Block a user