423 lines
11 KiB
Python
423 lines
11 KiB
Python
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,
|
||
) |