This commit is contained in:
2026-03-23 14:42:36 -03:00
parent 71fd0510de
commit 5ed876d694
17 changed files with 767 additions and 137 deletions

View File

View File

@@ -0,0 +1,102 @@
"""
Stage 1 — Frame Extraction
Extracts frames from a video at a configurable FPS using FFmpeg.
Emits log + stats_update SSE events as it works.
"""
from __future__ import annotations
import subprocess
import tempfile
from pathlib import Path
import numpy as np
from PIL import Image
from core.ffmpeg.probe import probe_file
from detect.events import push_detect_event
from detect.models import Frame
from detect.profiles.base import FrameExtractionConfig
def extract_frames(
video_path: str,
config: FrameExtractionConfig,
job_id: str | None = None,
) -> list[Frame]:
"""
Extract frames from video at the configured FPS.
Uses FFmpeg to decode frames as raw images, then loads them
as numpy arrays. Caps at config.max_frames.
"""
probe = probe_file(video_path)
duration = probe.duration or 0.0
if job_id:
push_detect_event(job_id, "log", {
"level": "INFO",
"stage": "FrameExtractor",
"msg": f"Starting extraction: {Path(video_path).name} "
f"({duration:.1f}s, {probe.width}x{probe.height}, fps={config.fps})",
})
frames: list[Frame] = []
with tempfile.TemporaryDirectory() as tmpdir:
pattern = str(Path(tmpdir) / "frame_%06d.jpg")
cmd = [
"ffmpeg", "-i", video_path,
"-vf", f"fps={config.fps}",
"-q:v", "2",
"-frames:v", str(config.max_frames),
pattern,
"-y", "-loglevel", "warning",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
if job_id:
push_detect_event(job_id, "log", {
"level": "ERROR",
"stage": "FrameExtractor",
"msg": f"FFmpeg failed: {result.stderr[:200]}",
})
raise RuntimeError(f"FFmpeg failed: {result.stderr}")
frame_files = sorted(Path(tmpdir).glob("frame_*.jpg"))
for i, fpath in enumerate(frame_files):
img = Image.open(fpath)
arr = np.array(img)
timestamp = i / config.fps
frames.append(Frame(
sequence=i,
chunk_id=0,
timestamp=timestamp,
image=arr,
))
if job_id:
push_detect_event(job_id, "log", {
"level": "INFO",
"stage": "FrameExtractor",
"msg": f"Extracted {len(frames)} frames",
})
push_detect_event(job_id, "stats_update", {
"frames_extracted": len(frames),
"frames_after_scene_filter": 0,
"regions_detected": 0,
"regions_resolved_by_ocr": 0,
"regions_escalated_to_local_vlm": 0,
"regions_escalated_to_cloud_llm": 0,
"cloud_llm_calls": 0,
"processing_time_seconds": 0.0,
"estimated_cloud_cost_usd": 0.0,
})
return frames