Files
xiaoxia-saas/tests/unit/test_transition_engine.py
T
xiaoxia ff38ee0f2b
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
feat: 转场特效引擎 — 14种转场预设 + TransitionEngine抽象 + 降级策略
Squash merge PR #293
2026-07-14 11:03:56 +08:00

485 lines
18 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""转场特效引擎单测 — Phase 8 智能增强."""
from __future__ import annotations
import pytest
from video_processing.transition_engine import (
CUT_TRANSITION,
DEFAULT_TRANSITION_DURATION,
MAX_TRANSITION_DURATION,
MIN_TRANSITION_DURATION,
TransitionConfig,
TransitionEngine,
TransitionType,
_normalize_transition_name,
)
# ── TransitionType 枚举测试 ──────────────────────────────────────────────────
class TestTransitionType:
"""TransitionType 枚举测试."""
def test_all_supported_count(self):
"""支持的转场类型数量(不含cut."""
supported = TransitionType.all_supported()
# 至少 8 种:fade, dissolve, slide*4, zoom, wipe*4, circlecrop, rectcrop
assert len(supported) >= 8
assert "fade" in supported
assert "dissolve" in supported
assert "zoom" in supported
assert "circlecrop" in supported
assert "rectcrop" in supported
def test_slide_directions(self):
"""四个方向的滑入转场都支持."""
assert TransitionType.is_supported("slideleft")
assert TransitionType.is_supported("slideright")
assert TransitionType.is_supported("slideup")
assert TransitionType.is_supported("slidedown")
def test_wipe_directions(self):
"""四个方向的擦除转场都支持."""
assert TransitionType.is_supported("wipeleft")
assert TransitionType.is_supported("wiperight")
assert TransitionType.is_supported("wipeup")
assert TransitionType.is_supported("wipedown")
def test_is_supported_case_insensitive(self):
"""大小写不敏感."""
assert TransitionType.is_supported("FADE")
assert TransitionType.is_supported("Fade")
assert TransitionType.is_supported("fade")
def test_is_supported_with_underscores(self):
"""下划线不影响判断."""
assert TransitionType.is_supported("slide_left")
assert TransitionType.is_supported("slide-left")
def test_is_supported_aliases(self):
"""别名支持."""
assert TransitionType.is_supported("crossfade")
assert TransitionType.is_supported("dissolve")
assert TransitionType.is_supported("zoomin")
assert TransitionType.is_supported("wipe")
def test_unsupported_transition(self):
"""不支持的转场返回 False."""
assert not TransitionType.is_supported("nonexistent_effect")
assert not TransitionType.is_supported("random_stuff")
assert not TransitionType.is_supported("")
def test_cut_not_in_supported(self):
"""硬切不在"支持的转场效果"列表中(它不是特效)."""
supported = TransitionType.all_supported()
assert "cut" not in supported
# ── 名称标准化测试 ────────────────────────────────────────────────────────────
class TestNormalizeTransitionName:
"""名称标准化函数测试."""
def test_lowercase(self):
"""大写转小写."""
assert _normalize_transition_name("FADE") == "fade"
assert _normalize_transition_name("Fade") == "fade"
def test_remove_underscores(self):
"""移除下划线."""
assert _normalize_transition_name("slide_left") == "slideleft"
assert _normalize_transition_name("slide_up") == "slideup"
def test_remove_hyphens(self):
"""移除连字符."""
assert _normalize_transition_name("slide-left") == "slideleft"
def test_mixed(self):
"""混合情况."""
assert _normalize_transition_name("Slide_Left") == "slideleft"
assert _normalize_transition_name("FADE-IN") == "fadein"
# ── TransitionConfig 测试 ────────────────────────────────────────────────────
class TestTransitionConfig:
"""TransitionConfig 配置解析测试."""
# ── 默认值 ──
def test_default_config(self):
"""默认配置是硬切."""
cfg = TransitionConfig.parse()
assert cfg.effect == CUT_TRANSITION
assert cfg.duration == DEFAULT_TRANSITION_DURATION
assert cfg.is_cut is True
def test_none_effect(self):
"""None effect 降级为 cut."""
cfg = TransitionConfig.parse(effect=None)
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_empty_effect(self):
"""空字符串 effect 降级为 cut."""
cfg = TransitionConfig.parse(effect="")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
# ── 有效转场类型 ──
def test_fade_effect(self):
"""fade 转场."""
cfg = TransitionConfig.parse(effect="fade")
assert cfg.effect == "fade"
assert cfg.is_cut is False
assert cfg.ffmpeg_transition == "fade"
def test_dissolve_effect(self):
"""dissolve 转场."""
cfg = TransitionConfig.parse(effect="dissolve")
assert cfg.effect == "dissolve"
assert cfg.ffmpeg_transition == "dissolve"
def test_zoom_effect(self):
"""zoom 转场 → FFmpeg zoomin."""
cfg = TransitionConfig.parse(effect="zoom")
assert cfg.effect == "zoom"
assert cfg.ffmpeg_transition == "zoomin"
def test_slide_left_alias(self):
"""slide_left 别名."""
cfg = TransitionConfig.parse(effect="slide_left")
assert cfg.effect == "slideleft"
assert cfg.ffmpeg_transition == "slideleft"
def test_wipe_alias(self):
"""wipe 别名 → 默认向左擦."""
cfg = TransitionConfig.parse(effect="wipe")
assert cfg.effect == "wipeleft"
assert cfg.ffmpeg_transition == "wipeleft"
def test_circlecrop_effect(self):
"""圆形扩散转场."""
cfg = TransitionConfig.parse(effect="circlecrop")
assert cfg.effect == "circlecrop"
assert cfg.ffmpeg_transition == "circlecrop"
def test_rectcrop_effect(self):
"""矩形扩散转场."""
cfg = TransitionConfig.parse(effect="rectcrop")
assert cfg.effect == "rectcrop"
assert cfg.ffmpeg_transition == "rectcrop"
# ── 降级策略 ──
def test_unsupported_fallback_to_cut(self):
"""不支持的转场自动降级为硬切,不阻断渲染."""
cfg = TransitionConfig.parse(effect="nonexistent_effect")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_unsupported_whitespace_fallback(self):
"""带空格的不支持转场也降级."""
cfg = TransitionConfig.parse(effect=" bad effect ")
assert cfg.effect == CUT_TRANSITION
# ── 时长边界校验 ──
def test_default_duration(self):
"""默认时长 0.5s."""
cfg = TransitionConfig.parse(effect="fade")
assert cfg.duration == 0.5
def test_duration_within_range(self):
"""正常范围内的时长."""
cfg = TransitionConfig.parse(effect="fade", duration=1.0)
assert cfg.duration == 1.0
def test_duration_min_boundary(self):
"""最小值边界."""
cfg = TransitionConfig.parse(effect="fade", duration=MIN_TRANSITION_DURATION)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_max_boundary(self):
"""最大值边界."""
cfg = TransitionConfig.parse(effect="fade", duration=MAX_TRANSITION_DURATION)
assert cfg.duration == MAX_TRANSITION_DURATION
def test_duration_below_min_clamped(self):
"""低于最小值的时长被钳制."""
cfg = TransitionConfig.parse(effect="fade", duration=0.1)
assert cfg.duration == MIN_TRANSITION_DURATION
assert cfg.duration >= MIN_TRANSITION_DURATION
def test_duration_above_max_clamped(self):
"""高于最大值的时长被钳制."""
cfg = TransitionConfig.parse(effect="fade", duration=5.0)
assert cfg.duration == MAX_TRANSITION_DURATION
assert cfg.duration <= MAX_TRANSITION_DURATION
def test_duration_zero_default_for_effect(self):
"""有转场效果但 duration=0 时使用默认值."""
# 0.0 会被当作小于最小值钳制到 0.3
cfg = TransitionConfig.parse(effect="fade", duration=0.0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_negative_clamped(self):
"""负时长被钳制到最小值."""
cfg = TransitionConfig.parse(effect="fade", duration=-1.0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_none_uses_default(self):
"""None duration 使用默认值."""
cfg = TransitionConfig.parse(effect="fade", duration=None)
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_duration_invalid_type(self):
"""无效类型的时长使用默认值."""
cfg = TransitionConfig.parse(effect="fade", duration="abc") # type: ignore
assert cfg.duration == DEFAULT_TRANSITION_DURATION
# ── cut 的 ffmpeg_transition ──
def test_cut_ffmpeg_transition_empty(self):
"""硬切没有对应的 FFmpeg xfade transition."""
cfg = TransitionConfig.parse(effect="cut")
assert cfg.ffmpeg_transition == ""
# ── TransitionEngine 测试 ────────────────────────────────────────────────────
class TestTransitionEngine:
"""TransitionEngine 转场引擎测试."""
def test_default_engine(self):
"""默认引擎初始化."""
engine = TransitionEngine()
assert engine is not None
def test_custom_default_duration(self):
"""自定义默认时长."""
engine = TransitionEngine(default_duration=1.0)
cfg = engine.resolve_config(effect="fade")
assert cfg.duration == 1.0
def test_resolve_config_fade(self):
"""解析 fade 配置."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="fade", duration=0.8)
assert cfg.effect == "fade"
assert cfg.duration == 0.8
def test_resolve_config_fallback(self):
"""不支持的转场降级."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="unknown_effect")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_resolve_config_duration_clamp(self):
"""时长边界钳制."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="fade", duration=3.0)
assert cfg.duration == MAX_TRANSITION_DURATION
# ── 批量解析 ──
def test_resolve_clip_transitions_all_valid(self):
"""批量解析全部有效转场."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(["cut", "fade", "dissolve", "slideleft"])
assert len(configs) == 4
assert configs[0].effect == "cut"
assert configs[0].is_cut is True
assert configs[1].effect == "fade"
assert configs[2].effect == "dissolve"
assert configs[3].effect == "slideleft"
def test_resolve_clip_transitions_with_fallback(self):
"""批量解析包含不支持的转场,自动降级."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(["fade", "bad_effect", "dissolve", "worse_effect"])
assert len(configs) == 4
assert configs[0].effect == "fade"
assert configs[1].effect == "cut" # 降级
assert configs[2].effect == "dissolve"
assert configs[3].effect == "cut" # 降级
def test_resolve_clip_transitions_with_durations(self):
"""带时长校验的批量解析(转场时长不超过片段时长的一半)."""
engine = TransitionEngine(default_duration=1.0)
# 片段只有 1.0s,转场时长被限制在 0.5s
configs = engine.resolve_clip_transitions(
["fade", "dissolve"],
clip_durations=[1.0, 1.0],
)
assert len(configs) == 2
# 1.0s 默认值超过了片段时长的一半 (0.5s),所以被钳制
assert configs[0].duration <= 0.5
assert configs[1].duration <= 0.5
def test_resolve_clip_transitions_short_clip_min_bound(self):
"""超短片段的转场时长至少为最小值."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(
["fade"],
clip_durations=[0.1], # 极短片段
)
assert len(configs) == 1
# 0.1 * 0.5 = 0.05 < MIN_TRANSITION_DURATION,所以用最小值
assert configs[0].duration == MIN_TRANSITION_DURATION
# ── xfade 滤镜链构建 ──
def test_build_xfade_single_clip(self):
"""单 clip 直接 copy."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[5.0],
clip_video_labels=["v0"],
transitions=["cut"],
output_label="outv",
)
assert "copy" in filter_str
assert "[outv]" in filter_str
assert total_dur == pytest.approx(5.0, abs=0.01)
def test_build_xfade_two_clips_fade(self):
"""两个 clip 之间 fade 转场."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
output_label="outv",
)
assert "xfade" in filter_str
assert "transition=fade" in filter_str
# 总时长 = 3 + 4 - transition_duration (0.5) = 6.5
assert total_dur == pytest.approx(6.5, abs=0.1)
def test_build_xfade_three_clips_mixed(self):
"""三个 clip 混合转场."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0, 5.0],
clip_video_labels=["v0", "v1", "v2"],
transitions=["cut", "fade", "dissolve"],
output_label="outv",
)
assert "xfade" in filter_str
assert "transition=fade" in filter_str
assert "transition=dissolve" in filter_str
# 总时长 ≈ 3 + 4 + 5 - 2 * 0.5 = 11.0
assert total_dur == pytest.approx(11.0, abs=0.2)
def test_build_xfade_with_custom_duration(self):
"""自定义转场时长."""
engine = TransitionEngine(default_duration=0.5)
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
transition_duration=1.0,
output_label="outv",
)
assert "xfade" in filter_str
# 总时长 = 3 + 4 - 1.0 = 6.0
assert total_dur == pytest.approx(6.0, abs=0.1)
def test_build_xfade_zoom_transition(self):
"""zoom 转场滤镜构建."""
engine = TransitionEngine()
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "zoom"],
)
assert "xfade" in filter_str
assert "transition=zoomin" in filter_str # zoom → zoomin
def test_build_xfade_slide_directions(self):
"""四个方向的滑入转场."""
engine = TransitionEngine()
for direction in ["slideleft", "slideright", "slideup", "slidedown"]:
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", direction],
)
assert f"transition={direction}" in filter_str
def test_build_xfade_fallback_transition(self):
"""不支持的转场降级后构建(降级为cut,等效于极短fade."""
engine = TransitionEngine()
# bad_effect 降级为 cutcut 使用极短转场
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "bad_effect"],
)
# 降级后是 cutcut 会被 xfade 层映射为 fade(因为 cut 不在 map 里)
# 但时长会很短,所以仍然有 xfade
assert "xfade" in filter_str
# ── 支持的转场列表 ──
def test_supported_transitions_list(self):
"""获取支持的转场列表(给 API 用)."""
transitions = TransitionEngine.supported_transitions()
assert len(transitions) >= 10 # cut + 至少 9 种特效
# 检查结构
for t in transitions:
assert "name" in t
assert "display_name" in t
assert "category" in t
# 检查分类
names = [t["name"] for t in transitions]
assert "cut" in names
assert "fade" in names
assert "zoom" in names
assert "circlecrop" in names
# ── 集成测试:与 UnifiedRenderService 协作 ────────────────────────────────────
class TestTransitionIntegration:
"""转场引擎与统一渲染服务的集成测试."""
def test_unified_render_service_has_transition_engine(self):
"""UnifiedRenderService 内部有 TransitionEngine 实例."""
from pathlib import Path
from video_processing.unified_render_service import UnifiedRenderService
# 构造最小化的服务实例
service = UnifiedRenderService(
plan=None,
clips=[],
asset_path_map={},
work_dir=Path("/tmp"),
)
assert hasattr(service, "_transition_engine")
assert isinstance(service._transition_engine, TransitionEngine)
def test_resolved_clip_has_transition_duration(self):
"""ResolvedClip 有 transition_duration 字段."""
from video_processing.unified_render_service import ResolvedClip
rc = ResolvedClip(
clip_id="test",
asset_id="asset1",
local_path=__file__, # 随便一个存在的路径
clip_type="main",
order=0,
transition_effect="fade",
transition_duration=0.8,
)
assert rc.transition_duration == 0.8
assert rc.transition_effect == "fade"