"""片头片尾引擎 — 视频包装与品牌标识. 支持: - 片头:视频片段 或 纯文字片头(背景色 + 标题 + 副标题) - 片尾:视频片段 或 关注引导片尾 - 自动与正片拼接(xfade 转场) - 时长可配置 """ from __future__ import annotations import logging import subprocess from pathlib import Path from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg from packages.domain.intro_outro_config import ( # noqa: F401 — 向后兼容 INTRO_OUTRO_TYPE_FOLLOW, INTRO_OUTRO_TYPE_NONE, INTRO_OUTRO_TYPE_TEXT, INTRO_OUTRO_TYPE_VIDEO, TRANSITION_FADE, IntroOutroConfig, ) logger = logging.getLogger(__name__) # ── 片头片尾引擎 ────────────────────────────────────────────────────────────── 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 # 转义文字 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 拼接. 只传了片头或片尾也可以,缺失的自动跳过。 """ # 收集所有片段 # 简单探测时长(用 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