修复ffmpeg的视频封面生成临时文件BUG

This commit is contained in:
2026-06-05 11:15:07 +08:00
parent 2ba50f6a7a
commit 6b8d016a8b
+145 -12
View File
@@ -16,13 +16,32 @@ class VideoCoverError(RuntimeError):
def _clean_cover_format(value: str | None) -> str: def _clean_cover_format(value: str | None) -> str:
"""
清理封面格式。
只建议使用:
- jpg / jpeg
- png
- webp
默认 jpg,避免配置为空导致路径异常。
"""
ext = (value or "jpg").strip().lower().lstrip(".") ext = (value or "jpg").strip().lower().lstrip(".")
return ext or "jpg" 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: def _is_valid_file(path: str | None) -> bool:
if not path: if not path:
return False return False
try: try:
return os.path.isfile(path) and os.path.getsize(path) > 0 return os.path.isfile(path) and os.path.getsize(path) > 0
except OSError: except OSError:
@@ -32,6 +51,7 @@ def _is_valid_file(path: str | None) -> bool:
def _safe_remove(path: str | None) -> None: def _safe_remove(path: str | None) -> None:
if not path: if not path:
return return
try: try:
if os.path.exists(path): if os.path.exists(path):
os.remove(path) os.remove(path)
@@ -40,7 +60,28 @@ def _safe_remove(path: str | None) -> None:
def _make_part_path(final_path: str) -> str: def _make_part_path(final_path: str) -> str:
return f"{final_path}.{uuid.uuid4().hex}.part" """
生成原子写入临时文件路径。
重点:
- 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: def get_ffmpeg_bin() -> str:
@@ -58,6 +99,12 @@ def get_ffmpeg_bin() -> str:
if ffmpeg_path.exists(): if ffmpeg_path.exists():
return str(ffmpeg_path) 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") found = shutil.which("ffmpeg")
if found: if found:
return found return found
@@ -69,6 +116,26 @@ def get_ffmpeg_bin() -> str:
raise VideoCoverError("未找到 ffmpeg,请安装 ffmpeg 或配置环境变量 FFMPEG_BIN") 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( def generate_video_cover(
video_path: str, video_path: str,
output_path: str, output_path: str,
@@ -84,7 +151,8 @@ def generate_video_cover(
- Windows / Linux 都可用。 - Windows / Linux 都可用。
- 不使用 shell=True,避免路径空格、命令注入问题。 - 不使用 shell=True,避免路径空格、命令注入问题。
- 使用 -nostdin + timeout,避免 ffmpeg 卡死 worker。 - 使用 -nostdin + timeout,避免 ffmpeg 卡死 worker。
- output_path 建议使用 .jpg 或 .webp - 使用 -f image2 明确告诉 ffmpeg 输出单张图片
- output_path 的最后后缀必须是 .jpg / .jpeg / .png / .webp。
""" """
video_file = Path(video_path) video_file = Path(video_path)
output_file = Path(output_path) output_file = Path(output_path)
@@ -92,10 +160,26 @@ def generate_video_cover(
if not video_file.exists(): if not video_file.exists():
raise VideoCoverError(f"视频文件不存在: {video_file}") 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) output_file.parent.mkdir(parents=True, exist_ok=True)
ffmpeg_bin = get_ffmpeg_bin() ffmpeg_bin = get_ffmpeg_bin()
scale_filter = f"scale={width}:-2"
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 = [ cmd = [
ffmpeg_bin, ffmpeg_bin,
@@ -104,16 +188,28 @@ def generate_video_cover(
"-hide_banner", "-hide_banner",
"-loglevel", "-loglevel",
"error", "error",
# 放在 -i 前,速度更快。
"-ss", "-ss",
seek_time, seek_time,
"-i", "-i",
str(video_file), str(video_file),
# 只截一帧。
"-frames:v", "-frames:v",
"1", "1",
# 等比缩放,宽度固定,高度自动修正为偶数。
"-vf", "-vf",
scale_filter, scale_filter,
"-q:v",
"3", # 明确输出单张图片,避免 ffmpeg 对临时文件格式判断异常。
"-f",
"image2",
*quality_args,
str(output_file), str(output_file),
] ]
@@ -149,10 +245,28 @@ def generate_video_cover_atomically(
width: int = 720, width: int = 720,
timeout: int = 15, timeout: int = 15,
) -> str: ) -> str:
"""
原子方式生成视频封面。
流程:
1. 如果最终封面已存在且有效,直接返回。
2. 先生成到临时文件。
3. 临时文件有效后 os.replace 到最终路径。
4. 任意异常清理临时文件。
关键:
临时文件最后后缀必须保留图片格式,例如:
- 正确:xxx.uuid.part.png
- 错误:xxx.png.uuid.part
"""
if _is_valid_file(output_path): if _is_valid_file(output_path):
return 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) part_path = _make_part_path(output_path)
try: try:
generate_video_cover( generate_video_cover(
video_path=video_path, video_path=video_path,
@@ -161,10 +275,17 @@ def generate_video_cover_atomically(
width=width, width=width,
timeout=timeout, timeout=timeout,
) )
if not _is_valid_file(part_path): if not _is_valid_file(part_path):
raise VideoCoverError(f"封面临时文件为空: {part_path}") raise VideoCoverError(f"封面临时文件为空: {part_path}")
os.replace(part_path, output_path) os.replace(part_path, output_path)
if not _is_valid_file(output_path):
raise VideoCoverError(f"封面最终文件为空: {output_path}")
return output_path return output_path
except Exception: except Exception:
_safe_remove(part_path) _safe_remove(part_path)
raise raise
@@ -178,10 +299,12 @@ def build_video_cover_path_and_url(record_id: str, date_dir: str) -> tuple[str,
- 这里不做签名,接口返回时统一通过 build_resource_signed_url 处理。 - 这里不做签名,接口返回时统一通过 build_resource_signed_url 处理。
- URL 固定使用 /generate/covers,便于 OpenResty/Nginx 统一做资源验签。 - URL 固定使用 /generate/covers,便于 OpenResty/Nginx 统一做资源验签。
""" """
ext = _clean_cover_format(settings.VIDEO_COVER_FORMAT) ext = _clean_cover_format(getattr(settings, "VIDEO_COVER_FORMAT", "jpg"))
cover_dir = os.path.join(settings.STORAGE_VIDEO_COVER_LOCAL_PATH, date_dir) 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_path = os.path.join(cover_dir, f"{record_id}.{ext}")
cover_url = f"/generate/covers/{date_dir}/{record_id}.{ext}" cover_url = f"/generate/covers/{date_dir}/{record_id}.{ext}"
return cover_path, cover_url return cover_path, cover_url
@@ -203,10 +326,14 @@ def try_generate_video_cover(
- 主 seek_time 失败后,使用 fallback_seek_time 再尝试一次。 - 主 seek_time 失败后,使用 fallback_seek_time 再尝试一次。
- 最终失败返回 None,不影响视频任务 completed。 - 最终失败返回 None,不影响视频任务 completed。
""" """
first_seek = seek_time or settings.VIDEO_COVER_SEEK_TIME first_seek = seek_time or getattr(settings, "VIDEO_COVER_SEEK_TIME", "00:00:01")
second_seek = fallback_seek_time or settings.VIDEO_COVER_FALLBACK_SEEK_TIME second_seek = fallback_seek_time or getattr(
cover_width = width or settings.VIDEO_COVER_WIDTH settings,
cover_timeout = timeout or settings.VIDEO_COVER_TIMEOUT_SECONDS "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: try:
return generate_video_cover_atomically( return generate_video_cover_atomically(
@@ -264,13 +391,16 @@ def create_video_cover_for_local_video(
- cover_path: 成功时为本地文件路径,失败为 None - cover_path: 成功时为本地文件路径,失败为 None
""" """
cover_path, cover_url = build_video_cover_path_and_url(record_id, date_dir) cover_path, cover_url = build_video_cover_path_and_url(record_id, date_dir)
generated_path = try_generate_video_cover( generated_path = try_generate_video_cover(
video_path=video_path, video_path=video_path,
output_path=cover_path, output_path=cover_path,
log_prefix=log_prefix, log_prefix=log_prefix,
) )
if not generated_path: if not generated_path:
return None, None return None, None
return cover_url, generated_path return cover_url, generated_path
@@ -281,10 +411,13 @@ async def async_create_video_cover_for_local_video(
date_dir: str, date_dir: str,
log_prefix: str = "视频封面生成", log_prefix: str = "视频封面生成",
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
"""
异步封装,避免在 FastAPI event loop 中直接执行 ffmpeg 阻塞。
"""
return await asyncio.to_thread( return await asyncio.to_thread(
create_video_cover_for_local_video, create_video_cover_for_local_video,
record_id=record_id, record_id=record_id,
video_path=video_path, video_path=video_path,
date_dir=date_dir, date_dir=date_dir,
log_prefix=log_prefix, log_prefix=log_prefix,
) )