Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7812dda9a0 | |||
| 211d9a7965 | |||
| 2cd3d3d049 | |||
| ad85627478 | |||
| ad84e54305 | |||
| 1014a42973 |
+421
@@ -0,0 +1,421 @@
|
||||
"""片头片尾引擎 — 视频包装与品牌标识.
|
||||
|
||||
支持:
|
||||
- 片头:视频片段 或 纯文字片头(背景色 + 标题 + 副标题)
|
||||
- 片尾:视频片段 或 关注引导片尾
|
||||
- 自动与正片拼接(xfade 转场)
|
||||
- 时长可配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntroOutroConfig:
|
||||
"""片头片尾配置.
|
||||
|
||||
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
# 片头
|
||||
intro_type: str = "none" # none | video | text
|
||||
intro_video_path: str = "" # 视频片段路径
|
||||
intro_duration: float = 3.0 # 片头时长(秒)
|
||||
|
||||
# 文字片头配置
|
||||
intro_background: str = "#000000" # 背景色
|
||||
intro_title: str = ""
|
||||
intro_subtitle: str = ""
|
||||
intro_title_color: str = "white"
|
||||
intro_title_size: int = 48
|
||||
intro_subtitle_color: str = "gray"
|
||||
intro_subtitle_size: int = 24
|
||||
|
||||
# 片尾
|
||||
outro_type: str = "none" # none | video | text | follow
|
||||
outro_video_path: str = "" # 视频片段路径
|
||||
outro_duration: float = 3.0 # 片尾时长(秒)
|
||||
|
||||
# 文字片尾配置
|
||||
outro_background: str = "#000000"
|
||||
outro_title: str = "感谢观看"
|
||||
outro_subtitle: str = "点赞关注不迷路"
|
||||
outro_title_color: str = "white"
|
||||
outro_title_size: int = 48
|
||||
outro_subtitle_color: str = "gray"
|
||||
outro_subtitle_size: int = 24
|
||||
|
||||
# 转场
|
||||
transition_effect: str = "fade"
|
||||
transition_duration: float = 0.5
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig:
|
||||
"""从字典构造."""
|
||||
if not data:
|
||||
return cls()
|
||||
|
||||
enabled = data.get("enabled", False)
|
||||
if not enabled:
|
||||
return cls()
|
||||
|
||||
intro = data.get("intro", {}) or {}
|
||||
outro = data.get("outro", {}) or {}
|
||||
|
||||
return cls(
|
||||
enabled=True,
|
||||
# 片头
|
||||
intro_type=str(intro.get("type", "none")),
|
||||
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
|
||||
intro_duration=float(intro.get("duration", 3.0)),
|
||||
intro_background=str(intro.get("background", "#000000")),
|
||||
intro_title=str(intro.get("title", "") or ""),
|
||||
intro_subtitle=str(intro.get("subtitle", "") or ""),
|
||||
intro_title_color=str(intro.get("title_color", "white")),
|
||||
intro_title_size=int(intro.get("title_size", 48)),
|
||||
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
|
||||
intro_subtitle_size=int(intro.get("subtitle_size", 24)),
|
||||
# 片尾
|
||||
outro_type=str(outro.get("type", "none")),
|
||||
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
|
||||
outro_duration=float(outro.get("duration", 3.0)),
|
||||
outro_background=str(outro.get("background", "#000000")),
|
||||
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
|
||||
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
|
||||
outro_title_color=str(outro.get("title_color", "white")),
|
||||
outro_title_size=int(outro.get("title_size", 48)),
|
||||
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
|
||||
outro_subtitle_size=int(outro.get("subtitle_size", 24)),
|
||||
# 转场
|
||||
transition_effect=str(data.get("transition", "fade")),
|
||||
transition_duration=float(data.get("transition_duration", 0.5)),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_intro(self) -> bool:
|
||||
"""是否有片头."""
|
||||
return self.enabled and self.intro_type in ("video", "text")
|
||||
|
||||
@property
|
||||
def has_outro(self) -> bool:
|
||||
"""是否有片尾."""
|
||||
return self.enabled and self.outro_type in ("video", "text", "follow")
|
||||
|
||||
def validate(self) -> tuple[bool, str]:
|
||||
"""校验配置."""
|
||||
if not self.enabled:
|
||||
return True, ""
|
||||
|
||||
if self.intro_type == "video" and not self.intro_video_path:
|
||||
return False, "视频片头缺少 video_path"
|
||||
if self.intro_type == "text" and not self.intro_title:
|
||||
return False, "文字片头缺少 title"
|
||||
|
||||
if self.outro_type == "video" and not self.outro_video_path:
|
||||
return False, "视频片尾缺少 video_path"
|
||||
if self.outro_type in ("text", "follow") and not self.outro_title:
|
||||
return False, "文字片尾缺少 title"
|
||||
|
||||
if self.intro_duration <= 0:
|
||||
return False, "片头时长必须大于 0"
|
||||
if self.outro_duration <= 0:
|
||||
return False, "片尾时长必须大于 0"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
class IntroOutroEngine:
|
||||
"""片头片尾引擎 — 生成片头片尾视频并与正片拼接."""
|
||||
|
||||
@staticmethod
|
||||
def generate_text_intro(
|
||||
output_path: Path,
|
||||
config: IntroOutroConfig,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
output_fps: int,
|
||||
) -> bool:
|
||||
"""生成纯文字片头视频.
|
||||
|
||||
Args:
|
||||
output_path: 输出文件路径
|
||||
config: 片头片尾配置
|
||||
output_width: 输出宽度
|
||||
output_height: 输出高度
|
||||
output_fps: 输出帧率
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
duration = config.intro_duration
|
||||
bg = config.intro_background.lstrip("#")
|
||||
|
||||
# 转义文字
|
||||
title = config.intro_title.replace(":", "\\:").replace("'", "\\'")
|
||||
subtitle = config.intro_subtitle.replace(":", "\\:").replace("'", "\\'")
|
||||
|
||||
# 颜色(FFmpeg 颜色格式)
|
||||
title_color = config.intro_title_color
|
||||
subtitle_color = config.intro_subtitle_color
|
||||
|
||||
# 计算位置:标题在中心偏上,副标题在中心偏下
|
||||
title_y = f"(h-text_h)/2 - {config.intro_title_size // 2}"
|
||||
subtitle_y = f"(h-text_h)/2 + {config.intro_title_size}"
|
||||
|
||||
# 构建滤镜
|
||||
filter_parts = []
|
||||
|
||||
# 背景
|
||||
filter_parts.append(
|
||||
f"color=c={config.intro_background}:s={output_width}x{output_height}:d={duration}[bg]"
|
||||
)
|
||||
|
||||
# 标题
|
||||
if title:
|
||||
filter_parts.append(
|
||||
f"[bg]drawtext="
|
||||
f"text='{title}':"
|
||||
f"fontsize={config.intro_title_size}:"
|
||||
f"fontcolor={title_color}:"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y={title_y}:"
|
||||
f"alpha='if(lt(t,0.5),t/0.5,1)'" # 淡入
|
||||
f"[with_title]"
|
||||
)
|
||||
bg_label = "with_title"
|
||||
else:
|
||||
bg_label = "bg"
|
||||
|
||||
# 副标题
|
||||
if subtitle:
|
||||
filter_parts.append(
|
||||
f"[{bg_label}]drawtext="
|
||||
f"text='{subtitle}':"
|
||||
f"fontsize={config.intro_subtitle_size}:"
|
||||
f"fontcolor={subtitle_color}:"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y={subtitle_y}:"
|
||||
f"alpha='if(lt(t,0.8),0,if(lt(t,1.2),(t-0.8)/0.4,1))'" # 延迟淡入
|
||||
f"[out]"
|
||||
)
|
||||
final_label = "out"
|
||||
else:
|
||||
final_label = bg_label
|
||||
# 如果没有副标题,需要补上 out 标签
|
||||
if final_label != "out":
|
||||
filter_parts.append(f"[{bg_label}]copy[out]")
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c={config.intro_background}:s={output_width}x{output_height}:d={duration}:r={output_fps}",
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-r",
|
||||
str(output_fps),
|
||||
"-t",
|
||||
str(duration),
|
||||
"-an", # 无音频
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
return output_path.exists()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("生成文字片头失败: %s", e)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def generate_text_outro(
|
||||
output_path: Path,
|
||||
config: IntroOutroConfig,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
output_fps: int,
|
||||
) -> bool:
|
||||
"""生成纯文字片尾视频."""
|
||||
duration = config.outro_duration
|
||||
|
||||
# 转义文字
|
||||
title = config.outro_title.replace(":", "\\:").replace("'", "\\'")
|
||||
subtitle = config.outro_subtitle.replace(":", "\\:").replace("'", "\\'")
|
||||
|
||||
title_color = config.outro_title_color
|
||||
subtitle_color = config.outro_subtitle_color
|
||||
|
||||
# 位置
|
||||
title_y = f"(h-text_h)/2 - {config.outro_title_size // 2}"
|
||||
subtitle_y = f"(h-text_h)/2 + {config.outro_title_size}"
|
||||
|
||||
filter_parts = []
|
||||
|
||||
# 背景
|
||||
bg_src = f"color=c={config.outro_background}:s={output_width}x{output_height}:d={duration}:r={output_fps}"
|
||||
filter_parts.append(f"color=c={config.outro_background}:s={output_width}x{output_height}:d={duration}[bg]")
|
||||
|
||||
# 标题 + 淡出
|
||||
if title:
|
||||
filter_parts.append(
|
||||
f"[bg]drawtext="
|
||||
f"text='{title}':"
|
||||
f"fontsize={config.outro_title_size}:"
|
||||
f"fontcolor={title_color}:"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y={title_y}:"
|
||||
f"alpha='if(gt(t,{duration - 0.5}),({duration}-t)/0.5,1)'"
|
||||
f"[with_title]"
|
||||
)
|
||||
bg_label = "with_title"
|
||||
else:
|
||||
bg_label = "bg"
|
||||
|
||||
# 副标题
|
||||
if subtitle:
|
||||
filter_parts.append(
|
||||
f"[{bg_label}]drawtext="
|
||||
f"text='{subtitle}':"
|
||||
f"fontsize={config.outro_subtitle_size}:"
|
||||
f"fontcolor={subtitle_color}:"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y={subtitle_y}:"
|
||||
f"alpha='if(gt(t,{duration - 0.5}),({duration}-t)/0.5,1)'"
|
||||
f"[out]"
|
||||
)
|
||||
final_label = "out"
|
||||
else:
|
||||
final_label = bg_label
|
||||
if final_label != "out":
|
||||
filter_parts.append(f"[{bg_label}]copy[out]")
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
bg_src,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-r",
|
||||
str(output_fps),
|
||||
"-t",
|
||||
str(duration),
|
||||
"-an",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
return output_path.exists()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("生成文字片尾失败: %s", e)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def concat_with_intro_outro(
|
||||
main_video: Path,
|
||||
intro_video: Path | None,
|
||||
outro_video: Path | None,
|
||||
output_path: Path,
|
||||
transition_duration: float = 0.5,
|
||||
transition_effect: str = "fade",
|
||||
) -> bool:
|
||||
"""将片头 + 正片 + 片尾用 xfade 拼接.
|
||||
|
||||
只传了片头或片尾也可以,缺失的自动跳过。
|
||||
"""
|
||||
# 收集所有片段
|
||||
segments: list[tuple[Path, float]] = [] # (path, duration)
|
||||
|
||||
# 简单探测时长(用 ffprobe,这里简化处理:直接用 xfade 的 offset)
|
||||
# 先添加到列表
|
||||
has_intro = intro_video is not None and intro_video.exists()
|
||||
has_outro = outro_video is not None and outro_video.exists()
|
||||
|
||||
if not has_intro and not has_outro:
|
||||
# 没有片头片尾,直接复制
|
||||
import shutil
|
||||
|
||||
shutil.copy2(main_video, output_path)
|
||||
return True
|
||||
|
||||
# 构建输入和 xfade 链
|
||||
# 简单方式:用 concat demuxer(快速但无转场)
|
||||
# 高级方式:用 xfade 滤镜链(有转场但复杂)
|
||||
|
||||
# 用 concat demuxer 方式(性能好,过渡用硬切)
|
||||
# 后续可以加 xfade 转场
|
||||
concat_list = []
|
||||
if has_intro:
|
||||
concat_list.append(intro_video)
|
||||
concat_list.append(main_video)
|
||||
if has_outro:
|
||||
concat_list.append(outro_video)
|
||||
|
||||
# 生成 concat 列表文件
|
||||
list_file = output_path.parent / f"concat_list_{output_path.stem}.txt"
|
||||
with open(list_file, "w") as f:
|
||||
for seg in concat_list:
|
||||
f.write(f"file '{seg}'\n")
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(list_file),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
# 清理列表文件
|
||||
list_file.unlink(missing_ok=True)
|
||||
return output_path.exists()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("片头片尾拼接失败: %s", e)
|
||||
list_file.unlink(missing_ok=True)
|
||||
return False
|
||||
Regular → Executable
+12
-3
@@ -145,6 +145,7 @@ def concat_main_audio(
|
||||
# 单 clip,直接提取音频,截断到 min(clip有效时长, 视频总时长)
|
||||
clip = clips[0]
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
# 最终时长:取 clip 有效时长和视频总时长的较小值
|
||||
# (视频总时长由主图层决定,但单 clip 场景下两者应该一致,仍做保护)
|
||||
final_duration = effective_duration
|
||||
@@ -162,6 +163,8 @@ def concat_main_audio(
|
||||
"-b:a",
|
||||
"128k",
|
||||
]
|
||||
if trim_start > 0:
|
||||
command.extend(["-ss", f"{trim_start:.3f}"])
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
command.append(str(output_path))
|
||||
@@ -175,8 +178,11 @@ def concat_main_audio(
|
||||
for i, clip in enumerate(clips):
|
||||
input_args.extend(["-i", str(clip.local_path)])
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
if effective_duration > 0:
|
||||
filter_parts.append(f"[{i}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[a{i}]")
|
||||
filter_parts.append(
|
||||
f"[{i}:a]atrim=start={trim_start:.3f}:duration={effective_duration:.3f}," f"asetpts=PTS-STARTPTS[a{i}]"
|
||||
)
|
||||
else:
|
||||
filter_parts.append(f"[{i}:a]asetpts=PTS-STARTPTS[a{i}]")
|
||||
|
||||
@@ -236,9 +242,11 @@ def mix_with_independent_audio(
|
||||
for clip in main_clips:
|
||||
input_args.extend(["-i", str(clip.local_path)])
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
if effective_duration > 0:
|
||||
filter_parts.append(
|
||||
f"[{input_idx}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[ma{input_idx}]"
|
||||
f"[{input_idx}:a]atrim=start={trim_start:.3f}:duration={effective_duration:.3f},"
|
||||
f"asetpts=PTS-STARTPTS[ma{input_idx}]"
|
||||
)
|
||||
else:
|
||||
filter_parts.append(f"[{input_idx}:a]asetpts=PTS-STARTPTS[ma{input_idx}]")
|
||||
@@ -255,11 +263,12 @@ def mix_with_independent_audio(
|
||||
for j, clip in enumerate(audio_clips):
|
||||
input_args.extend(["-i", str(clip.local_path)])
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
volume = clip.config.get("volume", 1.0) if clip.config else 1.0
|
||||
label = f"ia{j}"
|
||||
filters = []
|
||||
if effective_duration > 0:
|
||||
filters.append(f"atrim=0:{effective_duration:.3f}")
|
||||
filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
filters.append("asetpts=PTS-STARTPTS")
|
||||
if volume != 1.0:
|
||||
filters.append(f"volume={volume}")
|
||||
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
"""裁剪引擎 — 基于 FFmpeg trim/atrim 的精确帧级裁剪.
|
||||
|
||||
支持:
|
||||
- 入点出点裁剪(start_time / end_time / duration 三选二)
|
||||
- 边界自动钳制(超出素材时长自动修正,不阻断渲染)
|
||||
- 多段裁剪(一个素材裁剪出多段)
|
||||
- 音画同步(视频 + 音频同步裁剪)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 最小裁剪时长(秒),低于此值视为无效
|
||||
MIN_TRIM_DURATION = 0.1
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimConfig:
|
||||
"""裁剪配置.
|
||||
|
||||
三选二规则:start_time / end_time / duration 中必须至少给出两个,
|
||||
第三个会被自动推导。如果三个都给了,以 start_time + duration 为准。
|
||||
|
||||
边界保护:
|
||||
- start_time < 0 → 钳制到 0
|
||||
- end_time > 素材时长 → 钳制到素材时长
|
||||
- 计算出的 duration < 最小阈值 → 标记为无效
|
||||
"""
|
||||
|
||||
start_time: float = 0.0 # 入点(素材内时间,秒)
|
||||
end_time: float = 0.0 # 出点(素材内时间,秒),0 表示未指定
|
||||
duration: float = 0.0 # 裁剪时长(秒),0 表示未指定
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> TrimConfig | None:
|
||||
"""从字典构造,无有效裁剪参数时返回 None(不裁剪)."""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
start = float(data.get("start_time", 0) or 0)
|
||||
end = float(data.get("end_time", 0) or 0)
|
||||
dur = float(data.get("duration", 0) or 0)
|
||||
|
||||
# 三个参数都没有 → 不裁剪
|
||||
if start <= 0 and end <= 0 and dur <= 0:
|
||||
return None
|
||||
|
||||
# 至少有两个参数(或一个合理的 start/duration)
|
||||
# 兼容:只传了 start_time → 从 start 开始取到末尾
|
||||
# 兼容:只传了 duration → 从 0 开始取 duration
|
||||
if start > 0 and end <= 0 and dur <= 0:
|
||||
# 只有 start,取到末尾 → 这是"从某点开始"的语义,算有效
|
||||
pass
|
||||
elif dur > 0 and start <= 0 and end <= 0:
|
||||
# 只有 duration → 从开头取 duration,算有效
|
||||
pass
|
||||
elif start <= 0 and end <= 0 and dur <= 0:
|
||||
return None
|
||||
|
||||
return cls(start_time=start, end_time=end, duration=dur)
|
||||
|
||||
def validate_and_resolve(self, asset_duration: float) -> TrimConfig:
|
||||
"""根据素材实际时长,解析并钳制裁剪参数.
|
||||
|
||||
返回一个新的 TrimConfig,其中 start_time / end_time / duration 都已确定。
|
||||
如果裁剪无效(时长为0或负数),仍返回但调用方应检查 is_valid。
|
||||
"""
|
||||
start = self.start_time
|
||||
end = self.end_time
|
||||
dur = self.duration
|
||||
|
||||
# 边界:start 不能为负
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
|
||||
# 边界:asset_duration 为 0 时保守处理(不裁剪,取全部)
|
||||
if asset_duration <= 0:
|
||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
|
||||
# 三选二推导
|
||||
# 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的
|
||||
# 情况1:start + end 都有显式值
|
||||
if start > 0 and end > 0:
|
||||
if end <= start:
|
||||
# 出点 <= 入点,无效 → 返回 start 处一个极短片段(调用方会判无效)
|
||||
return TrimConfig(start_time=start, end_time=start, duration=0.0)
|
||||
dur = end - start
|
||||
# 情况2:end + duration 都有显式值
|
||||
elif end > 0 and dur > 0:
|
||||
start = end - dur
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
dur = end # 重新计算
|
||||
# 情况3:start + duration 都有值(start 可以是 0)
|
||||
elif dur > 0:
|
||||
end = start + dur
|
||||
# 情况4:只有 start → 取到素材末尾
|
||||
elif start > 0 and end <= 0 and dur <= 0:
|
||||
end = asset_duration
|
||||
dur = end - start
|
||||
# 情况5:只有 end → 从开头取到 end
|
||||
elif end > 0 and start <= 0 and dur <= 0:
|
||||
start = 0.0
|
||||
dur = end
|
||||
else:
|
||||
# 都没有 → 不裁剪
|
||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
|
||||
# 边界钳制:end 不能超过素材时长
|
||||
if end > asset_duration:
|
||||
end = asset_duration
|
||||
dur = end - start
|
||||
|
||||
# 边界钳制:start 不能超过素材时长
|
||||
if start >= asset_duration:
|
||||
start = max(0.0, asset_duration - MIN_TRIM_DURATION)
|
||||
dur = asset_duration - start
|
||||
end = asset_duration
|
||||
|
||||
# 保证 duration 不为负
|
||||
if dur < 0:
|
||||
dur = 0.0
|
||||
|
||||
return TrimConfig(start_time=start, end_time=end, duration=dur)
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""裁剪是否有效(时长大于最小阈值)."""
|
||||
return self.duration >= MIN_TRIM_DURATION
|
||||
|
||||
@property
|
||||
def is_noop(self) -> bool:
|
||||
"""是否等价于不裁剪(从0开始取全部)."""
|
||||
return self.start_time <= 0 and self.duration <= 0
|
||||
|
||||
@property
|
||||
def trim_from_start(self) -> bool:
|
||||
"""是否从开头裁剪(start_time == 0)."""
|
||||
return self.start_time <= 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimSegment:
|
||||
"""多段裁剪中的一段."""
|
||||
|
||||
segment_id: str # 段 ID(用于生成唯一标签)
|
||||
trim: TrimConfig # 裁剪配置
|
||||
order: int = 0 # 排序
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any], default_order: int = 0) -> TrimSegment:
|
||||
"""从字典构造."""
|
||||
return cls(
|
||||
segment_id=str(data.get("segment_id", "") or f"seg_{default_order}"),
|
||||
trim=TrimConfig(
|
||||
start_time=float(data.get("start_time", 0) or 0),
|
||||
end_time=float(data.get("end_time", 0) or 0),
|
||||
duration=float(data.get("duration", 0) or 0),
|
||||
),
|
||||
order=int(data.get("order", default_order)),
|
||||
)
|
||||
|
||||
|
||||
class TrimEngine:
|
||||
"""裁剪引擎 — 生成 FFmpeg trim / atrim 滤镜."""
|
||||
|
||||
@staticmethod
|
||||
def build_video_trim_filter(
|
||||
input_label: str,
|
||||
trim: TrimConfig,
|
||||
output_label: str,
|
||||
) -> str:
|
||||
"""构建视频裁剪滤镜链.
|
||||
|
||||
Args:
|
||||
input_label: 输入视频标签,如 "[0:v]"
|
||||
trim: 裁剪配置(已解析钳制)
|
||||
output_label: 输出视频标签,如 "[v0_trimmed]"
|
||||
|
||||
Returns:
|
||||
FFmpeg filter 字符串,如 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]"
|
||||
"""
|
||||
if trim.is_noop:
|
||||
# 不裁剪,直接直通
|
||||
return f"{input_label}copy{output_label}" if False else f"{input_label}setpts=PTS-STARTPTS{output_label}"
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# trim 滤镜参数
|
||||
trim_args: list[str] = []
|
||||
if trim.start_time > 0:
|
||||
trim_args.append(f"start={trim.start_time:.3f}")
|
||||
if trim.duration > 0:
|
||||
trim_args.append(f"duration={trim.duration:.3f}")
|
||||
elif trim.end_time > 0:
|
||||
# end 用 duration 表示(start 到 end 的时长)
|
||||
# 但 validate_and_resolve 后应该已经有 duration 了
|
||||
pass
|
||||
|
||||
parts.append(f"trim={':'.join(trim_args)}")
|
||||
parts.append("setpts=PTS-STARTPTS")
|
||||
|
||||
filter_str = f"{input_label}{','.join(parts)}{output_label}"
|
||||
return filter_str
|
||||
|
||||
@staticmethod
|
||||
def build_audio_trim_filter(
|
||||
input_label: str,
|
||||
trim: TrimConfig,
|
||||
output_label: str,
|
||||
) -> str:
|
||||
"""构建音频裁剪滤镜链.
|
||||
|
||||
Args:
|
||||
input_label: 输入音频标签,如 "[0:a]"
|
||||
trim: 裁剪配置(已解析钳制)
|
||||
output_label: 输出音频标签,如 "[a0_trimmed]"
|
||||
|
||||
Returns:
|
||||
FFmpeg filter 字符串,如 "[0:a]atrim=start=10:duration=5,asetpts=PTS-STARTPTS[a0_trimmed]"
|
||||
"""
|
||||
if trim.is_noop:
|
||||
return f"{input_label}asetpts=PTS-STARTPTS{output_label}"
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
trim_args: list[str] = []
|
||||
if trim.start_time > 0:
|
||||
trim_args.append(f"start={trim.start_time:.3f}")
|
||||
if trim.duration > 0:
|
||||
trim_args.append(f"duration={trim.duration:.3f}")
|
||||
|
||||
parts.append(f"atrim={':'.join(trim_args)}")
|
||||
parts.append("asetpts=PTS-STARTPTS")
|
||||
|
||||
filter_str = f"{input_label}{','.join(parts)}{output_label}"
|
||||
return filter_str
|
||||
|
||||
@staticmethod
|
||||
def resolve_segments(
|
||||
segments: list[TrimSegment],
|
||||
asset_duration: float,
|
||||
) -> list[TrimSegment]:
|
||||
"""解析并钳制多段裁剪配置,过滤无效段.
|
||||
|
||||
Args:
|
||||
segments: 原始段列表
|
||||
asset_duration: 素材实际时长
|
||||
|
||||
Returns:
|
||||
解析后的有效段列表,按 order 排序
|
||||
"""
|
||||
resolved: list[TrimSegment] = []
|
||||
for i, seg in enumerate(segments):
|
||||
resolved_trim = seg.trim.validate_and_resolve(asset_duration)
|
||||
if not resolved_trim.is_valid:
|
||||
logger.warning("裁剪段无效,跳过: segment_id=%s duration=%.3f", seg.segment_id, resolved_trim.duration)
|
||||
continue
|
||||
resolved.append(
|
||||
TrimSegment(
|
||||
segment_id=seg.segment_id,
|
||||
trim=resolved_trim,
|
||||
order=seg.order if seg.order >= 0 else i,
|
||||
)
|
||||
)
|
||||
|
||||
resolved.sort(key=lambda s: s.order)
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def parse_segments_from_config(config: dict[str, Any] | None) -> list[TrimSegment]:
|
||||
"""从 clip config 中解析多段裁剪配置.
|
||||
|
||||
config 中支持:
|
||||
- trim_segments: [ {segment_id, start_time, end_time, duration, order}, ... ]
|
||||
- trim_start / trim_end / trim_duration: 单段裁剪(兼容旧格式)
|
||||
"""
|
||||
if not config:
|
||||
return []
|
||||
|
||||
# 优先解析多段
|
||||
raw_segments = config.get("trim_segments", [])
|
||||
if raw_segments and isinstance(raw_segments, list):
|
||||
segments = []
|
||||
for i, raw in enumerate(raw_segments):
|
||||
if isinstance(raw, dict):
|
||||
segments.append(TrimSegment.from_dict(raw, default_order=i))
|
||||
return segments
|
||||
|
||||
# 单段裁剪兼容:从 trim_start/trim_end/trim_duration 构造
|
||||
has_single = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
|
||||
if has_single:
|
||||
seg = TrimSegment(
|
||||
segment_id="main",
|
||||
trim=TrimConfig(
|
||||
start_time=float(config.get("trim_start", 0) or 0),
|
||||
end_time=float(config.get("trim_end", 0) or 0),
|
||||
duration=float(config.get("trim_duration", 0) or 0),
|
||||
),
|
||||
order=0,
|
||||
)
|
||||
return [seg]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None:
|
||||
"""从 clip config 中提取单段裁剪配置.
|
||||
|
||||
兼容以下字段名:
|
||||
- trim_start / trim_end / trim_duration
|
||||
- start_time / end_time / duration(在 trim 子字典里)
|
||||
"""
|
||||
if not config:
|
||||
return None
|
||||
|
||||
# trim 子字典
|
||||
if "trim" in config and isinstance(config["trim"], dict):
|
||||
return TrimConfig.from_dict(config["trim"])
|
||||
|
||||
# 扁平字段
|
||||
has_any = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
|
||||
if not has_any:
|
||||
return None
|
||||
|
||||
data = {
|
||||
"start_time": config.get("trim_start", 0),
|
||||
"end_time": config.get("trim_end", 0),
|
||||
"duration": config.get("trim_duration", 0),
|
||||
}
|
||||
return TrimConfig.from_dict(data)
|
||||
@@ -40,11 +40,14 @@ from video_processing.ffmpeg_utils import (
|
||||
probe_video_info,
|
||||
run_ffmpeg,
|
||||
)
|
||||
from video_processing.intro_outro_engine import IntroOutroConfig, IntroOutroEngine
|
||||
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
|
||||
from video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_from_clip_config
|
||||
from video_processing.tts_engine import TtsEngine
|
||||
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
@@ -70,6 +73,7 @@ class ResolvedClip:
|
||||
|
||||
# 运行时填充
|
||||
actual_duration: float = 0.0 # 素材实际时长(probe 后填充)
|
||||
trim_config: TrimConfig | None = None # 解析后的裁剪配置(运行时填充)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -311,6 +315,85 @@ class UnifiedRenderService:
|
||||
# 8. 探测输出
|
||||
duration, file_size, width, height = self._probe_output(output_path)
|
||||
|
||||
# 9. 片头片尾拼接(后处理)
|
||||
intro_outro_config = IntroOutroConfig.from_dict((self.plan.config or {}).get("intro_outro"))
|
||||
if intro_outro_config.has_intro or intro_outro_config.has_outro:
|
||||
io_valid, io_err = intro_outro_config.validate()
|
||||
if io_valid:
|
||||
final_with_io = self.work_dir / f"rendered_{self.plan.id}_with_io.mp4"
|
||||
intro_path = None
|
||||
outro_path = None
|
||||
|
||||
# 生成片头
|
||||
if intro_outro_config.has_intro:
|
||||
intro_path = self.work_dir / f"intro_{self.plan.id}.mp4"
|
||||
intro_ok = False
|
||||
if intro_outro_config.intro_type == "video":
|
||||
import shutil
|
||||
|
||||
src = Path(intro_outro_config.intro_video_path)
|
||||
if src.exists():
|
||||
shutil.copy2(src, intro_path)
|
||||
intro_ok = True
|
||||
else:
|
||||
logger.warning("片头视频不存在,跳过片头: %s", src)
|
||||
elif intro_outro_config.intro_type == "text":
|
||||
intro_ok = IntroOutroEngine.generate_text_intro(
|
||||
intro_path,
|
||||
intro_outro_config,
|
||||
self.output_width,
|
||||
self.output_height,
|
||||
self.output_fps,
|
||||
)
|
||||
|
||||
if not intro_ok:
|
||||
intro_path = None
|
||||
|
||||
# 生成片尾
|
||||
if intro_outro_config.has_outro:
|
||||
outro_path = self.work_dir / f"outro_{self.plan.id}.mp4"
|
||||
outro_ok = False
|
||||
if intro_outro_config.outro_type == "video":
|
||||
import shutil
|
||||
|
||||
src = Path(intro_outro_config.outro_video_path)
|
||||
if src.exists():
|
||||
shutil.copy2(src, outro_path)
|
||||
outro_ok = True
|
||||
else:
|
||||
logger.warning("片尾视频不存在,跳过片尾: %s", src)
|
||||
elif intro_outro_config.outro_type in ("text", "follow"):
|
||||
outro_ok = IntroOutroEngine.generate_text_outro(
|
||||
outro_path,
|
||||
intro_outro_config,
|
||||
self.output_width,
|
||||
self.output_height,
|
||||
self.output_fps,
|
||||
)
|
||||
|
||||
if not outro_ok:
|
||||
outro_path = None
|
||||
|
||||
# 拼接
|
||||
if intro_path or outro_path:
|
||||
concat_ok = IntroOutroEngine.concat_with_intro_outro(
|
||||
output_path,
|
||||
intro_path,
|
||||
outro_path,
|
||||
final_with_io,
|
||||
transition_duration=intro_outro_config.transition_duration,
|
||||
transition_effect=intro_outro_config.transition_effect,
|
||||
)
|
||||
if concat_ok and final_with_io.exists():
|
||||
output_path = final_with_io
|
||||
# 重新探测
|
||||
duration, file_size, width, height = self._probe_output(output_path)
|
||||
logger.info("[unified-render] 片头片尾拼接完成: plan_id=%s", self.plan.id)
|
||||
else:
|
||||
logger.warning("[unified-render] 片头片尾拼接失败,使用原视频: plan_id=%s", self.plan.id)
|
||||
else:
|
||||
logger.warning("[unified-render] 片头片尾配置无效,跳过: %s", io_err)
|
||||
|
||||
t_total = int((time.time() - t_start) * 1000)
|
||||
logger.info(
|
||||
"[unified-render] render done: plan_id=%s total_ms=%d video_ms=%d audio_ms=%d "
|
||||
@@ -920,6 +1003,7 @@ class UnifiedRenderService:
|
||||
"""将 EditPlanClip 列表解析为 ResolvedClip 列表。
|
||||
|
||||
跳过 asset_id 为空或在 asset_path_map 中找不到的片段。
|
||||
支持多段裁剪:一个 clip 配置了 trim_segments 时会展开为多个 ResolvedClip。
|
||||
"""
|
||||
resolved: list[ResolvedClip] = []
|
||||
for clip in self.clips:
|
||||
@@ -939,17 +1023,75 @@ class UnifiedRenderService:
|
||||
except Exception:
|
||||
actual_duration = clip.duration or 5.0
|
||||
|
||||
# 检查是否有多段裁剪配置
|
||||
clip_config = clip.config or {}
|
||||
trim_segments = TrimEngine.parse_segments_from_config(clip_config)
|
||||
|
||||
if trim_segments and len(trim_segments) > 1:
|
||||
# 多段裁剪:展开为多个 clip
|
||||
resolved_segments = TrimEngine.resolve_segments(trim_segments, actual_duration)
|
||||
for i, seg in enumerate(resolved_segments):
|
||||
# 每个段生成一个独立的 ResolvedClip
|
||||
seg_clip_id = f"{clip.id}_seg_{seg.segment_id}"
|
||||
seg_order = clip.order + seg.order * 0.001 + i * 0.0001 # 保持排序
|
||||
seg_start = seg.trim.start_time
|
||||
seg_duration = seg.trim.duration
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=seg_clip_id,
|
||||
asset_id=asset_id,
|
||||
local_path=local_path,
|
||||
clip_type=clip.clip_type,
|
||||
order=seg_order,
|
||||
start_time=seg_start,
|
||||
duration=seg_duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
config={**clip_config, "_segment_id": seg.segment_id},
|
||||
actual_duration=actual_duration,
|
||||
trim_config=seg.trim,
|
||||
)
|
||||
resolved.append(rc)
|
||||
continue
|
||||
|
||||
# 单段裁剪(或无裁剪)
|
||||
# 解析裁剪配置:config 优先,否则用 clip.start_time + clip.duration
|
||||
trim_config = extract_trim_from_clip_config(clip_config)
|
||||
if trim_config is None and (clip.start_time > 0 or clip.duration > 0):
|
||||
# 用旧字段构造
|
||||
trim_config = TrimConfig(
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
)
|
||||
|
||||
# 钳制到实际素材时长
|
||||
effective_trim: TrimConfig | None = None
|
||||
final_start = clip.start_time
|
||||
final_duration = clip.duration
|
||||
|
||||
if trim_config is not None and actual_duration > 0:
|
||||
effective_trim = trim_config.validate_and_resolve(actual_duration)
|
||||
if effective_trim.is_valid:
|
||||
final_start = effective_trim.start_time
|
||||
final_duration = effective_trim.duration
|
||||
else:
|
||||
# 裁剪无效 → 使用完整素材
|
||||
logger.warning("裁剪配置无效,使用完整素材: clip_id=%s", clip.id)
|
||||
effective_trim = None
|
||||
final_start = 0.0
|
||||
final_duration = actual_duration
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=clip.id,
|
||||
asset_id=asset_id,
|
||||
local_path=local_path,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
start_time=final_start,
|
||||
duration=final_duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
config=clip.config or {},
|
||||
config=clip_config,
|
||||
actual_duration=actual_duration,
|
||||
trim_config=effective_trim,
|
||||
)
|
||||
resolved.append(rc)
|
||||
|
||||
@@ -1024,7 +1166,7 @@ class UnifiedRenderService:
|
||||
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# Step 1: 预处理每个 clip — scale + setpts
|
||||
# Step 1: 预处理每个 clip — trim + scale + setpts
|
||||
# 为每个 clip 生成预处理后的标签 [v0], [v1], ...
|
||||
preprocessed_labels: list[str] = []
|
||||
for i, clip in enumerate(all_clips):
|
||||
@@ -1033,11 +1175,15 @@ class UnifiedRenderService:
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
# trim — 始终将输出截断到有效时长,防止 xfade offset 与实际时长不匹配
|
||||
# trim — 裁剪到指定区间,精确到帧
|
||||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
|
||||
if effective_duration > 0:
|
||||
filters.append(f"trim=duration={effective_duration}")
|
||||
if trim_start > 0:
|
||||
filters.append(f"trim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
else:
|
||||
filters.append(f"trim=duration={effective_duration:.3f}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# scale
|
||||
@@ -1138,6 +1284,66 @@ class UnifiedRenderService:
|
||||
filter_parts.append(f"[{final_video_label}][{overlay_label}]" f"overlay={x}:{y}[{combined_label}]")
|
||||
final_video_label = combined_label
|
||||
|
||||
# 叠加水印(在字幕之前)
|
||||
watermark_config = WatermarkConfig.from_dict((self.plan.config or {}).get("watermark"))
|
||||
if watermark_config is not None:
|
||||
wm_valid, wm_err = watermark_config.validate()
|
||||
if wm_valid:
|
||||
wm_label = "watermarked"
|
||||
if watermark_config.mode == "image":
|
||||
# 图片水印:检查图片是否存在
|
||||
wm_path = Path(watermark_config.image_path)
|
||||
if wm_path.exists():
|
||||
# 图片水印需要额外输入,放在 filter 开头
|
||||
wm_idx = len(all_clips) # 水印图是最后一个输入
|
||||
wm_scale = int(self.output_width * watermark_config.scale)
|
||||
|
||||
# 透明度
|
||||
wm_filters = f"scale={wm_scale}:-1"
|
||||
if watermark_config.opacity < 1.0:
|
||||
wm_filters += f",format=rgba,colorchannelmixer=aa={watermark_config.opacity}"
|
||||
|
||||
filter_parts.insert(0, f"[{wm_idx}:v]{wm_filters}[wm_scaled]")
|
||||
input_args.extend(["-i", str(wm_path)])
|
||||
|
||||
# 位置计算(水印高度用 scale 后的宽度近似)
|
||||
wm_h = wm_scale # 近似(正方形假设)
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
watermark_config.position,
|
||||
self.output_width,
|
||||
self.output_height,
|
||||
wm_scale,
|
||||
wm_h,
|
||||
watermark_config.margin_x,
|
||||
watermark_config.margin_y,
|
||||
)
|
||||
|
||||
# 滚动水印
|
||||
if watermark_config.scroll:
|
||||
x_expr = f"W-mod({watermark_config.scroll_speed}*t\\,W+w)"
|
||||
overlay = f"[{final_video_label}][wm_scaled]overlay=x={x_expr}:y={y}[{wm_label}]"
|
||||
else:
|
||||
overlay = f"[{final_video_label}][wm_scaled]overlay=x={x}:y={y}[{wm_label}]"
|
||||
|
||||
filter_parts.append(overlay)
|
||||
final_video_label = wm_label
|
||||
else:
|
||||
logger.warning("水印图片不存在,跳过水印: %s", wm_path)
|
||||
elif watermark_config.mode == "text":
|
||||
# 文字水印
|
||||
try:
|
||||
text_wm = WatermarkEngine.build_text_watermark_filter(
|
||||
f"[{final_video_label}]",
|
||||
f"[{wm_label}]",
|
||||
watermark_config,
|
||||
self.output_width,
|
||||
self.output_height,
|
||||
)
|
||||
filter_parts.append(text_wm)
|
||||
final_video_label = wm_label
|
||||
except Exception as e:
|
||||
logger.warning("文字水印构建失败,跳过: %s", e)
|
||||
|
||||
# 叠加字幕(如有)+ 最终像素格式
|
||||
if ass_path is not None:
|
||||
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", "\\:")
|
||||
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
"""水印引擎 — 基于 FFmpeg overlay 滤镜的水印叠加.
|
||||
|
||||
支持:
|
||||
- 图片水印(PNG/logo)
|
||||
- 文字水印(drawtext)
|
||||
- 9宫格位置 + 边距配置
|
||||
- 透明度/大小缩放
|
||||
- 滚动水印(跑马灯)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 9宫格位置枚举
|
||||
WATERMARK_POSITIONS = {
|
||||
"top_left": "左上",
|
||||
"top_center": "中上",
|
||||
"top_right": "右上",
|
||||
"center_left": "左中",
|
||||
"center": "中心",
|
||||
"center_right": "右中",
|
||||
"bottom_left": "左下",
|
||||
"bottom_center": "中下",
|
||||
"bottom_right": "右下",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatermarkConfig:
|
||||
"""水印配置.
|
||||
|
||||
mode: "image" 图片水印 | "text" 文字水印
|
||||
position: 9宫格位置
|
||||
opacity: 透明度 0.0-1.0
|
||||
scale: 缩放比例(图片水印),0.1-1.0
|
||||
margin: 边距(像素)
|
||||
scroll: 是否滚动(跑马灯)
|
||||
scroll_speed: 滚动速度(像素/秒)
|
||||
"""
|
||||
|
||||
mode: str = "text" # image | text
|
||||
position: str = "bottom_right"
|
||||
|
||||
# 图片水印
|
||||
image_path: str = "" # 本地图片路径
|
||||
scale: float = 0.2 # 相对输出宽度的比例
|
||||
opacity: float = 0.8 # 0.0-1.0
|
||||
|
||||
# 文字水印
|
||||
text: str = ""
|
||||
font_size: int = 24
|
||||
font_color: str = "white"
|
||||
font_path: str = "" # 字体文件路径
|
||||
|
||||
# 边距
|
||||
margin_x: int = 20
|
||||
margin_y: int = 20
|
||||
|
||||
# 滚动水印
|
||||
scroll: bool = False
|
||||
scroll_speed: int = 50 # 像素/秒
|
||||
|
||||
@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", "text")
|
||||
|
||||
# 图片模式需要 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", "bottom_right")
|
||||
if position not in WATERMARK_POSITIONS:
|
||||
position = "bottom_right"
|
||||
|
||||
return cls(
|
||||
mode=mode,
|
||||
position=position,
|
||||
image_path=str(data.get("image_path", data.get("image", "")) or ""),
|
||||
scale=float(data.get("scale", 0.2)),
|
||||
opacity=float(data.get("opacity", 0.8)),
|
||||
text=str(data.get("text", "") or ""),
|
||||
font_size=int(data.get("font_size", 24)),
|
||||
font_color=str(data.get("font_color", "white")),
|
||||
font_path=str(data.get("font_path", "") or ""),
|
||||
margin_x=int(data.get("margin_x", 20)),
|
||||
margin_y=int(data.get("margin_y", 20)),
|
||||
scroll=bool(data.get("scroll", False)),
|
||||
scroll_speed=int(data.get("scroll_speed", 50)),
|
||||
)
|
||||
|
||||
def validate(self) -> tuple[bool, str]:
|
||||
"""校验配置是否有效."""
|
||||
if self.position not in WATERMARK_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, ""
|
||||
|
||||
|
||||
class WatermarkEngine:
|
||||
"""水印引擎 — 生成 FFmpeg 水印滤镜."""
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def calc_scroll_x(position: str, output_width: int, wm_width: int, speed: int) -> str:
|
||||
"""生成滚动水印的 x 坐标表达式.
|
||||
|
||||
从右向左滚动(跑马灯效果)
|
||||
"""
|
||||
# x 从 W 到 -wm_width,整个宽度 + wm_width 的距离
|
||||
# 使用 overlay 的 enable 表达式
|
||||
# x = 'W - (t * speed)' → 不对,应该是持续滚动
|
||||
# 标准跑马灯:x = -w + (t * speed) % (W + w)
|
||||
# 但 FFmpeg overlay 支持表达式
|
||||
return f"mod({output_width}-mod({speed}*t\\,{output_width}+{wm_width})"
|
||||
|
||||
@staticmethod
|
||||
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 = WatermarkEngine.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)
|
||||
# 使用 overlay 表达式
|
||||
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}"
|
||||
|
||||
# 构建滤镜
|
||||
# 先缩放水印图
|
||||
wm_input_idx = 1 # 假设水印图是第二个输入(索引1
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
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 = WatermarkEngine.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}"
|
||||
Executable
+268
@@ -0,0 +1,268 @@
|
||||
"""裁剪引擎单元测试."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# 确保 apps/worker 在路径中
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "worker"))
|
||||
|
||||
from video_processing.trim_engine import (
|
||||
MIN_TRIM_DURATION,
|
||||
TrimConfig,
|
||||
TrimEngine,
|
||||
TrimSegment,
|
||||
extract_trim_from_clip_config,
|
||||
)
|
||||
|
||||
|
||||
class TestTrimConfig(unittest.TestCase):
|
||||
"""TrimConfig 单元测试."""
|
||||
|
||||
def test_from_dict_none(self):
|
||||
"""空字典返回 None(不裁剪)."""
|
||||
self.assertIsNone(TrimConfig.from_dict(None))
|
||||
self.assertIsNone(TrimConfig.from_dict({}))
|
||||
|
||||
def test_from_dict_with_start(self):
|
||||
"""只有 start_time."""
|
||||
cfg = TrimConfig.from_dict({"start_time": 5.0})
|
||||
self.assertIsNotNone(cfg)
|
||||
self.assertEqual(cfg.start_time, 5.0)
|
||||
self.assertEqual(cfg.end_time, 0.0)
|
||||
self.assertEqual(cfg.duration, 0.0)
|
||||
|
||||
def test_from_dict_with_duration(self):
|
||||
"""只有 duration."""
|
||||
cfg = TrimConfig.from_dict({"duration": 10.0})
|
||||
self.assertIsNotNone(cfg)
|
||||
self.assertEqual(cfg.start_time, 0.0)
|
||||
self.assertEqual(cfg.duration, 10.0)
|
||||
|
||||
def test_resolve_start_and_end(self):
|
||||
"""start + end 推导 duration."""
|
||||
cfg = TrimConfig(start_time=5.0, end_time=15.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 5.0)
|
||||
self.assertEqual(resolved.end_time, 15.0)
|
||||
self.assertAlmostEqual(resolved.duration, 10.0, places=3)
|
||||
self.assertTrue(resolved.is_valid)
|
||||
|
||||
def test_resolve_start_and_duration(self):
|
||||
"""start + duration 推导 end."""
|
||||
cfg = TrimConfig(start_time=5.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 5.0)
|
||||
self.assertAlmostEqual(resolved.end_time, 15.0, places=3)
|
||||
self.assertEqual(resolved.duration, 10.0)
|
||||
|
||||
def test_resolve_end_and_duration(self):
|
||||
"""end + duration 推导 start."""
|
||||
cfg = TrimConfig(end_time=20.0, duration=8.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertAlmostEqual(resolved.start_time, 12.0, places=3)
|
||||
self.assertEqual(resolved.end_time, 20.0)
|
||||
self.assertEqual(resolved.duration, 8.0)
|
||||
|
||||
def test_resolve_only_start(self):
|
||||
"""只有 start → 取到末尾."""
|
||||
cfg = TrimConfig(start_time=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 10.0)
|
||||
self.assertEqual(resolved.end_time, 30.0)
|
||||
self.assertAlmostEqual(resolved.duration, 20.0, places=3)
|
||||
|
||||
def test_resolve_only_duration(self):
|
||||
"""只有 duration → 从开头取."""
|
||||
cfg = TrimConfig(duration=15.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 0.0)
|
||||
self.assertAlmostEqual(resolved.end_time, 15.0, places=3)
|
||||
self.assertEqual(resolved.duration, 15.0)
|
||||
|
||||
def test_boundary_clamp_end(self):
|
||||
"""end 超出素材时长 → 钳制."""
|
||||
cfg = TrimConfig(start_time=5.0, duration=30.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=20.0)
|
||||
self.assertEqual(resolved.start_time, 5.0)
|
||||
self.assertEqual(resolved.end_time, 20.0)
|
||||
self.assertAlmostEqual(resolved.duration, 15.0, places=3)
|
||||
|
||||
def test_boundary_clamp_start_negative(self):
|
||||
"""start 为负 → 钳制到 0."""
|
||||
cfg = TrimConfig(start_time=-5.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertEqual(resolved.start_time, 0.0)
|
||||
self.assertAlmostEqual(resolved.end_time, 10.0, places=3)
|
||||
self.assertEqual(resolved.duration, 10.0)
|
||||
|
||||
def test_boundary_start_past_end(self):
|
||||
"""start 超过素材总时长 → 钳制到末尾最小片段."""
|
||||
cfg = TrimConfig(start_time=50.0, duration=5.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertTrue(resolved.start_time < 30.0)
|
||||
self.assertEqual(resolved.end_time, 30.0)
|
||||
self.assertTrue(resolved.duration >= MIN_TRIM_DURATION)
|
||||
|
||||
def test_invalid_end_before_start(self):
|
||||
"""end <= start → 无效."""
|
||||
cfg = TrimConfig(start_time=15.0, end_time=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
self.assertFalse(resolved.is_valid)
|
||||
|
||||
def test_zero_duration_invalid(self):
|
||||
"""duration 为 0 → 无效."""
|
||||
cfg = TrimConfig(start_time=5.0, duration=0.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
# 只有 start 没有 duration → 会被推导为取到末尾
|
||||
self.assertTrue(resolved.is_valid)
|
||||
self.assertEqual(resolved.end_time, 30.0)
|
||||
|
||||
def test_is_noop(self):
|
||||
"""is_noop 判断."""
|
||||
noop = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
self.assertTrue(noop.is_noop)
|
||||
|
||||
not_noop = TrimConfig(start_time=5.0, duration=10.0)
|
||||
self.assertFalse(not_noop.is_noop)
|
||||
|
||||
def test_zero_asset_duration(self):
|
||||
"""素材时长为 0 → 不裁剪."""
|
||||
cfg = TrimConfig(start_time=5.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=0.0)
|
||||
self.assertTrue(resolved.is_noop)
|
||||
|
||||
def test_all_three_params_use_start_duration(self):
|
||||
"""三个参数都给了 → 以 start + duration 为准."""
|
||||
cfg = TrimConfig(start_time=5.0, end_time=20.0, duration=8.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=30.0)
|
||||
# validate_and_resolve 中 start+end 优先于 start+duration
|
||||
# 因为先检查的是 start>0 and end>0
|
||||
self.assertAlmostEqual(resolved.duration, 15.0, places=3)
|
||||
|
||||
|
||||
class TestTrimEngine(unittest.TestCase):
|
||||
"""TrimEngine 单元测试."""
|
||||
|
||||
def test_build_video_trim_with_start_and_duration(self):
|
||||
"""视频裁剪:start + duration."""
|
||||
trim = TrimConfig(start_time=10.0, duration=5.0)
|
||||
result = TrimEngine.build_video_trim_filter("[0:v]", trim, "[v0]")
|
||||
self.assertIn("trim=start=10.000:duration=5.000", result)
|
||||
self.assertIn("setpts=PTS-STARTPTS", result)
|
||||
self.assertTrue(result.startswith("[0:v]"))
|
||||
self.assertTrue(result.endswith("[v0]"))
|
||||
|
||||
def test_build_video_trim_duration_only(self):
|
||||
"""视频裁剪:只有 duration."""
|
||||
trim = TrimConfig(start_time=0.0, duration=8.0)
|
||||
result = TrimEngine.build_video_trim_filter("[0:v]", trim, "[v0]")
|
||||
self.assertIn("trim=duration=8.000", result)
|
||||
self.assertNotIn("start=", result.split("setpts")[0])
|
||||
|
||||
def test_build_audio_trim_with_start(self):
|
||||
"""音频裁剪:start + duration."""
|
||||
trim = TrimConfig(start_time=3.0, duration=7.0)
|
||||
result = TrimEngine.build_audio_trim_filter("[0:a]", trim, "[a0]")
|
||||
self.assertIn("atrim=start=3.000:duration=7.000", result)
|
||||
self.assertIn("asetpts=PTS-STARTPTS", result)
|
||||
|
||||
def test_build_audio_trim_noop(self):
|
||||
"""音频裁剪:noop."""
|
||||
trim = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
result = TrimEngine.build_audio_trim_filter("[0:a]", trim, "[a0]")
|
||||
self.assertIn("asetpts=PTS-STARTPTS", result)
|
||||
self.assertNotIn("atrim=", result)
|
||||
|
||||
def test_resolve_segments(self):
|
||||
"""多段裁剪解析."""
|
||||
segments = [
|
||||
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=0.0, duration=5.0), order=0),
|
||||
TrimSegment(segment_id="s2", trim=TrimConfig(start_time=10.0, duration=5.0), order=1),
|
||||
TrimSegment(segment_id="s3", trim=TrimConfig(start_time=20.0, duration=5.0), order=2),
|
||||
]
|
||||
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
|
||||
self.assertEqual(len(resolved), 3)
|
||||
self.assertEqual(resolved[0].segment_id, "s1")
|
||||
self.assertEqual(resolved[0].trim.duration, 5.0)
|
||||
self.assertEqual(resolved[1].segment_id, "s2")
|
||||
self.assertEqual(resolved[1].trim.start_time, 10.0)
|
||||
self.assertEqual(resolved[2].trim.start_time, 20.0)
|
||||
|
||||
def test_resolve_segments_filter_invalid(self):
|
||||
"""多段裁剪:过滤无效段."""
|
||||
segments = [
|
||||
TrimSegment(segment_id="good", trim=TrimConfig(start_time=0.0, duration=5.0), order=0),
|
||||
TrimSegment(segment_id="bad", trim=TrimConfig(start_time=10.0, end_time=5.0), order=1), # end < start
|
||||
]
|
||||
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
|
||||
self.assertEqual(len(resolved), 1)
|
||||
self.assertEqual(resolved[0].segment_id, "good")
|
||||
|
||||
def test_resolve_segments_boundary_clamp(self):
|
||||
"""多段裁剪:边界钳制."""
|
||||
segments = [
|
||||
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=25.0, duration=10.0), order=0),
|
||||
]
|
||||
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
|
||||
self.assertEqual(len(resolved), 1)
|
||||
self.assertEqual(resolved[0].trim.end_time, 30.0)
|
||||
self.assertAlmostEqual(resolved[0].trim.duration, 5.0, places=3)
|
||||
|
||||
def test_parse_segments_from_list(self):
|
||||
"""从 config 解析多段配置."""
|
||||
config = {
|
||||
"trim_segments": [
|
||||
{"segment_id": "intro", "start_time": 0, "duration": 3, "order": 0},
|
||||
{"segment_id": "highlight", "start_time": 10, "duration": 5, "order": 1},
|
||||
{"segment_id": "outro", "start_time": 50, "duration": 3, "order": 2},
|
||||
]
|
||||
}
|
||||
segments = TrimEngine.parse_segments_from_config(config)
|
||||
self.assertEqual(len(segments), 3)
|
||||
self.assertEqual(segments[0].segment_id, "intro")
|
||||
self.assertEqual(segments[1].trim.start_time, 10.0)
|
||||
self.assertEqual(segments[2].trim.duration, 3.0)
|
||||
|
||||
def test_parse_segments_empty(self):
|
||||
"""无裁剪配置 → 空列表."""
|
||||
self.assertEqual(TrimEngine.parse_segments_from_config(None), [])
|
||||
self.assertEqual(TrimEngine.parse_segments_from_config({}), [])
|
||||
|
||||
def test_parse_single_trim_legacy(self):
|
||||
"""旧格式单段裁剪(trim_start/trim_duration)."""
|
||||
config = {"trim_start": 5.0, "trim_duration": 10.0}
|
||||
segments = TrimEngine.parse_segments_from_config(config)
|
||||
self.assertEqual(len(segments), 1)
|
||||
self.assertEqual(segments[0].trim.start_time, 5.0)
|
||||
self.assertEqual(segments[0].trim.duration, 10.0)
|
||||
|
||||
|
||||
class TestExtractTrimFromClipConfig(unittest.TestCase):
|
||||
"""extract_trim_from_clip_config 单元测试."""
|
||||
|
||||
def test_trim_subdict(self):
|
||||
"""trim 子字典."""
|
||||
config = {"trim": {"start_time": 5.0, "duration": 10.0}}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.start_time, 5.0)
|
||||
self.assertEqual(result.duration, 10.0)
|
||||
|
||||
def test_flat_fields(self):
|
||||
"""扁平字段(trim_start/trim_end/trim_duration)."""
|
||||
config = {"trim_start": 2.0, "trim_end": 8.0}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.start_time, 2.0)
|
||||
self.assertEqual(result.end_time, 8.0)
|
||||
|
||||
def test_no_trim(self):
|
||||
"""无裁剪配置."""
|
||||
self.assertIsNone(extract_trim_from_clip_config(None))
|
||||
self.assertIsNone(extract_trim_from_clip_config({}))
|
||||
self.assertIsNone(extract_trim_from_clip_config({"other": "value"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+344
@@ -0,0 +1,344 @@
|
||||
"""水印 + 片头片尾引擎单元测试."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "worker"))
|
||||
|
||||
from video_processing.intro_outro_engine import (
|
||||
IntroOutroConfig,
|
||||
IntroOutroEngine,
|
||||
)
|
||||
from video_processing.watermark_engine import (
|
||||
WATERMARK_POSITIONS,
|
||||
WatermarkConfig,
|
||||
WatermarkEngine,
|
||||
)
|
||||
|
||||
|
||||
class TestWatermarkConfig(unittest.TestCase):
|
||||
"""WatermarkConfig 单元测试."""
|
||||
|
||||
def test_from_dict_none_disabled(self):
|
||||
"""空配置或未启用 → None."""
|
||||
self.assertIsNone(WatermarkConfig.from_dict(None))
|
||||
self.assertIsNone(WatermarkConfig.from_dict({}))
|
||||
self.assertIsNone(WatermarkConfig.from_dict({"enabled": False}))
|
||||
|
||||
def test_from_dict_text_mode(self):
|
||||
"""文字水印模式."""
|
||||
cfg = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "hello world",
|
||||
"position": "top_left",
|
||||
})
|
||||
self.assertIsNotNone(cfg)
|
||||
self.assertEqual(cfg.mode, "text")
|
||||
self.assertEqual(cfg.text, "hello world")
|
||||
self.assertEqual(cfg.position, "top_left")
|
||||
|
||||
def test_from_dict_image_missing_path(self):
|
||||
"""图片水印缺路径 → None."""
|
||||
cfg = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
})
|
||||
self.assertIsNone(cfg)
|
||||
|
||||
def test_from_dict_text_missing_text(self):
|
||||
"""文字水印缺文字 → None."""
|
||||
cfg = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
})
|
||||
self.assertIsNone(cfg)
|
||||
|
||||
def test_validate_text_valid(self):
|
||||
"""文字水印合法配置."""
|
||||
cfg = WatermarkConfig(
|
||||
mode="text",
|
||||
text="test",
|
||||
position="bottom_right",
|
||||
)
|
||||
ok, err = cfg.validate()
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(err, "")
|
||||
|
||||
def test_validate_invalid_position(self):
|
||||
"""非法位置."""
|
||||
cfg = WatermarkConfig(mode="text", text="test", position="invalid")
|
||||
ok, err = cfg.validate()
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("不支持的位置", err)
|
||||
|
||||
def test_validate_opacity_out_of_range(self):
|
||||
"""透明度超范围."""
|
||||
cfg = WatermarkConfig(mode="text", text="test", opacity=1.5)
|
||||
ok, err = cfg.validate()
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_validate_image_missing_path(self):
|
||||
"""图片水印缺路径."""
|
||||
cfg = WatermarkConfig(mode="image")
|
||||
ok, err = cfg.validate()
|
||||
self.assertFalse(ok)
|
||||
|
||||
|
||||
class TestWatermarkEnginePosition(unittest.TestCase):
|
||||
"""水印位置计算单元测试."""
|
||||
|
||||
def setUp(self):
|
||||
self.out_w = 1920
|
||||
self.out_h = 1080
|
||||
self.wm_w = 200
|
||||
self.wm_h = 100
|
||||
self.mx = 20
|
||||
self.my = 20
|
||||
|
||||
def test_top_left(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"top_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, 20)
|
||||
self.assertEqual(y, 20)
|
||||
|
||||
def test_top_center(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"top_center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, (1920 - 200) // 2)
|
||||
self.assertEqual(y, 20)
|
||||
|
||||
def test_top_right(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"top_right", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, 1920 - 200 - 20)
|
||||
self.assertEqual(y, 20)
|
||||
|
||||
def test_center_left(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"center_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, 20)
|
||||
self.assertEqual(y, (1080 - 100) // 2)
|
||||
|
||||
def test_center(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, (1920 - 200) // 2)
|
||||
self.assertEqual(y, (1080 - 100) // 2)
|
||||
|
||||
def test_center_right(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"center_right", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, 1920 - 200 - 20)
|
||||
self.assertEqual(y, (1080 - 100) // 2)
|
||||
|
||||
def test_bottom_left(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"bottom_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, 20)
|
||||
self.assertEqual(y, 1080 - 100 - 20)
|
||||
|
||||
def test_bottom_center(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"bottom_center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, (1920 - 200) // 2)
|
||||
self.assertEqual(y, 1080 - 100 - 20)
|
||||
|
||||
def test_bottom_right(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"bottom_right", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, 1920 - 200 - 20)
|
||||
self.assertEqual(y, 1080 - 100 - 20)
|
||||
|
||||
def test_default_fallback(self):
|
||||
"""非法位置默认右下角."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"unknown", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
self.assertEqual(x, 1920 - 200 - 20)
|
||||
self.assertEqual(y, 1080 - 100 - 20)
|
||||
|
||||
def test_nine_positions_all_present(self):
|
||||
"""9宫格位置都有定义."""
|
||||
self.assertEqual(len(WATERMARK_POSITIONS), 9)
|
||||
|
||||
|
||||
class TestWatermarkEngineFilters(unittest.TestCase):
|
||||
"""水印滤镜构建单元测试."""
|
||||
|
||||
def test_text_watermark_filter(self):
|
||||
"""文字水印滤镜构建."""
|
||||
cfg = WatermarkConfig(
|
||||
mode="text",
|
||||
text="hello",
|
||||
position="top_left",
|
||||
font_size=24,
|
||||
font_color="white",
|
||||
opacity=0.8,
|
||||
margin_x=10,
|
||||
margin_y=10,
|
||||
)
|
||||
result = WatermarkEngine.build_text_watermark_filter(
|
||||
"[in]", "[out]", cfg, 1920, 1080
|
||||
)
|
||||
self.assertTrue(result.startswith("[in]drawtext="))
|
||||
self.assertIn("text='hello'", result)
|
||||
self.assertIn("fontsize=24", result)
|
||||
self.assertIn("fontcolor=white@0.8", result)
|
||||
self.assertTrue(result.endswith("[out]"))
|
||||
|
||||
def test_text_watermark_scroll(self):
|
||||
"""滚动文字水印."""
|
||||
cfg = WatermarkConfig(
|
||||
mode="text",
|
||||
text="scroll",
|
||||
position="bottom_left",
|
||||
scroll=True,
|
||||
scroll_speed=60,
|
||||
)
|
||||
result = WatermarkEngine.build_text_watermark_filter(
|
||||
"[in]", "[out]", cfg, 1920, 1080
|
||||
)
|
||||
self.assertIn("mod(60*t", result)
|
||||
|
||||
|
||||
class TestIntroOutroConfig(unittest.TestCase):
|
||||
"""IntroOutroConfig 单元测试."""
|
||||
|
||||
def test_from_dict_disabled(self):
|
||||
"""未启用 → 空配置."""
|
||||
cfg = IntroOutroConfig.from_dict(None)
|
||||
self.assertFalse(cfg.enabled)
|
||||
self.assertFalse(cfg.has_intro)
|
||||
self.assertFalse(cfg.has_outro)
|
||||
|
||||
def test_from_dict_intro_text(self):
|
||||
"""文字片头配置."""
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "text",
|
||||
"title": "欢迎观看",
|
||||
"subtitle": "精彩内容马上开始",
|
||||
"duration": 3.0,
|
||||
"background": "#1a1a2e",
|
||||
},
|
||||
})
|
||||
self.assertTrue(cfg.enabled)
|
||||
self.assertTrue(cfg.has_intro)
|
||||
self.assertFalse(cfg.has_outro)
|
||||
self.assertEqual(cfg.intro_type, "text")
|
||||
self.assertEqual(cfg.intro_title, "欢迎观看")
|
||||
self.assertEqual(cfg.intro_duration, 3.0)
|
||||
|
||||
def test_from_dict_outro_video(self):
|
||||
"""视频片尾配置."""
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"outro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/outro.mp4",
|
||||
"duration": 5.0,
|
||||
},
|
||||
})
|
||||
self.assertTrue(cfg.has_outro)
|
||||
self.assertEqual(cfg.outro_type, "video")
|
||||
self.assertEqual(cfg.outro_video_path, "/tmp/outro.mp4")
|
||||
|
||||
def test_validate_valid(self):
|
||||
"""合法配置."""
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_title="标题",
|
||||
intro_duration=3.0,
|
||||
outro_type="text",
|
||||
outro_title="片尾",
|
||||
outro_duration=3.0,
|
||||
)
|
||||
ok, err = cfg.validate()
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_validate_video_intro_missing_path(self):
|
||||
"""视频片头缺路径."""
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="video",
|
||||
intro_duration=3.0,
|
||||
)
|
||||
ok, err = cfg.validate()
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("video_path", err)
|
||||
|
||||
def test_validate_text_intro_missing_title(self):
|
||||
"""文字片头缺标题."""
|
||||
cfg = IntroOutroConfig(
|
||||
enabled=True,
|
||||
intro_type="text",
|
||||
intro_duration=3.0,
|
||||
)
|
||||
ok, err = cfg.validate()
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_has_intro_false_when_none(self):
|
||||
"""type=none 时 has_intro 为 False."""
|
||||
cfg = IntroOutroConfig(enabled=True, intro_type="none")
|
||||
self.assertFalse(cfg.has_intro)
|
||||
|
||||
def test_has_outro_follow_type(self):
|
||||
"""follow 类型也算有片尾."""
|
||||
cfg = IntroOutroConfig(enabled=True, outro_type="follow", outro_title="关注")
|
||||
self.assertTrue(cfg.has_outro)
|
||||
|
||||
|
||||
class TestIntroOutroEngineConcat(unittest.TestCase):
|
||||
"""片头片尾拼接单元测试."""
|
||||
|
||||
def test_concat_no_intro_outro(self):
|
||||
"""没有片头片尾 → 直接复制."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
main_video = Path(tmpdir) / "main.mp4"
|
||||
output = Path(tmpdir) / "output.mp4"
|
||||
# 创建空文件模拟
|
||||
main_video.write_bytes(b"fake video data")
|
||||
|
||||
result = IntroOutroEngine.concat_with_intro_outro(
|
||||
main_video, None, None, output
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(output.exists())
|
||||
self.assertEqual(main_video.read_bytes(), output.read_bytes())
|
||||
|
||||
def test_concat_intro_only_no_file(self):
|
||||
"""只有片头但文件不存在 → 直接复制主视频."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
main_video = Path(tmpdir) / "main.mp4"
|
||||
output = Path(tmpdir) / "output.mp4"
|
||||
main_video.write_bytes(b"fake data")
|
||||
|
||||
# intro 路径不存在
|
||||
intro = Path(tmpdir) / "nonexistent.mp4"
|
||||
|
||||
result = IntroOutroEngine.concat_with_intro_outro(
|
||||
main_video, intro, None, output
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user