From 3d849467a0343e24984d4f82bb4cb2a96977aa3b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 09:59:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(wave137):=20pip=5Fengine=20=E7=BA=AF?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E6=8A=BD=E7=A6=BB=20+=2096=20=E5=8D=95?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽离 pip_engine_pure.py 纯函数模块,0 FFmpeg 依赖: - compute_pip_size / compute_pip_position: 尺寸与位置计算 - build_pip_pre_filter: 预处理滤镜链(scale/圆角/边框/透明度/动画) - build_animation_filters: fade 类动画滤镜 - build_overlay_expr: overlay x/y 表达式(支持滑动动画) - build_enable_expr: enable 时间控制表达式 - build_pip_filters: 完整 PiP 滤镜链编排 - validate_pip_layer: 配置合法性校验 - count_visible_layers / sort_layers_by_z_index: 辅助函数 原 pip_engine.py 完全不变,向后兼容。 新增 96 个单测,0 FFmpeg 依赖。 --- .../video_processing/pip_engine_pure.py | 486 +++++++++ tests/unit/test_pip_engine_pure.py | 966 ++++++++++++++++++ 2 files changed, 1452 insertions(+) create mode 100755 apps/worker/video_processing/pip_engine_pure.py create mode 100755 tests/unit/test_pip_engine_pure.py diff --git a/apps/worker/video_processing/pip_engine_pure.py b/apps/worker/video_processing/pip_engine_pure.py new file mode 100755 index 000000000..6e5d822d9 --- /dev/null +++ b/apps/worker/video_processing/pip_engine_pure.py @@ -0,0 +1,486 @@ +"""画中画(PiP)引擎纯逻辑模块. + +从 pip_engine.py 抽离的纯函数,0 FFmpeg 依赖,可完全单测。 +原模块 pip_engine.py 保持不变,向后兼容。 + +抽离范围: +- 滤镜链构建(scale / 圆角 / 边框 / 透明度 / 动画 / overlay) +- 位置与尺寸计算辅助(封装 domain 层调用) +- 完整 PiP 滤镜链编排 +- 配置验证与降级策略判断 +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from packages.domain.pip_config import ( + ANIMATION_FADE, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + PiPLayerConfig, + calculate_pip_position, + parse_size_value, +) + +logger = logging.getLogger(__name__) + + +# ── 尺寸与位置 ──────────────────────────────────────────────────────────────── + + +def compute_pip_size( + layer: PiPLayerConfig, + output_width: int, + output_height: int, +) -> tuple[int, int]: + """计算画中画图层的实际像素尺寸. + + Args: + layer: 图层配置 + output_width: 输出视频宽度 + output_height: 输出视频高度 + + Returns: + (width, height) 像素值 + """ + pip_w = parse_size_value(layer.width, output_width) + if layer.height: + pip_h = parse_size_value(layer.height, output_height) + else: + # 按宽度等比例(默认 16:9) + pip_h = int(pip_w * 9 / 16) + + # 钳制到输出尺寸内 + pip_w = max(1, min(pip_w, output_width)) + pip_h = max(1, min(pip_h, output_height)) + return pip_w, pip_h + + +def compute_pip_position( + layer: PiPLayerConfig, + pip_width: int, + pip_height: int, + output_width: int, + output_height: int, +) -> tuple[int, int]: + """计算画中画的实际位置 (x, y). + + 封装 domain 层的 calculate_pip_position, + 提供默认值并做边界钳制。 + """ + x, y = calculate_pip_position( + position=layer.position, + output_width=output_width, + output_height=output_height, + pip_width=pip_width, + pip_height=pip_height, + margin=layer.margin, + custom_x=layer.x, + custom_y=layer.y, + ) + + # 边界钳制:确保不超出画面 + x = max(0, min(x, output_width - pip_width)) + y = max(0, min(y, output_height - pip_height)) + return x, y + + +# ── 预处理滤镜 ──────────────────────────────────────────────────────────────── + + +def build_pip_pre_filter( + input_label: str, + layer: PiPLayerConfig, + pip_width: int, + pip_height: int, + output_label: str, +) -> str: + """构建单个 PiP 图层的预处理滤镜链. + + 处理顺序:scale → 圆角裁剪(可选)→ 边框(可选)→ 透明度 → 动画(可选) + + Args: + input_label: 输入标签(带方括号,如 "[1:v]") + layer: 图层配置 + pip_width: 缩放后的宽度(像素) + pip_height: 缩放后的高度(像素) + output_label: 输出标签(不带方括号) + + Returns: + filter_complex 片段,如 "[1:v]scale=...,setsar=1[pip_pre_0]" + """ + filters: list[str] = [] + + # Step 1: scale + SAR + filters.append(f"scale={pip_width}:{pip_height}") + filters.append("setsar=1") + + # Step 2: 圆角裁剪 + if layer.corner_radius > 0: + r = min(layer.corner_radius, pip_width // 2, pip_height // 2) + # 用 geq + 圆形遮罩实现四角圆角 + filters.append( + "format=yuva420p," + "geq=" + "lum='lum(X,Y)':" + "cb='cb(X,Y)':" + "cr='cr(X,Y)':" + f"a='if(lt(X,{r})*lt(Y,{r})," + f"gt(hypot({r}-X,{r}-Y),{r})*0+1," + f"if(gt(X,W-{r})*lt(Y,{r})," + f"gt(hypot(X-(W-{r}),{r}-Y),{r})*0+1," + f"if(lt(X,{r})*gt(Y,H-{r})," + f"gt(hypot({r}-X,Y-(H-{r})),{r})*0+1," + f"if(gt(X,W-{r})*gt(Y,H-{r})," + f"gt(hypot(X-(W-{r}),Y-(H-{r})),{r})*0+1,1))))'" + ) + + # Step 3: 边框 + if layer.border_width > 0: + bw = layer.border_width + color = layer.border_color + filters.append(f"pad={pip_width + 2 * bw}:{pip_height + 2 * bw}:{bw}:{bw}:{color}") + + # Step 4: 透明度 + if layer.opacity < 1.0: + alpha = max(0.0, min(1.0, layer.opacity)) + filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}") + + # Step 5: 入场出场动画(fade 类直接在预处理中加) + anim_filters = build_animation_filters(layer, pip_width, pip_height) + if anim_filters: + filters.extend(anim_filters) + + return f"{input_label}{','.join(filters)}[{output_label}]" + + +def build_animation_filters( + layer: PiPLayerConfig, + pip_width: int, + pip_height: int, +) -> list[str]: + """构建 fade 类入场出场动画滤镜. + + 注意:slide 类动画由 overlay 表达式处理,不在此函数内。 + + Returns: + 滤镜字符串列表(每项是一个完整 filter,可直接用逗号连接) + """ + filters: list[str] = [] + anim_dur = max(0.0, layer.animation_duration) + + # 入场动画 + if layer.animation_in == ANIMATION_FADE and anim_dur > 0: + filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1") + + # 出场动画(需要总时长) + if layer.animation_out == ANIMATION_FADE and anim_dur > 0 and layer.duration is not None and layer.duration > 0: + start_fade = max(0.0, layer.duration - anim_dur) + filters.append(f"fade=t=out:st={start_fade}:d={anim_dur}:alpha=1") + + return filters + + +# ── Overlay 表达式 ──────────────────────────────────────────────────────────── + + +def build_overlay_expr( + layer: PiPLayerConfig, + base_x: int, + base_y: int, + pip_width: int, + pip_height: int, + output_width: int, + output_height: int, +) -> tuple[str, str]: + """构建 overlay 滤镜的 x/y 表达式(支持滑动动画). + + Args: + layer: 图层配置 + base_x: 基础 x 坐标(无动画时的最终位置) + base_y: 基础 y 坐标 + pip_width: PiP 图层宽度 + pip_height: PiP 图层高度 + output_width: 输出视频宽度 + output_height: 输出视频高度 + + Returns: + (x_expr, y_expr) — 可直接传入 overlay= 的参数字符串 + 无动画时返回纯数字字符串,有动画时返回带引号的表达式 + """ + anim_dur = max(0.0, layer.animation_duration) + + x_expr = str(base_x) + y_expr = str(base_y) + + # ── 入场滑入动画 ── + if anim_dur > 0: + if layer.animation_in == ANIMATION_SLIDE_LEFT: + # 从左侧滑入:x 从 -pip_width 变化到 base_x + x_expr = ( + f"'{base_x}+if(lt(t,{anim_dur})," f"{-pip_width}+t/{anim_dur}*({base_x + pip_width})," f"{base_x})'" + ) + elif layer.animation_in == ANIMATION_SLIDE_RIGHT: + # 从右侧滑入:x 从 output_width 变化到 base_x + x_expr = ( + f"'{base_x}+if(lt(t,{anim_dur})," + f"{output_width}-t/{anim_dur}*({output_width - base_x})," + f"{base_x})'" + ) + elif layer.animation_in == ANIMATION_SLIDE_TOP: + # 从顶部滑入 + y_expr = ( + f"'{base_y}+if(lt(t,{anim_dur})," f"{-pip_height}+t/{anim_dur}*({base_y + pip_height})," f"{base_y})'" + ) + elif layer.animation_in == ANIMATION_SLIDE_BOTTOM: + # 从底部滑入 + y_expr = ( + f"'{base_y}+if(lt(t,{anim_dur})," + f"{output_height}-t/{anim_dur}*({output_height - base_y})," + f"{base_y})'" + ) + + # ── 出场滑出动画(需要总时长) ── + if layer.duration is not None and layer.duration > 0 and anim_dur > 0: + out_start = layer.duration - anim_dur + if out_start < 0: + out_start = 0 + + if layer.animation_out == ANIMATION_SLIDE_LEFT: + # 向左滑出 + x_expr = ( + f"'{base_x}+if(gt(t,{out_start})," + f"{base_x}-(t-{out_start})/{anim_dur}*({base_x + pip_width})," + f"{base_x})'" + ) + elif layer.animation_out == ANIMATION_SLIDE_RIGHT: + # 向右滑出 + x_expr = ( + f"'{base_x}+if(gt(t,{out_start})," + f"{base_x}+(t-{out_start})/{anim_dur}*({output_width - base_x + pip_width})," + f"{base_x})'" + ) + elif layer.animation_out == ANIMATION_SLIDE_TOP: + # 向上滑出 + y_expr = ( + f"'{base_y}+if(gt(t,{out_start})," + f"{base_y}-(t-{out_start})/{anim_dur}*({base_y + pip_height})," + f"{base_y})'" + ) + elif layer.animation_out == ANIMATION_SLIDE_BOTTOM: + # 向下滑出 + y_expr = ( + f"'{base_y}+if(gt(t,{out_start})," + f"{base_y}+(t-{out_start})/{anim_dur}*({output_height - base_y + pip_height})," + f"{base_y})'" + ) + + return x_expr, y_expr + + +def build_enable_expr( + layer: PiPLayerConfig, +) -> str: + """构建 overlay 的 enable 时间控制表达式. + + Returns: + enable 表达式片段,如 ":enable='between(t,1,5)'" + 无时间限制时返回空字符串 + """ + start = max(0.0, layer.start_time) + duration = layer.duration + + if start <= 0 and (duration is None or duration <= 0): + return "" + + if duration and duration > 0: + end = start + duration + return f":enable='between(t,{start},{end})'" + else: + return f":enable='gte(t,{start})'" + + +# ── 完整滤镜链 ──────────────────────────────────────────────────────────────── + + +def build_pip_filters( + base_label: str, + layers: list[PiPLayerConfig], + source_paths: list[Path | str], + *, + output_width: int, + output_height: int, + base_input_idx: int = 0, +) -> tuple[list[str], list[str], str]: + """构建完整的画中画滤镜链和输入参数(纯函数版). + + 与 PiPEngine.build_pip_filters 对应,但不依赖类实例, + 所有参数显式传入,方便测试。 + + Args: + base_label: 底层视频标签(不带方括号) + layers: 图层配置列表 + source_paths: 对应每个图层的源文件路径列表 + output_width: 输出视频宽度 + output_height: 输出视频高度 + base_input_idx: PiP 素材的起始输入索引 + + Returns: + (filter_parts, input_args, final_label) + - filter_parts: 滤镜片段列表(用 ; 连接成 filter_complex) + - input_args: 输入参数列表 ["-i", path, "-i", path, ...] + - final_label: 最终输出标签(不带方括号) + + Raises: + ValueError: layers 和 source_paths 长度不一致 + """ + if len(layers) != len(source_paths): + raise ValueError(f"layers ({len(layers)}) 和 source_paths ({len(source_paths)}) 长度不一致") + + if not layers: + return [], [], base_label + + filter_parts: list[str] = [] + input_args: list[str] = [] + current_label = base_label + + for i, (layer, path) in enumerate(zip(layers, source_paths)): + # 计算实际大小 + pip_w, pip_h = compute_pip_size(layer, output_width, output_height) + + # 添加输入 + input_args.extend(["-i", str(path)]) + + # 实际输入索引 + actual_input_idx = base_input_idx + i + + # 预处理标签 + pre_label = f"pip_pre_{i}" + + # 构建预处理滤镜 + pre_filter = build_pip_pre_filter( + input_label=f"[{actual_input_idx}:v]", + layer=layer, + pip_width=pip_w, + pip_height=pip_h, + output_label=pre_label, + ) + filter_parts.append(pre_filter) + + # 计算位置 + base_x, base_y = compute_pip_position(layer, pip_w, pip_h, output_width, output_height) + + # 构建 overlay 表达式 + x_expr, y_expr = build_overlay_expr(layer, base_x, base_y, pip_w, pip_h, output_width, output_height) + + # 时间控制 + enable_expr = build_enable_expr(layer) + + # 合成标签 + combined_label = f"pip_combined_{i}" + + # overlay 滤镜 + overlay_filter = ( + f"[{current_label}][{pre_label}]" f"overlay={x_expr}:{y_expr}{enable_expr}" f"[{combined_label}]" + ) + filter_parts.append(overlay_filter) + + current_label = combined_label + + return filter_parts, input_args, current_label + + +# ── 配置验证 ────────────────────────────────────────────────────────────────── + + +def validate_pip_layer(layer: PiPLayerConfig) -> tuple[bool, str]: + """验证单个 PiP 图层配置是否合法. + + Returns: + (is_valid, error_message) — 合法时 error_message 为空 + """ + errors: list[str] = [] + + # 源类型检查 + if not layer.source_type: + errors.append("source_type 不能为空") + elif layer.source_type not in ("local_path", "asset_id", "url"): + errors.append(f"不支持的 source_type: {layer.source_type}") + + if not layer.source: + errors.append("source 不能为空") + + # 尺寸检查 + if layer.width is None or layer.width == "": + errors.append("width 不能为空") + + # 位置检查 + valid_positions = { + "top_left", + "top_center", + "top_right", + "center_left", + "center", + "center_right", + "bottom_left", + "bottom_center", + "bottom_right", + "custom", + } + if layer.position not in valid_positions: + errors.append(f"不支持的 position: {layer.position}") + + # 数值范围检查 + if layer.opacity < 0.0 or layer.opacity > 1.0: + errors.append(f"opacity 必须在 0-1 之间: {layer.opacity}") + + if layer.corner_radius < 0: + errors.append(f"corner_radius 不能为负: {layer.corner_radius}") + + if layer.border_width < 0: + errors.append(f"border_width 不能为负: {layer.border_width}") + + if layer.animation_duration < 0: + errors.append(f"animation_duration 不能为负: {layer.animation_duration}") + + if layer.start_time < 0: + errors.append(f"start_time 不能为负: {layer.start_time}") + + if layer.duration is not None and layer.duration < 0: + errors.append(f"duration 不能为负: {layer.duration}") + + # 动画类型检查 + valid_anims = { + "", + None, + ANIMATION_FADE, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + ANIMATION_SLIDE_BOTTOM, + } + if layer.animation_in and layer.animation_in not in valid_anims: + errors.append(f"不支持的 animation_in: {layer.animation_in}") + if layer.animation_out and layer.animation_out not in valid_anims: + errors.append(f"不支持的 animation_out: {layer.animation_out}") + + return (len(errors) == 0, "; ".join(errors)) + + +def count_visible_layers(layers: list[PiPLayerConfig]) -> int: + """统计可见图层数量(排除完全透明的).""" + count = 0 + for layer in layers: + if layer.opacity > 0: + count += 1 + return count + + +def sort_layers_by_z_index(layers: list[PiPLayerConfig]) -> list[PiPLayerConfig]: + """按 z_index 从小到大排序图层(z_index 小的先画,在底层).""" + return sorted(layers, key=lambda l: l.z_index) diff --git a/tests/unit/test_pip_engine_pure.py b/tests/unit/test_pip_engine_pure.py new file mode 100755 index 000000000..e5b541cd7 --- /dev/null +++ b/tests/unit/test_pip_engine_pure.py @@ -0,0 +1,966 @@ +"""PiP Engine 纯逻辑单测. + +测试 pip_engine_pure.py 中的所有纯函数, +0 FFmpeg 依赖,快速轻量。 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from packages.domain.pip_config import ( + ANIMATION_FADE, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + PiPLayerConfig, +) +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, +) + +# ── 常量与工具 ──────────────────────────────────────────────────────────────── + +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 [l.z_index for l 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 [l.z_index for l in sorted_layers] == [-5, 0, 3] -- 2.54.0 From c93e79f947fb285fb8163d3a5f0398a0384b5eea Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 28 Jul 2026 02:03:32 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_pip_engine_pure.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_pip_engine_pure.py b/tests/unit/test_pip_engine_pure.py index e5b541cd7..7d44be714 100755 --- a/tests/unit/test_pip_engine_pure.py +++ b/tests/unit/test_pip_engine_pure.py @@ -9,15 +9,6 @@ from __future__ import annotations from pathlib import Path import pytest - -from packages.domain.pip_config import ( - ANIMATION_FADE, - ANIMATION_SLIDE_BOTTOM, - ANIMATION_SLIDE_LEFT, - ANIMATION_SLIDE_RIGHT, - ANIMATION_SLIDE_TOP, - PiPLayerConfig, -) from video_processing.pip_engine_pure import ( build_animation_filters, build_enable_expr, @@ -31,6 +22,15 @@ from video_processing.pip_engine_pure import ( 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 -- 2.54.0