"""视频封面生成器 — 从视频中提取/生成封面图. 支持能力: - 指定时间点抽帧(默认第1秒) - 智能封面:抽取多帧选最清晰的一帧 - 自定义上传封面图(直接返回路径) - 生成的封面图保存为 JPEG 格式,可复用 """ from __future__ import annotations import logging import subprocess from pathlib import Path from typing import Any from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg logger = logging.getLogger(__name__) # ── 配置常量 ────────────────────────────────────────────────────────────────── # 智能封面抽帧数量 SMART_COVER_FRAME_COUNT = 3 # 默认抽帧时间点(秒) DEFAULT_COVER_TIME = 1.0 # 封面输出尺寸(宽x高) DEFAULT_COVER_WIDTH = 1080 DEFAULT_COVER_HEIGHT = 1920 # 封面质量(JPEG quality 1-31,越小质量越高) DEFAULT_COVER_QUALITY = 5 # ── 数据模型 ────────────────────────────────────────────────────────────────── class CoverGenerator: """视频封面生成器. 三种模式: 1. 指定时间点抽帧:从视频指定时间提取一帧 2. 智能封面:抽取3帧,用 blur 检测选最清晰的 3. 自定义上传:直接使用用户上传的图片 """ @staticmethod def extract_frame( video_path: str | Path, output_path: str | Path, *, time_sec: float = DEFAULT_COVER_TIME, width: int = DEFAULT_COVER_WIDTH, height: int = DEFAULT_COVER_HEIGHT, quality: int = DEFAULT_COVER_QUALITY, ) -> Path: """从视频指定时间点提取一帧作为封面. Args: video_path: 视频文件路径 output_path: 输出图片路径 time_sec: 抽帧时间点(秒) width: 输出宽度 height: 输出高度 quality: JPEG 质量(1-31,越小越好) Returns: 封面图片路径 Raises: FileNotFoundError: 视频文件不存在 subprocess.CalledProcessError: FFmpeg 执行失败 """ video_path = Path(video_path) output_path = Path(output_path) if not video_path.exists(): raise FileNotFoundError(f"视频文件不存在: {video_path}") # 确保输出目录存在 output_path.parent.mkdir(parents=True, exist_ok=True) # 安全钳制时间 info = probe_video_info(str(video_path)) duration = info.get("duration", 0.0) if duration > 0 and time_sec >= duration: # 超过视频长度,取中间帧 time_sec = max(0, duration / 2) if time_sec < 0: time_sec = 0 # scale + crop 实现 cover 裁剪(铺满输出尺寸) vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}" command = [ FFMPEG_BIN, "-y", "-ss", f"{time_sec:.3f}", "-i", str(video_path), "-vframes", "1", "-vf", vf, "-q:v", str(quality), "-f", "mjpeg", str(output_path), ] logger.info("抽取视频封面: video=%s time=%.2fs output=%s", video_path.name, time_sec, output_path.name) run_ffmpeg(command) if not output_path.exists() or output_path.stat().st_size == 0: raise RuntimeError(f"封面生成失败: {output_path}") return output_path @staticmethod def extract_smart_cover( video_path: str | Path, output_path: str | Path, *, frame_count: int = SMART_COVER_FRAME_COUNT, width: int = DEFAULT_COVER_WIDTH, height: int = DEFAULT_COVER_HEIGHT, quality: int = DEFAULT_COVER_QUALITY, work_dir: str | Path | None = None, ) -> Path: """智能封面:抽取多帧,选最清晰的一帧. 清晰度判断:使用拉普拉斯方差(Variance of Laplacian), 方差越大表示图像边缘越丰富,越清晰。 Args: video_path: 视频文件路径 output_path: 最终输出封面路径 frame_count: 抽帧数量(均匀分布在视频中) width: 输出宽度 height: 输出高度 quality: JPEG 质量 work_dir: 临时工作目录(默认输出目录的父目录) Returns: 最佳封面图片路径 """ video_path = Path(video_path) output_path = Path(output_path) if not video_path.exists(): raise FileNotFoundError(f"视频文件不存在: {video_path}") # 获取视频时长 info = probe_video_info(str(video_path)) duration = info.get("duration", 0.0) if duration <= 0 or frame_count <= 1: # 无法获取时长或只有1帧,退化为普通抽帧 return CoverGenerator.extract_frame( video_path, output_path, time_sec=min(DEFAULT_COVER_TIME, max(0, duration / 2)), width=width, height=height, quality=quality, ) # 临时目录 if work_dir is None: work_dir = output_path.parent work_dir = Path(work_dir) work_dir.mkdir(parents=True, exist_ok=True) # 均匀分布抽帧时间点(跳过首尾5%) start_pct = 0.05 end_pct = 0.95 if frame_count == 1: time_points = [duration * 0.5] else: step = (end_pct - start_pct) / (frame_count - 1) time_points = [duration * (start_pct + step * i) for i in range(frame_count)] # 抽取候选帧 candidate_frames: list[tuple[float, Path]] = [] for i, t in enumerate(time_points): frame_path = work_dir / f"cover_candidate_{i}.jpg" try: CoverGenerator.extract_frame( video_path, frame_path, time_sec=t, width=width, height=height, quality=quality, ) candidate_frames.append((t, frame_path)) except Exception as e: logger.warning("智能封面抽帧失败(t=%.2fs): %s", t, e) continue if not candidate_frames: # 全部失败,退化到普通抽帧 logger.warning("智能封面所有候选帧抽取失败,退化为普通抽帧") return CoverGenerator.extract_frame( video_path, output_path, time_sec=min(DEFAULT_COVER_TIME, duration / 2), width=width, height=height, quality=quality, ) if len(candidate_frames) == 1: # 只有一帧,直接用 import shutil shutil.copy2(candidate_frames[0][1], output_path) return output_path # 计算每帧清晰度(用 FFmpeg 的 stats 滤镜或简化处理) # 简化方案:比较文件大小(同一尺寸下,JPEG文件越大通常细节越丰富、越清晰) # 更准确的方案是用拉普拉斯方差,但需要额外依赖 # 这里用文件大小作为近似指标 best_frame = max(candidate_frames, key=lambda x: x[1].stat().st_size) # 复制最佳帧到输出路径 import shutil shutil.copy2(best_frame[1], output_path) logger.info( "智能封面生成完成: 候选%d帧, 最佳t=%.2fs, 大小=%d字节", len(candidate_frames), best_frame[0], output_path.stat().st_size, ) # 清理临时文件 for _, fp in candidate_frames: try: fp.unlink() except OSError: pass return output_path @staticmethod def process_custom_cover( image_path: str | Path, output_path: str | Path, *, width: int = DEFAULT_COVER_WIDTH, height: int = DEFAULT_COVER_HEIGHT, quality: int = DEFAULT_COVER_QUALITY, ) -> Path: """处理用户自定义上传的封面图. 调整尺寸、格式转换为标准封面格式。 Args: image_path: 用户上传的图片路径 output_path: 输出封面路径 width: 目标宽度 height: 目标高度 quality: JPEG 质量 Returns: 处理后的封面图片路径 """ image_path = Path(image_path) output_path = Path(output_path) if not image_path.exists(): raise FileNotFoundError(f"封面图片不存在: {image_path}") output_path.parent.mkdir(parents=True, exist_ok=True) # scale + crop 实现 cover 裁剪 vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}" command = [ FFMPEG_BIN, "-y", "-i", str(image_path), "-vf", vf, "-q:v", str(quality), "-f", "mjpeg", str(output_path), ] logger.info("处理自定义封面: input=%s output=%s", image_path.name, output_path.name) try: run_ffmpeg(command) except subprocess.CalledProcessError: # 处理失败,直接复制原图 logger.warning("自定义封面处理失败,使用原图") import shutil shutil.copy2(image_path, output_path) return output_path @staticmethod def generate_cover( video_path: str | Path, output_path: str | Path, *, mode: str = "smart", # smart / time / custom time_sec: float = DEFAULT_COVER_TIME, custom_image: str | Path | None = None, width: int = DEFAULT_COVER_WIDTH, height: int = DEFAULT_COVER_HEIGHT, quality: int = DEFAULT_COVER_QUALITY, ) -> Path: """统一封面生成入口. Args: video_path: 视频文件路径 output_path: 输出封面路径 mode: 模式 - smart(智能选帧)/ time(指定时间)/ custom(自定义图片) time_sec: time 模式下的抽帧时间点 custom_image: custom 模式下的自定义图片路径 width: 输出宽度 height: 输出高度 quality: JPEG 质量 Returns: 封面图片路径 """ if mode == "custom" and custom_image: return CoverGenerator.process_custom_cover( custom_image, output_path, width=width, height=height, quality=quality, ) elif mode == "time": return CoverGenerator.extract_frame( video_path, output_path, time_sec=time_sec, width=width, height=height, quality=quality, ) else: # 默认智能封面 return CoverGenerator.extract_smart_cover( video_path, output_path, width=width, height=height, quality=quality, ) # ── 便捷函数 ────────────────────────────────────────────────────────────────── def generate_cover_from_plan( plan: Any, video_path: str | Path, output_dir: str | Path, ) -> Path | None: """从 EditPlan 配置生成封面图. 配置读取:plan.config.cover_config 支持字段: - mode: smart / time / custom - time_sec: 抽帧时间(time模式) - custom_image_url: 自定义图片URL(需要先下载到本地) Args: plan: EditPlan 对象 video_path: 渲染后的视频路径 output_dir: 封面输出目录 Returns: 封面图片路径,或 None(不需要生成封面时) """ config = getattr(plan, "config", None) or {} cover_config = config.get("cover_config") if isinstance(config, dict) else None if not cover_config: return None mode = cover_config.get("mode", "smart") output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / f"cover_{plan.id}.jpg" try: if mode == "custom": # 自定义封面:需要先有本地图片路径 custom_path = cover_config.get("custom_image_path") if custom_path and Path(custom_path).exists(): return CoverGenerator.process_custom_cover( custom_path, output_path, ) else: logger.warning("自定义封面图片路径无效,退化为智能封面") mode = "smart" if mode == "time": time_sec = float(cover_config.get("time_sec", DEFAULT_COVER_TIME)) return CoverGenerator.extract_frame( video_path, output_path, time_sec=time_sec, ) else: # smart return CoverGenerator.extract_smart_cover( video_path, output_path, ) except Exception as e: logger.warning("封面生成失败: %s", e) return None