
Capture Video Frames
- 81 installs
- 112 repo stars
- Updated July 8, 2026
- pamelafox/presentation-skills
Helps with ai & agent building tasks.
About
capture-video-frames is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- capture-video-frames
- AI & Agent Building
- AI-coding skill
Capture Video Frames by the numbers
- 81 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,179 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pamelafox/presentation-skills --skill capture-video-framesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 112 |
| Last updated | July 8, 2026 |
| Repository | pamelafox/presentation-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Capture and describe video frames
Step 1: Capture frames
Run the capture_video_frames.py script:
uv run .agents/skills/capture-video-frames/capture_video_frames.py <youtube_url> <output_dir> [--interval SECONDS]Arguments
youtube_url(required): YouTube video URL (same formats accepted by the extract-transcript skill).output_dir(required): Directory to save frames and the manifest file. Created if it doesn't exist.--interval(optional): Seconds between captured frames. Defaults to 30.
Outputs
- frame_0000.png, frame_0030.png, … — PNG images named by their timestamp in seconds (zero-padded to 4 digits).
- frames_manifest.md — A markdown file listing each frame with its timestamp and a placeholder for descriptions.
Example frames_manifest.md:
| File | Timestamp | Description |
|------|-----------|-------------|
| frame_0000.png | [00:00] | |
| frame_0030.png | [00:30] | |
| frame_0060.png | [01:00] | |Prerequisites
- yt-dlp:
brew install yt-dlporpip install yt-dlp - ffmpeg:
brew install ffmpegorapt-get install ffmpeg
Step 2: Describe frames using the describe-frame subagent
After capturing frames, describe each frame by running the describe-frame custom agent as a subagent. Each subagent invocation gets an isolated context, so frame images won't accumulate and exhaust the context window.
The describe-frame agent is defined in .github/agents/describe-frame.md.
Procedure
1. Read frames_manifest.md from the output directory to get the full list of frames. 2. For each frame, run the describe-frame agent as a subagent with a prompt that includes:
- The absolute path to the current frame image to view.
- The absolute path to the previous frame image to view (if one exists).
- The previous frame's description as text (if one exists).
3. The subagent will return a plain-text description (or (same as previous) if the frame is essentially identical to the previous one). 4. After each subagent returns, update the Description column for that row in frames_manifest.md immediately. 5. Continue until all frames are described.
Subagent prompt template
Use this as the prompt when invoking the describe-frame subagent (fill in the bracketed values):
Describe the current frame image at: [CURRENT_FRAME_ABSOLUTE_PATH]
[If previous frame exists, include these two lines:]
The previous frame image is at: [PREVIOUS_FRAME_ABSOLUTE_PATH]
The previous frame was described as: "[PREVIOUS_DESCRIPTION]"Example output
After describing all frames, frames_manifest.md should look like:
| File | Timestamp | Description |
|------|-----------|-------------|
| frame_0000.png | [00:00] | Title slide introducing "Building RAG apps with Python" |
| frame_0030.png | [00:30] | Speaker showing the agenda with four main topics |
| frame_0060.png | [01:00] | (same as previous) |
| frame_0090.png | [01:30] | Architecture diagram of a retrieval-augmented generation pipeline |Step 3: Deduplicate frames and select best speaker faces
After all frames are described, groups of consecutive (same as previous) rows represent the same visual content captured at different moments. Within each group, speaker faces may differ — eyes open vs closed, mouth open vs closed, facing camera vs turned away.
Goal
For each group of duplicate frames, keep only one frame — the one with the best speaker face quality — and remove the rest.
Criteria for best face (in priority order)
1. The current speaker's mouth should be open (mid-speech). If you know who is speaking at that timestamp (from a transcript), prioritize that speaker. 2. Eyes open — no mid-blink frames. 3. Facing camera — not turned sideways or looking down. 4. If no speakers are visible (e.g., full-screen demo or slide without webcam feeds), all frames in the group are equivalent — keep the first one.
Procedure
1. Identify all groups of consecutive rows where the description is (same as previous). Each group starts with the "anchor" frame (the one with an actual description) followed by one or more (same as previous) rows. 2. For each group, run the describe-frame subagent to compare faces across the anchor frame and each duplicate. Use this prompt template:
Compare these two frames focusing ONLY on the speaker faces visible in webcam feeds. Which frame has better speaker faces — eyes open, facing camera, mouth open (mid-speech), not mid-blink or turned away?
Frame A: [ANCHOR_FRAME_ABSOLUTE_PATH]
Frame B: [DUPLICATE_FRAME_ABSOLUTE_PATH]
Reply with ONLY one of:
- "A BETTER" if the anchor frame has better speaker faces
- "B BETTER" if the duplicate frame has better speaker faces
- "EQUAL" if both are equivalent
- "NO SPEAKERS" if no speaker faces are visible in either frame
Then add a brief reason.3. After comparing all duplicates in a group against the anchor (and the current best), determine the single best frame. 4. If the best frame is NOT the anchor:
- Move the anchor's description to the best frame's row.
- Add a face-quality note to the description, e.g.,
(better speaker faces than frame_XXXX: eyes open, facing camera)
5. Remove all other (same as previous) rows from the manifest. 6. If the anchor was already the best, just remove the duplicate rows.
Recapturing frames for closed mouths
After deduplication, if the best frame in a group still has the speaking person's mouth closed (both speakers have mouths closed), try recapturing at nearby timestamps:
1. Download the video if not already available:
yt-dlp -f "bestvideo[height<=720]" --no-playlist -o "<output_dir>/video.%(ext)s" "<youtube_url>"2. Capture alternative frames at +2s, +5s, +8s, and +10s offsets from the frame's timestamp:
ffmpeg -ss <SECONDS> -i <output_dir>/video.mp4 -frames:v 1 -q:v 2 <output_dir>/alt_<FRAME>_<SECONDS>.png -y3. Use the describe-frame subagent to check if the speaker's mouth is open in any alternative, AND that the slide/demo content is still the same. 4. If a better alternative is found, replace the frame file (cp alt_XXXX.png frame_XXXX.png). 5. Clean up: rm -f <output_dir>/alt_*.png <output_dir>/video.mp4
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Capture frames from a YouTube video at a regular interval.
Downloads the video with yt-dlp, then extracts frames with ffmpeg.
Produces PNG images and a frames_manifest.md mapping filenames to timestamps.
"""
import argparse
import re
import subprocess
import sys
import tempfile
from pathlib import Path
def extract_video_id(url: str) -> str:
"""Extract the YouTube video ID from a URL."""
patterns = [
r"(?:v=|/v/|youtu\.be/)([^&?/]+)",
r"(?:embed/)([^&?/]+)",
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return match.group(1)
raise ValueError(f"Could not extract video ID from URL: {url}")
def get_video_duration(video_path: str) -> float:
"""Get video duration in seconds using ffprobe."""
cmd = [
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
video_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return float(result.stdout.strip())
def format_timestamp(seconds: int) -> str:
"""Format seconds as [MM:SS] or [HH:MM:SS]."""
hours, remainder = divmod(seconds, 3600)
minutes, secs = divmod(remainder, 60)
if hours > 0:
return f"[{hours:02d}:{minutes:02d}:{secs:02d}]"
return f"[{minutes:02d}:{secs:02d}]"
def download_video(url: str, output_path: str) -> None:
"""Download a YouTube video using yt-dlp."""
cmd = [
"yt-dlp",
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
"--merge-output-format", "mp4",
"-o", output_path,
url,
]
try:
subprocess.run(cmd, check=True)
except FileNotFoundError:
print(
"yt-dlp not found. Install it:\n"
" macOS: brew install yt-dlp\n"
" pip: pip install yt-dlp",
file=sys.stderr,
)
sys.exit(1)
def extract_frames(video_path: str, output_dir: Path, interval: int) -> list[tuple[str, int]]:
"""Extract frames from video at the given interval.
Returns a list of (filename, timestamp_seconds) tuples.
"""
duration = get_video_duration(video_path)
frames: list[tuple[str, int]] = []
for t in range(0, int(duration), interval):
filename = f"frame_{t:04d}.png"
output_file = output_dir / filename
cmd = [
"ffmpeg",
"-y",
"-ss", str(t),
"-i", video_path,
"-frames:v", "1",
"-q:v", "2",
str(output_file),
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError:
print(
"ffmpeg not found. Install it:\n"
" macOS: brew install ffmpeg\n"
" Ubuntu: apt-get install ffmpeg",
file=sys.stderr,
)
sys.exit(1)
if output_file.exists():
frames.append((filename, t))
return frames
def write_manifest(frames: list[tuple[str, int]], output_dir: Path) -> Path:
"""Write frames_manifest.md with filenames, timestamps, and empty descriptions."""
manifest_path = output_dir / "frames_manifest.md"
lines = ["| File | Timestamp | Description |", "|------|-----------|-------------|"]
for filename, seconds in frames:
timestamp = format_timestamp(seconds)
lines.append(f"| {filename} | {timestamp} | |")
manifest_path.write_text("\n".join(lines) + "\n")
return manifest_path
def main() -> None:
"""Download a YouTube video and capture frames at a regular interval."""
parser = argparse.ArgumentParser(description="Capture frames from a YouTube video")
parser.add_argument("youtube_url", help="YouTube video URL")
parser.add_argument("output_dir", help="Directory to save frames and manifest")
parser.add_argument(
"--interval",
type=int,
default=30,
help="Seconds between captured frames (default: 30)",
)
args = parser.parse_args()
# Validate URL
extract_video_id(args.youtube_url)
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Download video to a temp file
with tempfile.TemporaryDirectory() as tmpdir:
video_path = str(Path(tmpdir) / "video.mp4")
print(f"Downloading video from {args.youtube_url}...")
download_video(args.youtube_url, video_path)
print(f"Extracting frames every {args.interval} seconds...")
frames = extract_frames(video_path, output_dir, args.interval)
manifest_path = write_manifest(frames, output_dir)
print(f"Captured {len(frames)} frames in {output_dir}")
print(f"Manifest written to {manifest_path}")
if __name__ == "__main__":
main()