fix(P0): 素材库视频上传时生成缩略图 #588

Merged
auto-approve-bot merged 3 commits from fix/video-thumbnail-ingest into develop 2026-07-19 15:54:47 +08:00
2 changed files with 75 additions and 41 deletions
@@ -38,53 +38,36 @@ def extract_first_frame(
"""
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
# 计算抽帧时间点:取视频时长 * 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)
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
# -vframes 1 只取一帧
# -q:v 2 jpeg 高质量
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
cmd = [
FFMPEG_BIN,
"-y",
"-ss",
seek_str,
"-i",
video_path,
"-vframes",
"1",
"-vf",
scale_filter,
"-q:v",
"2",
output_path,
]
_is_temp_output = True
try:
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
except Exception:
# 失败时退回到第0帧兜底
cmd2 = [
# 计算抽帧时间点:取视频时长 * 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)
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
# -vframes 1 只取一帧
# -q:v 2 jpeg 高质量
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
cmd = [
FFMPEG_BIN,
"-y",
"-ss",
seek_str,
"-i",
video_path,
"-ss",
"00:00:00",
"-vframes",
"1",
"-vf",
@@ -93,12 +76,40 @@ def extract_first_frame(
"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"Thumbnail generation failed: {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)
return output_path
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
raise RuntimeError(f"Thumbnail generation 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:
+23
View File
@@ -280,6 +280,7 @@ def ingest_asset(job_id: str) -> dict:
# 先从 OSS 下载文件到本地临时目录,再提取元数据
# storage_key 是 OSS 内部路径,不能直接传给 ffprobe/Pillow
local_file = None
thumbnail_url = None
try:
suffix = Path(job.storage_key).suffix or ".bin"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
@@ -291,6 +292,27 @@ def ingest_asset(job_id: str) -> dict:
metadata, extract_success = {}, False
else:
metadata, extract_success = extract_media_metadata(str(local_file), media_type)
# 视频类型:生成缩略图(文件还在的时候生成)
thumbnail_url = None
if media_type == "video" and extract_success:
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
if thumbnail_url:
logger.info(
"素材缩略图生成成功: job_id=%s url=%s",
job_id,
thumbnail_url[:80],
)
except Exception as thumb_err:
logger.warning(
"素材缩略图生成失败(不影响主流程): job_id=%s error=%s",
job_id,
thumb_err,
)
finally:
if local_file and local_file.exists():
try:
@@ -359,6 +381,7 @@ def ingest_asset(job_id: str) -> dict:
codec=metadata.get("codec") or None,
status=AssetStatus.READY,
file_hash=job.file_hash,
thumbnail_url=thumbnail_url,
)
asset_repo.create(asset)