# Video Frame Extractor: Thumbnails & Frames from Video URLs (`ataism/video-frames`) Actor

Thumbnails and frames from direct video URLs or uploads: evenly spaced, every N seconds or at scene changes. JPEG/WebP, timestamps. $1.20 per 1,000 frames.

- **URL**: https://apify.com/ataism/video-frames.md
- **Developed by:** [Kaizoku Data](https://apify.com/ataism) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 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.

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: Thumbnails & Frames from Video URLs

Get thumbnails and frames out of the video files **you list or upload**: evenly spaced frames, one frame every N seconds, or the frames where the picture changes. Each frame is a JPEG or WebP in your run's key-value store with a public link, its **real timestamp**, size and SHA-256, plus one metadata row per video (length, resolution, codec, frame rate). Keyless, no browser, no proxy, no AI.

**Price: $1.20 per 1,000 frames ($0.0012 per frame saved).** Scene-change mode adds **$0.003 per started minute analysed** (more for 4K or very frequent keyframes), and big files add **$0.004 per started 100 MB above the first 100 MB**. No start fee on top of Apify's default. Only frames that are saved are charged; failed, blocked and skipped videos are free.

- **Interval:** N frames evenly spaced over the video, or one every N seconds, at most 100 per video by default (500 at most).
- **Scene changes (opt-in):** the first frame plus a frame at every change of picture, found by comparing keyframes.
- **JPEG or WebP,** quality and max width settings, optional **contact sheet** (up to 100 frames tiled in one image, not charged).
- **A row per frame** (video URL, index, timestamp, width, height, key, public link, SHA-256) **and a row per video** (measured duration, resolution, codec, fps, keyframes, warnings).
- **Direct video file URLs and uploads only** (mp4, webm, mov, mkv, avi, ogv and more): no YouTube, TikTok, Vimeo or other platform downloads. Private addresses are refused and each host's robots.txt is checked.
- **Quick start:** press *Start*. The prefilled input takes 5 frames from a 4.7 MB public-domain newsreel on Wikimedia Commons (about 5 seconds, $0.006).

### What it does

For each video URL (or uploaded file):

1. Checks the host's `robots.txt` for the file's path (our User-Agent is `video-frames-actor/0.1 (https://apify.com/ataism/video-frames)`). A disallowed path is **not downloaded** and gets the error `blocked-by-robots`, uncharged. A `Crawl-delay` in that robots.txt is honoured in full (never shortened): downloads from that host are spaced at least that far apart; above 2 seconds the host is skipped (free `slow-host` rows) unless you set `allowSlowHosts`, and above **10 seconds always** (these limits are for the default 1,024 MB; they shrink in proportion when you give the run more memory, 2.5 s at 4,096 MB, and grow when you give it less). The wait is billed as run time: 10 s per extra video of one host is about 9% of the $0.006 of a 5-frame job at $0.20 per compute unit, which is why longer delays are skipped and never shortened. A robots.txt that answers 429 or 5xx, or cannot be reached, is asked once more; if it still fails the host is treated as disallow-all (`robots-unreachable`, free): a 429 is never read as "allow everything". A host that refuses the download itself (401, 403 or 429) three times gets no more requests from the run (`host-refused`, free).
2. Downloads the whole file once (public addresses only, at most 5 redirects, each hop re-checked, size cap, stall and time limits). The first bytes must be a video container: **the file's bytes decide, not the extension or Content-Type**. Web pages, playlists (HLS and the like), audio files and everything else are rejected after a few KB.
3. Reads the video with ffprobe and **measures its real length from the video packets**, not from the header (a header can be missing or wrong). Length limits and billing use the measured value. The same pass finds every keyframe.
4. Picks the frames (below), decodes each one with ffmpeg and saves it to the key-value store.
5. Pushes one dataset row per frame and one per video, then charges.

ffmpeg opens only local files and only video container formats: a file that tries to pull in other files or URLs (a playlist or concat script disguised as a video) fails as `unreadable`.

#### Choosing frames

**Interval, keyframe seeking (default).** *Frames per video* = N gives N frames at the middle of N equal slices of the video (so never at exactly 0 s or the end). *Every N seconds* gives frames at 0, N, 2N and so on. In the default **keyframe** seeking, each wanted time is replaced by the **keyframe nearest to it**: a keyframe can be decoded without decoding anything before it, so this is the cheapest and fastest way to get a frame. Positions are only as exact as the keyframe spacing (often 2 to 10 seconds). When two wanted times share a keyframe you get one frame for them, not two (the video row says so in `warnings`), so a short video with few keyframes can return fewer frames than you asked for. Every frame row carries the **real timestamp** (`timestampSec`) and the time that was asked for (`requestedSec`).

**Interval, accurate seeking.** Each frame is the first one at or after the wanted time. ffmpeg has to decode from the keyframe before it up to that time, which costs compute: for long gaps between keyframes at high resolution the Actor takes the keyframe before the wanted time instead (the row says `snapped: true`), so decoding for one frame stays bounded for a file that reports its size and frame rate honestly. The guard cannot trust a file that lies, so there are further limits: ffmpeg itself refuses any frame above the run's pixel cap (4096 x 2160) even if the stream changes size after its header, the packet rate is used if it is higher than the frame rate in the header, every frame has a time limit (30 s at a keyframe, 60 s when accurately seeked), and a video is dropped after 2 frame timeouts, once more frames failed than were saved (at least 2), or once the decoding that produced no frame adds up to more than 120 s or to what is left of the run's 15-minute unpaid-work limit (this also stops a file that alternates good and bad frames). Whatever is decoded for a video that yields no frames counts toward the run's unpaid-work limit; the frames that were saved are delivered and charged.

**Scene changes.** The Actor compares each **keyframe** with the one before it (scaled to 320 px; the score is the mean brightness difference as a fraction of the full range: 0 = identical, 1 = black against white) and keeps the first frame plus every keyframe whose score is above the threshold (default 0.2) and at least *Minimum seconds between scenes* after the previous one. This is **not** a per-frame cut detector: it does not decode every frame (that costs about ten times as much, and would not be sustainable at the price). Video encoders normally put a keyframe at a cut, so most cuts are found at the right time; a video with only a fixed keyframe spacing and no keyframes at cuts can only show changes between keyframes. A video with a single keyframe gives only its first frame.

### Input

| Field | What it does |
|---|---|
| **Video file URLs** (`videoUrls`) | Direct `http(s)` links to video files, one per line. Default: one public-domain sample video. |
| **Uploaded files** (`videoFiles`) | Upload from your computer to a key-value store (the field offers this), or paste a record URL. See *Using your own files*. |
| **How to pick frames** (`method`) | `interval` (default) or `scene`. |
| **Frames per video** (`frames`) | Interval: N evenly spaced frames. Default 5. Ignored when *Every N seconds* is set. |
| **Every N seconds** (`everySeconds`) | Interval: a frame at 0, N, 2N... Minimum 0.5. `0` = use *Frames per video*. |
| **Seeking** (`seek`) | `keyframe` (default, fast) or `accurate` (exact time, more compute). Interval only; scene frames are always keyframes. |
| **Max frames per video** (`maxFramesPerVideo`) | Default 100, hard cap 500. With *Frames per video* above the cap you get the cap, spread over the whole video; with *Every N seconds* the first frames. |
| **Scene change threshold** (`sceneThreshold`) | 0.02 to 0.95, default 0.2. Lower finds more changes (and more false ones). |
| **Minimum seconds between scenes** (`minSceneGapSeconds`) | Default 1. |
| **Frame format** (`outputFormat`) / **Quality** (`quality`) | `jpeg` (default) or `webp`; 1 to 100, default 80. A saved frame is at most **4 MB**: a larger one (for example 4K at full width and quality 100) is re-encoded at a lower quality, and at a smaller width if that is not enough, and its row says `outputCapped: true`. |
| **Max width** (`maxWidth`) | Default 1280 px; frames are scaled down (never up), height follows. `0` keeps the video's own size. Up to 4096. Rotation and non-square pixels are applied. |
| **Contact sheet** (`contactSheet`) | Also save one image per video with up to 100 of its frames tiled together. Not charged. |
| **Max video file size (MB)** (`maxFileSizeMb`) | Default 500, hard cap 2000 and also limited by the run's disk (2 x memory). Larger files are skipped, uncharged. |
| **Max video length (minutes)** (`maxDurationMinutes`) | Default 30, hard cap 120, **measured from the video itself**. Longer videos are skipped, uncharged. |
| **Allow slow hosts** (`allowSlowHosts`) | Off by default. See *What it does*, step 1. The waiting is billed as run time on your account. |
| **Download timeout** (`downloadTimeoutSecs`) | Per video, default 600 s. Time spent waiting for a `Crawl-delay` is not counted. |

Videos above 4096 x 2160 pixels (for example 8K) are refused with `resolution-too-high`, uncharged, whatever the run's memory: an 8K frame costs more compute than its price.

#### Example inputs

**1. Five evenly spaced thumbnails per video, 640 px WebP:**

```json
{
  "videoUrls": ["https://example.com/clips/intro.mp4", "https://example.com/clips/demo.webm"],
  "frames": 5,
  "outputFormat": "webp",
  "maxWidth": 640
}
```

**2. A frame every 10 seconds at the exact time, with a contact sheet:**

```json
{
  "videoUrls": ["https://example.com/lectures/week1.mp4"],
  "everySeconds": 10,
  "seek": "accurate",
  "maxFramesPerVideo": 60,
  "contactSheet": true
}
```

**3. One frame per scene (opt-in, charged per minute analysed):**

```json
{
  "videoUrls": ["https://example.com/trailers/film.mp4"],
  "method": "scene",
  "sceneThreshold": 0.25,
  "minSceneGapSeconds": 2,
  "maxFramesPerVideo": 40
}
```

#### Using your own files

The **Uploaded files** field uses Apify's file-upload editor: it lets you upload a file to a key-value store in your account (new or existing) or paste a URL, and hands the Actor a URL. The Actor then downloads that record like any other URL (uploads are your own files, so robots.txt is not consulted for `api.apify.com` records). Records of private stores are read with the run's own Apify token, but only when the URL you supplied is itself an `api.apify.com` key-value-store record URL; the token is never sent to any other host or on a redirect. For other private files use a signed or temporary link.

### Output

The dataset has three kinds of rows (`rowType`): `frame`, `video` and `error`. Frame rows come first for each video, then its video row.

Frame row:

```json
{
  "rowType": "frame",
  "inputIndex": 0,
  "videoUrl": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Liberators_attacks_axis_oil_fields_in_Romania%2C_Ploie%C5%9Fti.ogv",
  "fileName": "Liberators_attacks_axis_oil_fields_in_Romania,_Ploieşti.ogv",
  "status": "ok",
  "index": 1,
  "timestampSec": 38.805,
  "requestedSec": 38.709,
  "width": 320,
  "height": 240,
  "format": "jpeg",
  "bytes": 3522,
  "key": "0001-Liberators-attacks-axis-oil-fields-in-Romania-Ploies-ti-0002.jpg",
  "url": "https://api.apify.com/v2/key-value-stores/<storeId>/records/0001-Liberators-...-0002.jpg?signature=...",
  "sha256": "44101780c4d8f76117314f7f4b70fccc8b47300eff12c4a7a1aece5319a2d809",
  "processedAt": "2026-10-20T15:16:39.779Z"
}
```

Video row (after the frames of that video):

```json
{
  "rowType": "video",
  "inputIndex": 0,
  "videoUrl": "https://upload.wikimedia.org/...",
  "status": "ok",
  "method": "interval",
  "seek": "keyframe",
  "framesSaved": 5,
  "durationSec": 129.029,
  "headerDurationSec": 129.029,
  "container": "ogg",
  "videoCodec": "theora",
  "width": 320,
  "height": 240,
  "displayWidth": 320,
  "displayHeight": 240,
  "rotation": 0,
  "fps": 29.97,
  "hasAudio": true,
  "keyframes": 67,
  "sizeBytes": 4725511,
  "charged": { "frame": 5, "scene-minute": 0, "input-100mb": 0 },
  "warnings": []
}
```

`timestampSec` is the real time of the frame in the video (from ffmpeg), `requestedSec` the time that was asked for, and `outputCapped` is present (and `true`) only when the frame was over 4 MB at your quality and size and was re-encoded smaller; `width`, `height` and `bytes` describe the saved file. `snapped` is `true` only when an accurate frame was replaced by the keyframe before it. `durationSec` is measured from the video packets; `headerDurationSec` is what the file header claimed (the video row has a warning when they differ). The key-value store also holds `RUN_SUMMARY` (counts, events charged, bytes downloaded and stored, seconds spent downloading, measuring, detecting scenes and extracting, why the run stopped; `unwrittenErrorRows` only if an error row could not be written to the dataset even after a retry, in which case the run is marked failed) and `PROGRESS` (what lets a restarted run continue).

Error row (the video is skipped and **not charged**): `rowType: "error"`, `status: "error"`, `errorCode`, `message`.

#### Error codes

| Code | Meaning |
|---|---|
| `platform` | YouTube, TikTok, Instagram, Facebook, X, Vimeo, Twitch, Reddit, LinkedIn, Google and similar hosts are never downloaded (also after a redirect). Use a direct file link or upload the file. |
| `blocked` | Not an http(s) URL, or the host is a private, loopback or link-local address (also after a redirect). |
| `blocked-by-robots` / `robots-unreachable` | robots.txt disallows the path, or it could not be read (429, 5xx or a network error, after one retry: treated as disallow-all, free). |
| `slow-host` | robots.txt asks for a `Crawl-delay` above 2 s (and *Allow slow hosts* is off) or above 10 s (at 1,024 MB; the limits scale with the run's memory). |
| `host-refused` | The host answered 401, 403 or 429 three times in this run; its remaining videos are skipped without a request. |
| `http`, `dns`, `network`, `timeout`, `redirects`, `invalid-url` | The download failed. A 401, 403 or 429 is reported as `http`; there is no retry with other headers and no proxy. |
| `row-too-large`, `push-failed` | The result rows of the video were above 9 MB, or the dataset write failed, so they were not delivered. Nothing is charged; the frames stay in the key-value store. Use fewer frames per video. |
| `not-video` | The link returned a web page or a file that is not a video container (a playlist, audio file or other file type). |
| `too-large` | Above *Max video file size*. |
| `too-long` | The measured length is above *Max video length*. |
| `no-video` | The file has no video stream (audio only) or it is empty. |
| `resolution-too-high` | Above 4096 x 2160 pixels (8K is not supported). The limit is enforced by ffmpeg itself, also while the file is first read, so a video whose first frame is too big is refused before anything is decoded. The decoder also refuses any single frame above that size, so a stream that grows after its header gets no frames for the large part. |
| `scene-codec-unsupported` | Scene detection needs a codec where keyframes can be read without decoding the other frames: H.264, HEVC, AV1, VP8, MPEG-2, MPEG-4 or Theora. VP9 (most WebM files from the web), Motion JPEG and unmeasured codecs are refused, free of charge. Use the interval method. |
| `scene-too-heavy` | Scene detection on this video would cost more than 300 scene-minute events (a very long 4K video or one with a keyframe on almost every frame). Use the interval method. |
| `unreadable`, `ffmpeg`, `no-frame`, `timeout` | The video could not be read or decoded (damaged, unsupported codec, or too slow). |
| `budget` | The run's *Max total charge* cannot pay for this video. Raise it. |
| `unexpected` | A bug; the video is free. |

### Pricing

| Event | Price | Charged |
|---|---|---|
| `frame` | **$0.0012** | One per frame saved. The contact sheet is free. |
| `scene-minute` | **$0.003** | Scene method only: one per started minute of video analysed, **or** one per 30 megapixels of keyframes analysed if that is more (a 4K video, or a keyframe every second or two, costs more units). If detection stops early at the frame limit, only the part analysed counts. |
| `input-100mb` | **$0.004** | One per started 100 MB of the downloaded file above the first 100 MB (downloading and reading a big file takes run time however few frames you take). |

Charged only after the frames are saved and their rows are pushed; a video that fails or is skipped costs nothing. Before any decoding the Actor checks that your *Max total charge* can pay for the video; with too little left it takes fewer frames (and says so in `warnings`) or skips the video, and the run stops cleanly with `stoppedBy: "max-charge"`.

| Example | Frames | Other events | Total |
|---|---|---|---|
| The default sample (4.7 MB, 5 frames) | 5 x $0.0012 = $0.006 | none | **$0.006** |
| 20 thumbnails of a 60 MB clip | $0.024 | none | **$0.024** |
| 10 frames of a 10-minute 1080p video (300 MB) | $0.012 | 3 x $0.004 = $0.012 | **$0.024** |
| Scene mode on that video (1080p, a keyframe every 3 s: 14 scene units, 25 frames found) | $0.030 | 14 x $0.003 + 3 x $0.004 = $0.054 | **$0.084** |
| 100 frames of a 20-minute 4K video (700 MB) | $0.120 | 6 x $0.004 = $0.024 | **$0.144** |

The scene-minute event covers decoding every keyframe of the video; the frames you get are charged on top, as frames.

### Limits

- Per video: 500 MB and 30 minutes by default (hard caps 2 GB and 120 minutes), at most 100 frames by default (hard cap 500), at most 4096 x 2160 pixels (8K is refused), at most 3 million packets and 500,000 keyframes.
- Per run: at most 500 videos, one video at a time. After 20 videos that could not be processed, 1.5 GB downloaded for videos that failed, or 15 minutes spent on them, the run stops (`stoppedBy: "unpaid-work-cap"`).
- Memory: the default 1,024 MB is enough for 4K; the minimum is 512 MB. Disk is 2 x memory on Apify; the file size limit follows it.
- What is read: MP4, MOV, Matroska/WebM, AVI, MPEG-TS, MPEG-PS, FLV, ASF/WMV, Ogg and NUT containers. Frames are decoded only for the usual video codecs (H.264, HEVC, VP8, VP9, AV1, Theora, MPEG-1/2/4, H.263, VC-1/WMV, FLV, Motion JPEG, ProRes, DNxHD, FFV1, Cinepak and a few older ones; the list names ffmpeg decoders, for example libdav1d for AV1, and is checked against the ffmpeg of the Actor image by the self-test); other codecs give no frame, and what decodes also depends on the ffmpeg build. DRM-protected and streaming-only (HLS, DASH) sources are not supported.
- **Frame size:** a saved frame is at most 4 MB (storing bigger files would cost more than a frame earns). Frames at the defaults (1280 px, quality 80, about 0.15 MB) are never touched. A frame over 4 MB is re-encoded from the saved image, so the video is not decoded again: at most 5 re-encodes: quality 85, 70, 55 (only steps below your quality; frames far over the limit start at 70), then the width to 70% and 50% at quality 55. The result is the first that fits. A frame that cannot be made to fit is skipped like an undecodable frame and not charged.
- The frame limit applies to frames saved; the contact sheet holds up to 100 of them (at most 200 MB of frames is kept for it).

### Privacy and terms

You are responsible for having the right to process the videos you give it. The Actor reads only the URLs and files you supply and keeps nothing except the frames and rows in your own run's storage; it does no face or content analysis and does not look at the people in a video. Frames can of course show people if the video does: **if a video shows identifiable people, handling the frames is subject to the privacy and data protection rules that apply to you.** Platform videos are never downloaded. A host's robots.txt is respected. A block (403, 429, login page, challenge) is an error row; the Actor never tries to get around one.

### Use it via API, n8n or Make

cURL:

```bash
curl -X POST "https://api.apify.com/v2/acts/ataism~video-frames/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"videoUrls":["https://example.com/clips/intro.mp4"],"frames":5}'
```

JavaScript (`apify-client`):

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('ataism/video-frames').call({ videoUrls: ['https://example.com/clips/intro.mp4'], frames: 5 });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const frames = items.filter((x) => x.rowType === 'frame').map((x) => ({ time: x.timestampSec, url: x.url }));
```

Python (`apify-client`):

```python
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("ataism/video-frames").call(run_input={"videoUrls": ["https://example.com/clips/intro.mp4"], "frames": 5})
items = client.dataset(run["defaultDatasetId"]).list_items().items
frames = [(x["timestampSec"], x["url"]) for x in items if x["rowType"] == "frame"]
```

**n8n:** add the *Apify* node, operation **Run an Actor and get dataset**, Actor `ataism/video-frames`, put the input JSON in *Input JSON*. Filter the items on `rowType` = `frame` in the next node.

**Make:** add *Apify → Run an Actor* (Actor `ataism/video-frames`, input as JSON, wait for it to finish), then *Apify → Get Dataset Items* with the run's default dataset ID.

# Actor input Schema

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

Direct http(s) links to video files (mp4, webm, mov, mkv, avi, ogv and more), one per line. Private and local addresses are blocked, and robots.txt is respected. To use a file from your computer, upload it with the next field.

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

Upload files from your computer (the editor stores them in a key-value store), or paste key-value store record URLs.

## `method` (type: `string`):

Interval: evenly spaced frames, or one every N seconds. Scene changes: the first frame plus the frames where the picture changes (compares keyframes; costs a scene-minute event per started minute analysed, more for 4K or very frequent keyframes). Not offered for VP9 or Motion JPEG video (free error row): use Interval.

## `frames` (type: `integer`):

Interval method: this many frames, evenly spaced over the video (each from the middle of an equal slice). Ignored when "Every N seconds" is set.

## `everySeconds` (type: `number`):

Interval method: one frame at 0 s, N s, 2N s and so on. 0 means use "Frames per video" instead. At least 0.5.

## `seek` (type: `string`):

Keyframe (fast, the default): each frame is the keyframe nearest to the wanted time, so positions are only as exact as the keyframe spacing (often 2 to 10 s) and two wanted times can share a keyframe (you then get fewer frames). Accurate: the frame at the wanted time, which decodes up to the next keyframe's worth of video, so long videos with few keyframes are slower (and snapped to a keyframe when it would take too long). The real time of every frame is in timestampSec.

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

No video gives more frames than this, whatever the other settings ask for. Hard cap 500.

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

Scene method: how different a keyframe must be from the one before it to count as a new scene. It is the mean brightness difference as a fraction of the full range: 0.2 catches most cuts, lower finds more changes (and more false ones), higher only hard cuts. 0.02 to 0.95.

## `minSceneGapSeconds` (type: `number`):

Scene method: a change closer than this to the previous kept frame is skipped.

## `outputFormat` (type: `string`):

JPEG works everywhere. WebP is smaller for the same quality.

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

1 to 100. 80 is a good balance. A saved frame is at most 4 MB: a larger one (4K at full width and a high quality) is re-encoded at a lower quality, then a smaller width, and its row says outputCapped.

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

Frames wider than this are scaled down (height follows; never scaled up). 0 keeps the video's own size. Up to 4096 (videos above 4096 x 2160 are refused). A saved frame is at most 4 MB, see Quality.

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

Also save one image per video with up to 100 of its frames tiled together (thumbnail strip). Not charged.

## `maxFileSizeMb` (type: `integer`):

Larger files are skipped, not charged. Hard cap 2000 MB; also limited by the run's disk (2x memory), so give big files more memory. The first 100 MB of each video are included in the frame price; above that an input-100mb event applies per started 100 MB.

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

Longer videos are skipped, not charged. The length is measured from the video itself, not read from the file header. Hard cap 120 minutes.

## `allowSlowHosts` (type: `boolean`):

A site whose robots.txt asks for more than 2 seconds between requests is skipped unless this is on; the delay is then honoured in full, up to 10 s at the default 1,024 MB of memory (longer delays are always skipped; the limit shrinks in proportion when you give the run more memory).

## `downloadTimeoutSecs` (type: `integer`):

Per video, including redirects.

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

Internal: "selfTest" runs the maintainer health check and saves no items.

## Actor input object example

```json
{
  "videoUrls": [
    "https://upload.wikimedia.org/wikipedia/commons/6/6a/Liberators_attacks_axis_oil_fields_in_Romania%2C_Ploie%C5%9Fti.ogv"
  ],
  "method": "interval",
  "frames": 5,
  "everySeconds": 0,
  "seek": "keyframe",
  "maxFramesPerVideo": 100,
  "sceneThreshold": 0.2,
  "minSceneGapSeconds": 1,
  "outputFormat": "jpeg",
  "quality": 80,
  "maxWidth": 1280,
  "contactSheet": false,
  "maxFileSizeMb": 500,
  "maxDurationMinutes": 30,
  "allowSlowHosts": false,
  "downloadTimeoutSecs": 600,
  "mode": "run"
}
```

# Actor output Schema

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

One row per frame with its public link, real timestamp and size, one row per video with its metadata, and one row per skipped video.

## `frames` (type: `string`):

The key-value store holding the frame images and contact sheets.

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

Counts, events charged, bytes downloaded and stored, seconds spent per phase, 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://upload.wikimedia.org/wikipedia/commons/6/6a/Liberators_attacks_axis_oil_fields_in_Romania%2C_Ploie%C5%9Fti.ogv"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("ataism/video-frames").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://upload.wikimedia.org/wikipedia/commons/6/6a/Liberators_attacks_axis_oil_fields_in_Romania%2C_Ploie%C5%9Fti.ogv"] }

# Run the Actor and wait for it to finish
run = client.actor("ataism/video-frames").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://upload.wikimedia.org/wikipedia/commons/6/6a/Liberators_attacks_axis_oil_fields_in_Romania%2C_Ploie%C5%9Fti.ogv"
  ]
}' |
apify call ataism/video-frames --silent --output-dataset

```

## MCP server setup

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

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/Rnf6dLJ3QaZGwh7ic/builds/DSeG8Rk9XZ4fPul28/openapi.json
