Files
video-gen/video-gen-api/app/services/video_cover_service.py
T

423 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)