"""用 ffmpeg 在每条 events 记录的 start_offset_sec 处抽一帧,生成缩略图。 供网页事件列表展示预览图,点击缩略图再跳转播放对应视频的完整时间点。 失败重试上限:单个事件连续失败超过 MAX_ATTEMPTS 次后,在 extra_json 里标记 thumbnail_failed,后续运行直接跳过,避免对损坏文件无限重试。 """ import json import subprocess from analysis.config import PROJECT_ROOT, THUMBNAILS_DIR from analysis.db import get_events, get_video_by_id, update_event_thumbnail, update_event_extra MAX_ATTEMPTS = 3 def _extract_frame(video_path, offset_sec, output_path): # -ss 放在 -i 之后用精确seek:放在 -i 之前的快速seek在临近视频结尾处 # 会因缺少解码所需的参考帧而失败(HEVC "Could not find ref with POC")。 # 录像多为几秒到几十秒的短片,精确seek的性能代价可忽略。 result = subprocess.run( [ "ffmpeg", "-i", str(video_path), "-ss", str(offset_sec), "-frames:v", "1", "-q:v", "3", "-y", str(output_path), ], capture_output=True, timeout=30, ) return result.returncode == 0 and output_path.exists() def _get_attempts(event): try: extra = json.loads(event["extra_json"] or "{}") return extra.get("thumbnail_attempts", 0) except (json.JSONDecodeError, TypeError): return 0 def _increment_attempts(event): try: extra = json.loads(event["extra_json"] or "{}") except (json.JSONDecodeError, TypeError): extra = {} extra["thumbnail_attempts"] = extra.get("thumbnail_attempts", 0) + 1 if extra["thumbnail_attempts"] >= MAX_ATTEMPTS: extra["thumbnail_failed"] = True update_event_extra(event["id"], json.dumps(extra, ensure_ascii=False)) def process_all(limit=None): THUMBNAILS_DIR.mkdir(exist_ok=True) events = [] for e in get_events(): if e["thumbnail_path"]: continue try: extra = json.loads(e["extra_json"] or "{}") if extra.get("thumbnail_failed"): continue except (json.JSONDecodeError, TypeError): pass events.append(e) if limit: events = events[:limit] video_cache = {} processed = 0 failed = 0 for event in events: video_id = event["video_id"] if video_id not in video_cache: video_cache[video_id] = get_video_by_id(video_id) video = video_cache[video_id] if video is None: _increment_attempts(event) failed += 1 continue video_path = PROJECT_ROOT / video["file_path"] if not video_path.exists(): _increment_attempts(event) failed += 1 continue output_path = THUMBNAILS_DIR / f"{event['id']}.jpg" offset_sec = event["start_offset_sec"] or 0.0 if _extract_frame(video_path, offset_sec, output_path): update_event_thumbnail(event["id"], str(output_path.relative_to(PROJECT_ROOT))) processed += 1 else: _increment_attempts(event) failed += 1 return {"processed": processed, "failed": failed} if __name__ == "__main__": print("===== 开始生成缩略图 =====") result = process_all() print(f"→ 生成 {result['processed']} 张缩略图,失败 {result['failed']} 条") print("===== 生成完成 =====")