# Video Frame Extractor: Keyframes & Thumbnails (`enisbodlli/video-keyframe-extractor`) Actor

Extract frames from video files, uploads and video podcast feeds: a set number, one every N seconds, scene changes, keyframes or listed times, plus a contact sheet. Skips blank and repeated frames. JPG, PNG or WebP with links. Pay per frame stored.

- **URL**: https://apify.com/enisbodlli/video-keyframe-extractor.md
- **Developed by:** [Enis Bodlli](https://apify.com/enisbodlli) (community)
- **Categories:** Videos, AI, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 frames

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Video Frame Extractor: Keyframes & Thumbnails

Turn **videos into images**: a set number of **frames**, one every few seconds, a frame at each
**scene change**, the video's own **keyframes**, or frames at the **times you list**, plus an
optional **contact sheet** that shows the whole video on one image. Give it links to video files,
web pages that embed a video, **video podcast RSS feeds**, or upload your own files. Every frame
comes back as a JPG, PNG or WebP with a link and its exact timestamp. It is built for people who
make thumbnails and previews, teams that feed video into AI vision models, researchers who code
footage, and AI agents that need to see what is in a video.

- **Only useful frames, and only those are charged.** Black, white and faded frames, and frames
  that repeat the one before (a slide that stays on screen), are skipped for free.
- **Scenes with their scores, or keyframes for speed.** Scene mode gives each cut's strength;
  keyframe mode reads a long video in a fraction of the time.
- **Pay per frame, $2 per 1,000 frames** ($1.50 on Gold), with nothing to pay for failed links,
  skipped frames or platform usage.

The easiest way to try it: keep the example input and press **Start**. You get up to six frames
and a contact sheet from each of two short films in about 10 seconds.

### What you get for each frame

```json
{
    "status": "extracted",
    "type": "frame",
    "videoUrl": "https://media.w3.org/2010/05/sintel/trailer.mp4",
    "videoTitle": "trailer.mp4",
    "mode": "count",
    "frameIndex": 1,
    "timestampSeconds": 4.375,
    "timecode": "00:00:04.375",
    "imageUrl": "https://api.apify.com/v2/key-value-stores/STORE_ID/records/frame-aed5bc55391c5011-00001.jpg",
    "imageWidth": 854,
    "imageHeight": 480,
    "contactSheetUrl": "https://api.apify.com/v2/key-value-stores/STORE_ID/records/contact-sheet-aed5bc55391c5011.jpg",
    "durationSeconds": 52.208,
    "fps": 24,
    "videoCodec": "h264",
    "billedCount": 1
}
```

### What you can do with it

- **Thumbnails and preview strips** for a video library, a course or a podcast's video episodes.
- **Frames for AI vision models**: send the frames to GPT, Claude or Gemini to describe, tag,
  moderate or search what is in a video, without sending the whole file.
- **Scene and shot lists** for editing, research or archive work, with a timecode per cut.
- **Slide capture** from recorded talks and webinars: one image per slide, repeats left out.
- **Training data** for computer vision: frames at a fixed rate, at the size and format you need.
- **A new video podcast episode, as pictures**: point it at the feed and schedule it.

### How to use it

1. Paste one or more links in **Video links**, or upload files in **Upload video files**. A direct
   file link (MP4, MOV, WEBM, MKV, AVI, FLV, MPEG-TS and more), a page that embeds a video file, and
   a video podcast RSS or Atom feed all work.
2. Choose **How frames are chosen**:
   - `count`: a set number of frames spread evenly over the video (default 10).
   - `interval`: one frame every few seconds, from 0.1 s upwards.
   - `scenes`: a frame at each cut or scene change, with its score; lower **Scene change
     sensitivity** finds softer cuts.
   - `keyframes`: the frames the video was encoded around, the fastest way through a long video.
   - `timestamps`: frames at the times you list, as seconds (`90`), clock times (`1:30`) or a share
     of the video (`50%`).
3. Optionally set **Start at** and **End at** to work on part of each video, a **Maximum width**,
   the **Image format** and quality, and switch on **Make a contact sheet**.
4. Press **Start**. Frames appear in the dataset as they are stored; open the **Overview** tab to
   see them as images, or download the dataset as JSON, CSV or Excel.

#### How long it takes

Measured on Apify at the default 512 MB on 2026-10-20, with a 10-minute 720p film:

| What | Images | Time |
|---|---|---|
| One frame every 10 seconds | 56 | 10 s |
| Scene changes, with a contact sheet | 123 | 17 s |
| One frame every 0.6 seconds | 784 | 2 min 17 s |

Each video is downloaded once, so a large file adds its download time. Videos are processed one
after another; more memory gives the run more CPU, which shortens long videos.

### Pricing

You pay per image stored, a frame or a contact sheet, plus Apify's run start of $0.00005 per GB of
memory ($0.00005 at the default 512 MB). Platform usage is included, so there is nothing else to
pay.

| Apify plan tier | Per frame | Per 1,000 frames |
|---|---|---|
| Free, Bronze | $0.002 | $2.00 |
| Silver | $0.00176 | $1.76 |
| Gold and above | $0.0015 | $1.50 |

Worked examples at the Free and Bronze price:

- The example input, 11 frames and 2 contact sheets: 13 x $0.002 = **$0.026**.
- 1,000 frames: **$2.00**, or $1.76 on Silver and $1.50 on Gold.
- One frame every 10 seconds from an hour of video: 360 frames, **$0.72**.
- Ten thumbnails and a contact sheet for each of 100 videos: 1,100 images, **$2.20**.
- A blank or repeated frame, a link that fails, or a video with no usable frame: nothing.

On the Apify free plan, the monthly $5 of credit pays for about 2,500 frames.

**Maximum frames per video** caps what each video can cost, and you can set a maximum charge per run:
the Actor then stores only as many frames as that amount pays for, keeps a place for the contact
sheet, and finishes as succeeded with a message that says the limit stopped it. A run that Apify
restarts on another server carries on where it stopped and charges no frame twice.

### Input

```json
{
    "videoUrls": [
        "https://media.w3.org/2010/05/sintel/trailer.mp4",
        "https://feeds.twit.tv/twit_video_hd.xml"
    ],
    "maxVideosPerFeed": 1,
    "mode": "scenes",
    "sceneThreshold": 0.3,
    "maxFramesPerVideo": 50,
    "startTime": "0:30",
    "imageFormat": "webp",
    "maxWidth": 1280,
    "contactSheet": true,
    "contactSheetColumns": 5
}
```

| Field | What it does | Default |
|---|---|---|
| `videoUrls` | Links to video files, pages that embed one, or video podcast feeds | example films |
| `videoFiles` | Uploaded files, or links to records in an Apify key-value store | none |
| `maxVideosPerFeed` | How many of a feed's newest videos are processed | 3 |
| `mode` | `count`, `interval`, `scenes`, `keyframes` or `timestamps` | `count` |
| `frameCount` | Frames per video in `count` mode | 10 |
| `intervalSeconds` | Seconds between frames in `interval` mode | 10 |
| `sceneThreshold` | Scene change sensitivity, 0.01 to 0.99 | 0.3 |
| `timestamps` | Times for `timestamps` mode | none |
| `maxFramesPerVideo` | At most this many frames per video in `interval`, `scenes` and `keyframes` | 100 |
| `startTime`, `endTime` | The part of each video to work on | whole video |
| `skipBlankFrames` | Leave out black, white and faded frames, free | on |
| `skipDuplicateFrames` | Leave out a frame that looks the same as the last one kept, free | on |
| `imageFormat` | `jpg`, `png` or `webp` | `jpg` |
| `maxWidth` | Scale wider frames down to this width; 0 keeps the video's size | 1280 |
| `imageQuality` | JPG and WebP quality, 1 to 100 | 85 |
| `contactSheet` | One grid image per video with every frame and its time | off |
| `contactSheetColumns` | Frames per row on the contact sheet | 4 |

### Output

One dataset row per image: `type` is `frame` or `contact-sheet`. Every row also carries the video's
duration, size, frame rate, codec and whether it has sound, and for a feed the feed's and episode's
title and date. A link that fails gives one row with `status` `failed` and the reason.

```json
[
    {
        "status": "extracted",
        "type": "frame",
        "inputUrl": "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
        "sourceType": "file",
        "videoIndex": 2,
        "videoTitle": "Big_Buck_Bunny_360_10s_1MB.mp4",
        "mode": "count",
        "frameIndex": 3,
        "timestampSeconds": 4.167,
        "timecode": "00:00:04.167",
        "sceneScore": null,
        "imageUrl": "https://api.apify.com/v2/key-value-stores/STORE_ID/records/frame-822a3b8b2647449b-00003.jpg",
        "imageKey": "frame-822a3b8b2647449b-00003.jpg",
        "imageFormat": "jpg",
        "imageWidth": 640,
        "imageHeight": 360,
        "imageBytes": 37086,
        "framesInVideo": 6,
        "durationSeconds": 10,
        "width": 640,
        "height": 360,
        "fps": 30,
        "videoCodec": "h264",
        "hasAudio": false,
        "billedEvent": "frame",
        "billedCount": 1,
        "error": null,
        "errorKind": null
    },
    {
        "status": "extracted",
        "type": "contact-sheet",
        "videoIndex": 2,
        "videoTitle": "Big_Buck_Bunny_360_10s_1MB.mp4",
        "imageUrl": "https://api.apify.com/v2/key-value-stores/STORE_ID/records/contact-sheet-822a3b8b2647449b.jpg",
        "imageWidth": 1308,
        "imageHeight": 380,
        "framesInVideo": 6,
        "billedEvent": "frame",
        "billedCount": 1
    },
    {
        "status": "failed",
        "type": null,
        "inputUrl": "https://www.youtube.com/watch?v=abc",
        "imageUrl": null,
        "billedCount": 0,
        "error": "YouTube links are not supported: this Actor takes frames from video files, web pages that embed a video file, and video podcast feeds. Use a direct link to a video file you may use, or upload it in \"videoFiles\".",
        "errorKind": "unsupported-platform"
    }
]
```

The images themselves are in the run's key-value store, named `frame-<video>-<number>` and
`contact-sheet-<video>`. The `RUN_SUMMARY` record there counts the videos, frames stored, frames
skipped as blank or repeated, failed inputs and what was charged.

### Limits

- **No YouTube, TikTok, Instagram, Vimeo, Facebook, X or other platform pages,** and no Google
  Drive or Dropbox share pages. Those links fail with `unsupported-platform` before any download and
  cost nothing. Use a direct link to a file you may use, or upload it.
- **No streaming playlists.** HLS (`.m3u8`) and DASH (`.mpd`) streams are not read; a page whose only
  video is a stream fails with `not-media` and costs nothing.
- **Only videos that open without a login.** Private, members-only and paywalled videos are not
  supported.
- **File size follows memory.** Up to about 600 MB per video at the default 512 MB, 1.2 GB at
  1 GB, and more with more memory.
- **`count` mode can return fewer frames than asked.** A frame that falls on a blank or repeated
  picture is skipped, not replaced, so the frames stay evenly spread. The `interval`, `scenes` and
  `keyframes` modes read on past skipped frames to reach their maximum.
- **The whole file is downloaded**, even when only part of it is used.
- **No text recognition, captions or AI descriptions.** The Actor returns images and timestamps;
  send the images to the model of your choice.

### FAQ

#### Is it legal to extract frames from a video?

The Actor reads only public files, pages and feeds that open without a login, and it does not get
around any blocking. Copyright in a video stays with its owner: frames for analysis, research,
search, previews of your own videos or quoting are common, while republishing someone else's frames
can need permission. Check the terms of the source and the laws that apply to you; this is not legal
advice.

#### Why did a link fail?

The `error` field says why in plain words and `errorKind` gives the cause. The usual ones: the link
is a platform page (`unsupported-platform`), the server answered 404 (`not-found`) or refused the
download (`refused`), the link is an image, text or a page without a video file (`not-media`), the
file is audio only (`no-video`), or the file is over the size limit (`too-large`). A failed link is
never charged, and the other links in the run carry on.

#### Why did I get fewer frames than I asked for?

Blank and repeated frames are skipped for free. A video that is mostly black, a still image or a
slide that stays on screen gives fewer frames; switch off **Skip blank frames** or **Skip repeated
frames** to keep them all. In `scenes` mode, a calm video has few cuts: lower the sensitivity.

#### Which mode is fastest for a long video?

`keyframes` decodes only the keyframes, and `count` or `interval` with frames 15 seconds or more
apart jumps straight to each time. `scenes` and dense `interval` runs read every frame of the
chosen range.

#### Can I use the frames with an AI model?

Yes. Each `imageUrl` opens without an Apify login, so you can pass it straight to a vision model,
or run the Actor from an AI agent through the Apify MCP server and read the dataset back.

#### Can I use it through the API or on a schedule?

Yes. Start it through the Apify API, the Python or JavaScript client, Make, Zapier or n8n, and save
an input as a task to run it on a schedule. Every result has the same fields.

### More Actors from this developer

- [Podcast & Audio Transcriber](https://apify.com/enisbodlli/podcast-audio-transcriber): podcasts,
  MP3s and videos to text with Whisper, with timed segments and subtitles.
- [ATS Job Postings: Workday, Greenhouse, Lever & Ashby](https://apify.com/enisbodlli/ats-job-postings):
  every open job of the companies you choose.
- [Website Contact Scraper](https://apify.com/enisbodlli/website-contact-scraper): emails, phones
  and social profiles from company websites.
- [Email Validator & List Cleaner](https://apify.com/enisbodlli/email-validator): syntax and domain
  checks for a mailing list.
- [Handelsregister Scraper](https://apify.com/enisbodlli/handelsregister-scraper) and
  [European Company Registry Search](https://apify.com/enisbodlli/eu-company-registry-search):
  company records from official registers.

# Changelog

This Actor's version history is a separate document: https://apify.com/enisbodlli/video-keyframe-extractor/changelog.md

# Actor input Schema

## `videoUrls` (type: `array`):

One link per line. Works with direct video files (MP4, MOV, WEBM, MKV, AVI, FLV, MPEG-TS and more), web pages that embed a video file, and video podcast RSS or Atom feeds. YouTube, TikTok, Vimeo and other platform pages are not supported: use a direct link to a file you may use.

## `videoFiles` (type: `array`):

Video files from your computer, stored in an Apify key-value store, or links to records in one. Up to about 600 MB each at the default 512 MB of memory, 1.2 GB at 1 GB; more memory allows larger files.

## `maxVideosPerFeed` (type: `integer`):

For a feed link: how many of its newest entries with a video file are processed. Single video links ignore this.

## `mode` (type: `string`):

count: a fixed number of frames spread evenly over the video. interval: one frame every few seconds. scenes: a frame at each cut or scene change. keyframes: the video's own keyframes, the fastest way through a long video. timestamps: frames at the times you list.

## `frameCount` (type: `integer`):

For mode count: how many frames each video gives, spread evenly over the chosen time range.

## `intervalSeconds` (type: `number`):

For mode interval: one frame every this many seconds. 0.5 gives two frames per second; 60 gives one a minute.

## `sceneThreshold` (type: `number`):

For mode scenes: how different a frame must be from the one before it to count as a new scene, from 0.01 to 0.99. Lower finds more scenes; 0.3 suits most videos, 0.15 to 0.2 suits slides and screen recordings.

## `timestamps` (type: `array`):

For mode timestamps: one time per line, as seconds (90), a clock time (1:30 or 01:02:03.5), or a share of the video (50%).

## `maxFramesPerVideo` (type: `integer`):

For modes interval, scenes and keyframes: at most this many frames from each video, counted from the start of the chosen range.

## `startTime` (type: `string`):

Skip the part of each video before this time, as seconds (90) or a clock time (1:30). Leave empty to start at the beginning.

## `endTime` (type: `string`):

Stop at this time in each video, as seconds or a clock time. Leave empty to go to the end.

## `skipBlankFrames` (type: `boolean`):

Leave out frames that are one flat colour, such as black frames between scenes and fades. Skipped frames are not charged.

## `skipDuplicateFrames` (type: `boolean`):

Leave out a frame that looks the same as the last frame kept, such as a slide that stays on screen. Skipped frames are not charged.

## `imageFormat` (type: `string`):

jpg is small and suits most uses; png is lossless; webp is smaller than jpg at the same quality.

## `maxWidth` (type: `integer`):

Frames wider than this are scaled down, keeping their shape; narrower frames keep their size. 0 keeps every frame at the video's own size.

## `imageQuality` (type: `integer`):

For jpg and webp, from 1 to 100. Higher looks better and makes larger files. png ignores it.

## `contactSheet` (type: `boolean`):

Add one image per video with its frames in a grid, each labelled with its time. It is stored and charged as one more image. Needs at least two frames; sheets show up to 100 frames, spread evenly.

## `contactSheetColumns` (type: `integer`):

How many frames sit side by side on a contact sheet.

## Actor input object example

```json
{
  "videoUrls": [
    "https://media.w3.org/2010/05/sintel/trailer.mp4",
    "https://feeds.twit.tv/twit_video_hd.xml"
  ],
  "videoFiles": [
    "https://api.apify.com/v2/key-value-stores/STORE_ID/records/interview.mp4"
  ],
  "maxVideosPerFeed": 3,
  "mode": "count",
  "frameCount": 6,
  "intervalSeconds": 10,
  "sceneThreshold": 0.3,
  "timestamps": [
    "0:05",
    "1:30",
    "50%"
  ],
  "maxFramesPerVideo": 100,
  "startTime": "0:30",
  "endTime": "10:00",
  "skipBlankFrames": true,
  "skipDuplicateFrames": true,
  "imageFormat": "jpg",
  "maxWidth": 1280,
  "imageQuality": 85,
  "contactSheet": true,
  "contactSheetColumns": 4
}
```

# Actor output Schema

## `results` (type: `string`):

One item per frame or contact sheet in the run's default dataset: the image link, its time in the video, the video's length, size, frame rate and codec, and what the item was charged.

## `images` (type: `string`):

The frame and contact sheet images, stored in the run's default key-value store; each item's imageUrl links to one.

## `runSummary` (type: `string`):

What the run did: videos processed, frames stored, blank and repeated frames skipped, which inputs failed and why, what was charged, and why the run stopped.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "videoUrls": [
        "https://media.w3.org/2010/05/sintel/trailer.mp4",
        "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4"
    ],
    "frameCount": 6,
    "contactSheet": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("enisbodlli/video-keyframe-extractor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "videoUrls": [
        "https://media.w3.org/2010/05/sintel/trailer.mp4",
        "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
    ],
    "frameCount": 6,
    "contactSheet": True,
}

# Run the Actor and wait for it to finish
run = client.actor("enisbodlli/video-keyframe-extractor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "videoUrls": [
    "https://media.w3.org/2010/05/sintel/trailer.mp4",
    "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4"
  ],
  "frameCount": 6,
  "contactSheet": true
}' |
apify call enisbodlli/video-keyframe-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,enisbodlli/video-keyframe-extractor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/gnX3s1YPagAlTZleM/builds/F9Chp6eNu2hME4IMq/openapi.json
