diff --git a/apps/worker/video_processing/intro_outro_engine.py b/apps/worker/video_processing/intro_outro_engine.py new file mode 100755 index 000000000..d9d9b434c --- /dev/null +++ b/apps/worker/video_processing/intro_outro_engine.py @@ -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 diff --git a/apps/worker/video_processing/render_audio.py b/apps/worker/video_processing/render_audio.py old mode 100644 new mode 100755 index 286b404fd..1c3082232 --- a/apps/worker/video_processing/render_audio.py +++ b/apps/worker/video_processing/render_audio.py @@ -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}") diff --git a/apps/worker/video_processing/trim_engine.py b/apps/worker/video_processing/trim_engine.py new file mode 100755 index 000000000..ada3f373a --- /dev/null +++ b/apps/worker/video_processing/trim_engine.py @@ -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) diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index a02c5a626..9b8b4b483 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -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(":", "\\:") diff --git a/apps/worker/video_processing/watermark_engine.py b/apps/worker/video_processing/watermark_engine.py new file mode 100755 index 000000000..cc9c802d3 --- /dev/null +++ b/apps/worker/video_processing/watermark_engine.py @@ -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}" diff --git a/tests/unit/test_trim_engine.py b/tests/unit/test_trim_engine.py new file mode 100755 index 000000000..d6b7a8eb8 --- /dev/null +++ b/tests/unit/test_trim_engine.py @@ -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() diff --git a/tests/unit/test_watermark_intro_outro.py b/tests/unit/test_watermark_intro_outro.py new file mode 100755 index 000000000..05b2d0c1d --- /dev/null +++ b/tests/unit/test_watermark_intro_outro.py @@ -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()