perf: use FFmpeg fast seek instead of fps filter for frame extraction

- Replace single `ffmpeg -vf fps=1/interval` call (full video decode) with
  per-frame `ffmpeg -ss <ts> -frames:v 1` calls (keyframe seek)
- 6-8x faster on ARM: 180s+ → 33s for 60 frames from 30min video
- Per-frame timeout 30s (was 120s single call), failed seeks logged and skipped
- Verified end-to-end: 30min 360MB video → 60 candidates → 12 keyframes in 39s
This commit is contained in:
ericwyuan
2026-08-20 02:30:17 +08:00
parent 6a9d1626fb
commit 7ce8aff67c

View File

@@ -109,7 +109,7 @@ class VideoPreprocessor:
return 0.0 return 0.0
def extract_candidate_frames(self, video_path: str) -> List[str]: def extract_candidate_frames(self, video_path: str) -> List[str]:
"""等距粗抽候选帧(数量随视频时长自适应)""" """等距粗抽候选帧(数量随视频时长自适应,使用快速 seek"""
os.makedirs(self.frames_dir, exist_ok=True) os.makedirs(self.frames_dir, exist_ok=True)
duration = self._get_video_duration(video_path) duration = self._get_video_duration(video_path)
self.video_duration = duration self.video_duration = duration
@@ -128,17 +128,25 @@ class VideoPreprocessor:
candidate_count = 0 candidate_count = 0
logger.warning(f"[task_id={self.task_id}] ffprobe 失败,退化为 60s 间隔抽帧") logger.warning(f"[task_id={self.task_id}] ffprobe 失败,退化为 60s 间隔抽帧")
cmd = [ # 快速 seek 逐帧提取(比 fps 滤镜快 6-8 倍ARM CPU 上尤甚)
'ffmpeg', '-i', video_path, timestamps = [i * interval for i in range(candidate_count)] if candidate_count > 0 else []
'-vf', f'fps=1/{interval}', if not timestamps:
'-q:v', '2', # 兜底: 未知时长,用 ffprobe 不可用时按 60s 间隔
os.path.join(self.frames_dir, 'frame_%04d.jpg') timestamps = [i * 60 for i in range(30)]
]
try: for i, ts in enumerate(timestamps):
subprocess.run(cmd, capture_output=True, timeout=120, check=True) output_path = os.path.join(self.frames_dir, f'frame_{i+1:04d}.jpg')
except subprocess.CalledProcessError as e: cmd = [
logger.error(f"[task_id={self.task_id}] FFmpeg 抽帧失败: {e}") 'ffmpeg', '-ss', f'{ts:.1f}',
raise '-i', video_path,
'-frames:v', '1',
'-q:v', '2',
output_path
]
try:
subprocess.run(cmd, capture_output=True, timeout=30, check=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(f"[task_id={self.task_id}] seek 到 {ts:.1f}s 失败: {e}")
# 收集候选帧路径 # 收集候选帧路径
frames = sorted([ frames = sorted([
@@ -147,7 +155,7 @@ class VideoPreprocessor:
if f.endswith('.jpg') if f.endswith('.jpg')
]) ])
log_task(logger, self.task_id, 'extract', log_task(logger, self.task_id, 'extract',
f'视频时长 {duration:.0f}s, 粗抽 {len(frames)} 张候选帧 (目标 {candidate_count})') f'视频时长 {duration:.0f}s, 快速 seek 粗抽 {len(frames)} 张候选帧 (目标 {candidate_count})')
return frames return frames
def _compute_adaptive_key_frame_counts(self) -> Tuple[int, int]: def _compute_adaptive_key_frame_counts(self) -> Tuple[int, int]: