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:
@@ -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 间隔抽帧")
|
||||||
|
|
||||||
|
# 快速 seek 逐帧提取(比 fps 滤镜快 6-8 倍,ARM CPU 上尤甚)
|
||||||
|
timestamps = [i * interval for i in range(candidate_count)] if candidate_count > 0 else []
|
||||||
|
if not timestamps:
|
||||||
|
# 兜底: 未知时长,用 ffprobe 不可用时按 60s 间隔
|
||||||
|
timestamps = [i * 60 for i in range(30)]
|
||||||
|
|
||||||
|
for i, ts in enumerate(timestamps):
|
||||||
|
output_path = os.path.join(self.frames_dir, f'frame_{i+1:04d}.jpg')
|
||||||
cmd = [
|
cmd = [
|
||||||
'ffmpeg', '-i', video_path,
|
'ffmpeg', '-ss', f'{ts:.1f}',
|
||||||
'-vf', f'fps=1/{interval}',
|
'-i', video_path,
|
||||||
|
'-frames:v', '1',
|
||||||
'-q:v', '2',
|
'-q:v', '2',
|
||||||
os.path.join(self.frames_dir, 'frame_%04d.jpg')
|
output_path
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
|
subprocess.run(cmd, capture_output=True, timeout=30, check=True)
|
||||||
except subprocess.CalledProcessError as e:
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||||
logger.error(f"[task_id={self.task_id}] FFmpeg 抽帧失败: {e}")
|
logger.warning(f"[task_id={self.task_id}] seek 到 {ts:.1f}s 失败: {e}")
|
||||||
raise
|
|
||||||
|
|
||||||
# 收集候选帧路径
|
# 收集候选帧路径
|
||||||
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]:
|
||||||
|
|||||||
Reference in New Issue
Block a user