# Video Frame Extractor - Screenshots, Scenes, Contact Sheet (`tiger19935/video-frame-extractor`) Actor

Extract frames from video URLs (mp4, webm, mov, mkv, m3u8) with FFmpeg: every N seconds, N evenly spaced, scene changes or exact timestamps. JPG/PNG/WebP, public links, optional contact sheet. Pay per video and frame.

- **URL**: https://apify.com/tiger19935/video-frame-extractor.md
- **Developed by:** [Andrew Boiko](https://apify.com/tiger19935) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 video processeds

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 - Screenshots, Scene Changes and Contact Sheets (FFmpeg)

Turn video links into image files. Give the Actor direct video URLs (MP4, WebM, MOV, MKV or an HLS `.m3u8` playlist) and it extracts frames with **FFmpeg**: every N seconds, N evenly spaced frames, frames at detected **scene changes**, or frames at **exact timestamps**. Every frame is stored as JPG, PNG or WebP with a public link, and you can get one **contact sheet** per video.

**Pay only for videos that were processed and frames that were stored.** Unreadable videos, HTTP errors and anything over your limits are never charged. New Apify accounts can try it with the free plan credit.

### Who is it for

- Teams that need thumbnails or preview stills for a video library.
- Content, SEO and social teams creating cover images, storyboards or slide-style summaries.
- Developers feeding frames into OCR, image classification or vision-model pipelines (the dataset gives you stable image URLs and timestamps).
- QA and compliance teams that need periodic snapshots of a video, or the frame at a known time code.

### What it does and does not do

- Reads the video **over HTTP with range requests**: for interval, count and timestamp modes FFmpeg seeks to each position and only reads the part of the file it needs, it does not download the whole video. `scenes` mode must decode the whole video once.
- Needs a **direct video file or HLS playlist URL**. Pages such as YouTube, Vimeo, TikTok or Instagram are not video files and will fail with a clear error. DRM-protected or encrypted streams are not supported. Live streams without a known duration only work in `timestamps` mode.
- Frames are exact to the video's frame grid at the requested time. Scene detection runs at 5 frames per second on a 320 px wide copy, so scene times are accurate to about 0.2 s.
- No audio extraction, no video clips, no OCR and no face detection.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `videoUrls` | array | required (or `videoFiles`) | Direct video links, one per row. |
| `videoFiles` | array | `[]` | Videos stored in an Apify key-value store: `storeId/recordKey`, read with the run's token. |
| `mode` | `interval`, `count`, `scenes`, `timestamps` | `count` | How frame positions are chosen. |
| `intervalSeconds` | integer | 10 | Mode `interval`: seconds between frames, starting at 0. |
| `frameCount` | integer 1-300 | 8 | Mode `count`: evenly spaced frames, centered in equal slices. |
| `sceneThreshold` | string "0.1".."0.9" | "0.3" | Mode `scenes`: lower finds more (soft) cuts, higher only hard cuts. |
| `timestamps` | array | `[]` | Mode `timestamps`: `83.5`, `01:23`, `00:01:23.500`, `90s`. |
| `maxFramesPerVideo` | integer 1-300 | 20 | Hard cap per video (protects your budget). |
| `format` | `jpg`, `png`, `webp` | `jpg` | Image format. |
| `maxWidth` | integer 0-7680 | 1280 | Scale down wider frames (never up). `0` keeps the original size. |
| `quality` | integer 1-100 | 85 | JPG/WebP quality. |
| `makeContactSheet` | boolean | false | One tiled JPG per video. Included in the price. |
| `contactSheetColumns` | integer 1-12 | 5 | Grid columns of the contact sheet (320 px tiles). |
| `maxDurationMinutes` | integer | 120 | Longer videos are rejected without charge. |
| `maxVideoSizeMb` | integer | 2048 | Files larger than this (as reported by the server) are rejected without charge. |
| `includeFailed` | boolean | false | Add free rows with `status: "failed"` for videos that failed. |

### Output

The dataset has one row per frame (`type: "frame"`) and one summary row per video (`type: "video"`). Frame images are in the default key-value store; `frameUrl` is the public link.

Frame row:

```json
{
  "type": "frame",
  "id": "41be81f899:2",
  "videoId": "41be81f899",
  "videoUrl": "https://example.com/video.mp4",
  "frameIndex": 2,
  "timestampSec": 4.5,
  "timestampLabel": "00:00:04.500",
  "frameUrl": "https://api.apify.com/v2/key-value-stores/<storeId>/records/frames-41be81f8-0002.jpg",
  "recordKey": "frames-41be81f8-0002.jpg",
  "format": "jpg",
  "width": 1280,
  "height": 720,
  "bytes": 84231,
  "sceneScore": null,
  "extractedAt": "2026-10-14T10:57:10.203Z",
  "source": "ffmpeg"
}
```

Video row:

```json
{
  "type": "video",
  "id": "41be81f899",
  "videoUrl": "https://example.com/video.mp4",
  "status": "ok",
  "mode": "count",
  "durationSec": 12,
  "resolution": "1920x1080",
  "width": 1920,
  "height": 1080,
  "fps": 29.97,
  "codec": "h264",
  "container": "mov",
  "bitrate": 3439000,
  "hasAudio": true,
  "frames": 4,
  "contactSheetUrl": "https://api.apify.com/v2/key-value-stores/<storeId>/records/contact-sheet-41be81f8.jpg",
  "warnings": [],
  "processedAt": "2026-10-14T10:57:10.221Z",
  "source": "ffmpeg"
}
```

`frameIndex` starts at 1. In `scenes` mode `sceneScore` holds FFmpeg's scene change score (0-1) for that frame. Record keys use `frames-<videoHash>-<n>.<ext>` (key-value store keys cannot contain slashes).

The run also writes `OUTPUT` (counts, charged events, failed videos with reasons, warnings, stop reason) and a short `RESULTS_MD` to the key-value store.

### Pricing

Pay-per-event, **pay only for videos processed and frames stored**:

| Event | Price | When |
|---|---|---|
| `video` | **$0.005** | One video successfully opened, probed and processed with at least one frame stored. |
| `frame` | **$0.001** | One frame image extracted and stored. |

Example: 3 frames from one video cost $0.005 + 3 x $0.001 = $0.008. 100 frames from 5 videos cost $0.025 + $0.10 = $0.125.

Not charged: videos that cannot be opened, are not videos, are too long or too large, frames that FFmpeg could not produce, free `includeFailed` rows, and anything beyond your maximum cost per run (the run then stops cleanly and `OUTPUT.stoppedReason` is `budget`). The contact sheet is free. Apify may apply Bronze/Silver/Gold plan discounts.

### How it works

1. Each URL is checked (public address only, redirects followed with the same check) and its size is read with a HEAD request.
2. `ffprobe` reads duration, resolution, frame rate and codec.
3. Frame positions are planned for the chosen mode. `scenes` mode first runs one analysis pass with FFmpeg's scene filter.
4. FFmpeg extracts each frame with a fast input seek (`-ss` before `-i`), a few at a time per host.
5. Frames are saved to the key-value store, then charged and added to the dataset one by one.
6. Progress is saved after every frame. After a migration or restart the Actor continues and does not charge a video or frame twice.

### Limits and honest caveats

- The video host must answer from a normal HTTP(S) server. Signed URLs that expire, hosts that block data-center IPs and pages that need cookies or logins cannot be read.
- Seeking is fast for MP4 with the index (`moov`) at the start and for HLS. MP4 files with the index at the end, or very sparse keyframes, are slower.
- `scenes` mode reads and decodes the entire video; a 2-hour 1080p video can take many minutes. Use `maxDurationMinutes` to bound it.
- If no scene change reaches the threshold, one frame from the middle of the video is returned with a warning (lower `sceneThreshold` for more).
- Frames at the very end of a video are clamped slightly before the last frame.
- Frames are not de-duplicated: if you ask for 10 frames of a still image you get 10 near-identical images.

### Legal and ethical notes

- You are responsible for having the right to process the videos you submit. The Actor does not bypass access controls or DRM.
- Frames of people are personal data under GDPR and similar laws; make sure you have a lawful basis.
- Extracted frames are stored in your own Apify key-value store under its retention rules. Video URLs are only contacted to read the video.
- FFmpeg is used as a separate program (`ffmpeg-static` and `ffprobe-static` binaries). FFmpeg is licensed under the GPL/LGPL; see the FFmpeg project for details.

### FAQ

**Can I use YouTube links?**
No. YouTube pages are not direct video files, and downloading them may violate YouTube's terms. Use a direct file or playlist URL you are allowed to access.

**Does it download the whole video?**
Not in `interval`, `count` and `timestamps` modes: it seeks with range requests. `scenes` mode reads the whole file.

**What does a frame cost?**
$0.001 per frame plus $0.005 per video.

**How many frames can I get from one video?**
Up to 300 per video (`maxFramesPerVideo`), set by you.

**Can I get the frame at an exact time?**
Yes, use mode `timestamps` with values like `00:01:23.500`.

**What happens if my budget runs out mid-run?**
The Actor stops cleanly, keeps the frames already delivered and lists the rest in `OUTPUT`. Uncharged frames are deleted from the store.

**Is the order of dataset rows guaranteed?**
Frame rows of a video come first in time order, then the video summary row. With several videos the rows of different videos can interleave.

**Which formats are supported?**
Anything FFmpeg can decode over HTTP: H.264, HEVC, VP8/VP9, AV1 and more, in MP4, MOV, WebM, MKV, TS or HLS.

### Use via API

Replace `username~video-frame-extractor` with this Actor's ID and `YOUR_TOKEN` with your Apify API token.

```bash
curl -X POST "https://api.apify.com/v2/acts/username~video-frame-extractor/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"videoUrls":["https://example.com/video.mp4"],"mode":"count","frameCount":6,"format":"jpg"}'
```

```javascript
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('username~video-frame-extractor').call({
    videoUrls: ['https://example.com/video.mp4'],
    mode: 'interval',
    intervalSeconds: 10,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const frames = items.filter((x) => x.type === 'frame');
console.log(frames.map((f) => f.frameUrl));
```

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_TOKEN")
run = client.actor("username~video-frame-extractor").call(run_input={
    "videoUrls": ["https://example.com/video.mp4"],
    "mode": "scenes",
    "sceneThreshold": "0.3",
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    if item["type"] == "frame":
        print(item["timestampLabel"], item["frameUrl"])
```

### Integrations

Connect the Actor to Make, n8n, Zapier or Google Sheets with the Apify integrations: start a run with a list of video URLs, then use the `frameUrl` column to save images to Drive, a CMS or a vision-model step. Webhooks tell your system when a run finishes.

### Support

Open an issue from the Actor page with the run link and the video URL (if it is public). Please do not send videos you do not have rights to.

### More tools by the same author

- [Website Screenshot API](https://apify.com/tiger19935/website-screenshot-api): Full-page and mobile screenshots in bulk
- [Image OCR Bulk](https://apify.com/tiger19935/image-ocr-bulk): Bulk OCR for images
- [Bulk Image and File Downloader](https://apify.com/tiger19935/bulk-image-file-downloader): Bulk download of images and files
- [PDF Text and Table Extractor](https://apify.com/tiger19935/pdf-text-table-extractor): PDF to text, tables and Markdown with OCR

# Actor input Schema

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

One direct video link per row: .mp4, .webm, .mov, .mkv or an HLS playlist (.m3u8, VOD). YouTube/Vimeo page links are NOT direct video files and will fail. The server must allow range requests for fast seeking (most do).

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

Videos already stored in an Apify key-value store of your account, one per row as 'storeId/recordKey' (or just 'recordKey' for this run's default store). Read through the Apify API with the run's token.

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

interval = one frame every N seconds. count = N evenly spaced frames (centered in equal slices). scenes = frames at detected scene changes (needs a full pass over the video, slower). timestamps = frames at exact times you list.

## `intervalSeconds` (type: `integer`):

Mode 'interval': seconds between frames (starting at 0). Example: 10 on a 60 s video gives 6 frames. At most 'Max frames per video' frames are produced.

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

Mode 'count': how many evenly spaced frames to take (1-300). Frames are centered in equal slices, so the first frame is not a black intro frame.

## `sceneThreshold` (type: `string`):

Mode 'scenes': FFmpeg scene score threshold as a decimal number between 0.1 and 0.9. Lower = more frames (also soft cuts), higher = only hard cuts. 0.3 is a good start. Analysis runs at 5 frames per second on a 320 px wide copy, so times are accurate to about 0.2 s.

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

Mode 'timestamps': one time per row, as seconds (83.5), MM:SS (01:23), HH:MM:SS or HH:MM:SS.mmm (00:01:23.500). Times beyond the end of the video are skipped with a warning.

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

Hard limit of frames taken from one video (1-300). Protects your budget: each frame is one 'frame' event.

## `format` (type: `string`):

jpg = small files (default), png = lossless and large, webp = small with good quality.

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

Frames wider than this are scaled down keeping the aspect ratio (never upscaled). 0 = keep the original size. Range 16-7680.

## `quality` (type: `integer`):

1-100, used for JPG and WebP only. 85 is a good balance of size and quality.

## `makeContactSheet` (type: `boolean`):

Also create one JPG image that tiles all frames of a video in a grid (link in the video row as 'contactSheetUrl'). Included in the price, no extra event.

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

Number of columns of the contact sheet grid (1-12). Each tile is 320 px wide.

## `maxDurationMinutes` (type: `integer`):

Videos longer than this are rejected without charge. Default 120 (2 hours), maximum 1440. Scene analysis of long videos can take many minutes.

## `maxVideoSizeMb` (type: `integer`):

Videos whose HTTP size (HEAD / Content-Length, when the server reports it) is above this are rejected without charge. Default 2048 MB. Seek-based modes read only a small part of the file; 'scenes' mode reads all of it.

## `includeFailed` (type: `boolean`):

If on, videos that could not be processed get a free row with status 'failed' and an error message. They are never charged. Reasons are always listed in the OUTPUT record.

## Actor input object example

```json
{
  "videoUrls": [
    "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
  ],
  "videoFiles": [],
  "mode": "count",
  "intervalSeconds": 10,
  "frameCount": 3,
  "sceneThreshold": "0.3",
  "timestamps": [],
  "maxFramesPerVideo": 20,
  "format": "jpg",
  "maxWidth": 640,
  "quality": 85,
  "makeContactSheet": false,
  "contactSheetColumns": 5,
  "maxDurationMinutes": 120,
  "maxVideoSizeMb": 2048,
  "includeFailed": false
}
```

# Actor output Schema

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

All result rows.

## `summary` (type: `string`):

Counts, charged events and warnings.

# 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://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
    ],
    "mode": "count",
    "frameCount": 3,
    "maxWidth": 640
};

// Run the Actor and wait for it to finish
const run = await client.actor("tiger19935/video-frame-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://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"],
    "mode": "count",
    "frameCount": 3,
    "maxWidth": 640,
}

# Run the Actor and wait for it to finish
run = client.actor("tiger19935/video-frame-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://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
  ],
  "mode": "count",
  "frameCount": 3,
  "maxWidth": 640
}' |
apify call tiger19935/video-frame-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,tiger19935/video-frame-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/iBHYdgXNIt5vobu0w/builds/UhqlDPLG0w8KqB02r/openapi.json
