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

> Learn how to extract video metadata like duration, resolution, codec, and audio using ffprobe. This guide details the process and parsing for clean Python dictionaries.

- Repository: [bradautomates/claude-video](https://github.com/bradautomates/claude-video)
- Tags: how-to-guide
- Published: 2026-08-03

---

**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.

```python
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/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

```python
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/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:

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

```

Source: [[`frames.py`](https://github.com/bradautomates/claude-video/blob/main/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.duration` → `video_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` |

```python
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/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

```python
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

```python
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`](https://github.com/bradautomates/claude-video/blob/main/watch.py) consumes `get_metadata` to calculate frame extraction budgets. Running:

```bash
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`](https://github.com/bradautomates/claude-video/blob/main/skills/watch/scripts/frames.py) | Core `get_metadata` implementation and frame extraction logic |
| [`skills/watch/scripts/watch.py`](https://github.com/bradautomates/claude-video/blob/main/skills/watch/scripts/watch.py) | Orchestrates workflow; uses metadata for FPS calculations |
| [`skills/watch/scripts/setup.py`](https://github.com/bradautomates/claude-video/blob/main/skills/watch/scripts/setup.py) | Validates binary dependencies before processing |
| [`tests/test_fixtures.py`](https://github.com/bradautomates/claude-video/blob/main/tests/test_fixtures.py) | Mirrors ffprobe patterns for test fixtures |

---

## Summary

- **Single function design**: `get_metadata` in [`frames.py`](https://github.com/bradautomates/claude-video/blob/main/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`](https://github.com/bradautomates/claude-video/blob/main/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.