"""转场配置领域模型 — 纯逻辑,无 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, ""