# Video Frame Extractor - YouTube, TikTok & MP4 Keyframes for AI (`kantolabs/video-frame-extractor`) Actor

Extract frames from any video file or YouTube, TikTok and Instagram link: scene-change keyframes, frames at exact timestamps, thumbnails or one every N seconds, plus a contact sheet. Image links ready for AI vision. $2 per 1,000 frames.

- **URL**: https://apify.com/kantolabs/video-frame-extractor.md
- **Developed by:** [Kanto Labs](https://apify.com/kantolabs) (community)
- **Categories:** Videos, AI, Developer tools
- **Features:** Limited permissions
- **Stats:** 270 total users, 149 monthly users, 99.1% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.60 / 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, Scene Changes & Thumbnails from Any Video

**Extract frames from a video** file in one API call: the **keyframes** at real scene changes, a fixed number of
evenly spaced frames, one frame every N seconds, or the **best video thumbnail**. Every frame gets a **public image link and a
timestamp**, each video gets a **contact sheet** (a storyboard grid) and its metadata (duration,
resolution, fps, codecs).

Built for **AI pipelines**: vision models such as GPT-4o, Claude and Gemini read images, not video -
this actor gives them the right frames in one call. No ffmpeg install, no servers, no API key.
You pay **only per frame extracted** ($2 per 1,000 frames).

### What does Video Frame Extractor do?

Video Frame Extractor downloads each video file you link to, reads its metadata (duration, resolution, fps, codecs) and saves still frames as JPG, PNG or WEBP with a timestamp: the real scene changes, a fixed number of evenly spaced frames, or one frame every N seconds. Every video also gets a contact-sheet grid. You get one dataset row per video with a public link to every frame.

### Use cases

- **Video understanding with LLMs** - send 8-20 keyframes to a vision model to summarize, tag or moderate a video.
- **Thumbnails** - pick the best still for a video page, a course lesson or a product clip.
- **Storyboards and contact sheets** - review hours of footage at a glance.
- **Ad and creative research** - break your own or licensed ad videos into scenes to compare hooks and cuts.
- **Dataset building** - sample frames from recorded footage to train or evaluate computer-vision models.
- **Quality checks** - spot wrong intros or missing slates in a batch of exports (turn `skipBlankFrames` off to also see black frames).

### Who is it for?

- **AI engineers** sending video to vision models (GPT, Claude, Gemini) that read images, not video.
- **Video and content teams** picking thumbnails and making storyboards.
- **Ad and creative researchers** breaking their own or licensed ads into scenes.
- **ML and data teams** sampling frames from footage for datasets.
- **QA teams** checking a batch of video exports at a glance.

### Frame modes

| Mode | What you get | Good for |
|---|---|---|
| `auto` (default) | First frame + the strongest scene cuts, topped up with evenly spaced frames to reach `frameCount` | A representative storyboard of any video |
| `scenes` | Only real scene changes (ffmpeg scene score above the threshold) | Edited videos, trailers, ads |
| `count` | Exactly N evenly spaced frames | Consistent sampling for AI |
| `interval` | One frame every N seconds | Long recordings, lectures, CCTV-style footage |
| `timestamps` | One frame at each exact time you list (`90`, `1:30`, `00:01:30.500`) | Moments you already know: a timecode from a transcript, a highlight, a chapter start |
| `keyframes` | The video's real keyframes (I-frames chosen by the encoder), up to `maxFramesPerVideo` | Lossless-quality stills, shot starts in edited videos, dataset sampling |

Every mode also takes an optional **`startTime` / `endTime`** window (seconds or HH:MM:SS) to skip an intro or work on one part
of a long video. Video platform links (YouTube, TikTok, Instagram, Facebook, X) work in every mode - see below.

### Best thumbnails mode - auto-pick the best video thumbnail

Set `mode` to **`bestThumbnails`** and the actor picks the frames a person would choose as the **video thumbnail**,
instead of returning a storyboard. Per video it scores about 40 candidate frames (the encoder's keyframes - the
sharpest pictures in the file, placed at scene cuts - or evenly spaced frames for files with few keyframes) on:

- **Sharpness** (Laplacian variance; the face counts more than the background), **exposure**, **contrast** and **colourfulness**
- **Faces** (YuNet face detector): size, looking at the camera, **eyes open**, mouth not covered (a hand or object in front of it)
- **Composition** (rule of thirds / centred subject), with a small penalty for logos and end credits

Black, blank, blurry and duplicate frames are dropped, the best `thumbnailCount` frames (default 3, max 10) are
kept with a minimum time gap so they are not three copies of one shot, and each one is checked against the next
0.75 s of video (a blink or motion blur on the chosen frame is swapped for a better neighbour).

Each thumbnail comes with its `rank`, `thumbnailScore` (0-100), a `scoreBreakdown` and optional free **crops**
(`thumbnailCrops`: 16:9 for YouTube, 9:16 for Shorts / Reels / TikTok, 1:1, 4:5) centred on the faces or the
main subject. Thumbnails are full resolution up to `maxWidth` (1280 by default - the YouTube thumbnail size; 0 = original).

```json
{
    "videoUrls": ["https://your-bucket.s3.amazonaws.com/uploads/episode-12.mp4"],
    "mode": "bestThumbnails",
    "thumbnailCount": 3,
    "thumbnailCrops": ["16:9", "9:16", "1:1"]
}
```

Cost: each returned thumbnail is one frame at the normal frame price - 3 thumbnails = **$0.006 per video**, so
1,000 videos = $6. The ~40 candidates that were scored and dropped, the crops and the contact sheet are free.
The scoring is automatic and cannot judge everything a person does (an odd mid-word expression, on-screen text you
would rather avoid), which is why it returns several ranked options rather than one.

### How to use it

1. Click **Try for free**.
2. Paste direct links to your video files into **Video URLs**, one per line.
3. Pick a **Mode**: `auto`, `scenes`, `count` or `interval`.
4. Click **Start**, then open the frame links and the contact sheet from the results, or read them through the API.

### Input

| Field | What it does | Default |
|---|---|---|
| `videoUrls` | Direct links to video files (MP4, MOV, WEBM, MKV, AVI, TS...) or public YouTube / TikTok / Instagram / Facebook / X video links | 2 sample videos |
| `mode` | `auto`, `scenes`, `count`, `interval`, `timestamps`, `keyframes` or `bestThumbnails` | `auto` |
| `timestamps` | Times for `timestamps` mode: `90`, `90.5`, `1:30`, `00:01:30.500` | - |
| `startTime` / `endTime` | Only take frames inside this window (every mode) | whole video |
| `frameCount` | Frames per video for `auto` / `count` | `12` |
| `intervalSeconds` | Seconds between frames for `interval` | `10` |
| `sceneThreshold` | Scene-cut sensitivity (lower = more cuts) | `0.3` |
| `maxFramesPerVideo` | Cap for `scenes` / `interval` / `keyframes` | `50` |
| `skipBlankFrames` | Step past black / fade-to-black / single-colour frames (up to 2 s forward); blank frames are dropped and **not charged** | `true` |
| `imageFormat` | `jpg`, `png` or `webp` | `jpg` |
| `maxWidth` | Downscale wider frames (0 = original resolution) | `1280` |
| `contactSheet` | Also save a grid image of all frames (free) | `true` |
| `maxVideoSizeMb` | Skip larger files | `500` |
| `includeSummaryRow` | One `summary` row per video + one `frame` row per image (`recordType`) instead of one row per video | `false` |

```json
{
    "videoUrls": ["https://your-bucket.s3.amazonaws.com/uploads/demo.mp4"],
    "mode": "count",
    "frameCount": 8,
    "maxWidth": 1024
}
```

Exact moments of one part of a video:

```json
{
    "videoUrls": ["https://www.youtube.com/watch?v=jNQXAC9IVRw"],
    "mode": "timestamps",
    "timestamps": ["0:03", "8.5", "00:00:15.250"]
}
```

### Timestamps, keyframes and a start/end window

- **`timestamps`** returns one frame at each time you list, in time order, and adds `requestedTime` (the time as you typed it)
  to each frame. Times after the end of the video, or outside the window, are listed in `timestampsSkipped` with the reason and
  cost nothing. Unreadable values get one free notice row (`invalidTimestamps`) and the run goes on with the valid ones.
  Exact moments are kept even when the frame is black. Filling `timestamps` while `mode` is left on `auto` switches to this mode.
- **`keyframes`** returns the encoder's keyframes (I-frames) - every one of them up to `maxFramesPerVideo`, or an even spread
  over all of them when there are more. `keyframesInVideo` says how many there were. A black keyframe is dropped, free.
- **`startTime` / `endTime`** limit any mode, `bestThumbnails` included, to one part of the video; the row gets a `window` field.
  A start time after the end of the video gives a free error row; an invalid window stops the run before anything is charged.

### Video platform links (YouTube, TikTok, Instagram, Facebook, X)

Public single-video links work as well as files: YouTube videos and Shorts, TikTok videos (also `vm.tiktok.com` share links),
Instagram reels and posts, Facebook videos and reels, and X posts with a video. They are read **logged out** through Apify Proxy
(no cookies, no login, no captcha solving), at up to 1080p. The row gets `platform`, `sourceUrl`, `videoId`, `title`, `author`,
`authorHandle`, `caption`, `uploadDate`, `viewCount` and `likeCount`. Private, age-restricted, region-blocked, removed or
login-only videos, live streams, playlists, channels and image posts return a **free error row** with the reason. The price is
the same `frame-extracted` event as for files - nothing extra per video or per download. Only use frames you have the right to use.

#### How platform links are read (fast on long videos)

- **Only the size you asked for is fetched.** Frames are never wider than "Max frame width" (1280 px by default), so the actor
  picks the smallest copy of the video that is still at least that wide - for a 1080p YouTube video that is the 1280x720 copy.
  The saved frames have the same size as before; the row's `width` / `height` / `fileSizeBytes` describe the copy that was read.
  Set "Max frame width" to 0 for the largest copy (up to 1080p).
- **Long YouTube videos are not downloaded when only a few moments are needed.** In `timestamps`, `count` and `interval` mode, and
  in any mode with `startTime` / `endTime`, the frames are read straight from YouTube's server - only the seconds around each frame
  are transferred - when that is far less than the whole file. The frames are the same as from a full download. Example: 3
  timestamps from a 10-minute video read a few MB instead of the whole file.
- **`auto` and `scenes` on long YouTube videos find the cuts on YouTube's smallest copy (e.g. 256x144)**, read straight from the
  server, then fetch only the chosen frames at full size - a 10-minute video takes under a minute instead of several. The row
  then carries `sceneScanNote` saying so. On other platforms, a video too long to scan cheaply at its resolution gets evenly
  spaced frames instead of scene cuts (`sceneScanNote` explains; add a `startTime` / `endTime` window to get cuts from a part).
- `keyframes` and `bestThumbnails` (and short videos in every mode) download the file.

### Run it on TikTok / Instagram / Facebook Ads / YouTube scraper results

Already scraping posts or ads? Pick that run's dataset in **Scraper dataset** and the actor finds the video in every row and
extracts its frames in any mode - no glue step between the two actors.

```json
{ "datasetId": "<the scraper run's dataset ID>", "maxItems": 20, "mode": "count", "frameCount": 6 }
```

| Scraper | What is read |
|---|---|
| clockworks/tiktok-scraper, clockworks/free-tiktok-scraper | `mediaUrls` (videos the scraper saved to your storage) if present, else the TikTok link (`webVideoUrl`) |
| apify/instagram-scraper, instagram-reel-scraper, instagram-post-scraper | `downloadedVideo`, `videoUrl` or the video inside a carousel; the post link when the CDN link has expired |
| apify/facebook-ads-scraper | the ad's video file (`videoHdUrl`, then `videoSdUrl` if the HD link fails) |
| jmlp/meta-ad-library-scraper, automation-lab/facebook-ads-library (Meta Ad Library) | the ad's video file (`snapshot.videos` HD, then SD / `videoUrls`); ad ID, page, start date, active flag, ad copy, CTA and landing page go to flat columns |
| streamers/youtube-scraper | the video link (`url`) |

- Rows come back **in the dataset's order**, one per scraper row, with the source post's `inputIndex`, `upstreamId`,
  `upstreamPostUrl`, `upstreamAuthor`, `upstreamCaption`, date, views and likes as **flat columns** (view **Scraper posts**).
- Image posts, slideshows, image ads, private or removed videos and the scraper's own error rows get a **free** row with
  `skipReason` and a plain-English `error`.
- **Max videos from the dataset** (default 20) keeps a first try cheap; rows without a video do not count.
- **n8n / Make / Apify integrations:** when the scraper's run finishes, start this actor with
  `{"datasetId": "{{resource.defaultDatasetId}}"}` (Apify integration) or map the scraper run's `defaultDatasetId` (n8n, Make).
  Pass the dataset ID, not the run ID: Apify lets this actor read datasets you hand it, not your runs.

### Output

One dataset item **per video**. Real output from a test run on the Sintel trailer (the `frames`
array is trimmed to 2 of its 12 frames; `sceneScore` is set when the frame is a detected cut):

```json
{
    "videoUrl": "https://media.w3.org/2010/05/sintel/trailer.mp4",
    "fileName": "trailer.mp4",
    "fileSizeBytes": 4372373,
    "durationSeconds": 52.21,
    "width": 854,
    "height": 480,
    "fps": 24,
    "videoCodec": "h264",
    "audioCodec": "aac",
    "bitrateKbps": 669,
    "container": "mov",
    "mode": "auto",
    "scenesDetected": 6,
    "frameCount": 12,
    "frames": [
        {
            "index": 4,
            "timestampSeconds": 10.877,
            "timecode": "00:00:10.877",
            "sceneScore": null,
            "width": 854,
            "height": 480,
            "bytes": 5121,
            "key": "b670602f-trailer-004-000010877ms.jpg",
            "url": "https://api.apify.com/v2/key-value-stores/YuY6xATAnCD87Tv00/records/b670602f-trailer-004-000010877ms.jpg?signature=..."
        },
        {
            "index": 5,
            "timestampSeconds": 16.5,
            "timecode": "00:00:16.500",
            "sceneScore": 1,
            "width": 854,
            "height": 480,
            "bytes": 29030,
            "key": "b670602f-trailer-005-000016500ms.jpg",
            "url": "https://api.apify.com/v2/key-value-stores/YuY6xATAnCD87Tv00/records/b670602f-trailer-005-000016500ms.jpg?signature=..."
        }
    ],
    "blankFramesSkipped": 0,
    "contactSheetUrl": "https://api.apify.com/v2/key-value-stores/YuY6xATAnCD87Tv00/records/b670602f-trailer-contact-sheet.jpg?signature=...",
    "processingTimeMs": 4841,
    "error": null
}
```

The frame images and the contact sheet are stored in the run's key-value store; the `url` links
open without an Apify login, so you can pass them straight to an AI model or a spreadsheet.

#### One row per frame (optional)

Set `"includeSummaryRow": true` to get flat rows instead: per video one `summary` row (every video field above, plus `frameUrls`,
without the `frames` list), then one `frame` row per image (`index`, `timestampSeconds`, `timecode`, `url`, `width`, `height`,
`sceneScore`, plus `videoUrl`, `fileName` and - for platform links - `platform`, `videoId`, `title`). Failed videos, timeouts and
input notices are one free `notice` row each. Every row has `recordType` (`summary` / `frame` / `notice`), so an n8n, Make or
Zapier step can filter on it. Same frames, same price - only the row shape changes. Off by default.

### How much does it cost to extract frames from a video?

Pay per event: **$0.002 per frame** ($2 per 1,000 frames), plus Apify's tiny start fee of $0.00005 per run.
The contact sheet, the metadata, skipped blank frames and videos that fail to download or decode are free.
Apify Silver and Gold plan users pay less per frame ($0.0018 / $0.0016).

Worked example: **a 10-minute video at 1 frame every 10 s = 60 frames = $0.12.**

```json
{
    "videoUrls": ["https://your-bucket.s3.amazonaws.com/uploads/webinar.mp4"],
    "mode": "interval",
    "intervalSeconds": 10,
    "maxFramesPerVideo": 60
}
```

(`interval` mode stops at `maxFramesPerVideo`, which is 50 by default - raise it when you want every
interval frame of a longer video.)

More examples:

- 100 product videos x 8 frames each (`count` mode) = 800 frames = **$1.60**
- One 2-hour lecture, one frame every 60 s = 120 frames = **$0.24** (set `maxFramesPerVideo` to 120)
- 1,000 videos x 3 best thumbnails = 3,000 frames = **$6.00**
- The default demo run (`{}` input, 2 sample videos x 12 frames) = 24 frames = **$0.048**

Set **Maximum cost per run** and the actor never extracts more frames than that budget allows.

### Use it in n8n, Make, Zapier or any API

The actor is a plain HTTP API, so every automation tool can call it. The simplest call starts a run, waits
for it and returns the dataset rows in one request:

```bash
curl -X POST "https://api.apify.com/v2/acts/kantolabs~video-frame-extractor/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"videoUrls": ["https://media.w3.org/2010/05/sintel/trailer.mp4"], "mode": "count", "frameCount": 8}'
```

- **n8n**: add the official **Apify** node, operation **Run actor**, Actor `kantolabs/video-frame-extractor`,
  input `{"videoUrls": ["{{ $json.videoUrl }}"], "mode": "count", "frameCount": 8}`, wait for finish on.
  Then an Apify **Get dataset items** node with the run's `defaultDatasetId`, and a **Split Out** node on
  `frames` gives one n8n item per frame with `url`, `timestampSeconds` and `timecode`. That replaces the
  OpenCV Code node of the usual "describe a video with GPT-4o" templates, which n8n Cloud cannot run.
  On self-hosted n8n without the Apify node, an **HTTP Request** node (POST to the URL above) does the same.
- **Make**: the Apify app's **Run an Actor** module (Actor `kantolabs/video-frame-extractor`, "run synchronously"
  on), then **Get Dataset Items** and an **Iterator** over `frames`.
