Files
xiaoxia-saas/packages/domain/watermark_config.py

361 lines
12 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""水印配置领域模型 — 纯逻辑,无FFmpeg依赖.
抽离自 watermark_engine.py,包含:
- 水印位置常量(9宫格)
- WatermarkConfig 数据类(from_dict / validate
- 位置计算(calc_position / calc_scroll_x
- 滤镜字符串构建(build_image_watermark_filter / build_text_watermark_filter
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ────────────────────────────────────────────────────────────────────
# 9宫格位置枚举
WATERMARK_POSITIONS: dict[str, str] = {
"top_left": "左上",
"top_center": "中上",
"top_right": "右上",
"center_left": "左中",
"center": "中心",
"center_right": "右中",
"bottom_left": "左下",
"bottom_center": "中下",
"bottom_right": "右下",
}
VALID_POSITIONS = set(WATERMARK_POSITIONS.keys())
# 默认值常量
DEFAULT_POSITION = "bottom_right"
DEFAULT_MODE = "text"
DEFAULT_SCALE = 0.2
DEFAULT_OPACITY = 0.8
DEFAULT_FONT_SIZE = 24
DEFAULT_FONT_COLOR = "white"
DEFAULT_MARGIN_X = 20
DEFAULT_MARGIN_Y = 20
DEFAULT_SCROLL_SPEED = 50
# ── 数据类 ──────────────────────────────────────────────────────────────────
@dataclass
class WatermarkConfig:
"""水印配置.
mode: "image" 图片水印 | "text" 文字水印
position: 9宫格位置
opacity: 透明度 0.0-1.0
scale: 缩放比例(图片水印),0.1-1.0
margin: 边距(像素)
scroll: 是否滚动(跑马灯)
scroll_speed: 滚动速度(像素/秒)
"""
mode: str = DEFAULT_MODE # image | text
position: str = DEFAULT_POSITION
# 图片水印
image_path: str = "" # 本地图片路径
scale: float = DEFAULT_SCALE # 相对输出宽度的比例
opacity: float = DEFAULT_OPACITY # 0.0-1.0
# 文字水印
text: str = ""
font_size: int = DEFAULT_FONT_SIZE
font_color: str = DEFAULT_FONT_COLOR
font_path: str = "" # 字体文件路径
# 边距
margin_x: int = DEFAULT_MARGIN_X
margin_y: int = DEFAULT_MARGIN_Y
# 滚动水印
scroll: bool = False
scroll_speed: int = DEFAULT_SCROLL_SPEED # 像素/秒
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> WatermarkConfig | None:
"""从字典构造,空配置返回 None(不加水印)."""
if not data:
return None
enabled = data.get("enabled", False)
if not enabled:
return None
mode = data.get("mode", DEFAULT_MODE)
# 图片模式需要 image_path;文字模式需要 text
if mode == "image":
image_path = data.get("image_path", "") or data.get("image", "") or ""
if not image_path:
logger.warning("图片水印缺少 image_path,跳过水印")
return None
elif mode == "text":
text = data.get("text", "") or ""
if not text:
logger.warning("文字水印缺少 text,跳过水印")
return None
position = data.get("position", DEFAULT_POSITION)
if position not in VALID_POSITIONS:
position = DEFAULT_POSITION
return cls(
mode=mode,
position=position,
image_path=str(data.get("image_path", data.get("image", "")) or ""),
scale=float(data.get("scale", DEFAULT_SCALE)),
opacity=float(data.get("opacity", DEFAULT_OPACITY)),
text=str(data.get("text", "") or ""),
font_size=int(data.get("font_size", DEFAULT_FONT_SIZE)),
font_color=str(data.get("font_color", DEFAULT_FONT_COLOR)),
font_path=str(data.get("font_path", "") or ""),
margin_x=int(data.get("margin_x", DEFAULT_MARGIN_X)),
margin_y=int(data.get("margin_y", DEFAULT_MARGIN_Y)),
scroll=bool(data.get("scroll", False)),
scroll_speed=int(data.get("scroll_speed", DEFAULT_SCROLL_SPEED)),
)
def validate(self) -> tuple[bool, str]:
"""校验配置是否有效."""
if self.position not in VALID_POSITIONS:
return False, f"不支持的位置: {self.position}"
if not (0.0 <= self.opacity <= 1.0):
return False, "透明度必须在 0-1 之间"
if self.mode == "image":
if not self.image_path:
return False, "图片水印缺少图片路径"
if not (0.01 <= self.scale <= 1.0):
return False, "缩放比例必须在 0.01-1.0 之间"
elif self.mode == "text":
if not self.text:
return False, "文字水印缺少文字内容"
if self.font_size <= 0:
return False, "字体大小必须大于 0"
else:
return False, f"不支持的水印模式: {self.mode}"
return True, ""
def has_effect(self) -> bool:
"""判断水印是否有实际效果(非空配置)."""
if self.mode == "image":
return bool(self.image_path) and self.opacity > 0
elif self.mode == "text":
return bool(self.text) and self.opacity > 0 and self.font_size > 0
return False
# ── 位置计算 ────────────────────────────────────────────────────────────────
def calc_position(
position: str,
output_width: int,
output_height: int,
wm_width: int,
wm_height: int,
margin_x: int,
margin_y: int,
) -> tuple[int, int]:
"""根据9宫格位置计算水印坐标 (x, y).
坐标系:左上角为 (0, 0)
"""
if position == "top_left":
return margin_x, margin_y
elif position == "top_center":
return (output_width - wm_width) // 2, margin_y
elif position == "top_right":
return output_width - wm_width - margin_x, margin_y
elif position == "center_left":
return margin_x, (output_height - wm_height) // 2
elif position == "center":
return (output_width - wm_width) // 2, (output_height - wm_height) // 2
elif position == "center_right":
return output_width - wm_width - margin_x, (output_height - wm_height) // 2
elif position == "bottom_left":
return margin_x, output_height - wm_height - margin_y
elif position == "bottom_center":
return (output_width - wm_width) // 2, output_height - wm_height - margin_y
elif position == "bottom_right":
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
else:
# 默认右下角
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
def calc_scroll_x(position: str, output_width: int, wm_width: int, speed: int) -> str:
"""生成滚动水印的 x 坐标表达式.
从右向左滚动(跑马灯效果)
"""
# 标准跑马灯:x = -w + (t * speed) % (W + w)
# FFmpeg overlay 表达式写法
return f"mod({output_width}-mod({speed}*t\\,{output_width}+{wm_width})"
# ── 滤镜构建 ────────────────────────────────────────────────────────────────
def build_image_watermark_filter(
input_video_label: str,
wm_image_path: str,
output_width: int,
output_height: int,
output_label: str,
config: WatermarkConfig,
) -> tuple[str, list[str]]:
"""构建图片水印滤镜链.
Args:
input_video_label: 输入视频标签,如 "[final_video]"
wm_image_path: 水印图片本地路径
output_width: 输出视频宽度
output_height: 输出视频高度
output_label: 输出标签
config: 水印配置
Returns:
(filter_complex_str, input_args_list)
input_args 是 ["-i", wm_image_path] 格式
"""
# 计算水印尺寸(按输出宽度比例缩放)
wm_width = int(output_width * config.scale)
wm_height = -1 # 保持比例
wm_filter = f"scale={wm_width}:{wm_height}"
# 透明度处理
if config.opacity < 1.0:
wm_filter += f",format=rgba,colorchannelmixer=aa={config.opacity}"
# 水印预处理标签
wm_pre_label = "[wm_scaled]"
# 计算位置
x, y = calc_position(
config.position,
output_width,
output_height,
wm_width,
wm_width, # 高度未知,先用宽度估算
config.margin_x,
config.margin_y,
)
# 滚动水印
if config.scroll:
# 从右向左滚动:x = W - (t * speed) mod (W + wm_w)
x_expr = f"{output_width}-mod({config.scroll_speed}*t\\,{output_width}+{wm_width}"
y_expr = str(y)
overlay_expr = f"x={x_expr}:y={y_expr}"
else:
overlay_expr = f"x={x}:y={y}"
# 构建滤镜
filter_parts = [
f"[1:v]{wm_filter}{wm_pre_label}",
f"{input_video_label}{wm_pre_label}overlay={overlay_expr}{output_label}",
]
filter_complex = ";".join(filter_parts)
input_args = ["-i", wm_image_path]
return filter_complex, input_args
def build_text_watermark_filter(
input_video_label: str,
output_label: str,
config: WatermarkConfig,
output_width: int,
output_height: int,
) -> str:
"""构建文字水印滤镜(drawtext.
Args:
input_video_label: 输入视频标签
output_label: 输出标签
config: 水印配置
output_width: 输出宽度
output_height: 输出高度
Returns:
FFmpeg filter 字符串
"""
# 转义文字中的特殊字符
text = config.text.replace(":", "\\:").replace("'", "\\'")
# 字体配置
font_config = []
if config.font_path:
font_path_escaped = config.font_path.replace(":", "\\:").replace("'", "\\'")
font_config.append(f"fontfile='{font_path_escaped}'")
font_config.append(f"fontsize={config.font_size}")
font_config.append(f"fontcolor={config.font_color}@{config.opacity}")
# 估算文字宽高(粗略估算,用于位置计算)
# 每个汉字约等于 font_size 宽高
approx_w = len(config.text) * config.font_size
approx_h = config.font_size
# 位置计算
x, y = calc_position(
config.position,
output_width,
output_height,
approx_w,
approx_h,
config.margin_x,
config.margin_y,
)
# 滚动水印
if config.scroll:
x_expr = f"w-mod({config.scroll_speed}*t\\,W+w)"
pos_config = [f"x={x_expr}", f"y={y}"]
else:
pos_config = [f"x={x}", f"y={y}"]
# 组装 drawtext
drawtext_parts = [f"text='{text}'"] + font_config + pos_config
drawtext = "drawtext=" + ":".join(drawtext_parts)
return f"{input_video_label}{drawtext}{output_label}"
# ── 工具函数 ────────────────────────────────────────────────────────────────
def get_position_names() -> list[str]:
"""获取所有合法位置名称列表(按从上到下、从左到右顺序)."""
return [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
]
def get_position_display_name(position: str) -> str:
"""获取位置的中文显示名."""
return WATERMARK_POSITIONS.get(position, position)