39187a0660
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy 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 API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
F401 (14): 移除未使用的 import
- multi_track_mixer_pure.py: Any, Optional, math
- pip_engine_pure.py: Any
- speed_engine.py: Optional, MAX_SPEED, MIN_SPEED
- url_security.py: ALLOWED_AUDIO_MIME_TYPES, ALLOWED_IMAGE_MIME_TYPES,
ALLOWED_VIDEO_MIME_TYPES, MAGIC_NUMBERS, MAX_URL_LENGTH,
check_internal_hostname, is_trusted_domain
E741 (3): 重命名模糊变量 l → layer
- pip_engine_pure.py: lambda 参数
- test_pip_engine_pure.py: 两处列表推导
B017 (2): pytest.raises(Exception) → FrozenInstanceError
- test_video_filter_builder.py
- test_transition_presets.py
B905 (1): zip() 补充 strict=True
- pip_engine_pure.py
967 lines
36 KiB
Python
Executable File
967 lines
36 KiB
Python
Executable File
"""PiP Engine 纯逻辑单测.
|
||
|
||
测试 pip_engine_pure.py 中的所有纯函数,
|
||
0 FFmpeg 依赖,快速轻量。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from video_processing.pip_engine_pure import (
|
||
build_animation_filters,
|
||
build_enable_expr,
|
||
build_overlay_expr,
|
||
build_pip_filters,
|
||
build_pip_pre_filter,
|
||
compute_pip_position,
|
||
compute_pip_size,
|
||
count_visible_layers,
|
||
sort_layers_by_z_index,
|
||
validate_pip_layer,
|
||
)
|
||
|
||
from packages.domain.pip_config import (
|
||
ANIMATION_FADE,
|
||
ANIMATION_SLIDE_BOTTOM,
|
||
ANIMATION_SLIDE_LEFT,
|
||
ANIMATION_SLIDE_RIGHT,
|
||
ANIMATION_SLIDE_TOP,
|
||
PiPLayerConfig,
|
||
)
|
||
|
||
# ── 常量与工具 ────────────────────────────────────────────────────────────────
|
||
|
||
OUTPUT_W = 1080
|
||
OUTPUT_H = 1920
|
||
|
||
|
||
def _make_layer(**kwargs) -> PiPLayerConfig:
|
||
"""快速创建图层配置."""
|
||
defaults = dict(
|
||
source_type="local_path",
|
||
source="/tmp/test.mp4",
|
||
width="25%",
|
||
height=None,
|
||
position="bottom_right",
|
||
margin=20,
|
||
opacity=1.0,
|
||
corner_radius=0,
|
||
border_width=0,
|
||
border_color="black",
|
||
z_index=0,
|
||
start_time=0.0,
|
||
duration=None,
|
||
animation_in=None,
|
||
animation_out=None,
|
||
animation_duration=0.5,
|
||
)
|
||
defaults.update(kwargs)
|
||
return PiPLayerConfig(**defaults)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# compute_pip_size
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestComputePipSize:
|
||
"""尺寸计算测试."""
|
||
|
||
def test_percentage_width_auto_height(self):
|
||
"""百分比宽度,自动高度(16:9)."""
|
||
layer = _make_layer(width="25%")
|
||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||
assert w == 270 # 1080 * 25%
|
||
assert h == 151 # 270 * 9 / 16 = 151.875 → 151
|
||
|
||
def test_pixel_width_and_height(self):
|
||
"""像素宽高."""
|
||
layer = _make_layer(width=300, height=200)
|
||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||
assert w == 300
|
||
assert h == 200
|
||
|
||
def test_pixel_width_percent_height(self):
|
||
"""像素宽 + 百分比高."""
|
||
layer = _make_layer(width=200, height="10%")
|
||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||
assert w == 200
|
||
assert h == 192 # 1920 * 10%
|
||
|
||
def test_full_width_clamped(self):
|
||
"""超过输出尺寸时钳制到输出范围内."""
|
||
layer = _make_layer(width="200%")
|
||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||
assert w == OUTPUT_W
|
||
assert h <= OUTPUT_H # 按比例后高度不超过输出
|
||
|
||
def test_zero_width_minimum(self):
|
||
"""极小尺寸钳制到至少 1 像素."""
|
||
layer = _make_layer(width="0%")
|
||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||
assert w >= 1
|
||
assert h >= 1
|
||
|
||
def test_pixel_int_width(self):
|
||
"""整数像素宽度."""
|
||
layer = _make_layer(width=500, height=300)
|
||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||
assert w == 500
|
||
assert h == 300
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# compute_pip_position
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestComputePipPosition:
|
||
"""位置计算测试."""
|
||
|
||
def test_bottom_right(self):
|
||
"""右下角位置."""
|
||
layer = _make_layer(position="bottom_right", margin=20)
|
||
pip_w, pip_h = 200, 150
|
||
x, y = compute_pip_position(layer, pip_w, pip_h, OUTPUT_W, OUTPUT_H)
|
||
assert x == OUTPUT_W - pip_w - 20
|
||
assert y == OUTPUT_H - pip_h - 20
|
||
|
||
def test_top_left(self):
|
||
"""左上角."""
|
||
layer = _make_layer(position="top_left", margin=10)
|
||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||
assert x == 10
|
||
assert y == 10
|
||
|
||
def test_top_center(self):
|
||
"""顶部居中."""
|
||
layer = _make_layer(position="top_center", margin=20)
|
||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||
assert x == (OUTPUT_W - 200) // 2
|
||
assert y == 20
|
||
|
||
def test_center(self):
|
||
"""正中心."""
|
||
layer = _make_layer(position="center")
|
||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||
assert x == (OUTPUT_W - 200) // 2
|
||
assert y == (OUTPUT_H - 150) // 2
|
||
|
||
def test_custom_position(self):
|
||
"""自定义坐标."""
|
||
layer = _make_layer(position="custom", x=100, y=200)
|
||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||
assert x == 100
|
||
assert y == 200
|
||
|
||
def test_margin_effect(self):
|
||
"""不同 margin 值影响位置."""
|
||
layer1 = _make_layer(position="bottom_right", margin=0)
|
||
layer2 = _make_layer(position="bottom_right", margin=50)
|
||
x1, y1 = compute_pip_position(layer1, 200, 150, OUTPUT_W, OUTPUT_H)
|
||
x2, y2 = compute_pip_position(layer2, 200, 150, OUTPUT_W, OUTPUT_H)
|
||
assert x1 > x2
|
||
assert y1 > y2
|
||
|
||
def test_clamped_when_outside(self):
|
||
"""自定义坐标超出画面时钳制到边界内."""
|
||
layer = _make_layer(position="custom", x=-50, y=99999)
|
||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||
assert x >= 0
|
||
assert x <= OUTPUT_W - 200
|
||
assert y >= 0
|
||
assert y == OUTPUT_H - 150 # y 超出底部,钳制到底部
|
||
|
||
def test_bottom_center(self):
|
||
"""底部居中."""
|
||
layer = _make_layer(position="bottom_center", margin=30)
|
||
x, y = compute_pip_position(layer, 300, 200, OUTPUT_W, OUTPUT_H)
|
||
assert x == (OUTPUT_W - 300) // 2
|
||
assert y == OUTPUT_H - 200 - 30
|
||
|
||
def test_center_left(self):
|
||
"""左侧居中."""
|
||
layer = _make_layer(position="center_left", margin=15)
|
||
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == 15
|
||
assert y == (OUTPUT_H - 100) // 2
|
||
|
||
def test_center_right(self):
|
||
"""右侧居中."""
|
||
layer = _make_layer(position="center_right", margin=15)
|
||
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == OUTPUT_W - 150 - 15
|
||
assert y == (OUTPUT_H - 100) // 2
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# build_pip_pre_filter
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestBuildPipPreFilter:
|
||
"""预处理滤镜构建测试."""
|
||
|
||
def test_basic_scale_setsar(self):
|
||
"""基础:scale + setsar."""
|
||
layer = _make_layer()
|
||
result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0")
|
||
assert result.startswith("[1:v]")
|
||
assert "scale=200:150" in result
|
||
assert "setsar=1" in result
|
||
assert result.endswith("[pip_pre_0]")
|
||
|
||
def test_corner_radius_filter(self):
|
||
"""圆角裁剪滤镜."""
|
||
layer = _make_layer(corner_radius=20)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "geq=" in result
|
||
assert "format=yuva420p" in result
|
||
# 圆角半径应钳制到 min(r, w//2, h//2)
|
||
assert "hypot(" in result
|
||
|
||
def test_corner_radius_clamped(self):
|
||
"""圆角半径超过尺寸一半时自动钳制."""
|
||
layer = _make_layer(corner_radius=1000) # 超大
|
||
result = build_pip_pre_filter("[0:v]", layer, 100, 80, "pre")
|
||
# 钳制后 r = min(1000, 50, 40) = 40
|
||
# 检查 geq 表达式中的 r 值
|
||
import re
|
||
|
||
r_matches = re.findall(r"lt\(X,(\d+)\)\*lt\(Y,\1\)", result)
|
||
assert r_matches
|
||
assert int(r_matches[0]) <= 50 # 不超过宽的一半
|
||
|
||
def test_border_filter(self):
|
||
"""边框滤镜."""
|
||
layer = _make_layer(border_width=5, border_color="red")
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "pad=210:160:5:5:red" in result
|
||
|
||
def test_zero_border_no_pad(self):
|
||
"""border_width=0 时不加 pad."""
|
||
layer = _make_layer(border_width=0)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "pad=" not in result
|
||
|
||
def test_opacity_filter(self):
|
||
"""透明度滤镜."""
|
||
layer = _make_layer(opacity=0.5)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "colorchannelmixer=aa=0.5" in result
|
||
assert "format=yuva420p" in result
|
||
|
||
def test_full_opacity_no_alpha(self):
|
||
"""opacity=1.0 时不加透明度滤镜."""
|
||
layer = _make_layer(opacity=1.0)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "colorchannelmixer" not in result
|
||
|
||
def test_opacity_clamped_high(self):
|
||
"""opacity > 1.0 时钳制."""
|
||
layer = _make_layer(opacity=2.0)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
# 钳制到 1.0,不加透明度滤镜
|
||
assert "colorchannelmixer=aa=1" not in result
|
||
assert "colorchannelmixer" not in result
|
||
|
||
def test_opacity_clamped_low(self):
|
||
"""opacity < 0 时钳制到 0."""
|
||
layer = _make_layer(opacity=-0.5)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "colorchannelmixer=aa=0.0" in result
|
||
|
||
def test_fade_in_animation(self):
|
||
"""淡入动画."""
|
||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.3)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "fade=t=in:st=0:d=0.3:alpha=1" in result
|
||
|
||
def test_fade_out_animation(self):
|
||
"""淡出动画(需要 duration)."""
|
||
layer = _make_layer(
|
||
animation_out=ANIMATION_FADE,
|
||
animation_duration=0.5,
|
||
duration=5.0,
|
||
)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "fade=t=out:st=4.5:d=0.5:alpha=1" in result
|
||
|
||
def test_fade_out_no_duration(self):
|
||
"""淡出无 duration 时不加."""
|
||
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5, duration=None)
|
||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||
assert "fade=t=out" not in result
|
||
|
||
def test_combined_effects(self):
|
||
"""多个效果组合:圆角 + 边框 + 透明度."""
|
||
layer = _make_layer(
|
||
corner_radius=15,
|
||
border_width=3,
|
||
border_color="white",
|
||
opacity=0.8,
|
||
)
|
||
result = build_pip_pre_filter("[0:v]", layer, 300, 200, "pre")
|
||
assert "geq=" in result # 圆角
|
||
assert "pad=306:206:3:3:white" in result # 边框
|
||
assert "colorchannelmixer=aa=0.8" in result # 透明度
|
||
|
||
def test_output_label(self):
|
||
"""输出标签正确."""
|
||
layer = _make_layer()
|
||
result = build_pip_pre_filter("[2:v]", layer, 100, 80, "my_label")
|
||
assert result.endswith("[my_label]")
|
||
|
||
def test_input_label(self):
|
||
"""输入标签正确."""
|
||
layer = _make_layer()
|
||
result = build_pip_pre_filter("[5:v]", layer, 100, 80, "out")
|
||
assert result.startswith("[5:v]")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# build_animation_filters
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestBuildAnimationFilters:
|
||
"""动画滤镜构建测试."""
|
||
|
||
def test_no_animation(self):
|
||
"""无动画返回空列表."""
|
||
layer = _make_layer()
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert result == []
|
||
|
||
def test_fade_in_only(self):
|
||
"""仅淡入."""
|
||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5)
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert len(result) == 1
|
||
assert "fade=t=in" in result[0]
|
||
|
||
def test_fade_out_with_duration(self):
|
||
"""淡出(有 duration)."""
|
||
layer = _make_layer(
|
||
animation_out=ANIMATION_FADE,
|
||
animation_duration=0.3,
|
||
duration=10.0,
|
||
)
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert len(result) == 1
|
||
assert "fade=t=out:st=9.7:d=0.3" in result[0]
|
||
|
||
def test_fade_out_no_duration_skipped(self):
|
||
"""淡出无 duration 时跳过."""
|
||
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5)
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert result == []
|
||
|
||
def test_fade_in_and_out(self):
|
||
"""淡入 + 淡出."""
|
||
layer = _make_layer(
|
||
animation_in=ANIMATION_FADE,
|
||
animation_out=ANIMATION_FADE,
|
||
animation_duration=0.5,
|
||
duration=3.0,
|
||
)
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert len(result) == 2
|
||
assert any("fade=t=in" in f for f in result)
|
||
assert any("fade=t=out" in f for f in result)
|
||
|
||
def test_slide_in_not_here(self):
|
||
"""slide 动画不在此函数处理."""
|
||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert result == []
|
||
|
||
def test_zero_duration_no_animation(self):
|
||
"""动画时长为 0 时不加."""
|
||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0)
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert result == []
|
||
|
||
def test_negative_duration_clamped(self):
|
||
"""负动画时长钳制为 0."""
|
||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=-1)
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert result == []
|
||
|
||
def test_fade_out_start_clamped_to_zero(self):
|
||
"""淡出开始时间不为负."""
|
||
layer = _make_layer(
|
||
animation_out=ANIMATION_FADE,
|
||
animation_duration=2.0,
|
||
duration=1.0, # 比动画时长短
|
||
)
|
||
result = build_animation_filters(layer, 200, 150)
|
||
assert len(result) == 1
|
||
# start = max(0, 1.0 - 2.0) = 0
|
||
assert "st=0.0:d=2.0" in result[0]
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# build_overlay_expr
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestBuildOverlayExpr:
|
||
"""overlay 表达式构建测试."""
|
||
|
||
def test_no_animation_static_position(self):
|
||
"""无动画时返回静态坐标."""
|
||
layer = _make_layer()
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == "100"
|
||
assert y == "200"
|
||
|
||
def test_slide_in_from_left(self):
|
||
"""从左侧滑入."""
|
||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert "if(lt(t,0.5)" in x
|
||
assert "-150" in x # 起始位置 = -pip_width
|
||
assert y == "200" # y 不变
|
||
|
||
def test_slide_in_from_right(self):
|
||
"""从右侧滑入."""
|
||
layer = _make_layer(animation_in=ANIMATION_SLIDE_RIGHT, animation_duration=0.5)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert str(OUTPUT_W) in x
|
||
assert y == "200"
|
||
|
||
def test_slide_in_from_top(self):
|
||
"""从顶部滑入."""
|
||
layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.3)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == "100"
|
||
assert "if(lt(t,0.3)" in y
|
||
assert "-100" in y
|
||
|
||
def test_slide_in_from_bottom(self):
|
||
"""从底部滑入."""
|
||
layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.3)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == "100"
|
||
assert str(OUTPUT_H) in y
|
||
|
||
def test_slide_out_to_left(self):
|
||
"""向左滑出."""
|
||
layer = _make_layer(
|
||
animation_out=ANIMATION_SLIDE_LEFT,
|
||
animation_duration=0.5,
|
||
duration=3.0,
|
||
)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert "gt(t,2.5)" in x
|
||
assert y == "200"
|
||
|
||
def test_slide_out_to_right(self):
|
||
"""向右滑出."""
|
||
layer = _make_layer(
|
||
animation_out=ANIMATION_SLIDE_RIGHT,
|
||
animation_duration=0.5,
|
||
duration=3.0,
|
||
)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert "gt(t,2.5)" in x
|
||
assert y == "200"
|
||
# 向右滑出:结束时 x > base_x(值变大)
|
||
# 检查表达式中含增大方向的计算
|
||
assert "+(t-2.5)/0.5*" in x
|
||
|
||
def test_slide_out_to_top(self):
|
||
"""向上滑出."""
|
||
layer = _make_layer(
|
||
animation_out=ANIMATION_SLIDE_TOP,
|
||
animation_duration=0.5,
|
||
duration=5.0,
|
||
)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == "100"
|
||
assert "gt(t,4.5)" in y
|
||
|
||
def test_slide_out_to_bottom(self):
|
||
"""向下滑出."""
|
||
layer = _make_layer(
|
||
animation_out=ANIMATION_SLIDE_BOTTOM,
|
||
animation_duration=0.5,
|
||
duration=5.0,
|
||
)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == "100"
|
||
assert "gt(t,4.5)" in y
|
||
# 向下滑出:y 值增大
|
||
assert "+(t-4.5)/0.5*" in y
|
||
|
||
def test_slide_in_and_out_different_axes(self):
|
||
"""滑入(x方向) + 滑出(y方向),两个轴都有动画."""
|
||
layer = _make_layer(
|
||
animation_in=ANIMATION_SLIDE_LEFT,
|
||
animation_out=ANIMATION_SLIDE_BOTTOM,
|
||
animation_duration=0.5,
|
||
duration=4.0,
|
||
)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert "lt(t,0.5)" in x # x 方向入场
|
||
assert "gt(t,3.5)" in y # y 方向出场
|
||
|
||
def test_zero_animation_duration_no_effect(self):
|
||
"""动画时长为 0 时无效果."""
|
||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == "100"
|
||
assert y == "200"
|
||
|
||
def test_no_duration_skip_outro(self):
|
||
"""无 duration 时跳过滑出."""
|
||
layer = _make_layer(
|
||
animation_out=ANIMATION_SLIDE_LEFT,
|
||
animation_duration=0.5,
|
||
duration=None,
|
||
)
|
||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x == "100"
|
||
assert y == "200"
|
||
|
||
def test_expression_format_quoted(self):
|
||
"""有动画时表达式带单引号."""
|
||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||
x, _ = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||
assert x.startswith("'")
|
||
assert x.endswith("'")
|
||
|
||
def test_static_position_unquoted(self):
|
||
"""无动画时纯数字,不带引号."""
|
||
layer = _make_layer()
|
||
x, y = build_overlay_expr(layer, 50, 60, 100, 80, OUTPUT_W, OUTPUT_H)
|
||
assert x == "50"
|
||
assert y == "60"
|
||
assert "'" not in x
|
||
assert "'" not in y
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# build_enable_expr
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestBuildEnableExpr:
|
||
"""enable 表达式构建测试."""
|
||
|
||
def test_no_time_restriction(self):
|
||
"""无时间限制返回空."""
|
||
layer = _make_layer()
|
||
assert build_enable_expr(layer) == ""
|
||
|
||
def test_start_time_only(self):
|
||
"""只有开始时间."""
|
||
layer = _make_layer(start_time=5.0)
|
||
result = build_enable_expr(layer)
|
||
assert result == ":enable='gte(t,5.0)'"
|
||
|
||
def test_duration_only(self):
|
||
"""只有 duration(从 0 开始)."""
|
||
layer = _make_layer(duration=10.0)
|
||
result = build_enable_expr(layer)
|
||
assert result == ":enable='between(t,0.0,10.0)'"
|
||
|
||
def test_start_and_duration(self):
|
||
"""开始时间 + 时长."""
|
||
layer = _make_layer(start_time=2.0, duration=5.0)
|
||
result = build_enable_expr(layer)
|
||
assert "between(t,2.0,7.0)" in result
|
||
|
||
def test_zero_start_with_duration(self):
|
||
"""0 开始 + 时长."""
|
||
layer = _make_layer(start_time=0, duration=3.5)
|
||
result = build_enable_expr(layer)
|
||
assert "between(t,0.0,3.5)" in result
|
||
|
||
def test_negative_start_clamped(self):
|
||
"""负开始时间钳制为 0."""
|
||
layer = _make_layer(start_time=-1.0, duration=5.0)
|
||
result = build_enable_expr(layer)
|
||
assert "between(t,0.0,5.0)" in result
|
||
|
||
def test_none_duration(self):
|
||
"""duration=None 视为无限."""
|
||
layer = _make_layer(start_time=3.0, duration=None)
|
||
result = build_enable_expr(layer)
|
||
assert "gte(t,3.0)" in result
|
||
assert "between" not in result
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# build_pip_filters
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestBuildPipFilters:
|
||
"""完整滤镜链构建测试."""
|
||
|
||
def test_empty_layers(self):
|
||
"""空图层列表返回空."""
|
||
filters, inputs, label = build_pip_filters(
|
||
"base",
|
||
[],
|
||
[],
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
)
|
||
assert filters == []
|
||
assert inputs == []
|
||
assert label == "base"
|
||
|
||
def test_single_layer(self):
|
||
"""单个图层."""
|
||
layer = _make_layer(width="20%", position="bottom_right")
|
||
path = Path("/tmp/clip1.mp4")
|
||
|
||
filters, inputs, label = build_pip_filters(
|
||
"v0",
|
||
[layer],
|
||
[path],
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
)
|
||
|
||
# 2 个滤镜片段:预处理 + overlay
|
||
assert len(filters) == 2
|
||
# 1 个输入
|
||
assert inputs == ["-i", str(path)]
|
||
# 最终标签
|
||
assert label == "pip_combined_0"
|
||
|
||
def test_multiple_layers(self):
|
||
"""多个图层."""
|
||
layers = [
|
||
_make_layer(width="30%", position="bottom_left"),
|
||
_make_layer(width="25%", position="top_right"),
|
||
_make_layer(width="20%", position="top_left"),
|
||
]
|
||
paths = [Path("/tmp/a.mp4"), Path("/tmp/b.mp4"), Path("/tmp/c.mp4")]
|
||
|
||
filters, inputs, label = build_pip_filters(
|
||
"base",
|
||
layers,
|
||
paths,
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
)
|
||
|
||
# 每个图层 2 个滤镜(预处理 + overlay)
|
||
assert len(filters) == 6
|
||
# 3 个输入
|
||
assert len(inputs) == 6 # -i path × 3
|
||
assert inputs[0::2] == ["-i", "-i", "-i"]
|
||
# 最终标签是最后一个 combined
|
||
assert label == "pip_combined_2"
|
||
|
||
def test_base_input_idx_offset(self):
|
||
"""base_input_idx 偏移."""
|
||
layer = _make_layer(width="20%")
|
||
filters, inputs, label = build_pip_filters(
|
||
"base",
|
||
[layer],
|
||
[Path("/tmp/x.mp4")],
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
base_input_idx=5,
|
||
)
|
||
# 预处理滤镜引用 [5:v]
|
||
assert "[5:v]" in filters[0]
|
||
|
||
def test_layer_count_mismatch_raises(self):
|
||
"""图层和路径数量不一致时报错."""
|
||
with pytest.raises(ValueError, match="长度不一致"):
|
||
build_pip_filters(
|
||
"base",
|
||
[_make_layer()],
|
||
[],
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
)
|
||
|
||
def test_filter_chaining(self):
|
||
"""多图层时滤镜链正确串联."""
|
||
layers = [_make_layer(width="10%"), _make_layer(width="10%")]
|
||
paths = [Path("/tmp/1.mp4"), Path("/tmp/2.mp4")]
|
||
|
||
filters, _, _ = build_pip_filters(
|
||
"base",
|
||
layers,
|
||
paths,
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
)
|
||
|
||
# 第一个 overlay 的输入是 base + pip_pre_0
|
||
# 输出是 pip_combined_0
|
||
assert "[base]" in filters[1]
|
||
assert "[pip_combined_0]" in filters[1]
|
||
|
||
# 第二个 overlay 的输入是 pip_combined_0 + pip_pre_1
|
||
# 输出是 pip_combined_1
|
||
assert "[pip_combined_0]" in filters[3]
|
||
assert "[pip_combined_1]" in filters[3]
|
||
|
||
def test_with_animation_layer(self):
|
||
"""带动画的图层生成正确表达式."""
|
||
layer = _make_layer(
|
||
width="30%",
|
||
animation_in=ANIMATION_SLIDE_BOTTOM,
|
||
animation_duration=0.5,
|
||
)
|
||
filters, inputs, _ = build_pip_filters(
|
||
"v0",
|
||
[layer],
|
||
[Path("/tmp/a.mp4")],
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
)
|
||
# overlay 滤镜中包含滑动表达式
|
||
overlay_filter = filters[1]
|
||
assert "overlay=" in overlay_filter
|
||
assert str(OUTPUT_H) in overlay_filter # 从底部滑入
|
||
|
||
def test_with_enable_time(self):
|
||
"""带时间控制的图层."""
|
||
layer = _make_layer(width="20%", start_time=2.0, duration=5.0)
|
||
filters, _, _ = build_pip_filters(
|
||
"v0",
|
||
[layer],
|
||
[Path("/tmp/a.mp4")],
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
)
|
||
overlay_filter = filters[1]
|
||
assert "enable=" in overlay_filter
|
||
assert "between" in overlay_filter
|
||
|
||
def test_string_paths(self):
|
||
"""路径可以是字符串."""
|
||
layer = _make_layer(width="10%")
|
||
filters, inputs, label = build_pip_filters(
|
||
"v0",
|
||
[layer],
|
||
["/tmp/s.mp4"],
|
||
output_width=OUTPUT_W,
|
||
output_height=OUTPUT_H,
|
||
)
|
||
assert inputs == ["-i", "/tmp/s.mp4"]
|
||
assert len(filters) == 2
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# validate_pip_layer
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestValidatePipLayer:
|
||
"""配置验证测试."""
|
||
|
||
def test_valid_layer(self):
|
||
"""合法配置."""
|
||
layer = _make_layer()
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is True
|
||
assert err == ""
|
||
|
||
def test_empty_source_type(self):
|
||
"""空 source_type."""
|
||
layer = _make_layer(source_type="")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "source_type" in err
|
||
|
||
def test_invalid_source_type(self):
|
||
"""不支持的 source_type."""
|
||
layer = _make_layer(source_type="ftp")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "source_type" in err
|
||
|
||
def test_empty_source(self):
|
||
"""空 source."""
|
||
layer = _make_layer(source="")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "source" in err
|
||
|
||
def test_invalid_position(self):
|
||
"""不支持的 position."""
|
||
layer = _make_layer(position="middle")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "position" in err
|
||
|
||
def test_opacity_too_high(self):
|
||
"""opacity > 1."""
|
||
layer = _make_layer(opacity=1.5)
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "opacity" in err
|
||
|
||
def test_opacity_negative(self):
|
||
"""opacity < 0."""
|
||
layer = _make_layer(opacity=-0.1)
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "opacity" in err
|
||
|
||
def test_negative_corner_radius(self):
|
||
"""负圆角."""
|
||
layer = _make_layer(corner_radius=-5)
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "corner_radius" in err
|
||
|
||
def test_negative_border_width(self):
|
||
"""负边框."""
|
||
layer = _make_layer(border_width=-2)
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "border_width" in err
|
||
|
||
def test_negative_start_time(self):
|
||
"""负开始时间."""
|
||
layer = _make_layer(start_time=-1.0)
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "start_time" in err
|
||
|
||
def test_negative_duration(self):
|
||
"""负时长."""
|
||
layer = _make_layer(duration=-5.0)
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "duration" in err
|
||
|
||
def test_invalid_animation_in(self):
|
||
"""不支持的入场动画."""
|
||
layer = _make_layer(animation_in="zoom")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "animation_in" in err
|
||
|
||
def test_invalid_animation_out(self):
|
||
"""不支持的出场动画."""
|
||
layer = _make_layer(animation_out="spin")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert "animation_out" in err
|
||
|
||
def test_multiple_errors_combined(self):
|
||
"""多个错误合并."""
|
||
layer = _make_layer(source_type="", source="", opacity=2.0, position="xxx")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is False
|
||
assert err.count(";") >= 2 # 至少 2 个错误
|
||
|
||
def test_valid_url_source(self):
|
||
"""URL 类型 source 合法."""
|
||
layer = _make_layer(source_type="url", source="https://example.com/v.mp4")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is True
|
||
|
||
def test_valid_asset_id(self):
|
||
"""asset_id 类型合法."""
|
||
layer = _make_layer(source_type="asset_id", source="asset_123")
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is True
|
||
|
||
def test_zero_values_valid(self):
|
||
"""0 值合法(不是负数)."""
|
||
layer = _make_layer(
|
||
corner_radius=0,
|
||
border_width=0,
|
||
start_time=0,
|
||
animation_duration=0,
|
||
)
|
||
ok, err = validate_pip_layer(layer)
|
||
assert ok is True
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# count_visible_layers
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestCountVisibleLayers:
|
||
"""可见图层统计测试."""
|
||
|
||
def test_all_visible(self):
|
||
"""全部可见."""
|
||
layers = [_make_layer(opacity=1.0), _make_layer(opacity=0.5)]
|
||
assert count_visible_layers(layers) == 2
|
||
|
||
def test_all_invisible(self):
|
||
"""全部不可见."""
|
||
layers = [_make_layer(opacity=0.0), _make_layer(opacity=0.0)]
|
||
assert count_visible_layers(layers) == 0
|
||
|
||
def test_mixed(self):
|
||
"""混合."""
|
||
layers = [
|
||
_make_layer(opacity=1.0),
|
||
_make_layer(opacity=0.0),
|
||
_make_layer(opacity=0.001),
|
||
]
|
||
assert count_visible_layers(layers) == 2
|
||
|
||
def test_empty_list(self):
|
||
"""空列表."""
|
||
assert count_visible_layers([]) == 0
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# sort_layers_by_z_index
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestSortLayersByZIndex:
|
||
"""图层排序测试."""
|
||
|
||
def test_sorted_by_z_index(self):
|
||
"""按 z_index 从小到大排序."""
|
||
layers = [
|
||
_make_layer(z_index=5, source="/tmp/a.mp4"),
|
||
_make_layer(z_index=1, source="/tmp/b.mp4"),
|
||
_make_layer(z_index=3, source="/tmp/c.mp4"),
|
||
]
|
||
sorted_layers = sort_layers_by_z_index(layers)
|
||
assert [layer.z_index for layer in sorted_layers] == [1, 3, 5]
|
||
|
||
def test_same_z_index_stable(self):
|
||
"""相同 z_index 保持相对顺序."""
|
||
layers = [
|
||
_make_layer(z_index=2, source="/tmp/1.mp4"),
|
||
_make_layer(z_index=2, source="/tmp/2.mp4"),
|
||
]
|
||
sorted_layers = sort_layers_by_z_index(layers)
|
||
assert sorted_layers[0].source == "/tmp/1.mp4"
|
||
assert sorted_layers[1].source == "/tmp/2.mp4"
|
||
|
||
def test_empty_list(self):
|
||
"""空列表."""
|
||
assert sort_layers_by_z_index([]) == []
|
||
|
||
def test_single_layer(self):
|
||
"""单个图层."""
|
||
layers = [_make_layer(z_index=0)]
|
||
assert len(sort_layers_by_z_index(layers)) == 1
|
||
|
||
def test_negative_z_index(self):
|
||
"""负 z_index."""
|
||
layers = [
|
||
_make_layer(z_index=0, source="/tmp/0.mp4"),
|
||
_make_layer(z_index=-5, source="/tmp/-5.mp4"),
|
||
_make_layer(z_index=3, source="/tmp/3.mp4"),
|
||
]
|
||
sorted_layers = sort_layers_by_z_index(layers)
|
||
assert [layer.z_index for layer in sorted_layers] == [-5, 0, 3]
|