2026-04-24 14:40:34 +10:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Probe video metadata and extract frames at an auto-scaled fps.
|
|
|
|
|
|
|
|
|
|
Auto-fps targets a frame budget, not a fixed rate. Token cost scales with frame
|
|
|
|
|
count, so budget-by-duration keeps short videos dense and long videos capped.
|
|
|
|
|
When a user-specified range is passed, focused-mode budgets denser (they are
|
|
|
|
|
zooming in for detail).
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MAX_FPS = 2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clamp_fps(fps: float, duration_seconds: float, max_frames: int) -> tuple[float, int]:
|
|
|
|
|
fps = min(fps, MAX_FPS)
|
|
|
|
|
target = min(max_frames, max(1, int(round(fps * duration_seconds))))
|
|
|
|
|
return fps, target
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_time(value: str | float | int | None) -> float | None:
|
|
|
|
|
"""Parse SS, MM:SS, or HH:MM:SS (with optional .ms) into seconds."""
|
|
|
|
|
if value is None:
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(value, (int, float)):
|
|
|
|
|
return float(value)
|
|
|
|
|
s = str(value).strip()
|
|
|
|
|
if not s:
|
|
|
|
|
return None
|
|
|
|
|
parts = s.split(":")
|
|
|
|
|
try:
|
|
|
|
|
if len(parts) == 1:
|
|
|
|
|
return float(parts[0])
|
|
|
|
|
if len(parts) == 2:
|
|
|
|
|
return int(parts[0]) * 60 + float(parts[1])
|
|
|
|
|
if len(parts) == 3:
|
|
|
|
|
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
raise SystemExit(f"Cannot parse time value: {value!r} (expected SS, MM:SS, or HH:MM:SS)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_time(seconds: float) -> str:
|
|
|
|
|
total = int(round(seconds))
|
|
|
|
|
hours, rem = divmod(total, 3600)
|
|
|
|
|
minutes, sec = divmod(rem, 60)
|
|
|
|
|
if hours:
|
|
|
|
|
return f"{hours}:{minutes:02d}:{sec:02d}"
|
|
|
|
|
return f"{minutes:02d}:{sec:02d}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_metadata(video_path: str) -> dict:
|
|
|
|
|
if shutil.which("ffprobe") is None:
|
|
|
|
|
raise SystemExit("ffprobe is not installed. Install with: brew install ffmpeg")
|
|
|
|
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
"ffprobe",
|
|
|
|
|
"-v", "quiet",
|
|
|
|
|
"-print_format", "json",
|
|
|
|
|
"-show_format",
|
|
|
|
|
"-show_streams",
|
2026-05-09 08:13:32 +10:00
|
|
|
str(Path(video_path).resolve()),
|
2026-04-24 14:40:34 +10:00
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
raise SystemExit(f"ffprobe failed: {result.stderr.strip()}")
|
|
|
|
|
|
|
|
|
|
data = json.loads(result.stdout or "{}")
|
|
|
|
|
streams = data.get("streams", [])
|
|
|
|
|
fmt = data.get("format", {})
|
|
|
|
|
video_stream = next((s for s in streams if s.get("codec_type") == "video"), {})
|
|
|
|
|
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
|
|
|
|
|
|
|
|
duration = float(fmt.get("duration") or video_stream.get("duration") or 0)
|
|
|
|
|
return {
|
|
|
|
|
"duration_seconds": duration,
|
|
|
|
|
"width": video_stream.get("width"),
|
|
|
|
|
"height": video_stream.get("height"),
|
|
|
|
|
"codec": video_stream.get("codec_name"),
|
|
|
|
|
"size_bytes": int(fmt.get("size") or 0),
|
|
|
|
|
"has_audio": audio_stream is not None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def auto_fps(duration_seconds: float, max_frames: int = 100) -> tuple[float, int]:
|
|
|
|
|
"""Pick fps that targets a sensible frame budget for full-video scans."""
|
|
|
|
|
if duration_seconds <= 0:
|
|
|
|
|
return 1.0, 1
|
|
|
|
|
|
|
|
|
|
if duration_seconds <= 30:
|
|
|
|
|
target = min(max_frames, max(12, int(round(duration_seconds))))
|
|
|
|
|
elif duration_seconds <= 60:
|
|
|
|
|
target = min(max_frames, 40)
|
|
|
|
|
elif duration_seconds <= 180: # 3 min
|
|
|
|
|
target = min(max_frames, 60)
|
|
|
|
|
elif duration_seconds <= 600: # 10 min
|
|
|
|
|
target = min(max_frames, 80)
|
|
|
|
|
else:
|
|
|
|
|
target = max_frames
|
|
|
|
|
|
|
|
|
|
return _clamp_fps(target / duration_seconds, duration_seconds, max_frames)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def auto_fps_focus(duration_seconds: float, max_frames: int = 100) -> tuple[float, int]:
|
|
|
|
|
"""Denser budget for user-specified ranges — they are zooming in for detail."""
|
|
|
|
|
if duration_seconds <= 0:
|
|
|
|
|
return min(MAX_FPS, 2.0), 2
|
|
|
|
|
|
|
|
|
|
if duration_seconds <= 5:
|
|
|
|
|
target = min(max_frames, max(10, int(round(duration_seconds * 6))))
|
|
|
|
|
elif duration_seconds <= 15:
|
|
|
|
|
target = min(max_frames, max(30, int(round(duration_seconds * 4))))
|
|
|
|
|
elif duration_seconds <= 30:
|
|
|
|
|
target = min(max_frames, 60)
|
|
|
|
|
elif duration_seconds <= 60:
|
|
|
|
|
target = min(max_frames, 80)
|
|
|
|
|
elif duration_seconds <= 180:
|
|
|
|
|
target = max_frames
|
|
|
|
|
else:
|
|
|
|
|
target = max_frames
|
|
|
|
|
|
|
|
|
|
return _clamp_fps(target / duration_seconds, duration_seconds, max_frames)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract(
|
|
|
|
|
video_path: str,
|
|
|
|
|
out_dir: Path,
|
|
|
|
|
fps: float,
|
|
|
|
|
resolution: int = 512,
|
|
|
|
|
max_frames: int = 100,
|
|
|
|
|
start_seconds: float | None = None,
|
|
|
|
|
end_seconds: float | None = None,
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
if shutil.which("ffmpeg") is None:
|
|
|
|
|
raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
|
|
|
|
|
|
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
for existing in out_dir.glob("frame_*.jpg"):
|
|
|
|
|
existing.unlink()
|
|
|
|
|
|
|
|
|
|
output_pattern = str(out_dir / "frame_%04d.jpg")
|
|
|
|
|
cmd: list[str] = [
|
|
|
|
|
"ffmpeg",
|
|
|
|
|
"-hide_banner",
|
|
|
|
|
"-loglevel", "error",
|
|
|
|
|
"-y",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# -ss before -i = fast seek (keyframe-snap, good enough for preview frames).
|
|
|
|
|
if start_seconds is not None:
|
|
|
|
|
cmd += ["-ss", f"{start_seconds:.3f}"]
|
|
|
|
|
if end_seconds is not None:
|
|
|
|
|
cmd += ["-to", f"{end_seconds:.3f}"]
|
|
|
|
|
|
|
|
|
|
cmd += [
|
2026-05-09 08:13:32 +10:00
|
|
|
"-i", str(Path(video_path).resolve()),
|
2026-04-24 14:40:34 +10:00
|
|
|
"-vf", f"fps={fps},scale={resolution}:-2",
|
|
|
|
|
"-frames:v", str(max_frames),
|
|
|
|
|
"-q:v", "4",
|
|
|
|
|
output_pattern,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
raise SystemExit(f"ffmpeg frame extraction failed: {result.stderr.strip()}")
|
|
|
|
|
|
|
|
|
|
offset = start_seconds or 0.0
|
|
|
|
|
frames = sorted(out_dir.glob("frame_*.jpg"))
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"index": i,
|
|
|
|
|
"timestamp_seconds": round(offset + (i / fps if fps > 0 else 0.0), 2),
|
|
|
|
|
"path": str(p),
|
|
|
|
|
}
|
|
|
|
|
for i, p in enumerate(frames)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
if len(sys.argv) < 3:
|
|
|
|
|
print(
|
|
|
|
|
"usage: frames.py <video-path> <out-dir> [--fps F] [--resolution W] "
|
|
|
|
|
"[--max-frames N] [--start T] [--end T]",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
raise SystemExit(2)
|
|
|
|
|
|
|
|
|
|
video = sys.argv[1]
|
|
|
|
|
out = Path(sys.argv[2])
|
|
|
|
|
args = sys.argv[3:]
|
|
|
|
|
|
|
|
|
|
fps_override = None
|
|
|
|
|
resolution = 512
|
|
|
|
|
max_frames = 100
|
|
|
|
|
start_arg = None
|
|
|
|
|
end_arg = None
|
|
|
|
|
i = 0
|
|
|
|
|
while i < len(args):
|
|
|
|
|
if args[i] == "--fps":
|
|
|
|
|
fps_override = float(args[i + 1]); i += 2
|
|
|
|
|
elif args[i] == "--resolution":
|
|
|
|
|
resolution = int(args[i + 1]); i += 2
|
|
|
|
|
elif args[i] == "--max-frames":
|
|
|
|
|
max_frames = int(args[i + 1]); i += 2
|
|
|
|
|
elif args[i] == "--start":
|
|
|
|
|
start_arg = args[i + 1]; i += 2
|
|
|
|
|
elif args[i] == "--end":
|
|
|
|
|
end_arg = args[i + 1]; i += 2
|
|
|
|
|
else:
|
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
meta = get_metadata(video)
|
|
|
|
|
start_sec = parse_time(start_arg)
|
|
|
|
|
end_sec = parse_time(end_arg)
|
|
|
|
|
full_duration = meta["duration_seconds"]
|
|
|
|
|
|
|
|
|
|
effective_start = start_sec if start_sec is not None else 0.0
|
|
|
|
|
effective_end = end_sec if end_sec is not None else full_duration
|
|
|
|
|
effective_duration = max(0.0, effective_end - effective_start)
|
|
|
|
|
|
|
|
|
|
focused = start_sec is not None or end_sec is not None
|
|
|
|
|
if focused:
|
|
|
|
|
fps, target = auto_fps_focus(effective_duration, max_frames=max_frames)
|
|
|
|
|
else:
|
|
|
|
|
fps, target = auto_fps(effective_duration, max_frames=max_frames)
|
|
|
|
|
if fps_override is not None:
|
|
|
|
|
fps = fps_override
|
|
|
|
|
target = max(1, int(round(fps * effective_duration)))
|
|
|
|
|
|
|
|
|
|
frames = extract(
|
|
|
|
|
video, out,
|
|
|
|
|
fps=fps,
|
|
|
|
|
resolution=resolution,
|
|
|
|
|
max_frames=max_frames,
|
|
|
|
|
start_seconds=start_sec,
|
|
|
|
|
end_seconds=end_sec,
|
|
|
|
|
)
|
|
|
|
|
print(json.dumps(
|
|
|
|
|
{"meta": meta, "fps": fps, "target": target, "focused": focused, "frames": frames},
|
|
|
|
|
indent=2,
|
|
|
|
|
))
|