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
Squash merge PR #293
485 lines
18 KiB
Python
Executable File
485 lines
18 KiB
Python
Executable File
"""转场特效引擎单测 — 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 降级为 cut,cut 使用极短转场
|
||
filter_str, _ = engine.build_xfade_chain(
|
||
clip_durations=[3.0, 4.0],
|
||
clip_video_labels=["v0", "v1"],
|
||
transitions=["cut", "bad_effect"],
|
||
)
|
||
# 降级后是 cut,cut 会被 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"
|