How to Extract Video Metadata (Duration, Resolution, Codec, Audio) Using ffprobe

The claude-video skill extracts video metadata by calling ffprobe with JSON output flags, then parsing the format and stream data into a clean Python dictionary.

This article breaks down the implementation from the bradautomates/claude-video repository, where a single get_metadata function handles all ffprobe interactions for the watch skill. The approach favors portability across AI agent hosts by isolating binary dependencies and returning plain Python structures.


How ffprobe Video Metadata Extraction Works

The metadata pipeline follows four distinct phases: dependency verification, subprocess invocation, error handling, and structured parsing. This design keeps the skill functional across Claude Code, Codex, Cursor, and other agent environments without assuming pre-installed tools.


Step 1: Verify ffprobe Binary Availability

Before any subprocess call, the code confirms ffprobe exists on the system using shutil.which. This prevents opaque failures later in the pipeline.

import shutil

if not shutil.which("ffprobe"):
    raise SystemExit("ffprobe not found. Please install FFmpeg / ffprobe.")

Source: [frames.py](https://github.com/bradautomates/claude-video/blob/main/skills/watch/scripts/frames.py#L86-L88)

This check appears at lines 86-88 and provides an actionable error message rather than a generic FileNotFoundError.


Step 2: Build and Execute the ffprobe Command

The get_metadata function constructs a subprocess call with three critical flags:

  • -v quiet — suppresses diagnostic output
  • -print_format json — returns machine-parseable JSON
  • -show_format -show_streams — includes container metadata and per-stream data
import subprocess
from pathlib import Path

result = subprocess.run(
    [
        "ffprobe",
        "-v", "quiet",
        "-print_format", "json",
        "-show_format",
        "-show_streams",
        str(Path(video_path).resolve()),
    ],
    capture_output=True,
    text=True,
)

Source: [frames.py](https://github.com/bradautomates/claude-video/blob/main/skills/watch/scripts/frames.py#L90-L99)

Using Path.resolve() ensures absolute paths, avoiding working-directory ambiguity in subprocess contexts.


Step 3: Handle ffprobe Errors Gracefully

Non-zero exit codes trigger an immediate SystemExit with stderr content exposed to the caller:

if result.returncode != 0:
    raise SystemExit(f"ffprobe failed: {result.stderr}")

Source: [frames.py](https://github.com/bradautomates/claude-video/blob/main/skills/watch/scripts/frames.py#L102)

This pattern lets calling code distinguish between missing binaries (caught earlier) and corrupt/unsupported video files (caught here).


Step 4: Parse ffprobe JSON Output for Metadata Fields

The parsing logic extracts six key fields by traversing the nested JSON structure:

Field JSON Path Fallback Logic
duration_seconds format.durationvideo_stream.duration Defaults to 0.0
width video_stream.width None if absent
height video_stream.height None if absent
codec video_stream.codec_name None if absent
size_bytes format.size Defaults to 0
has_audio Presence of audio stream bool
import json

data = json.loads(result.stdout)
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,
}

Source: [frames.py](https://github.com/bradautomates/claude-video/blob/main/skills/watch/scripts/frames.py#L106-L119)

The duration fallback chain优先s container-level metadata (more reliable for variable-frame-rate files) over stream-level duration. The generator expression with next efficiently locates the first matching stream without full iteration.


Complete Usage Examples

Direct Library Import

from skills.watch.scripts.frames import get_metadata

meta = get_metadata("lecture.mp4")

print(f"Duration:   {meta['duration_seconds']:.2f}s")
print(f"Resolution: {meta['width']}×{meta['height']}")
print(f"Video codec: {meta['codec']}")
print(f"Audio:      {'present' if meta['has_audio'] else 'absent'}")
print(f"File size:  {meta['size_bytes']:,} bytes")

Handling Missing Dependencies

import shutil

# Pre-check before calling get_metadata

if not shutil.which("ffprobe"):
    print("Install FFmpeg: https://ffmpeg.org/download.html")
else:
    meta = get_metadata("video.mov")

Integration in the Watch Skill

The top-level watch.py consumes get_metadata to calculate frame extraction budgets. Running:

claude watch https://youtube.com/watch?v=xyz

internally invokes the same function and renders:


[watch] video metadata: 2m13s, 1280×720, h264, audio present


Key Files in the Metadata Pipeline

File Purpose
skills/watch/scripts/frames.py Core get_metadata implementation and frame extraction logic
skills/watch/scripts/watch.py Orchestrates workflow; uses metadata for FPS calculations
skills/watch/scripts/setup.py Validates binary dependencies before processing
tests/test_fixtures.py Mirrors ffprobe patterns for test fixtures

Summary

  • Single function design: get_metadata in frames.py encapsulates all ffprobe interaction
  • JSON output parsing: -print_format json eliminates fragile text parsing
  • Defensive parsing: Duration falls back from format to stream level; missing fields return None or 0
  • Binary abstraction: shutil.which check keeps the skill portable across agent hosts
  • Clean return type: Plain dict with six standardized keys integrates seamlessly with downstream Python code

Frequently Asked Questions

What happens if ffprobe is not installed?

The code raises SystemExit with the message "ffprobe not found. Please install FFmpeg / ffprobe." at lines 86-88 of frames.py. This occurs before any subprocess attempt, providing immediate actionable feedback.

Why does duration use a fallback chain?

Container-level format.duration is generally more accurate for files with variable frame rates or editing metadata. The code tries this first, then falls back to video_stream.duration, and finally defaults to 0.0 if both are absent.

Can this detect multiple audio tracks?

The current implementation sets has_audio to True if any audio stream exists. It uses next() with a generator, which stops at the first match. To count tracks, modify the logic to sum(1 for s in streams if s.get("codec_type") == "audio").

Is the metadata extraction synchronous or asynchronous?

The claude-video skill uses subprocess.run for synchronous execution. For I/O-bound batch processing, you could adapt the pattern to asyncio.create_subprocess_exec while preserving the same ffprobe flag structure.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →