Add --interval strategy and single-video mode to video analyzer
This commit is contained in:
@@ -6,9 +6,17 @@ prompting for a UX research-style analysis. Saves results as markdown
|
|||||||
in docs/research/.
|
in docs/research/.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run python -m app.analyze_videos # analyze all .mp4 in videos/
|
# Analyze all videos in videos/
|
||||||
uv run python -m app.analyze_videos videos/file.mp4 # single video
|
uv run python -m app.analyze_videos
|
||||||
NUM_FRAMES=8 uv run python -m app.analyze_videos # custom frame count
|
|
||||||
|
# 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
|
import argparse
|
||||||
@@ -44,8 +52,9 @@ OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
|||||||
# Gemini models available on OpenRouter:
|
# Gemini models available on OpenRouter:
|
||||||
# google/gemini-2.0-flash-exp:free (free, good for testing)
|
# google/gemini-2.0-flash-exp:free (free, good for testing)
|
||||||
# google/gemini-2.0-flash (fast, multimodal)
|
# 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_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 = """\
|
UX_PROMPT = """\
|
||||||
Analyze this screen recording like a UX researcher.
|
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.
|
you observe in the frames provided.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Frame extraction
|
# Frame extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def extract_frames(video_path: Path, num_frames: int = 6) -> list[dict]:
|
def pick_timestamps(duration: float, interval_sec: int = 30, num_frames: int = 0) -> list[float]:
|
||||||
"""Extract evenly-spaced key frames from a video using ffmpeg."""
|
"""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():
|
if not video_path.exists():
|
||||||
print(f"SKIP — file not found: {video_path}", file=sys.stderr)
|
print(f"SKIP — file not found: {video_path}", file=sys.stderr)
|
||||||
return []
|
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 = Path(".tmp_video_frames")
|
||||||
tmp_dir.mkdir(exist_ok=True)
|
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 = []
|
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"
|
out_path = tmp_dir / f"{video_path.stem}_frame_{i:03d}.jpg"
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
"-ss", ts,
|
"-ss", str(ts),
|
||||||
"-i", str(video_path),
|
"-i", str(video_path),
|
||||||
"-vframes:v", "1",
|
"-vframes:v", "1",
|
||||||
"-q:v", "2", # good quality JPEG
|
"-q:v", "2", # good quality JPEG
|
||||||
@@ -170,7 +177,6 @@ def call_openrouter(payload: dict) -> str:
|
|||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
# Optional: pass-through headers for attribution / tracking
|
|
||||||
"HTTP-Referer": "https://github.com/notid/e-filing",
|
"HTTP-Referer": "https://github.com/notid/e-filing",
|
||||||
"X-Title": "eFiling Video Analyzer",
|
"X-Title": "eFiling Video Analyzer",
|
||||||
}
|
}
|
||||||
@@ -184,7 +190,6 @@ def call_openrouter(payload: dict) -> str:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
# Extract text from the response
|
|
||||||
choices = data.get("choices", [])
|
choices = data.get("choices", [])
|
||||||
if not choices:
|
if not choices:
|
||||||
raise ValueError(f"No choices in OpenRouter response: {json.dumps(data, indent=2)[:500]}")
|
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
|
# 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/."""
|
"""Write the analysis as a markdown file in docs/research/."""
|
||||||
output_dir = Path(__file__).resolve().parent.parent / "docs" / "research"
|
output_dir = Path(__file__).resolve().parent.parent / "docs" / "research"
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Sanitize filename
|
|
||||||
safe_name = re.sub(r"[^\w\s\-]", "", video_path.stem)
|
safe_name = re.sub(r"[^\w\s\-]", "", video_path.stem)
|
||||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||||
out_file = output_dir / f"{safe_name}_{timestamp}.md"
|
out_file = output_dir / f"{safe_name}_{timestamp}.md"
|
||||||
|
|
||||||
|
dur_min = int(duration // 60)
|
||||||
|
dur_sec = int(duration % 60)
|
||||||
header = f"""\
|
header = f"""\
|
||||||
# eFiling — UX Analysis: {video_path.name}
|
# eFiling — UX Analysis: {video_path.name}
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Source video** | `{video_path.name}` |
|
| **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} |
|
| **Model** | {model} |
|
||||||
| **Frames analyzed** | {num_frames} |
|
| **Frames analyzed** | {num_frames} |
|
||||||
|
|
||||||
@@ -222,23 +228,64 @@ def write_report(video_path: Path, analysis: str, model: str, num_frames: int) -
|
|||||||
return out_file
|
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
|
# Main
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def 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(
|
parser.add_argument(
|
||||||
"videos",
|
"videos",
|
||||||
nargs="*",
|
nargs="*",
|
||||||
default=[],
|
default=[],
|
||||||
help="Video files to analyze (defaults to all .mp4 in videos/)",
|
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(
|
parser.add_argument(
|
||||||
"--num-frames",
|
"--num-frames",
|
||||||
type=int,
|
type=int,
|
||||||
default=int(os.getenv("NUM_FRAMES", "6")),
|
default=int(os.getenv("NUM_FRAMES", "0")),
|
||||||
help="Number of frames to extract per video (default: 6)",
|
help="Extract exactly N frames, evenly spaced. Set to >0 to override --interval.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--model",
|
"--model",
|
||||||
@@ -265,22 +312,31 @@ def main():
|
|||||||
print("No .mp4 files to analyze.", file=sys.stderr)
|
print("No .mp4 files to analyze.", file=sys.stderr)
|
||||||
sys.exit(0)
|
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"Analyzing {len(video_paths)} video(s) with model '{args.model}'...")
|
||||||
|
print(f"Strategy: {strategy_label}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
for i, vp in enumerate(video_paths, 1):
|
for i, vp in enumerate(video_paths, 1):
|
||||||
print(f"[{i}/{len(video_paths)}] {vp.name}")
|
print(f"[{i}/{len(video_paths)}] {vp.name}")
|
||||||
|
|
||||||
frames = extract_frames(vp, args.num_frames)
|
duration = probe_duration(vp)
|
||||||
if not frames:
|
if duration <= 0:
|
||||||
|
print(f" SKIP — could not determine duration", file=sys.stderr)
|
||||||
continue
|
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:
|
try:
|
||||||
payload = build_payload(frames)
|
payload = build_payload(frames)
|
||||||
analysis = call_openrouter(payload)
|
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}")
|
print(f" ✅ Saved to {out_file}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f" ❌ Error: {exc}", file=sys.stderr)
|
print(f" ❌ Error: {exc}", file=sys.stderr)
|
||||||
|
|||||||
Reference in New Issue
Block a user