import asyncio import logging import os import shutil import subprocess import uuid from pathlib import Path from app.config import settings logger = logging.getLogger("videogen") class VideoCoverError(RuntimeError): pass def _clean_cover_format(value: str | None) -> str: """ 清理封面格式。 只建议使用: - jpg / jpeg - png - webp 默认 jpg,避免配置为空导致路径异常。 """ ext = (value or "jpg").strip().lower().lstrip(".") if not ext: return "jpg" allowed_exts = {"jpg", "jpeg", "png", "webp"} if ext not in allowed_exts: logger.warning("不支持的视频封面格式: %s,已回退为 jpg", ext) return "jpg" return ext def _is_valid_file(path: str | None) -> bool: if not path: return False try: return os.path.isfile(path) and os.path.getsize(path) > 0 except OSError: return False def _safe_remove(path: str | None) -> None: if not path: return try: if os.path.exists(path): os.remove(path) except OSError: pass def _make_part_path(final_path: str) -> str: """ 生成原子写入临时文件路径。 重点: - ffmpeg 会根据输出文件最后的扩展名判断格式。 - 不能生成 xxx.png.uuid.part,因为最后扩展名是 .part。 - 必须生成 xxx.uuid.part.png,让最后扩展名仍然是 .png / .jpg / .webp。 示例: final_path: storage/generate/covers/2026/06/05/0019.png part_path: storage/generate/covers/2026/06/05/0019.abcd1234.part.png """ path = Path(final_path) suffix = path.suffix if not suffix: suffix = ".jpg" return str(path.with_name(f"{path.stem}.{uuid.uuid4().hex}.part{suffix}")) def get_ffmpeg_bin() -> str: """ 获取 ffmpeg 可执行文件路径。 优先级: 1. 环境变量 FFMPEG_BIN 2. settings.FFMPEG_BIN 3. 系统 PATH 中的 ffmpeg / ffmpeg.exe """ env_bin = settings.FFMPEG_BIN if env_bin: ffmpeg_path = Path(env_bin) if ffmpeg_path.exists(): return str(ffmpeg_path) setting_bin = getattr(settings, "FFMPEG_BIN", None) if setting_bin: ffmpeg_path = Path(setting_bin) if ffmpeg_path.exists(): return str(ffmpeg_path) found = shutil.which("ffmpeg") if found: return found found_exe = shutil.which("ffmpeg.exe") if found_exe: return found_exe raise VideoCoverError("未找到 ffmpeg,请安装 ffmpeg 或配置环境变量 FFMPEG_BIN") def _build_quality_args(output_path: str) -> list[str]: """ 根据封面格式生成对应质量参数。 说明: - jpg/jpeg:使用 -q:v,数值越小质量越高,3 比较适中。 - png:不使用 -q:v,避免无效参数造成干扰。 - webp:使用 -q:v 80,体积和质量比较均衡。 """ ext = Path(output_path).suffix.lower().lstrip(".") if ext in {"jpg", "jpeg"}: return ["-q:v", "3"] if ext == "webp": return ["-q:v", "80"] return [] def generate_video_cover( video_path: str, output_path: str, seek_time: str = "00:00:01", width: int = 720, timeout: int = 15, ) -> str: """ 从视频中截取封面图。 说明: - 默认截第 1 秒,避免首帧黑屏。 - Windows / Linux 都可用。 - 不使用 shell=True,避免路径空格、命令注入问题。 - 使用 -nostdin + timeout,避免 ffmpeg 卡死 worker。 - 使用 -f image2 明确告诉 ffmpeg 输出单张图片。 - output_path 的最后后缀必须是 .jpg / .jpeg / .png / .webp。 """ video_file = Path(video_path) output_file = Path(output_path) if not video_file.exists(): raise VideoCoverError(f"视频文件不存在: {video_file}") if not video_file.is_file(): raise VideoCoverError(f"视频路径不是文件: {video_file}") output_ext = output_file.suffix.lower().lstrip(".") if output_ext not in {"jpg", "jpeg", "png", "webp"}: raise VideoCoverError( f"封面输出文件扩展名不支持: {output_file}," "请使用 .jpg / .jpeg / .png / .webp" ) output_file.parent.mkdir(parents=True, exist_ok=True) ffmpeg_bin = get_ffmpeg_bin() safe_width = int(width or 720) if safe_width <= 0: safe_width = 720 scale_filter = f"scale={safe_width}:-2" quality_args = _build_quality_args(str(output_file)) cmd = [ ffmpeg_bin, "-y", "-nostdin", "-hide_banner", "-loglevel", "error", # 放在 -i 前,速度更快。 "-ss", seek_time, "-i", str(video_file), # 只截一帧。 "-frames:v", "1", # 等比缩放,宽度固定,高度自动修正为偶数。 "-vf", scale_filter, # 明确输出单张图片,避免 ffmpeg 对临时文件格式判断异常。 "-f", "image2", *quality_args, str(output_file), ] try: result = subprocess.run( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=timeout, ) except subprocess.TimeoutExpired as exc: raise VideoCoverError(f"ffmpeg 截图超时: {video_file}") from exc except OSError as exc: raise VideoCoverError(f"ffmpeg 执行失败: {exc}") from exc if result.returncode != 0: err = (result.stderr or "").strip() raise VideoCoverError(f"ffmpeg 截图失败: {err[-1000:]}") if not output_file.exists() or output_file.stat().st_size <= 0: raise VideoCoverError(f"封面生成失败,输出文件为空: {output_file}") return str(output_file) def generate_video_cover_atomically( video_path: str, output_path: str, seek_time: str = "00:00:01", width: int = 720, timeout: int = 15, ) -> str: """ 原子方式生成视频封面。 流程: 1. 如果最终封面已存在且有效,直接返回。 2. 先生成到临时文件。 3. 临时文件有效后 os.replace 到最终路径。 4. 任意异常清理临时文件。 关键: 临时文件最后后缀必须保留图片格式,例如: - 正确:xxx.uuid.part.png - 错误:xxx.png.uuid.part """ if _is_valid_file(output_path): return output_path output_file = Path(output_path) output_file.parent.mkdir(parents=True, exist_ok=True) part_path = _make_part_path(output_path) try: generate_video_cover( video_path=video_path, output_path=part_path, seek_time=seek_time, width=width, timeout=timeout, ) if not _is_valid_file(part_path): raise VideoCoverError(f"封面临时文件为空: {part_path}") os.replace(part_path, output_path) if not _is_valid_file(output_path): raise VideoCoverError(f"封面最终文件为空: {output_path}") return output_path except Exception: _safe_remove(part_path) raise def build_video_cover_path_and_url(record_id: str, date_dir: str) -> tuple[str, str]: """ 根据记录ID和日期目录生成本地封面文件路径与对外URL。 注意: - 这里不做签名,接口返回时统一通过 build_resource_signed_url 处理。 - URL 固定使用 /generate/covers,便于 OpenResty/Nginx 统一做资源验签。 """ ext = _clean_cover_format(getattr(settings, "VIDEO_COVER_FORMAT", "jpg")) cover_dir = os.path.join(settings.STORAGE_VIDEO_COVER_LOCAL_PATH, date_dir) cover_path = os.path.join(cover_dir, f"{record_id}.{ext}") cover_url = f"/generate/covers/{date_dir}/{record_id}.{ext}" return cover_path, cover_url def try_generate_video_cover( *, video_path: str, output_path: str, seek_time: str | None = None, fallback_seek_time: str | None = None, width: int | None = None, timeout: int | None = None, log_prefix: str = "视频封面生成", ) -> str | None: """ best-effort 封面生成。 规则: - 任何异常都捕获并记录日志。 - 主 seek_time 失败后,使用 fallback_seek_time 再尝试一次。 - 最终失败返回 None,不影响视频任务 completed。 """ first_seek = seek_time or getattr(settings, "VIDEO_COVER_SEEK_TIME", "00:00:01") second_seek = fallback_seek_time or getattr( settings, "VIDEO_COVER_FALLBACK_SEEK_TIME", "00:00:03", ) cover_width = width or getattr(settings, "VIDEO_COVER_WIDTH", 720) cover_timeout = timeout or getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15) try: return generate_video_cover_atomically( video_path=video_path, output_path=output_path, seek_time=first_seek, width=cover_width, timeout=cover_timeout, ) except Exception as first_exc: if second_seek and second_seek != first_seek: try: return generate_video_cover_atomically( video_path=video_path, output_path=output_path, seek_time=second_seek, width=cover_width, timeout=cover_timeout, ) except Exception as second_exc: logger.warning( "%s失败,已忽略,不影响视频生成成功。video_path=%s, output_path=%s, first_error=%s, fallback_error=%s", log_prefix, video_path, output_path, first_exc, second_exc, exc_info=True, ) return None logger.warning( "%s失败,已忽略,不影响视频生成成功。video_path=%s, output_path=%s, error=%s", log_prefix, video_path, output_path, first_exc, exc_info=True, ) return None def create_video_cover_for_local_video( *, record_id: str, video_path: str, date_dir: str, log_prefix: str = "视频封面生成", ) -> tuple[str | None, str | None]: """ 给本地视频生成封面。 返回: - cover_url: 成功时为 /generate/covers/...,失败为 None - cover_path: 成功时为本地文件路径,失败为 None """ cover_path, cover_url = build_video_cover_path_and_url(record_id, date_dir) generated_path = try_generate_video_cover( video_path=video_path, output_path=cover_path, log_prefix=log_prefix, ) if not generated_path: return None, None return cover_url, generated_path async def async_create_video_cover_for_local_video( *, record_id: str, video_path: str, date_dir: str, log_prefix: str = "视频封面生成", ) -> tuple[str | None, str | None]: """ 异步封装,避免在 FastAPI event loop 中直接执行 ffmpeg 阻塞。 """ return await asyncio.to_thread( create_video_cover_for_local_video, record_id=record_id, video_path=video_path, date_dir=date_dir, log_prefix=log_prefix, )