Video Frame Extractor - Keyframes & Scenes, 100 Videos/Run
Pricing
from $2.00 / 1,000 frame extracteds
Video Frame Extractor - Keyframes & Scenes, 100 Videos/Run
Extract frames, keyframes, scene changes and thumbnails from up to 100 video URLs or a Dataset of videos. One row per frame with an image link, one status row per video, per-frame pricing.
Pricing
from $2.00 / 1,000 frame extracteds
Rating
0.0
(0)
Developer
Vadim Bezrukov
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
Turn up to 100 video URLs, or a Dataset of videos from another Actor, into frames, keyframes, scene changes and thumbnails. Every stored frame is one Dataset row with a public image link, and every video gets one status row, so the next step in n8n, Make or an agent reads one flat column instead of unpacking arrays.
Paste direct https:// links to video files you are allowed to process (your own storage, signed S3, R2 or GCS links, direct Drive download links), or point the run at the Dataset of a previous Actor. The run downloads each file, reads it with ffprobe and renders only the frames it needs, so a frame from minute 50 costs the same as a frame from second 5.
The prefilled input takes four evenly spaced frames from a 5 second CC0 flower video published by MDN (960x540). You should see one video row with status SUCCESS and four frame rows whose output_url opens a JPEG. Open the Frames view of the Dataset to see them as a table. Run again when the next batch of videos arrives: a schedule, a Drive or S3 trigger in n8n, or the Dataset of the Actor upstream of this one.
Price: $0.002 per stored frame plus $0.005 per processed video; the scenes mode adds $0.005 per started minute it scans. Failed videos and failed frames are free. Update history is in CHANGELOG.md.
Frames from a Dataset of videos
The usual chain: a Downloader, scraper or Bulk FFmpeg run leaves a Dataset whose rows hold video URLs. Point this Actor at it:
{"mode": "scenes","datasetId": "aaaaaaaaaaaaaaaaa","sourceField": "storage_url","idField": "id","maxFramesPerVideo": 20}
sourceField is the top-level column with the video URL: storage_url after Parallel Bulk File Downloader, output_url after Bulk FFmpeg Media Processor (proxy operation), url after most scrapers. The run reads up to maxItems rows (default 100) starting at datasetOffset (default 0). When the Dataset has more rows, RUN_SUMMARY reports nextDatasetOffset and nextAction names the offset for the next run, so a 300-row Dataset is three runs with offsets 0, 100 and 200. source_index is the row's position in the Dataset, so generated ids stay stable across those runs. A row with an empty URL becomes INVALID_INPUT and the other rows continue. idField may hold text or a number; other characters become hyphens.
In an Actor-to-Actor integration or webhook, set datasetId to {{resource.defaultDatasetId}}. The platform substitutes the id before the run starts, and under limited permissions this Actor can read only the Dataset named in that top-level field. A raw payload.resource.defaultDatasetId is not read and ends FAILED with an INVALID_INPUT summary that says so. This mode reads a Dataset in your Apify account, never another customer's storage.
Modes
| Mode | What you get | Settings |
|---|---|---|
count | frameCount evenly spaced frames, each in the middle of an equal slice of the video | frameCount 1-200, default 8 |
interval | one frame at 0 s, then every intervalSeconds, up to maxFramesPerVideo | intervalSeconds 1-3600, default 10 |
timestamps | frames at the seconds you list per file | options.timestamps on each item, 1-200 values |
scenes | one frame at each detected scene change, the strongest maxFramesPerVideo cuts in time order, with scene_score | sceneThreshold 0.1-0.9, default 0.4 |
keyframes | frames at the video stream's keyframes, evenly subsampled to maxFramesPerVideo | none |
Settings for every video: imageFormat (jpeg default, or webp), maxWidth (64-4096, default 1280; frames are scaled down, never up, and the height follows the aspect ratio), contactSheet (one free JPEG that tiles the video's frames, five columns of 320 px thumbnails) and maxFramesPerVideo (1-200, default 50; caps interval, scenes and keyframes).
One mode per run. A file's own options override the run's settings for that file, for example {"frameCount": 3} or {"imageFormat": "webp"}. An unknown option name rejects only that file with INVALID_INPUT (INVALID_OPTION); a bad run-level value is rejected before the run starts.
timestamps mode needs items, because a Dataset row has no per-file timestamps:
{"mode": "timestamps","items": [{"id": "intro", "source": "https://example.com/intro.mp4", "options": {"timestamps": [1.5, 12, 30]}},{"id": "demo", "source": "https://example.com/demo.mp4", "options": {"timestamps": [5]}}]}
A timestamp past the end of the video rejects that file before any frame is rendered. A timestamp at the very end returns the last frame. In scenes mode a video with no cut above the threshold, such as a static shot, still gets one frame at 10% of its duration and note NO_SCENE_CUTS, so a quiet video is a SUCCESS with one frame, never an empty success. keyframes lists keyframe times without decoding the other frames, so it is the fastest mode for long videos.
Batch example
{"mode": "count","frameCount": 12,"maxWidth": 960,"contactSheet": true,"items": [{"id": "clip-a", "source": "https://example.com/a.mp4"},{"id": "clip-b", "source": "https://example.com/b.mp4"}]}
id is optional. Without it, rows are named item-0000, item-0001 and so on by position, so pass a stable id when you compare runs. Send items or a dataset, not both. Adding a datasetId in the Console while the prefilled flower sample is still in items uses the dataset; edited items next to a datasetId are rejected.
Output rows and views
Two row kinds share the default Dataset, told apart by kind. Field names are snake_case and schema_version is 1; fields are only ever added. The values below are illustrative.
A video row, exactly one per input video:
{"kind": "video","source": "video-frame-extractor","source_id": "clip-a","source_url": "https://example.com/a.mp4","source_index": 0,"id": "clip-a","status": "SUCCESS","mode": "count","input": {"bytes": 1200000, "duration_seconds": 9.98, "container": "mov", "width": 1920, "height": 1080, "fps": 29.97, "video_codec": "h264"},"frames_requested": 4,"frames_stored": 4,"first_frame_url": "https://api.apify.com/v2/key-value-stores/STORE/records/artifacts-clip-a-0000-frame-0000.jpg","contact_sheet_url": null,"error_code": null,"note": null,"billed_frames": 4,"billed_scan_minutes": 0,"billed_videos": 1,"scraped_at": "2026-10-20T12:00:00Z"}
A frame row, one per stored frame:
{"kind": "frame","source_id": "clip-a#0002","id": "clip-a","video_id": "clip-a","frame_index": 2,"timestamp_seconds": 6.237,"mode": "count","scene_score": null,"output_url": "https://api.apify.com/v2/key-value-stores/STORE/records/artifacts-clip-a-0000-frame-0002.jpg","width": 1280,"height": 720}
Dataset views:
- Frames: frame rows only, with
video_id,frame_index,timestamp_seconds,output_urlas a link,width,heightandscene_score. This is the view to send to a vision model or a sheet. - Videos: one status row per video, with the contact sheet link.
- Failed: video rows whose
statusis notSUCCESS.
Frames are stored in the run's Key-Value Store as artifacts-<id>-<index>-frame-<nnnn>.jpg (or .webp) and the contact sheet as artifacts-<id>-<index>-sheet.jpg; record keys cannot contain slashes. When your account restricts storage access, the links carry a signature query parameter, so output_url still opens without an Apify token. Anyone with a link can open that image, so share the links as you would share the frames. fingerprint hashes the meaningful fields of a row and skips the observation time, the processing time and the download hash, so comparing two runs is a hash check. input.sha256 is the hash of the downloaded file, so the same file behind a new signed URL can be recognized.
RUN_SUMMARY in the Key-Value Store holds the overall status (SUCCESS, PARTIAL, ALL_FAILED, NO_RESULTS or FAILED), counts per video status, frames requested and stored, charged event counts, the FFmpeg version line, Dataset paging, retryIds and nextAction. Read it first. BILLING_RECEIPT is the charge ledger of the run.
Pricing
| Event | When it is charged | Price |
|---|---|---|
apify-actor-start | Actor start, once per GB of memory (minimum one) | $0.00005 |
frame-extracted | one frame stored and validated as a JPEG or WebP image | $0.002 |
video-processed | one video that ended SUCCESS or PARTIAL (download, probe, contact sheet) | $0.005 |
scene-minute-scanned | one started minute of a video decoded by the scene scan, scenes mode only | $0.005 |
Examples: the prefill (one video, four frames) is about $0.013. 20 videos with 12 frames each in count mode are about $0.58. 50 five-minute videos with 20 scene frames each are about $3.50.
The frames are stored before anything is charged. A failed video, a rejected URL, a frame that fails validation, a retry, a video stopped by the run timeout and the contact sheet are never charged. Before rendering, the run reserves the video's whole price inside maxTotalChargeUsd; when the limit cannot cover a video, that video is BUDGET_EXCEEDED, uncharged, its frames are not kept, and videos that already succeeded stay in the Dataset.
These are the event charges. This Actor does not add a separate platform-usage line on top of them.
Limits
- 1-100 videos per run (
maxItems); concurrency 1-4 videos, and each video renders at most two frames at a time - 1-200 frames per video
- 256 MiB per file by default, 1 GiB maximum (
maxInputBytesPerItem); 2 GiB downloaded per run by default (maxTotalInputBytes) - videos longer than
maxDurationSeconds(default 1 hour, maximum 2 hours) areTOO_LARGE, uncharged - download ports 80 and 443; direct files only
- memory 1024 MB by default, 2048 MB maximum; run timeout 1 hour by default
- the
scenesscan decodes the whole video: at 1024 MB it took 25-50 seconds per video minute in our tests, and it may run for 1.5 times the video's length (orprocessingTimeoutSeconds, if longer). For videos over 15 minutes, or many videos per run, use 2048 MB or a longer run timeout, or usekeyframesmode - no ZIP, S3 or Drive upload of the frames; use the
output_urllinks or Parallel Bulk File Downloader
Near the run timeout, downloads and ffmpeg get only the time that is left, and videos that cannot start in time come back as TIMEOUT with error_code RUN_DEADLINE, uncharged, so the run still finishes with its summary. Send those ids again with a longer timeout.
API, n8n and Make
Start the Actor with the Apify run API and one of the JSON inputs above. In n8n use the Apify node, or an HTTP Request node that POSTs to https://api.apify.com/v2/acts/automa-flow~video-frame-extractor/run-sync-get-dataset-items?view=frames with your Apify token in the Authorization header: it waits for the run and returns the frame rows. Then send each output_url to the next step, for example a vision model that writes one description per scene into a sheet. In Make, use the Apify app's "Run an Actor" module with "wait for finish", then "Get Dataset Items" with view frames.
A daily schedule over an upstream Dataset is the usual second run: create a schedule in the Apify Console with the Dataset input above. When the upstream Actor's run finishes, an integration can start this Actor with datasetId set to {{resource.defaultDatasetId}}. Do not schedule the prefilled flower sample; it is only the first-run check.
Retry rule: send the ids in RUN_SUMMARY.retryIds (videos whose status is not SUCCESS) in a new run. Successful videos are not in that list, so their frames are not paid for twice. A resent PARTIAL video is processed and charged again in full. A restarted run (for example after a platform migration) republishes finished rows from its receipt without charging them again.
Ask an agent: "Extract 8 frames from each of these direct video URLs and give me the image links." The direct MCP endpoint is https://mcp.apify.com?tools=automa-flow/video-frame-extractor. An agent should call it for direct video file URLs or a Dataset id, never for YouTube, TikTok or other page URLs, and bound the call with maxItems, maxFramesPerVideo and maxTotalChargeUsd. Execution uses the caller's Apify account. Read RUN_SUMMARY first; on PARTIAL or ALL_FAILED, inspect error_code on the video rows before retrying only retryIds.
Failure semantics
SUCCESS means every planned frame was rendered, starts with JPEG or WebP bytes, was read back by ffprobe with a width and height, and was stored. PARTIAL means at least one frame was stored and error names the first failed frame; only stored frames are charged. Any other status has no frame rows, frames_stored 0 and an error.
| Status | Meaning |
|---|---|
SUCCESS | Every planned frame stored |
PARTIAL | Some frames stored, the first failure in error |
INVALID_INPUT | Bad or duplicate id, unknown option, timestamp past the end, blocked URL shape |
SOURCE_NOT_FOUND | HTTP 404 or 410, or a missing kvs:// record |
SOURCE_BLOCKED | HTTP 401/403, or a redirect to a blocked address |
SOURCE_FAILED | Other HTTP errors, DNS failure, network error, an HTML page instead of a video |
TOO_LARGE | Byte cap or duration cap |
UNSUPPORTED_MEDIA | Not a readable video, no video stream (NO_VIDEO_STREAM), or no duration for count/interval (DURATION_REQUIRED; use timestamps) |
PROCESSING_FAILED | ffmpeg failed or no frame passed validation |
TIMEOUT | Download or ffmpeg exceeded its timeout, or the run timeout came first (RUN_DEADLINE) |
BUDGET_EXCEEDED | maxTotalChargeUsd cannot cover the video |
An unreachable host is SOURCE_FAILED; a file that was fetched but holds no video is UNSUPPORTED_MEDIA. Those are different outcomes. The run finishes successfully when per-video failures are the only problem, even when every video fails on its own host (ALL_FAILED). It fails the whole run when FFmpeg is missing or built with --enable-nonfree, when the Dataset or Key-Value Store cannot be written, when a charge returns an uncertain result, or, after delivering every row, when the run timeout stopped every video (globalFailure RUN_DEADLINE, nothing charged).
If a run fails or looks wrong, open an Issue and include the run URL. Apify shares that run with us automatically, so there is no need to paste your input.
Security and privacy
Video URLs are downloaded with a normal HTTP client and only then passed to ffmpeg as local files. ffmpeg never receives the URL, a shell string or a filter graph from the input. The client allows http and https on ports 80 and 443, rejects embedded passwords, localhost, link-local and metadata names, private and reserved IP ranges, IPv6 loopback and unique-local addresses, and checks DNS before the request and on every redirect. The body is streamed and cut at the byte cap. The client sends no cookies and no Authorization header. Query values are redacted in logs, and frames, timestamps and URLs are not logged, only counts.
Frames can show people. This Actor does no recognition and sends nothing to an external service; frames stay in your run's Key-Value Store. You need the rights or permission to process the files you submit. This Actor does not download videos from YouTube, TikTok, Instagram, Facebook, X or Vimeo pages and does not run yt-dlp.
Licensing
The container installs Debian's unmodified ffmpeg package and refuses a build that contains --enable-nonfree. Debian's FFmpeg is GPL-2 or later because optional GPL components are enabled. The image keeps Debian's copyright file and the installed package version; the source for that binary is the corresponding Debian source package. Frames use the native JPEG encoder and libwebp (BSD-licensed) when present. Details are in LICENSING.md.
FFmpeg is a trademark of the FFmpeg project. This Actor is not affiliated with, endorsed by or sponsored by FFmpeg; the name describes compatibility.
Technical details
Python 3.12 on the Apify Python image. Every mode becomes a list of timestamps first; each frame is then one short ffmpeg run that seeks to its instant, so the cost per frame does not grow with the video's length. The scene scan decodes once at 320 px width. Downloads use httpx with a pinned DNS backend and one retry on network errors and retryable statuses. ffprobe and ffmpeg run as argument arrays with a timeout and a kill on expiry, and temporary files are removed after each video. There is no browser, no proxy product and no external database.