fc6ff32376
核心改动: - 渲染完成后从已渲染视频抽帧作为封面,天然带标题(ASS字幕已烧录) - 删除3条旧封面路径:缩略图、候选帧、FFmpeg备用抽帧 - 封面URL持久化到GenerationTask.cover_url - 封面API直接从持久化URL读取
135 lines
4.2 KiB
Python
Executable File
135 lines
4.2 KiB
Python
Executable File
"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。
|
|
|
|
统一封面管道:视频渲染时标题已通过 ASS 字幕烧进视频,
|
|
渲染完成后直接从此视频抽帧,封面天然带标题,无需额外叠加逻辑。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def extract_first_frame(
|
|
video_path: str,
|
|
output_path: str | None = None,
|
|
*,
|
|
width: int = -1,
|
|
height: int = -1,
|
|
timeout: int = 30,
|
|
seek_ratio: float = 0.15,
|
|
min_seek_seconds: float = 1.0,
|
|
) -> str:
|
|
"""抽取视频封面帧(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
|
|
|
因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。
|
|
|
|
Args:
|
|
video_path: 视频文件路径
|
|
output_path: 输出图片路径,不传则用临时文件
|
|
width: 输出宽度(默认 -1,保持原始分辨率)
|
|
height: 输出高度(默认 -1,保持原始分辨率)
|
|
timeout: 超时时间(秒)
|
|
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
|
|
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
|
|
|
|
Returns:
|
|
生成的封面帧文件路径
|
|
|
|
Raises:
|
|
RuntimeError: ffmpeg 执行失败或输出文件为空
|
|
"""
|
|
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
|
|
|
_is_temp_output = False
|
|
if output_path is None:
|
|
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
|
tmp.close()
|
|
output_path = tmp.name
|
|
_is_temp_output = True
|
|
|
|
try:
|
|
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
|
|
try:
|
|
duration = probe_duration(video_path)
|
|
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
|
except Exception:
|
|
# probe 失败时 fallback 到第1秒
|
|
seek_time = min_seek_seconds
|
|
|
|
# 格式化为 HH:MM:SS.xx
|
|
seek_str = _format_seek_time(seek_time)
|
|
|
|
# 构建 scale filter:如果指定了宽高则缩放,否则保持原始分辨率
|
|
if width > 0 or height > 0:
|
|
w_str = str(width) if width > 0 else "-1"
|
|
h_str = str(height) if height > 0 else "-1"
|
|
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
|
else:
|
|
# 保持原始分辨率,只确保格式兼容
|
|
scale_filter = "format=yuvj420p"
|
|
|
|
# -ss 放在 -i 前面(input seeking,更快)
|
|
# -vframes 1 只取一帧
|
|
# -q:v 2 jpeg 高质量
|
|
cmd = [
|
|
FFMPEG_BIN,
|
|
"-y",
|
|
"-ss",
|
|
seek_str,
|
|
"-i",
|
|
video_path,
|
|
"-vframes",
|
|
"1",
|
|
"-vf",
|
|
scale_filter,
|
|
"-q:v",
|
|
"2",
|
|
output_path,
|
|
]
|
|
|
|
try:
|
|
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
|
except Exception:
|
|
# 失败时退回到第0帧兜底
|
|
cmd2 = [
|
|
FFMPEG_BIN,
|
|
"-y",
|
|
"-i",
|
|
video_path,
|
|
"-ss",
|
|
"00:00:00",
|
|
"-vframes",
|
|
"1",
|
|
"-vf",
|
|
scale_filter,
|
|
"-q:v",
|
|
"2",
|
|
output_path,
|
|
]
|
|
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
|
|
|
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
|
raise RuntimeError(f"Cover frame extraction failed: {output_path}")
|
|
|
|
return output_path
|
|
except Exception:
|
|
# 失败时清理自己创建的临时文件
|
|
if _is_temp_output and output_path:
|
|
try:
|
|
Path(output_path).unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _format_seek_time(seconds: float) -> str:
|
|
"""将秒数格式化为 HH:MM:SS.xx 格式。"""
|
|
h = int(seconds // 3600)
|
|
m = int((seconds % 3600) // 60)
|
|
s = seconds % 60
|
|
return f"{h:02d}:{m:02d}:{s:05.2f}"
|