- **Zapier**: the Apify app's **Run Actor** action, then use the frame `url` fields in the next step.
- **Webhooks and schedules**: Apify can call your webhook when a run finishes, or run the actor on a schedule.

### Use with AI vision (GPT-4o, Claude, Gemini)

Each frame `url` is a public HTTPS link to a JPG (no Apify login needed), so you can hand it straight to a
vision model. 8-20 frames at the default 1280 px width are enough for most "what happens in this video"
questions; `count` mode gives an even spread, `auto` mode adds the real scene changes.

```python
from apify_client import ApifyClient
from openai import OpenAI

apify = ApifyClient("YOUR_APIFY_TOKEN")
run = apify.actor("kantolabs/video-frame-extractor").call(run_input={
    "videoUrls": ["https://media.w3.org/2010/05/sintel/trailer.mp4"],
    "mode": "auto",
    "frameCount": 10,
})
item = next(apify.dataset(run.default_dataset_id).iterate_items())

content = [{"type": "text", "text": "These are frames of one video in time order. Describe what happens."}]
for f in item["frames"]:
    content.append({"type": "text", "text": f"Frame at {f['timecode']}"})
    content.append({"type": "image_url", "image_url": {"url": f["url"]}})

reply = OpenAI().chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": content}])
print(reply.choices[0].message.content)
```

