"""封面管理服务. 提供封面配置管理和从视频抽帧生成封面的能力。 抽帧使用 FFmpeg,上传使用共享存储服务。 """ from __future__ import annotations import logging import tempfile from pathlib import Path from typing import Any, Dict logger = logging.getLogger(__name__) # ── 常量 ────────────────────────────────────────────────────────────────────── DEFAULT_COVER_WIDTH = 1080 DEFAULT_COVER_HEIGHT = 1920 DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好) COVER_STORAGE_PREFIX = "covers" class CoverService: """封面管理服务.""" def __init__(self, storage_service: Any, asset_repository: Any) -> None: self._storage = storage_service self._asset_repo = asset_repository # ── 配置读写 ────────────────────────────────────────────────────────── @staticmethod def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]: """从 plan.config 中提取封面配置. Args: plan_config: 剪辑计划的 config 字段 Returns: 封面配置 dict """ cover = plan_config.get("cover", {}) if not isinstance(cover, dict): cover = {} # 确保默认字段存在 return { "type": cover.get("type", "ai_frame"), "image_url": cover.get("image_url", ""), "frame_time": cover.get("frame_time"), } # ── 抽帧生成封面 ────────────────────────────────────────────────────── def extract_cover_from_clip( self, plan_id: str, asset_id: str, frame_time: float = 1.0, *, width: int = DEFAULT_COVER_WIDTH, height: int = DEFAULT_COVER_HEIGHT, quality: int = DEFAULT_COVER_QUALITY, ) -> Dict[str, Any]: """从指定素材的指定时间点抽取一帧作为封面. Args: plan_id: 剪辑计划 ID(用于生成存储路径) asset_id: 素材 ID frame_time: 抽帧时间点(秒) width: 输出宽度 height: 输出高度 quality: JPEG 质量 Returns: 封面数据 dict,包含 type / image_url / frame_time Raises: ValueError: 素材不存在或不是视频 RuntimeError: 抽帧或上传失败 """ # 1. 获取素材 asset = self._asset_repo.get(asset_id) if self._asset_repo else None if not asset: raise ValueError(f"素材不存在: {asset_id}") storage_key = getattr(asset, "storage_key", "") if not storage_key: raise ValueError(f"素材没有文件: {asset_id}") mime_type = getattr(asset, "mime_type", "") if mime_type and not mime_type.startswith("video"): raise ValueError(f"素材不是视频类型: {mime_type}") # 2. 下载视频到临时目录 with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir: tmp_path = Path(tmp_dir) video_path = tmp_path / f"source_{asset_id[:8]}" logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id) try: self._storage.download_file(storage_key, str(video_path)) except Exception as e: raise RuntimeError(f"下载素材失败: {e}") from e if not video_path.exists() or video_path.stat().st_size == 0: raise RuntimeError("下载的素材文件为空") # 3. FFmpeg 抽帧 output_path = tmp_path / "cover.jpg" self._extract_frame( video_path=video_path, output_path=output_path, time_sec=frame_time, width=width, height=height, quality=quality, ) if not output_path.exists() or output_path.stat().st_size == 0: raise RuntimeError("封面抽帧失败") # 4. 上传到 OSS cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg" logger.info("上传封面到存储: key=%s", cover_key) try: self._storage.upload_file( file_or_path=str(output_path), storage_key=cover_key, content_type="image/jpeg", ) except Exception as e: raise RuntimeError(f"上传封面失败: {e}") from e # 5. 获取访问 URL try: image_url = self._storage.get_url(cover_key) except Exception: image_url = cover_key # 降级为 storage_key logger.info( "封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d", plan_id, asset_id, frame_time, output_path.stat().st_size if output_path.exists() else 0, ) return { "type": "manual", "image_url": image_url, "frame_time": frame_time, } def generate_smart_cover( self, plan_id: str, asset_id: str, *, width: int = DEFAULT_COVER_WIDTH, height: int = DEFAULT_COVER_HEIGHT, quality: int = DEFAULT_COVER_QUALITY, ) -> Dict[str, Any]: """智能选帧:从视频中选取多帧,选最清晰的一帧. Args: plan_id: 剪辑计划 ID asset_id: 素材 ID width: 输出宽度 height: 输出高度 quality: JPEG 质量 Returns: 封面数据 dict """ # 简单实现:取视频 1/3 处的帧作为智能封面 # 更复杂的多帧选清晰帧可以后续优化 frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算 result = self.extract_cover_from_clip( plan_id=plan_id, asset_id=asset_id, frame_time=frame_time, width=width, height=height, quality=quality, ) result["type"] = "ai_frame" return result # ── 内部方法 ────────────────────────────────────────────────────────── @staticmethod def _extract_frame( video_path: Path, output_path: Path, *, time_sec: float, width: int, height: int, quality: int, ) -> None: """使用 FFmpeg 从视频中抽取一帧. Args: video_path: 视频文件路径 output_path: 输出图片路径 time_sec: 抽帧时间点(秒) width: 输出宽度 height: 输出高度 quality: JPEG 质量 """ import subprocess # scale + crop 实现 cover 裁剪 vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}" command = [ "ffmpeg", "-y", "-ss", f"{time_sec:.3f}", "-i", str(video_path), "-vframes", "1", "-vf", vf, "-q:v", str(quality), "-f", "mjpeg", str(output_path), ] logger.debug("FFmpeg 抽帧命令: %s", " ".join(command)) try: result = subprocess.run( command, capture_output=True, text=True, timeout=60, ) if result.returncode != 0: logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:]) # 尝试不使用 scale+crop 的简化命令 simple_command = [ "ffmpeg", "-y", "-ss", f"{time_sec:.3f}", "-i", str(video_path), "-vframes", "1", "-q:v", str(quality), "-f", "mjpeg", str(output_path), ] result2 = subprocess.run( simple_command, capture_output=True, text=True, timeout=60, ) if result2.returncode != 0: raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}") except subprocess.TimeoutExpired as e: raise RuntimeError("FFmpeg 抽帧超时") from e except FileNotFoundError as e: raise RuntimeError("FFmpeg 不可用") from e