"""画中画(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(降级跳过). 安全: - local_path 类型:必须在允许的目录内,防止路径遍历 - url 类型:必须通过 SSRF 安全校验 """ from video_processing.path_security import is_in_allowed_dirs from video_processing.url_security import UrlSecurityError, validate_url_safety try: if layer.source_type == "local_path": if not layer.source: return None # 路径安全校验:必须在允许目录内 src_path = Path(layer.source) if not src_path.exists(): return None if not is_in_allowed_dirs(src_path): logger.warning( "PiP local_path 不在允许目录内,拒绝: %s", layer.source[:80], ) return None return src_path.resolve() elif layer.source_type == "asset_id": if layer.source in asset_path_map: return asset_path_map[layer.source] return None elif layer.source_type == "url": # URL类型:先做SSRF安全校验,由调用者负责实际下载 try: validate_url_safety(layer.source, purpose="pip_source") logger.info("PiP URL 安全校验通过: %s", layer.source[:80]) except UrlSecurityError as e: logger.warning("PiP URL 安全校验失败: %s (error=%s)", layer.source[:80], e) return None # 暂时不支持直接URL下载,返回None表示降级跳过 return None except Exception as e: logger.warning("PiP素材验证失败: %s", e) return None