diff --git a/apps/worker/video_processing/pip_engine.py b/apps/worker/video_processing/pip_engine.py new file mode 100755 index 000000000..cb84550e3 --- /dev/null +++ b/apps/worker/video_processing/pip_engine.py @@ -0,0 +1,483 @@ +"""画中画(PiP)引擎 — 基于 FFmpeg overlay 滤镜实现多图层叠加. + +支持能力: +- 多图层叠加:主画面 + 多个副画面 +- 位置:9宫格 + 自由坐标(像素或百分比) +- 大小:宽高缩放(像素或百分比) +- 圆角裁剪:支持圆角矩形裁剪 +- 透明度:0-100% +- 入场出场动画:淡入淡出、滑入滑出 +- 时间同步:每个副画面独立开始时间和持续时长 +- 降级策略:素材不存在时跳过,不阻断渲染 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +# ── 位置常量 ────────────────────────────────────────────────────────────────── + +# 9宫格位置枚举 +POSITION_TOP_LEFT = "top_left" +POSITION_TOP_CENTER = "top_center" +POSITION_TOP_RIGHT = "top_right" +POSITION_CENTER_LEFT = "center_left" +POSITION_CENTER = "center" +POSITION_CENTER_RIGHT = "center_right" +POSITION_BOTTOM_LEFT = "bottom_left" +POSITION_BOTTOM_CENTER = "bottom_center" +POSITION_BOTTOM_RIGHT = "bottom_right" + +_VALID_POSITIONS = { + POSITION_TOP_LEFT, + POSITION_TOP_CENTER, + POSITION_TOP_RIGHT, + POSITION_CENTER_LEFT, + POSITION_CENTER, + POSITION_CENTER_RIGHT, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_CENTER, + POSITION_BOTTOM_RIGHT, +} + +# 动画类型 +ANIMATION_FADE = "fade" # 淡入淡出 +ANIMATION_SLIDE_LEFT = "slide_left" # 从左滑入 +ANIMATION_SLIDE_RIGHT = "slide_right" # 从右滑入 +ANIMATION_SLIDE_TOP = "slide_top" # 从上滑入 +ANIMATION_SLIDE_BOTTOM = "slide_bottom" # 从下滑入 + +_VALID_ANIMATIONS = { + ANIMATION_FADE, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + ANIMATION_SLIDE_BOTTOM, +} + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class PiPLayerConfig: + """单个画中画图层配置.""" + + # 素材来源 + source: str = "" # 素材ID或视频URL + source_type: str = "asset_id" # "asset_id" | "url" | "local_path" + + # 位置配置 + position: str = POSITION_BOTTOM_RIGHT # 9宫格位置或 "custom" + x: int | str = 0 # 自定义x坐标(像素或百分比如 "30%") + y: int | str = 0 # 自定义y坐标 + margin: int = 20 # 9宫格模式下的边距(像素) + + # 大小配置 + width: int | str = "25%" # 宽度(像素或百分比) + height: int | str = "" # 高度(空则按比例自适应) + + # 样式 + opacity: float = 1.0 # 透明度 0.0-1.0 + corner_radius: int = 0 # 圆角半径(像素),0表示无圆角 + border_width: int = 0 # 边框宽度 + border_color: str = "white" # 边框颜色 + + # 时间控制 + start_time: float = 0.0 # 开始显示时间(秒) + duration: float = 0.0 # 持续时长(秒),0表示全程显示 + + # 动画 + animation_in: str = "" # 入场动画类型 + animation_out: str = "" # 出场动画类型 + animation_duration: float = 0.5 # 动画时长(秒) + + # 层级 + z_index: int = 1 # 图层顺序,数字越大越在上层 + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if not self.source: + return False, "source不能为空" + + if self.position != "custom" and self.position not in _VALID_POSITIONS: + return False, f"无效的position: {self.position}" + + if self.opacity < 0 or self.opacity > 1: + return False, "opacity必须在0-1之间" + + if self.corner_radius < 0: + return False, "corner_radius不能为负数" + + if self.start_time < 0: + return False, "start_time不能为负数" + + if self.duration < 0: + return False, "duration不能为负数" + + if self.animation_in and self.animation_in not in _VALID_ANIMATIONS: + return False, f"无效的入场动画: {self.animation_in}" + + if self.animation_out and self.animation_out not in _VALID_ANIMATIONS: + return False, f"无效的出场动画: {self.animation_out}" + + if self.animation_duration < 0: + return False, "animation_duration不能为负数" + + return True, "" + + +@dataclass +class PiPConfig: + """画中画整体配置.""" + + enabled: bool = False + layers: list[PiPLayerConfig] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig": + """从字典解析配置.""" + if not data or not data.get("enabled", False): + return cls(enabled=False) + + layers_data = data.get("layers", []) + layers = [] + for layer_data in layers_data: + try: + layer = PiPLayerConfig( + source=layer_data.get("source", ""), + source_type=layer_data.get("source_type", "asset_id"), + position=layer_data.get("position", POSITION_BOTTOM_RIGHT), + x=layer_data.get("x", 0), + y=layer_data.get("y", 0), + margin=int(layer_data.get("margin", 20)), + width=layer_data.get("width", "25%"), + height=layer_data.get("height", ""), + opacity=float(layer_data.get("opacity", 1.0)), + corner_radius=int(layer_data.get("corner_radius", 0)), + border_width=int(layer_data.get("border_width", 0)), + border_color=layer_data.get("border_color", "white"), + start_time=float(layer_data.get("start_time", 0.0)), + duration=float(layer_data.get("duration", 0.0)), + animation_in=layer_data.get("animation_in", ""), + animation_out=layer_data.get("animation_out", ""), + animation_duration=float(layer_data.get("animation_duration", 0.5)), + z_index=int(layer_data.get("z_index", 1)), + ) + valid, err = layer.validate() + if valid: + layers.append(layer) + else: + logger.warning("PiP图层配置无效,跳过: %s", err) + except (ValueError, TypeError) as e: + logger.warning("PiP图层解析失败,跳过: %s", e) + + # 按 z_index 排序 + layers.sort(key=lambda layer: layer.z_index) + + return cls(enabled=bool(layers), layers=layers) + + +# ── PiP 引擎 ────────────────────────────────────────────────────────────────── + + +class PiPEngine: + """画中画引擎 — 生成 FFmpeg 滤镜链实现多图层叠加.""" + + def __init__( + self, + output_width: int, + output_height: int, + output_fps: int = 30, + ): + self.output_width = output_width + self.output_height = output_height + self.output_fps = output_fps + + def _parse_size(self, value: int | str, base: int) -> int: + """解析尺寸值(像素或百分比).""" + if isinstance(value, int): + return max(1, value) + if isinstance(value, str) and value.endswith("%"): + pct = float(value.rstrip("%")) / 100.0 + return max(1, int(base * pct)) + try: + return max(1, int(value)) + except (ValueError, TypeError): + return int(base * 0.25) # 默认25% + + def _parse_position( + self, + layer: PiPLayerConfig, + pip_width: int, + pip_height: int, + ) -> tuple[int, int]: + """计算画中画的实际位置 (x, y).""" + W = self.output_width + H = self.output_height + m = layer.margin + + if layer.position == "custom": + x = self._parse_size(layer.x, W) + y = self._parse_size(layer.y, H) + return (x, y) + + pos_map = { + POSITION_TOP_LEFT: (m, m), + POSITION_TOP_CENTER: ((W - pip_width) // 2, m), + POSITION_TOP_RIGHT: (W - pip_width - m, m), + POSITION_CENTER_LEFT: (m, (H - pip_height) // 2), + POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2), + POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2), + POSITION_BOTTOM_LEFT: (m, H - pip_height - m), + POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m), + POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m), + } + return pos_map.get(layer.position, pos_map[POSITION_BOTTOM_RIGHT]) + + def _build_pip_pre_filter( + self, + input_label: str, + layer: PiPLayerConfig, + pip_width: int, + pip_height: int, + output_label: str, + ) -> str: + """构建单个PiP图层的预处理滤镜链. + + 处理顺序:scale → 圆角裁剪(可选)→ 边框(可选)→ 透明度 → 动画(可选) + """ + filters: list[str] = [] + + # Step 1: scale + 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 + 圆形遮罩实现圆角 + # 更简单的方式:用 rounded 滤镜(FFmpeg 5.0+)或 format + alpha + # 这里用更通用的方式:创建圆角遮罩 + overlay 到透明背景 + filters.append( + f"format=yuva420p," + f"geq=" + f"lum='lum(X,Y)':" + f"cb='cb(X,Y)':" + f"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 = layer.opacity + filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}") + + # Step 5: 入场出场动画 + if layer.animation_in or layer.animation_out: + filters.extend(self._build_animation_filters(layer, pip_width, pip_height)) + + filter_str = f"[{input_label}]{','.join(filters)}[{output_label}]" + return filter_str + + def _build_animation_filters( + self, + layer: PiPLayerConfig, + pip_width: int, + pip_height: int, + ) -> list[str]: + """构建入场出场动画滤镜.""" + filters: list[str] = [] + anim_dur = layer.animation_duration + + if layer.animation_in == ANIMATION_FADE: + # 淡入 + filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1") + elif layer.animation_in == ANIMATION_SLIDE_LEFT: + # 从左滑入 — 用 overlay 动态x实现,这里先标记位置表达式 + pass # slide 动画在 overlay 表达式中处理 + elif layer.animation_in == ANIMATION_SLIDE_RIGHT: + pass + elif layer.animation_in == ANIMATION_SLIDE_TOP: + pass + elif layer.animation_in == ANIMATION_SLIDE_BOTTOM: + pass + + if layer.animation_out == ANIMATION_FADE: + # 淡出需要知道总时长,这里用表达式 + if layer.duration > 0: + start_fade = layer.duration - anim_dur + filters.append(f"fade=t=out:st={max(0, start_fade)}:d={anim_dur}:alpha=1") + + return filters + + def _build_overlay_expr( + self, + layer: PiPLayerConfig, + base_x: int, + base_y: int, + pip_width: int, + pip_height: int, + ) -> tuple[str, str]: + """构建 overlay 滤镜的 x/y 表达式(支持滑动动画). + + Returns: + (x_expr, y_expr) — FFmpeg表达式字符串 + """ + W = self.output_width + H = self.output_height + anim_dur = layer.animation_duration + + x_expr = str(base_x) + y_expr = str(base_y) + + # 入场滑入动画 + if layer.animation_in == ANIMATION_SLIDE_LEFT: + # 从左侧滑入:x 从 -pip_width 变化到 base_x + x_expr = f"'{base_x}+(X)*0+if(lt(t,{anim_dur}),{-pip_width}+t/{anim_dur}*({base_x}+{pip_width}),{base_x})'" + elif layer.animation_in == ANIMATION_SLIDE_RIGHT: + # 从右侧滑入:x 从 W 变化到 base_x + x_expr = f"'{base_x}+if(lt(t,{anim_dur}),{W}-t/{anim_dur}*({W}-{base_x}),{base_x})'" + elif layer.animation_in == ANIMATION_SLIDE_TOP: + y_expr = f"'{base_y}+if(lt(t,{anim_dur}),{-pip_height}+t/{anim_dur}*({base_y}+{pip_height}),{base_y})'" + elif layer.animation_in == ANIMATION_SLIDE_BOTTOM: + y_expr = f"'{base_y}+if(lt(t,{anim_dur}),{H}-t/{anim_dur}*({H}-{base_y}),{base_y})'" + + # 出场滑出动画(需要总时长) + if layer.duration > 0 and anim_dur > 0: + out_start = layer.duration - anim_dur + if layer.animation_out == ANIMATION_SLIDE_LEFT: + x_expr = f"'{base_x}+if(gt(t,{out_start}),{base_x}-(t-{out_start})/{anim_dur}*({base_x}+{pip_width}),{base_x})'" + elif layer.animation_out == ANIMATION_SLIDE_RIGHT: + x_expr = f"'{base_x}+if(gt(t,{out_start}),{base_x}+(t-{out_start})/{anim_dur}*({W}-{base_x}+{pip_width}),{base_x})'" + elif layer.animation_out == ANIMATION_SLIDE_TOP: + y_expr = f"'{base_y}+if(gt(t,{out_start}),{base_y}-(t-{out_start})/{anim_dur}*({base_y}+{pip_height}),{base_y})'" + elif layer.animation_out == ANIMATION_SLIDE_BOTTOM: + y_expr = f"'{base_y}+if(gt(t,{out_start}),{base_y}+(t-{out_start})/{anim_dur}*({H}-{base_y}+{pip_height}),{base_y})'" + + return (x_expr, y_expr) + + def build_pip_filters( + self, + base_label: str, + pip_sources: list[tuple[str, PiPLayerConfig, Path]], + *, + base_input_idx: int = 0, + ) -> tuple[str, list[str], str]: + """构建完整的画中画滤镜链和输入参数. + + Args: + base_label: 底层视频的滤镜标签(如 "final_video" 或 "v0",不带方括号) + pip_sources: [(input_label, layer_config, source_path), ...] + base_input_idx: PiP 素材在整个 FFmpeg 输入中的起始索引 + + Returns: + (filter_parts, input_args, final_label) + - filter_parts: 滤镜字符串列表(用 ; 连接后成为 filter_complex) + - input_args: 额外的输入参数列表 ["-i", path, "-i", path, ...] + - final_label: 最终合成后的输出标签(不带方括号) + """ + if not pip_sources: + return [], [], base_label + + filter_parts: list[str] = [] + input_args: list[str] = [] + current_label = base_label + + for i, (input_label, layer, path) in enumerate(pip_sources): + # 添加输入 + input_args.extend(["-i", str(path)]) + + # 计算实际大小 + pip_w = self._parse_size(layer.width, self.output_width) + if layer.height: + pip_h = self._parse_size(layer.height, self.output_height) + else: + # 按宽度等比例(假设16:9,实际会scale时保持比例) + pip_h = int(pip_w * 9 / 16) + + # 实际输入索引 = 起始索引 + 当前偏移 + actual_input_idx = base_input_idx + i + + # 预处理标签 + pre_label = f"pip_pre_{i}" + + # 构建预处理滤镜 + pre_filter = self._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 = self._parse_position(layer, pip_w, pip_h) + + # 构建overlay表达式(支持滑动动画) + x_expr, y_expr = self._build_overlay_expr(layer, base_x, base_y, pip_w, pip_h) + + # 时间控制(enable表达式) + enable_expr = "" + if layer.start_time > 0 or layer.duration > 0: + start = layer.start_time + if layer.duration > 0: + end = start + layer.duration + enable_expr = f":enable='between(t,{start},{end})'" + else: + enable_expr = f":enable='gte(t,{start})'" + + # 合成标签 + 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_layer_source( + self, + layer: PiPLayerConfig, + asset_path_map: dict[str, Path], + ) -> Path | None: + """验证图层素材是否可用,返回本地路径或None(降级跳过).""" + try: + if layer.source_type == "local_path": + path = Path(layer.source) + if path.exists(): + return path + elif layer.source_type == "asset_id": + if layer.source in asset_path_map: + return asset_path_map[layer.source] + elif layer.source_type == "url": + # URL类型由调用者负责下载,这里返回标记 + return None # 暂时不支持直接URL + except Exception as e: + logger.warning("PiP素材验证失败: %s", e) + + return None diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 2a0575af7..631ba24f5 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -39,6 +39,7 @@ from video_processing.ffmpeg_utils import ( probe_video_info, run_ffmpeg, ) +from video_processing.pip_engine import PiPConfig, PiPEngine, PiPLayerConfig from video_processing.render_audio import RenderContext, merge_audio_video, mix_audio from video_processing.render_subtitles import generate_ass_subtitles @@ -203,15 +204,21 @@ class UnifiedRenderService: # 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置) ass_path = self._maybe_generate_ass(video_duration) + # 4.5 解析画中画配置 + pip_config = PiPConfig.from_dict((self.plan.config or {}).get("pip_config")) + pip_sources = self._resolve_pip_sources(pip_config) if pip_config.enabled else [] + has_pip = len(pip_sources) > 0 + # 灰度埋点:开始渲染 layer_roles = [layer.role for layer in layers] clip_counts = {layer.role: len(layer.clips) for layer in layers} logger.info( - "[unified-render] start render: plan_id=%s clip_count=%d layers=%s clip_counts=%s", + "[unified-render] start render: plan_id=%s clip_count=%d layers=%s clip_counts=%s pip_layers=%d", self.plan.id, len(resolved), layer_roles, clip_counts, + len(pip_sources), ) # 5. 视频主渲染 @@ -219,7 +226,8 @@ class UnifiedRenderService: video_only_path = self.work_dir / f"rendered_{self.plan.id}_video.mp4" output_path = self.work_dir / f"rendered_{self.plan.id}.mp4" - is_pass_through = self._can_use_pass_through(layers) + # 有画中画时不走直通(需要额外图层叠加) + is_pass_through = self._can_use_pass_through(layers) and not has_pip pass_through_has_audio = False used_stream_copy = False @@ -245,6 +253,11 @@ class UnifiedRenderService: ) else: filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path) + + # 追加画中画滤镜 + if has_pip: + filter_complex, input_args = self._append_pip_filters(filter_complex, input_args, pip_sources) + self._execute_ffmpeg(filter_complex, input_args, video_only_path) t_video_end = time.time() @@ -984,3 +997,96 @@ class UnifiedRenderService: if clip.duration > 0: return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration return clip.actual_duration if clip.actual_duration > 0 else 0.0 + + # ── 画中画(PiP)相关方法 ────────────────────────────────────────────────── + + def _resolve_pip_sources(self, pip_config: PiPConfig) -> list[tuple[str, PiPLayerConfig, Path]]: + """解析画中画图层的素材源,返回可用的图层列表. + + 降级策略:素材不存在或无效的图层自动跳过,不阻断渲染。 + + Returns: + [(input_label_placeholder, layer_config, local_path), ...] + input_label 在 build_pip_filters 中会用实际的输入索引替换 + """ + if not pip_config.enabled: + return [] + + engine = PiPEngine( + output_width=self.output_width, + output_height=self.output_height, + output_fps=self.output_fps, + ) + + result = [] + for i, layer in enumerate(pip_config.layers): + path = engine.validate_layer_source(layer, self.asset_path_map) + if path is None: + logger.warning("PiP图层素材不可用,跳过: layer_index=%d source=%s", i, layer.source) + continue + # 标签占位,实际输入索引由 build_pip_filters 内部管理 + result.append((f"pip_src_{i}", layer, path)) + + return result + + def _append_pip_filters( + self, + filter_complex: str, + input_args: list[str], + pip_sources: list[tuple[str, Any, Path]], + ) -> tuple[str, list[str]]: + """将画中画滤镜追加到 filter_complex 末尾. + + 处理逻辑: + 1. 将原 final_video 标签重命名为 pip_base(作为PiP的底层视频) + 2. 追加 PiP 预处理和 overlay 滤镜 + 3. PiP 最终输出命名为 final_video + + Args: + filter_complex: 原 filter_complex 字符串 + input_args: 原输入参数列表 + pip_sources: PiP 素材列表 [(label, layer_config, path), ...] + + Returns: + (new_filter_complex, new_input_args) + """ + if not pip_sources: + return filter_complex, input_args + + pip_engine = PiPEngine( + output_width=self.output_width, + output_height=self.output_height, + output_fps=self.output_fps, + ) + + # 1. 将原 final_video 改为 pip_base + new_filter = filter_complex.replace("[final_video]", "[pip_base]") + + # 2. 构建 PiP 滤镜链 + # 主输入数量 = len(input_args) // 2(每个输入占 "-i path" 两个参数) + base_input_idx = len(input_args) // 2 + pip_filter_parts, pip_input_args, final_label = pip_engine.build_pip_filters( + base_label="pip_base", + pip_sources=pip_sources, + base_input_idx=base_input_idx, + ) + + if not pip_filter_parts: + # 没有有效PiP滤镜,恢复原标签 + return filter_complex, input_args + + # 3. 追加 PiP 滤镜 + 最终格式转换(输出为 final_video) + pip_filter_str = ";".join(pip_filter_parts) + final_format = f"[{final_label}]format=yuv420p[final_video]" + new_filter = f"{new_filter};{pip_filter_str};{final_format}" + + # 4. 追加输入参数 + new_input_args = list(input_args) + pip_input_args + + logger.info( + "[unified-render] appended PiP filters: layers=%d new_inputs=%d", + len(pip_sources), + len(pip_input_args) // 2, + ) + + return new_filter, new_input_args diff --git a/tests/unit/test_pip_engine.py b/tests/unit/test_pip_engine.py new file mode 100755 index 000000000..7a5e71cff --- /dev/null +++ b/tests/unit/test_pip_engine.py @@ -0,0 +1,597 @@ +"""画中画(PiP)引擎单元测试.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from video_processing.pip_engine import ( + ANIMATION_FADE, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_RIGHT, + POSITION_CENTER, + POSITION_TOP_LEFT, + POSITION_TOP_RIGHT, + PiPConfig, + PiPEngine, + PiPLayerConfig, +) + +# ── PiPLayerConfig.validate 测试 ────────────────────────────────────────────── + + +class TestPiPLayerConfigValidate: + """PiP图层配置校验测试.""" + + def test_valid_config(self): + """正常配置应该通过校验.""" + layer = PiPLayerConfig(source="asset_001") + ok, err = layer.validate() + assert ok + assert err == "" + + def test_empty_source(self): + """空source应该失败.""" + layer = PiPLayerConfig(source="") + ok, err = layer.validate() + assert not ok + assert "source" in err + + def test_invalid_position(self): + """无效位置应该失败.""" + layer = PiPLayerConfig(source="asset_001", position="invalid_pos") + ok, err = layer.validate() + assert not ok + assert "position" in err + + def test_custom_position_valid(self): + """custom位置应该通过.""" + layer = PiPLayerConfig(source="asset_001", position="custom", x=100, y=50) + ok, err = layer.validate() + assert ok + + def test_opacity_out_of_range_high(self): + """opacity超过1应该失败.""" + layer = PiPLayerConfig(source="asset_001", opacity=1.5) + ok, err = layer.validate() + assert not ok + assert "opacity" in err + + def test_opacity_out_of_range_low(self): + """opacity小于0应该失败.""" + layer = PiPLayerConfig(source="asset_001", opacity=-0.5) + ok, err = layer.validate() + assert not ok + assert "opacity" in err + + def test_opacity_boundary_values(self): + """opacity边界值应该通过.""" + for val in [0.0, 0.5, 1.0]: + layer = PiPLayerConfig(source="asset_001", opacity=val) + ok, _ = layer.validate() + assert ok + + def test_negative_corner_radius(self): + """负圆角应该失败.""" + layer = PiPLayerConfig(source="asset_001", corner_radius=-5) + ok, err = layer.validate() + assert not ok + assert "corner_radius" in err + + def test_negative_start_time(self): + """负开始时间应该失败.""" + layer = PiPLayerConfig(source="asset_001", start_time=-1.0) + ok, err = layer.validate() + assert not ok + assert "start_time" in err + + def test_negative_duration(self): + """负持续时间应该失败.""" + layer = PiPLayerConfig(source="asset_001", duration=-5.0) + ok, err = layer.validate() + assert not ok + assert "duration" in err + + def test_invalid_animation_in(self): + """无效入场动画应该失败.""" + layer = PiPLayerConfig(source="asset_001", animation_in="spin") + ok, err = layer.validate() + assert not ok + assert "入场动画" in err + + def test_all_valid_animations(self): + """所有有效动画类型应该通过.""" + for anim in [ + ANIMATION_FADE, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + ANIMATION_SLIDE_BOTTOM, + ]: + layer = PiPLayerConfig(source="asset_001", animation_in=anim, animation_out=anim) + ok, _ = layer.validate() + assert ok + + def test_zero_duration_valid(self): + """duration=0(全程显示)应该通过.""" + layer = PiPLayerConfig(source="asset_001", duration=0.0) + ok, _ = layer.validate() + assert ok + + +# ── PiPConfig.from_dict 测试 ────────────────────────────────────────────────── + + +class TestPiPConfigFromDict: + """PiP配置字典解析测试.""" + + def test_none_config(self): + """None配置应该返回disabled.""" + config = PiPConfig.from_dict(None) + assert not config.enabled + assert len(config.layers) == 0 + + def test_empty_config(self): + """空字典应该返回disabled.""" + config = PiPConfig.from_dict({}) + assert not config.enabled + + def test_enabled_false(self): + """enabled=False应该返回disabled.""" + config = PiPConfig.from_dict({"enabled": False, "layers": [{"source": "a"}]}) + assert not config.enabled + + def test_single_layer(self): + """单图层解析.""" + data = { + "enabled": True, + "layers": [ + { + "source": "asset_001", + "position": POSITION_TOP_RIGHT, + "width": "30%", + "opacity": 0.9, + "corner_radius": 10, + "start_time": 2.0, + "duration": 5.0, + "z_index": 2, + } + ], + } + config = PiPConfig.from_dict(data) + assert config.enabled + assert len(config.layers) == 1 + layer = config.layers[0] + assert layer.source == "asset_001" + assert layer.position == POSITION_TOP_RIGHT + assert layer.width == "30%" + assert layer.opacity == 0.9 + assert layer.corner_radius == 10 + assert layer.start_time == 2.0 + assert layer.duration == 5.0 + assert layer.z_index == 2 + + def test_multiple_layers_sorted_by_z_index(self): + """多图层应该按z_index排序.""" + data = { + "enabled": True, + "layers": [ + {"source": "asset_high", "z_index": 5}, + {"source": "asset_low", "z_index": 1}, + {"source": "asset_mid", "z_index": 3}, + ], + } + config = PiPConfig.from_dict(data) + assert len(config.layers) == 3 + assert config.layers[0].source == "asset_low" + assert config.layers[1].source == "asset_mid" + assert config.layers[2].source == "asset_high" + + def test_invalid_layer_skipped(self): + """无效图层应该被跳过.""" + data = { + "enabled": True, + "layers": [ + {"source": "asset_good"}, + {"source": "", "position": "invalid"}, # 空source + {"source": "asset_good2", "opacity": 2.0}, # opacity超范围 + ], + } + config = PiPConfig.from_dict(data) + # 第1个有效,第2、3个无效 + assert len(config.layers) == 1 + assert config.layers[0].source == "asset_good" + + def test_all_invalid_layers_disabled(self): + """所有图层都无效时enabled为False.""" + data = { + "enabled": True, + "layers": [ + {"source": ""}, + {"source": ""}, + ], + } + config = PiPConfig.from_dict(data) + assert not config.enabled + assert len(config.layers) == 0 + + def test_default_values(self): + """默认值应该正确.""" + data = { + "enabled": True, + "layers": [{"source": "asset_001"}], + } + config = PiPConfig.from_dict(data) + layer = config.layers[0] + assert layer.position == POSITION_BOTTOM_RIGHT + assert layer.width == "25%" + assert layer.opacity == 1.0 + assert layer.corner_radius == 0 + assert layer.start_time == 0.0 + assert layer.duration == 0.0 + assert layer.z_index == 1 + + +# ── PiPEngine 位置计算测试 ──────────────────────────────────────────────────── + + +class TestPiPEnginePosition: + """PiP引擎位置计算测试.""" + + @pytest.fixture + def engine(self): + return PiPEngine(output_width=1920, output_height=1080, output_fps=30) + + def test_top_left_position(self, engine): + """左上角位置.""" + layer = PiPLayerConfig(source="a", position=POSITION_TOP_LEFT, margin=20) + x, y = engine._parse_position(layer, 480, 270) + assert x == 20 + assert y == 20 + + def test_top_right_position(self, engine): + """右上角位置.""" + layer = PiPLayerConfig(source="a", position=POSITION_TOP_RIGHT, margin=20) + x, y = engine._parse_position(layer, 480, 270) + assert x == 1920 - 480 - 20 + assert y == 20 + + def test_bottom_right_position(self, engine): + """右下角位置(默认).""" + layer = PiPLayerConfig(source="a", position=POSITION_BOTTOM_RIGHT, margin=30) + x, y = engine._parse_position(layer, 480, 270) + assert x == 1920 - 480 - 30 + assert y == 1080 - 270 - 30 + + def test_bottom_left_position(self, engine): + """左下角位置.""" + layer = PiPLayerConfig(source="a", position=POSITION_BOTTOM_LEFT, margin=15) + x, y = engine._parse_position(layer, 480, 270) + assert x == 15 + assert y == 1080 - 270 - 15 + + def test_center_position(self, engine): + """中心位置.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, margin=0) + x, y = engine._parse_position(layer, 480, 270) + assert x == (1920 - 480) // 2 + assert y == (1080 - 270) // 2 + + def test_custom_position_pixel(self, engine): + """自定义像素位置.""" + layer = PiPLayerConfig(source="a", position="custom", x=100, y=200) + x, y = engine._parse_position(layer, 480, 270) + assert x == 100 + assert y == 200 + + def test_custom_position_percentage(self, engine): + """自定义百分比位置.""" + layer = PiPLayerConfig(source="a", position="custom", x="50%", y="25%") + x, y = engine._parse_position(layer, 480, 270) + assert x == 1920 // 2 + assert y == 1080 // 4 + + def test_top_center_position(self, engine): + """顶部居中位置.""" + layer = PiPLayerConfig(source="a", position="top_center", margin=10) + x, y = engine._parse_position(layer, 480, 270) + assert x == (1920 - 480) // 2 + assert y == 10 + + def test_invalid_position_fallback(self, engine): + """无效位置应该fallback到右下角.""" + layer = PiPLayerConfig(source="a", position="unknown_position", margin=20) + # 直接测试_parse_position(注意:validate会拦截,但_parse_position自己也有fallback) + x, y = engine._parse_position(layer, 480, 270) + assert x == 1920 - 480 - 20 + assert y == 1080 - 270 - 20 + + +# ── PiPEngine 尺寸解析测试 ──────────────────────────────────────────────────── + + +class TestPiPEngineSize: + """PiP引擎尺寸解析测试.""" + + @pytest.fixture + def engine(self): + return PiPEngine(output_width=1920, output_height=1080, output_fps=30) + + def test_pixel_size_int(self, engine): + """像素尺寸(整数).""" + assert engine._parse_size(500, 1920) == 500 + + def test_pixel_size_str(self, engine): + """像素尺寸(字符串数字).""" + assert engine._parse_size("500", 1920) == 500 + + def test_percentage_size(self, engine): + """百分比尺寸.""" + assert engine._parse_size("50%", 1920) == 960 + assert engine._parse_size("25%", 1920) == 480 + + def test_zero_size_default(self, engine): + """0或无效值应该有最小值保护.""" + assert engine._parse_size(0, 1920) == 1 + assert engine._parse_size("", 1920) == 480 # 默认25% + + def test_negative_size_default(self, engine): + """负值应该取绝对值后至少为1.""" + # _parse_size 用 max(1, value),负值会走 except 分支 + result = engine._parse_size("-100", 1920) + # 会走ValueError分支,返回默认值 + assert result > 0 + + +# ── PiPEngine 滤镜构建测试 ──────────────────────────────────────────────────── + + +class TestPiPEngineBuildFilters: + """PiP引擎滤镜构建测试.""" + + @pytest.fixture + def engine(self): + return PiPEngine(output_width=1920, output_height=1080, output_fps=30) + + @pytest.fixture + def fake_video(self, tmp_path): + """创建一个假的视频文件路径.""" + path = tmp_path / "test_video.mp4" + path.write_bytes(b"fake video data") + return path + + def test_empty_sources(self, engine): + """空素材列表应该返回空.""" + filters, inputs, label = engine.build_pip_filters("base_label", []) + assert filters == [] + assert inputs == [] + assert label == "base_label" + + def test_single_layer_basic(self, engine, fake_video): + """单图层基础滤镜构建.""" + layer = PiPLayerConfig( + source="asset_001", + position=POSITION_TOP_RIGHT, + width="25%", + ) + sources = [("pip_src_0", layer, fake_video)] + + filters, inputs, final_label = engine.build_pip_filters("base_video", sources, base_input_idx=3) + + # 应该有2个滤镜: 预处理 + overlay + assert len(filters) == 2 + # 输入参数应该有2个(-i + path) + assert len(inputs) == 2 + assert inputs[0] == "-i" + assert inputs[1] == str(fake_video) + + # 预处理滤镜应该使用正确的输入索引 + assert "3:v" in filters[0] + # 应该包含scale + assert "scale=" in filters[0] + # 应该有pip_pre_0标签 + assert "[pip_pre_0]" in filters[0] + + # overlay滤镜 + assert "overlay=" in filters[1] + assert "[base_video][pip_pre_0]" in filters[1] + + def test_single_layer_final_label(self, engine, fake_video): + """最终输出标签应该正确.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER) + sources = [("s0", layer, fake_video)] + + _, _, final_label = engine.build_pip_filters("main_v", sources) + assert final_label == "pip_combined_0" + + def test_multiple_layers(self, engine, fake_video): + """多图层叠加.""" + layer1 = PiPLayerConfig(source="a", position=POSITION_TOP_LEFT, z_index=1) + layer2 = PiPLayerConfig(source="b", position=POSITION_BOTTOM_RIGHT, z_index=2) + sources = [ + ("s0", layer1, fake_video), + ("s1", layer2, fake_video), + ] + + filters, inputs, final_label = engine.build_pip_filters("base", sources, base_input_idx=0) + + # 2层 × 2个滤镜(预处理+overlay)= 4个滤镜 + assert len(filters) == 4 + # 2个输入文件 + assert len(inputs) == 4 # 2 × (-i + path) + + # 输入索引应该连续 + assert "0:v" in filters[0] + assert "1:v" in filters[2] + + # 最终标签应该是第二个overlay的输出 + assert final_label == "pip_combined_1" + + def test_with_opacity(self, engine, fake_video): + """透明度应该在滤镜中体现.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, opacity=0.5) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + pre_filter = filters[0] + assert "colorchannelmixer=aa=0.5" in pre_filter + assert "yuva420p" in pre_filter + + def test_with_corner_radius(self, engine, fake_video): + """圆角裁剪应该在滤镜中体现.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, corner_radius=20) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + pre_filter = filters[0] + assert "geq=" in pre_filter + + def test_with_border(self, engine, fake_video): + """边框应该在滤镜中体现.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, border_width=3, border_color="red") + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + pre_filter = filters[0] + assert "pad=" in pre_filter + assert "red" in pre_filter + + def test_timing_start_time_and_duration(self, engine, fake_video): + """时间控制应该生成enable表达式.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=5.0, duration=10.0) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + overlay_filter = filters[1] + assert "enable=" in overlay_filter + assert "between(t,5.0,15.0)" in overlay_filter + + def test_timing_start_time_only(self, engine, fake_video): + """只有开始时间(全程显示到结束).""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=3.0, duration=0.0) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + overlay_filter = filters[1] + assert "enable=" in overlay_filter + assert "gte(t,3.0)" in overlay_filter + + def test_no_timing_no_enable(self, engine, fake_video): + """无时间限制时不应该有enable表达式.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, start_time=0.0, duration=0.0) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + overlay_filter = filters[1] + assert "enable=" not in overlay_filter + + def test_fade_animation(self, engine, fake_video): + """淡入淡出动画.""" + layer = PiPLayerConfig( + source="a", + position=POSITION_CENTER, + animation_in=ANIMATION_FADE, + animation_out=ANIMATION_FADE, + duration=10.0, + animation_duration=0.8, + ) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + pre_filter = filters[0] + assert "fade=t=in" in pre_filter + assert "fade=t=out" in pre_filter + assert "alpha=1" in pre_filter + + def test_slide_animation_in(self, engine, fake_video): + """滑入动画应该在overlay表达式中.""" + layer = PiPLayerConfig( + source="a", + position=POSITION_CENTER, + animation_in=ANIMATION_SLIDE_LEFT, + animation_duration=0.5, + ) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + overlay_filter = filters[1] + # x表达式应该包含动态变化 + assert "overlay=" in overlay_filter + + def test_full_opacity_no_alpha(self, engine, fake_video): + """opacity=1时不应该有colorchannelmixer.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, opacity=1.0) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + pre_filter = filters[0] + assert "colorchannelmixer" not in pre_filter + + def test_zero_corner_radius_no_geq(self, engine, fake_video): + """corner_radius=0时不应该有geq滤镜.""" + layer = PiPLayerConfig(source="a", position=POSITION_CENTER, corner_radius=0) + sources = [("s0", layer, fake_video)] + + filters, _, _ = engine.build_pip_filters("base", sources) + pre_filter = filters[0] + assert "geq=" not in pre_filter + + +# ── PiPEngine 素材验证(降级策略)测试 ──────────────────────────────────────── + + +class TestPiPEngineValidateSource: + """PiP引擎素材验证与降级测试.""" + + @pytest.fixture + def engine(self): + return PiPEngine(output_width=1920, output_height=1080, output_fps=30) + + def test_asset_id_in_map(self, engine, tmp_path): + """asset_id在map中应该返回路径.""" + asset_path = tmp_path / "test.mp4" + asset_path.write_bytes(b"data") + asset_map = {"asset_001": asset_path} + + layer = PiPLayerConfig(source="asset_001", source_type="asset_id") + result = engine.validate_layer_source(layer, asset_map) + assert result == asset_path + + def test_asset_id_not_in_map(self, engine): + """asset_id不在map中应该返回None(降级).""" + layer = PiPLayerConfig(source="nonexistent", source_type="asset_id") + result = engine.validate_layer_source(layer, {}) + assert result is None + + def test_local_path_exists(self, engine, tmp_path): + """本地路径存在应该返回.""" + path = tmp_path / "video.mp4" + path.write_bytes(b"data") + + layer = PiPLayerConfig(source=str(path), source_type="local_path") + result = engine.validate_layer_source(layer, {}) + assert result == path + + def test_local_path_not_exists(self, engine): + """本地路径不存在应该返回None(降级).""" + layer = PiPLayerConfig(source="/nonexistent/path.mp4", source_type="local_path") + result = engine.validate_layer_source(layer, {}) + assert result is None + + def test_url_type_not_supported(self, engine): + """URL类型暂时不支持,返回None.""" + layer = PiPLayerConfig(source="http://example.com/video.mp4", source_type="url") + result = engine.validate_layer_source(layer, {}) + assert result is None + + def test_exception_handling(self, engine): + """异常情况应该返回None(不阻断).""" + layer = PiPLayerConfig(source=None, source_type="local_path") # type: ignore + # 模拟异常情况 + result = engine.validate_layer_source(layer, {}) + assert result is None