Video Frame Extractor: Thumbnails & Frames from Video URLs avatar

Video Frame Extractor: Thumbnails & Frames from Video URLs

Pricing

from $1.20 / 1,000 frames

Go to Apify Store
Video Frame Extractor: Thumbnails & Frames from Video URLs

Video Frame Extractor: Thumbnails & Frames from Video URLs

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.

Pricing

from $1.20 / 1,000 frames

Rating

0.0

(0)

Developer

Kaizoku Data

Kaizoku Data

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

13 days ago

Last modified

Categories

Security

Limited permissions

Share

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

FieldWhat 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:

{
"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:

{
"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):

{
"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:

{
"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):

{
"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

CodeMeaning
platformYouTube, 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.
blockedNot an http(s) URL, or the host is a private, loopback or link-local address (also after a redirect).
blocked-by-robots / robots-unreachablerobots.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-hostrobots.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-refusedThe 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-urlThe 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-failedThe 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-videoThe link returned a web page or a file that is not a video container (a playlist, audio file or other file type).
too-largeAbove Max video file size.
too-longThe measured length is above Max video length.
no-videoThe file has no video stream (audio only) or it is empty.
resolution-too-highAbove 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-unsupportedScene 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-heavyScene 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, timeoutThe video could not be read or decoded (damaged, unsupported codec, or too slow).
budgetThe run's Max total charge cannot pay for this video. Raise it.
unexpectedA bug; the video is free.

Pricing

EventPriceCharged
frame$0.0012One per frame saved. The contact sheet is free.
scene-minute$0.003Scene 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.004One 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".

ExampleFramesOther eventsTotal
The default sample (4.7 MB, 5 frames)5 x $0.0012 = $0.006none$0.006
20 thumbnails of a 60 MB clip$0.024none$0.024
10 frames of a 10-minute 1080p video (300 MB)$0.0123 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.03014 x $0.003 + 3 x $0.004 = $0.054$0.084
100 frames of a 20-minute 4K video (700 MB)$0.1206 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:

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):

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):

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.