Spaces:
Running
Running
| """Extract still frames from a video at given timestamps via ffmpeg.""" | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import subprocess | |
| from concurrent.futures import ThreadPoolExecutor | |
| from pathlib import Path | |
| DEFAULT_WORKERS = 6 | |
| def _workers() -> int: | |
| """Parallel ffmpeg processes. Each is CPU-hungry, so keep this modest.""" | |
| raw = (os.getenv("FFMPEG_WORKERS") or "").strip() | |
| return int(raw) if raw.isdigit() and int(raw) > 0 else DEFAULT_WORKERS | |
| def probe_duration_sec(video_path: Path | str) -> float: | |
| """Return media duration in seconds via ffprobe.""" | |
| video = Path(video_path).expanduser().resolve() | |
| if not video.is_file(): | |
| raise FileNotFoundError(f"Video not found: {video}") | |
| cmd = [ | |
| "ffprobe", | |
| "-v", | |
| "error", | |
| "-show_entries", | |
| "format=duration", | |
| "-of", | |
| "json", | |
| str(video), | |
| ] | |
| proc = subprocess.run(cmd, capture_output=True, text=True) | |
| if proc.returncode != 0: | |
| err = (proc.stderr or proc.stdout or "").strip()[-400:] | |
| raise RuntimeError(f"ffprobe failed: {err}") | |
| data = json.loads(proc.stdout or "{}") | |
| duration = float((data.get("format") or {}).get("duration") or 0) | |
| if duration <= 0: | |
| raise RuntimeError(f"Could not read duration for {video}") | |
| return duration | |
| def clamp_timestamp(ts: float, duration_sec: float, *, margin: float = 0.15) -> float: | |
| """Keep seek inside [0, duration - margin] so ffmpeg can decode a frame.""" | |
| if duration_sec <= 0: | |
| return max(0.0, float(ts)) | |
| end = max(0.0, duration_sec - margin) | |
| return min(max(0.0, float(ts)), end) | |
| def extract_frames( | |
| video_path: Path | str, | |
| timestamps_sec: list[float], | |
| out_dir: Path | str, | |
| *, | |
| prefix: str = "frame", | |
| duration_sec: float | None = None, | |
| ) -> list[Path]: | |
| """Write one JPEG per timestamp into out_dir. Returns paths in order.""" | |
| video = Path(video_path).expanduser().resolve() | |
| if not video.is_file(): | |
| raise FileNotFoundError(f"Video not found: {video}") | |
| dest = Path(out_dir) | |
| dest.mkdir(parents=True, exist_ok=True) | |
| duration = duration_sec if duration_sec is not None else probe_duration_sec(video) | |
| def grab(item: tuple[int, float]) -> Path: | |
| i, ts = item | |
| t = clamp_timestamp(ts, duration) | |
| out = dest / f"{prefix}_{i:02d}.jpg" | |
| tail = ["-frames:v", "1", "-q:v", "2", "-an", str(out)] | |
| # Input seek (-ss before -i) jumps straight to the nearest keyframe instead of | |
| # decoding from t=0; falls back to output seek, which is reliable past the last one. | |
| for cmd in ( | |
| ["ffmpeg", "-nostdin", "-y", "-ss", f"{t:.3f}", "-i", str(video), *tail], | |
| ["ffmpeg", "-nostdin", "-y", "-i", str(video), "-ss", f"{t:.3f}", *tail], | |
| ): | |
| proc = subprocess.run(cmd, capture_output=True, text=True) | |
| if proc.returncode == 0 and out.is_file() and out.stat().st_size > 0: | |
| return out | |
| err = (proc.stderr or proc.stdout or "").strip()[-800:] | |
| raise RuntimeError(f"ffmpeg failed at t={t:.3f}s (duration={duration:.3f}s): {err}") | |
| items = list(enumerate(timestamps_sec)) | |
| if len(items) < 2: | |
| return [grab(it) for it in items] | |
| with ThreadPoolExecutor(max_workers=min(_workers(), len(items))) as pool: | |
| return list(pool.map(grab, items)) # ordered; first exception propagates | |