Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 254ad761b2 |
@@ -12,229 +12,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from enum import StrEnum
|
||||
else:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
pass
|
||||
|
||||
|
||||
from packages.domain.transition_config import ( # noqa: F401 — 向后兼容
|
||||
CUT_TRANSITION,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
MAX_TRANSITION_DURATION,
|
||||
MIN_TRANSITION_DURATION,
|
||||
TransitionConfig,
|
||||
TransitionType,
|
||||
)
|
||||
from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 转场时长范围(秒)
|
||||
MIN_TRANSITION_DURATION = 0.3
|
||||
MAX_TRANSITION_DURATION = 2.0
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
# 硬切(无转场)
|
||||
CUT_TRANSITION = "cut"
|
||||
|
||||
|
||||
# ── 转场类型枚举 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionType(StrEnum):
|
||||
"""支持的转场效果类型.
|
||||
|
||||
每种类型对应 FFmpeg xfade filter 的一个 transition 值。
|
||||
新增转场只需在此添加一项,并在 _FFMPEG_XFADE_MAP 中映射。
|
||||
"""
|
||||
|
||||
# 硬切(无转场效果,直接拼接)
|
||||
CUT = "cut"
|
||||
|
||||
# 淡入淡出(最常用,默认 fallback)
|
||||
FADE = "fade"
|
||||
|
||||
# 溶解(交叉溶解)
|
||||
DISSOLVE = "dissolve"
|
||||
|
||||
# 滑入系列
|
||||
SLIDE_LEFT = "slideleft"
|
||||
SLIDE_RIGHT = "slideright"
|
||||
SLIDE_UP = "slideup"
|
||||
SLIDE_DOWN = "slidedown"
|
||||
|
||||
# 缩放
|
||||
ZOOM = "zoom"
|
||||
|
||||
# 擦除系列
|
||||
WIPE_LEFT = "wipeleft"
|
||||
WIPE_RIGHT = "wiperight"
|
||||
WIPE_UP = "wipeup"
|
||||
WIPE_DOWN = "wipedown"
|
||||
|
||||
# 圆形扩散
|
||||
CIRCLE_CROP = "circlecrop"
|
||||
|
||||
# 矩形覆盖
|
||||
RECT_CROP = "rectcrop"
|
||||
|
||||
@classmethod
|
||||
def all_supported(cls) -> list[str]:
|
||||
"""返回所有支持的转场类型名称列表."""
|
||||
return [t.value for t in cls if t != cls.CUT]
|
||||
|
||||
@classmethod
|
||||
def is_supported(cls, name: str) -> bool:
|
||||
"""检查转场类型是否支持(不区分大小写和下划线)."""
|
||||
normalized = _normalize_transition_name(name)
|
||||
return normalized in _NAME_TO_ENUM_MAP
|
||||
|
||||
|
||||
# ── 名称 → 枚举 映射(支持多种别名)──────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalize_transition_name(name: str) -> str:
|
||||
"""标准化转场名称:小写 + 去下划线."""
|
||||
return name.lower().replace("_", "").replace("-", "")
|
||||
|
||||
|
||||
# 构建别名映射
|
||||
_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {}
|
||||
for _t in TransitionType:
|
||||
_NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t
|
||||
|
||||
# 额外的别名
|
||||
_ALIASES: dict[str, TransitionType] = {
|
||||
"dissolve": TransitionType.DISSOLVE,
|
||||
"crossfade": TransitionType.DISSOLVE,
|
||||
"crossdissolve": TransitionType.DISSOLVE,
|
||||
"fadein": TransitionType.FADE,
|
||||
"fadeout": TransitionType.FADE,
|
||||
"fadeblack": TransitionType.FADE,
|
||||
"slide": TransitionType.SLIDE_LEFT, # 默认向左滑
|
||||
"wipe": TransitionType.WIPE_LEFT, # 默认向左擦
|
||||
"zoomin": TransitionType.ZOOM,
|
||||
"zoomout": TransitionType.ZOOM,
|
||||
"circle": TransitionType.CIRCLE_CROP,
|
||||
"rect": TransitionType.RECT_CROP,
|
||||
}
|
||||
for _alias, _type in _ALIASES.items():
|
||||
_key = _normalize_transition_name(_alias)
|
||||
if _key not in _NAME_TO_ENUM_MAP:
|
||||
_NAME_TO_ENUM_MAP[_key] = _type
|
||||
|
||||
|
||||
# ── TransitionType → FFmpeg xfade transition 名称映射 ─────────────────────────
|
||||
|
||||
|
||||
_FFMPEG_XFADE_MAP: dict[TransitionType, str] = {
|
||||
TransitionType.FADE: "fade",
|
||||
TransitionType.DISSOLVE: "dissolve",
|
||||
TransitionType.SLIDE_LEFT: "slideleft",
|
||||
TransitionType.SLIDE_RIGHT: "slideright",
|
||||
TransitionType.SLIDE_UP: "slideup",
|
||||
TransitionType.SLIDE_DOWN: "slidedown",
|
||||
TransitionType.ZOOM: "zoomin",
|
||||
TransitionType.WIPE_LEFT: "wipeleft",
|
||||
TransitionType.WIPE_RIGHT: "wiperight",
|
||||
TransitionType.WIPE_UP: "wipeup",
|
||||
TransitionType.WIPE_DOWN: "wipedown",
|
||||
TransitionType.CIRCLE_CROP: "circlecrop",
|
||||
TransitionType.RECT_CROP: "rectcrop",
|
||||
}
|
||||
|
||||
|
||||
# ── 转场配置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TransitionConfig:
|
||||
"""转场效果配置.
|
||||
|
||||
Attributes:
|
||||
effect: 转场效果名称(见 TransitionType)
|
||||
duration: 转场时长(秒),范围 0.3~2.0,默认 0.5
|
||||
"""
|
||||
|
||||
effect: str = CUT_TRANSITION
|
||||
duration: float = DEFAULT_TRANSITION_DURATION
|
||||
|
||||
@classmethod
|
||||
def parse(cls, effect: str | None = None, duration: float | None = None) -> "TransitionConfig":
|
||||
"""解析并验证转场配置,自动处理边界和降级.
|
||||
|
||||
Args:
|
||||
effect: 转场效果名称(None 或空则使用默认 cut)
|
||||
duration: 转场时长(None 则使用默认值)
|
||||
|
||||
Returns:
|
||||
验证后的 TransitionConfig
|
||||
"""
|
||||
# 处理 effect
|
||||
final_effect = CUT_TRANSITION
|
||||
if effect and effect.strip():
|
||||
effect_clean = effect.strip()
|
||||
if TransitionType.is_supported(effect_clean):
|
||||
final_effect = _resolve_transition_enum(effect_clean).value
|
||||
elif effect_clean.lower() == CUT_TRANSITION:
|
||||
final_effect = CUT_TRANSITION
|
||||
else:
|
||||
# 降级:不支持的转场 → 硬切,不阻断渲染
|
||||
logger.warning(
|
||||
"不支持的转场效果 '%s',已降级为硬切(cut)",
|
||||
effect_clean,
|
||||
)
|
||||
final_effect = CUT_TRANSITION
|
||||
|
||||
# 处理 duration:边界钳制
|
||||
final_duration = DEFAULT_TRANSITION_DURATION
|
||||
if duration is not None:
|
||||
try:
|
||||
d = float(duration)
|
||||
if d < MIN_TRANSITION_DURATION:
|
||||
logger.warning(
|
||||
"转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值",
|
||||
d,
|
||||
MIN_TRANSITION_DURATION,
|
||||
)
|
||||
final_duration = MIN_TRANSITION_DURATION
|
||||
elif d > MAX_TRANSITION_DURATION:
|
||||
logger.warning(
|
||||
"转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值",
|
||||
d,
|
||||
MAX_TRANSITION_DURATION,
|
||||
)
|
||||
final_duration = MAX_TRANSITION_DURATION
|
||||
else:
|
||||
final_duration = d
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("无效的转场时长 '%s',使用默认值 %.1fs", duration, DEFAULT_TRANSITION_DURATION)
|
||||
final_duration = DEFAULT_TRANSITION_DURATION
|
||||
|
||||
return cls(effect=final_effect, duration=final_duration)
|
||||
|
||||
@property
|
||||
def is_cut(self) -> bool:
|
||||
"""是否为硬切(无转场效果)."""
|
||||
return self.effect == CUT_TRANSITION
|
||||
|
||||
@property
|
||||
def ffmpeg_transition(self) -> str:
|
||||
"""获取对应的 FFmpeg xfade transition 名称."""
|
||||
if self.is_cut:
|
||||
return ""
|
||||
enum_type = _resolve_transition_enum(self.effect)
|
||||
return _FFMPEG_XFADE_MAP.get(enum_type, "fade")
|
||||
|
||||
|
||||
def _resolve_transition_enum(name: str) -> TransitionType:
|
||||
"""将名称解析为 TransitionType 枚举,必须先通过 is_supported 校验."""
|
||||
normalized = _normalize_transition_name(name)
|
||||
return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE)
|
||||
|
||||
|
||||
# ── 转场引擎 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Executable
+245
@@ -0,0 +1,245 @@
|
||||
"""转场配置领域模型 — 纯逻辑,无 FFmpeg 依赖.
|
||||
|
||||
抽离自 transition_engine.py 的枚举、数据类和纯逻辑函数,
|
||||
方便单测覆盖,同时保持向后兼容。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from enum import StrEnum
|
||||
else:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 转场时长范围(秒)
|
||||
MIN_TRANSITION_DURATION = 0.3
|
||||
MAX_TRANSITION_DURATION = 2.0
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
# 硬切(无转场)
|
||||
CUT_TRANSITION = "cut"
|
||||
|
||||
|
||||
# ── 转场类型枚举 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionType(StrEnum):
|
||||
"""支持的转场效果类型.
|
||||
|
||||
每种类型对应 FFmpeg xfade filter 的一个 transition 值。
|
||||
"""
|
||||
|
||||
# 硬切(无转场效果,直接拼接)
|
||||
CUT = "cut"
|
||||
|
||||
# 淡入淡出(最常用,默认 fallback)
|
||||
FADE = "fade"
|
||||
|
||||
# 溶解(交叉溶解)
|
||||
DISSOLVE = "dissolve"
|
||||
|
||||
# 滑入系列
|
||||
SLIDE_LEFT = "slideleft"
|
||||
SLIDE_RIGHT = "slideright"
|
||||
SLIDE_UP = "slideup"
|
||||
SLIDE_DOWN = "slidedown"
|
||||
|
||||
# 缩放
|
||||
ZOOM = "zoom"
|
||||
|
||||
# 擦除系列
|
||||
WIPE_LEFT = "wipeleft"
|
||||
WIPE_RIGHT = "wiperight"
|
||||
WIPE_UP = "wipeup"
|
||||
WIPE_DOWN = "wipedown"
|
||||
|
||||
# 圆形扩散
|
||||
CIRCLE_CROP = "circlecrop"
|
||||
|
||||
# 矩形覆盖
|
||||
RECT_CROP = "rectcrop"
|
||||
|
||||
@classmethod
|
||||
def all_supported(cls) -> list[str]:
|
||||
"""返回所有支持的转场类型名称列表(不含 cut)."""
|
||||
return [t.value for t in cls if t != cls.CUT]
|
||||
|
||||
@classmethod
|
||||
def is_supported(cls, name: str) -> bool:
|
||||
"""检查转场类型是否支持(不区分大小写和下划线)."""
|
||||
normalized = _normalize_transition_name(name)
|
||||
return normalized in _NAME_TO_ENUM_MAP
|
||||
|
||||
|
||||
# ── 名称 → 枚举 映射(支持多种别名)──────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalize_transition_name(name: str) -> str:
|
||||
"""标准化转场名称:小写 + 去下划线 + 去中划线."""
|
||||
return name.lower().replace("_", "").replace("-", "")
|
||||
|
||||
|
||||
# 构建别名映射
|
||||
_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {}
|
||||
for _t in TransitionType:
|
||||
_NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t
|
||||
|
||||
# 额外的别名
|
||||
_ALIASES: dict[str, TransitionType] = {
|
||||
"dissolve": TransitionType.DISSOLVE,
|
||||
"crossfade": TransitionType.DISSOLVE,
|
||||
"crossdissolve": TransitionType.DISSOLVE,
|
||||
"fadein": TransitionType.FADE,
|
||||
"fadeout": TransitionType.FADE,
|
||||
"fadeblack": TransitionType.FADE,
|
||||
"slide": TransitionType.SLIDE_LEFT, # 默认向左滑
|
||||
"wipe": TransitionType.WIPE_LEFT, # 默认向左擦
|
||||
"zoomin": TransitionType.ZOOM,
|
||||
"zoomout": TransitionType.ZOOM,
|
||||
"circle": TransitionType.CIRCLE_CROP,
|
||||
"rect": TransitionType.RECT_CROP,
|
||||
}
|
||||
for _alias, _type in _ALIASES.items():
|
||||
_key = _normalize_transition_name(_alias)
|
||||
if _key not in _NAME_TO_ENUM_MAP:
|
||||
_NAME_TO_ENUM_MAP[_key] = _type
|
||||
|
||||
|
||||
# ── TransitionType → FFmpeg xfade transition 名称映射 ─────────────────────────
|
||||
|
||||
|
||||
_FFMPEG_XFADE_MAP: dict[TransitionType, str] = {
|
||||
TransitionType.FADE: "fade",
|
||||
TransitionType.DISSOLVE: "dissolve",
|
||||
TransitionType.SLIDE_LEFT: "slideleft",
|
||||
TransitionType.SLIDE_RIGHT: "slideright",
|
||||
TransitionType.SLIDE_UP: "slideup",
|
||||
TransitionType.SLIDE_DOWN: "slidedown",
|
||||
TransitionType.ZOOM: "zoomin",
|
||||
TransitionType.WIPE_LEFT: "wipeleft",
|
||||
TransitionType.WIPE_RIGHT: "wiperight",
|
||||
TransitionType.WIPE_UP: "wipeup",
|
||||
TransitionType.WIPE_DOWN: "wipedown",
|
||||
TransitionType.CIRCLE_CROP: "circlecrop",
|
||||
TransitionType.RECT_CROP: "rectcrop",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_transition_enum(name: str) -> TransitionType:
|
||||
"""将名称解析为 TransitionType 枚举,找不到则回退到 FADE."""
|
||||
normalized = _normalize_transition_name(name)
|
||||
return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE)
|
||||
|
||||
|
||||
# ── 转场配置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TransitionConfig:
|
||||
"""转场效果配置.
|
||||
|
||||
Attributes:
|
||||
effect: 转场效果名称(见 TransitionType)
|
||||
duration: 转场时长(秒),范围 0.3~2.0,默认 0.5
|
||||
"""
|
||||
|
||||
effect: str = CUT_TRANSITION
|
||||
duration: float = DEFAULT_TRANSITION_DURATION
|
||||
|
||||
@classmethod
|
||||
def parse(
|
||||
cls,
|
||||
effect: str | None = None,
|
||||
duration: float | None = None,
|
||||
) -> "TransitionConfig":
|
||||
"""解析并验证转场配置,自动处理边界和降级.
|
||||
|
||||
Args:
|
||||
effect: 转场效果名称(None 或空则使用默认 cut)
|
||||
duration: 转场时长(None 则使用默认值)
|
||||
|
||||
Returns:
|
||||
验证后的 TransitionConfig
|
||||
"""
|
||||
# 处理 effect
|
||||
final_effect = CUT_TRANSITION
|
||||
if effect and effect.strip():
|
||||
effect_clean = effect.strip()
|
||||
if TransitionType.is_supported(effect_clean):
|
||||
final_effect = _resolve_transition_enum(effect_clean).value
|
||||
elif effect_clean.lower() == CUT_TRANSITION:
|
||||
final_effect = CUT_TRANSITION
|
||||
else:
|
||||
# 降级:不支持的转场 → 硬切,不阻断渲染
|
||||
logger.warning(
|
||||
"不支持的转场效果 '%s',已降级为硬切(cut)",
|
||||
effect_clean,
|
||||
)
|
||||
final_effect = CUT_TRANSITION
|
||||
|
||||
# 处理 duration:边界钳制
|
||||
final_duration = DEFAULT_TRANSITION_DURATION
|
||||
if duration is not None:
|
||||
try:
|
||||
d = float(duration)
|
||||
if d < MIN_TRANSITION_DURATION:
|
||||
logger.warning(
|
||||
"转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值",
|
||||
d,
|
||||
MIN_TRANSITION_DURATION,
|
||||
)
|
||||
final_duration = MIN_TRANSITION_DURATION
|
||||
elif d > MAX_TRANSITION_DURATION:
|
||||
logger.warning(
|
||||
"转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值",
|
||||
d,
|
||||
MAX_TRANSITION_DURATION,
|
||||
)
|
||||
final_duration = MAX_TRANSITION_DURATION
|
||||
else:
|
||||
final_duration = d
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"无效的转场时长 '%s',使用默认值 %.1fs",
|
||||
duration,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
)
|
||||
final_duration = DEFAULT_TRANSITION_DURATION
|
||||
|
||||
return cls(effect=final_effect, duration=final_duration)
|
||||
|
||||
@property
|
||||
def is_cut(self) -> bool:
|
||||
"""是否为硬切(无转场效果)."""
|
||||
return self.effect == CUT_TRANSITION
|
||||
|
||||
@property
|
||||
def ffmpeg_transition(self) -> str:
|
||||
"""获取对应的 FFmpeg xfade transition 名称."""
|
||||
if self.is_cut:
|
||||
return ""
|
||||
enum_type = _resolve_transition_enum(self.effect)
|
||||
return _FFMPEG_XFADE_MAP.get(enum_type, "fade")
|
||||
|
||||
def validate(self) -> tuple[bool, str]:
|
||||
"""校验配置合法性,返回 (是否合法, 错误信息)."""
|
||||
if self.duration < MIN_TRANSITION_DURATION:
|
||||
return False, f"duration不能小于{MIN_TRANSITION_DURATION}s"
|
||||
if self.duration > MAX_TRANSITION_DURATION:
|
||||
return False, f"duration不能大于{MAX_TRANSITION_DURATION}s"
|
||||
if not self.is_cut and not TransitionType.is_supported(self.effect):
|
||||
return False, f"不支持的转场效果: {self.effect}"
|
||||
return True, ""
|
||||
Executable
+276
@@ -0,0 +1,276 @@
|
||||
"""transition_config 模块单测 — 纯逻辑,无 FFmpeg 依赖."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.transition_config import (
|
||||
CUT_TRANSITION,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
MAX_TRANSITION_DURATION,
|
||||
MIN_TRANSITION_DURATION,
|
||||
TransitionConfig,
|
||||
TransitionType,
|
||||
)
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_duration_bounds(self):
|
||||
assert MIN_TRANSITION_DURATION == 0.3
|
||||
assert MAX_TRANSITION_DURATION == 2.0
|
||||
assert DEFAULT_TRANSITION_DURATION == 0.5
|
||||
assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION
|
||||
|
||||
def test_cut_transition(self):
|
||||
assert CUT_TRANSITION == "cut"
|
||||
|
||||
|
||||
# ── TransitionType 枚举 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionType:
|
||||
def test_all_supported_includes_all_except_cut(self):
|
||||
supported = TransitionType.all_supported()
|
||||
assert "cut" not in supported
|
||||
assert "fade" in supported
|
||||
assert "dissolve" in supported
|
||||
assert len(supported) >= 10 # 至少有10种转场
|
||||
|
||||
def test_all_supported_unique(self):
|
||||
supported = TransitionType.all_supported()
|
||||
assert len(supported) == len(set(supported))
|
||||
|
||||
def test_is_supported_exact_match(self):
|
||||
assert TransitionType.is_supported("fade") is True
|
||||
assert TransitionType.is_supported("dissolve") is True
|
||||
assert TransitionType.is_supported("slideleft") is True
|
||||
|
||||
def test_is_supported_case_insensitive(self):
|
||||
assert TransitionType.is_supported("FADE") is True
|
||||
assert TransitionType.is_supported("Fade") is True
|
||||
assert TransitionType.is_supported("SlideLeft") is True
|
||||
|
||||
def test_is_supported_with_underscores(self):
|
||||
assert TransitionType.is_supported("slide_left") is True
|
||||
assert TransitionType.is_supported("wipe_right") is True
|
||||
assert TransitionType.is_supported("circle_crop") is True
|
||||
|
||||
def test_is_supported_with_hyphens(self):
|
||||
assert TransitionType.is_supported("slide-left") is True
|
||||
assert TransitionType.is_supported("wipe-down") is True
|
||||
|
||||
def test_is_supported_aliases(self):
|
||||
assert TransitionType.is_supported("crossfade") is True
|
||||
assert TransitionType.is_supported("crossdissolve") is True
|
||||
assert TransitionType.is_supported("fadein") is True
|
||||
assert TransitionType.is_supported("fadeout") is True
|
||||
assert TransitionType.is_supported("slide") is True
|
||||
assert TransitionType.is_supported("wipe") is True
|
||||
assert TransitionType.is_supported("zoomin") is True
|
||||
assert TransitionType.is_supported("zoomout") is True
|
||||
assert TransitionType.is_supported("circle") is True
|
||||
assert TransitionType.is_supported("rect") is True
|
||||
|
||||
def test_is_supported_unknown(self):
|
||||
assert TransitionType.is_supported("unknown_effect") is False
|
||||
assert TransitionType.is_supported("") is False
|
||||
assert TransitionType.is_supported("12345") is False
|
||||
|
||||
def test_enum_values_match_ffmpeg(self):
|
||||
# 枚举值应该就是 ffmpeg xfade 的 transition 名
|
||||
assert TransitionType.FADE.value == "fade"
|
||||
assert TransitionType.DISSOLVE.value == "dissolve"
|
||||
assert TransitionType.SLIDE_LEFT.value == "slideleft"
|
||||
assert TransitionType.CUT.value == "cut"
|
||||
|
||||
|
||||
# ── TransitionConfig 默认值 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigDefaults:
|
||||
def test_default_config(self):
|
||||
cfg = TransitionConfig()
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
assert cfg.is_cut is True
|
||||
|
||||
def test_is_cut_true(self):
|
||||
cfg = TransitionConfig(effect="cut")
|
||||
assert cfg.is_cut is True
|
||||
|
||||
def test_is_cut_false(self):
|
||||
cfg = TransitionConfig(effect="fade")
|
||||
assert cfg.is_cut is False
|
||||
|
||||
|
||||
# ── TransitionConfig.parse ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigParse:
|
||||
def test_none_params_default(self):
|
||||
cfg = TransitionConfig.parse()
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_empty_effect_default(self):
|
||||
cfg = TransitionConfig.parse(effect="")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_whitespace_effect_default(self):
|
||||
cfg = TransitionConfig.parse(effect=" ")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_valid_effect_fade(self):
|
||||
cfg = TransitionConfig.parse(effect="fade")
|
||||
assert cfg.effect == "fade"
|
||||
assert cfg.is_cut is False
|
||||
|
||||
def test_valid_effect_case_insensitive(self):
|
||||
cfg = TransitionConfig.parse(effect="FADE")
|
||||
assert cfg.effect == "fade"
|
||||
|
||||
def test_valid_effect_with_underscores(self):
|
||||
cfg = TransitionConfig.parse(effect="slide_left")
|
||||
assert cfg.effect == "slideleft"
|
||||
|
||||
def test_alias_effect(self):
|
||||
cfg = TransitionConfig.parse(effect="crossfade")
|
||||
assert cfg.effect == "dissolve" # 别名映射到 dissolve
|
||||
|
||||
def test_unknown_effect_falls_back_to_cut(self):
|
||||
cfg = TransitionConfig.parse(effect="magic_sparkles")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert cfg.is_cut is True
|
||||
|
||||
def test_cut_effect_stays_cut(self):
|
||||
cfg = TransitionConfig.parse(effect="cut")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_cut_effect_case_insensitive(self):
|
||||
cfg = TransitionConfig.parse(effect="CUT")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_duration_default(self):
|
||||
cfg = TransitionConfig.parse(duration=None)
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_duration_within_range(self):
|
||||
cfg = TransitionConfig.parse(duration=1.0)
|
||||
assert cfg.duration == 1.0
|
||||
|
||||
def test_duration_at_min(self):
|
||||
cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_duration_at_max(self):
|
||||
cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION)
|
||||
assert cfg.duration == MAX_TRANSITION_DURATION
|
||||
|
||||
def test_duration_below_min_clamped(self):
|
||||
cfg = TransitionConfig.parse(duration=0.1)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_duration_above_max_clamped(self):
|
||||
cfg = TransitionConfig.parse(duration=3.0)
|
||||
assert cfg.duration == MAX_TRANSITION_DURATION
|
||||
|
||||
def test_duration_zero_clamped(self):
|
||||
cfg = TransitionConfig.parse(duration=0)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_duration_negative_clamped(self):
|
||||
cfg = TransitionConfig.parse(duration=-1.0)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_duration_invalid_string_fallback(self):
|
||||
cfg = TransitionConfig.parse(duration="bad") # type: ignore[arg-type]
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_duration_numeric_string(self):
|
||||
cfg = TransitionConfig.parse(duration="1.5") # type: ignore[arg-type]
|
||||
assert cfg.duration == 1.5
|
||||
|
||||
def test_full_parse(self):
|
||||
cfg = TransitionConfig.parse(effect="wipe_up", duration=1.2)
|
||||
assert cfg.effect == "wipeup"
|
||||
assert cfg.duration == 1.2
|
||||
assert cfg.is_cut is False
|
||||
|
||||
|
||||
# ── TransitionConfig.ffmpeg_transition ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestFfmpegTransition:
|
||||
def test_cut_returns_empty(self):
|
||||
cfg = TransitionConfig(effect="cut")
|
||||
assert cfg.ffmpeg_transition == ""
|
||||
|
||||
def test_fade_matches(self):
|
||||
cfg = TransitionConfig(effect="fade")
|
||||
assert cfg.ffmpeg_transition == "fade"
|
||||
|
||||
def test_dissolve_matches(self):
|
||||
cfg = TransitionConfig(effect="dissolve")
|
||||
assert cfg.ffmpeg_transition == "dissolve"
|
||||
|
||||
def test_slide_left_matches(self):
|
||||
cfg = TransitionConfig(effect="slideleft")
|
||||
assert cfg.ffmpeg_transition == "slideleft"
|
||||
|
||||
def test_wipe_down_matches(self):
|
||||
cfg = TransitionConfig(effect="wipedown")
|
||||
assert cfg.ffmpeg_transition == "wipedown"
|
||||
|
||||
def test_zoom_matches_zoomin(self):
|
||||
cfg = TransitionConfig(effect="zoom")
|
||||
assert cfg.ffmpeg_transition == "zoomin"
|
||||
|
||||
def test_circle_crop_matches(self):
|
||||
cfg = TransitionConfig(effect="circlecrop")
|
||||
assert cfg.ffmpeg_transition == "circlecrop"
|
||||
|
||||
|
||||
# ── TransitionConfig.validate ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigValidate:
|
||||
def test_valid_cut(self):
|
||||
cfg = TransitionConfig(effect="cut", duration=0.5)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
def test_valid_fade(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=1.0)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_duration_below_min_invalid(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=0.1)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is False
|
||||
assert "duration" in err
|
||||
|
||||
def test_duration_above_max_invalid(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=3.0)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is False
|
||||
assert "duration" in err
|
||||
|
||||
def test_unsupported_effect_invalid(self):
|
||||
cfg = TransitionConfig(effect="unknown", duration=0.5)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不支持的转场" in err
|
||||
|
||||
def test_min_duration_boundary_valid(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_max_duration_boundary_valid(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
Reference in New Issue
Block a user