"""转场特效引擎单测 — 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"