test(unit): 第65波 - intro_outro + transition + pip 引擎配置 (+82) #861
@@ -1,301 +1,309 @@
|
||||
"""
|
||||
片头片尾引擎配置与纯逻辑测试.
|
||||
"""片头片尾引擎单元测试 - 配置解析等纯逻辑."""
|
||||
|
||||
覆盖 IntroOutroConfig.from_dict / validate / has_intro / has_outro 等纯逻辑.
|
||||
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.intro_outro_engine import IntroOutroConfig
|
||||
|
||||
|
||||
class TestIntroOutroConfigDefaults:
|
||||
"""默认配置测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
config = IntroOutroConfig()
|
||||
assert config.enabled is False
|
||||
assert config.intro_type == "none"
|
||||
assert config.outro_type == "none"
|
||||
assert config.intro_duration == 3.0
|
||||
assert config.outro_duration == 3.0
|
||||
assert config.transition_effect == "fade"
|
||||
assert config.transition_duration == 0.5
|
||||
|
||||
|
||||
class TestIntroOutroConfigFromDict:
|
||||
"""from_dict 构造逻辑."""
|
||||
"""from_dict 配置解析测试."""
|
||||
|
||||
def test_none_returns_default_disabled(self):
|
||||
cfg = IntroOutroConfig.from_dict(None)
|
||||
assert cfg.enabled is False
|
||||
assert cfg.intro_type == "none"
|
||||
assert cfg.outro_type == "none"
|
||||
def test_none_returns_default(self):
|
||||
"""None 返回默认配置."""
|
||||
config = IntroOutroConfig.from_dict(None)
|
||||
assert config.enabled is False
|
||||
|
||||
def test_empty_dict_returns_default_disabled(self):
|
||||
cfg = IntroOutroConfig.from_dict({})
|
||||
assert cfg.enabled is False
|
||||
def test_empty_dict_returns_default(self):
|
||||
"""空 dict 返回默认."""
|
||||
config = IntroOutroConfig.from_dict({})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_enabled_false_returns_default_disabled(self):
|
||||
cfg = IntroOutroConfig.from_dict({"enabled": False})
|
||||
assert cfg.enabled is False
|
||||
def test_disabled_returns_default(self):
|
||||
"""enabled=False 返回默认."""
|
||||
config = IntroOutroConfig.from_dict({"enabled": False})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_enabled_with_video_intro(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/intro.mp4",
|
||||
"duration": 5.0,
|
||||
},
|
||||
"outro": {"type": "none"},
|
||||
})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.intro_type == "video"
|
||||
assert cfg.intro_video_path == "/tmp/intro.mp4"
|
||||
assert cfg.intro_duration == 5.0
|
||||
def test_enabled_defaults(self):
|
||||
"""启用时默认值正确."""
|
||||
config = IntroOutroConfig.from_dict({"enabled": True})
|
||||
assert config.enabled is True
|
||||
assert config.intro_type == "none"
|
||||
assert config.outro_type == "none"
|
||||
|
||||
def test_enabled_with_text_intro(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
def test_text_intro(self):
|
||||
"""文字片头配置."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "text",
|
||||
"title": "Hello",
|
||||
"subtitle": "World",
|
||||
"background": "#ffffff",
|
||||
"title_color": "black",
|
||||
"title_size": 64,
|
||||
"duration": 2.5,
|
||||
},
|
||||
"outro": {"type": "none"},
|
||||
})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.intro_type == "text"
|
||||
assert cfg.intro_title == "Hello"
|
||||
assert cfg.intro_subtitle == "World"
|
||||
assert cfg.intro_background == "#ffffff"
|
||||
assert cfg.intro_title_color == "black"
|
||||
assert cfg.intro_title_size == 64
|
||||
assert cfg.intro_duration == 2.5
|
||||
|
||||
def test_enabled_with_video_outro(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {"type": "none"},
|
||||
"outro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/outro.mp4",
|
||||
"duration": 4.0,
|
||||
"title": "我的片头",
|
||||
"subtitle": "欢迎收看",
|
||||
},
|
||||
})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.outro_type == "video"
|
||||
assert cfg.outro_video_path == "/tmp/outro.mp4"
|
||||
assert cfg.outro_duration == 4.0
|
||||
assert config.intro_type == "text"
|
||||
assert config.intro_title == "我的片头"
|
||||
assert config.intro_subtitle == "欢迎收看"
|
||||
|
||||
def test_enabled_with_text_outro_default_values(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {"type": "none"},
|
||||
"outro": {"type": "text"},
|
||||
})
|
||||
assert cfg.outro_title == "感谢观看"
|
||||
assert cfg.outro_subtitle == "点赞关注不迷路"
|
||||
assert cfg.outro_title_size == 48
|
||||
assert cfg.outro_duration == 3.0
|
||||
|
||||
def test_video_key_fallback(self):
|
||||
"""video 字段作为 video_path 的 fallback."""
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
def test_video_intro(self):
|
||||
"""视频片头配置."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "video",
|
||||
"video": "/tmp/fallback.mp4",
|
||||
"video_path": "/videos/intro.mp4",
|
||||
"duration": 5.0,
|
||||
},
|
||||
"outro": {"type": "none"},
|
||||
})
|
||||
assert cfg.intro_video_path == "/tmp/fallback.mp4"
|
||||
assert config.intro_type == "video"
|
||||
assert config.intro_video_path == "/videos/intro.mp4"
|
||||
assert config.intro_duration == 5.0
|
||||
|
||||
def test_video_intro_video_alias(self):
|
||||
"""video 字段作为 video_path 别名."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "video",
|
||||
"video": "/videos/intro.mp4",
|
||||
},
|
||||
})
|
||||
assert config.intro_video_path == "/videos/intro.mp4"
|
||||
|
||||
def test_text_outro(self):
|
||||
"""文字片尾配置."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"outro": {
|
||||
"type": "text",
|
||||
"title": "感谢观看",
|
||||
"subtitle": "点赞关注",
|
||||
},
|
||||
})
|
||||
assert config.outro_type == "text"
|
||||
assert config.outro_title == "感谢观看"
|
||||
assert config.outro_subtitle == "点赞关注"
|
||||
|
||||
def test_outro_default_title(self):
|
||||
"""片尾默认标题."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"outro": {"type": "text"},
|
||||
})
|
||||
assert config.outro_title == "感谢观看"
|
||||
assert config.outro_subtitle == "点赞关注不迷路"
|
||||
|
||||
def test_text_intro_styling(self):
|
||||
"""文字片头样式配置."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "text",
|
||||
"title": "测试",
|
||||
"background": "#FF0000",
|
||||
"title_color": "yellow",
|
||||
"title_size": 64,
|
||||
"subtitle_color": "white",
|
||||
"subtitle_size": 32,
|
||||
},
|
||||
})
|
||||
assert config.intro_background == "#FF0000"
|
||||
assert config.intro_title_color == "yellow"
|
||||
assert config.intro_title_size == 64
|
||||
assert config.intro_subtitle_color == "white"
|
||||
assert config.intro_subtitle_size == 32
|
||||
|
||||
def test_transition_config(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"""转场配置."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {"type": "none"},
|
||||
"outro": {"type": "none"},
|
||||
"transition": "fade",
|
||||
"transition": "dissolve",
|
||||
"transition_duration": 1.0,
|
||||
})
|
||||
assert cfg.transition_effect == "fade"
|
||||
assert cfg.transition_duration == 1.0
|
||||
assert config.transition_effect == "dissolve"
|
||||
assert config.transition_duration == 1.0
|
||||
|
||||
def test_default_transition(self):
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
def test_empty_intro_dict(self):
|
||||
"""空 intro dict."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {"type": "none"},
|
||||
"outro": {"type": "none"},
|
||||
"intro": {},
|
||||
})
|
||||
assert cfg.transition_effect == "fade"
|
||||
assert cfg.transition_duration == 0.5
|
||||
assert config.intro_type == "none"
|
||||
|
||||
def test_none_intro(self):
|
||||
"""None intro 值."""
|
||||
config = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": None,
|
||||
})
|
||||
assert config.intro_type == "none"
|
||||
|
||||
|
||||
class TestIntroOutroConfigProperties:
|
||||
"""has_intro / has_outro 属性."""
|
||||
|
||||
def test_has_intro_video_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="video",
|
||||
intro_video_path="/tmp/a.mp4",
|
||||
)
|
||||
assert cfg.has_intro is True
|
||||
|
||||
def test_has_intro_text_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="Hi",
|
||||
)
|
||||
assert cfg.has_intro is True
|
||||
class TestHasIntroOutro:
|
||||
"""has_intro / has_outro 属性测试."""
|
||||
|
||||
def test_no_intro_when_disabled(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=False,
|
||||
intro_type="video",
|
||||
intro_video_path="/tmp/a.mp4",
|
||||
)
|
||||
assert cfg.has_intro is False
|
||||
"""禁用时无片头."""
|
||||
config = IntroOutroConfig()
|
||||
assert config.has_intro is False
|
||||
assert config.has_outro is False
|
||||
|
||||
def test_no_intro_when_none_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
def test_video_intro_has_intro(self):
|
||||
"""视频片头有has_intro."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="none",
|
||||
intro_type="video",
|
||||
intro_video_path="/a.mp4",
|
||||
)
|
||||
assert cfg.has_intro is False
|
||||
assert config.has_intro is True
|
||||
|
||||
def test_has_outro_video_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
def test_text_intro_has_intro(self):
|
||||
"""文字片头有has_intro."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="test",
|
||||
)
|
||||
assert config.has_intro is True
|
||||
|
||||
def test_none_intro_no_intro(self):
|
||||
"""none类型无片头."""
|
||||
config = IntroOutroConfig(enabled=True, intro_type="none")
|
||||
assert config.has_intro is False
|
||||
|
||||
def test_video_outro_has_outro(self):
|
||||
"""视频片尾有has_outro."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="video",
|
||||
outro_video_path="/tmp/a.mp4",
|
||||
outro_video_path="/a.mp4",
|
||||
)
|
||||
assert cfg.has_outro is True
|
||||
assert config.has_outro is True
|
||||
|
||||
def test_has_outro_text_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
def test_text_outro_has_outro(self):
|
||||
"""文字片尾有has_outro."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="text",
|
||||
outro_title="Bye",
|
||||
outro_title="test",
|
||||
)
|
||||
assert cfg.has_outro is True
|
||||
assert config.has_outro is True
|
||||
|
||||
def test_has_outro_follow_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
def test_follow_outro_has_outro(self):
|
||||
"""follow类型片尾有has_outro."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="follow",
|
||||
outro_title="Follow me",
|
||||
outro_title="test",
|
||||
)
|
||||
assert cfg.has_outro is True
|
||||
|
||||
def test_no_outro_when_disabled(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=False,
|
||||
outro_type="text",
|
||||
outro_title="Bye",
|
||||
)
|
||||
assert cfg.has_outro is False
|
||||
|
||||
def test_no_outro_when_none_type(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="none",
|
||||
)
|
||||
assert cfg.has_outro is False
|
||||
assert config.has_outro is True
|
||||
|
||||
|
||||
class TestIntroOutroConfigValidate:
|
||||
"""validate 校验逻辑."""
|
||||
class TestValidate:
|
||||
"""validate 配置校验测试."""
|
||||
|
||||
def test_disabled_is_valid(self):
|
||||
cfg = IntroOutroConfig(enabled=False)
|
||||
ok, msg = cfg.validate()
|
||||
def test_disabled_valid(self):
|
||||
"""禁用配置合法."""
|
||||
config = IntroOutroConfig()
|
||||
ok, msg = config.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_video_intro_missing_path(self):
|
||||
cfg = IntroOutroConfig(
|
||||
"""视频片头缺少路径."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="video",
|
||||
intro_video_path="",
|
||||
outro_type="none",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
ok, msg = config.validate()
|
||||
assert ok is False
|
||||
assert "video_path" in msg
|
||||
|
||||
def test_text_intro_missing_title(self):
|
||||
cfg = IntroOutroConfig(
|
||||
"""文字片头缺少标题."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="",
|
||||
outro_type="none",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
ok, msg = config.validate()
|
||||
assert ok is False
|
||||
assert "title" in msg
|
||||
|
||||
def test_video_outro_missing_path(self):
|
||||
cfg = IntroOutroConfig(
|
||||
"""视频片尾缺少路径."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="none",
|
||||
outro_type="video",
|
||||
outro_video_path="",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
ok, msg = config.validate()
|
||||
assert ok is False
|
||||
assert "video_path" in msg
|
||||
|
||||
def test_text_outro_missing_title(self):
|
||||
cfg = IntroOutroConfig(
|
||||
"""文字片尾缺少标题."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="none",
|
||||
outro_type="text",
|
||||
outro_title="",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
ok, msg = config.validate()
|
||||
assert ok is False
|
||||
assert "title" in msg
|
||||
|
||||
def test_intro_duration_zero(self):
|
||||
cfg = IntroOutroConfig(
|
||||
def test_zero_intro_duration_invalid(self):
|
||||
"""片头时长为0无效."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="Hi",
|
||||
intro_title="test",
|
||||
intro_duration=0,
|
||||
outro_type="none",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
ok, msg = config.validate()
|
||||
assert ok is False
|
||||
assert "片头时长" in msg
|
||||
assert "时长" in msg
|
||||
|
||||
def test_intro_duration_negative(self):
|
||||
cfg = IntroOutroConfig(
|
||||
def test_negative_outro_duration_invalid(self):
|
||||
"""片尾时长为负无效."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
outro_type="text",
|
||||
outro_title="test",
|
||||
outro_duration=-1.0,
|
||||
)
|
||||
ok, msg = config.validate()
|
||||
assert ok is False
|
||||
assert "时长" in msg
|
||||
|
||||
def test_valid_text_both(self):
|
||||
"""文字片头片尾都合法."""
|
||||
config = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="Hi",
|
||||
intro_duration=-1.0,
|
||||
outro_type="none",
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "片头时长" in msg
|
||||
|
||||
def test_outro_duration_zero(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="none",
|
||||
outro_type="text",
|
||||
outro_title="Bye",
|
||||
outro_duration=0,
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "片尾时长" in msg
|
||||
|
||||
def test_valid_full_config(self):
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="video",
|
||||
intro_video_path="/tmp/intro.mp4",
|
||||
intro_title="片头",
|
||||
intro_duration=3.0,
|
||||
outro_type="text",
|
||||
outro_title="Thanks",
|
||||
outro_duration=2.0,
|
||||
outro_title="片尾",
|
||||
outro_duration=3.0,
|
||||
)
|
||||
ok, msg = cfg.validate()
|
||||
ok, msg = config.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
+215
-550
@@ -1,597 +1,262 @@
|
||||
"""画中画(PiP)引擎单元测试."""
|
||||
"""画中画引擎单元测试 - 配置解析+校验等纯逻辑."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from video_processing.pip_engine import (
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
POSITION_BOTTOM_LEFT,
|
||||
POSITION_BOTTOM_RIGHT,
|
||||
POSITION_CENTER,
|
||||
POSITION_TOP_LEFT,
|
||||
POSITION_TOP_RIGHT,
|
||||
PiPConfig,
|
||||
PiPEngine,
|
||||
PiPLayerConfig,
|
||||
)
|
||||
|
||||
# ── PiPLayerConfig.validate 测试 ──────────────────────────────────────────────
|
||||
from video_processing.pip_engine import PiPConfig, PiPLayerConfig
|
||||
|
||||
|
||||
class TestPiPLayerConfigValidate:
|
||||
"""PiP图层配置校验测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""正常配置应该通过校验."""
|
||||
layer = PiPLayerConfig(source="asset_001")
|
||||
ok, err = layer.validate()
|
||||
assert ok
|
||||
assert err == ""
|
||||
|
||||
def test_empty_source(self):
|
||||
"""空source应该失败."""
|
||||
layer = PiPLayerConfig(source="")
|
||||
ok, err = layer.validate()
|
||||
assert not ok
|
||||
assert "source" in err
|
||||
|
||||
def test_invalid_position(self):
|
||||
"""无效位置应该失败."""
|
||||
layer = PiPLayerConfig(source="asset_001", position="invalid_pos")
|
||||
ok, err = layer.validate()
|
||||
assert not ok
|
||||
assert "position" in err
|
||||
|
||||
def test_custom_position_valid(self):
|
||||
"""custom位置应该通过."""
|
||||
layer = PiPLayerConfig(source="asset_001", position="custom", x=100, y=50)
|
||||
ok, err = layer.validate()
|
||||
assert ok
|
||||
|
||||
def test_opacity_out_of_range_high(self):
|
||||
"""opacity超过1应该失败."""
|
||||
layer = PiPLayerConfig(source="asset_001", opacity=1.5)
|
||||
ok, err = layer.validate()
|
||||
assert not ok
|
||||
assert "opacity" in err
|
||||
|
||||
def test_opacity_out_of_range_low(self):
|
||||
"""opacity小于0应该失败."""
|
||||
layer = PiPLayerConfig(source="asset_001", opacity=-0.5)
|
||||
ok, err = layer.validate()
|
||||
assert not ok
|
||||
assert "opacity" in err
|
||||
|
||||
def test_opacity_boundary_values(self):
|
||||
"""opacity边界值应该通过."""
|
||||
for val in [0.0, 0.5, 1.0]:
|
||||
layer = PiPLayerConfig(source="asset_001", opacity=val)
|
||||
ok, _ = layer.validate()
|
||||
assert ok
|
||||
|
||||
def test_negative_corner_radius(self):
|
||||
"""负圆角应该失败."""
|
||||
layer = PiPLayerConfig(source="asset_001", corner_radius=-5)
|
||||
ok, err = layer.validate()
|
||||
assert not ok
|
||||
assert "corner_radius" in err
|
||||
|
||||
def test_negative_start_time(self):
|
||||
"""负开始时间应该失败."""
|
||||
layer = PiPLayerConfig(source="asset_001", start_time=-1.0)
|
||||
ok, err = layer.validate()
|
||||
assert not ok
|
||||
assert "start_time" in err
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负持续时间应该失败."""
|
||||
layer = PiPLayerConfig(source="asset_001", duration=-5.0)
|
||||
ok, err = layer.validate()
|
||||
assert not ok
|
||||
assert "duration" in err
|
||||
|
||||
def test_invalid_animation_in(self):
|
||||
"""无效入场动画应该失败."""
|
||||
layer = PiPLayerConfig(source="asset_001", animation_in="spin")
|
||||
ok, err = layer.validate()
|
||||
assert not ok
|
||||
assert "入场动画" in err
|
||||
|
||||
def test_all_valid_animations(self):
|
||||
"""所有有效动画类型应该通过."""
|
||||
for anim in [
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
]:
|
||||
layer = PiPLayerConfig(source="asset_001", animation_in=anim, animation_out=anim)
|
||||
ok, _ = layer.validate()
|
||||
assert ok
|
||||
|
||||
def test_zero_duration_valid(self):
|
||||
"""duration=0(全程显示)应该通过."""
|
||||
layer = PiPLayerConfig(source="asset_001", duration=0.0)
|
||||
ok, _ = layer.validate()
|
||||
assert ok
|
||||
|
||||
|
||||
# ── PiPConfig.from_dict 测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPiPConfigFromDict:
|
||||
"""PiP配置字典解析测试."""
|
||||
|
||||
def test_none_config(self):
|
||||
"""None配置应该返回disabled."""
|
||||
config = PiPConfig.from_dict(None)
|
||||
assert not config.enabled
|
||||
assert len(config.layers) == 0
|
||||
|
||||
def test_empty_config(self):
|
||||
"""空字典应该返回disabled."""
|
||||
config = PiPConfig.from_dict({})
|
||||
assert not config.enabled
|
||||
|
||||
def test_enabled_false(self):
|
||||
"""enabled=False应该返回disabled."""
|
||||
config = PiPConfig.from_dict({"enabled": False, "layers": [{"source": "a"}]})
|
||||
assert not config.enabled
|
||||
|
||||
def test_single_layer(self):
|
||||
"""单图层解析."""
|
||||
data = {
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{
|
||||
"source": "asset_001",
|
||||
"position": POSITION_TOP_RIGHT,
|
||||
"width": "30%",
|
||||
"opacity": 0.9,
|
||||
"corner_radius": 10,
|
||||
"start_time": 2.0,
|
||||
"duration": 5.0,
|
||||
"z_index": 2,
|
||||
}
|
||||
],
|
||||
}
|
||||
config = PiPConfig.from_dict(data)
|
||||
assert config.enabled
|
||||
assert len(config.layers) == 1
|
||||
layer = config.layers[0]
|
||||
assert layer.source == "asset_001"
|
||||
assert layer.position == POSITION_TOP_RIGHT
|
||||
assert layer.width == "30%"
|
||||
assert layer.opacity == 0.9
|
||||
assert layer.corner_radius == 10
|
||||
assert layer.start_time == 2.0
|
||||
assert layer.duration == 5.0
|
||||
assert layer.z_index == 2
|
||||
|
||||
def test_multiple_layers_sorted_by_z_index(self):
|
||||
"""多图层应该按z_index排序."""
|
||||
data = {
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "asset_high", "z_index": 5},
|
||||
{"source": "asset_low", "z_index": 1},
|
||||
{"source": "asset_mid", "z_index": 3},
|
||||
],
|
||||
}
|
||||
config = PiPConfig.from_dict(data)
|
||||
assert len(config.layers) == 3
|
||||
assert config.layers[0].source == "asset_low"
|
||||
assert config.layers[1].source == "asset_mid"
|
||||
assert config.layers[2].source == "asset_high"
|
||||
|
||||
def test_invalid_layer_skipped(self):
|
||||
"""无效图层应该被跳过."""
|
||||
data = {
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "asset_good"},
|
||||
{"source": "", "position": "invalid"}, # 空source
|
||||
{"source": "asset_good2", "opacity": 2.0}, # opacity超范围
|
||||
],
|
||||
}
|
||||
config = PiPConfig.from_dict(data)
|
||||
# 第1个有效,第2、3个无效
|
||||
assert len(config.layers) == 1
|
||||
assert config.layers[0].source == "asset_good"
|
||||
|
||||
def test_all_invalid_layers_disabled(self):
|
||||
"""所有图层都无效时enabled为False."""
|
||||
data = {
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": ""},
|
||||
{"source": ""},
|
||||
],
|
||||
}
|
||||
config = PiPConfig.from_dict(data)
|
||||
assert not config.enabled
|
||||
assert len(config.layers) == 0
|
||||
class TestPiPLayerConfigDefaults:
|
||||
"""PiPLayerConfig 默认配置测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值应该正确."""
|
||||
data = {
|
||||
"enabled": True,
|
||||
"layers": [{"source": "asset_001"}],
|
||||
}
|
||||
config = PiPConfig.from_dict(data)
|
||||
layer = config.layers[0]
|
||||
assert layer.position == POSITION_BOTTOM_RIGHT
|
||||
"""默认值正确."""
|
||||
layer = PiPLayerConfig()
|
||||
assert layer.source == ""
|
||||
assert layer.source_type == "asset_id"
|
||||
assert layer.position == "bottom_right"
|
||||
assert layer.margin == 20
|
||||
assert layer.width == "25%"
|
||||
assert layer.height == ""
|
||||
assert layer.opacity == 1.0
|
||||
assert layer.corner_radius == 0
|
||||
assert layer.border_width == 0
|
||||
assert layer.border_color == "white"
|
||||
assert layer.start_time == 0.0
|
||||
assert layer.duration == 0.0
|
||||
assert layer.animation_in == ""
|
||||
assert layer.animation_out == ""
|
||||
assert layer.animation_duration == 0.5
|
||||
assert layer.z_index == 1
|
||||
|
||||
|
||||
# ── PiPEngine 位置计算测试 ────────────────────────────────────────────────────
|
||||
class TestPiPLayerConfigValidate:
|
||||
"""PiPLayerConfig.validate 校验测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
layer = PiPLayerConfig(source="asset_123")
|
||||
ok, msg = layer.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
class TestPiPEnginePosition:
|
||||
"""PiP引擎位置计算测试."""
|
||||
def test_empty_source_invalid(self):
|
||||
"""空source非法."""
|
||||
layer = PiPLayerConfig(source="")
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "source" in msg
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return PiPEngine(output_width=1920, output_height=1080, output_fps=30)
|
||||
def test_invalid_position(self):
|
||||
"""无效position."""
|
||||
layer = PiPLayerConfig(source="asset_123", position="invalid_pos")
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "position" in msg
|
||||
|
||||
def test_top_left_position(self, engine):
|
||||
"""左上角位置."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_TOP_LEFT, margin=20)
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == 20
|
||||
assert y == 20
|
||||
|
||||
def test_top_right_position(self, engine):
|
||||
"""右上角位置."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_TOP_RIGHT, margin=20)
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == 1920 - 480 - 20
|
||||
assert y == 20
|
||||
|
||||
def test_bottom_right_position(self, engine):
|
||||
"""右下角位置(默认)."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_BOTTOM_RIGHT, margin=30)
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == 1920 - 480 - 30
|
||||
assert y == 1080 - 270 - 30
|
||||
|
||||
def test_bottom_left_position(self, engine):
|
||||
"""左下角位置."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_BOTTOM_LEFT, margin=15)
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == 15
|
||||
assert y == 1080 - 270 - 15
|
||||
|
||||
def test_center_position(self, engine):
|
||||
"""中心位置."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, margin=0)
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == (1920 - 480) // 2
|
||||
assert y == (1080 - 270) // 2
|
||||
|
||||
def test_custom_position_pixel(self, engine):
|
||||
"""自定义像素位置."""
|
||||
layer = PiPLayerConfig(source="a", position="custom", x=100, y=200)
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == 100
|
||||
assert y == 200
|
||||
|
||||
def test_custom_position_percentage(self, engine):
|
||||
"""自定义百分比位置."""
|
||||
layer = PiPLayerConfig(source="a", position="custom", x="50%", y="25%")
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == 1920 // 2
|
||||
assert y == 1080 // 4
|
||||
|
||||
def test_top_center_position(self, engine):
|
||||
"""顶部居中位置."""
|
||||
layer = PiPLayerConfig(source="a", position="top_center", margin=10)
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == (1920 - 480) // 2
|
||||
assert y == 10
|
||||
|
||||
def test_invalid_position_fallback(self, engine):
|
||||
"""无效位置应该fallback到右下角."""
|
||||
layer = PiPLayerConfig(source="a", position="unknown_position", margin=20)
|
||||
# 直接测试_parse_position(注意:validate会拦截,但_parse_position自己也有fallback)
|
||||
x, y = engine._parse_position(layer, 480, 270)
|
||||
assert x == 1920 - 480 - 20
|
||||
assert y == 1080 - 270 - 20
|
||||
|
||||
|
||||
# ── PiPEngine 尺寸解析测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPiPEngineSize:
|
||||
"""PiP引擎尺寸解析测试."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return PiPEngine(output_width=1920, output_height=1080, output_fps=30)
|
||||
|
||||
def test_pixel_size_int(self, engine):
|
||||
"""像素尺寸(整数)."""
|
||||
assert engine._parse_size(500, 1920) == 500
|
||||
|
||||
def test_pixel_size_str(self, engine):
|
||||
"""像素尺寸(字符串数字)."""
|
||||
assert engine._parse_size("500", 1920) == 500
|
||||
|
||||
def test_percentage_size(self, engine):
|
||||
"""百分比尺寸."""
|
||||
assert engine._parse_size("50%", 1920) == 960
|
||||
assert engine._parse_size("25%", 1920) == 480
|
||||
|
||||
def test_zero_size_default(self, engine):
|
||||
"""0或无效值应该有最小值保护."""
|
||||
assert engine._parse_size(0, 1920) == 1
|
||||
assert engine._parse_size("", 1920) == 480 # 默认25%
|
||||
|
||||
def test_negative_size_default(self, engine):
|
||||
"""负值应该取绝对值后至少为1."""
|
||||
# _parse_size 用 max(1, value),负值会走 except 分支
|
||||
result = engine._parse_size("-100", 1920)
|
||||
# 会走ValueError分支,返回默认值
|
||||
assert result > 0
|
||||
|
||||
|
||||
# ── PiPEngine 滤镜构建测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPiPEngineBuildFilters:
|
||||
"""PiP引擎滤镜构建测试."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return PiPEngine(output_width=1920, output_height=1080, output_fps=30)
|
||||
|
||||
@pytest.fixture
|
||||
def fake_video(self, tmp_path):
|
||||
"""创建一个假的视频文件路径."""
|
||||
path = tmp_path / "test_video.mp4"
|
||||
path.write_bytes(b"fake video data")
|
||||
return path
|
||||
|
||||
def test_empty_sources(self, engine):
|
||||
"""空素材列表应该返回空."""
|
||||
filters, inputs, label = engine.build_pip_filters("base_label", [])
|
||||
assert filters == []
|
||||
assert inputs == []
|
||||
assert label == "base_label"
|
||||
|
||||
def test_single_layer_basic(self, engine, fake_video):
|
||||
"""单图层基础滤镜构建."""
|
||||
def test_custom_position_valid(self):
|
||||
"""custom位置合法."""
|
||||
layer = PiPLayerConfig(
|
||||
source="asset_001",
|
||||
position=POSITION_TOP_RIGHT,
|
||||
width="25%",
|
||||
source="asset_123",
|
||||
position="custom",
|
||||
x=100,
|
||||
y=100,
|
||||
)
|
||||
sources = [("pip_src_0", layer, fake_video)]
|
||||
ok, _ = layer.validate()
|
||||
assert ok is True
|
||||
|
||||
filters, inputs, final_label = engine.build_pip_filters("base_video", sources, base_input_idx=3)
|
||||
def test_opacity_too_high(self):
|
||||
"""透明度超过1."""
|
||||
layer = PiPLayerConfig(source="a", opacity=1.5)
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "opacity" in msg
|
||||
|
||||
# 应该有2个滤镜: 预处理 + overlay
|
||||
assert len(filters) == 2
|
||||
# 输入参数应该有2个(-i + path)
|
||||
assert len(inputs) == 2
|
||||
assert inputs[0] == "-i"
|
||||
assert inputs[1] == str(fake_video)
|
||||
def test_opacity_negative(self):
|
||||
"""透明度为负."""
|
||||
layer = PiPLayerConfig(source="a", opacity=-0.1)
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "opacity" in msg
|
||||
|
||||
# 预处理滤镜应该使用正确的输入索引
|
||||
assert "3:v" in filters[0]
|
||||
# 应该包含scale
|
||||
assert "scale=" in filters[0]
|
||||
# 应该有pip_pre_0标签
|
||||
assert "[pip_pre_0]" in filters[0]
|
||||
def test_opacity_boundary_zero(self):
|
||||
"""透明度边界值0."""
|
||||
layer = PiPLayerConfig(source="a", opacity=0.0)
|
||||
ok, _ = layer.validate()
|
||||
assert ok is True
|
||||
|
||||
# overlay滤镜
|
||||
assert "overlay=" in filters[1]
|
||||
assert "[base_video][pip_pre_0]" in filters[1]
|
||||
def test_opacity_boundary_one(self):
|
||||
"""透明度边界值1."""
|
||||
layer = PiPLayerConfig(source="a", opacity=1.0)
|
||||
ok, _ = layer.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_single_layer_final_label(self, engine, fake_video):
|
||||
"""最终输出标签应该正确."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
def test_negative_corner_radius(self):
|
||||
"""负圆角."""
|
||||
layer = PiPLayerConfig(source="a", corner_radius=-5)
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "corner_radius" in msg
|
||||
|
||||
_, _, final_label = engine.build_pip_filters("main_v", sources)
|
||||
assert final_label == "pip_combined_0"
|
||||
def test_negative_start_time(self):
|
||||
"""负开始时间."""
|
||||
layer = PiPLayerConfig(source="a", start_time=-1.0)
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "start_time" in msg
|
||||
|
||||
def test_multiple_layers(self, engine, fake_video):
|
||||
"""多图层叠加."""
|
||||
layer1 = PiPLayerConfig(source="a", position=POSITION_TOP_LEFT, z_index=1)
|
||||
layer2 = PiPLayerConfig(source="b", position=POSITION_BOTTOM_RIGHT, z_index=2)
|
||||
sources = [
|
||||
("s0", layer1, fake_video),
|
||||
("s1", layer2, fake_video),
|
||||
]
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
layer = PiPLayerConfig(source="a", duration=-2.0)
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "duration" in msg
|
||||
|
||||
filters, inputs, final_label = engine.build_pip_filters("base", sources, base_input_idx=0)
|
||||
def test_zero_duration_valid(self):
|
||||
"""零时长(全程显示)合法."""
|
||||
layer = PiPLayerConfig(source="a", duration=0.0)
|
||||
ok, _ = layer.validate()
|
||||
assert ok is True
|
||||
|
||||
# 2层 × 2个滤镜(预处理+overlay)= 4个滤镜
|
||||
assert len(filters) == 4
|
||||
# 2个输入文件
|
||||
assert len(inputs) == 4 # 2 × (-i + path)
|
||||
def test_invalid_animation_in(self):
|
||||
"""无效入场动画."""
|
||||
layer = PiPLayerConfig(source="a", animation_in="invalid_anim")
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "入场动画" in msg
|
||||
|
||||
# 输入索引应该连续
|
||||
assert "0:v" in filters[0]
|
||||
assert "1:v" in filters[2]
|
||||
def test_invalid_animation_out(self):
|
||||
"""无效出场动画."""
|
||||
layer = PiPLayerConfig(source="a", animation_out="invalid_anim")
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "出场动画" in msg
|
||||
|
||||
# 最终标签应该是第二个overlay的输出
|
||||
assert final_label == "pip_combined_1"
|
||||
|
||||
def test_with_opacity(self, engine, fake_video):
|
||||
"""透明度应该在滤镜中体现."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, opacity=0.5)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
pre_filter = filters[0]
|
||||
assert "colorchannelmixer=aa=0.5" in pre_filter
|
||||
assert "yuva420p" in pre_filter
|
||||
|
||||
def test_with_corner_radius(self, engine, fake_video):
|
||||
"""圆角裁剪应该在滤镜中体现."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, corner_radius=20)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
pre_filter = filters[0]
|
||||
assert "geq=" in pre_filter
|
||||
|
||||
def test_with_border(self, engine, fake_video):
|
||||
"""边框应该在滤镜中体现."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, border_width=3, border_color="red")
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
pre_filter = filters[0]
|
||||
assert "pad=" in pre_filter
|
||||
assert "red" in pre_filter
|
||||
|
||||
def test_timing_start_time_and_duration(self, engine, fake_video):
|
||||
"""时间控制应该生成enable表达式."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=5.0, duration=10.0)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
overlay_filter = filters[1]
|
||||
assert "enable=" in overlay_filter
|
||||
assert "between(t,5.0,15.0)" in overlay_filter
|
||||
|
||||
def test_timing_start_time_only(self, engine, fake_video):
|
||||
"""只有开始时间(全程显示到结束)."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=3.0, duration=0.0)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
overlay_filter = filters[1]
|
||||
assert "enable=" in overlay_filter
|
||||
assert "gte(t,3.0)" in overlay_filter
|
||||
|
||||
def test_no_timing_no_enable(self, engine, fake_video):
|
||||
"""无时间限制时不应该有enable表达式."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=0.0, duration=0.0)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
overlay_filter = filters[1]
|
||||
assert "enable=" not in overlay_filter
|
||||
|
||||
def test_fade_animation(self, engine, fake_video):
|
||||
"""淡入淡出动画."""
|
||||
layer = PiPLayerConfig(
|
||||
source="a",
|
||||
position=POSITION_CENTER,
|
||||
animation_in=ANIMATION_FADE,
|
||||
animation_out=ANIMATION_FADE,
|
||||
duration=10.0,
|
||||
animation_duration=0.8,
|
||||
)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
pre_filter = filters[0]
|
||||
assert "fade=t=in" in pre_filter
|
||||
assert "fade=t=out" in pre_filter
|
||||
assert "alpha=1" in pre_filter
|
||||
|
||||
def test_slide_animation_in(self, engine, fake_video):
|
||||
"""滑入动画应该在overlay表达式中."""
|
||||
layer = PiPLayerConfig(
|
||||
source="a",
|
||||
position=POSITION_CENTER,
|
||||
animation_in=ANIMATION_SLIDE_LEFT,
|
||||
animation_duration=0.5,
|
||||
)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
overlay_filter = filters[1]
|
||||
# x表达式应该包含动态变化
|
||||
assert "overlay=" in overlay_filter
|
||||
|
||||
def test_full_opacity_no_alpha(self, engine, fake_video):
|
||||
"""opacity=1时不应该有colorchannelmixer."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, opacity=1.0)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
pre_filter = filters[0]
|
||||
assert "colorchannelmixer" not in pre_filter
|
||||
|
||||
def test_zero_corner_radius_no_geq(self, engine, fake_video):
|
||||
"""corner_radius=0时不应该有geq滤镜."""
|
||||
layer = PiPLayerConfig(source="a", position=POSITION_CENTER, corner_radius=0)
|
||||
sources = [("s0", layer, fake_video)]
|
||||
|
||||
filters, _, _ = engine.build_pip_filters("base", sources)
|
||||
pre_filter = filters[0]
|
||||
assert "geq=" not in pre_filter
|
||||
def test_negative_animation_duration(self):
|
||||
"""负动画时长."""
|
||||
layer = PiPLayerConfig(source="a", animation_duration=-0.5)
|
||||
ok, msg = layer.validate()
|
||||
assert ok is False
|
||||
assert "animation_duration" in msg
|
||||
|
||||
|
||||
# ── PiPEngine 素材验证(降级策略)测试 ────────────────────────────────────────
|
||||
class TestPiPConfigDefaults:
|
||||
"""PiPConfig 默认配置测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
config = PiPConfig()
|
||||
assert config.enabled is False
|
||||
assert config.layers == []
|
||||
|
||||
|
||||
class TestPiPEngineValidateSource:
|
||||
"""PiP引擎素材验证与降级测试."""
|
||||
class TestPiPConfigFromDict:
|
||||
"""PiPConfig.from_dict 解析测试."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return PiPEngine(output_width=1920, output_height=1080, output_fps=30)
|
||||
def test_none_returns_disabled(self):
|
||||
"""None 返回禁用配置."""
|
||||
config = PiPConfig.from_dict(None)
|
||||
assert config.enabled is False
|
||||
assert config.layers == []
|
||||
|
||||
def test_asset_id_in_map(self, engine, tmp_path):
|
||||
"""asset_id在map中应该返回路径."""
|
||||
asset_path = tmp_path / "test.mp4"
|
||||
asset_path.write_bytes(b"data")
|
||||
asset_map = {"asset_001": asset_path}
|
||||
def test_empty_dict_returns_disabled(self):
|
||||
"""空 dict 返回禁用."""
|
||||
config = PiPConfig.from_dict({})
|
||||
assert config.enabled is False
|
||||
|
||||
layer = PiPLayerConfig(source="asset_001", source_type="asset_id")
|
||||
result = engine.validate_layer_source(layer, asset_map)
|
||||
assert result == asset_path
|
||||
def test_disabled_returns_disabled(self):
|
||||
"""enabled=False 返回禁用."""
|
||||
config = PiPConfig.from_dict({"enabled": False})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_asset_id_not_in_map(self, engine):
|
||||
"""asset_id不在map中应该返回None(降级)."""
|
||||
layer = PiPLayerConfig(source="nonexistent", source_type="asset_id")
|
||||
result = engine.validate_layer_source(layer, {})
|
||||
assert result is None
|
||||
def test_enabled_no_layers(self):
|
||||
"""启用但无图层,disabled."""
|
||||
config = PiPConfig.from_dict({"enabled": True, "layers": []})
|
||||
assert config.enabled is False
|
||||
assert config.layers == []
|
||||
|
||||
def test_local_path_exists(self, engine, tmp_path):
|
||||
"""本地路径存在应该返回."""
|
||||
path = tmp_path / "video.mp4"
|
||||
path.write_bytes(b"data")
|
||||
def test_single_layer(self):
|
||||
"""单个图层."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "asset_001", "position": "top_left"},
|
||||
],
|
||||
})
|
||||
assert config.enabled is True
|
||||
assert len(config.layers) == 1
|
||||
assert config.layers[0].source == "asset_001"
|
||||
assert config.layers[0].position == "top_left"
|
||||
|
||||
layer = PiPLayerConfig(source=str(path), source_type="local_path")
|
||||
result = engine.validate_layer_source(layer, {})
|
||||
assert result == path
|
||||
def test_multiple_layers_sorted_by_z_index(self):
|
||||
"""多个图层按z_index排序."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "a", "z_index": 3},
|
||||
{"source": "b", "z_index": 1},
|
||||
{"source": "c", "z_index": 2},
|
||||
],
|
||||
})
|
||||
assert len(config.layers) == 3
|
||||
assert config.layers[0].z_index == 1
|
||||
assert config.layers[1].z_index == 2
|
||||
assert config.layers[2].z_index == 3
|
||||
|
||||
def test_local_path_not_exists(self, engine):
|
||||
"""本地路径不存在应该返回None(降级)."""
|
||||
layer = PiPLayerConfig(source="/nonexistent/path.mp4", source_type="local_path")
|
||||
result = engine.validate_layer_source(layer, {})
|
||||
assert result is None
|
||||
def test_invalid_layer_skipped(self):
|
||||
"""无效图层跳过."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "valid_asset"},
|
||||
{"source": ""}, # 无效,空source
|
||||
],
|
||||
})
|
||||
assert len(config.layers) == 1
|
||||
assert config.layers[0].source == "valid_asset"
|
||||
|
||||
def test_url_type_not_supported(self, engine):
|
||||
"""URL类型暂时不支持,返回None."""
|
||||
layer = PiPLayerConfig(source="http://example.com/video.mp4", source_type="url")
|
||||
result = engine.validate_layer_source(layer, {})
|
||||
assert result is None
|
||||
def test_all_invalid_layers_disabled(self):
|
||||
"""全部无效则disabled."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": ""},
|
||||
{"source": ""},
|
||||
],
|
||||
})
|
||||
assert config.enabled is False
|
||||
assert config.layers == []
|
||||
|
||||
def test_exception_handling(self, engine):
|
||||
"""异常情况应该返回None(不阻断)."""
|
||||
layer = PiPLayerConfig(source=None, source_type="local_path") # type: ignore
|
||||
# 模拟异常情况
|
||||
result = engine.validate_layer_source(layer, {})
|
||||
assert result is None
|
||||
def test_layer_full_config(self):
|
||||
"""完整图层配置."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{
|
||||
"source": "https://example.com/video.mp4",
|
||||
"source_type": "url",
|
||||
"position": "bottom_right",
|
||||
"width": "30%",
|
||||
"opacity": 0.8,
|
||||
"corner_radius": 10,
|
||||
"border_width": 2,
|
||||
"border_color": "red",
|
||||
"start_time": 5.0,
|
||||
"duration": 10.0,
|
||||
"z_index": 5,
|
||||
},
|
||||
],
|
||||
})
|
||||
assert len(config.layers) == 1
|
||||
layer = config.layers[0]
|
||||
assert layer.source == "https://example.com/video.mp4"
|
||||
assert layer.source_type == "url"
|
||||
assert layer.width == "30%"
|
||||
assert layer.opacity == 0.8
|
||||
assert layer.corner_radius == 10
|
||||
assert layer.border_width == 2
|
||||
assert layer.border_color == "red"
|
||||
assert layer.start_time == 5.0
|
||||
assert layer.duration == 10.0
|
||||
assert layer.z_index == 5
|
||||
|
||||
@@ -1,484 +1,176 @@
|
||||
"""转场特效引擎单测 — 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 TestTransitionConstants:
|
||||
"""常量测试."""
|
||||
|
||||
def test_duration_ranges(self):
|
||||
"""时长范围合理."""
|
||||
assert MIN_TRANSITION_DURATION == 0.3
|
||||
assert MAX_TRANSITION_DURATION == 2.0
|
||||
assert DEFAULT_TRANSITION_DURATION == 0.5
|
||||
assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION
|
||||
|
||||
def test_cut_transition_value(self):
|
||||
"""cut转场值."""
|
||||
assert CUT_TRANSITION == "cut"
|
||||
|
||||
|
||||
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_supports_fade(self):
|
||||
"""支持fade."""
|
||||
assert TransitionType.is_supported("fade") is True
|
||||
|
||||
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_supports_cut(self):
|
||||
"""cut也在TransitionType枚举中."""
|
||||
assert "cut" in [t.value for t in TransitionType]
|
||||
|
||||
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_unsupported_effect(self):
|
||||
"""不支持的效果."""
|
||||
assert TransitionType.is_supported("nonexistent_effect_xyz") is False
|
||||
|
||||
def test_is_supported_case_insensitive(self):
|
||||
"""大小写不敏感."""
|
||||
assert TransitionType.is_supported("FADE")
|
||||
assert TransitionType.is_supported("Fade")
|
||||
assert TransitionType.is_supported("fade")
|
||||
"""是否大小写不敏感(看实现)."""
|
||||
# 直接测试几个已知的
|
||||
assert TransitionType.is_supported("fade") is True
|
||||
assert TransitionType.is_supported("dissolve") is True
|
||||
|
||||
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
|
||||
def test_all_types_have_value(self):
|
||||
"""所有枚举都有有效值."""
|
||||
for t in TransitionType:
|
||||
assert isinstance(t.value, str)
|
||||
assert len(t.value) > 0
|
||||
|
||||
|
||||
# ── 名称标准化测试 ────────────────────────────────────────────────────────────
|
||||
class TestTransitionConfigParse:
|
||||
"""TransitionConfig.parse 解析测试."""
|
||||
|
||||
def test_no_args_default(self):
|
||||
"""无参数默认配置."""
|
||||
config = TransitionConfig.parse()
|
||||
assert config.effect == CUT_TRANSITION
|
||||
assert config.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
class TestNormalizeTransitionName:
|
||||
"""名称标准化函数测试."""
|
||||
def test_none_effect_default(self):
|
||||
"""None effect默认为cut."""
|
||||
config = TransitionConfig.parse(effect=None)
|
||||
assert config.effect == CUT_TRANSITION
|
||||
|
||||
def test_lowercase(self):
|
||||
"""大写转小写."""
|
||||
assert _normalize_transition_name("FADE") == "fade"
|
||||
assert _normalize_transition_name("Fade") == "fade"
|
||||
def test_empty_effect_default(self):
|
||||
"""空字符串effect默认为cut."""
|
||||
config = TransitionConfig.parse(effect="")
|
||||
assert config.effect == CUT_TRANSITION
|
||||
|
||||
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_whitespace_effect_default(self):
|
||||
"""空白effect默认为cut."""
|
||||
config = TransitionConfig.parse(effect=" ")
|
||||
assert config.effect == CUT_TRANSITION
|
||||
|
||||
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"
|
||||
"""fade效果."""
|
||||
config = TransitionConfig.parse(effect="fade")
|
||||
assert config.effect == "fade"
|
||||
|
||||
def test_dissolve_effect(self):
|
||||
"""dissolve 转场."""
|
||||
cfg = TransitionConfig.parse(effect="dissolve")
|
||||
assert cfg.effect == "dissolve"
|
||||
assert cfg.ffmpeg_transition == "dissolve"
|
||||
def test_unsupported_effect_falls_back_to_cut(self):
|
||||
"""不支持的效果降级到cut."""
|
||||
config = TransitionConfig.parse(effect="super_cool_effect")
|
||||
assert config.effect == CUT_TRANSITION
|
||||
|
||||
def test_zoom_effect(self):
|
||||
"""zoom 转场 → FFmpeg zoomin."""
|
||||
cfg = TransitionConfig.parse(effect="zoom")
|
||||
assert cfg.effect == "zoom"
|
||||
assert cfg.ffmpeg_transition == "zoomin"
|
||||
def test_cut_effect(self):
|
||||
"""显式cut效果."""
|
||||
config = TransitionConfig.parse(effect="cut")
|
||||
assert config.effect == CUT_TRANSITION
|
||||
|
||||
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_custom_duration(self):
|
||||
"""自定义时长."""
|
||||
config = TransitionConfig.parse(duration=1.0)
|
||||
assert config.duration == 1.0
|
||||
|
||||
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
|
||||
"""时长低于最小值钳制."""
|
||||
config = TransitionConfig.parse(duration=0.1)
|
||||
assert config.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
|
||||
"""时长高于最大值钳制."""
|
||||
config = TransitionConfig.parse(duration=5.0)
|
||||
assert config.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_at_min(self):
|
||||
"""时长边界最小值."""
|
||||
config = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION)
|
||||
assert config.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_at_max(self):
|
||||
"""时长边界最大值."""
|
||||
config = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION)
|
||||
assert config.duration == MAX_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_invalid_duration_falls_back(self):
|
||||
"""无效时长回退到默认."""
|
||||
config = TransitionConfig.parse(duration="not_a_number")
|
||||
assert config.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
|
||||
def test_none_duration_default(self):
|
||||
"""None时长用默认值."""
|
||||
config = TransitionConfig.parse(duration=None)
|
||||
assert config.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 == ""
|
||||
def test_effect_and_duration(self):
|
||||
"""同时指定效果和时长."""
|
||||
config = TransitionConfig.parse(effect="fade", duration=1.0)
|
||||
assert config.effect == "fade"
|
||||
assert config.duration == 1.0
|
||||
|
||||
|
||||
# ── TransitionEngine 测试 ────────────────────────────────────────────────────
|
||||
class TestIsCut:
|
||||
"""is_cut 属性测试."""
|
||||
|
||||
def test_cut_is_cut(self):
|
||||
"""cut是硬切."""
|
||||
config = TransitionConfig(effect=CUT_TRANSITION, duration=0.5)
|
||||
assert config.is_cut is True
|
||||
|
||||
def test_fade_not_cut(self):
|
||||
"""fade不是硬切."""
|
||||
config = TransitionConfig(effect="fade", duration=0.5)
|
||||
assert config.is_cut is False
|
||||
|
||||
|
||||
class TestTransitionEngine:
|
||||
"""TransitionEngine 转场引擎测试."""
|
||||
class TestFfmpegTransition:
|
||||
"""ffmpeg_transition 属性测试."""
|
||||
|
||||
def test_default_engine(self):
|
||||
"""默认引擎初始化."""
|
||||
engine = TransitionEngine()
|
||||
assert engine is not None
|
||||
def test_cut_returns_empty(self):
|
||||
"""cut返回空字符串."""
|
||||
config = TransitionConfig(effect=CUT_TRANSITION, duration=0.5)
|
||||
assert config.ffmpeg_transition == ""
|
||||
|
||||
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_fade_returns_fade(self):
|
||||
"""fade返回fade."""
|
||||
config = TransitionConfig(effect="fade", duration=0.5)
|
||||
result = config.ffmpeg_transition
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 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"
|
||||
def test_valid_effect_has_ffmpeg_name(self):
|
||||
"""所有非cut的支持效果都有对应的ffmpeg名称."""
|
||||
for t in TransitionType:
|
||||
if t.value == CUT_TRANSITION:
|
||||
continue # cut返回空是正常的
|
||||
config = TransitionConfig(effect=t.value, duration=0.5)
|
||||
assert config.ffmpeg_transition != ""
|
||||
|
||||
Reference in New Issue
Block a user