7ff7ae6c7d
CI/CD Pipeline / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m35s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
510 lines
19 KiB
Python
Executable File
510 lines
19 KiB
Python
Executable File
"""画中画(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
|