ac667e60c9
CI/CD Pipeline / Check if frontend-only change (push) Blocked by required conditions
CI/CD Pipeline / Validate - Code Quality (push) Blocked by required conditions
CI/CD Pipeline / Validate - Type Check (mypy) (push) Blocked by required conditions
CI/CD Pipeline / Validate - Migration (alembic) (push) Blocked by required conditions
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Blocked by required conditions
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Web Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
246 lines
8.2 KiB
Python
Executable File
246 lines
8.2 KiB
Python
Executable File
"""转场配置领域模型 — 纯逻辑,无 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, ""
|