mmap-worker / app /workers /video /frames.py
jugalgajjar's picture
feat: video ingest (cv2 + ffmpeg)
38ef752
Raw
History Blame Contribute Delete
3.5 kB
"""Adaptive frame sampling for video ingest. Worker-only (uses OpenCV)."""
import logging
log = logging.getLogger("mmap.video.frames")
def probe_video_duration(path: str) -> float:
"""Read just the container metadata (no frame decode) and return the
duration in seconds. Returns 0.0 when fps/frame-count are unreadable β€”
callers should treat 0.0 as "unknown", not "empty"."""
import cv2 # type: ignore[import-not-found]
video = cv2.VideoCapture(path)
if not video.isOpened():
raise RuntimeError(f"could not open video at {path!r}")
try:
fps = float(video.get(cv2.CAP_PROP_FPS) or 0.0)
total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
if fps <= 0 or total_frames <= 0:
return 0.0
return total_frames / fps
finally:
video.release()
def extract_adaptive_frames(
path: str,
*,
max_frame_budget: int = 30,
target_width: int = 720,
jpeg_quality: int = 85,
) -> list[bytes]:
"""Sample frames from `path` with a duration-aware cadence, downscale to
`target_width`, and JPEG-encode. Returns a list of frame bytes. Assumes
duration ≀ 300s β€” callers enforce the cap upstream."""
# Lazy import: cv2 (opencv-python-headless) lives in the worker image only.
import cv2 # type: ignore[import-not-found]
video = cv2.VideoCapture(path)
if not video.isOpened():
raise RuntimeError(f"could not open video at {path!r}")
try:
fps = float(video.get(cv2.CAP_PROP_FPS) or 0.0)
total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
if fps <= 0 or total_frames <= 0:
log.info("video frames: empty stream (fps=%.2f frames=%d)", fps, total_frames)
return []
duration = total_frames / fps
# Adaptive bucket sampling tuned to keep token cost bounded while
# preserving temporal resolution on short clips. The ≀300s tier is
# the terminal branch β€” duration is bounded upstream by the
# `video_max_duration_sec` cap.
if duration <= 60:
step = max(1, int(fps))
elif duration <= 120:
step = max(1, int(fps * 2))
else:
step = max(1, int(fps * 5))
indices = list(range(0, total_frames, step))
# Hard ceiling β€” downsample uniformly if the cadence still overshoots.
if len(indices) > max_frame_budget:
stride = len(indices) / max_frame_budget
indices = [indices[int(i * stride)] for i in range(max_frame_budget)]
log.info(
"video frames: duration=%.1fs fps=%.1f step=%d sampled=%d",
duration,
fps,
step,
len(indices),
)
out: list[bytes] = []
encode_params = [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality]
for idx in indices:
video.set(cv2.CAP_PROP_POS_FRAMES, idx)
ok, frame = video.read()
if not ok:
continue
h, w = frame.shape[:2]
if w > target_width:
scale = target_width / w
frame = cv2.resize(
frame,
(target_width, int(h * scale)),
interpolation=cv2.INTER_AREA,
)
ok, buf = cv2.imencode(".jpg", frame, encode_params)
if ok:
out.append(bytes(buf))
return out
finally:
video.release()