Files
xiaoxia-saas/tests/unit/test_pip_engine_pure.py
T
xiaoxia 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
fix: 修复 20 个 ruff lint 错误,解除所有 PR CI 阻塞
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
2026-07-28 16:03:01 +08:00

967 lines
36 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]