# video-thumbnail-creator

CLI tool to extract and select a thumbnail (poster) image from a video or image file.
Supports manual selection, fully automatic AI selection (Claude Vision), and a
semi-automatic suggest mode where the AI proposes a frame and you confirm.

Every generated JPEG has creation metadata (EXIF) embedded so that the exact parameters
used during generation are preserved and can be read back with the `info` subcommand.

> **Note:** This tool extracts and saves a still image only. Embedding the
> image into the video file (e.g. with AtomicParsley) is out of scope and must
> be handled by the caller.

---

## Requirements

- Python 3.10+
- [ffmpeg](https://ffmpeg.org/) and `ffprobe` available in `$PATH` *(required for video input; not needed for image-only input)*
- **macOS only:** `sips` — built-in macOS image tool, used for wide-gamut color space conversion (TIFF/HEIC with Rec.2020 or Display P3).
  On Linux/Windows, Pillow is used as a fallback but may not handle wide-gamut images correctly.
- An [Anthropic API key](https://console.anthropic.com/) for `auto` / `suggest` modes
  (set via `CLAUDE_API_KEY` env variable or `video-thumbnail-creator config set claude.api_key <key>`)

---

## Installation

```bash
pip install video-thumbnail-creator
```

Or in editable/development mode:

```bash
pip install -e .
```

---

## Usage

```
video-thumbnail-creator extract <input_path> [OPTIONS]
```

### Options

| Option | Description |
|--------|-------------|
| `--mode manual` | Interactive: open mosaic, enter frame number 0–19 |
| `--mode auto` | Fully automatic: AI selects the best frame |
| `--mode suggest` | AI suggests a frame; you confirm or override |
| `--format poster` | Output format 2:3 (1080×1620) with 1:1 crop + text area **(default)** |
| `--format landscape` | Output format 16:9 (1920×1080) — existing behaviour |
| `--embedded-image prefer` | Use embedded cover art or sidecar image if present; otherwise extract frames |
| `--embedded-image ignore` | Always extract frames (ignore any embedded cover art or sidecar images) |
| `--embedded-image ask` | Prompt user when embedded cover art or sidecar image is found **(default)** |
| `--crop-position POSITION` | Set crop position directly (`left`, `center-left`, `center`, `center-right`, `right`); skips interactive prompt and AI crop selection |
| `--overlay-title TEXT` | Title text to overlay on the output image |
| `--overlay-title-from-filename` | Use the input filename stem as overlay title |
| `--overlay-category TEXT` | Category label shown above the title (poster format only) |
| `--overlay-category-logo PATH` | PNG logo shown instead of category text (poster format only); wide logos centered above title, square/portrait logos to the left |
| `--overlay-note TEXT` | Small text centered at the bottom of the poster text area (poster format only) |
| `--style NAME` | Poster style to use (default: `default`). Run `video-thumbnail-creator styles` to list available options |
| `--description TEXT` | Optional video description for AI context (max 1000 chars) |
| `--output-dir PATH` | Output directory (default: same directory as the video) |
| `--output-name-suffix SUFFIX` | Suffix appended to the video filename stem (default: `-poster`) |
| `--json` | Emit machine-readable JSON to stdout |
| `--no-badges` | Disable automatic technical badges (4K, HD, HDR) on the poster |
| `--fanart` | Generate an additional clean 16:9 fanart image (for Infuse/Emby) with `-fanart` suffix |

---

## `info` Subcommand

Read and display the creation metadata embedded in a generated poster image:

```bash
video-thumbnail-creator info /path/to/poster.jpg
```

**Default output:**
```
Poster Metadata:
  Version            1.3.0
  Source             frame
  Frame Index        12
  Crop Position      center-left
  Format             poster
  Mode               auto
  Input File         2025-11-01_Herbst-Spaziergang.mp4
  Overlay Title      Herbst-Spaziergang
  Category           Videoschnittstudio Silvan Kurmann
  Note               1. November 2025
  AI Reasoning       Sharp, well-lit frame with child running towards camera…
  Created            2026-02-26T14:30:00
```

**JSON output (`--json`):**
```bash
video-thumbnail-creator info --json /path/to/poster.jpg
```
```json
{
  "vtc_version": "1.3.0",
  "source": "frame",
  "frame_index": 12,
  "crop_position": "center-left",
  "format": "poster",
  "mode": "auto",
  "input_file": "2025-11-01_Herbst-Spaziergang.mp4",
  "overlay_title": "Herbst-Spaziergang",
  "overlay_category": "Videoschnittstudio Silvan Kurmann",
  "overlay_note": "1. November 2025",
  "ai_reasoning": "Sharp, well-lit frame with child running towards camera...",
  "created_at": "2026-02-26T14:30:00"
}
```

The embedded metadata enables future re-generation of posters (e.g. with a new template)
without needing AI calls or interactive prompts.

---

## Poster Format (2:3)

The default `poster` format produces a **1080×1620** image composed of two sections:

```
┌──────────────────┐
│                  │
│   1:1 crop of    │  ← 1080×1080 square crop (with subtle vignette)
│   selected frame │
│                  │
├──────────────────┤  ← 10px separator line (#2a2a2a)
│▓  [category]    ▓│  ← optional category text or logo above title
│▓                ▓│
│▓    Title       ▓│  ← bold, auto-sized (40–72px), centered, drop shadow
│▓                ▓│
│▓         note   ▓│  ← optional small note text at bottom-right
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│  ← solid dark background (#1a1a1a)
└──────────────────┘
        2:3
```

- **Top section (1080×1080)**: A 1:1 square crop of the selected high-res frame with a
  subtle radial vignette effect (15–20% edge darkening). The horizontal crop position
  (`left` / `center-left` / `center` / `center-right` / `right`) can be set directly
  with `--crop-position`, chosen by AI in `auto`/`suggest` mode, or prompted from the
  user in `manual` mode.
- **Bottom section**: The text area fills the entire bottom section edge-to-edge. Layout
  details depend on the active style (see [Poster Styles](#poster-styles)):
  - **Title** (optional `--overlay-title`): bold, auto-sized, word-wrapped, centered, white with drop shadow.
  - **Note** (optional `--overlay-note`): small text centered at the bottom of the text area.
  - **Category** / **Logo** (optional): placement varies by style — in the text area above the title, or as an overlay band at the top of the image.

---

## Supported Image Formats

When `<input_path>` points to an image file, the mosaic/frame-extraction pipeline
is skipped and the image is used directly as the high-res source for crop-position
selection and poster/landscape composition.

| Format | Notes |
|--------|-------|
| JPEG (`.jpg`, `.jpeg`) | Used directly (copied as-is for sRGB images) |
| PNG (`.png`) | Converted to JPEG via Pillow |
| TIFF (`.tiff`, `.tif`) | Wide-gamut (Rec.2020, P3) converted to sRGB via `sips` (macOS) |
| HEIC/HEIF (`.heic`, `.heif`) | Converted to sRGB JPEG via `sips` (macOS) |

> **Note:** Wide-gamut color space conversion (Rec.2020, Display P3) requires
> macOS with `sips`. On other systems, Pillow is used as a fallback but may
> not handle wide-gamut TIFF files correctly.

---

## Examples

### Manual mode

```bash
video-thumbnail-creator extract /path/to/video.mp4 --mode manual
```

The mosaic of 20 frames is opened in the system image viewer. Enter the frame
number (0–19) at the prompt. In poster format, you are also prompted to choose
a crop position. The result is saved next to the video.

### Manual mode — landscape with text overlay

```bash
video-thumbnail-creator extract /path/to/video.mp4 \
  --mode manual \
  --format landscape \
  --overlay-title-from-filename
```

### Automatic AI mode (poster, default format)

```bash
video-thumbnail-creator extract /path/to/video.mp4 \
  --mode auto \
  --description "Documentary about rocket launches" \
  --overlay-title "2025-05-15 – Starship IFT-7"
```

The AI selects the best frame from the 20-frame mosaic, then chooses the
optimal 1:1 crop position for the poster, and renders the text in the
blurred bottom area.

### Poster with category, logo, and note

```bash
# Category text above title, note at bottom-right
video-thumbnail-creator extract /path/to/video.mp4 \
  --mode auto \
  --overlay-title "Starship IFT-7" \
  --overlay-category "Space Exploration" \
  --overlay-note "2025-05-15"

# Category logo (wide, centered above title)
video-thumbnail-creator extract /path/to/video.mp4 \
  --mode auto \
  --overlay-title "Starship IFT-7" \
  --overlay-category-logo /path/to/channel-logo-wide.png \
  --overlay-note "Episode 7"

# Square/portrait logo (left of title)
video-thumbnail-creator extract /path/to/video.mp4 \
  --mode manual \
  --overlay-title "My Documentary" \
  --overlay-category-logo /path/to/icon-square.png
```

> **Note on `--overlay-category` vs `--overlay-category-logo`**: If both are provided, the logo
takes precedence and a warning is printed to stderr.

### Using embedded cover art (MP4/M4V/MOV)

```bash
# Use embedded artwork if present, fall back to frame extraction
video-thumbnail-creator extract /path/to/video.mp4 \
  --embedded-image prefer \
  --mode auto

# Always skip frame extraction and use embedded cover art
video-thumbnail-creator config set defaults.embedded_image prefer
```

The `--embedded-image` option controls how embedded cover art and sidecar images are handled:
- `prefer`: Use the embedded image or sidecar image if found; otherwise extract frames normally.
- `ignore`: Always extract frames, even if embedded cover art or sidecar images exist.
- `ask` *(default)*: Prompt the user when an embedded image or sidecar image is found.

> **Note:** Embedded image detection is only supported for MP4, M4V, and MOV
> containers. For other formats the tool falls through to sidecar detection and
> then frame extraction.
> The `--embedded-image` option is independent from `--mode`; the mode only
affects how the crop position is determined after the source image is resolved.

### Using sidecar thumbnail images

When extracting from a video file (e.g. `video.mp4`), the tool also checks for
existing thumbnail images ("sidecar" files) in the same directory with the same
filename stem:

```
video.jpg / video.jpeg / video.png / video.tiff / video.tif
```

The detection priority for video input is:
1. Embedded image (inside the video container)
2. Sidecar image (next to the video file)
3. Frame extraction (mosaic flow)

The `--embedded-image` option controls sidecar image handling the same way it
controls embedded image handling.

```bash
# Use sidecar image if present, fall back to frame extraction
video-thumbnail-creator extract /path/to/video.mp4 \
  --embedded-image prefer \
  --mode auto

# Always use frames, ignore any sidecar images
video-thumbnail-creator extract /path/to/video.mp4 \
  --embedded-image ignore \
  --mode manual
```

When a sidecar image is used, the JSON output has `"source": "sidecar"`:
```json
{
  "poster_path": "/path/to/video-poster.jpg",
  "frame_index": -1,
  "mode": "auto",
  "format": "poster",
  "source": "sidecar",
  "reasoning": "Sidecar image file was used as source. | Crop: ...",
  "crop_position": "center",
  "input_path": "/path/to/video.mp4"
}
```

### Using `--crop-position` to skip interactive and AI crop selection

```bash
# Set crop position directly — skips AI crop selection (saves API costs)
video-thumbnail-creator extract /path/to/video.mp4 \
  --mode auto \
  --crop-position center

# Useful for batch processing where crop position is already known
for f in /videos/*.mp4; do
  video-thumbnail-creator extract "$f" --mode auto --crop-position center-left
done
```

The `--crop-position` option accepts: `left`, `center-left`, `center`, `center-right`, `right`.
When provided with `--format poster`, it skips both the interactive crop prompt
(manual mode) and the AI crop selection call (auto/suggest modes).

### Image file input (JPEG, PNG, TIFF, HEIC)

```bash
# Create poster from a TIFF image (auto color space conversion on macOS)
video-thumbnail-creator extract /path/to/photo.tiff \
  --mode auto \
  --overlay-title "Herbst-Spaziergang"

# Create poster from JPEG with manual crop
video-thumbnail-creator extract /path/to/photo.jpg \
  --mode manual \
  --overlay-title "Mein Foto" \
  --overlay-category "Familie Kurmann"
```

For image input, ffmpeg/ffprobe are not required. The image itself is used as
the high-res source; only crop-position selection and poster composition run.

### Semi-automatic suggest mode

```bash
video-thumbnail-creator extract /path/to/video.mp4 \
  --mode suggest \
  --output-dir /tmp/thumbs \
  --output-name-suffix -thumb
```

In poster format, after the frame is selected the AI also suggests a crop
position, which you can confirm or override at the prompt.

---

## Configuration

Settings can be stored in `~/.config/video-thumbnail-creator/config.toml` so you
don't have to pass them on every invocation.  The directory and file are created
automatically on the first `config set`.

### Priority order (highest to lowest)

1. Explicit CLI arguments
2. Config file values
3. Built-in defaults

### Commands

```bash
# Store a value
video-thumbnail-creator config set claude.api_key "sk-ant-..."

# Read a single value
video-thumbnail-creator config get claude.model

# Show all stored values
video-thumbnail-creator config list
```

### Allowed keys

| Key | Description | Default |
|-----|-------------|---------|
| `claude.api_key` | Anthropic Claude API key | *(none)* |
| `claude.model` | Claude model name | `claude-opus-4-5` |
| `tools.ffmpeg` | Path to `ffmpeg` binary | `ffmpeg` |
| `tools.ffprobe` | Path to `ffprobe` binary | `ffprobe` |
| `defaults.output_name_suffix` | Suffix for output filename | `-poster` |
| `defaults.mode` | Default selection mode | `manual` |
| `defaults.format` | Default output format (`poster` or `landscape`) | `poster` |
| `defaults.embedded_image` | Default embedded image handling (`prefer`, `ignore`, `ask`) | `ask` |

### Example `config.toml`

```toml
[claude]
api_key = "sk-ant-..."
model = "claude-opus-4-5"

[tools]
ffmpeg = "ffmpeg"
ffprobe = "ffprobe"

[defaults]
output_name_suffix = "-poster"
mode = "manual"
format = "poster"
embedded_image = "ask"
```

### Output filename

The output filename is formed by appending the suffix to the video file stem:
```
2025-05-15_Starship_IFT7.mkv  +  suffix "-poster"  →  2025-05-15_Starship_IFT7-poster.jpg
```

---

## Output

### Default (no `--json`)

stdout contains only the absolute path of the created image:
```
/path/to/poster.jpg
```

All status messages, progress info, and AI reasoning are written to **stderr**.

### JSON mode (`--json`)

```json
{
  "poster_path": "/path/to/poster.jpg",
  "frame_index": 12,
  "mode": "auto",
  "format": "poster",
  "source": "frame",
  "reasoning": "Sharp, well-lit frame that is representative of the content.",
  "crop_position": "center-left",
  "overlay_title": "My Video Title",
  "overlay_category": "Space Exploration",
  "overlay_note": "2025-05-15",
  "input_path": "/path/to/video.mp4"
}
```

The `overlay_category` field is only present when `--overlay-category` is provided (and `--overlay-category-logo`
is not used alongside it). The `overlay_note` field is only present when `--overlay-note` is provided.

When embedded cover art is used as the source:
```json
{
  "poster_path": "/path/to/video-poster.jpg",
  "frame_index": -1,
  "mode": "auto",
  "format": "poster",
  "source": "embedded",
  "reasoning": "Embedded cover art was used as source image. | Crop: ...",
  "crop_position": "center",
  "input_path": "/path/to/video.mp4"
}
```

When an image file is used as the source:
```json
{
  "poster_path": "/path/to/photo-poster.jpg",
  "frame_index": -1,
  "mode": "auto",
  "format": "poster",
  "source": "image",
  "reasoning": "Image file was used as source. | Crop: ...",
  "crop_position": "center",
  "input_path": "/path/to/photo.tiff"
}
```

The `source` field is `"frame"` when a video frame was used, `"embedded"` when
embedded cover art was used, `"sidecar"` when a sidecar image file was used, and
`"image"` when an image file was used as input.
When `source` is `"embedded"`, `"sidecar"`, or `"image"`, `frame_index` is `-1`.

When `--fanart` is used, the JSON output includes an additional `fanart_path` field:
```json
{
  "poster_path": "/path/to/video-poster.jpg",
  "fanart_path": "/path/to/video-fanart.jpg",
  ...
}
```

---

## Fanart Image (`--fanart`)

The `--fanart` flag generates an **additional** clean 16:9 JPEG alongside the
normal poster or landscape output. This image has no text overlays, no badges,
and no gradients — just the pure source frame scaled to 16:9. It is intended for
media servers such as **Infuse** and **Emby** that look for a file with a
`-fanart` suffix.

```bash
# Generates both "My Video-poster.jpg" and "My Video-fanart.jpg"
videos-thumbnail-creator extract "My Video.mp4" --fanart
```

**Output resolution:**
- 4K source (width ≥ 3840 or height ≥ 2160): **3840 × 2160**
- Otherwise: **1920 × 1080**

**Non-16:9 sources:** A blurred background fill is applied automatically
(same visual approach as the existing frame extraction) so the output is always
exactly 16:9 without black bars or stretching.

---

## Poster Styles

All visual design constants (colors, fonts, layout, badge placement) are defined
as **built-in named styles**. Select a style with `--style <name>` (default: `default`).

### List available styles

```bash
video-thumbnail-creator styles
```

| Style | Description |
|-------|-------------|
| `default` | Classic blurred video background in text area, Helvetica Neue, note at bottom |
| `internet` | Category header overlaid at top of image, matte black text area, large bold fonts, badges on image — optimised for YouTube/web video posters |

### Examples

```bash
# Default style (classic blurred background)
video-thumbnail-creator extract video.mp4 \
  --overlay-title "My Title"

# Internet style (matte black, category at top, HD badge on image)
video-thumbnail-creator extract video.mp4 \
  --style internet \
  --overlay-category "My Channel" \
  --overlay-title "My Video Title" \
  --overlay-note "15. March 2025"
```

---

## Exit Codes

| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | General error (file not found, ffmpeg missing, etc.) |
| `2` | No selection made (user cancelled) |
| `3` | AI selection failed (no API key, timeout, invalid response) |

---

## Integration

### Library Integration (Python Import)

`video-thumbnail-creator` ships a full Python API at three levels — pick the
one that fits your use case:

```
CLI (Terminal user)
  └→ High-Level API: create_thumbnail()     – one call, everything automatic
       └→ Mid-Level API: ThumbnailSession    – multi-step, caller controls each step
            └→ Low-Level API: extract_frames(), compose_poster(), etc.  – individual building blocks
```

#### High-Level API – `create_thumbnail()`

One call for fully automatic thumbnail creation (AI selects frame and crop position):

```python
from video_thumbnail_creator import create_thumbnail

result = create_thumbnail(
    "/path/to/video.mp4",
    overlay_title="My Film",
    output_dir="/output/",
    fanart=True,
)
print(result.poster_path)   # absolute path to the poster JPEG
print(result.fanart_path)   # absolute path to the fanart JPEG (or None)
print(result.reasoning)     # AI explanation
```

Works with image files too (JPEG, PNG, TIFF, HEIC) — ffmpeg is not required:

```python
result = create_thumbnail("/path/to/cover.jpg", format="poster")
```

#### Mid-Level API – `ThumbnailSession`

`ThumbnailSession` gives you full control over each step. Use the context
manager for automatic cleanup of temporary files.

**Automatic** — AI decides everything, step by step:

```python
from video_thumbnail_creator import ThumbnailSession

with ThumbnailSession("/path/to/video.mp4") as session:
    suggestion = session.suggest_frame(title="My Film", description="A documentary")
    session.select_frame(suggestion["frame_index"])
    crop = session.suggest_crop()
    result = session.compose(
        crop_position=crop["crop_position"],
        overlay_title="My Film",
        output_dir="/output/",
    )
```

**Suggest** — AI suggests, caller confirms or overrides:

```python
with ThumbnailSession("/path/to/video.mp4") as session:
    print(session.mosaic_path)   # show the mosaic to the user
    suggestion = session.suggest_frame()
    # ... show suggestion to user, let them confirm or pick a different index ...
    chosen_index = int(input(f"Frame [{suggestion['frame_index']}]: ") or suggestion["frame_index"])
    session.select_frame(chosen_index)
    result = session.compose(crop_position="center", output_dir="/output/")
```

**Manual** — caller decides everything, mosaic is just a visual aid:

```python
with ThumbnailSession("/path/to/video.mp4") as session:
    # Display session.mosaic_path to the user, then:
    session.select_frame(7)
    result = session.compose(
        crop_position="center-left",
        overlay_title="My Film",
        format="poster",
        output_path="/output/my-film-poster.jpg",
        fanart=True,
    )
```

**Image input** — no frame extraction needed:

```python
with ThumbnailSession("/path/to/cover.jpg") as session:
    # session._frame_selected is already True; call compose() directly
    result = session.compose(format="poster", output_dir="/output/")
```

#### Low-Level API – Individual Functions

Use the building blocks directly when you need maximum control:

```python
from video_thumbnail_creator import (
    get_video_properties,
    extract_frames,
    create_mosaic,
    extract_single_frame_highres,
    compose_poster,
    compose_fanart,
    detect_badges,
)

props = get_video_properties("/path/to/video.mp4")
frame_paths = extract_frames("/path/to/video.mp4", "/tmp/frames/")
mosaic = create_mosaic(frame_paths, "/tmp/mosaic.jpg")
highres = extract_single_frame_highres("/path/to/video.mp4", 5, "/tmp/highres.jpg")
badges = detect_badges(props)
compose_poster(highres, "center", "My Title", "/output/poster.jpg", badges=badges)
compose_fanart(highres, "/output/fanart.jpg", is_4k=props["is_4k"])
```

---

### CLI Integration (Subprocess)

Because stdout contains only the file path (or clean JSON), this tool is also
easy to integrate via `subprocess` when you cannot import it directly:

```python
import subprocess, json

result = subprocess.run(
    ["video-thumbnail-creator", "extract", video_path, "--mode", "auto", "--json"],
    capture_output=True, text=True, check=True,
    env={**os.environ, "CLAUDE_API_KEY": "sk-ant-..."},
)
data = json.loads(result.stdout)
poster_path = data["poster_path"]
```

---

## Recent Changes

> This section lists the release notes for the three most recent versions.
> For older versions, see the [Releases](https://github.com/kurmann/video-thumbnail-creator/releases) page
> or the respective version on [PyPI](https://pypi.org/project/video-thumbnail-creator/#history).

### v1.6.0

- **Default style**: `internet` style is now the default poster style; the legacy `default` style has been removed
- **Library API**: `ThumbnailSession` class for step-by-step thumbnail creation with full control over each stage (frame selection, crop position, composition)
- **Convenience function**: `create_thumbnail()` one-liner for fully automatic thumbnail generation from Python code
- **Three integration levels**: High-level (`create_thumbnail`), mid-level (`ThumbnailSession`), and low-level (individual functions like `extract_frames`, `compose_poster`, etc.)

### v1.5.0

- **Built-in named styles**: Replaces external `poster_templates.toml` with a built-in style registry; new `--style` CLI flag and `styles` subcommand to list available styles
- **New `internet` style**: Category label rendered as overlay band at the top of the square image, matte black text area (no blur), large fonts — optimised for YouTube/internet video posters
- **`default` style**: Classic blurred-background layout retained as the built-in default

### v1.4.6
- **Layout**: Increase overlay category font size in poster composer by @Copilot in #34
- **Layout**: Remove rounded frame border from poster layout; promote separator line as sole divider
- **Code cleanup**: Modularize cli.py, centralize fonts/AI-retry, remove dead code, optimize hot paths

### v1.4.2

- **Batches**: Rename FHD→HD, add HFR badge support (4K60/HD48), remove PNG logo config for technical badges
- **Thumbnail selection**: Prioritise recurring presenter over topic objects in thumbnail selection

### v1.4.1

- **Library API**: New `ThumbnailSession` class for step-by-step thumbnail creation with full control over each stage (frame selection, crop position, composition)
- **Convenience function**: New `create_thumbnail()` one-liner for fully automatic thumbnail generation from Python code
- **Three integration levels**: High-level (`create_thumbnail`), mid-level (`ThumbnailSession`), and low-level (individual functions like `extract_frames`, `compose_poster`, etc.)
