Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 261ea8360e | |||
| 0c6dbb611d | |||
| 528deeb52e | |||
| 84abfbcfcd |
Executable
+483
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+597
@@ -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
|
||||
Reference in New Issue
Block a user