Add --interval strategy and single-video mode to video analyzer

This commit is contained in:
2026-05-13 11:10:16 -07:00
parent 8d6f361ef7
commit 2857194759

View File

@@ -6,9 +6,17 @@ prompting for a UX research-style analysis. Saves results as markdown
in docs/research/.
Usage:
uv run python -m app.analyze_videos # analyze all .mp4 in videos/
uv run python -m app.analyze_videos videos/file.mp4 # single video
NUM_FRAMES=8 uv run python -m app.analyze_videos # custom frame count
# Analyze all videos in videos/
uv run python -m app.analyze_videos
# Analyze one specific video
uv run python -m app.analyze_videos "videos/E-Filing in Filevine.mp4"
# Extract a frame every 30 seconds (recommended for 3-4 min videos)
INTERVAL=15 uv run python -m app.analyze_videos
# Force exactly N frames, evenly spaced
NUM_FRAMES=8 uv run python -m app.analyze_videos
"""
import argparse
@@ -44,8 +52,9 @@ OPENROUTER_BASE = "https://openrouter.ai/api/v1"
# Gemini models available on OpenRouter:
# google/gemini-2.0-flash-exp:free (free, good for testing)
# google/gemini-2.0-flash (fast, multimodal)
# google/gemini-2.5-flash-preview-04-17 (latest preview)
# google/gemini-2.5-flash-preview-05-20 (latest preview)
DEFAULT_MODEL = os.getenv("OPENROUTER_MODEL", "google/gemini-2.5-flash-preview-05-20")
DEFAULT_INTERVAL = int(os.getenv("INTERVAL", "30")) # seconds between frames
UX_PROMPT = """\
Analyze this screen recording like a UX researcher.
@@ -72,12 +81,38 @@ Be specific about UI elements, button labels, menu paths, and exact behaviors
you observe in the frames provided.
"""
# ---------------------------------------------------------------------------
# Frame extraction
# ---------------------------------------------------------------------------
def extract_frames(video_path: Path, num_frames: int = 6) -> list[dict]:
"""Extract evenly-spaced key frames from a video using ffmpeg."""
def pick_timestamps(duration: float, interval_sec: int = 30, num_frames: int = 0) -> list[float]:
"""Pick timestamps to extract frames from a video.
Two strategies:
- interval : one frame every N seconds (default). Good for longer videos.
- num_frames: evenly spread exactly N frames across the whole video.
Always skips the first and last 2% to avoid black intro/outro frames.
"""
margin = max(duration * 0.02, 1.0)
usable = duration - 2 * margin
if num_frames > 0:
return [round(margin + i * usable / (num_frames - 1), 2) for i in range(num_frames)]
else:
timestamps: list[float] = []
t = margin
while t <= (duration - margin):
timestamps.append(round(t, 2))
t += interval_sec
if not timestamps:
timestamps.append(margin)
return timestamps
def extract_frames(video_path: Path, timestamps: list[float]) -> list[dict]:
"""Extract frames from a video at the given timestamps using ffmpeg."""
if not video_path.exists():
print(f"SKIP — file not found: {video_path}", file=sys.stderr)
return []
@@ -85,43 +120,15 @@ def extract_frames(video_path: Path, num_frames: int = 6) -> list[dict]:
tmp_dir = Path(".tmp_video_frames")
tmp_dir.mkdir(exist_ok=True)
# Estimate duration
try:
dur_output = subprocess.check_output(
[
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video_path),
],
stderr=subprocess.DEVNULL,
).decode().strip()
duration = float(dur_output)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
print(f"SKIP — could not probe video: {video_path}", file=sys.stderr)
return []
if duration <= 0:
print(f"SKIP — bad duration for: {video_path}", file=sys.stderr)
return []
# Pick evenly spaced timestamps (skip first/last 2% to avoid black frames)
margin = max(duration * 0.02, 1.0)
times = [
str(margin + i * (duration - 2 * margin) / (num_frames - 1))
for i in range(num_frames)
]
images = []
for i, ts in enumerate(times):
for i, ts in enumerate(timestamps):
out_path = tmp_dir / f"{video_path.stem}_frame_{i:03d}.jpg"
try:
subprocess.run(
[
"ffmpeg",
"-y",
"-ss", ts,
"-ss", str(ts),
"-i", str(video_path),
"-vframes:v", "1",
"-q:v", "2", # good quality JPEG
@@ -170,7 +177,6 @@ def call_openrouter(payload: dict) -> str:
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
# Optional: pass-through headers for attribution / tracking
"HTTP-Referer": "https://github.com/notid/e-filing",
"X-Title": "eFiling Video Analyzer",
}
@@ -184,7 +190,6 @@ def call_openrouter(payload: dict) -> str:
resp.raise_for_status()
data = resp.json()
# Extract text from the response
choices = data.get("choices", [])
if not choices:
raise ValueError(f"No choices in OpenRouter response: {json.dumps(data, indent=2)[:500]}")
@@ -195,23 +200,24 @@ def call_openrouter(payload: dict) -> str:
# Output
# ---------------------------------------------------------------------------
def write_report(video_path: Path, analysis: str, model: str, num_frames: int) -> Path:
def write_report(video_path: Path, analysis: str, model: str, num_frames: int, duration: float) -> Path:
"""Write the analysis as a markdown file in docs/research/."""
output_dir = Path(__file__).resolve().parent.parent / "docs" / "research"
output_dir.mkdir(parents=True, exist_ok=True)
# Sanitize filename
safe_name = re.sub(r"[^\w\s\-]", "", video_path.stem)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d")
out_file = output_dir / f"{safe_name}_{timestamp}.md"
dur_min = int(duration // 60)
dur_sec = int(duration % 60)
header = f"""\
# eFiling — UX Analysis: {video_path.name}
| Field | Value |
|-------|-------|
| **Source video** | `{video_path.name}` |
| **Analysis date** | {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")} |
| **Duration** | {dur_min}m {dur_sec}s |\n| **Analysis date** | {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")} |
| **Model** | {model} |
| **Frames analyzed** | {num_frames} |
@@ -222,23 +228,64 @@ def write_report(video_path: Path, analysis: str, model: str, num_frames: int) -
return out_file
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def probe_duration(video_path: Path) -> float:
"""Get video duration in seconds."""
try:
dur = subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(video_path)],
stderr=subprocess.DEVNULL,
).decode().strip()
return float(dur)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
return 0.0
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Analyze screen recordings with Gemini via OpenRouter")
parser = argparse.ArgumentParser(
description="Analyze screen recordings with Gemini via OpenRouter",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
available strategies:
--interval 30 extract one frame every 30 seconds (default, good for long videos)
--num-frames 6 evenly spread N frames across the whole video
examples:
# analyze one specific video
python -m app.analyze_videos "videos/E-Filing in Filevine.mp4"
# analyze all videos with one frame every 30 s (default)
python -m app.analyze_videos
# exactly 8 frames spread across each video
python -m app.analyze_videos --num-frames 8
""",
)
parser.add_argument(
"videos",
nargs="*",
default=[],
help="Video files to analyze (defaults to all .mp4 in videos/)",
)
parser.add_argument(
"--interval",
type=int,
default=DEFAULT_INTERVAL,
help="Extract one frame every N seconds (default: 30). Overrides --num-frames.",
)
parser.add_argument(
"--num-frames",
type=int,
default=int(os.getenv("NUM_FRAMES", "6")),
help="Number of frames to extract per video (default: 6)",
default=int(os.getenv("NUM_FRAMES", "0")),
help="Extract exactly N frames, evenly spaced. Set to >0 to override --interval.",
)
parser.add_argument(
"--model",
@@ -265,22 +312,31 @@ def main():
print("No .mp4 files to analyze.", file=sys.stderr)
sys.exit(0)
strategy_label = "exact frames" if args.num_frames > 0 else f"interval ({args.interval}s)"
print(f"Analyzing {len(video_paths)} video(s) with model '{args.model}'...")
print(f"Strategy: {strategy_label}")
print()
for i, vp in enumerate(video_paths, 1):
print(f"[{i}/{len(video_paths)}] {vp.name}")
frames = extract_frames(vp, args.num_frames)
if not frames:
duration = probe_duration(vp)
if duration <= 0:
print(f" SKIP — could not determine duration", file=sys.stderr)
continue
print(f" Extracted {len(frames)} frame(s)")
timestamps = pick_timestamps(duration, args.interval, args.num_frames)
frames = extract_frames(vp, timestamps)
if not frames:
print(f" SKIP — no frames extracted")
continue
print(f" Strategy: {strategy_label}{len(frames)} frame(s) from {int(duration//60)}m{int(duration%60):02}s video")
try:
payload = build_payload(frames)
analysis = call_openrouter(payload)
out_file = write_report(vp, analysis, args.model, len(frames))
out_file = write_report(vp, analysis, args.model, len(frames), duration)
print(f" ✅ Saved to {out_file}")
except Exception as exc:
print(f" ❌ Error: {exc}", file=sys.stderr)