diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index 175a1f9a1..5429587cd 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -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: diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index a3e001e01..b123db26a 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -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)