- **Claude**: send each frame as `{"type": "image", "source": {"type": "url", "url": frame_url}}` in the message content.
- **Gemini**: download each frame's bytes and pass them inline as `image/jpeg` parts (or use the contact sheet as one image).
- **Fewer tokens**: one `contactSheetUrl` image (a labelled grid of all frames) is often enough for a quick summary.
- **AI agents (MCP)**: connect the Apify MCP server (`mcp.apify.com`) to Claude, ChatGPT, Cursor or VS Code and the
  agent can find and call this actor itself whenever it needs to see what is inside a video file.

### Integrations and API

Run it from your own code with the official `apify-client` package (`pip install "apify-client>=3"`; Python shown, JavaScript works the same way):

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("kantolabs/video-frame-extractor").call(run_input={
    "videoUrls": [
        "https://media.w3.org/2010/05/sintel/trailer.mp4"
    ],
    "mode": "count",
    "frameCount": 8
})
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)
```

JavaScript: `new ApifyClient({ token }).actor('kantolabs/video-frame-extractor').call(input)` from the `apify-client` npm package.

### Limits

- Platform links: public, single videos only, read logged out (see above). LinkedIn, Vimeo, Twitch and Douyin pages are refused.
- A run that is about to reach its timeout stops cleanly: the videos it could not finish get a free row with `skipReason: "timeout"`.
- 500 MB per video by default, up to 4 GB. Up to 200 frames per video.
- `scenes` mode scans every frame, so long videos take longer; `count` and `interval` decode only the frames they need.
- No audio or transcription - images and metadata only.
- `count` never returns more frames than the video really has (a 1-second clip at 30 fps gives at most 30), so you never pay for duplicates.
- Google Drive / Dropbox / OneDrive *share* links open a web page, not the file: use their direct-download link (Dropbox: `?dl=1`).

### How do I extract frames from a video?

Paste a direct link to the video file into **Video URLs**, pick a mode and click **Start**. `auto` gives a
12-frame storyboard (scene changes + even spacing), `scenes` gives only the real cuts (the keyframes of an
edited video), `count` gives exactly N evenly spaced frames and `interval` gives one frame every N seconds.
Each frame comes back as an image link with its timestamp. From code, use the API examples above.

### How do I get a thumbnail from a video?

Use `bestThumbnails` mode. The actor scores about 40 candidate frames for sharpness, exposure, faces, open eyes
and composition and returns the best 3 (up to 10), ranked, with optional 16:9, 9:16, 1:1 and 4:5 crops.
3 thumbnails cost $0.006 per video. If you just need the first frame, use `count` mode with `frameCount: 1`
(it returns the middle of the video) or `scenes` mode with `maxFramesPerVideo: 1` (the first frame).

### How to convert a video to images?

Use `interval` mode for one image every N seconds (for example `intervalSeconds: 1` = one image per second,
up to `maxFramesPerVideo`, max 200 per video) or `count` mode for a fixed number. Pick `jpg`, `png` (lossless)
or `webp`, and set `maxWidth` to 0 to keep the original resolution. All images are saved in the run's
key-value store, so you can download them in one go from the Storage tab or the API.

### Which video formats work?

Anything ffmpeg can decode: MP4/H.264, H.265/HEVC, VP9/WEBM, AV1, MOV, MKV, AVI, MPEG-TS, M4V and more.

### How big can the video be?

500 MB by default, up to 4 GB with `maxVideoSizeMb`. Long videos in `scenes` mode take longer because every
frame is scanned for cuts; `count` and `interval` only decode the frames they need.

### Will I get (and pay for) black frames?

No, by default. Many videos fade in from black and scene cuts often pass through a black frame. With
`skipBlankFrames` on (the default), a black or single-colour frame is replaced by the first frame with content
in the next 2 seconds; if there is none, it is dropped and not charged (`blankFramesSkipped` counts them).
Title cards - text on black - are kept. Turn the option off if you need frames at the exact planned timestamps.

### Can I extract frames from YouTube or TikTok links?

Yes. Paste a public YouTube video or Short, TikTok, Instagram reel, Facebook video or X post link into **Video URLs** and
use any mode. The video is read logged out; private, age-restricted or blocked videos return a free error row.

### Something is not working?

Open an issue on the Issues tab - it is answered quickly.

### Related Actors by Kanto Labs

- [Video to LLM Context](https://apify.com/kantolabs/video-to-llm-context) - keyframes **plus the spoken transcript** per scene in one AI-ready text block for ChatGPT, Claude or Gemini. Use it when the audio matters too.
- [Video Scene Detection & Shot List](https://apify.com/kantolabs/video-scene-detection) - every cut, dissolve and fade with timecodes, a CSV/EDL shot list and optional clips per scene. Use it for editing and ad teardowns.
- [AI Image Upscaler](https://apify.com/kantolabs/ai-image-upscaler) - Real-ESRGAN 2x/4x to sharpen small frames or thumbnails.
- [Social Video Transcript](https://apify.com/kantolabs/social-video-transcript) - text, SRT and VTT with timestamps from public TikTok, Instagram Reels, YouTube, Facebook and X links or a scraper dataset. Use it when you need what is said, not the frames.
- [Video Ad Creative Breakdown](https://apify.com/kantolabs/video-creative-breakdown) - hook, shot list with keyframes, transcript, pacing and CTA for ad and short-form video files, plus a Markdown creative brief.
- [AI Image Caption & Alt Text Generator](https://apify.com/kantolabs/image-caption-generator) - captions, alt text, tags and a scene summary. Pick this actor's dataset there to caption every extracted frame with its timestamp.

# Actor input Schema

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

Direct links to video files (MP4, MOV, WEBM, MKV, M3U8...) from your storage, S3/R2/GCS, CDNs or Dropbox ?dl=1, OR public single-video links: YouTube videos and Shorts, TikTok, Instagram reels/posts, Facebook and X videos (read logged out). One per line. Private, age-restricted, removed or blocked videos return a free error row. Use this actor whenever you or an AI agent need to see what is inside a video as images.

## `datasetId` (type: `string`):

Pick a scraper run's dataset: frames are extracted from every video in it, in order, with the post's id, link, author and caption in flat upstream\* columns. Reads clockworks TikTok scrapers, apify Instagram scrapers (profile, reel, post), apify/facebook-ads-scraper, Meta Ad Library scrapers (jmlp/meta-ad-library-scraper, automation-lab/facebook-ads-library), streamers/youtube-scraper, or any dataset with a video link in videoUrl / url. Image posts and rows without a video are free rows. In an integration pass {{resource.defaultDatasetId}}.

## `maxItems` (type: `integer`):

Stop after this many videos from the scraper dataset (rows without a video do not count). Keeps a first try cheap.

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

Which frames. `auto` (default): first frame + strongest scene changes, topped up evenly. `scenes`: only real cuts (ads, trailers). `count`: N evenly spaced frames, e.g. 8-20 for a vision LLM. `interval`: one every N seconds (lectures, screen recordings). `timestamps`: exact moments you list. `keyframes`: the video's real I-frames (encoder keyframes). `bestThumbnails`: the best-looking few.

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

Used by `timestamps` mode: the exact moments to grab, one frame each, in seconds (90, 90.5) or HH:MM:SS(.ms) (00:01:30.500, 1:30). Use it when you already know the moments (a highlight, a timecode from a transcript). Filling this list with mode left on `auto` switches to `timestamps`. Times past the video end are listed in timestampsSkipped, free. Blank frames are kept (exact moments).

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

Only take frames from this point on, in every mode: seconds (90) or HH:MM:SS(.ms) (00:01:30). Use it to skip an intro or to work on one part of a long video. Empty = from the start.

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

Only take frames up to this point, in every mode: seconds or HH:MM:SS(.ms). Must be after the start time. Empty = to the end. A time past the end of the video means the end.

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

How many frames `auto` and `count` modes return per video (1-200). 8-20 is enough for an AI vision model to understand a video; 1 in `count` mode returns the middle frame. Each frame is charged once.

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

Used by `interval` mode: seconds between frames (1 = one image per second). Frames start at 0:00 and stop at the video end or at "Max frames per video". Example: a 10-minute video at 10 s = 60 frames (raise the max to 60).

## `thumbnailCount` (type: `integer`):

Used by `bestThumbnails` mode: how many of the best frames to return per video, best first. Each returned thumbnail is one charged frame; the candidates that were scored and dropped are free.

## `thumbnailCrops` (type: `array`):

Used by `bestThumbnails` mode: extra free crops of each thumbnail, centred on the faces (or the main subject). 16:9 = YouTube, 9:16 = Shorts/Reels/TikTok, 1:1 = square posts, 4:5 = Instagram portrait. A crop that matches the video's own shape is not repeated. Empty list = no crops.

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

Minimum ffmpeg scene score (0-1) that counts as a cut. Lower finds more, subtler changes.

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

Upper limit for `scenes`, `interval` and `keyframes` modes (1-200, default 50), so a long video cannot produce thousands of frames. Raise it when you want every interval frame or every keyframe; above the limit, keyframes are picked evenly across the video.

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

When a chosen frame is black (fade-to-black at a cut, a black intro) or one flat colour, step forward up to 2 seconds to the first frame with content; if there is none, drop it. Dropped frames are not charged. In `keyframes` mode a blank keyframe is dropped (free), never moved; `timestamps` mode always keeps the exact moments. Turn off to keep frames exactly at the planned times.

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

Format of the saved frames.

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

Frames wider than this are scaled down (aspect ratio kept). 0 keeps the original resolution. 1280 is plenty for AI vision models.

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

Compression quality for JPEG and WEBP frames.

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

Also save one grid image with all frames and their timecodes (free).

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

Thumbnails per row on the contact sheet.

## `includeSummaryRow` (type: `boolean`):

Off (default): one dataset row per video with a `frames` list. On: each video gives one `summary` row (all video fields, `frameUrls`, no frames list) followed by one `frame` row per image (`recordType`: summary / frame), and every failure or notice is one free `notice` row. Easier to map in n8n, Make, Zapier or a spreadsheet. Same frames, same price.

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

Larger videos are skipped (free).

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

Give up on a video that does not download within this time.

## Actor input object example

```json
{
  "videoUrls": [
    "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
    "https://media.w3.org/2010/05/sintel/trailer.mp4"
  ],
  "maxItems": 20,
  "mode": "auto",
  "frameCount": 12,
  "intervalSeconds": 10,
  "thumbnailCount": 3,
  "thumbnailCrops": [
    "16:9",
    "9:16",
    "1:1"
  ],
  "sceneThreshold": "0.3",
  "maxFramesPerVideo": 50,
  "skipBlankFrames": true,
  "imageFormat": "jpg",
  "maxWidth": 1280,
  "imageQuality": 85,
  "contactSheet": true,
  "contactSheetColumns": 4,
  "includeSummaryRow": false,
  "maxVideoSizeMb": 500,
  "downloadTimeoutSecs": 300
}
```

# Actor output Schema

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

No description

# 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://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
        "https://media.w3.org/2010/05/sintel/trailer.mp4"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("kantolabs/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://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
        "https://media.w3.org/2010/05/sintel/trailer.mp4",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("kantolabs/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://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
    "https://media.w3.org/2010/05/sintel/trailer.mp4"
  ]
}' |
apify call kantolabs/video-frame-extractor --silent --output-dataset

```

## MCP server setup

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